(@usaform/element-plus 1.0 发布) 我打造的一款,平民化的、高性能、高灵活的表单
介绍
@usaform/element-plus
是一款跨 vue
应用的表单逻辑库,本身只负责逻辑粘合,实际内容完全由户决定,结合组件库使用可以大大提高写表单的效率,因为内部逻辑会更偏向 element-plus
所以起了个同名,只要是基于 vue
的都可以使用
判断是否需要可以看看您是否有以下的诉求
-
表单深度嵌套
现有的组件库能做,可是用起来觉得体验很差
主要提供便利的写法,和简单的管理方式
-
动态表单
比如,对表单项动态的增删改查
对动态表单的支持体现在多个维度上
- 性能取舍。性能好写起来繁琐一点点,正常性能下写起来简单
- 扩展能力。表单可以简单的拆成 3 个部分,
布局layout/表单项input/控制器
。控制器是管理数据的这由框架提供;布局提供一个默认的,允许完全自定义;表单项可以使用组件库的组件填充,也可以完全自定义 - 跨字段操作。比如在一个字段中可以直接操作其他任意层级字段的内容
-
元框架
表单是前端应用中的高频元素,提供再多的预处理都是有限的,但某些东西是能够进行统一抽象的,该框架提供了 2 个维度的二次封装能力(可以用来搞搞 kpi)
- 基于逻辑组件,这里用到框架本身的组件,自己添一点更加开箱即用的功能给团队使用,比如业务表单组件,
ProForm
,高阶布局,等 - 基于表单逻辑控制
hook
,这是更加底层的一组精简的表单hook
,@usaform/element-plus
就是基于它做的上层组件封装,使用它可以自定义表单的组件逻辑控制能力
- 基于逻辑组件,这里用到框架本身的组件,自己添一点更加开箱即用的功能给团队使用,比如业务表单组件,
简单概括,能提供的优点
- 复杂表单中,性能 / 便利二选一(混用的性能我不知道怎么界定,应该大岔不岔吧)
- 相对简单写法 (同类产品中相对简单,仍有一定上手难度,因为文档字多)
- 高度灵活
- 相对较小的体积(目前用的 tsc 打包,文件都没压缩,类型文件没压缩,很多中文的文档内联进了发布的包里,使得看起来会非常的大。项目中用时打包工具会自动进行 tree shaking 和压缩。用 tsc 只是表面数据不好看,实际没什么影响 )
判断是否不需要,不要为了用而用!
- 认为组件库提供的已经够用的(对团队有学习成本)
- 单层次的简单表单(给自己找罪受)
- 结构完全静态的表单(基于组件库二次封装下基本是够用的,强行用,收益是未知的,请谨慎判断)
本文会带大家由浅到难做几个表单,来感受一下,各种概念和高级用法不做展开,深入学习请参考 npm 上的破烂文档(尽力写了)和 github 仓库里的 examples
创建应用
这里请使用 vite
创建一个 vue
的应用
下载依赖
shell
pnpm add @usaform/element-plus element-plus @vitejs/plugin-vue-jsx sass
配置 vite
js
import vue from "@vitejs/plugin-vue"
import jsx from "@vitejs/plugin-vue-jsx"
export default defineConfig({
plugins: [vue(), jsx()]
})
引入样式文件
ts
import "element-plus/dist/index.css"
//如果要使用内部的 FormItem 组件才需要引入,不用就没必要了
import "@usaform/element-plus/style.scss"
使用插槽写法,创建一个简单的嵌套表单
vue
<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
的响应式系统是细颗粒度的精确更新,可是一旦我们在组件内嵌套插槽时,当子组件发生数据发生更新,至少至少会使得父组件一并更新
而解决它我们需要把插槽用组件代替,那么写法会变成
html
<!-- 之前 -->
<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 用什么组件,所以完整代码如下
vue
<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
vue
<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>
在父组件中使用它
html
<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
,我统一管它们叫互操作方法,用法往后看~
全局提供,简化书写
看前面的例子会发现出现了一定程度的模版代码
html
<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 用哪个即可
ts
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 部分
- 创造数据的
- 非创造数据的
非创造数据的就包含了(布局 + 校验),像嵌套用的字段组件它相当于是在给这个表单创建一个分组数据,所以属于创造的范畴
内部提供的 FormItem
是 element-plus/ElFormItem
的仿品,因为人家内部对于校验相关的代码无法复用(我想用但用不起来),二次封装是个不划算的做法,所以做了个仿品,不能保证它们使用上的一致性
完整的参数类型如下,了解即可,用到再看
ts
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
一致,例如
ts
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 字段开始
css
form
input
select1
select2
subscribe
ts
const {subscribe} = actions
//返回一个取消订阅的方法
const unSubscribe = subscribe("../select[1,2]", (newValue, oldValue) => {
//do...
})
批量订阅其他字段的修改,返回一个取消订阅的函数
第一个参数中的字符串表示查找路径,框架会按照 路径系统
,按照一定的语法把字符串拆成正则去匹配
get/set
这两个分别用于获取和修改,例如表单回显或者动态改值
callLayout/callElement
ts
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 下边所有的字段,所有字段下边的 ca/[0-9]/c
找自己下边的 a,a 下边 0-9 的字段(通常用于数组),所有字段下边的 c
-
根部查找(就是从最顶层向下找)
~/a
从最顶层找下边的 a
-
向上找
../a
找父节点下的 a
-
搜索全部
xx/xx/all
- 必须是以
all
结尾才会找全部,否则会视为一般查找被转成正则 - 通常用于方法调用中
call/callLayout/callElement
,它可以无视表单的深度查找所有
- 必须是以
-
返回自己
""
空字符会返回自身- 因为直接修改暴露出来的响应式变量会存在很多未知的边界情况,建议除了
PlainField
始终使用内部提供的操作方式,来修改自身或者其他字段的值
正则会进行缓存,不会造成正则的性能问题
表单回显
ts
Const fidFirstData = {
Object: { input: 1, select: 2 }
}
Const formConfig: FormConfig = { defaultFormData: fidFirstData }
//模版中记得绑定 <Form ref="actions" />
Const actions = ref ()
Actions.Set ("", fidFirstData)
回显有 2 种方式
- 通过
defaultFormData
设置初始化的值 - 通过
actions. Set
方法
数组表单
这是一个数组形态的,稍微复杂点的动态表单,它的代码使用插槽写,长这样
html
<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 的方式套娃就行了 - 互操作。提供了路径系统,路径写法进行了很大程度的简化,基本就是以
/
分离在写简单的正则(匹配字段名都用不到很复杂的符号) - 管理。互操作方法可以让你在任意字段中做到,对全体表单、某个、某些个字段进项各种修改,订阅,获取,等
怎么封装属于自己的表单组件
这个封装可以分三种
仓库中提供了很多方向的使用 demo,强烈建议可以把 demo 下载下来自己跑跑点点
shell
# 如果下载以后,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
成分,也不会归并到任何公司的产物里 - 个人比较看重稳定性和实用性,