盘点React 代码优化的 N 种方法
参考:
- 避免React Context导致的重复渲染:zhuanlan.zhihu.com/p/50336226
- Immutability in React: There’s nothing wrong with mutating objects: blog.logrocket.com/immutabilit…
- Immutable.js,持久化数据结构和结构共享 - 为什么使用 Immutable.js 而不是普通的 JavaScript 对象?:medium.com/@dtinth/imm…
- Immutable.js 是如何实现数据结构持久化的?: www.cnblogs.com/DM428/p/139…
目录
- 使用 React 本身的优化方法:
PureComponent、shouldComponentUpdate、memo、useMemo、useCallback等 - React 里几种会影响性能的用法:
- 错误使用 context 导致所有子组件 re-render,不管是不是 context 的消费者;
- 由于 React 组件默认根据浅比较 props 来决定是否更新的策略,引用类型的 props 会让子组件随着父组件更新也做不必要的更新;
- 在 React 组件生命周期方法和 React 事件回调之外,多次调用
setState, 导致setState不能被批量化,造成多次重渲染
- 不可变数据,
immutable.js、immer - 减少重复计算:
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 里几种会影响性能的用法
-
错误使用 context 可能会导致所有子组件 re-render,不管是不是 context 的消费者
❌ 使用 context 的反模式:context 子组件和 context value 的更新放在一个组件里面,导致 context value 更新时,让所有 消费组件 和 非消费组件 无差别更新
✅ 正确使用 context:把 context value 的管理和消费组件分开,在 context value 更新的时候,只会更新消费组件,不会让非消费组件更新
从上面这两个例子中也证实,如果不做优化,子组件会随着父组件的更新而全量更新,由此,除了将 context value的管理与 context 消费组件分开,我们还可以将消费组件包上
React.memo(),来避免消费组件的无效更新。 -
由于 React 组件默认根据浅比较 props 来决定是否更新的策略,引用类型的 props 会让子组件随着父组件更新也做不必要的更新
在类组件中,为了防止这种情况,应该避免在
render方法里面创建引用类型的 props 并传给子组件,否则每次父组件重渲染,引用类型的 props 都会是一个新值,导致子组件重渲染。避免在 render 里面创建新引用,比如不要在 props 中传递内联函数,回调函数应该放在类组件的属性上初始化,最好用箭头函数语法(绑定 this,看这篇文章)。而在函数组件里面,可以用
useMemo、useCallback包裹 props 再传给子组件, 这些 hooks 会记忆所计算的值,每次组件渲染的时候可以沿用上一次计算的结果,当然如果计算结果是一个对象,它的引用也不会变了,所以不会引起子组件重渲染。 -
在 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: 24,
friend: {
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: 24,
friend: {
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 对象?
-
immer.js: 没有实现自己数据持久结构,只是通过proxy来劫持数据修改,内部维护修改前和修改后的两份数据,用的数据结构也是 js 的原生对象,在数据量很大的时候会很耗性能。immer.js的优点是 api 少,使用简单。
使用这些库,我们可以直接修改数据,返回来的时候是一个新的数据,不会修改原数据,同时前后两个数据只会在修改的部分断开引用,未修改的部分将会共享引用。
用 immer.js 修改 state,简洁明了:
import produce from "immer"
state = {
user: {
name: 'chege',
age: 24,
friend: {
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 时,ConnectChildA 和 ConnectChildB 的 mapStateToProps 都会执行,尽管 ConnectChildB 订阅的 state 没有更新,但是它们都依赖于一个 store,所以都收到了更新通知,所有依赖于 store 的组件都全量更新了。
由于 redux 的设计,store 下任何一个 state 都会全量更新下面所有组件,这是无法避免的,但是可以避免更新时发生太多的无效计算。在 mapStateToProps 这个节点缓存计算结果,避免组件不必要的更新。reselector 具体用法见: