React 嵌套对象的性能优化
发布时间:
最后更新:
在最近写的 React 应用 ICAnalyzer 中遇到了性能问题,总结下优化经验。
ICAnalyzer 的详细介绍见 ICAnalyzer 开发小记,这里就不再提了,这个应用里头有一些比较复杂的表单,嵌套了好几层,比如核心的编码器选项:
- 所有的编码器配置包含在一个大对象中,作为顶层的状态。
- 每个编码器配置,有是否启用和选项列表两个属性。
- 选项列表包含一系列选项的状态。
- 一些选项组件包含多个输入组件,每个组件有值。
这个表单嵌套了 4 层,全部的状态都放在顶层对话框的一个 State Hook 里,一旦更改了任意属性,对话框的渲染函数都将被调用,下面一大堆组件全部重新渲染,性能消耗相当大。
实测在我的古董笔记本(i5-4200M)上调整一个选项,其渲染时间需就要 57.8 ms。浏览器的刷新率是 60 fps,也就是说两次刷新之间只有 16 ms 的空闲,一旦主线程阻塞时间超过该值就会掉帧。
通常来说填写表单并不需要这么高的 fps,但不巧的是本应用里有个滑块组件,它对于流畅度是有要求的。
滑块组件
性能优化 #
翻阅 React 文档的性能优化一章,其中就明确提到了使用浅比较来跳过状态没有改变的组件的渲染。传统的类组件通过shouldComponentUpdate
钩子或者PureComponent
来实现,而对于函数式组件则使用React.memo
。
按理来说,一个函数的状态和输入相同,那么它返回的值也相同,这么看所有函数式组件应该都可以 memo,但 React 并没有这么做,它的作者认为不值得,所以只能自己选择开销大且 props 变化少的组件来手动用 React.memo
封装。
如果应用在开发中遵循了 React 的数据不变原则,那么就可以仅通过浅比较来判断相等。但还要注意一点,每因为次渲染时都要执行一次渲染函数,其内部的闭包函数每次都会重新创建一个,这可能导致浅比较失效。
对此,官方给出的解决方案是useCallback
,把函数用useCallback
包装后只要依赖数组中的项跟上次渲染相同,则返回跟上次一样的引用,这样就避免了闭包函数的变动。
export default function Component() {
const [value, setValue] = useState(...);
// 闭包函数在每次渲染时都会创建新的。
function handleChange(e) {
setValue(e.currentTarget.value);
}
// 使用 useCallback 当 onValueChange 没变时返回之前的。
const memoized = useCallback(handleChange, [onValueChange]);
return <Child value={value} onChange={memoized}/>;
}
最后把子组件用 memo 一包就搞定。
function Child(props) { /* ... */ }
export default React.memo(Child);
就这样,通过使用React.memo
包装一部分开销较大的组件,主要是各种输入框,成功地将渲染耗时降低到了 10.9 ms,滑块拖起来流畅多了。
代码优化 #
解决了性能问题就完了吗?当然没有,刚才为了确保 setter 函数不变,我们搞出了一大堆useCallback
,又臭又长。
当然这还不是最恶心的,为了保证状态对象的不变性,不能直接修改 state,而是要这样:
const [value, setValue] = useState(...);
function setInnerValue(newValue) {
setValue(prev =>({ ...prev, inner: newValue}));
}
给setValue
的参数需要是一个函数,在里头复制当前的对象并修改属性,最后返回复制的对象。这个例子看起来还不是很麻烦,但是在最开始提到了本项目表单组件的状态嵌套了很多层,所以真正的代码是这样的:
const [value, setValue] = useState({ a: { b: { c: 0 } } });
const setA = (newValue) => setValue(prev => ({
...prev, a: newValue
});
const setC = (newValue) => setValue(prev => ({
...prev, a: {
...prev.a, b: {
...prev.b, c: newValue
}
}
});
const memoizedSetA = useCallback(setA, [setValue]);
const memoizedSetC = useCallback(setC, [setValue]);
return <Child value={value} onChangeC={memoizedSetC}/>;
那一堆...
看着就烦,遍地都是这样的代码还谈何维护性?
实际上这个需求很常见,就是众所周知的不变对象的修改问题,解决方案也有很多,比如 Immutable.js、immer 等等,但它们都只解决了一部分问题:
-
Immutable.js 使用自定义的 Map 和 List 来实现 Copy on write,这种实现方式很不好,自定义对象的 API 跟原生的
object
和array
不同,这会导致大量的重构。 -
immer 使用 ES6 的代理,做到了跟原生对象一样的 API,但它的
produce
函数又要套一层,写起来一点都不方便。
更重要的是,上述代码中实际存在两个问题:修改深层属性和函数引用不变,而这些库只解决了第一个,那一堆useCallback
仍然无法消除。这对于有强迫症的我是不可接受的,于是开始思考如何才能完美地解决这些问题。
合并函数的设计 #
我们知道useState
这个 Hook 返回的 Setter 的引用是不变的,相当于内部已经useCallback
过了。而统计了本应用里表单里的输入组件之后,我发现它们基本不涉及 State 之外的值,这给我提供了一个思路:能不能以 useState
的 Setter 函数为根,构建一棵树,节点也都是 Setter 函数,并与状态对象属性对应?
首先是子 Setter 的创建方法,通过一个简单的闭包记录下属性名即可:
const [value, setValue] = useState({ a: { b: { c: 0 } } });
// 创建子 Setter,这里只能合并对象,但增加对数组、Map的支持也不难。
function derive(setValue, key) {
function subSetValue(action) {
if (typeof action !== "function") {
return setValue.set(key, action);
}
setValue(prev => {
const newVal = action(prev[key]);
return { ...prev, [key]: newVal };
});
}
return useCallback(subSetValue, [setValue, key]);
}
const setA = derive(setValue, "a");
const setB = derive(setA , "b");
const setC = derive(setB, "c");
这样一棵树就构建完成,调用任何子 Setter,改动都会递归地传播到根节点,使路径上的所有对象都变为新的,完美符合 React 的不变性要求。另外生成的子 Setter 的函数签名跟setValue
一样,也就是说对调用方透明,这在某些重构的情况下可以省不少事。
为了进一步精简代码,可以将derive
函数挂载到setValue
上,并添加一些便捷函数,因为setValue
是不变的所以完全可行。顺便给增强之后的 Setter 取个新名字 Merger,意思是能将改动向上级合并的函数。
function getMerger(merger) {
if (merger.cache) {
return merger;
}
merger.cache = {};
merger.merge = changes => {
merger(prev => ({ ...prev, ...changes }));
};
merger.set = (key, value) => {
merger(prev => ({ ...prev, [key]: value }));
};
// 子 Setter 保存到父级,derive 函数里的 useCallback 都可以省了。
merger.sub = (key) => {
return merger.cache[key] ??= derive(merger, key);
};
return merger;
}
用法:
const [value, setValue] = useState({ a: { b: { c: 0 } } });
const setA = getMerger(setValue).sub("a");
const setC = setA.sub("b").sub("c");
// 直接修改某个属性
setA.set("d", 1);
// 同时修改 a 中的 d 和 e 两个属性
setA.merge({ d: 1, e: 2 });
具体的代码可在 ICAnalyzer 的源码里找到。实际使用大概是下面这个样子,没有合并也没有useCallback
,所有 props 里的onChange
函数都不会在渲染时改变,完美适应React.memo
。
function Component() {
const [value, setValue] = useState(...);
return <Child value={value} onChange={getMerger(setValue)}/>;
}
const Child = React.memo((props) => {
const { value, onChange } = props;
return <NestedChild value={value.nested} onChange={onChange.sub("nested")}/>;
});
const NestedChild = React.memo((props) => {
const { value, onChange } = props;
return <input value={value} onChange={e => onChange(e.target.value))}/>;
});
当然这种设计也不是没有缺点,因为子 Setter 无法感知到父级,所以它要求组件的层次必须与数据结构相对应,当然只要组件化做得好肯定是没问题的。
另外为了去掉useCallback
,所有的操作都要设置在 Setter 上,如果需要的操作不在此列,比如() => setValue(value+=1)
这样的还是得再创建单独的函数并将其用useCallback
包装。当然也可以选择把它作为便捷函数放在 Setter 的属性上,这就看如何取舍了。
总结 #
通过React.memo
以及让 Setter 保持不变,成功地将渲染函数的执行时间降低到 16 ms 以下,大幅提升了应用的流畅度。同时封装 Setter 构建 Setter 树,消除了重复的面条代码,真正做到了性能代码两不误。