React 嵌套对象的性能优化

发布时间:

最后更新:

在最近写的 React 应用 ICAnalyzer 中遇到了性能问题,总结下优化经验。

ICAnalyzer 的详细介绍见 ICAnalyzer 开发小记,这里就不再提了,这个应用里头有一些比较复杂的表单,嵌套了好几层,比如核心的编码器选项:

表单结构表单结构

  1. 所有的编码器配置包含在一个大对象中,作为顶层的状态。
  2. 每个编码器配置,有是否启用和选项列表两个属性。
  3. 选项列表包含一系列选项的状态。
  4. 一些选项组件包含多个输入组件,每个组件有值。

这个表单嵌套了 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包装后只要依赖数组中的项跟上次渲染相同,则返回跟上次一样的引用,这样就避免了闭包函数的变动。

javascript
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 一包就搞定。

javascript
function Child(props) { /* ... */ }

export default React.memo(Child);

就这样,通过使用React.memo包装一部分开销较大的组件,主要是各种输入框,成功地将渲染耗时降低到了 10.9 ms,滑块拖起来流畅多了。

优化后优化后

代码优化 #

解决了性能问题就完了吗?当然没有,刚才为了确保 setter 函数不变,我们搞出了一大堆useCallback,又臭又长。

当然这还不是最恶心的,为了保证状态对象的不变性,不能直接修改 state,而是要这样:

javascript
const [value, setValue] = useState(...);

function setInnerValue(newValue) {
	setValue(prev =>({ ...prev, inner: newValue}));
}

setValue的参数需要是一个函数,在里头复制当前的对象并修改属性,最后返回复制的对象。这个例子看起来还不是很麻烦,但是在最开始提到了本项目表单组件的状态嵌套了很多层,所以真正的代码是这样的:

javascript
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 等等,但它们都只解决了一部分问题:

更重要的是,上述代码中实际存在两个问题:修改深层属性和函数引用不变,而这些库只解决了第一个,那一堆useCallback仍然无法消除。这对于有强迫症的我是不可接受的,于是开始思考如何才能完美地解决这些问题。

合并函数的设计 #

我们知道useState这个 Hook 返回的 Setter 的引用是不变的,相当于内部已经useCallback过了。而统计了本应用里表单里的输入组件之后,我发现它们基本不涉及 State 之外的值,这给我提供了一个思路:能不能以 useState的 Setter 函数为根,构建一棵树,节点也都是 Setter 函数,并与状态对象属性对应?

MergerTreeMergerTree

首先是子 Setter 的创建方法,通过一个简单的闭包记录下属性名即可:

javascript
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,意思是能将改动向上级合并的函数。

javascript
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;
}

用法:

javascript
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

javascript
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 树,消除了重复的面条代码,真正做到了性能代码两不误。

评论加载中