旧文重读:给被绑架的子组件讨个说法

最近重新回看了早期发表在掘金的技术文章,我对自己的耐心感到陌生。

工程师在变,框架在变,AI 也能替我们讲出答案了,但万幸,这篇文章的思路并不过时。

原文如下:


前言

沉浸式地写过一个React项目就会发现,不同于一些替你做决定的框架,“潜规则”丰富的React远比看上去要难相处。

React中主要有两类坑点,一种是现象不符合预期,让你措手不及,严重影响开发进度。另一种是看似风平浪静,水下暗流涌动,不动声色地孕育隐患。

官方文档不会介绍花样百出的最差实践,所以下一批开发者又会掉入相同的陷阱。隐藏的坑点需要开发者亲自下地扫雷,经验主义发挥了重要作用,尤其是在Hooks使用中。

这个系列的文章会介绍一些React使用的常见陷阱,带你追溯原因和探索解决方案,帮助新手迅速跳过坑点。

往期文章:

【React避坑指南】useEffect依赖引用类型

上一篇文章我们讲述了useEffect依赖引用类型时,即使依赖项的值不变,也会执行引起不必要的重渲染。这次我们关注另一种无意义的重渲染。

绑架式重渲染

这里由外向内定义了三层组件,结构是 App -> A -> B | C。每个组件都设置了独立的state,点击button后会更新state,组件之间没有props传递。

1function App() { 2 const [count, setCount] = useState(0); 3 4 useEffect(() => { 5 const clear = changeBgColor("parent", "green"); 6 return clear; 7 }); 8 9 return ( 10 <div id="parent" className="component"> 11 <button onClick={() => setCount(count + 1)}> APP</button> 12 <CompA /> 13 </div> 14 ); 15} 16 17function CompA() { 18 const [count, setCount] = useState(0); 19 20 useEffect(() => { 21 const clear = changeBgColor("a", "red"); 22 return clear; 23 }); 24 25 return ( 26 <div id="a" className="component"> 27 <button onClick={() => setCount(count + 1)}>Component A</button> 28 <CompB /> 29 <CompC /> 30 </div> 31 ); 32} 33 34function CompB() { 35 const [count, setCount] = useState(0); 36 37 useEffect(() => { 38 const clear = changeBgColor("b", "yellow"); 39 return clear; 40 }); 41 42 return ( 43 <div id="b" className="component"> 44 <button onClick={() => setCount(count + 1)}>Component B</button> 45 </div> 46 ); 47} 48 49function CompC() { 50 const [count, setCount] = useState(0); 51 52 useEffect(() => { 53 const clear = changeBgColor("c", "blue"); 54 return clear; 55 }); 56 57 return ( 58 <div id="c" className="component"> 59 <button onClick={() => setCount(count + 1)}> Component C</button> 60 </div> 61 ); 62} 63 64export default App;

我们知道,useEffect如果不添加第二个参数,每次组件刷新都会执行回调,借助这个特性,我们为每一个组件绑定背景渐变的动画,便于观察组件是否刷新。

1const changeBgColor = (key: string, color: string) => { 2 const ele = document.getElementById(key) as HTMLElement; 3 4 ele.style.backgroundColor = color; 5 6 setTimeout(() => { 7 ele.style.transition = "background-color 1s"; 8 ele.style.backgroundColor = "transparent"; 9 }, 0); 10 11 const timer = setTimeout(() => { 12 ele.style.transition = ""; 13 }, 1000); 14 15 return () => clearTimeout(timer); 16};

当我们更新最外层组件App的state时,App会重渲染,作为子组件树的A、B、C会一同刷新。进一步依次更新每一个子组件的状态,可以发现:

  • 更新A的state -> A、B、C刷新, App不变;
  • 更新B的state -> B刷新, 其余不变;
  • 更新C的state -> C刷新, 其余不变;

根据以上观察,于是有了结论:

父组件渲染会导致整个子组件树刷新,无关子组件的状态或参数是否改变。子组件渲染不会影响父组件和兄弟组件。

子组件从属于父组件,随父组件刷新,看似符合直觉。

但在这个案例中,子组件不会接受父组件的props,子组件render的内容与父组件无关,父组件刷新时,子组件状态也不会改变,但子组件却被“绑架”着执行了一轮重渲染,这貌似也有争议。

因为我们期待的理想状态,就如同Svelte文档中讲到的:

Svelte 编写的代码在应用程序的状态更改时就能像做外科手术一样更新 DOM。

但是这时不得不提起另一句:

框架的设计是权衡的艺术,框架之间的差异反应出设计者的认知。

React认为在大多数场景下,子组件并非纯渲染组件,其props继承自父组件,父组件状态更新,会影响到子组件render的内容,因此子随父变的逻辑适用80%以上的场景,其余场景可以提供API专门应对。

于是就有了React.memo

保护罩,可隔离

React.memo是一种高阶函数,可以用于优化函数组件的性能。当使用React.memo包装一个组件时,React.memo会将组件的props与前一次渲染的props进行浅比较。如果props没有发生变化,则React.memo会使用上一次渲染的结果,而不重新渲染组件。

我们将上例中的内层组件C用memo包裹,重复上述试验,可以观察到, 无论父组件的状态如何变化,C组件岿然不动,脱离了父辈绑架。

1const MemoC = memo(CompC);

如果我们继续把Memo包裹的组件上提至A组件,可以发现A组件及其子组件,都不受最外层App状态更新的影响,Memo如同保护罩防止了内层组件的刷新。

保护罩,但镂空

这个问题看似解决了,上例中的组件没有涉及props的传递, 我们进一步让组件更加复杂,给内层组件A增加一个propskeyvalue,值为外层组件的state

1const App = () => { 2 const [count, setCount] = useState(0); 3 4 useEffect(() => { 5 const clear = changeBgColor("parent", "green"); 6 return clear; 7 }); 8 9 return ( 10 <div id="parent" className="component"> 11 <button onClick={() => setCount(count + 1)}>App</button> 12 <CompA value={0} /> 13 </div> 14 ); 15}; 16 17const CompA: React.FC<{ value: number }> = memo( 18 ({ value }) => { 19 useEffect(() => { 20 const clear = changeBgColor("a", "red"); 21 return clear; 22 }); 23 24 return ( 25 <div id="a" className="component"> 26 <div>Memo (Component A) </div> 27 </div> 28 ); 29 } 30);

尽管被Memo包裹,但由于props变化,内层的组件还是重新执行了一轮渲染。

如果把props值改为常量,内层组件不会刷新,memo发挥了缓存组件的作用,以上符合预期。

但是,当我在props中增加一个object,值为{ value: 0 }:

1const App = () => { 2 // ... 3 4 return ( 5 <div id="parent" className="component"> 6 <button onClick={() => setCount(count + 1)}>App</button> 7 <CompA value={0} object={{ value: 0 }}/> 8 </div> 9 ); 10}; 11 12const CompA: React.FC<{ value: number, object: { value: number } }> = memo( 13 ({ value, object }) => { 14 // ... 15 } 16);

发现尽管每次传递的props恒定,而且子组件使用Memo包裹,但父组件刷新时还是连带子组件,似乎memo这一层保护罩是镂空的。

看过上一篇文章的同学一定能猜到,这还是由于Javascript中引用类型的储存方式和React Hooks的浅比较机制决定的。

useEffect类似,memo也采用浅比较决定是否执行组件的render(),对于原始类型,这并没有问题。但当props为引用类型时,尽管object内部的值相同,父组件每次刷新,都会新建另一个object传给子组件: const object = { value: 0 }

由于前后二次创建的object在内存中的地址完全不同,{ value: 0 } === { value: 0 }浅比较始终为false,子组件进行了无效刷新。

所以说,memo这层函数组件保护罩,看似坚固,实际是镂空型钢丝网,并非想象中的铁板一张。

保护罩,但后果自负

但具体应用中,我们不能放弃在props中传递引用类型,但如何让引用类型的内存地址保持一致呢?

这里先给出第一种解法, 从父组件传递的内容入手。

1const App = () => { 2 // ... 3 4 const object = useMemo(() => { 5 return { value: 0 } 6 }, []); 7 8 return ( 9 <div id="parent" className="component"> 10 <button onClick={() => setCount(count + 1)}>App</button> 11 <CompA value={0} object={object}/> 12 </div> 13 ); 14};

既然每一次函数组件的刷新导致props重新声明,那不如把引用类型的propsuseMemo包裹成可缓存的变量,只在组件挂载时创建这个变量,后续更新组件并不会改变object的保存的地址和值。

如果object作为props同时传给了多个子组件,这种从上层组件解决问题的方式,就非常适合,对于新组件的拓展,少了很多心智负担。

但是这种思路就像是给镂空保护罩附带了使用说明,要求被过滤的物体尺寸不能小于一个数值,否则后果自负。

保护罩,但Plus

第二种方案从子组件的刷新机制入手,深入了解memo会发现,它的第二个参数是一个可选的比较函数areEqualmemo利用这个函数判断组件的props是否发生变化。如果areEqual函数没有传入,则默认使用浅层比较shallowEqual

1function memo<Props extends object>( 2 Component: (props: Props) => ReactElement | null, 3 areEqual?: (prevProps: Props, nextProps: Props) => boolean, 4): NamedExoticComponent<Props>; 5 6function areEqual(prevProps: object, nextProps: object): boolean { 7 return shallowEqual(prevProps, nextProps); 8} 9 10function shallowEqual(objA: unknown, objB: unknown): boolean { 11 if (Object.is(objA, objB)) { 12 return true; 13 } 14 15 if (typeof objA !== 'object' || objA === null || typeof objB !== 'object' || objB === null) { 16 return false; 17 } 18 19 const keysA = Object.keys(objA); 20 const keysB = Object.keys(objB); 21 22 if (keysA.length !== keysB.length) { 23 return false; 24 } 25 26 for (let i = 0; i < keysA.length; i++) { 27 if ( 28 !Object.prototype.hasOwnProperty.call(objB, keysA[i]) || 29 !Object.is(objA[keysA[i]], objB[keysA[i]]) 30 ) { 31 return false; 32 } 33 } 34 35 return true; 36}

所以我们可以自定义这个函数的判断逻辑,使用deepClone或者手动比较对象的值。

1const CompA: React.FC<{ value: number; object: any }> = memo( 2 ({ value, object }) => { 3 4 //... 5 6 return ( 7 <div id="a" className="component"> 8 <div>Memo (Component A) </div> 9 </div> 10 ); 11 }, 12 (prev, next) => { 13 // 对象的深比较 14 return deepCompare(prev, next); 15 // 或采用自定义逻辑 16 // return prev.value === next.value && prev.object.value === next.object.value; 17 } 18);

这种方法背后的思路是追根溯源的改变memo的刷新逻辑,如果很多组件都要使用memo,完全可以封装一个deepMemo一劳永逸的作为保护罩Plus。

1const deepMemo = (FunctionComponent) => { 2 return memo(FunctionComponent, (prev, next) => { 3 return deepCompare(prev, next); 4 }) 5}

总结

说来一圈,这个问题并不复杂,但疏于关注的重渲染的开发者未必会意识到,而且正如上篇提到的,这篇文章依然遵循我们解决问题的一般思路:

由异常现象出发(子组件无效渲染),到文档和源码追根溯源(memo的用法),在语言底层定位原因(基本类型和引用类型的储存方式不同),围绕原因提出治标(修改外部传入props)或治本(修改内部刷新机制)的方案,优化方案形成更好的工程实践(deepMemo),总结形成可推广复用的逻辑