关于我:
儿葱,25岁,工作 3 年半,在百度工作 3 年,现在美团任职,对于 Node.js Web React 方向比较熟悉。Github: imcuttle 邮箱: imcuttle@163.com
下面进入正题,我将从一个实际的问题场景出发,层层讲诉 React 中 Mobx 和 Redux 的原理和对比。
在业务开发中,我们经常会需要开发一些复杂组件,其中不仅包括交互的复杂,也包括涉及到的数据结构复杂,如书写一个无限层级的树组件,其中的复杂度包括但不限于:
- 数据嵌套递归逻辑
- 各个节点更新渲染的逻辑处理
- 节点渲染性能的考量
以一个我碰到的实际业务场景为例:需要开发一个将 markdown文本渲染成 html 元素的组件。
设计考虑
大部分比较快速的实现方式比较简单,将 markdown 转换成 html 文本,然后使用 dangerouslySetInnerHTML 渲染 html 文本。
这种方式比较快捷,但是没有模块化、不方便注入交互逻辑。如需支持允许外部控制渲染各个 markdown 语法类型的结构体,这种方式不能够满足。
const MarkdownView = ({markdown}) => <div className="markdown-body" dangerouslySetInnerHTML={{__html: marked(markdown)}} />
所以这时候就应该设计成结构化渲染 markdown,如使用 remark 将 markdown 解析成为 Markdown AST (Abstract Syntax Tree 抽象语法树)
如以下 markdown 文本:
## Header
> Alpha bravo charlie.
将会解析成如下 MD AST
[
{
type: 'heading',
depth: 1,
children: [{type: 'text', value: 'Header'}]
},
{
type: 'blockquote',
children: [{
type: 'paragraph',
children: [{type: 'text', value: 'Alpha bravo charlie.'}]
}]
}
]
然后,我们只需要通过渲染 MD AST 即可
import React from "react";
const renderers = {
heading: (props) => {
const { depth } = props.node;
const tagName = `h${depth}`;
return React.createElement(tagName, {}, props.children);
},
paragraph: (props) => <p>{props.children}</p>,
blockquote: (props) => <blockquote>{props.children}</blockquote>,
text: (props) => <span>{props.node.value}</span>
};
const renderNode = (node, props) => {
const Comp = renderers[node.type];
return (
<Comp {...props} node={node}>
{(node.children || []).map((childNode, index) =>
renderNode(childNode, { parent: node, key: index })
)}
</Comp>
);
};
const MarkdownView = ({ mdast }) => {
return (
<div className="markdown-body">
{!!mdast && mdast.map((node, i) => renderNode(node, { key: i }))}
</div>
);
};
export default MarkdownView;
以上一段剪短的代码就是简单的实现,但是这段代码只是功能正确,在遇到大体量的 MDAST,同时涉及到 MDAST 更新渲染的时候,该 MarkdownView 性能较低。
优化组件的更新渲染性能,我们从以下 2 个点展开:
- 理清 React 的单向数据流和组件更新模式
- 优化更新性能
- immutable 操作
- 细化更新颗粒度
- 善于用 shouldComponentUpdate
- mobx?
mutable 操作
React 单向数据流和组件更新模式
以 MarkdownView 为例,MarkdownView 组件接收 mdast 数据,随后通过 props 传递给各 renderer 组件,这种从父组件传递数据到子组件的模式是单向数据流。
<MarkdownView> # 将 props 分发下去
<heading> <text>
由于这种模式是从上往下的数据传递,如涉及到子组件的更新,一般需要通过父组件数据改变而触发,也就是外部的 Props 修改来驱动更新;
如下代码,实现了点击 text 文本后,更新渲染为 input,用于更新 text.value:
const Text = (props) => {
const [editing, setEditing] = React.useState(false);
const [value, setValue] = React.useState(props.node.value);
React.useEffect(() => {
setValue(props.node.value);
}, [props.node.value]);
if (editing) {
return (
<input
autoFocus
onBlur={() => {
setEditing(false);
// 注意这里
props.node.value = value;
}}
value={value}
onChange={(e) => setValue(e.target.value)}
/>
);
}
return <span onClick={() => setEditing(true)}>{props.node.value}</span>;
};
具体代码见如下中的 mutable 模式:
https://codesandbox.io/s/markdown-view-9b7yq?file=/src/App.jscodesandbox.io效果是,blur 之后修改的 value 是可以渲染出来的,但是如果我们把代码修改成如下:
onBlur={() => {
setEditing(false);
// 注意这里
requestAnimationFrame(() => {
props.node.value = value;
})
}}
结果 value 不能被同步渲染出来,这是因为
props.node.value = value;
这种 mutable 操作,不能够触发组件的更新,而第一个例子之所以能够起效果,是因为 onBlur 中 setEditing(false) 触发更新是异步的,React 会进行批更新优化,这个话题后续有空可以细谈。
什么 mutable 操作呢?如下:
const ref = { name: 'John' }
// mutable 操作,执行后,ref 引用未发生改变
ref.name = 'Tom'
// immutable 操作,执行后,ref 引用发生改变
const newRef = { ...ref, name: 'Tom' }
也就是说在修改完 props.node.value 后,才触发 render 更新,所以可以生效;而 requestAnimationFrame 导致 props.node.value 修改发生在 render 之后;
基于 React 单向数据流原理,那么正确的 React 代码书写应该是如何的呢?
正确的基于 React 单向数据流的代码
根据 React 单向数据流原理,应该修改最外层的 mdast,然后从上向下的触发子组件渲染,如下父子组件渲染关系:
A
/
B C
/
D E F
MDAST 修改后,A 组件渲染驱动 B / C,B 驱动 D / E,C 驱动 F;但是这样也会带来一个问题:每一次 MDAST 修改,A 组件触发渲染,都会导致所有子组件进行渲染更新,对于比较庞大的组件树来说,效率低。
所以 React 引入了 shouldComponentUpdate 生命周期,下文用 scu 代替
在 MDAST 修改后,A 组件触发 scu,判断 A 组件可以 render 之后,B / C 组件触发 scu,依次进行下去。
所以为了提升效率,一般都会浅比较:新旧的 props 和 state, 来判断是否需要进行更新。因此一般会使用 immutable.js 或 immer 来对数据进行更新,但是最小程度的修改引用;
如下图,演示的是 immutable 库修改数据所做的事情:
可以看到对于一个深层嵌套的数据,修改黄色节点,会导致其祖先节点引用的修改,但其他节点引用都不会修改。
这样对应在组件中,每次修改节点数据,导致根组件数据改变,进而触发单向数据流渲染,对于已经书写妥当 scu 的组件,其相关的数据只要没有发生改变,是不会触发渲染更新的,减少不必要的渲染更新,提高性能,具体可以参考下例中的 immutable 模式
markdown-view - CodeSandboxcodesandbox.io对于单向数据流渲染更新,需要注意:
- 一个 react 组件才有自己的特定生命周期。如果对于MarkdownView 组件中各个节点数据的渲染统一都是通过一个个的方法体渲染,是不会提高更新颗粒度的,所以需要尽量多的拆分组件。
- 错误的 scu 书写会导致渲染更新错误
那么基于单向数据流的更新渲染,有没有它的问题存在呢?
答案是有的,随着数据量的增加,嵌套深度增加,每一次的底层组件数据修改,都会回溯到根组件的更新,每一次更新渲染也需要不断的 scu 和从父到子的 render ,即使是在 react diff 、react fiber 的优化下,还是会有对应的瓶颈出现。那么有没有一种不需要回溯渲染的方式呢?
答案依然是有的。
终极杀招 Mobx
使用 mobx 可以避免父到子的回溯渲染,如下例子中的 mutable mobx 模式
markdown-view - CodeSandboxcodesandbox.iomobx react 的更新渲染机制于官方的单向数据触发的渲染不同,mobx 会将 mdast 变成一个被观察的数据,在每一次 mutable 修改中,都会被观察到数据的改动。
一旦在发现同步渲染所访问到的数据发生改动,就会主动的触发组件的更新,如 forceUpdate,这样一来 mutable 的操作修改,只会影响到使用了该数据的组件,这些组件进行主动的更新,而不会回溯进行父到子的更新渲染,大大减少了不必要的 suc 和 render
总结
综上,需要书写一个高性能的复杂组件,需要考虑的点颇多,最终有两种方式可供大家选择:
- 基于 immutable 库和单向数据流 和 适量的组件拆分、scu 的书写来实现被动的更新。如 slate.js 的实现
- 基于 mobx 和适量的 observer 组件单元 来实现组件自驱的更新
后面贴一个其他同学做的测试数据,可以看到 mobx 性能更佳
redux、immutablejs和mobx性能对比(三) - 渴望做梦 - 博客园www.cnblogs.com