通过 React Hooks 实现超大对象的状态管理

1,264 阅读5分钟

这里的超大对象,是指如 50 MB 左右,甚至以上的对象,我们还需要在他们变化时更新渲染。对于这种大型对象,渲染的效率不仅非常重要,内存占用率,以及重绘的次数也是需要重要考虑的一点。

是什么情况下,我们会需要管理这么大的状态对象呢?一个简单的例子是图表,例如包含海量数据的点图,折线图等;

4d93d200260004019255af2355c3f16d.png

另一个例子,则是一些复杂的树形结构,其中复杂的层次关系和节点的元数据,也大大增加了对象大小。

这样的大状态在网络传输时可能经过良好的压缩,或者直接从本地读取,但解析后如何高效的在 React 中进行状态更新,则是我们今天希望解决的问题:

TL; DR 通过 useRef, useState, useMemo 来更新大对象是一个强大而有效的解决方案,能最大限度的节省内存空间,减少重绘次数。其中,useRef 用于实际存储数据,而 useStateuseMemo 则用于控制组件的重绘。

useState

修改单个属性

管理对象的状态更新,我们一般都会想到使用 React 自带的 useState

但如果在大对象更新时,如果我们每次都传入新的大对象,useState 的方法真的足够好吗?让我们通过下面的程序进行测试:

function App() {
    const [v, setV] = useState({ arrInUseState: new Uint8Array(0) });
    return <button onClick={() => {
        setV({ arrInUseState: new Uint8Array(1024 * 1024 * 1000) });
    }}>Button {v.arrInUseState.length}</button>;
}

具体的,测试中多次点击 button 后,在 Chrome DevTools 的 Memory Tab 中点击垃圾回收,待内存占用稳定后,我们对内存使用进行 Snapshot 查看内存实际占用情况:

407ad43d144638ee77cd52215c72f1fe.png

e37217426e8146129919f0bf5c58ab8d.png

从上面两图可以看到,实际上该 state 占用了两份内存空间:

  • 一份 ID 为 @538091,在 fiber@67791
  • 另一份 ID 为 @538915,在 fiber@67791alternate 之下

相信比较容易猜测到,alternate 之下的是上一次渲染时的状态,具体也可以参考另一位掘友的文章:

另外一个重要属性就是 alternate,我们需要它是因为大多数时间我们将会有两个fiber tree。一个代表着已经渲染的dom, 我们成其为current tree 或者 old tree。另外一个是在更新(当调用setState或者render)时创建的,称其为work-in-progress tree。

作者:山石岩

链接:juejin.cn/post/684490…

来源:稀土掘金

著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

也就是说,上一次渲染的 state 并不会被回收,因为 alternate 一直持有着该 state,我们占用了两倍所需的内存空间!但除了内存空间外,我们还需要避免重复渲染,下面让我们一步步实现:

useRef

比较容易想到的是,Ref 既然不需要通过与上次状态比较,来判定是否更新,我们自然可以用 useRef 来存储较大的状态,下面简单验证一下:

function App() {
    const vRef = useRef({ arrInUseRef: new Uint8Array(0) });
    return <button onClick={() => {
        vRef.current.arrInUseRef = new Uint8Array(1024 * 1024 * 1000);
        console.log(vRef.current.arrInUseRef.length);
    }}>Button {vRef.current.arrInUseRef.length}</button>;
}

和 useState 时一样,点击数次 Button,回收内存,Take Sanpshot:

669f981a8ab7440d84affb7fae353a8f.png

可以看到,useRef 构造出 Refcurrent 中确实只存储了一份数据。

那当 vRef.current 更新时,我们怎么进行状态更新呢?一个简单的方法是用一个简单的 state 作为标志位:

function App() {
    const [vStamp, setVStamp] = useState(0);
    const vRef = useRef({ arrInUseRef: new Uint8Array(0) });
    return <button onClick={() => {
		vRef.current.arrInUseRef = new Uint8Array(1024 * 1024 * 1000);
		// 更新 vRef.current 时,记得更新 vStamp
		setVStamp(Date.now());
		console.log(vRef.current.arrInUseRef.length);
    }}>Button {vRef.current.arrInUseRef.length}</button>;
}

这样,我们只要在更新 vRef.current 时,更新 vStamp,就会触发组件重渲染,自然起到了状态更新的效果。另外,在不需要更新时,我们也可以通过不更新 vStamp 来避免触发更新。

useMemo

内存的占用只是大对象的一个问题,有时候我们多占用 50, 100 多 MB 的内存,也不会有很大的问题(遇到内存问题时,是处理一个单个占用内存 500 MB 以上的对象)。

但更重要的一个问题是,这些大对象在视图更新起来自然不会特别快。例如,包含超大数据图表绘制可能就会有数秒的延迟。这种情况下,组件的其他 props, state, 可能本身只是为了获取/计算大对象本身,但现在都可能触发耗时的视图更新。

但现在的,有了 useState 的标记后,我们可以通过 useMemo 手动控制视图更新:

function App() {
	const [vStamp, setVStamp] = useState(0);
	const vRef = useRef({ arrInUseRef: new Uint8Array(0) });
	return useMemo(() => 
            (<button onClick={() => {
                    vRef.current.arrInUseRef = new Uint8Array(1024 * 1024 * 1000);
                    setVStamp(Date.now());
                    console.log(vRef.current.arrInUseRef.length);
            }}>Button {vRef.current.arrInUseRef.length}
            </button>), 
            [vStamp]
	);
}

这样,只有当初始化和 vStamp 更新时,useMemo 内的 button 才会被重新渲染。

总结

通过 useRef, useState, useMemo 来更新大对象是一个强大而有效的解决方案,能最大限度的节省内存空间,减少重绘次数。

其中,useRef 用于实际存储数据,避免浪费额外的内存空间, 实现大对象存储时存储空间的最高效利用。useState 则作为标志位标记组件是否需要更新,并用 useMemo 手动控制组件的视图更新,实现完全避免不必要的视图更新。