前言
沉浸式地写过一个React项目就会发现,不同于一些替你做决定的框架,“潜规则”丰富的React远比看上去要难相处。
React中主要有两类坑点,一种是现象不符合预期,让你措手不及,严重影响开发进度。另一种是看似风平浪静,水下暗流涌动,不动声色地孕育隐患。
官方文档不会介绍花样百出的最差实践,所以下一批开发者又会掉入相同的陷阱。隐藏的坑点需要开发者亲自下地扫雷,经验主义发挥了重要作用,尤其是在Hooks使用中。
这个系列的文章会介绍一些React使用的常见陷阱,带你追溯原因和探索解决方案,帮助新手迅速跳过坑点。
往期文章:
上一篇文章我们讲述了useEffect
依赖引用类型时,即使依赖项的值不变,也会执行引起不必要的重渲染。这次我们关注另一种无意义的重渲染。
绑架式重渲染
这里由外向内定义了三层组件,结构是 App -> A -> B | C
。每个组件都设置了独立的state
,点击button后会更新state
,组件之间没有props
传递。
function App() {
const [count, setCount] = useState(0);
useEffect(() => {
const clear = changeBgColor("parent", "green");
return clear;
});
return (
<div id="parent" className="component">
<button onClick={() => setCount(count + 1)}> APP</button>
<CompA />
</div>
);
}
function CompA() {
const [count, setCount] = useState(0);
useEffect(() => {
const clear = changeBgColor("a", "red");
return clear;
});
return (
<div id="a" className="component">
<button onClick={() => setCount(count + 1)}>Component A</button>
<CompB />
<CompC />
</div>
);
}
function CompB() {
const [count, setCount] = useState(0);
useEffect(() => {
const clear = changeBgColor("b", "yellow");
return clear;
});
return (
<div id="b" className="component">
<button onClick={() => setCount(count + 1)}>Component B</button>
</div>
);
}
function CompC() {
const [count, setCount] = useState(0);
useEffect(() => {
const clear = changeBgColor("c", "blue");
return clear;
});
return (
<div id="c" className="component">
<button onClick={() => setCount(count + 1)}> Component C</button>
</div>
);
}
export default App;
我们知道,useEffect
如果不添加第二个参数,每次组件刷新都会执行回调,借助这个特性,我们为每一个组件绑定背景渐变的动画,便于观察组件是否刷新。
const changeBgColor = (key: string, color: string) => {
const ele = document.getElementById(key) as HTMLElement;
ele.style.backgroundColor = color;
setTimeout(() => {
ele.style.transition = "background-color 1s";
ele.style.backgroundColor = "transparent";
}, 0);
const timer = setTimeout(() => {
ele.style.transition = "";
}, 1000);
return () => clearTimeout(timer);
};
当我们更新最外层组件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组件岿然不动,脱离了父辈绑架。
const MemoC = memo(CompC);
如果我们继续把Memo
包裹的组件上提至A组件,可以发现A组件及其子组件,都不受最外层App状态更新的影响,Memo
如同保护罩防止了内层组件的刷新。
保护罩,但镂空
这个问题看似解决了,上例中的组件没有涉及props
的传递, 我们进一步让组件更加复杂,给内层组件A增加一个props
,key
为value
,值为外层组件的state
。
const App = () => {
const [count, setCount] = useState(0);
useEffect(() => {
const clear = changeBgColor("parent", "green");
return clear;
});
return (
<div id="parent" className="component">
<button onClick={() => setCount(count + 1)}>App</button>
<CompA value={0} />
</div>
);
};
const CompA: React.FC<{ value: number }> = memo(
({ value }) => {
useEffect(() => {
const clear = changeBgColor("a", "red");
return clear;
});
return (
<div id="a" className="component">
<div>Memo (Component A) </div>
</div>
);
}
);
尽管被Memo
包裹,但由于props
变化,内层的组件还是重新执行了一轮渲染。
如果把props
值改为常量,内层组件不会刷新,memo
发挥了缓存组件的作用,以上符合预期。
但是,当我在props
中增加一个object
,值为{ value: 0 }
:
const App = () => {
// ...
return (
<div id="parent" className="component">
<button onClick={() => setCount(count + 1)}>App</button>
<CompA value={0} object={{ value: 0 }}/>
</div>
);
};
const CompA: React.FC<{ value: number, object: { value: number } }> = memo(
({ value, object }) => {
// ...
}
);
发现尽管每次传递的props
恒定,而且子组件使用Memo
包裹,但父组件刷新时还是连带子组件,似乎memo
这一层保护罩是镂空的。
看过上一篇文章的同学一定能猜到,这还是由于Javascript中引用类型的储存方式和React Hooks的浅比较机制决定的。
和useEffect
类似,memo
也采用浅比较决定是否执行组件的render()
,对于原始类型,这并没有问题。但当props
为引用类型时,尽管object
内部的值相同,父组件每次刷新,都会新建另一个object
传给子组件: const object = { value: 0 }
。
由于前后二次创建的object
在内存中的地址完全不同,{ value: 0 } === { value: 0 }
浅比较始终为false
,子组件进行了无效刷新。
所以说,memo
这层函数组件保护罩,看似坚固,实际是镂空型钢丝网,并非想象中的铁板一张。
保护罩,但后果自负
但具体应用中,我们不能放弃在props
中传递引用类型,但如何让引用类型的内存地址保持一致呢?
这里先给出第一种解法, 从父组件传递的内容入手。
const App = () => {
// ...
const object = useMemo(() => {
return { value: 0 }
}, []);
return (
<div id="parent" className="component">
<button onClick={() => setCount(count + 1)}>App</button>
<CompA value={0} object={object}/>
</div>
);
};
既然每一次函数组件的刷新导致props
重新声明,那不如把引用类型的props
用useMemo
包裹成可缓存的变量,只在组件挂载时创建这个变量,后续更新组件并不会改变object的保存的地址和值。
如果object
作为props
同时传给了多个子组件,这种从上层组件解决问题的方式,就非常适合,对于新组件的拓展,少了很多心智负担。
但是这种思路就像是给镂空保护罩附带了使用说明,要求被过滤的物体尺寸不能小于一个数值,否则后果自负。
保护罩,但Plus
第二种方案从子组件的刷新机制入手,深入了解memo
会发现,它的第二个参数是一个可选的比较函数areEqual
,memo
利用这个函数判断组件的props
是否发生变化。如果areEqual
函数没有传入,则默认使用浅层比较shallowEqual
。
function memo<Props extends object>(
Component: (props: Props) => ReactElement | null,
areEqual?: (prevProps: Props, nextProps: Props) => boolean,
): NamedExoticComponent<Props>;
function areEqual(prevProps: object, nextProps: object): boolean {
return shallowEqual(prevProps, nextProps);
}
function shallowEqual(objA: unknown, objB: unknown): boolean {
if (Object.is(objA, objB)) {
return true;
}
if (typeof objA !== 'object' || objA === null || typeof objB !== 'object' || objB === null) {
return false;
}
const keysA = Object.keys(objA);
const keysB = Object.keys(objB);
if (keysA.length !== keysB.length) {
return false;
}
for (let i = 0; i < keysA.length; i++) {
if (
!Object.prototype.hasOwnProperty.call(objB, keysA[i]) ||
!Object.is(objA[keysA[i]], objB[keysA[i]])
) {
return false;
}
}
return true;
}
所以我们可以自定义这个函数的判断逻辑,使用deepClone或者手动比较对象的值。
const CompA: React.FC<{ value: number; object: any }> = memo(
({ value, object }) => {
//...
return (
<div id="a" className="component">
<div>Memo (Component A) </div>
</div>
);
},
(prev, next) => {
// 对象的深比较
return deepCompare(prev, next);
// 或采用自定义逻辑
// return prev.value === next.value && prev.object.value === next.object.value;
}
);
这种方法背后的思路是追根溯源的改变memo
的刷新逻辑,如果很多组件都要使用memo
,完全可以封装一个deepMemo
一劳永逸的作为保护罩Plus。
const deepMemo = (FunctionComponent) => {
return memo(FunctionComponent, (prev, next) => {
return deepCompare(prev, next);
})
}
总结
说来一圈,这个问题并不复杂,但疏于关注的重渲染的开发者未必会意识到,而且正如上篇提到的,这篇文章依然遵循我们解决问题的一般思路:
由异常现象出发(子组件无效渲染),到文档和源码追根溯源(memo的用法),在语言底层定位原因(基本类型和引用类型的储存方式不同),围绕原因提出治标(修改外部传入props)或治本(修改内部刷新机制)的方案,优化方案形成更好的工程实践(deepMemo),总结形成可推广复用的逻辑