【手撸低代码工具】二次封装UI库(六)反思与重构(表单)

472 阅读6分钟

对UI库进行封装,其实很早就开始了,先是使用JS的方式,后来学会了一点TS,于是一点一点把 anyScript 换成 typescript,最近 Vue3.3 的更新,支持了从外部引入 Interface,于是又开始重构组件 props 的创建方式。

当然,如果是一般的项目,基本是没有这个时间去改的,能跑就行嘛,为啥要改?所以,这不是一般的项目,只是一个尝试。

因为有时间,所以看以前写的代码不顺眼,那么就会去重构。比如这次就发现,组件内部传递信息,也必须使用 props 吗?使用状态行不行?

使用状态,可以可以汇拢散乱的数据,轻松支持复杂的子组件的结构,不用去设计各种 props,都放入状态即可,那么要不要改?

使用状态代替 props 的传递。

首先声明一点,封装的组件内部与外部的使用者之间,最好还是通过 props,因为各种UI库都是这么做的。如果这也使用状态,那么UI库就和状态发生一种强行绑定,这个不是很友好,不建议使用。

那么 表单控件内部为啥要用 状态呢?因为 el-form-item 又这封装了一个小组件,而且还使用的比较频繁,而它的props 又比较复杂,所以适合使用状态。

设计一种状态

  • 状态的范围:那必须是局部状态,不可能是全局状态,否则就乱套了。
  • 包含需要的各种数据
  • 内部处理好联动

其实,这个状态从某种角度来看,代替了部分 controller 的职责。

我们整理一下表单控件需要的各种数据。

来至于配置信息的数据

因为json文件的结构问题,以及维护 json 的便捷性问题,所以结构有些散乱:

  • formMeta:表单控件的 meta
    • columnsNumber: number 表单列数
    • colOrder: Array<number> 字段ID集合,排序依据
    • linkageMeta: 联动筛选的配置信息
    • ruleMeta: 验证规则的信息
    • subMeta: 分栏信息
  • childPropsList input 的 props 和 meta
    • meta: colName、controlType 等
    • props: UI库的组件需要的属性

运行时创建的数据

根据配置信息,创建或者调整的数据:

  • formColSpan: 多列用的
  • showCol: 联动筛选用的
  • childMetaList: 字段纯 meta 的集合
  • childPropsList: 字段纯 props 的集合

来至于 props 的数据

  • model:表单的 model,通过 props 传入
  • partModel 通过props 传入,同时也是依据 联动筛选 创建的

使用 nf-state 做一个状态

为啥不用 pinia?因为他是全局状态,虽然可以使用 id 来区分,但是万一重名了咋办?

截止到 Pinia V2.1.3 ,Pinia 的 id 不支持 Symbol,因为内部会判断 ID 是不是 __hot: 开头:

 function devtoolsPlugin({ app, store, options }) {
      // HMR module
      if (store.$id.startsWith('__hot:')) {
          return;
      }
      略
  }

使用 Symbol 可以方便的避免重名,但是可惜不支持。

所以,自己做一套状态好了,又不难。当然我们也可以直接使用 reactive + provide/inject 来实现。

先定义一个 Interface

发现了,还得先定义 Interface:

/**
 * 表单控件内部需要的状态
 */
export interface IFormState<T> {
  // props 传入的表单 model
  model: T,
  // 筛选后的 model
  partModel?: IAnyObject,
  // 来自配置 formMeta,确定表单的列数
  columnsNumber: () => number
  // 来自配置的 formMeta
  formMeta: IFromMeta,
  // 来自 配置 的 验证规则
  ruleMeta: IRuleMeta,
  // 来自配置的 排序字段ID
  colOrder: Array<string | number>,
  // 来自配置的 联动筛选的设置
  linkageMeta: ILinkageMeta,
  // input 的 props 的集合,不含 meta
  childPropsList: IFormChildPropsList,
  // input  的 meta 集合
  childMetaList: IFormChildMetaList,
  // 根据 columnsNumber 和 input 的 meta 计算出来一个字段占多少 span
  formColSpan: FormColSpan,
  // 联动筛选时,字段是否可见
  showCol: ShowCol
}

这样就可以把表单控件需要的各种数据都集合在一起,需要使用的时候,拿出来即可。

定义状态。

// 创建一个局部表单内部状态
export function regFormState<T extends IAnyObject>(props: IFromProps<T>) {

  const state = defineStore<IFormState<T>>(key, () => {
 
    const { 
      formMeta,
      model,
      partModel
    } = props

    // 多列用的
    const formColSpan = reactive<FormColSpan>({})
    // 设置字段的是否可见
    const showCol = reactive<ShowCol>({})

    // 字段的纯 meta
    const childMetaList = {} as IAnyObject
    // 字段的纯 props
    const childPropsList = {} as IAnyObject
    // 转换一下
    Object.keys(props.childPropsList).forEach((key) => {
      childMetaList[key] = props.childPropsList[key].meta
      childPropsList[key] = props.childPropsList[key].props
    })
    // 获取表单的列数
    const columnsNumber = () => {
      return formMeta.columnsNumber
    }

    return {
      // 表单的 model
      model,
      partModel,
      // 配置
      columnsNumber,
      formMeta: formMeta, // 表单的meta
      ruleMeta: formMeta.ruleMeta, // 验证规则
      colOrder: formMeta.colOrder, // 字段排序
      linkageMeta: formMeta.linkageMeta, // 联动筛选
      // 创建
      childPropsList, // input 的 props
      childMetaList, // 字段的纯 meta
      formColSpan, // 多列用的
      showCol   // 设置字段的是否可见
    }
  })

  return state
}

/**
 * 获取局部状态,表单的内部状态
 * @returns 
 */
export function getFormState<T>() {
  const re = useStoreLocal<IFormState<T>>(key)
  return re
}

看起来有点像 Pinia,不过有两个区别:

  • 一个是局部状态,
  • 一个是可以使用 Symbol 做状态的标识,这样可以不用担心重名的问题了。

这里只是把数据集中在一起,并没有把操作方法也集中起来,还是放在了 controller 里面。
感觉放在哪里都差不多。

columnsNumber 使用了函数的形式,是想在配置信息发生变化时,可以获取最新的数据,当然也可以使用 computed ,或者直接使用 formMeta.columnsNumber

根据需求,这里做一个函数即可。

controller 的变化

/**
 * 表单控件的管理类,创建内部局部状态
 * @param props 表单控件的 props
 * @returns 
 */
export default function formController<T extends IAnyObject> (
  props: IFromProps<T>
) {

  // 注册一个状态,统一管理各种数据
  const state = regFormState(props)
  // IFormItemList<T>, 表单子组件的 meta
  const { childMetaList, formMeta } = state

  // 调用 useColSpan 设置一个字段占用多少 span, 返回刷新函数
  const { setFormColSpan } = useColSpan(state)
  
  // 调用 useLinkage 监听联动关系,返回刷新函数
  const { setFormColShow } = useLinkage(state)
  
  // 设置各种 watch 略

  return {
    state 
  }
}

其实变化并不太大,因为没有把 watch 放在状态内部。还没想出来更适合的方式。

先创建一个状态,然后调用函数设置 formColSpan 和 showCol,然后各种监听,当发生变化的时候,重新设置各种需要的数据。

使用状态

首先使用状态的是 base-item,之前通过 props传递数据,父组件要组织数据,子组件要一个一个获取,过程中又被 props 包装了一层。

子组件获取状态

现在方便多了,父组件不用想到底需要设置哪些 props,子组件自己从状态里获取即可。

<script setup lang="ts" generic="T extends object">
  import type { IFormItemMeta } from '../map'
  // 获取状态
  import { getFormState } from '../map'
  // 引入表单子控件
  import { formItemKey } from '../form-item/_map-form-item'
  // 定义 props  
  const props = defineProps<IFormItemMeta>()
  
  // 获取表单内部的状态
  const state = getFormState<T>()

  const {
    model,
    ruleMeta,
    formColSpan,
    showCol,
    childPropsList, // input 的 props
    childMetaList, // 字段的纯 meta
  } = state
  
</script>

这样方便父组件使用。

父组件的使用

<base-item :colOrder="formMeta.colOrder"></base-item>

父组件只需要设置 colOrder 即可。这是关于显示哪些字段的数组,因为表单控件需要分栏显示,那么一个栏目里有哪些字段,这个是动态数据,由分栏组件确定,不方便写入状态。

小结

折腾了好几天,效果似乎并不太理想,不过没关系,后续想出来更好的方式可以继续重构。

表单控件,基本就是这样了,后续是列表控件、查询控件等。