Code Monkey home page Code Monkey logo

react-formutil's Introduction

react-formutil

npm npm peerDependencies definitionTypes gzip download issues license github github github github

react-formutil

Happy to build the forms in React ^_^

react-formutil 定义了一种表单状态的收集、分发、同步模型。基于此,你可以很方便的使用 react-formutil 来创建、管理你的页面表单。

react-formutil 的优势

  1. 全局表单状态同步,使用更加简单、自然、符合直觉、心智负担小,同时对于复杂场景,也提供了 高性能表单优化指南 供参考
  2. 一切都是状态,表单核心相关的 $value $viewValue $diry $pristine $touched $untouched $valid $invalid $error 等都是实时状态,整个表单以类似redux的单一状态树进行表达;无需额外的getFieldValue(name) getFormValues() validateField()等额外动作
  3. 非侵入性,只提供了对表单状态收集的抽象接口,不渲染任何 dom 结构;但同时你非常容易编写出适合项目的 Field 组件,我们也提供了对流行 UI 库的适配
  4. 采用受控组件和 context,对组件嵌套层级没有限制,支持数据双向同步(model<->view
  5. 同时支持高阶组件和函数式子组件(render props)式调用,更灵活(>=0.5.0 起支持Hooks
  6. 具备灵活的表单校验方式,支持同步和异步校验、懒校验等

无论你是否已经了解了react-formutil的特性、用法以及 API,开始项目前,请务必阅读下我们建议的最佳实践

安装 Installation

react-formutil

react-formutil现在已经 正式1.0 版本了,这代表我们其 API 设计已经趋于完整稳定。事实上,react-formutil从创建之处的0.0.1版本到目前的1.0,其 API 设计从来没有发生重大变动,每个版本都是向前兼容的。这代表无论你当前在哪个版本,你都可以无痛升级到最新版,并且立即获得新版本的特性支持!

最新版

npm

# npm
npm install react-formutil --save

# yarn
yarn add react-formutil

0.5.x

0.5.x 包含了一些对v16.3以前版本的 react 的一些polyfills处理,所以可以用于小于16.3的 react 版本。如果你还在使用早期版本的 react,可以使用该版本。

所有的react@16早期版本的 react,都可以升级到最新的 react,所以建议将项目中的 react 都进行升级,并使用最新的0.6.x版本的react-formutil.

UMD包

UMD格式包既适用于webpack rollup等模块打包工具,也可以直接用于浏览器script标签引入(当然,前面需要先引入react的代码包)。我们也提供了两个版本的包供选择:

生产环境 Production 压缩后的代码包,体积比较小:

https://unpkg.com/react-formutil/dist/react-formutil.umd.production.js

开发环境 Development 未压缩代码,方便调试查错:

https://unpkg.com/react-formutil/dist/react-formutil.umd.development.js

示例 Examples

先看一个简单的示例:

Demo on codeSandbox.io

如果上方地址无法访问或者较慢,也可以查看:Demo on github pages

上面的示例简单展示了 react-formutil 的基本用法,你可以通过查看源代码(在codeSandbox或者查看docs)。

另外也准备了一些实例引导教程,教你一步步学习如何上手react-formutil! 你可以点击下方链接进入在线示例教程,跟着页面引导一步步学习如何由简到深的开发自己的表单组件!

使用 Usage

了解如何在 ant-designMaterial-UI等流行 react 组件库项目中使用 react-formutil?

react-formutil 主要提供了一个 Field 组件和一个 Form 组件,另外还有几个基于此的高阶组件:

  • <Field /> 是一个抽象的组件,它维护了一个表示当前域的状态模型。
  • <Form /> 也是一个抽象的组件,它主要作为整个表单的控制器,用来和其组件树中的Field做状态模型的收集与同步。
  • withField 是基于 Field 包装成高阶组件,方便习惯高阶方式的调用
  • withForm 是基于 Form 包装成高阶组件,方便习惯高阶方式的调用
  • <EasyField /> 是基于 Field 进行的组件封装,可以直接渲染出基于原生态浏览器的表单控件的表单项,方便直接使用。另外它也提供了一组抽象接口用于对接其他 react 组件库。
  • connect 是个高阶组件,用来给被包装的组件传递 $formutil 对象。

react-formutil 不像很多你能看到的其它的 react 表单库,它是非侵入性的。即它并不要求、也并不会强制渲染某种固定的 dom 结构。它只需要提供 name 值以及绑定好 $render 用来更新输入值,然后一切就会自动同步、更新。

需要强调,当使用 Field 和 Form 时,我们建议以函数作为子节点方式调用: function as child

当然,你也可以通过render属性来调用:render props

也可以传递component来指定直接渲染一个组件。

//一个函数式子组件书写示例
<Form>
    {$formutil => {
        return <Field name="username">{props => <input />}</Field>;
    }}
</Form>

//或者使用children属性
<Form
    children={$formutil => <Field name="username" children={props => <input />} />}
/>

//或者使用render属性
<Form
    render={$formutil => <Field name="username" render={props => <input />} />}
/>

//或者使用component属性
<Form
    component={MyForm} />

//当然也可以传递普通组件作为子节点
//Field组件写在loginForm这个组件中
<Form>
    <LoginForm />
</Form>

对于 <Form /> <Field /> <EasyField /> 三个组件,其相关属性的优先级为:

component > render > children

<Field />

Field 是一个标准的 react 组件,一个Field即代表一个表单域。它维护了一个与当前域有关的状态模型(具体可以参考:$fieldutil)。

它可以理解为表单控件的顶层组件,它本身不渲染任何实际 DOM 节点。它通过向子组件传递 $fieldutil 对象来同步表单控件的状态。

每个表单域的渲染都应当通过Field来实现。它提供了多种调用方法,可以以函数、或者 React 组件当作子组件调用,推荐使用render props

我们提供了一个教程,关于如果快速通过Field组件集成进项目:

如何在我自己的项目中便捷的使用Field组件?

Field 可以接收以下几个属性参数:

render component

这两个属性为可选,并且不能同时存在(component 会优先于 render,而将其覆盖)。

当使用function as child方式时,可以不传该属性。

如果设置了该属性,则其会覆盖掉function as child方式。

<Field name="username" render={$fieldutil => <input />} />
// 或
<Field name="username" component={MyField} />

name

该项必填,name 可以是一个简单的字符串,也可以是一个字符串表达式(该表达式执行没有 scope, 所以表达式中不能存在变量)

  • <Field name="username" />
  • <Field name="list[0]" />
  • <Field name="list[1].name" />
  • <Field name="list[2]['test' + 124]" />

以上都是合法的 name 值。对于多层级的 name 值,生成的表单参数对象,也会基于该对象层级创建。例如,上面的示例,将会生成以下格式的表单参数对象:

{
    "username": "",
    "list": ["", { "name": "" }, { "test124": "" }]
}

$defaultValue

0.5.4起,$defaultValue也可以传递一个函数,该函数接收所有传递给 Field 的 props,然后返回的要设置的默认值。类似react-redux中的mapPropsToState用法。

$defaultValue 可以通过传递一个值,或者一个返回初始值的函数,来将其作为 Field 的默认值/初始值。如过不传递该参数,则默认值都为空字符串。通过该属性,你可以指定某个表单控件的默认值或初始值。

  • <Field $defaultValue="username" />
  • <Field $defaultValue={{name: 'dog'}} />

$defaultValue 可以是任意类型值。

$defaultState

0.5.4起,$defaultState也可以传递一个函数,该函数接收所有传递给 Field 的 props,然后返回的要设置的初始状态。类似react-redux中的mapPropsToState用法。

$defaultState 可以覆盖表单控件的的默认状态,通过传递一个{ [key]: value }对象,或者一个返回{ [key]: value }对象的函数,来将其作为 Field 的初始状态。

<Field $defaultState={{ $value: 'username' }} />
<Field $defaultValue="username" />

上面两者等效,其实表单控件的值只是状态里的一个字段$value

$validators

该属性可以设置表单控件的校验方式,同时支持同步和异步校验。它是 key: value 的对象形式,key 为校验类型标识,value 为校验函数。仅当校验函数返回 true 时,表示该项校验通过,否则其他值将会被当作错误信息保存到状态中。

异步校验:如果校验函数返回一个promise对象,则resolved表示校验通过,rejected则校验不通过,同时rejected返回的reason将会被当作错误信息保存到$error对象中。

异步校验时,状态里会有 $pending 用来表示正在异步校验。如果值快速变化,会触发多次异步校验,但是 Field 只会响应最后一次异步校验结果,前面没有结束的异步校验,无论结果是否通过,都会被忽略!!

异步校验不会被自动取消,你需要自己在校验函数实现时,确保被多次调用时,可以取消掉之前未结束的异步校验(例如未响应的 ajax 请求,需要abort掉它)。

特别注意: 仅仅设置了$validators,并不会触发校验,还需要设置匹配$validators中每一项的属性标识符,该属性的值会作为第二个参数传递给校验函数。

校验被调用,会传入三个值:value、attr、props

  • value 为当前 Field 的值
  • attr 为校验标识值
  • props 为当前传给 Field 的所有 props,还包括以下三个特殊的值:
    • props.$validError 表示当前校验中,前面已经校验出的错误信息(该属性为0.5.0新增)
    • props.$fieldutil 当前 Field 的$fieldutil对象(该属性为0.5.0新增)。
      • 该值为上一次渲染的状态,可以通过$fieldutil.$new()尝试获取最新渲染状态
    • props.$formutil 当前 Field 所属 Form 的$formutil对象。
      • 该值为上一次渲染的状态,可以通过$formutil.$new()尝试获取最新渲染状态
<Field
    required
    maxLength="5"
    disableChar="z"
    asyncCheck
    $validators={{
        required: value => !!value || '该项必填',
        maxLength: (value, len) => value.length <= parseInt(len) || '最少长度:' + len,
        disableChar: (value, char) => value.indexOf(char) === -1 || '禁止输入字符:' + char,
        /* 注意:下面这条规则将不会触发校验,因为我们没有给Field传递 minNumber 属性来表示需要去校验该条规则 */
        minNumber: (value, limit) => value > parseFloat(limit) || '输入值必需大于:' + limit,

        /* 异步校验 */
        asyncCheck: value =>
            axios.post('/api/v1/check_account', { account: value }).catch(error => Promise.reject(error.message))
    }}>
    {$fieldutil => (
        <div className="form-group">
            <label>密码</label>
            <input
                type="number"
                onChange={ev => $fieldutil.$render(ev.target.value.trim())}
                value={$fieldutil.$viewValue}
            />
            {$fieldutil.$invalid && <div className="error">{$fieldutil.$getFirstError()}</div>}
        </div>
    )}
</Field>

在这个例子中,我们通过$validators 设置了 requiredmaxLength 以及 disabledChar 的校验规则。同时通过属性 props 表示了需要校验这三个字段。然后我们可以通过状态判断将错误信息展示出来。

当然,也可以只在一个校验函数里校验多个规则,甚至混合异步校验:

<Field
    baseCheck
    $validators={{
        baseCheck(value) {
            //校验非空
            if (!value) {
                return '该项必填';
            }

            //校验输入长度
            if (value.length < 5) {
                return '最小输入五个字符';
            }

            //异步校验
            return axios
                .post('/api/v1/check_account', { account: value })
                .catch(error => Promise.reject(error.message));
        }
    }}
/>

$asyncValidators

v0.2.22 起,建议直接使用 $validators 即可,$validators 也支持了异步校验。不建议单独使用 $asyncValidators

该属性可以设置表单项的异步校验规则,设置方式与$validators类似。但是不同的是,异步校验函数需要返回promise对象,该promiseresolve表示校验成功,reject表示校验失败,并且rejectreason会被当作失败原因保存到状态的$error对象。

异步校验时,状态里会有$pending用来表示正在异步校验。

$validateLazy

该属性为 v0.5.0 新增。

默认情况下,每次 Field 的值改变,在调用设置的校验方法时,会将所有的校验函数都执行一遍。

通过该属性,可以设置调用校验函数时,启用懒校验模式:即是否遇到第一个错误后停止调用后续其它校验函数。校验顺序为$validators对象中的校验函数的声明顺序。所以如果你有异步校验,最好将其放到$validators声明的最后,以确保$validateLazy能有效节省不必要的校验。

如果你在考虑实现一组用于多数表单项的校验函数,那么建议将这些校验规则分开,然后通过传递对应到每个校验函数的标识符来在不同的 Field 上启用不同的校验,并且可以利用$validateLazy来使用懒校验,提升校验性能。

如果仅仅是对个别 Field 做校验,我们更加建议将多个校验规则,在一个校验函数里实现!这样可以更加自由的设定校验顺序以及逻辑。

$memo

该属性为 v1.0.0 新增。

关于如何使用 react-formutil 创建高性能表单,可以阅读这篇 高性能表单指南 了解更多。

type $memo = boolean | any[];

// false 默认值, 即不启用渲染优化
// true 启用渲染优化,深度比较Field的所有props和自身状态
// any[] 启用渲染优化,深度比较$memo依赖项数组和自身状态

第一原则

If the slowdown is noticeable? 是否遇到了明显的应用性能下降?

你可能并不需要$memo

react-formutil与其它追求表单性能的表单库不一样,它被设计为全局状态实时更新渲染,意在强调自然、易用、响应式,避免使用时额外的心智负担。大多数情况下,简单的表单的性能是可以满足需求的,那么就不需要刻意去优化。

与任何会影响 react 本身渲染过程的优化手段shouldComponentUpdate PureComponent React.memo等一样,都可能导致组件产生一些难以发现、追踪的运行 bug,或者导致未来的维护产生不易察觉的 bug。因为$memo本身也就是使用这些技术达到优化目的。

第二原则

$memo 应当应用于明显导致性能下降的 Field 组件

一般来说,表单性能变差(例如输入变卡顿),并不一定是当前的 Field 性能差,而是该Form下的某个其它组件rerender性能较差导致的。应当分析找出这些组件,如果它正好是Field组件,那么可以使用$memo优化;如果不是Field组件,可以使用 memo-render 优化,或者直接在组件内部使用shouldComponentUpdate优化。

背景

由于react-formutil的理念是表单控制器状态被实时追踪更新,所以当一个Field的状态变化,会引起整个Form的重新渲染,而这又会导致其它没有状态变化的Field也会跟着一起重新渲染。这种设计对于表单副作用相关的场景是友好的,比如Field的值可以随意相互依赖、整个表单组件上下文中可以随意自由访问表单控制器等。

但是这种灵活性在某些场景下,比如当表单Field元素显著增多,或者Field渲染了复杂的、较重的组件时,过于频繁的重复渲染会引起表单性能下降。理想的状态下,当然希望单个Field的渲染不要引起其它不相关的Field的重复渲染。但是实际上,Field与其组件的副作用是无法预知的,react-formutil运行于假设表单上下文中随时都出出现副作用的场景下。

但是这也不意味着我们没有手段去优化表单性能了,既然单个Field变化必然引起整个表单的渲染,那么我们从其它Field着手优化即可,即如果可以确认该Field不依赖其它表单Field,那么只要当它本身的 props 和自身的状态模型没有发生变化,就可以告诉 react 跳过渲染,以达到优化目的。

而这正是$memo的作用和原理!

这里有个简单示例:

/**
 * 例如,VeryHeavyComponent是一个渲染开销非常大的组件,那么我们通过指定$memo属性,来避免其它非自身Field的变化引起本组件无必要的变动
 * 该例子中,$memo=true的情况下,Field会深度比较自身的props以及自身的state状态,如果没有变化就不会重新渲染。
 * 但是请注意,如果Field有传递临时函数属性,可以明确通过$memo传递要比较的依赖项数组,请参考下一个示例。否则可能导致负优化,请阅读下方的“陷阱和不当操作”了解更多
 */
<Field name="username" $memo component={VeryHeavyComponent} />

//////////////////////////////////////////////////////////////////////////////////////////////////////////

/**
 * 这个例子中,因为Field的children属于局部临时函数,所以直接$memo=true也不会产生优化效果,所以我们可以传递一个依赖更新的值数组
 * 这有些类似Hooks中的useMemo、useCallback的第二个参数作用
 */
<Field name="username" $memo={[$formutil.$params.otherFieldValue]}>
    {$fieldutil => <VeryHeavyComponent someProp={$formutil.$params.otherFieldValue} />}
</Field>

/**
 * $memo=[] 则只会在自身Field状态变化时重新渲染!
 * 该例子中,即使otherFieldValue更新了,这个Field也不会重新渲染!
 */
<Field name="username" $memo={[]}>
    {$fieldutil => <VeryHeavyComponent someProp={$formutil.$params.otherFieldValue} />}
</Field>

一些陷阱和不当操作

由于函数是无法深度比较(deep diff)的,所以前后渲染时传递的临时函数变量总是会被认为是不相等的,这就会导致$memo的深度比较失败;或者传递了大数据值属性时,深度比较效率较低;这些状况都需要特别注意,不能贸然启用$memo,否则某些情况下将会导致负向优化,反而加重应用性能下降。

所以,针对以上情况:

  • Field 有临时函数属性时,例如children render $parser $formatter $validators等属性可能存在这种现象
    • 方法一,请将这些函数属性使用memoization优化,例如绑定到组件实例、使用 useCallback useMemo
    • 方法二,请使用 $memo={[...]} 明确指定要比较的可能变动的值依赖项数组,忽略掉这些临时函数属性
  • Field 具有大数据值属性时(即数据非常庞大,深度比较非常耗性能),并且其又不会变化,请使用 $memo={[...]} 明确指定要比较的可变值依赖项数组
/**
 * Bad
 * function children 和$parser都是临时创建的局部函数变量,会导致深度比较总是失败
 */
<Field name="username" $memo $parser={value => value.trim()}>
    {$fieldutil => <VeryHeavyComponent value={$fieldutil.$viewValue} onChange={$fieldutil.$render} />}
</Field>;

//////////////////////////////////////////////////////////////////////////////////////////////////////////

/**
 * Good
 * children和$parser使用useCallback创建^_^
 */
const $parser = useCallback(value => value.trim(), []);
const render = useCallback(
    $fieldutil => <VeryHeavyComponent value={$fieldutil.$viewValue} onChange={$fieldutil.$render} />,
    []
);
<Field name="username" $memo $parser={$parser}>
    {render}
</Field>

/**
 * Good
 * 也可以明确指定更新依赖值,例如这里确定$parser、children都不依赖其它状态值,直接指定 $memo=[]
 */
<Field name="username" $memo={[]} $parser={value => value.trim()}>
    {$fieldutil => <VeryHeavyComponent value={$fieldutil.$viewValue} onChange={$fieldutil.$render} />}
</Field>;

其它情况

$memo只能用于Field本身的优化,但是如果整个Form下有其它非用作表单项的重型组件(即没有嵌套在Field下),可以使用 memo-render 做优化。

关于如何使用 react-formutil 创建高性能表单,可以阅读这篇 高性能表单指南 了解更多。

最后,小提示:可以使用 chrome 的 React Devtool 的Profiler面板来测试页面的性能瓶颈;查看$memo优化是否生效;分析导致优化失败的 props。

$onFieldChange

当 Field 的值随着最近一次重新渲染完成后触发该回调。

由于 react 的渲染是异步的,所以如果存在交叉验证,例如 A 控件依赖于 B 控件的值去校验自身,那么这种情况下,B 的值变更并不会导致 A 立即去应用新的值去校验。所以这种情况下,可以通过该属性设置回调,主动去触发校验 A 控件。

注意:

  1. 该回调并不会在调用 $render $setValues 等更新表单值的方法后立即触发,它会随着最新的一次 react 渲染执行。也正因为此,所以才能拿到变更后的表单的值和状态。
  2. 仅当当前 Field 的值(状态里的$value)有变动时才会触发,其他状态例如$diry $touched 等变化不会触发。
  3. 如果需要访问 DOM Event,请使用 onChange 绑定 DOM 节点访问即可。
  4. 不要在该回调里再次修改当前 Field 的值,否则会陷入死循环(修改该 Field 的其它状态或者修改其它 Field 的值是安全的)。
//在B的值变更并且渲染完毕后,主动再次要求A组件进行一次校验
<Field name="B" $onFieldChange={(newValue, preValue) => $formutil.$getField('A').$validate()}>
    //...
</Field>

$reserveOnUnmount

该属性为 v0.5.1 新增。

默认情况下,当一个Field被从组件树移除时(componentWillUnmount),会从Form控制器中取消注册,这将会导致该 Field 的状态从表单控制器状态集合中移除(例如,$params $errors等中不再有该 Field 的值、错误信息等)

如果你希望 Field 移除时,在 Form 控制器中保留该 Field 的状态,那么可以传递$reserveOnUnmount属性为true即可。当该 Field 再次挂载到组件树中时,会继承之前所有的状态,完全恢复!

<Field name="username" $reserveOnUnmount />
// OR
<Field name="username" $reserveOnUnmount={true} />

$parser

这里介绍的是针对0.5.0以后版本。如果你在使用之前的版本,请参考:$parser

当用户在表单中进行输入时(主动更新视图),视图中的值在更新到状态模型中前,会经过 $parser 处理。

// 通过$parser属性来过滤前后输入空格
<Field name="fieldName" $parser={(viewValue, $setViewValue) => viewValue.trim()}>
    //...
</Field>

注意,上述写法不会修改当前视图值,它仅仅影响状态模型中的值。如果希望限制用户的输入(例如禁止用户输入任意空格),可以通过$parser的第二个参数$setViewValue,来在用户每次输入后立即更新视图值。

// 通过$parser属性来过滤前后输入空格
<Field name="fieldName" $parser={(viewValue, $setViewValue) => $setViewValue(viewValue.trim())} />

$formatter

这里介绍的是针对0.5.0以后版本。如果你在使用之前的版本,请参考:$formatter

当在表单模型中主动更新模型值时,会通过 $formatter 将模型中的值转换为$viewValue后传递给视图渲染。

// 通过$formatter将模型中的值转换为标准的金额书写格式
<Field name="amount" $formatter={(value, $setModelValue) => priceFormat(value)} />

$formatter同样有一个回调方法$setModelValue,它可以用来在处理模型值时再次对其进行修改。

$ref

该属性为 v0.5.11 新增。

可以通过该属性传递一个回调函数或者一个RefObject对象,用来获取该 Field 的$fieldutil对象,以在其 context 外部访问:

let curFieldutil;
<Field name="username" $ref={$fieldutil => (curFieldutil = $fieldutil)} />;

const ref = React.createRef();
<Field name="username" $ref={ref} />;

其用法类似与 React 组件本身的 ref 属性用法,但是与ref={Function}不同的时,由于$fieldutil是一个每次 render 都会重新生成的Immutable对象,所以传递给$ref的回调函数也会随着每次 render 被调用。 所以,不要在回调函数里做任何有副作用的操作!

$fieldutil

$fieldutil 包含了当前Field对象的状态模型以及一组用来更新状态模型的方法。它会被传递给视图组件用来同步和更新表单的状态值。

{
    $value: "", //表单域状态模型值
    $viewValue: "", //表单域视图值,$value和$viewValue可以通过$parser或者$formatter相互转换
    $dirty: false, //是否修改过表单项
    $pristine: true, //与$dirty相反
    $touched: false, //是否接触过表单
    $untouched: true, //与$touched相反
    $focused: false, //是否聚焦到当前输入
    $valid: true, //表单项校验结果是否通过
    $invalid: false, //与$valid相反
    $error: {}, //表单校验错误信息
    $pending: false, //异步校验时该值将为true

    /*** 上面是状态模型,下面是可用方法 ***/

    $getState: () => $state, //返回当前状态模型对象
    $reset: ($newState) => $state, //重置为初始状态, $newState存在的话,会做一个合并
    $getComponent: (name) => FieldComponent, //返回Field组件实例

    $render: (value, callback) => {}, //更新表单域视图值,callback可选,会在组件更新后回调
    $setValue: (value, callback) => {}, //直接更新表单域模型值,callback可选。$setValue与$render的区别在于,前者的值会经过$parser处理后再更新到表单模型中,后者则不会。
    $setDirty: $dirty => {}, //设置$dirty装态
    $setTouched: $touched => {}, //设置$touched装态
    $setFocused: $focused => {}, //设置$focused装态
    $setState: $newState => {} //直接更新状态,其实上面的几个方法都是基于$setState
    $setValidity: ($key, $valid) => {} //设置校验, $valid为true代表校验通过,其它值表示校验失败,并当作错误原因
    $setError: ($error) => {} //直接设置错误状态
    $validate: () => {} //触发再次校验

该对象会传递给子组件,子组件可以利用其中的方法来同步、修改表单域状态模型:

  • 用户输入时需要通过调用$render来更新新值到状态中
  • 渲染表单项时,应该使用受控组件,并且根据状态模型中的 $viewValue 来渲染值(不建议使用$value来渲染视图,因为这样就无法使用$parser $formatter来对数据做二次过滤)
  • 错误信息和校验状态可以通过 $dirty $invalid $error 等来渲染

需要强调的是,Field 默认不同步$touched/$untouched$focused 状态,只有$dirty/$pristine会自动同步(首次调用$render会自动同步$dirty状态) 如果你需要其它状态,需要自己去绑定相关事件来更新状态:

<Field name="username">
    {$fieldutil => (
        <input
            value={$fieldutil.$viewValue}
            onChange={ev => $fieldutil.$render(ev.target.value)}
            onFocus={ev => $fieldutil.$setFocused(true)}
            onBlur={ev => $fieldutil.$setTouched(true) && $fieldutil.$setFocused(false)}
        />
    )}
</Field>

下面是$fieldutil中属性的更多解释:

$value

当前表单域的状态模型值。从表单控件中获取的值保存在该字段下。该值会被同步到整个表单的$params中。

$viewValue

该属性为 v0.5.0 新增

当前表单域的视图值。一般情况下其等同于$value

当你自定义了$parser时,会导致视图值与表单值不一致,此时渲染视图时应当使用$viewValue来渲染。

事实上,当你需要根据表单值更新 Field 视图时,你应当总是使用 $viewValue 来代替 $value,这总是安全的!

$dirty | $pristine | $touched | $untouched | $invalid | $valid | $focused | $pending

当前表单域的其它状态:

  • $dirty 控件被修改过
  • $pristine 控件没有被修改过,与$dirty 互斥
  • $touched 控件失去过焦点
  • $untouched 控件没有失去过焦点
  • $focused 焦点是否在当前控件
  • $valid 表单所有控件均校验通过
  • $invalid 表单中有至少一个控件校验不通过
  • $pending 是否正在进行异步检查
$error

保存了当前表单域的错误信息。它是一个{ [validdate key]: [error message] }对象。

当没有任何错误信息时,它是一个空对象。所以,需要判断当前表单域是否有错误时,应当通过$invalid $valid来判断!

$new()

该属性为 v0.5.0 新增。

获取最新的$fieldutil

每一次渲染后,Field传递的$fieldutil对象都是当前的状态的快照。当异步或者回调方法中传递$fieldutil对象,拿到的可能与最新的状态不一致。可以通过该方法获取到最新一次渲染后的$fieldutil对象!

$getState()

该属性为 v0.5.0 新增。如果你在使用旧版本,请使用$picker()代替。

返回 Field 的纯粹状态(不包含任何下方的方法)

$reset()
// 其函数签名如下
// 0.5.0起,同时支持参数回调,以及Promise回调
$reset($newState?: Partial<FieldState>, ($fieldutil: $Fieldutil) => void): Promise<$Fieldutil>;

重置当前表单域名为初始状态,即所有的$value $viewValue $dirty等状态都会恢复为初始状态。

$getComponent()

获取当前表单域的实例对象引用(虚拟 dom)

$setState($newState)
// 其函数签名如下
// 0.5.0起,同时支持参数回调,以及Promise回调
$setState($newState: Partial<FieldState>, ($fieldutil: $Fieldutil) => void): Promise<$Fieldutil>;

设置新的$state$newState会与当前$state合并

$setState({
    $dirty: true,
    $value: '124'
});
$render()
// 其函数签名如下
// 0.5.0起,同时支持参数回调,以及Promise回调
$render($viewValue: any, ($fieldutil: $Fieldutil) => void): Promise<$Fieldutil>;

更新表单域的视图值,并且该值会经过$parser处理后更新到表单域的状态模型中。

另外如果该表单域模型状态中的$dirtyfalse,也会同时将$dirty设置为true$pristinefalse)。

提醒 当从表单控件中同步值时,应当使用$render,而不是$setValue!

$setValue()
// 其函数签名如下
// 0.5.0起,同时支持参数回调,以及Promise回调
$setValue($value: T, ($fieldutil: $Fieldutil) => void): Promise<$Fieldutil>;

更新表单域的模型值,并且该值会经过$formatter后更新到视图上。

$setDirty($dirty) | $setTouched($touched) | $setFocused($focused) | $setValidity(errKey, result)
// 其函数签名如下
// 0.5.0起,同时支持参数回调,以及Promise回调
$setDirty($dirty: boolean, callback?:($fieldutil: $Fieldutil) => void): Promise<$Fieldutil>;
$setTouched($dir$touchedty: boolean, callback?:($fieldutil: $Fieldutil) => void): Promise<$Fieldutil>;
$setFocused($focused: boolean, callback?:($fieldutil: $Fieldutil) => void): Promise<$Fieldutil>;
$setValidity(validKey: string, result: any, callback?:($fieldutil: $Fieldutil) => void): Promise<$Fieldutil>;

设置$dirty $touched $error 等状态

$setDirty(true);
$setTouched(true);
$setFocused(true);
$setValidity('required', '必需填写'); //第二个参数不为true,则表示校验失败,并当作错误描述
$setValidity('required', true); //表示校验通过
$setError($error)
// 其函数签名如下
// 0.5.0起,同时支持参数回调,以及Promise回调
$setError($error: FieldError, callback?: ($fieldutil: $Fieldutil) => void): Promise<$Fieldutil>;

直接替换当前表单域的$error

$setError({
    required: '必需填写',
    maxLength: '不能超过10个字符'
});
$validate()
// 其函数签名如下
// 0.5.0起,同时支持参数回调,以及Promise回调
$validate(callback?: ($fieldutil: $Fieldutil) => void): Promise<$Fieldutil>;

手动触发校验 Field。一般情况下,你无需这么做,当值改变时,会自动调用设定的校验函数。仅当你的校验函数依赖于其它值时,在其它值改变时,你可以通过该方法手动触发校验。

该方法可以传递一个回调函数,或者通过其返回值 Promise 来监听校验完成。

请注意 当你手动运行了校验函数时,如果其中包含异步校验,在校验完成前,Field 的值可能再次发生变化,那么会导致校验重新运行。此时,回调函数以及 Promise 回调都将延迟到最后一次校验完成后触发,并且会保持你的调用顺序!

$onValidate()
// 其函数签名如下
// 0.5.1起,同时支持参数回调,以及Promise回调
$onValidate(callback?: ($fieldutil: $Fieldutil) => void): Promise<$Fieldutil>;

确保当前 Field 校验结束后进行回调操作。因为如果 Field 有异步校验,在你更改了 Field 的值后,可能想在校验结束后,根据 Field 最新的状态来做一些操作,此时可以就使用该方法。

$getFirstError()

获取当前表单域的错误项中的首个错误描述。由于$error是个对象,所以这里提供了一个方法简化错误信息的获取。

<Field>
    {$fieldutil => (
        <div>
            <input value={$fieldutil.$viewValue} onChange={ev => $fieldutil.$render(ev.target.value)} />
            {$fieldutil.$invalid && <p className="error">{$fieldutil.$getFirstError()}</p>}
        </div>
    )}
</Field>
$$formutil

当前表单域所属的 Form 的$formutil对象,它包含了整个表单的状态以及一些操作方法,具体可以参考下方 Form 说明。

特别注意,这里$$formutil是双$符号打头,表示不推荐使用。绝大多数情况下,对当前表单域的访问应当通过$fieldutil来完成状态的获取与收集。

<Field name="username">
{ $fieldutil => <input onChange={ev => $fieldutil.$render(ev.target.value)} onFocus={ev => $fieldutil.$$formutil.$validates()} />
</Field>

withField(Component)

特别注意:v0.4.0 版本起,withField将会把状态和方法都放到$fieldutil对象中传递给被装饰的组件!!这与之前的方式有所区别,请留意。

withField 是一个高阶组件,它基于Field组件实现。可以通过 withField 的第二个可选参数来为生成的表单域组件设置默认的 props!

import React from 'react';
import { withField } from 'react-formutil';

class FieldCustom extends React.Component {
    onChange = ev => this.props.$fieldutil.$render(ev.target.value);

    render() {
        return <input onChange={this.onChange} value={this.props.$fieldutil.$viewValue} />;
    }
}

export default withField(FieldCustom, {
    $defaultValue: '1234' // 该项将传递给Field组件
});

withField同样支持装饰器语法

@withField
class MyField extends Component {}

//or pass some default props
@withField({
    $defaultValue: '123'
})
class MyField extends Component {}

<EasyField />

我们深知提供的<Field />只是底层控制,并不能直接转换为生产力。实际使用中,还需要使用Field来适配自己项目所用的表单 UI 组件

所以我们也提供了一个EasyField组件,它通过Field将浏览器支持的原生表单控件都实现了支持,并且支持多选组和单选组:只需要指定type属性就可以使用了

同时,<EasyField />也提供了统一的值变动与获取绑定的 API(通过标准的value onChange onFocus onBlur等属性,大部分流行的组件库,例如ant-designdata-entry组件都实现了这种统一、规范的对外访问方式)。

特别提醒:EasyField会默认对所有的字符串输入做前后空格的过滤。如果不需要这个特性,可以通过重写$parser属性或者将其设置为null来关闭该功能:

<EasyField name="name" type="username" $parser={value => value} />
// OR
<EasyField name="name" type="username" $parser={null} />

$fieldHandler

Field向下传递$fieldutil对象类似,EasyField也会向下传递一个$fieldHandler的对像。

$fieldHandler$fieldutil是不同的,它是一个标准的包含value onChange onFocus onBlur四个属性的data-entry交互规范 API。当然,你也可以通过指定 valuePropname changePropName focusPropName blurPropName 属性来修改暴漏的接口方法属性名。

这意味着,所有支持这四个属性(或者部分支持)的组件,都可以嵌套/传递给EasyField使用!

// $fieldHandler的默认结构。通过指定valuePropName changePropName或者passUtil属性,都会影响实际的$fieldHandler中的值。
// value 表单项的值
// onChange 值变动回调,更新值到表单控制器中
// onFocus 用来同步$focused状态
// onBlur 用来同步$focused $touched等状态
{
    value, onChange, onFocus, onBlur;
}

之所以会有这么一个$fieldHandler对象,是因为Field提供的$fieldutil太抽象,无法直接对接各种原生表单控件和第三方表单组件。而$fieldHandler则只包含标准的value onChange onFocus onBlur等属性,可以放心的直接传递给支持的组件。

<EasyField /> 支持所有<Field />组件所接受的属性参数,可以用来指定该表单项的name、默认值、校验规则,以及使用$parser $formatter做值的过滤转换等。

它主要提供了两种调用方式:

渲染原生表单控件

EasyField 支持一个特殊的type属性,类似浏览器表单控件的type属性。如果传递了type属性,就默认会渲染浏览器原生控件。

当设置了 type 时,EasyField 将会尝试直接渲染浏览器表单元素。它支持以下类型:

  • input[type=text]
  • input[type=number]
  • input[type=search]
  • input[type=password]
  • input[type=checkbox]
  • input[type=radio]
  • input[...]
  • select
  • textarea
  • group.radio
  • group.checkbox

EasyField 对亚洲语言(中文、韩文、日文)输入法在输入过程中的的字母合成做了处理

一些调用示例:

input

事实上 type 值只要不是 selct textarea checkbox radio group.xxx 时都是渲染普通 input 输入框,并且 type 值会传给该 input。

<EasyField name="name" type="text" />
<EasyField name="pwd" type="password" />
<EasyField name="email" type="email" />
<EasyField name="search" type="search" />
<EasyField name="number" type="number" />

<EasyField name="comment" type="textarea" cols="8" rows="10" />
select

下拉列表可以将后选项当作子节点直接传递就行,就像普通的 select 标签一样!

<EasyField name="age" type="select">
    <option value="20">20</option>
    <option value="30">30</option>
</EasyField>
checkbox/radio

单选/多选还可以传递 checkedunchekced 属性,用来覆盖选中/未选中状态下所对应的值

<EasyField name="agree" checked="yes" unchecked="no" type="checkbox" />
<EasyField name="agree" type="raido" />
checkbox/radio group

type 值为 group.xxx 为渲染输入控件组,当前仅支持group.checkbox group.radio。它会向函数式子节点传递 GroupOption 属性,用来渲染单个后选项。每个后选项的值通过 $value 属性指定。

此时支持额外的属性groupNode,默认为'div',渲染一个空的 div 标签。react@16以上版本可以设置groupNode={null}来禁止渲染空的 div 节点

<EasyField type="group.checkbox" name="targets" required validMessage={{ required: '请至少选择一项' }}>
    {({ GroupOption }) =>
        this.targets.map(item => (
            <label key={item.id} className="checkbox-inline">
                <GroupOption $value={item.id} className="checkbox" /> {item.name}
            </label>
        ))
    }
</EasyField>

渲染自定义组件

如果不指定type属性,那么 EasyField 将会尝试通过 children | render | component 三个属性来渲染你传递的自定义组件。

Field向下传递$fieldutil对象类似,EasyField也会向下传递一个$fieldHandler的对象。

$fieldHandler$fieldutil是不同的,它是一个标准的包含value onChange onFocus onBlur是个属性的data-entry交互规范 API。当然,你也可以通过指定 valuePropname changePropName focusPropName blurPropName 属性来修改暴漏的接口方法属性名。

这意味着,所有支持这四个属性(或者部分支持)的组件,都可以嵌套/传递给EasyField使用!比如前面我们提到的通过type属性来渲染原生表单控件,其实还可以这么调用:

原生表单控件

普通文本输入

<EasyField name="username">
    <input type="text" />
</EasyField>

<EasyField name="pwd">
    <input type="password" placeholder="Password" />
</EasyField>

<EasyField name="select">
    <select>
        <option value="">Select</option>
        <option value="1">Option 1</option>
    </select>
</EasyField>

渲染复选框

因为input[type=checkbox]input[type=radio]是通过节点的checked属性来访问其是否被选中的状态的,所以我们可以传递一个valuePropName,来表示从节点中收集该属性值,而不是value

<EasyField name="username" valuePropName="checked">
    <input type="chekcbox" />
</EasyField>

上述代码,拿到的值是truefalse。如果希望能获取到其它值,我们可以象使用Field渲染时一样,只需要稍微改造下传递给onChange时的值就好了。比如这样:

// 这里只是举例,实际中不推荐大家这么调用
// <EasyField type="checkbox" checked="yes" unchecked="no" />
<EasyField name="username">
    {({ onChange, value }) => (
        <input type="checkbox" checked={value === 'yes'} onChange={ev => onChange(ev.target.checked ? 'yes' : 'no')} />
    )}
</EasyField>
列表数组

0.5.5起,EasyField新增支持type="list",可以用来方便的实现列表数组表单,即将一组表单以数组形式组合渲染:

[
    {
        username: 'xx',
        age: 18
    },
    {
        username: 'xx',
        age: 22
    }
    // ...
];

在该模式下,你需要传递一个render props形式的children,该函数中所渲染的表单将会被作为数组的值:

查看在线示例

<EasyField name="relationships" type="list">
    {($listutil: $Listutil) => {
        return (
            <>
                <div className="relationship-item">
                    <EasyField name="relation" type="select">
                        <option value="">select</option>
                        <option value="0">Father</option>
                        <option value="1">Mother</option>
                    </EasyField>

                    <EasyField name="name" placeholder="The name" />

                    <button onClick={() => $listutil.$remove($listutil.$index)}>Delete</button>
                </div>
                {$listutil.$isLast() && (
                    <div className="relationship-toolbar">
                        <button onClick={() => $listutil.$push()}>Add new</button>
                    </div>
                )}
            </>
        );
    }}
</EasyField>

如上示例,你将会得到一个可以自由增删的列表形式表单,它将会渲染下面结构的$params

// $params =
{
    relationships: [
        {
            relation: '0',
            name: 'John'
        },
        {
            relation: '1',
            name: 'Clare'
        }
    ];
}
$listutil

当你传递一个render props函数时,它将会接受两个参数:

  • $listutil 为每个数组子表单的$formutil对象,另外扩展了一些其它用于列表渲染的方法
  • $formutil 为整个数组表单的$formutil对象
// $listutil =
{
    ...$formutil, // 包含当前数组表单项的$formutil

    $length, // 数组表单项数量
    $index, // 当前表单的次序
    $insert(pos?: number, values?: object, callback?: Function), // 在pos位置新增,如果pos不指定,则为在当前列表末尾新增。如果指定values,则作为新增项的默认值
    $remove(pos?: number, callback?: Function), // 删除pos位置项,如果pos不指定,则为删除当前列表最后一项
    $push(values?: object, callback?: Function), // 在列表尾部新增。如果指定values,则作为新增项的默认值
    $pop(callback?: Function), // 删除列表最后一项
    $shift(callback?: Function), // 删除列表第一项
    $unshift(values?: object, callback?: Function) // 在列表前面增加。如果指定values,则作为新增项的默认值

    $isLast(), // 是否最后一项
    $isFirst(), // 是否第一项

    onFocus(), // $fieldHandler的onFocus回调,可以传递给渲染的Field组件,用来同步`$focused` `$touched`等状态
    onBlur()  // $fieldHandler的onBlur回调,可以传递给渲染的Field组件,用来同步`$focused` `$touched`等状态
}

你可以使用$listutil提供的方法,来渲染一些控制按钮,以控制列表项。但是需要注意以下几点:

  • 列表数组无法删除为0,如果你尝试删除最后一项,那么会删除后自动创建一个新的项。
  • children方法会随着列表数组的数量渲染n次,你可以通过$isFirst() $isLast()方法判断是否是第一项 末项,来控制一些不希望被多次重复渲染的内容:比如新增按钮
第三方组件

我们只需要通过 children | render | component 三个属性,来支持根据传递的$fieldHandler来渲染以及更新值就可以了。

社区提供了很多优秀的组件库,我们要使用他们也很简单。

例如,与 ant-design 进行交互:

// antd的Input实现了标准的value onChange接口
import { Input, Switch } from 'antd';

<EasyField name="username">
    <Input />
</EasyField>;

<EasyField name="switch" $defaultValue={true}>
    <Switch />
</EasyField>;

react-select 进行交互:

// react-select也实现了标准的value onChange接口
import Select from 'react-select';

// 因为Field默认值都是空字符串,react-select不接受字符串,所以我们传递默认值为空undefined
<EasyField name="react-select" $defaultValue={undefined}>
    <Select options={options} />
</EasyField>;

假如第三方的组件没有支持 value onChange等属性接口,那么也可以根据实际情况,通过指定valuePropName changePropname等或者通过给childrenrender传递渲染方法,然后在自定义方法里指定如何渲染即可:

// 假设我们要使用TheThirdlyComponent这个组件渲染表单,但是其接受值的属性名为renderValue,值变动的回调属性名为onValueChange
<EasyField name="custom" valuePropName="renderValue" changePropName={onValueChange}>
    <TheThirdlyComponent />
</EasyField>;

// 也可以这样
<EasyField name="custom">
    {$handler => {
        return <TheThirdlyComponent renderValue={$handler.value} onValueChange={value => $handler.onChange(value)} />;
    }}
</EasyField>;

name

Fieldname

$defaultValue

Field$defaultValue

$defaultState

Field$defaultState

$validators

Field$validators

但是请注意,EasyField内置了一些常用的校验方法,例如:

  • required 必填,如果是 group.checkbox,则必需至少选中一项 required
  • maxLength 。最大输入长度,支持 group.checkbox。有效输入时才会校验 maxLength="100"
  • minLength 最小输入长度,支持 group.checkbox。有效输入时才会校验 minLength="10"
  • max 最大输入数值,仅支持 Number 比较。有效输入时才会校验 max="100"
  • min 最小输入数值,仅支持 Number 比较。有效输入时才会校验 min="10"
  • pattern 正则匹配。有效输入时才会校验 pattern={/^\d+$/}
  • enum 枚举值检测。有效输入时才会校验 enum={[1,2,3]}
  • checker 自定义校验函数。checker={value => value > 10 && value < 100 || '输入比如大于10小与100'}

注:校验属性的值为 null 时表示不进行该校验

小技巧:你可以利用checker很便捷的完成自定义校验,不需要validMessage $validators

<EasyField checker={value => {
    if (!value) {
        return 'Required!';
    }

    if (value.length < 6) {
        return 'minlength: 6';
    }

    return true; // no error
}}

你可以通过直接给EasyField传递相应的校验规则标识符来启用对应的校验规则。

当你给EasyField传递$validators时,它会与内置的校验方法进行合并,并且会覆盖同名的默认校验方法。当内置的几种校验方法不能满足需求时,可以使用像Field$validators属性一样指定自定义校验。

如果你已经了解了默认支持 checker 校验属性,我们建议自定义校验逻辑都直接通过该方式实现

$validateLazy

Field$validateLazy

$memo

Field$memo

$asyncValidators

Field$asyncValidators

v0.2.22 起,建议直接使用 $validators 即可,$validators 也支持了异步校验。不建议单独使用 $asyncValidators

$parser

Field$parser

EasyField默认启用了对字符串值过滤前后空格。如果你不需要这个特性,可以通过将该属性设置为null或者覆盖实现来关闭这个设置。

$formatter

Field$formatter

defaultValue

注意,这个是省略前面的$符号。如果与$defaultValue同时存在,则会被后者覆盖。

validMessage

仅对使用内置校验规则有效。如果自定义校验要支持该属性,需要实现校验函数时支持该属性

可以通过该属性,设置内置的校验方法的错误信息展示:

<EasyField
    name="useraname"
    required
    maxLength="10"
    validMessage={{
        required: '必需填写',
        maxLength: '最多输入十个字符'
    }}
/>

checked / unchecked

仅对指定了type值的原生控件渲染有效

如果是 checkbox 或 radio,则可以设置该属性,表示选中/未选中所代表的值。默认为 true 和 false。

//这里可以设置选中、未选中用yes和no表示
<label>
    <EasyField type="checkbox" name="remember" checked="yes" unchecked="no" /> 是否同意用户协议
</label>

valuePropName changePropName focusPropName blurPropName

当不设置 type 属性,而使用自定义渲染时,如果组件的值以及值变动触发的更新回调方法不是默认的 value、onChange、onFocus、onBlur,可以通过这些参数更改:

function MyComponent({ current, onUpdate }) {
    return <button onClick={() => onUpdate(124)}>更新</button>;
}

<label>
    <EasyField component={MyComponent} valuePropName="current" changePropName="onUpdate" /> 是否同意用户协议
</label>;

getValueFromEvent()

该属性从v0.6.11开始支持

某些情况下,一些特殊的自定义组件,其onChange或者changePropName所对应的值改变时的回调方法,会传入非标准的 value 参数。我们可能需要特殊的处理从这些回调参数中获取我们需要的 value 值(这种情况下valuePropName无法满足所需)。

此时,可以通过getValueFromEvent来处理这种复杂情况。getValueFromEvent所接受的参数与底层组件的onChange所传递的参数一致。

// 例如,我们有一个组件,其onChange回调方法会接受两个参数,而第二个参数才是真正的值
<ComplexDataInOnChange /* onChange={(arg1, theRealValue) => {}} */ />

// 当与EasyField共同使用时
<EasyField name="amount" getValueFromEvent={(arg1, theRealValue) => theRealValue}>
    <ComplexDataInOnChange />
</EasyField>

passUtil

默认情况下,EasyField给自定义组件传递的属性中,不包括当前表单项组件的$fieldutil对象。

如果使用自定义组件时,如果需要访问当前 Field 的状态,可以通过设置该参数true,或者传入一个字符串,EasyField 会将$fieldutil通过该参数值传递给自定义组件:

<EasyField name="custom" passUtil="$fieldutil">
    {({ $fieldutil, onChange, value }) => {
        return <input className={$fieldutil.$invalid ? 'has-error' : ''} onChange={onChange} value={value} />;
    }}
</EasyField>

<Form />

Form 是一个标准的 react 组件,它的调用方法与 Field 类似。一个表单应当只具有一个顶层Form,它下面可以包含多个Field域。

Form 通过 $formutil 来与其内部的各个Field做状态模型的注册、收集与同步。它会基于每个Fieldnmae属性,来将其作用域下的所有的Field的状态模型,统一收集处理。

所有传递给 Form 组件或者函数,会在其 props/arguments 中接收到一个$formutil对象,它提供了多种状态集合以及对表单的一些操作方法。例如

  • 你可以通过$formutil.$params 拿到整个表单的输入值
  • 你可以通过$formutil.$invalid$formutil.$valid 来判断表单是否有误
  • 你可以通过$formutil.$errors 来获取表单的错误输入信息

$formutil的更多解释请参考:$formutil

Form 可以接收以下可选属性参数:

render | component

该属性为可选,当使用function as child方式时,可以不传该属性。如果设置了该属性,则其会覆盖掉function as child方式。

如果rendercomponent 同时存在,则后者会覆盖前者。

<Form
    render={$formutil => {/* ... */} />}
/>

<Form
    component={MyForm}
/>

$defaultValues

0.5.4起,$defaultValues也可以传递一个函数,该函数接收所有传递给 Form 的 props,然后返回的{ [name]: defaultValue }对象。类似react-redux中的mapPropsToState用法。

$defaultValues 可以通过传递一个 { [name]: defaultValue }对象,或者传递一个返回 { [name]: defaultValue }对象的函数,来将其作为表单的初始化值。

$defaultValues 的优先级高于 Field 自身的 $defaultValue 设置。

<Form
    $defaultValues={{
        username: 'qiqiboy'
    }}>
    {$formutil => (
        /* const { $params, $invalid, $errors, ...others } = $formutil; */
        <div>
            <Field name="username">{props => <input />}</Field>
            <Field name="password">{props => <input />}</Field>
        </div>
    )}
</Form>;

// 或者使用withForm
withForm(MyForm, {
    $defaultValues(props) {
        return {
            username: props.username
        };
    }
});

$defaultStates

0.5.4起,$defaultStates也可以传递一个函数,该函数接收所有传递给 Form 的 props,然后返回的{ [name]: defaultState }对象。类似react-redux中的mapPropsToState用法。

$defaultStates 可以通过传递一个 { [name]: defaultState }对象,或者传递一个返回 { [name]: defaultState }对象的函数,来将其作为表单的初始化状态

$defaultStates 的优先级高于 Field 自身的 $defaultState 设置。

<Form
    $defaultStates={{
        username: {
            $dirty: true
        }
    }}>
    {$formutil => (
        /* const { $params, $invalid, $errors, ...others } = $formutil; */
        <div>
            <Field name="username">{props => <input />}</Field>
            <Field name="password">{props => <input />}</Field>
        </div>
    )}
</Form>;

// 或者使用withForm
withForm(MyForm, {
    $defaultStates(props) {
        return {
            username: props.usernameState
        };
    }
});

$onFormChange

该属性可传入一个函数,当表单的值有变动时,会触发该回调,新的$formutil 对象和本次变动的新旧值的集合会依次以参数形式传递:

注意:

  1. 该回调不是随用户修改同步触发,它随 react 的最新的一次渲染完成触发。
  2. 请避免在该回调里不加条件的一直去变更表单项的值,否则可能陷入死循环(因为表单值变更即会导致该回调重新触发)。
<Form $onFormChange={($formutil, newValues, preValues) => console.log($formutil, newValues, preValues)}>//...</Form>;

//当表单值有变更时,将会打印:
//$formutil
{
    $params: {},
    $states: {},
    $invalid: false,
    $valid: true,
    //...
    $setStates: () => {},
    $getField: () => {},
    //...
}
//newValues
{
    username: 'new value';
}
//preValues
{
    username: 'pre value';
}

$validator

:该属性为v0.5.0新增!

现在你可以通过$validator属性,来直接对整个表单值进行校验了。当表单值更新时,会调用该校验函数,然后根据其返回值更新表单的校验结果。

其函数签名如下(如何使用typescript开发?):

($params: FormParams<Fields>, $formutil: $Formutil<Fields, Validators, WeakFields>) => FormValidateResult<Fields>;

Field$validators有以下区别:

  • Form$validator仅当表单值有变动时才会调用,而Field$validators则会每次更新Field的值时都会调用(即使前后两次值相同)。
  • Form$validator是在表单值稳定下来后才会调用(异步),而Field$validators则是与更新值是同步调用。
    • 所以$validator非常适合用来校验那些互相依赖的字段,例如两次密码输入是否一致
  • Form$validator校验结果应当以{ [ Field name ]: 'error message' }形式返回,或者包在promise对象中以 rejected 状态返回。
    • { username: 'error message', 'nestedObj.username': 'error message', nestedArray: [ 'error message' ] }

例 1: 校验密码是否一致

<Form
    $validator={$params => {
        if ($params.password !== $params.confirm_password) {
            return {
                password: 'The twice passwords are not equal.'
            };
        }
    }}
/>

例 2: 异步校验用户名是否重复

<Form
    $validator={async function($params) {
        cosnt result = await asyncCheckUsername($params.username)
        if (result.isReplica) {
            throw {
                username: 'The username has exist.'
            }
        }
    }}
/>

例 3: 返回多个字段校验结果

<Form
    $validator={$params => {
        const errors = {};

        if ($params.password !== $params.confirm_password) {
            errors.password = 'The twice passwords are not equal.';
        }

        if (isEmail($params.email) === false) {
            errors.email = 'Wrong email!';
        }

        return errors;
    }}
/>

虽然我们提供了这个属性用于表单整体校验,但是我们依然建议校验应该基于每个<Field />进行来作为最佳实践!

$processer

:该属性为v0.5.0新增!

$processer 可以用来对表单域项的$state做进一步的加工!在这里对$state做的修改都将影响到最终的表单状态!所以请慎用!

Form控制器提取每个表单项的状态模型,汇总到$formutil中时,会将每个域的状态模型以及其name值传递给$processer函数,该函数可以对$state 进行修改、加工!

但是,请注意,这里对$state的修改,不会影响到表单项的实际的状态模型!

/**
 * @param $state: object 该表单域项的状态模型对象,{ $value, $valid, $invalid, $dirty, ... }
 * @param name: string 该表单域项的name,例如:'username'
 */
function $processer($state: FieldState<T>, name: string) {
    // process $state
}

Form 在收集表单域的值时,是从$state.$value中获取的;但是如果$value不存在,或者其值是undefined && $state.$dirty也是true时,则会忽略该值!!

如果你了解以上信息,可以通过$processer方法,来对表单域的值做进一步的加工或过滤!

例如,当某些值不想被收集到$params中时,可以通过$processer来将其删除!

// 将某些字段的对象值转换为字符串
<Form $processer={($state, name) => {
    // userInfo为一个对象值,我们将其转换为json字符串
    if (name === 'userInfo') {
        $state.$value = JSON.stringify($state.$value);
    }
}} />

// 过滤掉所有值为Null或者Undefined的字段
<Form $processer={($state) => {
    if ($value === undefined || $value === null) {
        // 删除该值
        delete $state.$value;
    }
}} />

// 强制所有的值都收集。通过将所有的$dirty都设置为true,来强制收集所有的值!
// 这里只是举例,实际中都不需要这么做!
<Form $processer={($state) => {
    $state.$dirty = true;
}} />

$ref

该属性为 v0.5.11 新增。

可以通过该属性传递一个回调函数或者一个RefObject对象,用来获取该 Field 的$formutil对象,以在其 context 外部访问:

let $formutil;
<Form $ref={$formutil => ($formutil = $formutil)}>{/* ...*/}</Form>;

const $formutilRef = React.createRef();
<Form $ref={$formutilRef}>{/* ...*/}</Form>;

其用法类似与 React 组件本身的 ref 属性用法,但是与ref={Function}不同的时,由于$formutil是一个每次 render 都会重新生成的Immutable对象,所以传递给$ref的回调函数也会随着每次 render 被调用。 所以,不要在回调函数里做任何有副作用的操作!

$formutil

$formutil 前面我们提到了,它是Form组件基于其组件树下的所有Field的状态模型,经过收集整理后返回的关于整个表单的状态模型集合。另外它也包含了一组用于操作整个表单的方法。

具体每个状态属性以及方法的解释,请参考:

$new()

获取最新的表单$formutil。这里可能会产生一个疑问:为什么已经拿到了$formutil,还要再通过$new()再获取一次呢?

这是因为$formutil是随着渲染,每次都时时生成的新对象,即 react 组件的前后两次渲染,拿到的$formutil其实都是所属渲染帧的快照!

当使用withForm高阶组件时,我们如果通过this.props.$formutil来访问,都是安全的,因为最新的$formutil都会通过组件的props传递过去。

但是,当我们通过render props方式(即通过Form的 render、children 属性传递渲染函数),异步回调里获取的上下文中$fomutil则可能是之前的某个快照,并不是最新的,所以你获得的表单状态和值都可能是不正确的。

错误的调用

<Form>
    {$formutil => {
        const onChange = ev => {
            // 延迟2s执行
            setTimeout(() => {
                const { $invalid, $params } = $formutil;
                // 这里的$formutil来自于回调函数所在作用域上下文中的$formutil
                // 它是`onChange`事件触发时的最后一次渲染的快照
                // 如果`onChange`触发,到延迟2s回调函数执行,表单又有变化的话,那么这里拿到的$formutil有可能就是和最新的表单状态不一致
            }, 2000);
        };

        return <EasyField name="user" onChange={onChange} required />;
    }}
</Form>

正确的用法

<Form>
    {$formutil => {
        const onChange = ev => {
            // 延迟2s执行
            setTimeout(() => {
                const { $invalid, $params } = $formutil.$new();
                // 注意,这里通过 $formutil.$new() 获取即时的最新的 $formutil,这样子是绝对安全的用法。
                // 如果不确定该不该用 $formutil.$new(),那么请记住,总是使用$new()总是没错的!
                // ...
            }, 2000);
        };

        return <EasyField name="user" onChange={onChange} required />;
    }}
</Form>
$getField(name)
// 其函数签名如下
$getField(name: string): undefined | $Fieldutil;

获取对 name 对应的表单项的$fieldutil对象。

只能获取到已注册的 Field,否则返回空

$validate(name)
// 其函数签名如下
// 0.5.0起,同时支持参数回调,以及Promise回调
$validate(name: string, callback?: ($formutil: $Formutil) => void): undefined | Promise<$formutil>;

立即校验对应 name 的表单项。

只能对已注册的 Field 发起校验,并且返回 Promise 回调。否则返回空

$validates()
// 其函数签名如下
// 0.5.0起,同时支持参数回调,以及Promise回调
// 校验name说对应的Field
$validates(names: string | string[], callback?: ($formutil: $Formutil) => void): Promise<$Formutil>;
// 校验所有表单
$validates(callback?: ($formutil: $Formutil) => void): Promise<$Formutil>;

可以对单个表单域($valdiates('field'),类似上面的$validate())或者同时对多个表单域($validates(['field1', 'field2'])),甚至整个表单所有 Field 进行校验($validates(),不传 name 参数)。

对全部表单域进行校验,会同时触发Field的校验,以及Form$validator校验(如果有的话),并且回调方法以及 Promise 回调都将在所有校验完成后!

$onValidates()
// 其函数签名如下
// 0.5.1起,同时支持参数回调,以及Promise回调
$onValidates(callback?: ($formutil: $Formutil) => void): Promise<$Formutil>;

确保整个 Form 当前的校验已经完成,因为 Form 可能包含有异步校验,某些情况下,你可能需要在整个表单的校验完成后,再去执行一些操作,此时你可以通过该方法确认。

// 例如,当绑定表单值变动事件时,如果需要确保本次变动导致的校验完成后,再进行操作,可以调用该方法
<Form $onFormChange={$formutil => $formutil.$onValiates().then(() => console.log('form validate complete'))} />
$render(callback)
// 其函数签名如下
// 0.5.0起,同时支持参数回调,以及Promise回调
$render(callback?: ($formutil: $Formutil) => void): Promise<$Formutil>;

强制重新渲染表单组件,可以通过该方法的回调,在当前的渲染完成后回调

$setStates($stateTree)
// 其函数签名如下
// 0.5.0起,同时支持参数回调,以及Promise回调
$setStates($stateTree: { [name: string]: FieldState }, callback?: ($formutil: $Formutil) => void): Promise<$Formutil>;

可以用来更新表单项的状态:

$formutil.$setStates({
    username: { $dirty: true, $pristine: false },
    'list[0].name': {
        //也可以像下方一样传入结构化对象
        $dirty: true,
        $pristine: false
    },
    list: [
        {
            name: {
                $dirty: true,
                $pristine: false
            }
        }
    ]
});
$setValues($valueTree)
// 其函数签名如下
// 0.5.0起,同时支持参数回调,以及Promise回调
$setValues($valueTree: { [name: string]: any }, callback?: ($formutil: $Formutil) => void): Promise<$Formutil>;

可以用来更新表单项的值:

$formutil.$setValues({
    username: 'jack',
    'list[0].id': '123456', //也可以像下方一样传入结构化对象
    list: [
        {
            id: '123456'
        }
    ]
});
$setErrors($errorTree)
// 其函数签名如下
// 0.5.0起,同时支持参数回调,以及Promise回调
$setErrors($errorTree: { [name: string]: FieldError }, callback?: ($formutil: $Formutil) => void): Promise<$Formutil>;

可以用来设置表单的校验结果:

$formutil.$setErrors({
    username: {
        required: '必填'
    },
    'list[0].id': {} //代表校验通过
});
$reset($stateTree)
// 其函数签名如下
// 0.5.0起,同时支持参数回调,以及Promise回调
$reset($stateTree: { [name: string]: FieldState }, callback?: ($formutil: $Formutil) => void): Promise<$Formutil>;

可以用来重置表单,会将表单重置为初始状态(不会改变组件设置的默认状态和默认值)。如过传递了$stateTree,则会重置为合并了$stateTree 后的状态

$formutil.$reset();
$setDirts($dirtyTree) | $setTouches($touchedTree) | $setFocuses($focusedTree)
// 其函数签名如下
// 0.5.0起,同时支持参数回调,以及Promise回调
$setDirts($dirtyTree?: { [name: string]: boolean }, callback?: ($formutil: $Formutil) => void): Promise<$Formutil>;
$setTouches($touchedTree?: { [name: string]: boolean }, callback?: ($formutil: $Formutil) => void): Promise<$Formutil>;
$setFocuses($focusedTree?: { [name: string]: boolean }, callback?: ($formutil: $Formutil) => void): Promise<$Formutil>;

可以用来更新表单控件的$dirty$touched$focused状态,类似$setValues

$formutil.$setDirts({
    username: true,
    'list[0].id': false
});

$formutil.$setFocuses({
    username: true,
    'list[0].id': false
});
$batchState($newState) | $batchDirty($dirty) | $batchTouched($touched) | $batchFocused($focused)
// 其函数签名如下
// 0.5.0起,同时支持参数回调,以及Promise回调
$batchState($newState: FieldState, callback?: ($formutil: $Formutil) => void): Promise<$Formutil>;
$batchDirty($dirty: boolean, callback?: ($formutil: $Formutil) => void): Promise<$Formutil>;
$batchTouched($touched: boolean, callback?: ($formutil: $Formutil) => void): Promise<$Formutil>;
$batchFocused($focused: boolean, callback?: ($formutil: $Formutil) => void): Promise<$Formutil>;

批量更改所有表单项的状态

$formutil.$batchState({
    $dirty: true,
    $pristine: false
});
$formutil.$batchDirty(true); //同上效果
$formutil.$batchTouched(true);
$getFirstError()
// 其函数签名如下
$getFirstError(): undefined | string;

从表单的所有错误项中取出第一个错误描述

如果传递name参数,则为获取name对应的表单项的第一个错误信息!

// 获取整个表单的第一个错误
$formutil.$getFirstError();

// 获取name值为username的Field的第一个错误
$formutil.$getFirstError('username');

//例如
const { $invalid, $getFirstError } = this.props.$formutil;
if ($invalid) {
    alert($getFirstError());
} else {
    // ...submit data
}
$states | $weakStates

所有表单项的状态集合。$formutl.$state 是以 Field 的 name 值经过路径解析后的对象,$formutil.$weakState 是以 Fieldname 字符串当 key 的对象。

$params | $weakParams | $pureParams

所有表单项的 值$value 集合。

  • $params 是以 Fieldname 值经过路径解析后的对象,并且包含$defaultValues中的其它值
  • $weakParams 是直接以以 Fieldname 字符串当 key 的对象
  • $pureParams$params类似,只不过它仅仅包含实际注册的 Field 的值,不包括$defaultValues传递的未注册的值

请注意: 只有表单项的$dirty状态为false,或者其值$value不是undefined时,其值才会被收集解析道$params或者$weakParams中!

如果你希望调整该行为,可以通过$processer来调整表单对值的收集逻辑。

$params = {
    username: 'qiqiboy',
    list: [{ name: 'apple' }, { name: 'banana' }]
};

$weakParams = {
    username: 'qiqiboy',
    'list[0].name': 'apple',
    'list[1].name': 'banana'
};
$errors | $weakErrors

所有表单项的 $error 集合。$formutil.$errors 是以 Fieldname 值经过路径解析后的对象,$formutil.$weakErrors 是以 Fieldname 字符串当 key 的对象。

$errors = {
    username: {
        required: '必填'
    },
    list: [
        {
            name: {
                required: '必填'
            }
        },
        {
            name: {
                required: '必填'
            }
        }
    ]
};

$weakErrors = {
    username: {
        required: '必填'
    },
    'list[0].name': {
        required: '必填'
    },
    'list[1].name': {
        required: '必填'
    }
};
$dirts | $weakDirts

所有表单项的 $dirty 集合。$formutil.$dirts 是以 Fieldname 值经过路径解析后的对象,$formutil.$weakDirts 是以 Fieldname 字符串当 key 的对象。

$touches | $weakTouches

所有表单项的 $touched 集合。$formutil.$touches 是以 Fieldname 值经过路径解析后的对象,$formutil.$weakTouches 是以 Fieldname 字符串当 key 的对象。

$focuses | $weakFocuses

所有表单项的 $focused 集合。$formutil.$focuses 是以 Fieldname 值经过路径解析后的对象,$formutil.$weakFocuses 是以 Fieldname 字符串当 key 的对象。

$valid | $invalid

表单项中所有 Field$valid 均为 true 时,$formutil.$validtrue, $formutil.$invalid 为 false。表单项中有任意 Field$validfalse 时,$formutil.$validfalse, $formutil.$invalidTrue

$dirty | $pristine

表单项中所有 Field$dirty 均为 false 时,$formutil.$dirtyfalse, $formutil.$pristine 为 true。表单项中有任意 Field$dirtytrue 时,$formutil.$dirtytrue, $formutil.$pristinefalse

$touched | $untouched

表单项中所有 Field$touched 均为 false 时,$formutil.$touchedfalse, $formutil.$untouchedtrue。表单项中有任意 Field$touchedtrue 时,$formutil.$touchedtrue, $formutil.$untouchedfalse

$focused

表单项中所有 Field$focused 均为 false 时,$formutil.$focusedfalse。表单项中有任意 Field$focusedtrue 时,$formutil.$focusedtrue

withForm(Component)

withForm 是基于 Form 封装的高阶组件,withForm 的第二个参数为可选配置,如过定义了该参数,会将配置传递给 Form 组件。

class LoginForm extends Component {
    // ...
}

export default withForm(LoginForm, {
    $defaultValues: {} //该项将传递给Form组件
});

withForm同样支持装饰器语法

@withForm
class MyField extends Component {}

//or pass some default props
@withForm({
    $defaultValues: {}
})
class MyField extends Component {}

connect(Component)

connect 是一个高阶组件,它可以增强当前组件,并获取其最近的父辈级中的 Form 组件的 $formutil 对象,并以 props 传递给当前组件。

在大表单拆分多个小组件的时候很有用,不用将$formutil 再传来传去:

import { connect } from 'react-formutil';
class Submit extends Component {
    submit = () => {
        //通过connect可以拿到 $formutil
        const { $formutil } = this.props;
        // ...
    };

    render() {
        return <button onClick={this.submit} />;
    }
}

export default connect(Submit);
<Form>
    <div className="">
        <EasyField name="username" />
        <Submit />
    </div>
</Form>

Hooks

Hooks[email protected]开始,正式推出的新的组件开发 API。[email protected]开始,也提供了相关的适用与这一全新开发方式的相关Hooks

请注意,与官方态度一样,Hooks并不是要对之前基于class component开发方式的否定,它是可选的、并且向后兼容,不会破坏目前任何基于现有react-formutil的项目正常运行。

如果你要开始使用Hooks,请确保你已经安装了最新的react-formutil@>0.5.0以及react@>16.8.0 react-dom@>16.8.0

全新的Hooks方法,位于react-formutil/hooks下(如果要使用新增的useField useForm hooks,必须从这里导出获取)。

v0.5.6起,你可以直接从主包中导出 hooks 相关方法了

useField

useField 可以用来获取或者生成一个新的$fieldutil对象。它接受类似Field组件所有能接受的props参数:

function useField<T = string, Validators = {}, Fields = {}, WeakFields = Fields>(
    name?: string,
    props?: Omit<FieldProps<T, Validators, Fields, WeakFields>, 'name'>
): $Fieldutil<T, Validators, Fields, WeakFields>;

function useField<T = string, Validators = {}, Fields = {}, WeakFields = Fields>(
    props?: FieldProps<T, Validators, Fields, WeakFields>
): $Fieldutil<T, Validators, Fields, WeakFields>;

我们来尝试使用下useField。但是首先,假如我们要定义一个Field控件,它是个普通的input输入框,使用非hooks方法,大概长这样:

import { Field } from 'react-formutil';

/**
 * 你可以直接将自定义组件的所有props直接传递给`Field`,这样你的自定义组件就可以变成一个标准的类`Field`组件,
 * 它可以接受任意的`Field`支持的属性值,例如`$defualtValue` `$validators`。
 *
 * <UserNameField name="username" $defaultValue="Lucy" />
 */
function UserNameField(props) {
    return (
        <Field {...props}>
            {$fieldutil => <input value={$fieldutil.$viewValue} onChange={ev => $fieldutil.$render(ev.target.value)} />}
        </Field>
    );
}

假如使用useField的话,会是什么样子呢?

import { Field } from 'react-formutil/hooks'; // 请注意,这里的模块导入位置

function UserNameField(props) {
    const $fieldutil = useField(props);

    return <input value={$fieldutil.$viewValue} onChange={ev => $fieldutil.$render(ev.target.value)} />;
}

就是这么简单!上面的代码完全等效,但是明显使用hooks方式,更加简洁,没有HOC、没有render props,完全就是个普通的函数定义!

就像调用Field组件时,我们可以传递一些默认值、默认校验方法等,使用useField也可以这么做!

function UserNameField({ name }) {
    const $fieldutil = useField(name, {
        $validators: {
            required(value) {
                return !!value || 'Required!';
            }
        }
    });

    return <input value={$fieldutil.$viewValue} onChange={ev => $fieldutil.$render(ev.target.value)} />;
}

useHandler

useHandler基于EasyField实现,会反馈传递的$fieldHandler对象。我们可以通过这个 hook 更方便使用和继承EasyField的功能与特性!!

这里还是以渲染一个用户输入为例:

import { useHandler } from 'react-formutil/hooks';

function UserNameField(props) {
    const $handler = useHandler(props);

    return <input {...$handler} />;
}

/** 直接调用,并且利用EasyField的内置校验,要求必填,并且不能少于5个字符
<UserNameField
    name="username" 
    required
    minLength={5}
    validMessage={{ required: '请填写用户名!',maxLength: '用户名长度不能小于5个字符!' }}
/>
 */

是不是比上面使用useField更简单了呢?而且更厉害的是,直接也具有了支持EasyField内置校验规则的能力!!

useForm

useForm可以用来获取上下文中的$formutil对象。请注意,与useField不同,这里只能获取当前组件所在的Form中的$formutil对象,而不能创建一个新的Form上下文!!它比较类似于connect高阶组件的作用!

useField可以获取已经存在的其它Field$fieldutil,如果没有,它会创建一个新的$fieldutil句柄

import { Form, useForm } from 'react-formutil/hooks';

function UserInfoSubmitForm() {
    const $formutil = useForm();

    const onSubmit = function() {
        const { $invalid, $getFirstError } = $formutil;

        if ($invalid) {
            alert($getFirstError());
        } else {
            // submit data
        }
    };

    return <button className="btn-submit" onClick={onSubmit} />;
}

// 使用,必须位于<Form />组件,或者withForm()高阶组件所在的组件树中才能获取到!
<Form>
    {/*...*/}
    <Others>
        <UserInfoSubmitForm />
    </Others>
    {/*...*/}
</Form>;

最佳实践 Best Practices

react-formutil旨在提供一个非强侵入性 高度抽象 方便迁移 简化接入的表单工具。正是由于下面的几点思考,才有了与众不同的rect-formutil

  • 一张表单只能有一个顶层 <Form /> 或者 withForm。但是你可以通过将一个<Form />使用<Field />/withField包装后,使其变身为一个Field组件,来快捷复用以及嵌套表单使用!


  • 表单项Field应当是尽可能的做到小粒度 低耦合 独立性,保证其可复用性。例如表单校验,我们强烈建议通过<Field />$validators来对每个Field配置校验规则,而不是统一在Form层面进行校验!!

    • $validators对象也是可以复用的,你可以将所有的校验规则都放到一个$validators对象中,然后传递给所有的<Field />。但是不用担心这些规则会对所有<Field />生效。因为校验规则的生效,还需要对<Field />传递对应的校验规则标识符才会启用!
    • 我们知道其它很多表单库,或多或少,其文档、官方示例,甚至 API,都在推荐在<Form />层面对数据进行校验,但是我们认为这样会造成FormField的强耦合,不利于Field的组件复用!
    • 我们也提供了<Form />$validator属性来在<Form />层面做校验,但是请注意,仅建议用于那些校验时其字段相互耦合依赖的表单,例如两次密码输入确认场景

  • Field应当尽量保证对外渲染的值与接口接受到的值保持一致(包括类型、格式),对于复杂的Field数据收集,很多情况下,组件层面我们拿到的是array/object,但是接口可能需要 json 字符串。

    • 我们不建议submit时再对数据进行转换,因为这导致视图与服务 server 的数据结构不一致,导致无论提交数据还是渲染 server 数据,都需要无穷无尽的数据转换。你可以通过以下办法对数据在表单层面进行加工转换
    • 第一种办法,对于自己封装的Field,应当在通过$fieldutil.render()在传递数据值时对值做好数据转换
    • 第二种办法,针对第三方封装的Field或者只是个别情况下,那么我们应当通过 $parser属性来指定$viewValue$modleValue的转换(即视图数据到模型数据)
    • 如果你对前两种方法较为陌生,那么至少你应当通过Form$processer属性对数据进行转换。

  • <Field />name属性是支持深层路径索引(nested 嵌套)的,所以你可以善于利用其这一特性,方便的将值收集到对象或者数组中。


  • 大表单请尽可能进行拆分处理,将其转换为可以复用的表单片段(即只包含相关性、相似性的一组Field),然后通过组合这些表单片段来达到复用或者优化大表单单一组件过大的问题。


  • Typescript 开发中,对于withField withForm connect三个高阶组件调用,请使用函数式调用,避免@decorator装饰器语法,因为高阶组件会改变类签名,导致类型校验失败。

    • 通过函数调用方式使用提供的高阶组件,可以正确处理组件上挂在的$fieldutil $formutil类型声明,避免被当作必需属性。

FAQ & 常见问题解答

Field 与 EasyField 有什么区别

Field 是抽象的底层,它本身不会渲染任何 dom 结构出来,它仅提供了同步、渲染表单控件的接口。要实现具体的表单,需要通过 Field,使用它提供的接口,手动实现监听用户输入、同步数据等工作(例如不会主动同步$touched $focused 状态)

EasyField 则是基于 Field 封装的另一个组件,它针对浏览器原生的表单控件,封装实现了数据同步、表单校验,可以简化调用。EasyField 会自动绑定 changefocusblur 事件,并主动同步$touched $untouched $focused状态

Field中的 $value 与 $viewValue 有什么区别

v0.5.0起,Field 表单域中的状态模型中,新增了$viewValue。它与之前的$value的区别是:

  • $value表示的是表单域状态模型值,用来向Form同步。$formutil中的$params即为从每个Field中收集的$value集合!
  • $viewValue表示的是表单域的视图值,即视图中显示的值是根据该值显示的。它一般情况下都与$value相同,但是当我们自定义了$parser $formatter时,可能会导致两者不同。

当渲染视图时,应当根据$viewValue来渲染,否则会导致$parser $formatter属性失效(因为这两个属性就是处理$value$viewValue的转换的,如果不想使用默认支持的这两个数据处理钩子,使用$value当然也没什么问题~)!

为什么要做出这样的改动?

这是因为 v0.5.0 之前的版本,只有一个状态模型值$value。经过$parser处理的值会直接更新到模型中,而视图也是根据模型中的值渲染的,这就会导致$parser进而影响到视图值的显示!

例如,当我们希望过滤用户输入的空格时,我们一般会通过$parser传递过滤函数:

<Field name="user_name" $parser={value => value.trim()} />

但是,以上代码在v0.5.0之前的版本中,会导致用户完全无法输入空格:完全无法输入 Jobs Smith,中间的那个空格永远输入不上,因为一旦输入就会立即被$parser过滤掉,并且更新回视图中!

v0.5.0版本通过新增加的$viewValue,来将视图值单独存储,与原来的模型值$value做了区分。这样,就可以正常的输入 Jobs Smith 啦!

副作用

当然,这一改动也会导致$parser的某些用法产生与之前版本的预期不一致。

例如,当我们希望提供一个只能输入整数(其它字符直接不可输入)金额输入框时:

<Field name="user_name" $parser={value => value.split(/[^\d]/g, '')} />

以上代码在v0.5.0之前的版本中,即可达到目的。因为$parser处理过后的值会被更新到状态模型中,视图也是根据这个过滤后的值渲染的,所以就可以直接实现禁止用户输入非整数字符。

但是在v0.5.0之后的版本中,视图根据$viewValue渲染的话,会导致状态模型中的值被正确处理了,但是视图中的值还是用户原始输入,即可能包含非法值。要实现过滤视图中显示的值,我们可以通过$parser提供的第二个回调参数$setViewValue来同步更新视图值:

<Field name="user_name" $parser={(value, $setViewValue) => $setViewValue(value.split(/[^\d]/g, ''))} />

如何在我自己的项目中便捷的使用Field组件?

<Field />组件本身的设计理念如果你已经了解后,那么一定会产生这样的疑问:

在我自己的项目中,每个表单控件都基于Field去写,处理状态与错误显示,有点太复杂、太啰嗦了。有没有更优化的方法?

答案当然是“有的”!

  • 如果你在使用ant-designMaterial-UI或者react-boostrap等第三方的 UI 库,你可以参考:如何在 ant-design 或者 Material-UI 等项目中使用 react-formutil?
  • 如果你对上面提到的react-antd-formutilreact-material-formutil等适配库的实现比较了解,你也可以参考其对你目前使用的 UI 组件库做适配!
  • 如果你在使用团队自己对组件库,或者是个新接触react-formutil的新手,想快速实现项目中应用,请往下看

对于在使用自己私有(团队内部)表单 UI 组件或者实现的项目,我们这里提供了一个示例,通过封装一个FormItem来快速适配bootstrap框架提供的表单 UI:

Form Adaptor

你可以点击上述链接来查看代码实现,以及运行效果!

checkbox 多选或 radio 单选组怎么实现

可以直接 Field 实现,也可以使用 EasyField 实现(demo 都中有示例):

const hobbiesItems = [
    {
        id: 'music',
        name: '音乐'
    },
    {
        id: 'movie',
        name: '电影'
    },
    {
        id: 'ps4',
        name: 'ps4'
    }
];

<EasyField name="hobbies" type="group.checkbox">
    {props => (
        <div>
            {hobbies.map(item => (
                <label className="checkbox-inline" key={item.id}>
                    {/* props.GroupOption是每个候选项对应的input[checkbox],必须渲染出来,并传递 $value */}
                    <props.GroupOption $value={item.id} />
                    {item.name}
                </label>
            ))}
        </div>
    )}
</EasyField>;

使用 Field 实现一个上传图片的表单控件

假如我们需要在表单中插入一个按钮,用户需要点击按钮上传图片后,将图片地址同步到表单中

import React from 'react';
import { Field } from 'react-formutil';
import uploadFile from './uplaodFile'; //上传文件的方法

//定义我们自己的表单控件组件
export default function FieldFile(props) {
    return (
        <Field {...props}>
            {$props => {
                const selectFile = function() {
                    const fileInput = document.createElement('input');
                    fileInput.type = 'file';
                    fileInput.onchange = function() {
                        /* get file &upload */
                        const files = fileInput.files;
                        uploadFile(files).then(
                            fileUrl => {
                                //将文件地址更新到Field的状态中
                                $props.$render(fileUrl);
                            },
                            error => {
                                alert('upload fail');
                            }
                        );
                    };

                    fileInput.click();
                };

                return (
                    <div className="upload-image">
                        {$props.$value && <img src={$props.$value} className="preview" />}
                        <button onClick={selectFile}>{$props.$value ? '更改图片' : '上传图片'}</button>
                    </div>
                );
            }}
        </Field>
    );
}

/* ---------------------- 使用 -------------------- */

<div className="form-group">
    <label>点击上传头像</label>
    <FieldFile name="avatar" />
</div>;

如何获取对 Field 生成的节点的引用?

可以通过 $getField 获取到一组 handler 方法,其中有 $getComponent 方法,可以获取到组件对象,然后再通过 react-dom 提供的 findDOMNode 来获取到对应的实际 dom 元素节点

import { findDOMNode } from 'react-dom';

<Form>
    {$formutil => {
        function getNode(name) {
            return findDOMNode($formutil.$getField(name).$getComponent());
        }

        return <Field name="username">{/*...*/}</Field>;
    }}
</Form>;

对于有大量表单项的长页面有没有优化办法

对于一个具有很多表单项、导致页面很大的表单,如果全部在一个组件里维护,会比较痛苦。幸运的事,使用 react-formutl 你可以很方便将大表单拆分成多个模块,既能减小大组件带来的维护难题,还能复用表单模块。

比如同时要收集用户的个人信息和工作信息,我们可以将其拆分为三个模块:

  • Userinfo.js 用户基本信心的字段
  • Workinfo.js 用户工作信息的字段
  • Submit.js 提交区域(因为只有在 Form 组件下级才能拿到$formutil 信息)

注: Submit.js 和 Workinfo.js 合并到一起也是可以的。

// Userinfo.js
import React from 'react';
import { EasyField } from 'react-formutil';

export default function Userinfo({ $formutil }) {
    //可以从props中获取$formutil
    return (
        <div className="userinfo-form">
            <h3>基本信息</h3>
            <EasyField name="name" placeholder="姓名" />
            <EasyField name="age" placeholder="年龄" />
            <EasyField name="sex" placeholder="性别" />
            <EasyField name="phone" placeholder="手机" />
        </div>
    );
}
// Workinfo.js
import React from 'react';
import { EasyField } from 'react-formutil';

export default function Workinfo({ $formutil }) {
    //可以从props中获取$formutil
    return (
        <div className="workinfo-form">
            <h3>工作信息</h3>
            <EasyField name="company" placeholder="公司名称" />
            <EasyField name="job" placeholder="行业" />
            <EasyField name="work_address" placeholder="公司地址" />
        </div>
    );
}
//Submit.js
export default function Submit({ $formutil }) {
    //可以从props中获取$formutil

    const postData = () => {
        const { $params, $invalid, $erros } = $formutil;
        // ... 更多处理
    };

    return (
        <div className="submit-area">
            <button disabled={$formutil.$invlid} onClick={postData}>
                提交
            </button>
        </div>
    );
}
// EditInfoPage.js
import React from 'react';
import Userinfo from './Userinfo';
import Workinfo from './Workinfo';
import Submit from './Submit';
import { Form } from 'react-formutl';

export default function EditInfoPage() {
    //可以直接将拆分的模块以子组件放置在<Form />组件下(直接子组件,不可嵌套其它组件,否则可以使用下方的写法)
    return (
        <div className="editinfo-page">
            <Form>
                <Userinfo />
                <Workinfo />
                <Submit />
            </Form>
        </div>
    );

    /* 与下方写法等效 */

    return (
        <Form>
            {({ $formutil }) => (
                <div className="editinfo-page">
                    <Userinfo $formutil={$formutil} />
                    <Workinfo $formutil={$formutil} />
                    <Submit $formutil={$formutil} />
                </div>
            )}
        </Form>
    );

    /* 也可以使用 connect 高阶组件包装分拆的组件,然后就不必显式的传 $formutil */
    /**
     *  import { connect }  from 'react-formutil'
     *  class Submit extends Component {
     *      submit = () => {
     *          //通过connect可以拿到 $formutil
     *          const { $formutil } = this.props;
     *          // ...
     *      };
     *
     *      render() {
     *          return <button onClick={this.submit} />;
     *      }
     *  }
     *  export default connect(Submit);
     */
    return (
        <Form>
            <div className="editinfo-page">
                <Userinfo />
                <Workinfo />
                <Submit />
            </div>
        </Form>
    );
}

如何在 ant-design 或者 Material-UI 等项目中使用 react-formutil?

ant-designMaterial-UI项目中使用 react-formutil 也非常简单,以 ant-design 为例:

import React, { Component } from 'react';
import { EasyField, Field, withForm } from 'react-formutil';
import { Form, Input, Checkbox, DatePicker, Button } from 'antd';

//@decorator
@withForm
class MyForm extends Component {
    onSubmit = ev => {
        ev.preventDefault();

        const { $invalid } = this.props.$formutil;

        if ($invalid) {
            // some error
        } else {
            // submit data
        }
    };

    render() {
        return (
            <Form onSubmit={this.onSubmit}>
                {/* use <Field /> */}
                <Field name="title">
                    {props => <Input value={props.value} onChange={ev => props.$render(ev.target.value)} />}
                </Field>

                {/* use <EasyField />, not need to sync data by set 'onChange' manual */}
                <EasyField name="username">
                    <Input placeholder="Username" />
                </EasyField>

                <EasyField name="password">
                    <Input type="password" placeholder="Password" />
                </EasyField>

                <EasyField name="remeber" $defaultValue={true} valuePropName="checked">
                    <Checkbox>remember me</Checkbox>
                </EasyField>

                {/* use <Form.Item /> */}
                <Form.Item label="Date">
                    <EasyField name="date" $defaultValue={null}>
                        <DatePicker />
                    </EasyField>
                </Form.Item>

                <Button block type="primary">
                    Submit
                </Button>
            </Form>
        );
    }
}

是的,你可以使用Field来手动绑定 onChange 来同步数据,也可以直接使用EasyField嵌套 antd 的组件即可,EasyField会自动绑定好相关数据同步。

为了更便捷的在各大流程组件库项目中使用react-formutil,我们也提供了针对各个组件库的优化封装组件:

你可以点击上方链接来了解更多。

如果你还觉得有其它优秀的组件库也需要提供针对性的组件优化,也可以提 issues。

如何使用typescript开发?

[email protected] 起提供了针对typescriptDefinitionTypes声明文件,在开发中可能会用到的主要是以下几个:

  • $Formutil<Field, Validators, WeakFields> 整个表单的 $formtutil 类型声明
  • $Fieldutil<T, Validators, Fields, WeakFields> 单个表单项的 $fieldutil 类型声明
  • Field<T, Validators, Fields, WeakFields> Field 组件的类型声明
  • FieldProps<T, Validators, Fields, WeakFields> Field 组件的 props 类型声明
  • EasyField<T, Validators, Fields, WeakFields> EasyField 组件的类型声明
  • EasyFieldProps<T, Validators, Fields, WeakFields> EasyField 组件的 props 类型声明
  • Form<Fields, Validators, WeakFields> Form 组件的类型声明
  • FormProps<Fields, Validators, WeakFields> Form 组件的 props 类型声明

除了以上列出的,还有很多其它的类型定义,可以自行查看类型声明文件。

T 是指值类型;
Validators 是指表单的校验项结构;
Fields是指表单的参数域结构;
WeakFields是指扁平的Fields结构,默认等同于Fields。如果你的表单不使用深层结构,那么只需要提供Fields即可。

let IErrors: Validators = { required: true, maxLength: string }
let fields: Fields = { user: { name: string, age: number }, price: number }
let weakFields: WeakFields = { 'user.name': string, 'user.age': number, price: number }

import React, { Component } from 'react';
import { withForm, EasyField, $Formutil, $Fieldutil } from 'react-formutil';

// 定义整个表单的参数结构
interface IFields {
    name: string;
    age: number;
}

// 定义整个表单的校验结构
interface IErrors {
    required: string;
    max: string;
}

// 定义表单组件的props
// 因为我们使用了withForm高阶组件,所以我们需要声明$formutil这个对象
// 并且通过给 $Formutil 传递泛型参数,来明确整个$formutil对象中可以获取的表单相关结构信息
interface IProps {
    $formutil: $Formutil<IFields, IErrors>;
}

class UserForm extends Component<IProps> {
    componentDidMount() {
        // 可以调用$formutil对象
        this.props.$formutil.$setValues({
            name: 'xiao hong'
        });

        // 甚至可以访问错误信息结构
        console.log(this.props.$formutil.$errors.age.required);
    }

    render() {
        return (
            <form>
                {/* 这里类似上面声明IProps时传递了泛型参数,如果我们需要在EasyField属性配置中访问其对象信息,也需要提供泛型参数定义 */}
                <EasyField name="name" $onFieldChange={(newValue, oldValue, $formutil: $Formutil<IFields, IErrors>) => {
                    // 可以正常访问$formutil对象
                }} />

                {/* 这里我们定义该项的值为number类型,所以在渲染该值是需要做类型转换 */}
                <Field name="age">
                    { $fieldutil: $Fieldutil<number> => {
                            // console.log($fieldutil.$viewValue)
                            return <input onChange={ev => $fieldutil.$render(Number(ev.target.value))} value={$fieldutil.$viewValue} />
                        }
                    }
                </Field>
            </form>
        );
    }
}

export default withForm(UserForm)

react-formutil's People

Contributors

qiqiboy avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar

react-formutil's Issues

高性能react-formutil表单优化指南

speed-formutil

高性能 react 表单优化指南

我们的页面为什么会卡顿?

首先,我们要统一一些术语和认知:

Virtual DOM 尤其是 16+以后,VDOM 是指什么,很多人理解不一致。我们这里所说的 VDOM 是指react elements组成的树状结构对象,其映射了真实 DOM 的结构。react element是指React.createElement创建的对象。我们平常用 jsx 语法生命的每个标签都是一个个 VDOM Node。有些人和文章会混淆 Virtual DOM 是指 Fiber Tree,这里我们不采用这种说法。官方文档的Virtual DOM and Internals对于 react 体系中的Virtual DOM下了个定义In React world, the term “virtual DOM” is usually associated with React elements since they are the objects representing the user interface.。不过呢,我个人认为把 Fiber Tree 当作 Virtual DOM 也没什么问题,本身 FiberNode 就是对 elements 的升级补充。

Fiber 是指 16+以后新加入的主要针对reconciliation过程的核心算法的深度优化重写后的技术的泛称,是个抽象的概念。完整名称叫 Fiber reconciler

Fiber Node Fiber Node 是 Fiber 算法中的最小工作单元,其是用来描述 Fiber 要进行的工作的数据结构。它可以认为是对 react element 进行额外信息补充的特定结构对象,其可以认为是对 react element 的升级。react 16+以后 react element 结构简化了,就是因为更多的信息转移到了与其一一对应的 Fiber Node 中(_owner 属性)。另外 FIber Node 不会每次都重建,它是可变结构,保存了对应 react element 的相关组件信息

Fiber Tree 即由Fiber Node组成的结构,每个 Node 都通过return child sibling链接父、子、兄节点,组成一个大的链表式结构。Diffing 算法基于 Fiber Tree 进行

Reconciliation 协调,是指利用算法 diff 两棵 Fiber Tree 之间的差异,来决定需要更新的部分。具体大概包括生成 element、生成/更新 Fiber Node、调用 render、调用 life cycles、diff 等,其不包括后续renderer(commit)阶段。我们下面所说的“组件重新渲染(时间)”就是指进行reconciliation阶段。但是请注意,通常意义上的所说的渲染应当是包含 reconciliation 和 renderer(commit)两个阶段。

render/commitreconciliation/renderder通常可以认为是等价的,都是指 react 一次完整渲染的两个阶段。我们后面主要用commit来指代第二个阶段,提交变动到 DOM/渲染器进行 UI 更新

props、children 请注意,children是属于 props 的,事实上,<div>123</div>等同于<div children="123" />,优先级上,前者大于后者;我们所说的对组件 props 做比较,默认都是包含props.children

children 再说children属性,在component/react element层面指其props.children,但是在Virtual DOM/Fiber
Tree中,则是指其子节点。props.children不一定是子节点,因为组件本身可能不渲染props.children或者还会额外渲染其它elements。children属性和children节点不一定是相同的。

标准测试用例 后面在表单优化环节,会提到这个,这是指react-antd-formutil的 demo,其基于 antd 框架,包含约 30 个所有的 antd 中的data-entry型组件所组成的一个中型 form 表单。我们所说的所有的优化前后对比结果也是基于这个测试用例所述

在浏览器层面,卡顿无非就是 js 线程卡或者 UI 绘制线程(DOM 更新)卡。

具体到 react 应用中,绝大多数情况,我们的组件树结构没有大的变化(即不会产生大的 DOM 变动),但是页面依然出现了卡顿,那么就是只有一个原因:组件的reconciliation过程开销巨大。

我们这里主要讨论 reconciliation 阶段的优化,DOM 频繁更新(commit)导致的卡顿不在这个范围内,后者主要是保证生成 DOM 的稳定。

具体原因就是由于组件的 state 或者 props 变化,react 会从当前组件进行reconcilier,开始重建整个 react elements。大量的重建 elements、Fibe Node、Diff 消耗了大量的计算和资源。当 js 线程占用过久,就会影响浏览器渲染,进而引起页面掉帧,用户开始感觉到卡顿。

个人补充:一般来说,重建 element 是会很快的,因为就是创建一个个 js 对象。Diffing 一般来说也没啥太大问题,作为核心算法,不会是 react 的瓶颈。关键就是 element 合并生成/更新 Fiber Node 时,如果 render 方法开销较大,就会极大影响 reconciliation 的性能;当然如要更新的组件树过于庞大,即使每个 element 渲染只需要 0.01ms,上万的节点处理依然需要每次 100ms+的耗时,这也会导致卡顿

所以关键就是减少组件重新执行 render,也就得减少组件进入 Reconciliation。

如何优化 react 组件?

知道了性能问题产生在哪里,那么就减少导致性能的情况出现即可,也即 Avoid Reconciliation。我们要避免reconciliation开销大的组件非必要重复渲染。

在 react 中,可以阻止组件渲染的方法就是重写 class 组件的shouldComponentUpdate生命周期方法,或者通过 React.memo 优化 fuction 组件。React.PureComponentshouldComponentUpdate浅比较 props 和 state 的快速实现。

但是注意,shouldComponentUpdate 和 React.memo 的返回值是不一样的,shouldComponentUpdate 如果 props 和 state 一致,不需要重新渲染,要返回false;而memo则正好相反, true表示不需要渲染,反而false是可以渲染。另外memo无法比较 state,因为函数组件外部无法获取内部 state。不过幸好,useState对于更新相同的 state 并不会触发rerender

class App extends React.Component {
    shouldComponentUpdate(nextProps, nextState) {
        if (isEqual(nextProps, this.props)) {
            return false;
        }

        return true;
    }

    render() {
        return null;
    }
}
/////////////////////////////
const App = React.memo(
    () => null,
    (prev, next) => {
        if (isEqual(prev, next)) {
            return true;
        }

        return false;
    }
);

借图说话:

The last interesting case is C8. React had to render this component, but since the React elements it returned were equal to the previously rendered ones, it didn’t have to update the DOM.

Note that React only had to do DOM mutations for C6, which was inevitable. For C8, it bailed out by comparing the rendered React elements, and for C2’s subtree and C7, it didn’t even have to compare the elements as we bailed out on shouldComponentUpdate, and render was not called.

这里 C8 有个疑问,官方文档上对这个图的解释是,C8 生成的 react elements 和之前对比一样,所以不需要更新 DOM。但是查了很多文档,reconciliation就是指创建 elements、render、diff 这些过程,那么按说 C8 算是进入 reconciliation 了,但是图上将其标记为绿色。这里绿色圆圈应该是指是否需要更新 DOM?。

常见问题

PureComponent 这么方便,我是不是应该编写组件时都直接使用 PureComponent 就好了?

这个答案一定是否定的。react 没有把基于 shouldComponentUpdate 的浅比较作为默认的组件更新行为就知道,这么做一定是不合适的。它会导致许多问题变得复杂,具体原因如下:

  • 常见的内联的 object、function 类型的 props,会总是破坏 PureComponent 的浅比较。这会导致额外的比较反而成为额外的开销。当成千上万的 PureComponent 都因为类似原因导致重复比较,那么带来的性能损耗可能会显而易见
  • 组件依赖的上层数据,即 props 可能是复杂的,其不一定是Immutable的,这会成为潜在的 bug 点,并难以被发现。对于复杂的数据结构,如果没有应用Immutiable技术,那么对其子属性的更改可能无法生成可变值;或者在传递前,总是简单的{...xxx},那么就会导致上一个原因,即 prop 总是在变
  • 为了避免前面的问题,我们需要特别注意组件 props 的值的声明位置和方式,这会导致代码逻辑组织的困难和复杂。这一点造成的问题,可能远比 PureComponent 带来的改善严重的多
  • 导致 legacy context 的更新失效 当然,新的 context api 让这一点不再成为问题
  • 在团队项目中,由于能力经验差异,能力相对较低的人由于对 react 渲染机制的陌生,很容易写出破坏辛苦维护的优化结果的代码,单点破坏,可能就会造成严重影响。当然,这一点对于老鸟,如果疏忽也会有这个问题。(这一点是指本来组件树没有大的性能问题,但是团队习惯于都是使用 PureComponent,但是容易由于疏忽或者 review 漏掉,导致一些造成优化失效)

PureComponent 容易被可变对象破坏,那我用 shouldComponentUpdate 深比较可以了吧?

这个答案更是否定的。深比较虽然可以解决 object 对象的一致性比较,但是也存在非常明显的问题:

  • 大数据的比较可能带来相当大的开销,这极有可能比让组件重新渲染大得多。大数据可能来自与单个 prop 值,也可能是组件的 props 数量过多,加起来造成需要 diff 的值偏大
  • 可变函数(inline render props)值依然无法深度比较,这同样会导致负向优化

我自己根据实际组件编写 shouldComponentUpdate 比较逻辑总可以了吧?

可以这么做,但是一定要非常小心。因为没有一成不变的组件,未来在业务发展、项目迭代中,可能会因为新加入的 props 不符合 shouldComponentUpdate 的比较逻辑导致组件出现渲染异常。并且这个异常对部分人来说可能是难以察觉、发现的。如果对于 react 的更新渲染机理非常熟悉,可以编写良好的比较逻辑,当然这么做是个好的选择

基于以上考虑,我给的最佳实践方案是:

避免提前优化、过度优化

  • 如果我们的组件没有发生性能问题,就不要提前使用任何PureComponent memo shouldComponentUpdate等技术
  • 如果发生性能问题,优先考虑优化组件的划分、组合、状态管理等逻辑,尽量在组件使用管理上避免开销大的组件频繁被渲染;千万不要组件一遇到性能问题,就一刀切想着要上 SCU
  • 如果第二条无法做到,再考虑使用渲染优化技术介入,并且要克制的使用。任何的渲染优化,都在破坏 react 本身默认的组件树渲染逻辑

使用了 shouldComponentUpdate 阻止了组件渲染,是否就一定阻止了所有子组件的渲染?

并不是这样的,新的 context 不受这种优化的影响。即如果 context.Provider 传递的 value 发生了变化,所有连接的 Consumers,包括使用 useContextHook 的函数组件,都会重新渲染,不受上层组件的 shouldComponentUpdate 影响。这个特性也正是相比于老版本 context 的改进之一。

但是这个特性不注意也会导致 shouldComponentUpdate 优化失败,导致依然有性能问题产生:

const themeContext = React.createContext({});

function App() {
    const { Provider } = themeContext;
    return (
        <Provider value={{ color: 'white' }}>
            <PreventRender />
        </Provider>
    );
}

const PreventRender = React.memo(
    () => <ColorConsumer />,
    () => true
);

function ColorConsumer() {
    const { color } = React.useContext(themeContext);

    return <div>{color}</div>;
}

这个例子中,如果 App 重新渲染,即使 PreventRender 使用了 memo 阻止了重新渲染。但是 ColorConsumer 组件依然会被重新渲染。

总结

  • 按需优化,避免提前优化、过度优化
  • 优先通过调整组件的状态管理、划分、复用等逻辑,将引起组件频繁渲染的范围降低到最小以节省开销
  • 善于使用 chroem 的 Devtool 的 Performance 或者 React Devtool Profiler 来发现、定位问题组件

对于第三方组件或者不方便直接进行渲染优化改造的组件,可以尝试memo-render这个组件,它可以方便的在无需调整组件内部逻辑的情况下达到优化组件树渲染的目的。我们在下方的表单优化环节也会对此进行介绍。

react-formutil 1.0 是如何优化的?

之前版本的 react-formutil 的性能瓶颈在哪里?

前面我们讲到了 react 性能降低来自于复杂组件的reconciliaton的开销。而react-formutil的 Form 作为全局状态控制器,同步所有 Field 的状态更新。在表单场景中,用户快速输入导致的整个 Form 频繁进入reconciliation。如果 Form 中存在一个明显reconciliation开销过大的组件(不一定需要是 Field 相关组件),那么就会导致页面出现明显卡顿。具体来说react-formutil的性能问题主要来自以下两点:

  • 实时表单状态同步的设计理念下,任何 Field 的变动都会导致 Form 整体渲染;而 Form 中又存在reconciliation开销过大的组件,就导致了性能问题
  • Form 组件本身的性能,其在 render 时实时生成$formutil对象,该对象是Immutable的;而$formutil是一个非常复杂的对象集合,每次渲染重新生成,需要从每个 Field 中提取状态,进行计算合并。并且由于要支持nested path name,这个计算要考虑的情况很多,导致计算生成对象也会花费较多时间。

分布式表单可以高效,为什么不用分布式设计?

这里首先对分布式表单和 react-formutil 所代表的集中式/全局管理表单做个对比:

性能上

毫无疑问,分布式表单完胜全局表单,因为分布式表单的 Field 状态各自管理,当 Field 变动时只更新自身,不影响其它 Field 或者 Form 下的其它组件。而全局表单,单个 Field 变动会造成整个 Form 的重新渲染。

易用性

这一点,毫无疑问全局表单完胜分布式,因为 react 本身的特点就是自上而下单向数据流。集中式表单的状态传递就很好的契合了这一点。使用全局表单,在表单的 context 中,访问表单状态非常自然,就从读取父级 props 中传递的值即可。任何 Field、非 Field 组件都能只有读取表单状态。

而在分布式表单中,访问表单状态或者其它 Field 成为了一件略显棘手的事情。首先是需要触发动作,例如访问表单的值对象,需要手动触发getFormValues();获取其它 Field 的值,需要getFieldValue(name);另外由于只有变动的 Field 才会更新渲染,所以为了让其它依赖的 Field 可以更新,还需要明确指定相互间的依赖,或者使用类似sub/pub的状态订阅分发设计模式。另外对于非 Field 组件要获取表单的 Field 值,也会很困难。在这些场景中,一般需要通过onFormChange/onFieldChange把这些值更新到上层的 state 中,然后供其它组件访问。但是这也带来了重复渲染的问题,额外的 setState 造成不必要的整树更新。类似场景增多的话,同样带来性能隐患。

在表单中,react-formutil 认为副作用访问是个常见需求,即可能随时、到处存在需要访问 Form 状态的组件,我们优先保证在这些需求,最小代价满足。而分布式表单,明显是假设表单中不存在副作用,当有副作用需要时,手动去管理。

总结

  • 分布式表单性能高,但是部分场景让问题变得复杂、可用性低、心智负担大,额外创造了不符合 reactive 的场景
  • 全局表单性能一般,但是易用性高、更符合 react 的使用直觉

1.0以后的版本如何解决上述问题

优化全量渲染

对于表单整体刷新,这个设计理念不会改变。既然 Form 整体的渲染不可避免,那么我们就从 Field 去优化即可。Form 重新渲染时,如果当前 Field 不需要更新,那么就阻止掉该次渲染。

但是为了向后兼容性,默认情况下,Field 和之前一样,跟随 Form 重新渲染。所以我们新增了$memo属性,用来表明当前 Field 是否进行状态比对。

优化$formutil生成

$formutil虽然每次渲染重新生成,但是导致渲染的往往都是个别的 Field 的变化。所以我们只要记录下导致本次渲染发生的 Field,然后只重新生成该 Field 在$formutil中的状态集合即可。

所以1.0版本,开始,$formutil会浅拷贝之前的$formutil上那些不变的值,只重新计算发生变化的 Field。但是有个例外,即 Field 有unmount 发生,即有 Field 被移除注册(包括当前 Field 的 name 值发生变化,这会同步触发一次 unmount/mount),$formutil还是会进行全量计算,这是因为从庞大的$formutil中计算级连移除值时的计算开销并不一定比全量重新生成的开销小,尤其是计算如何移除nest path;而且可以避免特殊的例如 Field 具有undefined值时如果进行深层对象清理产生一些 bug(undefined是目前清理算法中的待移除标记)。

幸好unmount的发生不会是高频场景,这种情况即使发生了导致$formutil全量重建,也是可以接受的。

在我们的标准测试例子中,Form 的 rerender 性能从之前的均30ms上下降低到<1ms,几乎与普通轻量组件无异。

如何使用 react-formutil 进行高性能表单开发?

1.0 的react-formutil对于$formutil的计算优化是自动的,这一点无需用户关心。要创建高性能表单,主要是通过降低 Form 下的高开销组件的rerender来优化。

但是在进行优化前,还是要说一句:

当 Form 出现性能问题后再进行优化,避免提前优化、过度优化!

这与之前提到的 react 组件进行渲染优化可能导致的潜在问题原因一致。在实际业务场景中,Field 的各种属性,包括$validators、$parser 或者 children 等都可能依赖各种上层状态,贸然进行渲染优化,可能导致负向优化产生,或者导致难以察觉的问题。

react-formutil的设计就是全局表单状态同步,方便在 Form context 中,随时随地可以访问整个 Form 的状态。这是很自然、很 reactive、易用的,在没有明显性能问题前,保持表单组件按照 react 本身的reconciliation逻辑进行更新,避免潜在问题。

发现问题所在

当表单出现性能下降的,首先要找出reconciliation开销大的组件。可以通过 react 的Profiler相关工具测试组件的渲染开销。要说明的是,

优化开销大的非Field组件

开销大的组件不一定是 Field 组件!

例如如下场景:

// 待优化例子,Table为低性能组件
<Form>
    <Field name="page" />
    <Field name="search" />
    <Table page={$formutil.$parmas.page} />
</Form>

如果页面卡顿,可能是因为 Table 组件放到了 Form 下,其本身的reconciliation如果开销较大,那么就会导致 Form 更新时,其成为渲染瓶颈。

针对这种情况,有以下两种处理:

将 Table 移出 Form 组件

这样之做避免 Table 被 Form 的频繁渲染影响。如果 Table 需要访问表单值,可以通过 Form 的$onFormChange把值传递到上层组件后传给 Table。

// 优化后
function App() {
    const [page, setPage] = useState(1);
    const onFormChange = React.useCallback($formutil => {
        // useState同样的value不会触发重复渲染,所以可以放心直接进行setState操作
        //  而不用担心导致App组件随同Form的每次change都重新渲染
        setPage($formutil.$params.page);
    }, []);

    return (
        <div>
            <Form $onFormChange={onFormChange}>
                <Field name="page" />
                <Field name="search" />
            </Form>

            <Table page={page} />
        </div>
    );
}

优化调整 Table 本身的组件渲染

如果 Table 组件是自有组件,可以在 Table 加入 shouldComponentUpdate 渲染优化。但是如果 Table 属于第三方组件,那么这一条就不成立了,请看下一条。

通过第三方组件拦截 Table 的渲染

这个适用于要优化的组件是第三方组件,无法直接改造优化,或者不想在组件加入影响渲染机制的优化,只是想在此处临时处理。

这个就会用memo-render这个组件:

// 优化后
<Form>
    <Field name="page" />
    <Field name="search" />
    <MemoRender>
        <Table page={$formutil.$parmas.page} />
    </MemoRender>
    {/* 或者 */}
    <MemoRender deps={[$formutil.$parmas.page]}>
        <Table page={$formutil.$parmas.page} />
    </MemoRender>
</Form>

优化开销大的Field组件

对于 Field 的优化,则是通过$memo属性。事实上,它与memo-render是相同的优化原理。

interface FieldProps {
    $memo: boolean | any[];
}

$memo可以传递一个布尔值或者一个数组。

  • 当传递数组时,它与useCallback useMemo的第二个deps参数类似,即传入的数组中的值作为渲染比较的依赖项
  • 当传递空数组时$memo={[]},表示除了 Field 本身的状态变化,阻止所有重复渲染
  • 当传递true时,表示深度比较 Field 的所有 props 是否一致来决定是否重新渲染

如果你比较了解了 react 的渲染优化控制,那么可以根据实际情况选择怎么使用$memo。当然,大多数情况下,显式地指定$memo一个比较依赖数组无疑是最高效的选择。

$memo并不要求所有的 props 属性都是不可变值,可以传入 object 等类型的值,它会使用deep diff技术。但是我们要指出一些容易进入陷阱的误区:

Field 具备可变函数或者包含可变函数的属性

以下a b c三个 Field 都属于具有可变函数属性(inline-render-props)。c中的$validators虽然是个 object,但是$validators.required是个函数,深度比较时依然会导致比较失败。

// 错误示范
<Field $memo name="a" $parser={value => value.trim()} />
<Field $memo name="b">
    {$fieldutil => {/*...*/}}
</Field>
<Field $memo name="a" $validators={{
    required: value => !!value || 'Reuqired!'
}} />

如果要优化的话,就是把这些可变函数值变为不可变值。即如果是 class 组件,就放到组件实例上,如果是 function 组件,使用useCallback优化:

// 优化后
function App() {
    const $parser = React.useCallback(value => value.trim(), []);
    const renderFieldB = React.useCallback($fieldutil => {
        /*...*/
    }, []);
    const $validators = React.useMemo(
        () => ({
            required: value => !!value || 'Reuqired!'
        }),
        []
    );
    return (
        <Form>
            <Field $memo name="a" $parser={$parser} />
            <Field $memo name="b">
                {renderFieldB}
            </Field>
            <Field $memo name="a" $validators={$validators} />
        </Form>
    );
}

更高效的优化是,如果明确知道这些函数的渲染依赖项或者没有任何依赖,可以明确指定依赖比较项:

//  优化后
<Field $memo name={[]} $parser={value => value.trim()} />
<Field $memo name={[]}>
    {$fieldutil => {/*...*/}}
</Field>
<Field $memo name={[]} $validators={{
    required: value => !!value || 'Reuqired!'
}} />

因为这里三个 Field 的函数属性都不依赖第三方状态值,所以直接设置空数组即可。但是假如或有依赖呢?

如下的例子,这里直接$memo={[]}将导致,即使当前组件的props.isTrmValue变了,但是 Field 依然不会更新。导致用户此后进行输入的第一个字符可能无法正确被处理。

要优化这个问题,可以将props.isTrmValue假如$memo数组即可!则是 Field 就知道,如果props.isTrmValue值变了,需要重新渲染自身。

// 错误示范
<Field $memo={[]} name="a" $parser={value => props.isTrimValue ? value.trim() : value} />

// 优化后
<Field $memo={[props.isTrimValue]} name="a" $parser={value => props.isTrimValue ? value.trim() : value} />

Field 包含大数据值属性

大数据值是指数据量特别大,例如一些富文本编辑器,例如draftjscontentState对象,就是非常复杂、庞大的数据。使用$memo进行深比较的话,器带来的开销很有可能会大于对这个 Field 进行重复渲染的开销!所以需要一些特殊手段。

下面的例子中,由于 Editor 组件的 contentState 是个大数据值,所以无论$memo={true}还是$memo={[contentState]}都是不够高效的,但是我们可以基于 contentState 的浅比较创建一个随着 contentState 变化而变化的值,作为$memo比较的依赖项:

// 优化前,直接传递contentState到$memo,深比较性能较差
<Field name="editor" $memo={[contentState]}>
    <Editor contentState={contentState} />
</Field>
// 优化后,hooks示例
function App(props) {
    // 使用useRef存储
    // 注意不要使用useState等存储,否则setState会带来额外的渲染
    const contentStateRef = useRef({
        contentState: props.contextState,
        renderCount: 0
    });

    useEffect(() => {
        // 记录每次渲染后的contentState
        contetnStateRef.current.contentState = contentState;
    }, [props.contentSatte]);

    // 当前contentState与前一次的浅比较不一致后,将renderCount+1
    if (contetnStateRef.current.contentState !== props.contentState) {
        contetnStateRef.current.renderCount++;
    }

    return (
        <Form>
            <Field name="editor" $memo={[contetnStateRef.current.renderCount]}>
                <Editor contentState={props.contentState} />
            </Field>
        </Form>
    );
}

// class示例
class App extends React.Component {
    state = {
        renderCount: 0,
        contentState: this.props.contentState
    };

    static getDerivedStateFromProps(props, state) {
        if (props.contentState !== state.contentState) {
            return {
                state: state.renderCount + 1,
                contextState: props.contextState
            };
        }

        return null;
    }

    render() {
        return (
            <Form>
                <Field name="editor" $memo={[this.state.renderCount]}>
                    <Editor contentState={this.state.contentState} />
                </Field>
            </Form>
        );
    }
}

总结

  • 最小化原则,即将 Form 放到离 Field 最近的地方,避免直接套在一个过高的顶层组件树中
  • 被动优化原则,即当发生了渲染问题后再进行优化,避免提前优化、过度优化
  • 快准狠原则,即分析找出影响渲染的组件,精准优化;避免不成为性能瓶颈的 Field 也被优化

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.