You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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 的升级补充。
children 再说children属性,在component/react element层面指其props.children,但是在Virtual DOM/Fiber
Tree中,则是指其子节点。props.children不一定是子节点,因为组件本身可能不渲染props.children或者还会额外渲染其它elements。children属性和children节点不一定是相同的。
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.
前面我们讲到了 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 状态的组件,我们优先保证在这些需求,最小代价满足。而分布式表单,明显是假设表单中不存在副作用,当有副作用需要时,手动去管理。
对于表单整体刷新,这个设计理念不会改变。既然 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是目前清理算法中的待移除标记)。
speed-formutil
高性能 react 表单优化指南
1.0
以后的版本如何解决上述问题$formutil
生成非Field
组件Field
组件我们的页面为什么会卡顿?
在浏览器层面,卡顿无非就是 js 线程卡或者 UI 绘制线程(DOM 更新)卡。
具体到 react 应用中,绝大多数情况,我们的组件树结构没有大的变化(即不会产生大的 DOM 变动),但是页面依然出现了卡顿,那么就是只有一个原因:组件的
reconciliation
过程开销巨大。具体原因就是由于组件的 state 或者 props 变化,react 会从当前组件进行
reconcilier
,开始重建整个 react elements。大量的重建 elements、Fibe Node、Diff 消耗了大量的计算和资源。当 js 线程占用过久,就会影响浏览器渲染,进而引起页面掉帧,用户开始感觉到卡顿。所以关键就是减少组件重新执行 render,也就得减少组件进入 Reconciliation。
如何优化 react 组件?
知道了性能问题产生在哪里,那么就减少导致性能的情况出现即可,也即
Avoid Reconciliation
。我们要避免reconciliation
开销大的组件非必要重复渲染。在 react 中,可以阻止组件渲染的方法就是重写 class 组件的
shouldComponentUpdate
生命周期方法,或者通过 React.memo 优化 fuction 组件。React.PureComponent
是shouldComponentUpdate
浅比较 props 和 state 的快速实现。借图说话:
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.常见问题
PureComponent 这么方便,我是不是应该编写组件时都直接使用 PureComponent 就好了?
这个答案一定是否定的。react 没有把基于 shouldComponentUpdate 的浅比较作为默认的组件更新行为就知道,这么做一定是不合适的。它会导致许多问题变得复杂,具体原因如下:
Immutable
的,这会成为潜在的 bug 点,并难以被发现。对于复杂的数据结构,如果没有应用Immutiable
技术,那么对其子属性的更改可能无法生成可变值;或者在传递前,总是简单的{...xxx}
,那么就会导致上一个原因,即 prop 总是在变导致 legacy context 的更新失效当然,新的 context api 让这一点不再成为问题PureComponent 容易被可变对象破坏,那我用 shouldComponentUpdate 深比较可以了吧?
这个答案更是否定的。深比较虽然可以解决 object 对象的一致性比较,但是也存在非常明显的问题:
我自己根据实际组件编写 shouldComponentUpdate 比较逻辑总可以了吧?
可以这么做,但是一定要非常小心。因为没有一成不变的组件,未来在业务发展、项目迭代中,可能会因为新加入的 props 不符合 shouldComponentUpdate 的比较逻辑导致组件出现渲染异常。并且这个异常对部分人来说可能是难以察觉、发现的。如果对于 react 的更新渲染机理非常熟悉,可以编写良好的比较逻辑,当然这么做是个好的选择
基于以上考虑,我给的最佳实践方案是:
PureComponent
memo
shouldComponentUpdate
等技术使用了 shouldComponentUpdate 阻止了组件渲染,是否就一定阻止了所有子组件的渲染?
并不是这样的,新的 context 不受这种优化的影响。即如果 context.Provider 传递的 value 发生了变化,所有连接的 Consumers,包括使用
useContext
Hook 的函数组件,都会重新渲染,不受上层组件的 shouldComponentUpdate 影响。这个特性也正是相比于老版本 context 的改进之一。但是这个特性不注意也会导致 shouldComponentUpdate 优化失败,导致依然有性能问题产生:
这个例子中,如果 App 重新渲染,即使 PreventRender 使用了 memo 阻止了重新渲染。但是 ColorConsumer 组件依然会被重新渲染。
总结
对于第三方组件或者不方便直接进行渲染优化改造的组件,可以尝试
memo-render
这个组件,它可以方便的在无需调整组件内部逻辑的情况下达到优化组件树渲染的目的。我们在下方的表单优化环节也会对此进行介绍。react-formutil 1.0 是如何优化的?
之前版本的 react-formutil 的性能瓶颈在哪里?
前面我们讲到了 react 性能降低来自于复杂组件的
reconciliaton
的开销。而react-formutil
的 Form 作为全局状态控制器,同步所有 Field 的状态更新。在表单场景中,用户快速输入导致的整个 Form 频繁进入reconciliation
。如果 Form 中存在一个明显reconciliation
开销过大的组件(不一定需要是 Field 相关组件),那么就会导致页面出现明显卡顿。具体来说react-formutil
的性能问题主要来自以下两点:reconciliation
开销过大的组件,就导致了性能问题$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 状态的组件,我们优先保证在这些需求,最小代价满足。而分布式表单,明显是假设表单中不存在副作用,当有副作用需要时,手动去管理。
总结
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
来优化。但是在进行优化前,还是要说一句:
这与之前提到的 react 组件进行渲染优化可能导致的潜在问题原因一致。在实际业务场景中,Field 的各种属性,包括$validators、$parser 或者 children 等都可能依赖各种上层状态,贸然进行渲染优化,可能导致负向优化产生,或者导致难以察觉的问题。
react-formutil
的设计就是全局表单状态同步,方便在 Form context 中,随时随地可以访问整个 Form 的状态。这是很自然、很 reactive、易用的,在没有明显性能问题前,保持表单组件按照 react 本身的reconciliation
逻辑进行更新,避免潜在问题。发现问题所在
当表单出现性能下降的,首先要找出
reconciliation
开销大的组件。可以通过 react 的Profiler
相关工具测试组件的渲染开销。要说明的是,优化开销大的
非Field
组件例如如下场景:
如果页面卡顿,可能是因为 Table 组件放到了 Form 下,其本身的
reconciliation
如果开销较大,那么就会导致 Form 更新时,其成为渲染瓶颈。针对这种情况,有以下两种处理:
将 Table 移出 Form 组件
这样之做避免 Table 被 Form 的频繁渲染影响。如果 Table 需要访问表单值,可以通过 Form 的
$onFormChange
把值传递到上层组件后传给 Table。优化调整 Table 本身的组件渲染
如果 Table 组件是自有组件,可以在 Table 加入 shouldComponentUpdate 渲染优化。但是如果 Table 属于第三方组件,那么这一条就不成立了,请看下一条。
通过第三方组件拦截 Table 的渲染
这个适用于要优化的组件是第三方组件,无法直接改造优化,或者不想在组件加入影响渲染机制的优化,只是想在此处临时处理。
这个就会用
memo-render
这个组件:优化开销大的
Field
组件对于 Field 的优化,则是通过
$memo
属性。事实上,它与memo-render
是相同的优化原理。$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
是个函数,深度比较时依然会导致比较失败。如果要优化的话,就是把这些可变函数值变为不可变值。即如果是 class 组件,就放到组件实例上,如果是 function 组件,使用
useCallback
优化:更高效的优化是,如果明确知道这些函数的渲染依赖项或者没有任何依赖,可以明确指定依赖比较项:
因为这里三个 Field 的函数属性都不依赖第三方状态值,所以直接设置空数组即可。但是假如或有依赖呢?
如下的例子,这里直接
$memo={[]}
将导致,即使当前组件的props.isTrmValue
变了,但是 Field 依然不会更新。导致用户此后进行输入的第一个字符可能无法正确被处理。要优化这个问题,可以将
props.isTrmValue
假如$memo
数组即可!则是 Field 就知道,如果props.isTrmValue
值变了,需要重新渲染自身。Field 包含大数据值属性
大数据值是指数据量特别大,例如一些富文本编辑器,例如
draftjs
的contentState
对象,就是非常复杂、庞大的数据。使用$memo
进行深比较的话,器带来的开销很有可能会大于对这个 Field 进行重复渲染的开销!所以需要一些特殊手段。下面的例子中,由于 Editor 组件的 contentState 是个大数据值,所以无论
$memo={true}
还是$memo={[contentState]}
都是不够高效的,但是我们可以基于 contentState 的浅比较创建一个随着 contentState 变化而变化的值,作为$memo
比较的依赖项:总结
The text was updated successfully, but these errors were encountered: