当前位置: 首页 > 科技观察

从Context源码实现谈React性能优化

时间:2023-03-14 20:28:03 科技观察

阅读本文,你将收获:理解Context的实现原理在源码层面掌握React组件的渲染时序,从而写出高性能的React组件理解shouldComponentUpdate,React。对于memo、PureComponent等性能优化方法的实现,我会尽量把文章写得通俗易懂。但是,要完全理解文章的内容,你需要掌握这些前置知识:Fiber架构的一般工作流程优先级和更新在React源码中的含义组件渲染的时机Context的实现是紧密的与组件的渲染有关。在讲解它的实现之前,我们先了解一下render的时机。换句话说,组件什么时候渲染?这个问题的答案已经在React组件何时渲染中讨论过了。总结一下:在React中,每当触发更新(比如调用this.setState、useState),都会为组件创建一个对应的fiber节点。Fiber节点相互链接形成一个Fiber树。创建fiber节点有两种方式:bailout,即重用上次更新的组件对应的fiber节点作为本次更新的fiber节点。render,diff算法后生成一个新的fiber节点。组件的渲染(比如ClassComponent的render方法的调用,FunctionComponent的执行)都发生在这一步。经常有同学问:React每次更新都会重新生成一个fibertree,性能不会差吧?React性能真的不是很好。但是可以看到,并不是所有的组件都会在fibertree的生成过程中渲染,一些满足优化条件的组件会使用bailout逻辑。例如,对于以下Demo:functionSon(){console.log('childrender!');return

Son
;}functionParent(props){const[count,setCount]=React.useState(0);return({setCount(count+1)}}>count:{count}{props.children}
);}functionApp(){return();}constrootEl=document.querySelector("#root");ReactDOM.render(,rootEl);在线演示地址【2】点击Parent组件的div子组件触发更新,但是子渲染!不打印。这是因为Son组件会进入bailout逻辑。bailout的条件需要同时满足4个条件才能进入bailout逻辑:1.oldProps===newProps,即本次更新的props全部等于上次更新的props。请注意,这是同余比较。我们知道组件渲染会返回JSX,这是React.createElement的语法糖。所以render的返回结果其实就是React.createElement的执行结果,也就是一个包含props属性的对象。虽然这次更新和上一次更新props中的每个参数都没有变化,但是这次更新是React.createElement的执行结果,是一个全新的props引用,所以oldProps!==newProps。2.上下文值没有改变。我们知道,在当前的React版本中,既有新的上下文,也有旧的上下文,这里指的是旧版本上下文。3.workInProgress.type===current.typefiber.type更新前后保持不变,比如div没有变成p。4.!includesSomeLane(renderLanes,updateLanes)?当前光纤是否有更新?如果是,更新的优先级是否与本次整个Fiber树调度的优先级一致?如果一致,说明组件上有更新,需要去渲染逻辑。救市的优化不止于此。如果一个fiber子树的所有节点都没有更新,那么即使fiber的所有后代都遵循bailout逻辑,仍然存在遍历的代价。因此,在bailout中,检查纤程的所有后代纤程是否都满足条件4(检查的时间复杂度为O(1))。如果本次不需要更新所有后代纤程,bailout会直接返回null。跳过整个子树。不会bailout也不会渲染,就好像它不存在一样。相应的DOM不会产生任何变化。旧的ContextAPI的实现现在我们对render的时机有了一个大概的了解。有了这个概念,就可以理解ContextAPI是怎么实现的,为什么要重构了。我们先看看废弃的旧ContextAPI的实现。Fiber树的生成过程是通过遍历实现的可中断递归,因此分为递归和递归两个阶段。上下文对应的数据会保存在栈中。在交付阶段,Context被不断地压入栈中。所以Consumer可以通过Context栈向上找到对应的context值。在返回阶段,Context不断地出栈。那么为什么旧的ContextAPI被废弃了呢?因为它不能配合shouldComponentUpdate或者Memo等性能优化方法。shouldComponentUpdate的实现需要探究更深层次的原因。我们需要了解shouldComponentUpdate的原理,下文简称为SCU。SCU的使用是为了减少不必要的渲染,换句话说:让应该渲染的组件走bailout逻辑。刚才我们介绍了救助需要满足的条件。那么SCU作用于这4个条件中的哪一个呢?很明显第一个:oldProps===newProps使用shouldComponentUpdate时,这个组件bailout的条件会发生变化:--oldProps===newProps++SCU===false同理,当使用PureComponenet和React.memo时,bailout的条件也会改变:--oldProps===newProps++oldProps和newProps的浅比较等于返回旧的ContextAPI。当这些性能优化方法:使组件命中bailout逻辑,如果组件的子树满足bailout条件4,则fiber子树不会继续遍历生成。也就是说,你将不再体验到Context的堆叠和弹出。在这种情况下,即使上下文值发生变化,后代组件也检测不到。新的ContextAPI的实现知道了旧的ContextAPI的缺陷,我们来看看新的ContextAPI是如何实现的。通过:ctx=React.createContext();创建context实例后,需要使用Provider提供value,使用Consumer或者useContext订阅value。如:ctx=React.createContext();constNumProvider=({children})=>{const[num,add]=useState(0);return(add(num+1)}>add{children})}使用:constChild=()=>{const{num}=useContext(Ctx);return

{num

}当遍历组件生成对应的纤程时,会遍历到Ctx.Provider组件,Ctx.Provider内部会判断context值是否发生变化。如果上下文值发生变化,Ctx.Provider会在内部进行子树向下深度优先遍历,寻找与Provider匹配的Consumer。在上面的例子中,最终会找到useContext(Ctx)的Child组件对应的fiber,并触发fiber的更新。请注意,这里的实现非常巧妙:一般更新是由组件调用触发更新的方法生成的。比如上面的NumProvider组件,点击按钮调用add就会触发update。触发更新的实质是让组件在创建对应的fiber时不满足bailout条件4:!includesSomeLane(renderLanes,updateLanes)?,从而进入渲染逻辑。这里,Ctx.Provider中的context值发生变化,Ctx.Provider向下找到消费context值的组件Child,并触发对其fiber的更新。那么fiber对应的Child不满足条件4。这就解决了旧的ContextAPI的问题:由于Child对应的fiber不满足条件4,所以从Ctx.Provider到Child,这个子树不能满足:!!子树中的所有后代节点都满足条件4,所以即使有组件进入bailout逻辑时,也不会返回null,即不会忽略对这棵子树的遍历。最后,遍历继续到Child。由于不满足条件4,就会进入渲染逻辑,调用组件对应的函数。constChild=()=>{const{num}=useContext(Ctx);return

{num}

}会在函数调用中调用useContext从Context栈中找到对应更新后的context值并返回.总结React性能的一个关键是减少不必要的渲染。从上面我们可以看出,其实质就是让组件满足四个条件,从而进入bailout逻辑。ContextAPI的本质是让Consumer组件不满足条件4。我们也知道React虽然每次都会遍历整棵树,但是它会有bailout优化逻辑,并不是所有的组件都会渲染。在极端情况下,甚至会跳过一些子树(bailout返回null)。参考资料[1]React技术秘籍:http://react.iamkasong.com/[2]在线Demo地址:https://codesandbox.io/s/quirky-chaplygin-5bx67?file=/src/App.js