likes
comments
collection
share

React组件莫名重渲? 我来告诉你原因👌

作者站长头像
站长
· 阅读数 8

是个怎么一回事呢

最近项目上发现有几个页面越来越卡了, 其中几个数据量"比较"大的list页(每页100条数据😂), 录制了性能分析后发现dev环境下渲染时间能到将近2s...

当然本文不是要去说我是如何做性能优化的, 我们的例子没有什么太好的参考性, 主要还是代码写的太随意, 倒是我在优化性能的过程中觉得缺少趁手的优化工具。🤦‍♂️

一般我们要进行性能优化, 可能会使用Chrome dev tools的Performance工具录制一段页面操作, 在timeline上找到明显卡顿的地方, 再到下面的火焰图内具体分析函数调用情况。

这时发现某个组件连续渲染了好几次, 阻塞了页面重绘, 但其中只有一次是必要渲染。那我们肯定要想办法排除其他几次无用的渲染, 进一步的就需要知道一个组件的一次渲染具体的原因是下面的哪些呢:

  1. props变化
  2. state变化
  3. 父级渲染, 自身没有进行memo
  4. context 变化
  5. 第三方状态库forceUpdate(mobx之类)/useSyncExternalStore注册的subscribe函数调用, 这个可以合并到state变化

那么除了自己去一点点分析组件代码, 有没有什么工具可以方便的告诉我们某个组件的某次更新具体是因为上述的哪种原因呢?

React官方提供了一套React dev tools浏览器插件, 可以在组件render时高亮显示:

React组件莫名重渲? 我来告诉你原因👌

也可以通过录制profile查看每次渲染的发起者和渲染时间:

React组件莫名重渲? 我来告诉你原因👌

但似乎都没有详细说明某次render的具体原因,那以函数组件为例, 是否能找到类似功能的hook函数呢?

我在网上能找到了一些相关的hook:

useWhyDidYouUpdate React Hook - useHooks use-trace-update - npm (npmjs.com) hooks/packages/hooks/src/useWhyDidYouUpdate at master · alibaba/hooks (github.com)

但这些hook基本都只能对props的改变进行判断, 而这也往往是我们一上手就会检查的部分, 最简单的, 给需要处理的组件包一层memo就可以排除props改变造成的非必要render了, 比较麻烦的是context变化/第三方状态库变化引起的重新render, 比较隐蔽不易排查。

似乎可以自己写一个折腾折腾~

React更新到底在做什么?

React中一次更新本质上来说就是接收新的数据, 包括state, props, context, 通过这些数据得到一个更新后的WIP fiber树,并使用新的fiber树更新宿主环境(DOM)。

以函数组件更新state为例:

  1. dispatchSetState发起更新请求
  2. 调度更新
  3. render阶段, 从fiberRoot开始reconcile, 得到WIP fiber树
  4. commit阶段, WIP/current fiber树交换, 渲染器拿到数据更新宿主环境。

所以前面提到的一个组件更新的数种原因, 理论上都可以通过对比 WIP/current 两颗fiber树分析出来。在commit阶段, WIP/current fiber树交换之后, current fiber上包含的就是本次更新的数据, WIP上则是上一次的数据

其中state保存在fiber.memoizedState上, props保存在fiber.memoizedProps, context保存在fiber.dependencies上。

后面我会写一篇文章来详细掰扯掰扯state, props, context在fiber上是如何变动的, 目前我们只需要知道他们都保存在fiber上的什么位置就行了。

这么看起来, 只要我们能拿到运行时的fiber对象, 想要什么数据都有了, 那么...

如何在hook中得到组件的fiber对象呢?

hook函数是在组件更新过程中调用的, 在hook函数执行的时候如何能拿到位于调用栈上层的fiber对象呢?

前面提到过React dev tools, 这货作为一个外部工具都能拿到react运行时内部的fiber数据, react和dev tools之间必然存在某种渠道来传递fiber信息, 只要能对此加以利用就可以实现我的目标了。

(使用React的各位应该...都安装了React dev tool吧🤔🤔🤔)

下面来看看dev tools的原理吧。

React dev tools

如果安装了React dev tools(或者开发服务器引入了react-refresh), 都会在window对象上发现一系列形如__REACT_xxx__的全局变量:

React组件莫名重渲? 我来告诉你原因👌

这些变量中对我们有用的是__REACT_DEVTOOLS_GLOBAL_HOOK__: React组件莫名重渲? 我来告诉你原因👌

看到这些函数名有没有什么想法?看到哪个getFiberRoots没?从名字来看...给他一个rendererID, 似乎就可以得到一个fiberRoot, 而有了fiberRoot就可以得到任意一个fiber了。

所以, 接下来要弄明白, rendererID是什么, 怎么设置的呢? 下面我们来看看react dev tools的执行过程。

初始化

以Chrome浏览器, 安装了React devtools浏览器拓展为例, 我们定位到packages/react-devtools-extensions/chrome/manifest.json, 可以看到content_scripts里指定需要执行的脚本为build/injectGlobalHook.js

// file: packages/react-devtools-extensions/chrome/manifest.json

{
...
"content_scripts": [
    {
      "matches": [
        "<all_urls>"
      ],
      "js": [
        "build/injectGlobalHook.js"
      ],
      "run_at": "document_start"
    }
  ]
}

injectGlobalHook中通过动态插入Script标签的形式执行了来自/packages/react-devtools-shared/src/hook下的installHook函数,正是这个函数创建并初始化了window上的__REACT_DEVTOOLS_GLOBAL_HOOK__对象:

// file: packages/react-devtools-shared/src/hooks.js

// target这里是 window, 写成参数形式是因为react-devtools不仅仅在浏览器环境下使用。
function installHook(target: any): DevToolsHook | null {
  if (target.hasOwnProperty('__REACT_DEVTOOLS_GLOBAL_HOOK__')) {
    return null;
  }
  // hook内的一些函数声明
  function checkDCE(fn: Function){/*...*/}
  function inject(renderer){/*...*/}
  // ...
  
  const hook: DevToolsHook = {
    rendererInterfaces,
    listeners,

    // Fast Refresh for web relies on this.
    renderers,

    emit,
    getFiberRoots,
    inject,
    on,
    off,
    sub,

    // This is a legacy flag.
    // React v16 checks the hook for this to ensure DevTools is new enough.
    supportsFiber: true,

    // React calls these methods.
    checkDCE,
    onCommitFiberUnmount,
    onCommitFiberRoot,
    onPostCommitFiberRoot,
    setStrictMode,

    // Schedule Profiler runtime helpers.
    // These internal React modules to report their own boundaries
    // which in turn enables the profiler to dim or filter internal frames.
    getInternalModuleRanges,
    registerInternalModuleStart,
    registerInternalModuleStop,
  };
  
  // 设置为target的属性, 属性名 '__REACT_DEVTOOLS_GLOBAL_HOOK__'
  Object.defineProperty(
    target,
    '__REACT_DEVTOOLS_GLOBAL_HOOK__',
    ({
      // This property needs to be configurable for the test environment,
      // else we won't be able to delete and recreate it between tests.
      configurable: __DEV__,
      enumerable: false,
      get() {
        return hook;
      },
    }: Object),
  );
}

在这个文件里也可以找到getFiberRoots函数:

// file: packages/react-devtools-shared/src/hooks.js

const fiberRoots = {};
function getFiberRoots(rendererID) {
    const roots = fiberRoots;
    if (!roots[rendererID]) {
      roots[rendererID] = new Set();
    }
    return roots[rendererID];
}

fiberRoots 是在同文件内的 onCommitFiberRoot内设置的:

// file: packages/react-devtools-shared/src/hooks.js

function onCommitFiberRoot(rendererID, root, priorityLevel) {
    // 拿到一个空Set
    const mountedRoots = hook.getFiberRoots(rendererID);
    // root是 `rootFiber`, `root.current` 即为 `fiberRoot`
    const current = root.current;
    // 是否已经存入
    const isKnownRoot = mountedRoots.has(root);
    // 是否卸载
    const isUnmounting =
      current.memoizedState == null || current.memoizedState.element == null;
      
    // Keep track of mounted roots so we can hydrate when DevTools connect.
    if (!isKnownRoot && !isUnmounting) {
      // 满足没有存入, 也没有卸载, 则存入fiberRoots[rendererID]
      mountedRoots.add(root);
    } else if (isKnownRoot && isUnmounting) {
      mountedRoots.delete(root);
    }
    // ...
    // ....
}

另外 rendererID 是在同文件的 inject函数内设置的:

// file: packages/react-devtools-shared/src/hooks.js

let uidCounter = 0;
function inject(renderer) {
    const id = ++uidCounter;
    renderers.set(id, renderer);
    // ...
    // ...
}

可以看到, rendererID就是一个从1开始的递增整数。

与React关联

在react-devtools向页面注入__REACT_DEVTOOLS_GLOBAL_HOOK__全局变量后, React需要通过这个变量将自身与react devtools建立关联, React有几个不同的渲染器, 这里我们只看react-dom。

一通搜索+跳转之后,可以发现基本的初始化步骤如下:

packages/react-dom/src/client/ReactDOM.js ➡️ packages/react-reconciler/src/ReactFiberReconciler.new.js function injectIntoDevTools ➡️ packages/react-reconciler/src/ReactFiberDevToolsHook.new.js function injectInternals

我们直接看 injectInternals 函数:

// file: packages/react-reconciler/src/ReactFiberDevToolsHook.new.js

let rendererID = null;
let injectedHook = null;
export function injectInternals(internals: Object): boolean {
    // ...
    // 这里从window上取到dev tools插件添加的全局变量
    const hook = __REACT_DEVTOOLS_GLOBAL_HOOK__;
    // ...
    // internals是一个Object, 内包含前面收集到的各种方法,作为一个`renderer`传递给 `inject`方法, 
    // 得到一个 rendererID 赋值给本地变量
    rendererID = hook.inject(internals);
    // 赋值给本地变量 `injectedHook`
    injectedHook = hook;
}

同文件内有onCommitRoot函数, 其中会拿到injectInternals内获得的rendererIDinjectedHook来把fiberRoot压入dev tools那边的fiberRoots[rendererID]:

// file: packages/react-reconciler/src/ReactFiberDevToolsHook.new.js

export function onCommitRoot(root: FiberRoot, eventPriority: EventPriority) {
    // ...
    injectedHook.onCommitFiberRoot(
          rendererID,
          root,
          schedulerPriority,
          didError,
        );
    // ...
}

最后, onCommitRootReactFiberWorkLoop.new.jscommitRootImpl 内被调用:

// file: packages/react-reconciler/src/ReactFiberWorkLoop.new.js

import {
  // ..
  onCommitRoot as onCommitRootDevTools,
  // ...
} from './ReactFiberDevToolsHook.new';


function commitRootImpl(
  root: FiberRoot,
  recoverableErrors: null | Array<mixed>,
  transitions: Array<Transition> | null,
  renderPriorityLevel: EventPriority,
) {
    // ...
    onCommitRootDevTools(finishedWork.stateNode, renderPriorityLevel);
    // ...
}

现在明白了, rendererID即为渲染器ID, 一般前端项目也只有一个渲染器(SSR先不考虑), 那直接用rendererID = 1 即可调用 getFiberRoots 函数得到一个fiberRoot。

Fallback 方案

如果没有安装dev tools,我们还有一个途径可以拿到rootFiber(注意这里不是fiberRoot了), React在mount时会把rootFiber放在container Dom的一个属性上:

const internalContainerInstanceKey = '__reactContainer$' + randomKey;
export function markContainerAsRoot(hostRoot: Fiber, node: Container): void {
  node[internalContainerInstanceKey] = hostRoot;
}

那我们也可以根据这个属性拿到rootFiber, 后面的逻辑是一样的。

理论存在, 开始实践。✌️

useWhyUpdate

首先我们需要在useLayoutEffect执行逻辑, 此时一次更新刚完成,WIP/current树进行了一次交换, 所以fiberRoot.current即为更新过后的fiber树根节点, fiberRoot.current.alternate则为更新前的fiber树根节点, 对比这两棵树即可得到此次更新的原因。

此外需要传递一个函数组件名 name 作为参数。拿到fiber树后将会遍历并对比每个节点的 type.namename, 相等的才是需要计算更新原因的目标节点:

function useWhyUpdate(name: string, rootContainerId?: string) {
  // 每次更新后同步执行
  useLayoutEffect(() => {
    whyUpdate(name, rootContainerId);
  });
}

具体的逻辑在whyUpdate函数内(这里省略了具体实现细节, 完整代码在这里: FPG-Alan/useWhyUpdate: hook which can tell update reason of React function component (github.com)

const __REACT_DEVTOOLS_GLOBAL_HOOK__ = "__REACT_DEVTOOLS_GLOBAL_HOOK__";
const internalContainerInstanceKeyPrefix = "__reactContainer$";
function whyUpdate(name: string, rootContainerId) {
    // 得到 `fiberRoot`
    const rendererID = 1;
    const globalHook = (window as any)[__REACT_DEVTOOLS_GLOBAL_HOOK__];
    const fiberRoot = globalHook
        ?.getFiberRoots?.(rendererID)
        ?.values()
        ?.next()?.value;
    // 得到rootFiber
    let rootFiber = fiberRoot?.current;
    if (!rootFiber && rootContainerId) {
        const rootDom = document.getElementById(rootContainerId);
        const key = Object.keys(rootDom || {}).find(key =>
          key.includes(internalContainerInstanceKeyPrefix)
        );
        rootFiber = key && (rootDom as any)[key];
    }
    
    if (fiberRoot) {
        let fiberNode = fiberRoot.current;
        while (fiberNode) {
            // 遍历 fiber 树, 得到目标 fiber 节点
        }
        
        // 执行对比逻辑
        if (fiberNode && fiberNode !== fiberRoot.current) {
            // 1. 对比 `fiberNode.pendingProps` 和 `fiberNode.alternate.memoizedProps`
            //    收集props的变化
            
            // 2. 对比 `fiberNode.memoizedState` 和 `fiberNode.alternate.memoizedState`
            //    收集state的变化
            
            // 3. 循环 `fiberNode.dependencies` 对比每个 `context.memoizedValue` 和 
            //    `context.context._currentValue`
            //    收集context的变化
            
            
            // 打印收集到的所有变化, 即为此次更新的原因
            // 若上面三处都没有变化, 可认为是父级更新引起的子级更新
        }
     }
     
     // 找不到fiberRoot则认为是初次mount
}

比如我们有这样一段代码:

const TestContext = createContext({ name: "yy" });

function App() {
  const [userName, setUserName] = useState("yy");
  const [other, setOther] = useState("1");
  useWhyUpdate("App");

  useEffect(() => {
    setUserName("pitun");
    setOther("2");
  }, []);

  return (
    <TestContext.Provider value={{ name: userName }}>
      <Content other={other} />
    </TestContext.Provider>
  );
}

const Content = (props: any) => {
  useWhyUpdate("Content");
  return (
    <span>
      {props.other}
      <Item />
    </span>
  );
};

function Item() {
  useWhyUpdate("Item");
  const { name } = useContext(TestContext);
  return <span>{name}</span>;
}

运行之后看看效果:

React组件莫名重渲? 我来告诉你原因👌

基本达到预期目标, 那么今日份的折腾到此完毕~