盘点React 代码优化的 N 种方法

2,112 阅读11分钟

盘点React 代码优化的 N 种方法

参考:

  1. 避免React Context导致的重复渲染:zhuanlan.zhihu.com/p/50336226
  2. Immutability in React: There’s nothing wrong with mutating objects: blog.logrocket.com/immutabilit…
  3. Immutable.js,持久化数据结构和结构共享 - 为什么使用 Immutable.js 而不是普通的 JavaScript 对象?:medium.com/@dtinth/imm…
  4. Immutable.js 是如何实现数据结构持久化的?: www.cnblogs.com/DM428/p/139…

目录

  1. 使用 React 本身的优化方法:PureComponent、shouldComponentUpdate、memo、useMemo、useCallback
  2. React 里几种会影响性能的用法:
  1. 错误使用 context 导致所有子组件 re-render,不管是不是 context 的消费者;
  2. 由于 React 组件默认根据浅比较 props 来决定是否更新的策略,引用类型的 props 会让子组件随着父组件更新也做不必要的更新;
  3. 在 React 组件生命周期方法和 React 事件回调之外,多次调用 setState, 导致 setState 不能被批量化,造成多次重渲染
  1. 不可变数据,immutable.jsimmer
  2. 减少重复计算:reselector

正文

1. 使用 React 本身的优化方法

驱动 React 组件更新的来源有以下这些:

  • 自身 state 的更新
  • 父组件的更新(不管 props 有没有更新,只要父亲组件更新,当前组件也会被迫更新)
  • props 更新
  • forceUpdate强制更新

在类组件中,默认情况下 shouldComponentUpdate 返回 true,只要以上四点发生了,组件就会更新。前三点可能会导致多余的不必要更新,为了避免不必要更新,可以使用 shouldComponentUpdate 分别比较新旧 state 和新旧 props 来决定组件是否应该更新,更简单点使用 PureComponent,它内部的 shouldComponentUpdate 只是简单浅比较 state 和 props (所以当 state/props 是对象时,如果我们想要更新组件,必须用新对象覆盖原来的值,否则浅比较会导致组件无法更新)。在 React 里类似的优化方法还有使用 memo 包裹组件,这个是可以在类组件和函数组件中都可以用的,也可以自定义比较逻辑。前面这些方法都是用在当前组件里面用来阻止自身无效更新,在父组件上我们也可以用一些方法阻止当前组件的无效更新,这主要通过缓存 props 来 实现 ,用的方法就是 useMemo、useCallback 包裹 props 再传给子组件。

2. React 里几种会影响性能的用法

  1. 错误使用 context 可能会导致所有子组件 re-render,不管是不是 context 的消费者

    使用 context 的反模式:context 子组件和 context value 的更新放在一个组件里面,导致 context value 更新时,让所有 消费组件 和 非消费组件 无差别更新

    codesandbox.io/embed/jolly…

    正确使用 context:把 context value 的管理和消费组件分开,在 context value 更新的时候,只会更新消费组件,不会让非消费组件更新

    codesandbox.io/embed/aged-…

    从上面这两个例子中也证实,如果不做优化,子组件会随着父组件的更新而全量更新,由此,除了将 context value的管理与 context 消费组件分开,我们还可以将消费组件包上 React.memo() ,来避免消费组件的无效更新。

  2. 由于 React 组件默认根据浅比较 props 来决定是否更新的策略,引用类型的 props 会让子组件随着父组件更新也做不必要的更新

    在类组件中,为了防止这种情况,应该避免在 render 方法里面创建引用类型的 props 并传给子组件,否则每次父组件重渲染,引用类型的 props 都会是一个新值,导致子组件重渲染。避免在 render 里面创建新引用,比如不要在 props 中传递内联函数,回调函数应该放在类组件的属性上初始化,最好用箭头函数语法(绑定 this,看这篇文章)。

    而在函数组件里面,可以用 useMemo、useCallback 包裹 props 再传给子组件, 这些 hooks 会记忆所计算的值,每次组件渲染的时候可以沿用上一次计算的结果,当然如果计算结果是一个对象,它的引用也不会变了,所以不会引起子组件重渲染。

  3. 在 React 组件生命周期方法和 React 事件回调之外,多次调用 setState, 导致 setState 不能被批量化,造成多次重渲染

    我们知道 setState 有可能是异步的,这个异步不是指 setState 是异步执行的,而是说 setState 的效果是异步发生的,也就是 state 的更新是异步的,React 的 setState 在同一个批量事物中是会被合并为一次更新的,这提高了 React 的性能。以下情况会将 setState 批量化为一次:

    • 多个 setState 在同一个 React 合成事件回调中执行
    • 多个 setState 在同一个 React 生命周期里执行
    export default function App() {
      const [a, setA] = useState(0);
      const [b, setB] = useState(0);
      const [c, setC] = useState(0);
      const handleClick = () => {
        setA((a) => a + 1);
        setB((b) => b + 2);
        setC((c) => c + 3);
      };
      console.log("render");
      return (
        <div className="App">
          <h1>Hello CodeSandbox</h1>
          <button onClick={handleClick}>click</button>
          <h2>
            {a}-{b}-{c}
          </h2>
        </div>
      );
    }
    

    但是有的时候 setState 的效果也可能是同步发生的,如果 setState 不实在 React 合成事件中调用,比如在以下情况中多次调用 setState, 那么 state 的更新是同步的,不能被批量化,组件随每次 setState 更新:

    • DOM 原生事件回调
    • 异步回调中, promise.then() 、setTimeout
    export default function App() {
      const [a, setA] = useState(0);
      const [b, setB] = useState(0);
      const [c, setC] = useState(0);
      const handleClick = () => {
        setTimeout(() => {
          setA((a) => a + 1);
          setB((b) => b + 2);
          setC((c) => c + 3);
        }, 0);
    	/*
    	Promise.resolve().then(() => {
    	      setA((a) => a + 1);
    	      setB((b) => b + 2);
    	      setC((c) => c + 3);
    	    });
    	*/
      };
      console.log("render");
      return (
        <div className="App">
          <h1>Hello CodeSandbox</h1>
          <button onClick={handleClick}>click</button>
          <h2>
            {a}-{b}-{c}
          </h2>
        </div>
      );
    }
    

    对于这种情况,我们可以改变 state 的结构,将多个 state 合成一次,并且尽可能减少 setState 的次数

    React 18 之后,会默认批量化所有 setState,不管是 React 合成事件回调里面的,还是 setTimeout 等异步回调里面。不过有一些情况,我们如果还是需要禁止批量化,React 也提供一个 API ReactDOM.fluchSyc(() ⇒ {setState...}) 可以跳过批处理

3. 不可变数据

不可变数据就是,当你试图改变一个变量的值的时候,他会创建一个新的变量代替原来的变量,而不会直接修改原来的变量,原来的变量值是不可变的。

不可变数据是函数式编程的一部分

这个对于 React 尤其重要。 React 不允许我们直接修改 state 是有原因的,当然这也是它自身的设计导致的,不直接修改 state 可以让 React 通过比较新旧 state 来决定是否更新组件,如果 state 是一个深度嵌套的对象,就只能深度递归比较,这会消耗应用的性能,也给开发者带来不便。如果允许只通过浅比较(用 ===!== 运算符)就能知道组件是否应该更新就会更高效。所以我们在 React 组件里面更新 state 的时候就会这么写:


state = {
    user: {
      name:  'chege',
      age: 24friend: {
        name: 'xiaoming',
        age: 22
     }
  }
}
addAge() {
  // spread operator ... 
	this.setState({
    user: {
      ...this.state.user,
      age: 25
    }
   })
  /*  或者 Object.assign
	this.setState(
    Object.assign({}, this.state.user, {age: 25})
  )
	*/
}

...Object.assign()浅拷贝对象,新旧对象只会在第一层引用不同,深层对象的引用依旧相同。这可以保证依赖浅层对象的组件高效更新的同时,也能避免依赖深层对象的组件不必要的更新,因为我们只是改变了浅层的对象,深层对象没有更改。

在 Redux 中,reducer 也要求是一个纯函数,不能修改 state, 原因同 React 一样, 为了更方便的判断 state 是否更新 :

不要修改 state。 使用 Object.assign() 新建一个副本。不能这样使用 Object.assign(state, { visibilityFilter: action.filter }),因为它会改变第一个参数的值。你必须把第一个参数设置为空对象。你也可以开启对 ES7 提案对象展开运算符的支持, 从而使用 { ...state, ...newState } 达到相同的目的。

但是如果 state 对象再深一点,我们有可能不得不写一大堆 ...Object.assign() ,这样会导致代码观感不好:

state = {
    user: {
    name:  'chege',
    age: 24friend: {
      name: 'xiaoming',
      age: 22
    }
  }
}
addFriendAge() {
  // spread operator ... 
	this.setState({
          user: {
	    ...this.state.user,
           friend: {
             ...this.state.user.friend,
             age: 23
	   }
        }
      })
  /*  或者 Object.assign
	this.setState(
    Object.assign(
      {},
      Object.assign(
        {},
        this.state.user,
        
      ), 
      {age: 25}
    )
  )
	*/
}

如果想兼顾代码美观和应用性能,我们可以使用一些第三方的库,它们创建了一种 持久数据结构, 在创建不可变对象时,能够重用不需要更改的旧对象部分,节省创建对象所需的时间和空间。

看看上面的浅拷贝用的 ...Object.assign(),虽然达到了使我们的 React 应用正常运行,并且做到了一定程度优化的目的,但是并不是最优的方法,除了代码难看外,还有浅拷贝依然会消耗点性能,当拷贝的对象包含 100000 个属性的时候,JavaScript 不得不老老实实的拷贝每一个属性,如果属性是基本类型,会拷贝一份基本类型的值。所以浅拷贝依然会浪费内存和程序运行时间。一种不仅仅可以用于 React 优化的数据不可变实现方案是 持久数据结构(Persistent data structures),它的目的是创建一个不可变数据,每次改变都会返回一个新的版本,而不会在原来数据上直接修改,同时为了节省内存和修改时间,复用没有更改的部分,这就是 结构共享(structural sharing)。

有很多实现 持久数据结构 和 结构共享 的方案,这里不做展开,只讨论 js 中几个实现了数据不可变的库所用的方法:

  • Immutable.js:它的持久数据结构基于 前缀树(trie,又称字典树),树的节点上 0 和 1 表示左、右节点,从上到下节点连起来的路径 path 就表示键,叶子节点表示键值,不可变的特性就是通过拷贝路径实现的,比如想要改变某个叶子节点(path 为 101,值为 12),只需要拷贝那条路径上的节点,旁边的节点则共享。每次修改会产生一条右边那样的新路径,但是树的结构未发生变化,新路径和原来未改变的节点还可以构成一颗新树,而且结构一样,实现了未修改部分的共享。这就是大概的原理。一些更细节的是,把键 hash 成数值,数值再转为二进制,通过位分区作为键映射到键值,更多详情可看这里:

    Immutable.js,持久化数据结构和结构共享 - 为什么使用 Immutable.js 而不是普通的 JavaScript 对象?

    Immutable.js 是如何实现数据结构持久化的?

  • immer.js: 没有实现自己数据持久结构,只是通过 proxy 来劫持数据修改,内部维护修改前和修改后的两份数据,用的数据结构也是 js 的原生对象,在数据量很大的时候会很耗性能。immer.js 的优点是 api 少,使用简单。

使用这些库,我们可以直接修改数据,返回来的时候是一个新的数据,不会修改原数据,同时前后两个数据只会在修改的部分断开引用,未修改的部分将会共享引用。

immer.js 修改 state,简洁明了:

import produce from "immer"

state = {
    user: {
	name:  'chege',
        age: 24friend: {
	 name: 'xiaoming',
         age: 22
       }
   }
}
addFriendAge() {
  this.setState(produce(draft => {
     draft.user.firend.age= 23 
  }))
}

我们可以测试下使用 immer.js 能不能共享未给变部分:

import produce from "immer";

const state = {
  user: {
    name: "chege",
    age: 24,
    friend1: {
      name: "xiaoming",
      age: 22
    },
    friend2: {
      name: "xiaoming",
      age: 22
    }
  }
};

const nextState = produce(state, (draftState) => {
  draftState.user.friend1.age = 23;
});

console.log(nextState.user === state.user); // false,user 属于发生改变的 path 的一个节点
console.log(nextState.user.friend1 === state.user.friend1); // false, friend1 属于发生改变的 path 的一个节点
console.log(nextState.user.friend2 === state.user.friend2); 
// true,共享了为改变的对象,friend2 不属于发生改变的 path 的一个节点,是应该共享的部分

4. 减少重复计算

Redux 中的 state 都放在一棵树上,当组件订阅某个 state 的时候,需要从顶部开始解构 store,从上到下一层一层最后到达目标 state,这中间可能还会有一些计算生成衍生数据,用于最终在 UI 上现实出来。当 store 更新的时候,所有订阅了 state 的组件都会收到更新通知,重新计算各自的订阅数据,即使所订阅的部分没有更新,或者更新了当时最终计算结果还是一样的,都会重算一次:

下面的例子里面,当我们点击 add a 时,ConnectChildAConnectChildBmapStateToProps 都会执行,尽管 ConnectChildB 订阅的 state 没有更新,但是它们都依赖于一个 store,所以都收到了更新通知,所有依赖于 store 的组件都全量更新了。

codesandbox.io/embed/confi…

由于 redux 的设计,store 下任何一个 state 都会全量更新下面所有组件,这是无法避免的,但是可以避免更新时发生太多的无效计算。在 mapStateToProps 这个节点缓存计算结果,避免组件不必要的更新。reselector 具体用法见:

GitHub - reduxjs/reselect: Selector library for Redux