Skip to content

以一个列表为例讲讲 React 重渲染优化 ​

什么时候组件会重渲染? ​

状态更改、父级(或子级)重新渲染、context value 发生变化以及 hooks 发生变化的时候,都会触发组件的重渲染行为。

以列表 Demo 为例 ​

React的性能优化主要是在于如何减少组件重渲染,因为当视图变得复杂/组件层级嵌套比较深的时候,重渲染的开销就会变得很大,甚至会导致页面出现卡顿现象,所以如何减少重渲染是 React 性能优化最重要的一部分。

下面我们将会以一个长列表的例子去讲如何进行 React 重渲染的优化,点击列表中的某一项时可以切换其选中状态

de

下面是初始示例代码:

jsx
import { memo, useState, useCallback } from "react";

// 创建 100 个列表数据
const data = new Array(100)
  .fill()
  .map((_, i) => i + 1)
  .map((n) => ({
    id: n,
    name: `Item ${n}`,
  }));

export default function App() {
  // 存储长列表选中状态
  const [selected, setSelected] = useState([]);

  // 切换选中状态
  const toggleItem = (item) => {
    if (!selected.includes(item)) {
      setSelected([...selected, item]);
    } else {
      setSelected(selected.filter((current) => current !== item));
    }
  };

  return (
    <div className="App">
      <h1>长列表 Demo</h1>
      <List data={data} selectedItems={selected} toggleItem={toggleItem} />
    </div>
  );
}

const List = ({ data, selectedItems, toggleItem }) => {
  return (
    <ul>
      {data.map((item) => (
        <ListItem
          // 别忘记加 key,并且这个 key 需要是唯一的
          key={item.name}
          name={item.name}
          selected={selectedItems.includes(item)}
          onClick={() => toggleItem(item)}
        />
      ))}
    </ul>
  );
};

const ListItem = ({ name, selected, onClick }) => {
  // 在 render-body 模拟真实应用,进行一次复杂的计算
  expensiveOperation();

  return (
    <li
      style={selected ? { textDecoration: "line-through" } : undefined}
      onClick={onClick}
    >
      {name}
    </li>
  );
};

const expensiveOperation = () => {
  let total = 0;
  for (let i = 0; i < 200000; i++) {
    total += Math.random();
  }
  return total;
};

首先最基本的是在列表循环中加上 key,在动态列表中这个 key 是很重要的,它可以帮助 React 的 Diff 算法去进行数据比对,找出哪些 item 发生了变化,但是在动态列表中千万不要用 index 下标作为 key 值,假如你删除了列表中的第 N 项,那 N+1 后面所有项的 key 都会被改变,在 React 做 Diff 对比的时候就会出现性能问题

React 的 Diff 算法有这样的规则:

  • 当元素类型变化时,会销毁重建
  • 当元素类型不变时,对比属性
  • 当组件元素类型不变时,通过props递归判断子节点
  • 递归对比子节点,当子节点是列表时,通过key和props来判断。若key一致,则进行更新,若key不一致,就销毁重建

所以在列表中一定需要确保 key 的唯一性以避免不必要的销毁重建工作,但是如果你的列表是静态不会发生改变的,那其实用 index 也可以。

性能测试 ​

写完了这个组件之后,我们要测试它的性能,这时候就可以用 React Devtools Profiler 来进行性能分析。

首先我们先把 cpu 性能降低 6 倍来模拟移动端设备。

image-20230303235131655

接下来就打开 React 开发者性能分析工具

image-20230303235251793

接下来在 React Devtools 里面的设置打开记录组件为什么会渲染的选项

image-20230303235623699

配置完之后,接下来我们点击调试面板左上角的录制按钮,随便把几个 item 切换成选中状态,然后按停止录制

image-20230304155342428

我们可以看到整个渲染是自顶向下的(APP-> List -> ListItem),渲染时间为 1027ms ,平均每一个 ListItem 花费了 10ms (100个 item) 左右的时间进行更新,这 1 秒钟去做一整个列表的渲染目前看着还好对吧,但是如果把这个 100 换成 10000 ,然后把它放到一个更低端的设备上,那结果天呐真是太糟糕了。

现在其实最主要的问题是,我们只点击了其中的某一项 item 也会导致整个列表所有 item 的重渲染。接下来我们就要去谈一谈怎么去解决这个问题。

性能优化 ​

React.memo ​

那怎么去解决上面这个问题呢?这时候首先想到的就是 React.memo(Component, areEquals) 这个 API ,它会在每次组件重渲染的时候对 oldProps 和 newProps 进行浅比较,当 props 没发生改变的时候,组件就不会进行重渲染。 不过,当你使用这个 API 的时候要特别注意你的 props 中有没有对象或者函数,因为他们是引用数据类型,浅比较的时候实际比较的是他们的内存地址。 当然你也可以自己去写逻辑,在 areEquals 中去写嵌套对象里的每一个属性的对比逻辑,让它变成深层比较,最后返回一个 boolean 值,然后 memo 就会根据返回结果是否是 true 再去做重渲染,但是自己手写 memo 逻辑这样会大大增加代码维护成本,没特殊需求不建议去写。

在我们上面的 ListItem 组件中,用 memo 包裹之后其实就是下面这段逻辑

jsx
function arePropsEqual(prevProps, nextProps) {
  return prevProps.name === nextProps.name &&
         prevProps.selected === nextProps.selected &&
         prevProps.onClick === nextProps.onClick
}

这里我们可以大概看一下 memo 的源码,其实也很简单:

源码在 packages/react-reconciler/ReactFiberBeginWork.new.js 文件下,详细逻辑可以看 updateSimpleMemoComponent 这个函数,但是这个 API 里面的核心方法就是 shallowEqual ,返回 false 时组件要重渲染,返回 true 组件不需要重渲染。

jsx
function shallowEqual(objA, objB) {
  // 1.判断 A 和 B 是否同一个对象(判断是否是对同一个对象值的引用)
  if (Object.is(objA, objB)) {
    return true;
  }

  // 2.props 不是对象或者是 null,就返回 false,说明要更新组件
  if (
    typeof objA !== 'object' ||
    objA === null ||
    typeof objB !== 'object' ||
    objB === null
  ) {
    return false;
  }

  const keysA = Object.keys(objA);
  const keysB = Object.keys(objB);

  // 3.长度不同就直接返回 false
  if (keysA.length !== keysB.length) {
    return false;
  }

  // 4.对比 props 里的每一个属性,看 oldProps 和 newProps 是否有不同的 key
  for (let i = 0; i < keysA.length; i++) {
    const currentKey = keysA[i];
    if (
      !hasOwnProperty.call(objB, currentKey) ||
      !Object.is(objA[currentKey], objB[currentKey])
    ) {
      return false;
    }
  }

  return true;
}

对 memo 原理有了基本的了解之后,接下来回到我们上面的问题中,首先我们得对 ListItem 进行一个 memo 包装

jsx
import { memo } from "react";

// 注意看,这里用 memo 进行了包裹
const ListItem = memo(({ name, selected, onClick }) => {
  expensiveOperation(selected);

  return (
    <li
      style={selected ? { textDecoration: "line-through" } : undefined}
      onClick={onClick}
    >
      {name}
    </li>
  );
});

好的,加上了这个 memo 之后我们再回去测一下性能看看表现如何

image-20230304162317766

从图片的结果看,不仅没起到优化作用,渲染速度还变慢了? (为什莫,这到底是为什莫?)

其实道理很简单,我们的 ListItem 的 props 里面有一个引用数据类型,就是 onClick 这个事件函数(函数在 JS 中可以看作是一个对象,是引用数据类型),上面的图中我们也看到它提示了 onClick 这个 prop 发生了改变。但是实际上我们的 onClick 事件中的内容并没发生变化,为什么 memo 的比对结果还是 false?

实际上我们的点击方法是在 List 组件中,当点击列表 item 的时候就会调用该方法,然后就会触发重渲染

jsx
const List = ({ data, selectedItems, toggleItem }) => {
  return (
    <ul>
      {data.map((item) => (
        <ListItem
          key={item.name}
          name={item.name}
          selected={selectedItems.includes(item)}
          // 注意这个匿名函数,每次 re-render 的时候都会重新声明
          // 实际就是声明了这样的函数 const handleClick = () => { toggleItem(item) }
          // onClick={handleClick}
          onClick={() => toggleItem(item)}
        />
      ))}
    </ul>
  );
}

另外要说的一个概念,就是渲染体和非渲染体,渲染体中的代码会在每次组件重渲染的时候都会重新执行,也包括下面的这个 handleClick 方法,也就是说,点击列表 item 后触发重渲染就会重新声明 handleClick 方法

jsx
export default function List() {
    // 这里是渲染体
    
    const handleClick  = () => {
        // 这里是非渲染体
    }
    
    return <ul>...</ul>
}

重渲染前的 handleClick 和重渲染完成之后的 handleClick 方法指向的内存地址实际发生了改变。

js
(() => {}) === (() => {}) // false

所以此时传递到 ListItem 组件中的 handleClick 方法已经发生变化了,就有了上面说到的 onClick prop changed 的问题。

useCallback ​

那么知道了问题,我们就要去解决,此时就可以使用 useCallback 这个 API 了,它的参数第一项接收一个需要缓存的函数,第二项接收一个依赖,当依赖发生变化的时候才会去重新声明 toggleItem 函数。我们首先要对代码进行一点点小改写。

1.首先是 App 组件中的 toggleItem 方法添加上 useCallback 包裹

jsx
export default function App() {
  // 增加 useCallback 包裹
  const toggleItem = useCallback((item) => {
    if (!selected.includes(item)) {
      setSelected([...selected, item]);
    } else {
      setSelected(selected.filter((current) => current !== item));
    }
  }, [selected]);
    
  return ...
}

2.然后修改 List 组件中的 onClick 方法和 item 入参

jsx
const List = ({ data, selectedItems, toggleItem }) => {
  return (
    <ul>
      {data.map((item) => (
        <ListItem
          key={item.id}
          // 从 name={item.name} 换成 item={item}
          item={item}
          selected={selectedItems.includes(item)}
          // 从 onClick={() => toggleItem(item)} 换成 toggleItem
          onClick={toggleItem}
        />
      ))}
    </ul>
  );
};

3.最后修改 ListItem 组件中的 onClick 方法,并把点击的参数 item 作为回调往外传

jsx
// 入参中的 name -> item
const ListItem = memo(({ item, selected, onClick }) => {
  expensiveOperation(selected);

  return (
    <li
      style={selected ? { textDecoration: "line-through" } : undefined}
      // 改写这里变成 onClick=>{onClick} -> onClick={() => onClick(item)}
      // 为什么这里不需要用 useCallback 包裹呢?答案是没必要,加了反而降低性能,因为它传到的是原生标签
      onClick={() => onClick(item)}
    >
      {item.name}
    </li>
  );
})

当我们修改完代码并加上 useCallback 之后,再去开发者工具中测一下:

result

我们发现并没起到任何的优化作用,渲染的时间还是和之前一样,onClick 还是改变了。这是为什么呢???

通过分析发现,每当我们调用了 setSelected 的时候, 外部的 selected 数组都发生了改变,selected 作为 toggleItem 的依赖项,然后又导致了其重新声明了。

解决办法其实很简单,就是不要把 selected 作为其依赖项,而去使用 useState 的函数式更新。

jsx
const toggleItem = useCallback((item) => {
  // 修改这里
  setSelected((prevSelected) => {
      if (!prevSelected.includes(item)) {
          return [...prevSelected, item];
      } else {
          return prevSelected.filter((current) => current !== item);
      }
  });
}, []);

改完之后我们再到开发者工具里看一哈

res2

我们这里点击了 item1、2、3,可以看到,只有点击的对应组件本身进行了渲染,每次的渲染时间变成了10~20ms,对比之前 1s 的全量重渲染时间,快了贼多有没有?

useMemo ​

最后我们还要注意到,在 ListItem 中有一个循环计算,它在组件的渲染体内,这也是比较耗费性能的操作,假如我们只关注它第一次渲染时得到的结果,没必要在每次重渲染的时候都重新对它进行计算,所以此时可以使用 useMemo 包裹它,它就不会在每次重渲染的时候重新执行。

jsx
const ListItem = memo(({ item, selected, onClick }) => {
  // 在 render-body 模拟真实应用进行一次复杂的计算
  useMemo(() => expensiveOperation(), []);

  return (
    <li
      style={selected ? { textDecoration: "line-through" } : undefined}
      onClick={() => onClick(item)}
    >
      {item.name}
    </li>
  );
});

加了 useMemo 之后,我们可以看到组件的渲染时间变成了 4ms 左右了,比之前的十几毫秒更快了

image-20230304222246185

其它问题 ​

1.啥时候该用 React.memo? ​

当你的组件需要接收外部 props 的时候,建议统一都加上 memo,但是此时要关注是否有接收函数/对象/数组等引用数据类型的属性,如果有,就需要确保其使用 useCallback/useMemo 来保证其引用地址的唯一性,防止子组件不必要的重渲染。

2.啥时候该用 useCallback? ​

只有当一个函数要传递给使用 React.memo 包裹过的子组件的时候,才需要使用 useCallback 来包裹这个函数。

就像我们上面的例子一样, ListItem 是使用 memo 包裹过的,并且接收了一个外部的函数入参,这时候这个外部的函数才需要使用 useCallback包裹。

假如像这样的写法,就完全没必要用 useCallback,否则只会是负优化

jsx
export default function App() {
    const [selected, setSelected] = useState(false);
    
    const handleClick = () => {
        setSelected(!selected);
    }
    
    return <div onClick={handleClick}>{selected ? 'true' : 'false'}</div>
}

为什么说上面的代码加了 useCallback 是负优化?我们先来看下源码里的这两个方法

当我们组件第一次挂载的时候,会把 useCallback 包裹的函数和依赖放到 hook 的缓存中,然后在组件重渲染的时候,会把缓存取出来进行比对,如果依赖没变化就返回之前缓存的函数,此时其缓存的函数引用地址没发生变化,如果依赖发生改变了再重新去缓存。

jsx
function mountCallback(callback, deps) {
  const hook = mountWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  hook.memoizedState = [callback, nextDeps];
  return callback;
}

function updateCallback(callback, deps) {
  const hook = updateWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  const prevState = hook.memoizedState;
  if (prevState !== null) {
    if (nextDeps !== null) {
      const prevDep = prevState[1];
      // 对比依赖是否发生了改变
      if (areHookInputsEqual(nextDeps, prevDeps)) {
        return prevState[0];
      }
    }
  }
  hook.memoizedState = [callback, nextDeps];
  return callback;
}

说它是负优化就是本身缓存就是会耗费一定的内存空间的,并且如果有依赖项,还会增加了一次对比的过程,并且这个 handleClick 方法传递给的是原生标签,并不会发生任何优化作用的行为,所以 useCallback 不能无脑去用。 useMemo 其实也是差不多的道理,很简单的计算量不大的逻辑就完全没必要用 useMemo 了。

3.当useCallback函数包裹的依赖项太多了怎么优化? ​

在真实的应用场景中往往事情没这么简单,比如我们在 App 组件中新增了一个 isEnabled Hook,并且 toggleItem 依赖了这个 isEnabled 状态,每当 isEnabled 发生改变的时候,都会触发 App 组件重渲染,此时 toggleItem 本身也会发生改变,然后就会去让 List 重渲染,接下来就是 ListItem 重渲染..., 产生了一连串的连锁反应,之前说的优化都白干了

image-20230304232705799

jsx
export default function App() {
  const [selected, setSelected] = useState([]);
  const [isEnabled, setEnabled] = useState(false);

  const toggleItem = useCallback(
    (item) => {
      // Only toggle the items if enabled
      if (isEnabled) {
        setSelected((prevSelected) => {
          if (!prevSelected.includes(item)) {
            return [...prevSelected, item];
          } else {
            return prevSelected.filter((current) => current !== item);
          }
        });
      }
    },
    [isEnabled]
  );

  return (
    <div className="App">
      <button onClick={() => setEnabled(!isEnabled)}>
        {isEnabled ? "关闭" : "打开"}选中操作
      </button>
      <h1>长列表 Demo</h1>
      <List data={data} selectedItems={selected} toggleItem={toggleItem} />
    </div>
  );
}

那么该如何解决这个问题呢?

1.使用 useReducer ​

首先我们来看下 useReducer 重构过后的页面表现。当我们切换选中操作开关的时候,可以看到 ListItem 组件已经不像之前一样会重渲染了。

usereducer

根据 新版 React 官方文档 的介绍,在多事件处理函数中更新多状态的组件,这种情况就很适合把状态更新的逻辑给合并到一个函数中,这个函数就叫 reducer,其实新版官网里写的相当详细,解释了很多问题,具体的可以自己进去看一下。

根据官网的最佳实践:

  • useReducer 的 reducer 保证是一个纯函数(固定的输入总是会有固定的输出)
  • 每一个 case 只处理一类事件

于是我们写了下面这样的一段代码:

jsx
// 使用 TS 的话可以用 enum 枚举
const REDUCER_TYPE = {
  SET_IS_ENABLED: "setIsEnabled",
  SET_SELECTED: "setSelected",
  TOGGLE_ITEM: "toggleItem",
};

const appState = {
  selected: [],
  isEnabled: false,
};

const reducer = (state, action) => {
  switch (action.type) {
    case REDUCER_TYPE.SET_SELECTED: {
      return {
        ...state,
        selected: action.selected,
      };
    }
    case REDUCER_TYPE.SET_IS_ENABLED: {
      return {
        ...state,
        isEnabled: action.isEnabled,
      };
    }
    case REDUCER_TYPE.TOGGLE_ITEM: {
      if (state.isEnabled) {
        return {
          ...state,
          selected: !state.selected.includes(action.item)
            ? [...state.selected, action.item]
            : state.selected.filter((current) => current !== action.item),
        };
      } else {
        return state;
      }
    }
    default: {
      throw Error("Unknown action: " + action.type);
    }
  }
};

export default function App() {
  // const [selected, setSelected] = useState([]);
  // const [isEnabled, setEnabled] = useState(false);
  const [state, dispatch] = useReducer(reducer, appState);

  // 修改这段代码为 dispatch 事件
  const toggleItem = useCallback(
    (item) => {
      dispatch({
        type: REDUCER_TYPE.TOGGLE_ITEM,
        item,
      });
    },
    [dispatch]
  );

  return (
    <div className="App">
      <button
        onClick={() => {
      	  // dispatch 事件
          dispatch({
            type: REDUCER_TYPE.SET_IS_ENABLED,
            isEnabled: !state.isEnabled,
          });
        }}
      >
    	// 从 state 中读取 isEnabled
        {state.isEnabled ? "关闭" : "打开"}选中操作
      </button>
      <h1>长列表 Demo</h1>
      <List
        data={data}
		// 从 state 中读取 selected
        selectedItems={state.selected}
        toggleItem={toggleItem}
      />
    </div>
  );
}

再来看下 useState 和 useReducer 的对比:

  • useReducer 代码量会更多,但是如果多个事件函数中都要更新状态的时候,useReducer 写出的代码可能会更好维护,并且它也能很好解决一些重渲染的问题,因为它把状态的增删改都抽离于组件本身了
  • 可读性一般来说是 useState 更强一些,但是当组件状态变多且复杂的时候,useReducer 可读性反而会更好(但是要保证每个action的细粒度),不过这样带来的代价也是代码量上升
  • 从调试和测试的角度看也是 useReducer 更好,因为 useReducer 是纯函数不依赖于组件的渲染逻辑,而且可以很容易看到每一个 action 对应的状态改变发生的行为

2.使用状态管理库 ​

如果你觉得用 useReducer 太麻烦的话(代码量太多),可以试试状态管理库。老牌的 Redux 就不用说了,其实和 reducer 的写法差不多。这里有几个比较新且好用的状态管理库。分别是 Jotai 12.1k⭐、 Zustand 28k⭐、Valtio 6.3k⭐ ,这三个状态管理库都是同一个团队开发的,而且用的人还挺多。其中 Jotai 和 Recoil 类似, Zustand 和 Redux 类似,Valtio 和 Mobx 类似,它们的名字分别是日语、 德语、芬兰语 中的 “状态”,这几个库和之前一些老牌的库比上手要更简单,而且使用起来更简洁,并且主打轻量级,gzip 后不超过 3kb。

上面提到的几个库本质上代表了3个流派:

dispatch 流派(单向数据流-中心化管理):redux、zustand、dva 等

响应式流派(中心化管理):mobx、valtio 等

原子状态流派(原子组件化管理):recoil、jotai 等

这里我们就以 zustand 为例, 这个包 gzip 之后只有 1.1k 的大小,真是小到不要不要的了。

image-20230305222858844

我们现在就使用 zustand 来重构我们上面 useReducer 的例子

jsx
import { create } from "zustand";
import { shallow } from "zustand/shallow";

// 首先创建一个 Store
// 如果想要以可变的书写方式来返回不可变的数据,可以使用 immer 这个包
// 比如 set(produce(state => { state.isEnabled = !state.isEnabled; }))
// 或者使用 zustand 提供的 immer 中间件,可以使下面的代码更为简洁
const useAppStore = create((set) => ({
  selected: [],
  isEnabled: false,
  changeIsEnabled: () => set((state) => ({ isEnabled: !state.isEnabled })),
  toggleItem: (item) =>
    set((state) => {
      if (state.isEnabled) {
        return {
          ...state,
          selected: !state.selected.includes(item)
            ? [...state.selected, item]
            : state.selected.filter((current) => current !== item),
        };
      } else {
        return state.selected;
      }
    }),
}));

export default function App() {
  // 取出里面的属性,当下面4个属性中的任意一个发生改变的时候就会触发页面重渲染, 
  // shallow 表示 oldState 和 newState 之间会进行浅比较来进行是否需要重渲染
  const [selected, isEnabled, changeIsEnabled, toggleItem] = useAppStore(
    (state) => [
      state.selected,
      state.isEnabled,
      state.changeIsEnabled,
      state.toggleItem,
    ],
    shallow
  );

  return (
    <div className="App">
      <button onClick={changeIsEnabled}>
        {isEnabled ? "关闭" : "打开"}选中操作
      </button>
      <h1>长列表 Demo</h1>
      <List data={data} selectedItems={selected} toggleItem={toggleItem} />
    </div>
  );
}

我们再来看一下页面的表现,当我们切换 isEnabled 状态的时候,页面重渲染了,但是我们在 ListItem 并没发生重渲染,说明 toggleItem 这个函数并没有发生变化,这一切都是有 zustand 这个库在底层帮我们做好了优化 ,可以看到代码量也比自己用 useReducer 要少得多并且更加可读,当配合 immer 使用的时候代码还能更加简洁。

zustand

Zustand 在底层使用了 React 的 useSyncExternalStoreWithSelector(React 18内置) 这个 Hook,它可以让组件依据所选择的属性发生变化的时候进行重渲染,这也让 Zustand 不需要像 Redux 一样使用 Provider 来包裹组件,并且还能防止不必要的重渲染行为,使用起来更简单。