React 函数式组件缓存原理

对 React 函数式组件缓存的思考

自从 React 16.8 版本推出 Hooks 用法以来,围绕函数组件的优化出现了各种不同的思考。本篇文章从 React 底层 Render 角度,分析 React 的渲染缓存机制。思考 memo 在函数组件局部内容缓存中发挥的作用。

从 JSX 谈起

我们知道在浏览器中运用 React 技术栈,必不可少的需要两部分东西。一个是 React 本身,另外一个是 React-Dom。React 中渲染页面及交互的本质是运用 JS 的各类操作 DOM 的 API,而开发者不需要使用繁琐的 JS 的 DOM 操作相关 API,只需使用 JSX 语法就可创建 DOM 元素。

// 传统 js 对 dom 的操作
const root = document.getElementById("root");
const element = document.createElement('div');
element.innerHTML = 'hello world';
root.append(element);
/* --------------------------------------------------- */
// react 对 dom 的操作
const root = document.getElementById("root");
const element = React.createElement('div',{
  children: 'hello world'
})
// jsx 写法
const element = <div>hello world</div>;
ReactDOM.render(element, root);

但是负责渲染的 ReactDOM.render本身并不接收原始的 JSX,而是经过 React.createElement 所创建出来的特定 React 数据格式的数据结构。所以 JSX 语法需经过 Babel 特定的 JSX 语法转义插件转为 React.createElement 的结构。

// 你写的 jsx 
<div id="root">Hello world</div>
// jsx 的 babel 插件转义的
React.createElement('div', {id: 'root'}, 'Hello world')

整体流程如下图所示:
jsx
所以,真正决定 React 渲染结果的是 ReactDOM.render 所接收的由 React.createElement 所创建的数据结构。 只要这部分没变,那么下一次渲染就不会产生真实的 DOM 操作。
由此,我们可以推导出,各类像 memo 之类的缓存方案实际上就是谈如何缓存的这部分 React DOM 的特定数据结构。

React DOM 的数据结构

我们可以来看一下 React DOM 的数据结构长什么样。以创建一个 div 元素为例,打印出来的 React 数据结构如下
reacrt 数据结构

  • $$type 是 React DOM 数据结构的一个特定标识,可以起到防止 XSS 攻击等作用。具体的内容可以看 Dan Abramov 写得这篇文章 Why Do React Elements Have a $$typeof Property?
  • key 是当前这个DOM层级的标识,这个标识对渲染缓存判断很有用,尤其是对 LIst 形式的元素渲染。
  • props 是当前元素的属性,可以传入 className,onClick ,children 等内容。
  • ref 组件的 ref 特性。
  • type 组件类型,小写开头为 HTML 标签组件,大写开头为 React 组件。

一个简单的例子

我们来看一个没有做任何缓存的例子。

import React, { useState } from 'react';

export default () => {
  const [count, setCount] = useState(0)
  const increment = () => setCount(c => c + 1)
  return (
    <div>
      <button onClick={increment}>The count is {count}</button>
      <Logger label="counter" />
    </div>
  )
}

function Logger(props) {
  console.log(`${props.label} rendered`)
  return null
}

当我们点击按钮, setState 触发 count + 1,进而触发整个函数组件重新执行。Logger 组件会在每次按钮点击的时候,都进行重新渲染。
Logger

在这里,可能有的人会有疑问了。Logger 组件接收一个参数(label),但是 label 在每次重新渲染的时候值没有发生变化,为什么 Logger 还是会重新渲染?
这是类组件和函数组件两者的一个差别,在 ES6 规范中类本质上构造函数的一个语法糖。在 React 旧有的类组件的写法中,我们会去继承 Purecomponets,Purecomponets 会对传入的 props 做浅比较来决定是否更新。在函数组件中,每次重新执行函数,函数内部都会产生新的引用地址。
我们可以用 Set 去记录一下,每次渲染时产生的 React DOM 的数据结构。

import React, { useState } from 'react';

const set = new Set();
export default () => {
  const [count, setCount] = useState(0)
  const increment = () => setCount(c => c + 1);
  const Log =  <Logger label="counter" />

  set.add(Log);

  console.log(set)
  return (
    <div>
      <button onClick={increment}>The count is {count}</button>
      {Log}
    </div>
  )
}

function Logger(props) {
  console.log(`${props.label} rendered`)
  return null
}

多次点击按钮,渲染结果如下
set
可以发现,因为父级组件调用 setState 导致整个函数重新执行,Logger 组件每次拿到的 React DOM 的数据结构尽管内容上是一样的,但是产生了新的引用地址。要想缓存 Logger 组件就得让他的引用地址不发生变化。
我们可以尝试把 Logger 组件通过 props 的形式传入。

// index.js
function Logger(props) {
  console.log(`${props.label} rendered`)
  return null
}

ReactDOM.render(
  <React.StrictMode>
    <Demo logger={<Logger label="counter" />} />
  </React.StrictMode>,
  document.getElementById('root')
);

// Demo.js
import React, { useState } from 'react';

const set = new Set();
export default ({logger:Logger}) => {
  const [count, setCount] = useState(0)
  const increment = () => setCount(c => c + 1);

  set.add(Logger);

  console.log(set)
  return (
    <div>
      <button onClick={increment}>The count is {count}</button>
      {Logger}
    </div>
  )
}

多次点击按钮,渲染结果如下
在这里插入图片描述
从上面的对 Set 的打印结果可以发现,函数组件的重新执行,是对其内部而言,对传入的 props 不会产生影响。Logger 组件通过 props 传入,引用地址和内容没发生变化,起到了缓存的作用。这就验证了我们上面的一个观点,只要 React DOM 数据结构的引用地址及内容没变,组件就起到了缓存的作用。 如果内容没变,引用地址变了,就不会有缓存的作用。

所以在不引入多余的 React API 情况下,组件渲染缓存优化可以遵循以下规则:
第一步: 把对渲染性能开销大的组件提升的父级。
第二步:父级通过 props 把该组件传递下去。

React.memo

我们上面提到 React DOM 的数据结构的引用地址及内容不变,就不会触发 React 的重新渲染。但这个规则在 React 推出了 memo 之后,说法就不严谨了。我们来看下面这个例子。

import React, { useState } from 'react';

const set = new Set();

const Logger = React.memo((props) => {
  console.log(`${props.label} rendered`)
  return null
})

export default () => {
  const [count, setCount] = useState(0)
  const increment = () => setCount(c => c + 1);
  const Log = <Logger label="counter"/>

  set.add(Log);
  console.log(set)

  return (
    <div>
      <button onClick={increment}>The count is {count}</button>
      {Log}
    </div>
  )
}

点击按钮,打印的结果如下
在这里插入图片描述

尽管 Logger 组件的 React DOM 数据结构的引用地址发生了变化,但是 Logger 组件并没有重新渲染。我们可以发现,Logger 组件经过 memo 包装以后,type 标识为 memo 类型React 对 memo 类型的数据会比较其 props ,而不是引用地址不一样就立马重新渲染。
值得一提的是,React.memo 对 props 的比较是浅比较。在上面的例子中,Logger 组件的确起到了缓存的作用,如果我们给他传入一个函数,结果就不一样了,看下面的例子。

import React, { useState } from 'react';

const set = new Set();

const Logger = React.memo((props) => {
  console.log(`${props.label} rendered`)
  return null
})

export default () => {
  const [count, setCount] = useState(0)
  const increment = () => setCount(c => c + 1);
  const Log = <Logger label="counter" onFunction={increment}/>

  set.add(Log);
  console.log(set)

  return (
    <div>
      <button onClick={increment}>The count is {count}</button>
      {Log}
    </div>
  )
}

点击几次按钮,打印结果如下
在这里插入图片描述
在上面的代码中,我们给 Logger 组件传入了一个方法,在每次父级组件重新渲染的时候,函数产生的引用地址都是一个新的,所以这时候 memo 就起不到缓存的作用。要想让他再次起到缓存的作用,需要用 useCallback 去封装这个方法,这是文章后面要提的内容。

有了 React.memo 以后更严谨的观点是。对于函数类型组件,非 memo 类型的 React DOM 数据结构,React 会检查其引用地址再检查 props。而 memo 类型的 React DOM 数据结构,React 会检查其 props。

memo 虽好,但是也不能滥用。因为它会增加 React DOM 树的比较成本。

React.useMemo

随着 React 进入 Hooks 化时代,缓存函数组件内部状态就变得尤为重要。首先我们应当树立一个观念,函数的每次重新执行,内部产生的状态都是新的。所谓的缓存,本质上是把新的状态经过判断重新指向旧的地址。所以像 useEffect、useMemo、useCallback 之类的允许传入依赖项的 hook,每次函数的重新执行产生的都是一个新的 hook,只不过经过依赖项浅比较以后,根据比较的结果,判断是否指向旧的缓存地址

那么 hooks 是如何知道更新的时候到底指向哪一个缓存地址的呢?

我们先来看看React 官方为我们在使用 hooks 时制定了几条 hooks 使用规则

  1. 只在最顶层使用 Hook。不要在循环,条件或嵌套函数中调用 Hook, 确保总是在你的 React 函数的最顶层调用他们。
  2. 只在 React 函数中调用 Hook。不要在普通的 JavaScript 函数中调用 Hook。

对于第一条规则,很多人会有疑惑。第一条规则目的是为了保证 hooks 在每一个函数组件中调用都是有序且顺序不变的。那么,为什么要保证 hooks 调用有序呢?

React 官网的 hooks 使用规则也给我们简述了原因。

只要 Hook 的调用顺序在多次渲染之间保持一致,React 就能正确地将内部 state 和对应的 Hook 进行关联。

从更深层次角度看,React DOM 所产生的数据结构在 React 内部流转是以 Fiber 节点的形式进行流转。Fiber 的数据结构要比 React DOM 的数据结构要复杂得多,涉及到 React 内部任务调度等内容。这不是我们这篇文章的主题,大家感兴趣的话可以自行在 React 官网搜索 Fiber 相关的内容。
每一个函数组件就是一个最小的 Fiber 节点,该 Fiber 节点上会有一个 memoizedState 参数指向该函数组件内的 hooks 链。如下图所示:

hooks
hooks 链这种单向链表形式就要求我们不能在程序运行中去更改 hooks 的位置和顺序,如果中途突然插入或减少一个 hook ,就会导致 hooks 的结果映射错位。
所以,每次更新阶段 hooks 链的位置都是一模一样的,这也就为 useMemo 去查找缓存位置提供了方便
每次函数组件更新时,workInProgressHooks 指针会从头开始依次遍历该函数组件内的 hooks 链,如果碰到 useMemo这个 hooks 会调用 updateMemo 这个方法。我们通过 updateMemo 的源码来看看 useMemo 在更新阶段做了件什么事。

function updateMemo<T>(
  nextCreate: () => T,
  deps: Array<mixed> | void | null,
): T {
  // 返回当前hook
  const hook = updateWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  const prevState = hook.memoizedState;

  if (prevState !== null) {
    if (nextDeps !== null) {
      const prevDeps: Array<mixed> | null = prevState[1];
      // 判断update前后value是否变化
      if (areHookInputsEqual(nextDeps, prevDeps)) {
        // 未变化
        return prevState[0];
      }
    }
  }
  // 变化,重新计算value
  const nextValue = nextCreate();
  hook.memoizedState = [nextValue, nextDeps];
  return nextValue;
}

每一个 useMemo 都有一个 memoizedState 属性,这个属性存了两个值,当前缓存的结果和依赖。当下一次 useMemo 更新阶段时,会比较依赖项来决定是否重新执行 useMemo 内的方法,之后返回结果。因此,我们得出一个结论。只要 useMemo 的依赖项不变,函数组件更新阶段就不会执行 useMemo 内的方法,而是直接返回上一次的结果。

React.useCallback

看了上面 useMemo 缓存的原理,那么 useCallback 的原理也就能大概想到了。本质都是一样的,只不过 useMemo 执行了传入的方法,缓存了方法执行后返回的结果,而 useCallback 则缓存了传入的方法。

总结

在 React 开始推行 hooks 化以后,我们要经常与函数组件打交道。函数组件在每次执行后,内部都会产生新的引用地址。如果没有做任何缓存处理,就会导致给 ReactDOM.render 传入的 React DOM 数据结构产生新的引用地址进而触发组件内全部重新渲染,因此对于函数组件内部状态的缓存就尤为重要。useMemo 等需要依赖项的 hooks,借助 Fiber 所创建的依赖顺序的 hooks 链,缓存上一次的结果和依赖。在函数组件更新阶段时通过判断该 hook 的依赖项是否发生变化,来决定是否直接返回缓存的结果。

参考资料

  1. JSX 简介
  2. Why Do React Elements Have a $$typeof Property?
  3. One simple trick to optimize React re-renders
  4. Use React.memo() wisely
  5. You should use React.memo more!
  6. React 技术揭秘
  7. Why Do React Hooks Rely on Call Order?
  • 7
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值