这里的超大对象,是指如 50 MB 左右,甚至以上的对象,我们还需要在他们变化时更新渲染。对于这种大型对象,渲染的效率不仅非常重要,内存占用率,以及重绘的次数也是需要重要考虑的一点。
是什么情况下,我们会需要管理这么大的状态对象呢?一个简单的例子是图表,例如包含海量数据的点图,折线图等;
另一个例子,则是一些复杂的树形结构,其中复杂的层次关系和节点的元数据,也大大增加了对象大小。
这样的大状态在网络传输时可能经过良好的压缩,或者直接从本地读取,但解析后如何高效的在 React 中进行状态更新,则是我们今天希望解决的问题:
TL; DR 通过
useRef
,useState
,useMemo
来更新大对象是一个强大而有效的解决方案,能最大限度的节省内存空间,减少重绘次数。其中,useRef
用于实际存储数据,而useState
和useMemo
则用于控制组件的重绘。
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 查看内存实际占用情况:
从上面两图可以看到,实际上该 state 占用了两份内存空间:
- 一份 ID 为
@538091
,在fiber@67791
下 - 另一份 ID 为
@538915
,在fiber@67791
的alternate
之下
相信比较容易猜测到,alternate
之下的是上一次渲染时的状态,具体也可以参考另一位掘友的文章:
另外一个重要属性就是 alternate,我们需要它是因为大多数时间我们将会有两个fiber tree。一个代表着已经渲染的dom, 我们成其为current tree 或者 old tree。另外一个是在更新(当调用setState或者render)时创建的,称其为work-in-progress tree。
作者:山石岩
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
也就是说,上一次渲染的 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:
可以看到,useRef
构造出 Ref
的 current
中确实只存储了一份数据。
那当 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
手动控制组件的视图更新,实现完全避免不必要的视图更新。