最近工作的前端框架从 Vue 转移到了 React,业务组件从 element 转移到了 ant-design

没错,又是喜闻乐见的后台管理项目,技术栈变了,可造轮子的心没变,先从实现一个 Modifier 组件开始吧。

一、功能设想

当前项目,或者说接触过的大部分项目的新增和修改操作都是在弹窗组件中完成的,于是准备基于 AntModal + AntForm 实现如下功能:

  • 简化 AntForm 的使用(众所周知,AntForm 用起来很麻烦…
  • 提交数据之前自动执行校验
  • 提交数据之前自动对比表单数据,如果有变化才会执行并回调数据的异同部分
  • 提交数据,弹窗会自动管理 loading 和 显隐 状态
  • 弹窗提交成功后自动初始化表单数据
  • 归一化处理所有需要额外请求的字段,比如:文件和图片的上传

二、API 设计篇

直接上代码:AntModifier 欢迎 Star!

  • Modifier.Form

简化 AntForm 的使用:你不需要写 Form.create 和 getFieldDecorator 了!同时提供 Modifier 的表单提交功能

jsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import * as React from 'react'
import * as Modifier from 'Modifier'
import { Input, Button } from 'antd'

function submit(formData, customData) {
console.log(formData, customData)
return new Promise((resolve) => setTimeout(resolve, 1000))
}

function onSubmit() {
Modifier.Form.submit('modifierForm', 'customData')
}

function App() {
return (
<Modifier.Form name="modifierForm" action={submit}>
<Modifier.Item id="name" rules={[{ required: true }]}>
<Input placeholder="请输入用户名" />
</Modifier.Item>
<Button onClick={onSubmit}>提交</Button>
</Modifier.Form>
)
}
  • Modifier.Modal

如果你业务中表单的修改和创建是在弹窗中完成的,那就来用它吧!除了提供简化 AntForm 的功能外,还为你自动管理了 Modal 的状态

jsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import * as React from 'react'
import * as Modifier from 'Modifier'
import { Input, Button } from 'antd'

function submit(formData, customData) {
console.log(formData, customData)
return new Promise((resolve) => setTimeout(resolve, 1000))
}

function onSubmit() {
Modifier.Modal.show('modifierModal', 'customData')
}

function App() {
return (
<div>
<Modifier.Modal name="modifierModal" action={submit} title="创建用户">
<Form>
<Modifier.Item id="name" rules={[{ required: true }]}>
<Input placeholder="请输入用户名" />
</Modifier.Item>
</Form>
</Modifier.Modal>
<Button onClick={onSubmit}>创建用户</Button>
</div>
)
}

表单数据的映射方式

受历史技术栈的影响,最开始是准备把 AntForm 封装成类似 ElementForm 的 data 和 prop 这种表单数据的定义形式:

jsx
1
2
3
<Form data={formData}>
<Item prop="propName" />
</Form>

简单直观,并省去了 initialValue 的步骤。

然而这种方式本身是有一定风险的,尤其是在 Vue 的环境下:如果你传入的 data 就是某个待修改项的原始数据,双向绑定的存在会很容易导致原始数据被修改成某个并不希望出现的中间状态。即使是在 React 中,如果 data[prop] 指向的是某个对象,同样存在该风险。

然后为了杜绝该风险,可能还需要增加一步 data 的拷贝或者映射定义的设计,与其这样不如直接保留 AntForm 这种在每个 Item 上显式声明真正需要提交的字段这种方式了。

组件的嵌套关系以及 props 代理

  1. 首先既然是基于 AntModal + AntForm,组件的嵌套关系则要尽可能贴近于原生用法
  2. 只代理需要覆盖或者扩展功能组件的 Props,一代多会增大 Props 冲突的风险,而且一代多还会涉及到把 props.children 插入到哪里的问题(所以 Modifier 并没有同时代理 AntForm.Props
jsx
1
2
3
4
5
<Modifier {...AntModal.Props}>
<Form>
<Modifier.Item {...AntFormItem.Props} />
</Form>
</Modifier>

那么问题来了:Form.create 和 getFieldDecorator 的调用过程可以省去,但原本的功能肯定要保留,所以这两个方法需要的 options 怎么传?

根据调用的先后顺序和逻辑关系很容易想到用 Modifier 去代理 Form.create 的配置;用 Modifier.Item 去代理 getFieldDecorator 的配置。但问题是将配置展开,还是统一?

jsx
1
2
3
4
5
6
7
8
9
10
11
12
13
// 展开
<Modifier mapPropsToFields={ mapPropsToFields } onFieldsChange={ onFieldsChange }>
<Form>
<Modifier.Item id={ id } rules={ rules }/>
</Form>
</Modifier>

// 统一
<Modifier createOptions={ { mapPropsToFields, onFieldsChange } }>
<Form>
<Modifier.Item fieldDecorator={ { id, rules } } />
</Form>
</Modifier>
  • 展开的好处是更直观,非 TypeScript 项目也能有 props 输入提示以及能够降低由于 props 引用变化而触发组件更新的概率
  • 统一的好处是降低和代理组件 props 冲突的概率。

经过以上对比,最终选择了前者。

上层如何去调用组件的方法?

拿这个 Modifier.Modal 来说,如果按照 React 的传统设计,应当是提供 visible 和 onVisibleChange 这两个 props 来让上层去控制组件的显隐,包括 loading 状态也同样。

然而从组件的功能上看,从被打开的那一刻起,它就完全知道自己应当在什么时机被关闭,什么时机进入和退出 loading 状态,交出控制权反而增加了调用者的心智负担和风险。

于是确定了只对外提供一个 show 方法,开发者只需要知道自己什么时候打开弹窗即可 。

那么问题又来了,这个方法通过什么形式暴露出去?最开始的想法是基于 ref,通过它获取到组件实例,继而去调用它的 show 方法。看起来没啥毛病,实践了一段时间后发现在很多场景下,开发者为了组件的复用和 View 类组件的清爽,他们会基于 Modifier 组件再封装一个比如 UserCreator 的类似组件拿出去。

这样的话 UserCreator 组件本身必须再实现一个 show 方法,在该方法内部调用 modifier.show,理论上希望 UserCreator 的上层再通过 ref 去获取它的实例,再去调用 UserCreator.show。

然而由于 React 项目中大量 HOC 设计的使用,很可能获取 UserModifier 实例的过程会很坎坷。。。

最后,其实就 show 这个方法刚开始也不是挂载在 Modifier 的静态属性中的,而是封装进了 modifierUtils 。后来发现也没什么好 utils 的,遂放弃。。

1
2
import { modifierUtils } from 'Modifier'
modifierUtils.show()

综上,确定了 Modifier.XX.show 的这个设计。

关于 name

这个字段无论是作为 prop,还是静态方法的参数在前文中都多次出现了,它的主要作用是作为组件实例的唯一存储索引,后面的实现篇中也会讲到。这里主要想说一下它的推荐使用方式:如果你是基于本组件做了二次封装,尽量还是把 name 作为 prop 选项,让外层去赋值或者全局导出一个统一的 name 变量。

毕竟作为一个「魔法字符串」似的存在,它的声明和使用最好在同一个上下文,不然调用者很难知道它的含义。。

关于 customData

除了上面的 name 参数,还有一个参数是 customData。原本是没有这个设计的,但业务中经常会有这类场景:提交数据的时候往往还需要带上 id 等额外非用户输入的字段,通过该参数就可以很容易地实现该类需求。

但是,缺点是你没法直接在上层去控制 customData 这个数据的类型,意思是说用户在调用 Modifier.show 的时候,不知道它需不需要 customData,更不知道需要什么类型的 customData,会造成一定的维护困难。。。当然了,如果你用的是 TypeScript,倒是可以给静态方法传入一个泛型约束,不过这也得靠开发者自觉。

综合来看还是利大于弊,就保留了。

所以说到这里,还有没有其他更优雅的方式了?答案是:有!不过略微麻烦了一些:

jsx
1
2
;<UserCreator name="userCreator" id={this.state.id} />
Modifier.Modal.show('userCreator')

对,就是把 customData 作为 UserCreator 的 prop 传过去,这样就可以有实时的输入提示和字段校验,这在你用 TypeScript 的情况下会更明显!不过如果 id 是需要响应变化的字段,略麻烦的是你需要在上层组件额外维护一个 state。

Modifier.Modal 和 Modifier.Form

这两个基础组件其实是最后才有的,所以你看本篇文章中提到的组件关键字大部分都直接是 Modifier 。而且最开始的时候,它叫 AntModalModifier。。望文生义,它只是一个基于 AntModal 的表单修改组件,并没有简化 AntForm 的调用。

然后我把这篇文章关于 API 的设计部分写完的时候,是准备驳斥这种设计的。。后来发现我竟无法反驳了,当初放弃好像也只是因为实现的成本太高了。好吧,强迫症的我决定还是给它搞出来吧!哎、所以有时候写文章真的是给自己找事做。。

好,一步步完成了简化 AntForm 的功能。然后发现既然实现了这个功能,它却只能跟 Modal 一起使用岂不是很可惜?嗯!那把这个功能拿出来,可以单独使用。

所以首先要改的就是组件的名字:AntModalModifier => AntModifier(不要吐槽 Modifier 这个单词了。。

那如果叫了这个名字,该怎么去分区 Modal 模式和普通模式呢?最开始的想法是:

jsx
1
2
3
4
5
6
7
8
9
10
11
12
13
// Modal 模式
<Modifier name="xxx" action={action}>
<Form>
<Modifier.Item />
</Form>
<Modifier/>

// 普通模式
<Modifier>
<Form>
<Modifier.Item />
</Form>
<Modifier/>

逻辑是如果不传 name 就是普通模式了,确实很普通了,name 都不传,就意味着你没办法通过静态方法去调用组件方法了,可能除了作为表单展示来用,基本就没啥用处了。而且,既然名字叫了 Modifier,觉得还是给一点功能吧,这样也更符合本组件的「名义」。

ok,那这样就得继续传 name 和 action,再想个别的方案来区分它们,于是普通模式成了这样:

jsx
1
2
3
4
5
6
// 普通模式
<Modifier.Common name="xxx" action={action}>
<Form>
<Modifier.Item />
</Form>
<Modifier.Common/>

再继续看,确实这个设计的语义上很符合「普通模式」了,可是这样的话 Modal 模式 的设计却不符合「Modal 模式」了,凭什么它就直接叫 Modifier 了?好吧,于是 Modal 模式变成了下面这样:

jsx
1
2
3
4
5
6
// Modal 模式
<Modifier.Modal name="xxx" action={action}>
<Form>
<Modifier.Item />
</Form>
<Modifier.Common/>

又有了新的发现!Modifier.Modal 代理了 AntModal 的 Props,为什么 Modifier.Common 不能代理 AntForm 的呢?而且这时候大家都是一代一的状态了,所以才有了普通模式的最终设计:

jsx
1
2
3
<Modifier.Form name="xxx" action={action}>
<Modifier.Item />
<Modifier.Common/>

当然,就简化 AntForm 的这一功能也被单独抽象出来了,方便后期可能还会有各种 Modifier.XXX 的扩展。

三、实现篇

欢迎对照 源码 浏览以下内容

如何通过静态方法获取到组件实例?

思路是全局共享一个组件 key value 实例对象,key 即上文中的 props.name

在组件的 componentWillMount 阶段执行 add, componentWillUnmount 阶段执行 delete

如何共享 props.form?

毕竟组件的底层还是采用 Form.create 实现的,Modifier.Item 的渲染又得依赖 create 注入的 form(调用它的 getFieldDecorator) 。而显然 Modifier 和 Modifier.Item 是多级嵌套的父子关系。

是的,问题就变成了 React 里父怎样给子共享数据?很自然地想到了用 Context 去做

重头戏来了!如何干掉 Form.create?

干掉 Form.create 不难,难的是如何保留 create 的原本功能,也就是说还得能够继续给它传参。 上文中已经展示过参数的传入设计:

jsx
1
<Modifier {...someFormCreateOptions} />

那为什么说这个有困难呢?因为在逻辑上你得需要先在外面 create 了之后,才会有 Modifier 这个组件的存在。而如果按照上面的设计,显然得在 Modifier 的运行时去根据 props 动态地调用 create 。

于是不难想到去在 Modifier 的 WillMount 阶段去调用 create,生成的组件保存到当前实例,然后在 render 里再去渲染它:

jsx
1
2
3
4
5
6
7
8
9
10
class Component {
componentWillMount() {
this.Container = Form.create(this.props)(Form / Modal)
}

render() {
const Container = this.Container
return <Container {...this.props} />
}
}

看起来很完美!不过有个最大的问题是你会发现这样生成的表单部分,内容永远不会被更新了。。也就是说假设有个输入框,你没法输入内容,你也没法触发校验(其实是输入了,也触发了,只是视图没有更新)。

原因是目前的所有组件并没有和表单数据有直接的依赖关系,既不属于 props 也不属于 state,所以表单数据的变化并不会触发表单组件的更新。

那为什么原生的 AntForm 就可以?两个原因:

  1. AntForm 本身基于 rc-form , rc-form 会在表单数据变化的时候手动调用 this.forceUpdate() 来触发当前组件的更新
  2. 原生的使用方式是直接使用 create 之后的组件作为当前组件的根容器,它们是在同一个组件实例下,所以 this.forceUpdate 可以更新当前组件

因为本组件的 create 组件是动态生成,然后再强势插入的,这就导致了它们不在同一个组件实例下,成了父子关系。但即使是这样,从组件结构上来看,rc-form 调用的 forceUpdate 更新的组件作用域也完全包含了表单域,为什么就非得在同一个组件实例下?换句话说,不触发父组件的更新直接触发子组件有什么问题吗?原本是没问题的,但因为有了上一条实现的存在,即 Context 的引入,就导致了有问题。(Context:这锅我不背==

如果你熟悉 Context 的原理,应该能够想明白 Item 的更新将完全由 Context.Provider.value 的变化来触发。所以如果根组件不更新的话,这个 value 永远都不会变,从而造成了 Item 永不更新的问题。

那如何解决?从正常手段入手,可以去想办法在根组件去监听表单数据的变化,恰好 create 方法的 onValuesChange 就是干这事的,然后回调里再调用根组件自己的 forceUpdate。没错,这么做了之后解决了输入不了内容的问题

但是!表单组件的更新不仅仅是输入项的变化,还有校验的交互,比如错误提示这种。。这意味着如果我上来就直接调用组件的校验,这时候表单的值是没变的,也就没法触发 onValuesChange,也就导致根组件不更新,从而导致 Item 不会展示校验结果。。

方法总比困难多,继续思考… 那如果这样的话,为什么原生的可以更新?继续看 rc-form 的源码,原来是它在校验的时候最终也会去调用 forceUpdate。那我就有了一个大胆的想法,我去直接监听 create 组件的 update?好吧,还是太保守了,而且方法暂时没找到。

那就上最后的黑科技吧,即直接把 create 组件 的 forceUpdate 方法给修改成根组件的 forceUpdate。想了一下也没啥隐患,毕竟在本组件下,如果父层更新了也一定会触发子层的更新,所以这么改依然保持了原本 forceUpdate 的功能。于是,大功告成!

相关源码:change forceUpdate

如何解决初次 Item 不渲染的问题?

这个问题其实是由上面两个实现共同导致的:

  1. Item 的渲染依赖 Context.Provider 提供的 value 值,即 create 注入的 form
  2. 由于 create 组件和根组件不在同一个组件实例下,因而我需要在 render 里通过 ref 这个方式去拿到 create 组件的实例,继而拿到它的 props.form,继而再传给 Context.Provider。
  3. 第一次 render 结束,provider 出去的肯定是个空值

不过解决起来倒也不难。思路是在 componentDidMount 阶段,手动再执行一次 forceUpdate。由于第一次执行后,ref 已经拿到,这时候就可以正常去把 create 组件 的 props.form 共享出去了!

四、结语

我之前文章里说过:

如果说产品开发要讲究用户体验,那插件开发也要讲究开发体验,而好的开发体验,要靠好的 api 设计来保障

所以你用的各种组件也好,插件也罢,你用的爽很大程度上取决于该轮子的 api 设计,而且很多时候作者为了实现一个看起来很平常的选项要花不少代价。

最后我想说:没事也去造造轮子吧,如果说代码的实现是体力活,那 api 的设计就是艺术活!


 评论