(@usaform/element-plus 1.0 发布) 我打造的一款,平民化的、高性能、高灵活的表单

(@usaform/element-plus 1.0 发布) 我打造的一款,平民化的、高性能、高灵活的表单

介绍

@usaform/element-plus 是一款跨 vue 应用的表单逻辑库,本身只负责逻辑粘合,实际内容完全由户决定,结合组件库使用可以大大提高写表单的效率,因为内部逻辑会更偏向 element-plus 所以起了个同名,只要是基于 vue 的都可以使用

判断是否需要可以看看您是否有以下的诉求

  1. 表单深度嵌套

    现有的组件库能做,可是用起来觉得体验很差

    主要提供便利的写法,和简单的管理方式

  2. 动态表单

    比如,对表单项动态的增删改查

    对动态表单的支持体现在多个维度上

    • 性能取舍。性能好写起来繁琐一点点,正常性能下写起来简单
    • 扩展能力。表单可以简单的拆成 3 个部分,布局layout/表单项input/控制器。控制器是管理数据的这由框架提供;布局提供一个默认的,允许完全自定义;表单项可以使用组件库的组件填充,也可以完全自定义
    • 跨字段操作。比如在一个字段中可以直接操作其他任意层级字段的内容
  3. 元框架

    表单是前端应用中的高频元素,提供再多的预处理都是有限的,但某些东西是能够进行统一抽象的,该框架提供了 2 个维度的二次封装能力(可以用来搞搞 kpi)

    • 基于逻辑组件,这里用到框架本身的组件,自己添一点更加开箱即用的功能给团队使用,比如业务表单组件,ProForm,高阶布局,等
    • 基于表单逻辑控制 hook,这是更加底层的一组精简的表单 hook@usaform/element-plus 就是基于它做的上层组件封装,使用它可以自定义表单的组件逻辑控制能力

简单概括,能提供的优点

  1. 复杂表单中,性能 / 便利二选一(混用的性能我不知道怎么界定,应该大岔不岔吧)
  2. 相对简单写法 (同类产品中相对简单,仍有一定上手难度,因为文档字多)
  3. 高度灵活
  4. 相对较小的体积(目前用的 tsc 打包,文件都没压缩,类型文件没压缩,很多中文的文档内联进了发布的包里,使得看起来会非常的大。项目中用时打包工具会自动进行 tree shaking 和压缩。用 tsc 只是表面数据不好看,实际没什么影响 )

判断是否不需要,不要为了用而用!

  1. 认为组件库提供的已经够用的(对团队有学习成本)
  2. 单层次的简单表单(给自己找罪受)
  3. 结构完全静态的表单(基于组件库二次封装下基本是够用的,强行用,收益是未知的,请谨慎判断)

本文会带大家由浅到难做几个表单,来感受一下,各种概念和高级用法不做展开,深入学习请参考 npm 上的破烂文档(尽力写了)和 github 仓库里的 examples

npm 地址

创建应用

这里请使用 vite 创建一个 vue 的应用

下载依赖

pnpm add @usaform/element-plus element-plus @vitejs/plugin-vue-jsx sass

配置 vite

import vue from "@vitejs/plugin-vue"
import jsx from "@vitejs/plugin-vue-jsx"
export default defineConfig({
  plugins: [vue(), jsx()]
})

引入样式文件

import "element-plus/dist/index.css"
//如果要使用内部的 FormItem 组件才需要引入,不用就没必要了
import "@usaform/element-plus/style.scss"

使用插槽写法,创建一个简单的嵌套表单

<script setup>
import { Form, FormItem, PlainField, ObjectField } from "@usaform/element-plus"
import { ElInput, ElSelect, ElDivider } from "element-plus"
import {ref} from "vue"
  
const form = ref()

const reset = () => form.value.reset()
const submit = () => {
  console.log(form.value.getFormData())
}
const validate = async () => {
  console.log(await form.value.validate())
}
</script>
<template>
<Form ref="form">
  
  <!-- 这是 element-plus 的分割线组件 -->
	<ElDivider content-position="center">(布局样式) 基本表单元素</ElDivider>  
  
  <PlainField name="input" :layout="FormItem" :layout-props="{ label: '名称' }">
    <template #default="{ bind }">
      <ElInput v-bind="bind" placeholder="请输入名称" />
		</template>
	</PlainField>

  <PlainField name="select" :layout="FormItem" :layout-props="{ label: '下拉' }">
    <template #default="{ bind }">
      <ElSelect v-bind="bind" placeholder="请选择">
      	<ElOption label="1" value="1" />
      	<ElOption label="2" value="2" />
      	<ElOption label="3" value="3" />
       </ElSelect>
    </template>
  </PlainField>

	<ObjectField name="group">
    <PlainField name="input" :layout="FormItem" :layout-props="{ label: '名称' }">
      <template #default="{ bind }">
        <ElInput v-bind="bind" placeholder="请输入名称" />
      </template>
    </PlainField>
	</ObjectField>

</Form>
</template>

解释下这个组件中我们干了哪些事情

在模版中

  • 我们使用 <Form /> 组件创建了一个表单上下文

  • 我们可以在表单中随便写一些自定义的布局代码就会原封不动的展示,ElDivider 分割线是可以正常显示的

  • 既然是表单,我们得有表单项才行,这里我们用到了 2 个字段组件,而字段组件用于添加表单项

    <Form/> 可以看做是创建了一个对象 const obj = {},字段组件就是在给这个对象赋值 obj.你绑定的name = 填充组件的value

    PlainField 这是用于创建表单数据的组件,比如 input/select 这种生产数据的就是数据组件,无法进一步的嵌套

    ObjectField 分组组件,就是用来做嵌套用的,本身不产生什么逻辑和样式

  • 布局属性

    每个字段组件都有 layout 属于用于属性,用于告诉框架是否需要在填充组件(插槽里的内容)外边给套一层用来布局,目的在于数据和布局的分离,layout-props 就是外部传递给其内部的参数

  • 填充组件

    填充物可以完全自定义也可以是组件库的组件,默认插槽中的 bind 是一组用来参数对象,它来自于字段组件和布局组件,使用 v-bind 一键绑定即可,保险起见可以放在所有属性的最后边,防止和传递的发生参数冲突

    内部的字段组件主要提供 v-model 的参数,用于把更改的数据同步进框架内部

    内部布局组件的参数主要提供 id/size/disabled 这种属性

内部提供的字段组件共有 3 个,1 个造数据,2 个做嵌套(对象嵌套、数组嵌套),参数中只有 name 是必传的

script

  • 通过 ref 拿到 Form 组件的实例,这是一个包含了对表单进行增删改查等方法的对象
  • reset 会清空所有数据
  • getFormData 会在不触发校验下获取所有表单数据
  • validate 对所有数据字段进行校验(PlainField),校验能力支持有 FormItem 提供,支持 blur/change,规则同 element-plus

指定 key 高性能写法

vue 的响应式系统是细颗粒度的精确更新,可是一旦我们在组件内嵌套插槽时,当子组件发生数据发生更新,至少至少会使得父组件一并更新

而解决它我们需要把插槽用组件代替,那么写法会变成

<!-- 之前 -->
<PlainField name="input" :layout="FormItem" :layout-props="{ label: '名称' }">
  <template #default="{ bind }">
    <ElInput v-bind="bind" placeholder="请输入名称" />
  </template>
</PlainField>

<!-- 现在 -->
<PlainField 
   name="input" 
   layout="FormItem"  
   :layout-props="{ label: '名称' }" 
	 element="ElInput"
   :props="{ placeholder:'请输入名称' }"
/>

模版中,我们需要使用一个 key 来告诉框架用什么东西来填充,布局如此,填充组件也如此

element 指定填充组件,props 是传递给填充组件的参数

其他所有字段组件同理,如果指定 key 就意味着我们要把所有数据组件都外置出去,它们可以来自组件库,或者自定义的组件

这样做性能虽然上去了,但会封装许多小文件使得开发起来繁琐些

既然指定了 key,我们就得配置,每个 key 用什么组件,所以完整代码如下

<script setup>
import { Form, FormItem, PlainField, ObjectField } from "@usaform/element-plus"
import { ElInput, ElSelect, ElDivider } from "element-plus"
import {ref} from "vue"

 //注册每个 key 对应哪个组件
const config = {
  Elements: { ElInput, FormItem }
}
</script>
<template>
<Form :config="config">
    <!-- 之前 -->
  <PlainField name="input" :layout="FormItem" :layout-props="{ label: '名称' }">
    <template #default="{ bind }">
      <ElInput v-bind="bind" placeholder="请输入名称" />
    </template>
  </PlainField>

  <!-- 现在 -->
  <PlainField 
     name="input" 
     layout="FormItem"  
     :layout-props="{ label: '名称' }" 
     element="ElInput"
     :props="{ placeholder:'请输入名称' }"
  />
</Form>
</template>

自定义数据组件

因为插槽会至少导致父组件更新,我们想办法干掉了插槽,那么像 ElSelect 这种必须有插槽的怎么写呢?

组件库的组件不见得就是我们需要的,不满足怎么办?

自定义的写法很简单,我们以 ElSelect 的封装为例,随便创建个组件文件,我这里叫 Select

<script lang="ts" setup>
import { ElOption, ElSelect } from "element-plus"
const props = defineProps<{options: any[], actions: any}>()
const value = defineModel<string>()
</script>

<template>
  <ElSelect v-model="value">
    <ElOption v-for="v in props.options" :label="v.label" value="v.value" :key="v.value" />
  </ElSelect>
</template>

在父组件中使用它

<script setup>
import { Form, FormItem, PlainField, ObjectField } from "@usaform/element-plus"
import { ElInput, ElSelect, ElDivider } from "element-plus"
import {ref} from "vue"
  //我们上边封装的
  import Select from "./Select.vue"
  
const config = {
  Elements: { ElInput, FormItem, Select }
}
</script>
<template>
<Form :config="config">
  <!-- 现在 -->
  <PlainField 
     name="input" 
     element="Select"
     :props="{ options: [] }"
  />
</Form>
</template>

看上去是不是还挺简单的,那么参数都哪里去了?

参数来源于两个地方,字段组件和布局组件

参数确实会有很多个,内部提供的布局组件传递的都是 element-plus 表单组件本身就会用的的,所以可以不管让它自动传递下去,实际接收参数时通常只需要接收,actions/v-model相关的 这两个,以及你需要用到的自定义参数

v-model 有个 defineModel 的语法糖,所以写起来代码就更少了

actions 是对表单进行怎删改查的一坨子方法对象,<Form/> 组件的返回值,所有字段组件都叫 actions,我统一管它们叫互操作方法,用法往后看~

全局提供,简化书写

看前面的例子会发现出现了一定程度的模版代码

<PlainField name="input" :layout="FormItem" :layout-props="{ label: '名称' }">
    <template #default="{ bind }">
      <ElInput v-bind="bind" placeholder="请输入名称" />
    </template>
  </PlainField>

  <PlainField 
     name="input" 
     layout="FormItem"  
     :layout-props="{ label: '名称' }" 
     element="ElInput"
     :props="{ placeholder:'请输入名称' }"
  />

插槽写法总得 bind 下,指定 key 得在 <script> 中注册一下

我们可以通过全局统一注册使用,接着就只需要,指定 key 用哪个即可

import { ElInput, ElSelect } from "element-plus"
import { FormItem, useFormConfigProvide } from "@usaform/element-plus"

const app = createApp(App)
useFormConfigProvide(
  {
    Elements: {
      ElInput,
      ElSelect,
      FormItem,
    }
  },
  app
)

useFormConfigProvide 提供了全局注册的能力,第一个参数和 <Form/> 组件的 config 参数一模一样

hook 可以用来任意组件或者 js/ts/jsx/tsx 内,内部是通过 provide 进行依赖注入,所以如果不是在 vue 组件内,则必须传递第二个参数, createApp 的返回值

向下传递的参数会自动合并进 <Form/> 组件,而 useFormConfigProvide 本身不会合并

布局组件

设计时把表单项分成了 2 部分

  1. 创造数据的
  2. 非创造数据的

非创造数据的就包含了(布局 + 校验),像嵌套用的字段组件它相当于是在给这个表单创建一个分组数据,所以属于创造的范畴

内部提供的 FormItemelement-plus/ElFormItem 的仿品,因为人家内部对于校验相关的代码无法复用(我想用但用不起来),二次封装是个不划算的做法,所以做了个仿品,不能保证它们使用上的一致性

完整的参数类型如下,了解即可,用到再看

export interface FormItemProps {
  label?: string      //标题
  labelWith?: string | number   //标题宽度,默认是 auto
  size?: "small" | "large" | "default"  //尺寸,默认 small
  required?: boolean       //是否必传
  rules?: (RuleItem | string)[] //如果是字符串,会从 form.config.Rules 中取,RuleItem规则同async-validator,element-plus的form-item
  disabled?: boolean       //是否禁用
  inline?: boolean         //是否是行内,默认是 display:flex 行内变成 display:inline-flex
  position?: "left" | "right" | "top"  //效果同 element-plus formItem
  showError?: boolean        //是否在校验失败时展示错误信息
  __fieldInfo?: CPlainFieldLayoutInfo | CObjectFieldLayoutInfo | CArrayFieldLayoutInfo //字段组件一定会传递的,操作字段相关的一些内容
}

//内容基本都一致
type CPlainFieldLayoutInfo = {
  type: "plain" //什么类型的字段进来的
  fieldValue: Ref<any> //通过 shallowRef 创建的字段内的变量
  actions: PlainFieldActions  //不同类型字段组件的互操作方法
  Rules: Record<any, CFormRuleItem>  //全局配置中的对象
  children: (p: Record<any, any>) => any //对填充组件包装后的函数,可以在调用时动态混进去一些参数
}

对于自定义布局组件的场景,只有当需要自定义布局样式+布局逻辑时,才会需要自定义布局组件,一般情况下直接在填充组件中写布局代码也可以,FormItem 不强制使用

校验

添加自定义的校验规则可以配置,FormItem 的 rules 属性,然后在字段组件的 layout-props 属性中传过去

规则写法和 element-plus 一致,例如

const requiredRule: CFormRuleItem = {
  message: "", //异常信息
  trigger: "blur", //触发校验的方式
  validator: (_, v) => {  //自定义校验函数,返回 true 是通过,false 失败
    if (Array.isArray(v) || typeof v === "string") return v.length !== 0
    if (v === undefined || v === null || v === false) return false
    return true
  }
}

这是内部如果配置了 FormItem 的 required 属性,默认给挂进去的一条

触发方式分为,失去焦点 onBlur / 内容变化 onChange

前者需要用户手动调用(参数中的 onBlur 方法)来触发,但是,插槽写法被包含在 bind 中会自动绑定,自定义文件时如果不明确接收它,vue 会自动传递给根部组件。这些情况下并不需要手动干预就能如预期工作

为了简化校验规则的模板式写法,和指定 key 一样,可以在全部配置中设置 Rules 属性,然后指定规则的 key 就可以生效了

与表单进行互操作(增删改查)

互操作主要就是用提供的 actions 中的方法进行操作

不同的字段组件提供的内容会有所不同,我们来看几个通用的

假设我们现在的表单结构如下,我们所有的操作都假设从 input 字段开始

form
	input
	select1
	select2
  • subscribe
const {subscribe} = actions
//返回一个取消订阅的方法
const unSubscribe = subscribe("../select[1,2]", (newValue, oldValue) => {
  //do...
})

批量订阅其他字段的修改,返回一个取消订阅的函数

第一个参数中的字符串表示查找路径,框架会按照 路径系统,按照一定的语法把字符串拆成正则去匹配

详细的路径系统的规则

  • get/set

这两个分别用于获取和修改,例如表单回显或者动态改值

  • callLayout/callElement
type CallElement = (
	path: string,  //路径
	key: string,   //方法名
	point?: any    //this 指向
	...params: any[] //参数
) => Record<string, any> //返回所有匹配路径下的,方法的返回值

这两个会分别调用 PlainField 中的布局组件和填充组件中 expose 出来的方法

前者可以用来手动校验指定的数据组件,写起来大概长这样 actions.CallLayout ("字段路径", "validate")

后者可以用来调用组件库内部的方法

了解即可,用到了查文档

详细的路径系统的规则

路径系统是以 / 分割的字符串,内部会给转成正则进行匹配,写起来很像import (路径) 里的路径

  • 一般查找

    • a/b/c 找自己下边的 a,a 下边的 b,b 下边的 c
  • 正则查找 && 批量查找

    • a/.*/c 找自己下边的 a,a 下边所有的字段,所有字段下边的 c
    • a/[0-9]/c 找自己下边的 a,a 下边 0-9 的字段(通常用于数组),所有字段下边的 c
  • 根部查找(就是从最顶层向下找)

    • ~/a 从最顶层找下边的 a
  • 向上找

    • ../a 找父节点下的 a
  • 搜索全部

    • xx/xx/all
      • 必须是以 all 结尾才会找全部,否则会视为一般查找被转成正则
      • 通常用于方法调用中call/callLayout/callElement,它可以无视表单的深度查找所有
  • 返回自己

    • "" 空字符会返回自身
    • 因为直接修改暴露出来的响应式变量会存在很多未知的边界情况,建议除了 PlainField 始终使用内部提供的操作方式,来修改自身或者其他字段的值

正则会进行缓存,不会造成正则的性能问题

表单回显

Const fidFirstData = {
	Object: { input: 1, select: 2 }
}
Const formConfig: FormConfig = { defaultFormData: fidFirstData }


//模版中记得绑定 <Form ref="actions" />
Const actions = ref ()
Actions.Set ("", fidFirstData)

回显有 2 种方式

  1. 通过 defaultFormData 设置初始化的值
  2. 通过 actions. Set 方法

数组表单

(@usaform/element-plus 1.0 发布) 我打造的一款,平民化的、高性能、高灵活的表单

这是一个数组形态的,稍微复杂点的动态表单,它的代码使用插槽写,长这样

<Form>
  <ArrayField name="array">
    <template #default="{ fieldValue, actions }">
      <div v-for="(item, i) in fieldValue" :key="item.id">
        <PlainField :name="i" layout="FormItem" :layout-props="{ label: '名称', required: true }">
          <template #default="{ bind }">
            <ElInput v-bind="bind" placeholder="请输入名称" />
          </template>
        </PlainField>
      </div>
      <ElSpace>
        <ElButton @click="actions.push({ id: Math.random(), value: '11111111' })">尾部添加</ElButton>
        <ElButton @click="actions.pop()">尾部删除</ElButton>
        <ElButton @click="actions.unshift({ id: Math.random(), value: '2222' })">头部添加</ElButton>
        <ElButton @click="actions.shift()">头部删除</ElButton>
        <ElButton @click="actions.swap(0, fieldValue.length - 1)" v-if="fieldValue.length >= 2">首尾交换</ElButton>
      </ElSpace>
    </template>
  </ArrayField>
</Form>

<script>中的写法和前边的一致,主要是会传递进来一个 shallowRef 的 fieldValue 数组,可以循环它嵌套更多的子组件,事件都是封装好的,只需要往里边填充数据即可

会发现数组表单的写法并不复杂,挺简洁的,但它的注意事项比较多,比如数组项的 name 必须是下标,否则嵌套里的字段不知道挂在数组哪个坑里了,建议是粘贴 demo 或者跑仓库中相关组件的例子,对照文档进行理解

更复杂的组件

到此 3 个字段组件,一些高频和必须知道的东西都见得差不多了,琐碎的东西有很多,用到在查即可

Demo 给出的两个场景也都没那么复杂,如果单从写法上看,组件库也能做到,代码量可能也差不多

可如果更进一步,我们让 数组组件,布局组件,对象组件,数组组件 随机叠加进行嵌套呢,此时各个维度上的复杂度就会开始飙升了

  • 怎么保证性能问题
  • 怎么让写法变得优雅
  • 怎么保证表单的互操作性
  • 怎么进行这样一个东西的增删改查数据等等的管理

@usaform/element-plus 可以在保证用你上边看到的写法,保障提到的所有问题的同时解决它们

  • 性能。内部数据是互相隔离的,更新只发生在每个字段组件中
  • 写法。<XXXField element="xxx" /> 想要性能好就这样,用指定 key 的方式套娃就行了
  • 互操作。提供了路径系统,路径写法进行了很大程度的简化,基本就是以 / 分离在写简单的正则(匹配字段名都用不到很复杂的符号)
  • 管理。互操作方法可以让你在任意字段中做到,对全体表单、某个、某些个字段进项各种修改,订阅,获取,等

怎么封装属于自己的表单组件

这个封装可以分三种

  1. 自定义填充组件,已演示
  2. 自定义布局组件,频率很低,文档
  3. 自定义 风格/业务 组件,例如分步表单,点一下变个新的 分步表单demo

仓库中提供了很多方向的使用 demo,强烈建议可以把 demo 下载下来自己跑跑点点

仓库

# 如果下载以后,cd 进根目录

# 下载依赖
Pnpm i

# 运行
Pnpm --filter example-element-plus dev

为什么用 @usaform/element-plus 而不是其他表单框架

用谁家的都无所谓,只要开源了大家都在抄来抄去的搞微创新,就算谁把我的代码抄了冲 kpi 都没问题,只不过大家都是奔着解决问题去的,侧重各有不同,用的舒服是最主要的

我个人喜欢用简单灵活的东西,然后留出足够程度的扩展能力,针对具体场景不够用在做一个二次封装,既要还要通常不会有好的结果。本框架主要是提供一个简洁的架子,希望能用简单的写法去解决更多的场景,无论是二次封装还是底层扩展都很容易

Q&A

  • 后续计划
    • 暂时会以稳定和改 bug 为主
    • 后续 2.0 的计划主要是做到 ui <-> json 的双向互转,主要是能通过自定义的 json 结构做到
      • 在保持当前体积的前提下,做到和当前组件开发一样的效果,这对低代码,表单持久化
      • json组件 ui 的协同工作,这对于从接口中拿到 json,还原回组件形态之后的继续开发,会非常有帮助,纯粹的 json 是死的,规则多了维护就会产生巨大的压力
    • 再考虑的事情(如果你有好的建议,可以联系我共同考虑)
      • 是否需要提供一些辅助用的 hooks 和组件,比如
        • 递归组件
        • 异步表单
        • 超大表单(1 w+ 的表单项)
  • 可以放心使用吗
    • 这个东西我自己在用,朋友在用,属于个人项目,主要用于解决后台管理系统中的表单场景。它可以帮我解决很多相关问题,所以有着天然的驱动力支持我去维护好它
    • 没有任何 kpi 成分,也不会归并到任何公司的产物里
    • 个人比较看重稳定性和实用性,

原文链接:https://juejin.cn/post/7325791449664340020 作者:usagisah

(0)
上一篇 2024年1月21日 上午10:00
下一篇 2024年1月21日 上午10:10

相关推荐

发表回复

登录后才能评论