刚好最近在编写一个业务需求,浅谈为了近一步对更新动作做到更优的函化手性能优化,对组件的数组重渲染触发机制进行了研究和学习,接下来通过本文来介绍这一过程。浅谈 读完本文,函化手你将掌握函数组件的数组三个层面优化: 小彩蛋:如果想快速编写 React DEMO,你只需要一个 HTML 文件即可。数组 function App() { return ( ); } ReactDOM.render(,浅谈 document.querySelector("#root")); 我们先来看一个示例,函化手当点击 Parent/div 触发更新后,数组Child 组件会进行重渲染并打印 Child render! 吗? // 示例1 function Child() { console.log(Child render!); return } function Parent(props) { const [count, setCount] = React.useState(0); return ( count:{ count} { props.children} ); } function App() { return ( ); } 再看一个示例,将 <Child /> 组件的调用位置进行更换。当点击 Parent/div 触发更新后,Child 组件会进行重渲染并打印 Child render! 吗? // 示例2 function Child() { console.log(Child render!); return } function Parent(props) { const [count, setCount] = React.useState(0); return ( count:{ count} ); } function App() { return } 两者的区别在于 Child 组件在 Parent 组件中使用的方式不同。而多数情况我们会使用「示例2」的方式。 答案在这里公布一下: 可能你会困惑,仅仅是组件的注册位置不同,得到的结果却不相同,我们接着往下看寻找原因。 在更新场景下,以「函数组件」为例,在源码层面作为一个 Fiber 节点进入 Reconciler/beginWork 阶段「查找更新」时会有两个选择: 对于示例1,其实就是满足 bailout 的条件,从而跳过了更新。当一个 Fiber 同时满足以下 4 个条件时,会跳过更新。 若同时满足以上 4 个条件,组件将会跳过更新,不进行重渲染。在源码中判断逻辑如下: function beginWork(current, workInProgress, renderLanes) { if (current !== null) { var oldProps = current.memoizedProps; var newProps = workInProgress.pendingProps; // 满足前三个条件 if (oldProps !== newProps || hasContextChanged() || (workInProgress.type !== current.type)) { didReceiveUpdate = true; } else if (!includesSomeLane(renderLanes, updateLanes)) { ... // 满足第四个条件 return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes); } } ... 现在,我们对比一下两个示例的差异出现在了哪里。 我们知道,在 React 中,JSX 语法经过 babel 编译后会变成 React.createElement 函数调用,你可以在 babel repl 在线平台 试一试,对于 <Child />,编译后的结果如下: 而 React.createElement(Child, null) 经过执行后,服务器托管会返回一个全新的具有 $$typeof: Symbol(react.element) 属性对象,其中 props 会被赋予一个新的对象地址,如图所示: 那么此时对于 Child 组件,尽管 props 更新前后看上去没有任何变化,但源码中使用 oldProps === newProps 比较的是对象引用地址,故无法满足这一条件。 对于示例一,Child 的定义在 App 组件中,App 组件进入了 bailout 没有进行重渲染,所以不会重新执行 React.createElement(Child, null) 去返回新 props 对象; 而对于示例二,Child 的定义在 Parent 组件中,Parent 本身存在更新,经过重渲染后会执行 React.createElement(Child, null),从而导致 Child 前后 props 不一致带来的意外重渲染。 而我们大多数情况下使用组件都采用「示例二」方式,有没有办法避免 props 未发生变化而带来的意外更新呢?React.memo 可以帮助我们解决。 React.memo 是一个高阶组件。它与 React.PureComponent 非常相似,作为「性能优化」的方式存在。但只适用于函数组件,而不适用 class 组件。 通常,父组件发生一次更新重渲染,即使子组件所依赖的 Props 没有发生变化,它仍旧会被 re-render 重渲染。 当使用 React.memo 包裹函数组件后,它默认会对 Props 进行浅层比较来跳过渲染直接复用最近一次渲染的结果。 如果你想要控制对比过程,可通过自定义比较函数 areEqual(第二个参数传入)来实现。 function MyComponent(props) { /* 使用 props 渲染 */ } function areEqual(prevProps, nextProps) { / 如果把 nextProps 传入 render 方法的返回结果与 将 prevProps 传入 render 方法的返回结果一致则返回 true, 否则返回 false */ } 注意:与 class 组件中 shouldComponentUpdate() 方法不同的是,如果 props 相等,areEqual 会返回 true;如果 props 不相等,则返回 false。这与 shouldComponentUpdate 方法的返回值相反。 下面我们从源码层面了解具体实现。 在源码位置 react/src/ReactMemo.js 下,我们可以看到 memo 函数体代码实现。 let REACT_MEMO_TYPE = symbolFor(react.memo); export function memo const elementType = { $$typeof: REACT_MEMO_TYPE, type, compare: compare === undefined ? null : compare, }; return elementType; memo 接收一个经过 JSX 编译后的函数组件 ReactElement 对象,将其保存在 type 属性上。并且它的返回值可以作为一个组件方式去使用,假如我们的示例如下: function Child() { console.log(child render.); return } const MemoChild = React.memo(Child); function App() { const [count, setCount] = React.useState(0); return ( setCount(count + 1)}>Hello World. ); } const rootEl = document.querySelector("#root"); <MemoChild /> 经过 JSX 编译后的 ReactElement 对象结构如下,下文简称 MemoReactElement: { "$$typeof": Symbol(react.element) "type": { "$$typeof": Symbol(react.memo), "compare": null, "type": Child(), }, "key": null, "ref": null, "props": { }, "_owner": null, "_store": { } ReactElement 处理成 Fiber 节点的时机是在 Reconciler/beginWork 阶段。下面,我们分别从「初渲染」和「更新渲染」两类场景看看 React.memo 如何渲染 Child 组件。 memo 的处理主要发生在 Reconcile/beginWork 阶段,它会拿到包裹的函数组件去调用执行。 对于初渲染,首先会为 MemoReactElement 创建 Fiber 节点,并且设置 Fiber.tag 类型为 14(MemoComponent),然后让 Memo Fiber 进入 Reconcile/beginWork 去命中 case = MemoComponent 处理 Child 组件。 在处理过程中,先从 Fiber.type.type 中取出所包裹的函数组件(本例是 Child)去执行渲染;当没有传递第二参数 compare 时,会将 Fiber.tag 标记为 15(SimpleMemoComponent),在更新渲染时进入 beginWork,则会命中 SimpleMemoComponent case。 // 核心代码如下: function updateMemoComponent(current, workInProgress, nextProps, updateLanes, renderLanes) { const Component = workInProgress.type; // React.memo() 执行后返回的对象 const type = Component.type; // Child() workInProgress.tag = SimpleMemoComponent; // 15 workInProgress.type = type; // 执行 Child() 函数组件 return updateFunctionComponent(current, workInProgress, type, nextProps, renderLanes); 在点击 span 标签在 App 组件内触发一次更新后,会重新进行 render 对 <MemoChild /> 执行 React.createElement(),其中 props 返回了新的引用地址,因此在 beginWork 中,并不会命中 bailoutOnAlreadyFinishedWork。 function beginWork(current, workInProgress, renderLanes) { if (current !== null) { var oldProps = current.memoizedProps; var newProps = workInProgress.pendingProps; if (oldProps !== newProps || hasContextChanged() || (workInProgress.type !== current.type )) { // 命中这里 didReceiveUpdate = true; } ... } // 匹配到 case SimpleMemoComponent switch (workInProgress.tag) { case SimpleMemoComponent: return updateSimpleMemoComponent(current, workInProgress, workInProgress.type, workInProgress.pendingProps, updateLanes, renderLanes); ... } 虽然没有直接命中 bailout 去跳过更新,但是在 updateSimpleMemoComponent 通过 compare 对比 props,若 props 没有发生变化,则进入 bailout 跳过更新。 function updateSimpleMemoComponent(current, workInProgress, Component, nextProps, updateLanes, renderLanes) { if (current !== null) { var prevProps = current.memoizedProps; var compare = Component.compare; // 外部传递的比较函数 compare = compare !== null ? compare : shallowEqual; if (compare(prevProps, nextProps) && current.ref === workInProgress.ref && (workInProgress.type === current.type )) { didReceiveUpdate = false; if (!includesSomeLane(renderLanes, updateLanes)) { // 若 props 之间没有变化,且组件本身没有更新,进入这里,跳过更新 workInProgress.lanes = current.lanes; return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes); } else if ((current.flags & ForceUpdateForLegacySuspense) !== NoFlags) { didReceiveUpdate = true; } } } // 若 props 发生变化,对 Child 进行重渲染 return updateFunctionComponent(current, workInProgress, Component, nextProps, renderLanes); 这就是 React.memo 在 props 层面对函数组件的优化原理。默认提供的复杂对象比较函数 shallowEqual 在源码中的实现具体如下: function shallowEqual(objA, objB) { if (objectIs(objA, objB)) { return true; } if (typeof objA !== object || objA === null || typeof objB !== object || objB === null) { return false; } var keysA = Object.keys(objA); var keysB = Object.keys(objB); if (keysA.length !== keysB.length) { return false; } for (var i = 0; i < keysA.length; i++) { if (!hasOwnProperty$2.call(objB, keysA[i]) || !objectIs(objA[keysA[i]], objB[keysA[i]])) { return false; } } return true; React.memo 优化方向是避免函数组件被重新调用; React.useMemo 则是在函数组件被调用后,在它依赖项没有变化的情况下,不去执行复杂逻辑去计算新的变量值。 我们来看看官方文档给出的概念: useMemo,返回一个 memoized 值。把“创建”函数和依赖项数组作为参数传入 useMemo,它仅会在某个依赖项改变时才重新计算 memoized 值。这种优化有助于避免在每次渲染时都进行高开销的计算。如果没有提供依赖项数组,useMemo 在每次渲染时都会计算新的值。 代码示例: useMemo 对比依赖项变化的逻辑,在源码中的实现也比较容易理解(mount 和 update 实现不同): // mount 阶段 function mountMemo nextCreate: () => T, deps: Array ): T { // 创建并返回当前hook const hook = mountWorkInProgressHook(); const nextDeps = deps === undefined ? null : deps; // 计算 value const nextValue = nextCreate(); // 将 value 与 deps 保存在 hook.memoizedState(更新阶段比较差异时会使用) hook.memoizedState = [nextValue, nextDeps]; return nextValue; } // update 阶段 function updateMemo nextCreate: () => T, deps: Array ): T { // 获取当前 hook const hook = updateWorkInProgressHook(); const nextDeps = deps === undefined ? null : deps; const prevState = hook.memoizedState; if (prevState !== null) { if (nextDeps !== null) { const prevDeps: Array // 判断 update 前后 deps 是否变化 if (areHookInputsEqual(nextDeps, prevDeps)) { // 未变化 return prevState[0]; } } } // 变化,重新计算 value const nextValue = nextCreate(); hook.memoizedState = [nextValue, nextDeps]; return nextValue; 可见,React.memo 和 useMemo 针对函数组件的性能优化的「方向」有所不同,按需选择两者去进行性能优化。 当然,除此之外,useCallback 也是一种优化手段。 与 useMemo 类似,两者都接收一个 callback,唯一区别是 useCallback 用于优化缓存这个 callback「函数」本身,useMemo 用于优化缓存「变量」,即 callback 函数的执行结果。 const memoizedCallback = useCallback( () => { doSomething(a, b); }, [a, b], useCallback(fn, deps) 相当于 useMemo(() => fn, deps)。在源码中实现如下: // mount 阶段 function mountCallback // 创建并返回当前 hook const hook = mountWorkInProgressHook(); const nextDeps = deps === undefined ? null : deps; // 将 callback 与 deps 保存在 hook.memoizedState hook.memoizedState = [callback, nextDeps]; return callback; } // update 阶段 function updateCallback // 返回当前 hook const hook = updateWorkInProgressHook(); const nextDeps = deps === undefined ? null : deps; const prevState = hook.memoizedState; if (prevState !== null) { if (nextDeps !== null) { const prevDeps: Array // 判断 update 前后 deps 是否变化 if (areHookInputsEqual(nextDeps, prevDeps)) { // 未变化 return prevState[0]; } } } // 变化,将新的 callback 作为 value hook.memoizedState = [callback, nextDeps]; return callback;一、思考题
二、组件不进行重渲染的条件
三、React.memo
1. 概念四、React.useMemo
五、useCallback