AntModifier 设计和实现的心路历程(纯干货)

352 阅读14分钟
原文链接: zhuanlan.zhihu.com

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

没错,又是喜闻乐见的后台管理项目,技术栈变了,可造轮子的心没变,先从实现一个 Modifier 组件开始吧。另外,关于该组件的相关规范可以先看下我之前的这篇回答:

前端怎么深入思考?

一、功能设想

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

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

二、API 设计篇

直接上代码:AntModifier 欢迎 Star!

  • Modifier.Form
简化 AntForm 的使用:你不需要写Form.creategetFieldDecorator了!同时提供 Modifier 的表单提交功能
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 的状态
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 封装成类似 ElementFormdataprop这种表单数据的定义形式:

<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
<Modifier { ...代理 AntModal.Props }>
  <Form>
    <Modifier.Item { ...代理 AntFormItem.Props }/>
  </Form>
</Modifier>

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

根据调用的先后顺序和逻辑关系很容易想到用Modifier去代理 Form.create的配置;用

Modifier.Item去代理getFieldDecorator的配置。但问题是将配置展开,还是统一?

// 展开
<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>

展开的好处是更直观和能够降低由于 props引用变化而触发组件更新的概率;统一的好处是降低和代理组件props冲突的概率。

仔细考量之后,最终选择了前者。

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

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

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

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

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

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

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

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

import { modifierUtils } from 'Modifier'

modifierUtils.show()

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

  • 关于 name

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

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

  • 关于 customData

文章前面部分的示例中,无论是Modifier.Modal,还是Modifier.Form,在调用它们静态方法的时候,都可以传入一个customData。原本是没有这个设计的,但业务中经常会有这类场景:提交数据的时候往往还需要带上 id 等额外非用户输入的字段,通过该参数就可以很容易地实现该类需求。

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

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

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

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

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

  • Modifier.Modal 和 Modifier.Form

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

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

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

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

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

// Modal 模式
<Modifier name="xxx" action={action}>
  <Form>
    <Modifier.Item />
  </Form>
<Modifier/>

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

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

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

// 普通模式
<Modifier.Common name="xxx" action={action}>
  <Form>
    <Modifier.Item />
  </Form>
<Modifier.Common/>

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

// Modal 模式
<Modifier.Modal name="xxx" action={action}>
  <Form>
    <Modifier.Item />
  </Form>
<Modifier.Common/>

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

<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) 。而显然ModifierModifier.Item 是多级嵌套的父子关系。

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

相关源码:

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

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

<Modifier { ...someFormCreateOptions } />

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

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

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共享出去了!

相关源码:forceUpdate

四、结语

我之前文章里说过:

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

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

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