从 Context 源码实现谈 React 性能优化
(给前端大全加星标,提升前端技能)
转自:魔术师卡颂
学完这篇文章,你会收获:
了解
Context
的实现原理源码层面掌握
React
组件的render
时机,从而写出高性能的React
组件源码层面了解
shouldComponentUpdate
、React.memo
、PureComponent
等性能优化手段的实现
我会尽量将文章写的通俗易懂。但是,要完全理解文章内容,需要你掌握这些前置知识:
Fiber
架构的大体工作流程优先级
与更新
在React
源码中的意义
如果你还不具备前置知识,可以先阅读React技术揭秘[1](点击阅读原文)
组件render的时机
Context
的实现与组件的render
息息相关。在讲解其实现前,我们先来了解render
的时机。
换句话说,组件
在什么时候render
?
这个问题的答案,已经在React组件到底什么时候render啊聊过。在这里再概括下:
在React
中,每当触发更新
(比如调用this.setState
、useState
),会为组件创建对应的fiber
节点。
fiber
节点互相链接形成一棵Fiber
树。
有2种方式创建fiber
节点:
bailout
,即复用前一次更新该组件对应的fiber
节点作为本次更新的fiber
节点。render
,经过diff算法后生成一个新fiber
节点。组件的render
(比如ClassComponent
的render
方法调用、FunctionComponent
的执行)就发生在这一步。
经常有同学问:React
每次更新都会重新生成一棵Fiber
树,性能不会差么?
React
性能确实不算很棒。但如你所见,Fiber
树生成过程中并不是所有组件都会render
,有些满足优化条件的组件会走bailout
逻辑。
比如,对于如下Demo:
function Son() {
console.log('child render!');
return <div>Son</div>;
}
function Parent(props) {
const [count, setCount] = React.useState(0);
return (
<div onClick={() => {setCount(count + 1)}}>
count:{count}
{props.children}
</div>
);
}
function App() {
return (
<Parent>
<Son/>
</Parent>
);
}
const rootEl = document.querySelector("#root");
ReactDOM.render(<App/>, rootEl);
在线Demo地址[2]
点击Parent
组件的div
子组件,触发更新,但是child render!
并不会打印。
这是因为Son
组件会进入bailout
逻辑。
bailout的条件
要进入bailout
逻辑,需同时满足4个条件:
oldProps === newProps
即本次更新的props
全等于上次更新的props
。
注意这里是全等比较。
我们知道组件render
会返回JSX
,JSX
是React.createElement
的语法糖。
所以render
的返回结果实际上是React.createElement
的执行结果,即一个包含props
属性的对象。
即使本次更新与上次更新props
中每一项参数都没有变化,但是本次更新是React.createElement
的执行结果,是一个全新的props
引用,所以oldProps !== newProps
。
context value
没有变化
我们知道在当前React
版本中,同时存在新老两种context
,这里指老版本context
。
workInProgress.type === current.type
更新前后fiber.type
不变,比如div
没变为p
。
!includesSomeLane(renderLanes, updateLanes) ?
当前fiber
上是否存在更新
,如果存在那么更新
的优先级
是否和本次整棵Fiber
树调度的优先级
一致?
如果一致代表该组件上存在更新,需要走render
逻辑。
bailout
的优化还不止如此。如果一棵fiber
子树所有节点都没有更新,即使所有子孙fiber
都走bailout
逻辑,还是有遍历的成本。
所以,在bailout
中,会检查该fiber
的所有子孙fiber
是否满足条件4(该检查时间复杂度O(1)
)。
如果所有子孙fiber
本次都没有更新需要执行,则bailout
会直接返回null
。整棵子树都被跳过。
不会bailout
也不会render
,就像不存在一样。对应的DOM不会产生任何变化。
老Context API的实现
现在我们大体了解了render
的时机。有了这个概念,就能理解Context
API是如何实现的,以及为什么被重构。
我们先看被废弃的老Context
API的实现。
Fiber
树的生成过程是通过遍历实现的可中断递归,所以分为递和归2个阶段。
Context
对应数据会保存在栈中。
在递阶段,Context
不断入栈。所以Concumer
可以通过Context栈
向上找到对应的context value
。
在归阶段,Context
不断出栈。
那么老Context
API为什么被废弃呢?因为他没法和shouldComponentUpdate
或Memo
等性能优化手段配合。
shouldComponentUpdate的实现
要探究更深层的原因,我们需要了解shouldComponentUpdate
的原理,后文简称其为SCU
。
使用SCU
是为了减少不必要的render
,换句话说:让本该render
的组件走bailout
逻辑。
刚才我们介绍了bailout
需要满足的条件。那么SCU
是作用于这4个条件的哪个呢?
显然是第一条:oldProps === newProps
当使用shouldComponentUpdate
,这个组件bailout
的条件会产生变化:
-- oldProps === newProps
++ SCU === false
同理,使用PureComponenet
和React.memo
时,bailout
的条件也会产生变化:
-- oldProps === newProps
++ 浅比较oldProps与newsProps相等
回到老Context
API。
当这些性能优化手段:
使组件命中
bailout
逻辑同时如果组件的子树都满足
bailout
的条件4
那么该fiber
子树不会再继续遍历生成。
换言之,不会再经历Context
的入栈、出栈。
这种情况下,即使context value
变化,子孙组件也没法检测到。
新Context API的实现
知道老Context
API的缺陷,我们再来看新Context
API是如何实现的。
当通过:
ctx = React.createContext();
创建context
实例后,需要使用Provider
提供value
,使用Consumer
或useContext
订阅value
。
如:
ctx = React.createContext();
const NumProvider = ({children}) => {
const [num, add] = useState(0);
return (
<Ctx.Provider value={num}>
<button onClick={() => add(num + 1)}>add</button>
{children}
</Ctx.Provider>
)
}
使用:
const Child = () => {
const {num} = useContext(Ctx);
return <p>{num}</p>
}
当遍历组件生成对应fiber
时,遍历到Ctx.Provider
组件,Ctx.Provider
内部会判断context value
是否变化。
如果context value
变化,Ctx.Provider
内部会执行一次向下深度优先遍历子树的操作,寻找与该Provider
配套的Consumer
。
在上文的例子中会最终找到useContext(Ctx)
的Child
组件对应的fiber
,并为该fiber
触发一次更新。
注意这里的实现非常巧妙:
一般更新
是由组件调用触发更新的方法产生。比如上文的NumProvider
组件,点击button
调用add
会触发一次更新
。
触发更新
的本质是为了让组件创建对应fiber
时不满足bailout
条件4:
!includesSomeLane(renderLanes, updateLanes) ?
从而进入render
逻辑。
在这里,Ctx.Provider
中context value
变化,Ctx.Provider
向下找到消费context value
的组件Child
,为其fiber
触发一次更新。
则Child
对应fiber
就不满足条件4。
这就解决了老Context
API的问题:
由于Child
对应fiber
不满足条件4,所以从Ctx.Provider
到Child
,这棵子树没法满足:
!! 子树中所有子孙节点都满足条件4
所以即使遍历中途有组件进入bailout
逻辑,也不会返回null
,即不会无视这棵子树的遍历。
最终遍历进行到Child
,由于其不满足条件4,会进入render
逻辑,调用组件对应函数。
const Child = () => {
const {num} = useContext(Ctx);
return <p>{num}</p>
}
在函数调用中会调用useContext
从Context
栈中找到对应更新后的context value
并返回。
总结
React
性能一大关键在于:减少不必要的render
。
从上文我们看到,本质就是让组件满足4个条件,从而进入bailout
逻辑。
而Context
API本质是让Consumer
组件不满足条件4。
我们也知道了,React
虽然每次都会遍历整棵树,但会有bailout
的优化逻辑,不是所有组件都会render
。
极端情况下,甚至某些子树会被跳过遍历(bailout
返回null
)。
参考资料
React技术揭秘: http://react.iamkasong.com/
[2]在线Demo地址: https://codesandbox.io/s/quirky-chaplygin-5bx67?file=/src/App.js
觉得本文对你有帮助?请分享给更多人
推荐关注「前端大全」,提升前端技能
点赞和在看就是最大的支持❤️