React 作为当今web前端最热门的框架之一,其灵活的 Component、高效的 Virtual Dom、以及可控的单向数据流 State 管理等特性,已经是很多开发者搭建项目首选。也正因如此,很多初学 React 者认为:

React 好像就意味着组件化、高性能,我们永远只需要关心数据整体,两次数据之间的 UI 如何变化,则完全交给 Virtual DomDiff 算法去做。以至于我们很随意的去操纵数据,基本优化 shouldComponentUpdate 也懒得去写,毕竟不写也能正确渲染。但随着应用体积越来越大,会发现页面好像有点变慢了,特别是组件嵌套比较多,数据结构比较复杂的情况下,随便改变一个表单项,或者对列表做一个筛选都要耗时 100ms 以上,这个时候我们就需要优化了!

于是乎,接下来我们一起探讨 React 该如何去从代码层面优化性能。

1. React 工作原理

首先我们来简单了解下 React 是如何工作的,知其然知其所以然,只有了解了其工作原理,才能明白 React 的优化从何下手。由于 React 在完成一次 render 更多的是操作 Virtual Dom,所以我们主要围绕 React 对 Virtual Dom 调度方面来理解。

  • Virtual Dom 模型
  • 生命周期管理
  • setState 机制
  • Diff 算法

Virtual Dom 模型

virtual dom 实际上是对真实 Dom 的一个抽象,是一个js对象。react 所有的表层操作实际上是在操作 Virtual dom。

虚拟 DOM 是 React 的一大亮点,具有 batching (批处理) 和高效的 Diff 算法。这让我们可以无需担心性能问题而“毫无顾忌”的随时“ 刷新”整个页面,由虚拟 DOM 来确保只对界面上真正变化的部分进行实际的 DOM 操作。在实际开发中基本无需关心虚拟 DOM 是如何运作的,但是理解其运行机制不仅有助于更好的理解 React 组件的生命周期,而且对于进一步优化 React 程序也会有很大帮助。

Virtual Dom流程图.png


生命周期管理

React 生命周期可分为三个阶段:

  1. Mounting(挂载阶段)
  2. Updating(更新阶段)
  3. Unmounting(卸载阶段)

React16 废弃的三个生命周期函数:

  • componentWillMount
  • componentWillReceiveProps
  • componentWillUpdate
注:目前在16版本中 componentWillMountcomponentWillReceivePropscomponentWillUpdate 并未完全删除这三个生命周期函数,而且新增了 UNSAFE_componentWillMountUNSAFE_componentWillReceivePropsUNSAFE_componentWillUpdate 三个函数,官方计划在17版本完全删除这三个函数,只保留 UNSAVE_ 前缀的三个函数,目的是为了向下兼容,但是对于开发者而言应该尽量避免使用他们,而是使用新增的生命周期函数替代它们。

取而代之的是两个新的生命周期函数:

  • static getDerivedStateFromProps
  • getSnapshotBeforeUpdate

以下是 React 生命周期流程图:

react生命周期.png

Mounting(挂载阶段)

挂载阶段,也可以理解为组件的初始化阶段,就是将我们的组件插入到 DOM 中,只会发生一次。
这个阶段的生命周期函数调用如下:

Updating(更新阶段)

更新阶段,当组件的 props 改变了,或组件内部调用了 setStateforceUpdate,会发生多次。
这个阶段的生命周期函数调用如下:

Unmounting(卸载阶段)

卸载阶段,当组件被卸载或者销毁了,只会发生一次。
这个阶段的生命周期函数只有一个:


setState 机制

setState(updater[, callback])

我们知道 React 的 state 管理使用了 immutable 理念设计,所以当你想要改变 state 值的时候,无法使用传统的赋值方式this.state.counter = 1 去修改,而是需要通过调用 setState(updater[, callback]) 的方式修改。

setState() 将对组件 state 的更改排入队列,并通知 React 需要使用更新后的 state 重新渲染此组件及其子组件。将 setState() 视为请求而不是立即更新组件的命令。为了更好的感知性能,React 会延迟调用它,然后通过一次传递更新多个组件。React 并不会保证 state 的变更会立即生效。

setState() 并不总是立即更新组件。它会批量推迟更新。这使得在调用 setState() 后立即读取 this.state 成为了隐患。为了消除隐患,请使用 componentDidUpdate 或者 setState 的回调函数(setState(updater, callback)),这两种方式都可以保证在应用更新后触发。如需基于之前的 state 来设置当前的 state,可以使用带有形式参数的 updater 函数。

(state, props) => stateChange

state 是对应用变化时组件状态的引用。当然,它不应直接被修改。你应该使用基于 state 和 props 构建的新对象来表示变化。例如,假设我们想根据 props.step 来增加 state:

this.setState((state, props) => {
  return {counter: state.counter + props.step};
});

updater 函数中接收的 stateprops 都保证为最新。updater 的返回值会与 state 进行浅合并。

setState() 的第二个参数为可选的回调函数,它将在 setState 完成合并并重新渲染组件后执行。通常,我们建议使用 componentDidUpdate() 来代替此方式。

除非 shouldComponentUpdate() 返回 false,否则 setState() 将始终执行重新渲染操作。如果可变对象被使用,且无法在 shouldComponentUpdate() 中实现条件渲染,那么仅在新旧状态不一致时调用 setState() 可以避免不必要的重新渲染。

Diff 算法

Diff 算法用于计算出两个 virtual dom 的差异,是 React 中开销最大的地方。

传统 Diff 算法通过循环递归对比差异,查找两个随机树之间的最小差异算法复杂度为 O(n^3)。如你所想,这么高复杂度的算法是无法满足我们的需求的。React 使用了一种更为简单且直观的算法使得算法复杂度优化至O(n)。

React Diff 算法策略:

  • Web UI 中 DOM 节点跨层级的移动操作特别少,可以忽略不计。
  • 拥有相同类的两个组件将会生成相似的树形结构,拥有不同类的两个组件将会生成不同的树形结构。
  • 对于同一层级的一组子节点,它们可以通过唯一 id 进行区分。

为了方便理解,可以归纳为:

  • a. tree diff
  • b. component diff
  • c. element diff

tree diff

基于 tree diff 策略,React 对 Virtual DOM 树进行 分层比较、层级控制,只对相同颜色框内的节点进行比较(同一父节点的全部子节点),当发现某一子节点不在了直接删除该节点以及其所有子节点,不会用于进一步的比较,在算法层面上就是说只需要遍历一次就可以了,而无需在进行不必要的比较,便能完成整个 DOM 树的比较。

如图:

tree diff.png

同属于分层比较、层级控制范畴,还会出现 DOM 节点跨层级的移动操作( React 中这种情况 DOM 节点不稳定,损害性能,所以开发中不推荐这种情况的出现),React diff怎么解决的呢?如下图情况:

tree diff  dom.png

上面描述的是同一层次不同 DOM 节点范畴,React diff 用趋近于‘暴力’的方式,并不是把 A B C 直接拼接到 D 节点上,而是删除 A B C 三个节点之后在 D 下面在创建的 A B C。

component diff

React 是基于组件构建应用的,对于组件间的比较所采用的策略也是简洁高效。

  • 对于同一类型的组件,根据 Virtual DOM 是否变化也分两种,可以用 shouldComponentUpdate() 判断 Virtual DOM 是否发生了变化,若没有变化就不需要在进行 diff,这样可以节省大量时间,若变化了,就对相关节点进行 update
  • 对于非同一类的组件,则将该组件判断为 dirty component,从而替换整个组件下的所有子节点。

如下图,当 component D 改变为 component G 时,即使这两个 component 结构相似,一旦 React 判断 D 和 G 是不同类型的组件,就不会比较二者的结构,而是直接删除 component D,重新创建 component G 以及其子节点。虽然当两个 component 是不同类型但结构相似时,React diff 会影响性能,但正如 React 官方文档所言:不同类型的 component 是很少存在相似 DOM tree 的机会,因此这种极端因素很难在实现开发过程中造成重大影响的。

component diff.png

element diff

所有同一层级的子节点,他们都可以通过 key 来区分,并遵循策略 a、b。

element diff 1.png

没经过优化的算法,实现新老交替的方法是将 A B C D 全部删除之后,再新建 B A D C,这样的实现方法显然效率很低,React diff 怎么优化呢?是通过为每一个节点添加key值标识。

新老集合所包含的节点,如上图所示,新老集合进行 diff 差异化对比,通过 key 发现新老集合中的节点都是相同的节点,因此无需进行节点删除和创建,只需要将老集合中节点的位置进行移动,更新为新集合中节点的位置,此时 React 给出的 diff 结果为:B、D 不做任何操作,A、C 进行移动操作,即可。

上述分析的是新老集合中存在相同节点但是位置不同,要是有新加入的节点且有旧节点需要删除呢?如下图:

element diff 2.png

加了key的好处:

如果不加 key,map 遍历的时候控制台发出 warn,既然是 warn 就说明不加也能实现遍历,但是经过删除、创建、插入实现,这样的话损害性能可想而知,而加上 key 就可以有助于 React diff 算法结合 Virtual DOM 找到最合适的方式进行 diff,最大限度的实现高效 diff,即哪里需要改变,就改变哪里!

2. React 性能优化

由于 React 中性能主要耗费在于 update 阶段的 diff 算法,因此性能优化也主要针对 diff 算法,主要的优化方式归纳为以下几点:

  • shouldComponentUpdate 优化
  • Immutable 数据结构优化
  • propsbind 的优化
  • React key 的优化
  • 组件开发方式优化

shouldComponentUpdate 优化

当 React 组件的 propsstate 任一改变,都会触发 render 函数的执行,同样的父组件执行了 render 函数,即使未改变子组件的 props 也会触发子组件 render 函数的执行,进而促使 React Diff 对改变前后虚拟 Dom 节点的对比,来决定是否需要更新真实 Dom。

虽然 Virtual Dom 帮我们避免了没必要的真实 Dom 操作,但是随着应用复杂的提升,Dom 树越来越复杂,大量的对比操作也会影响性能。比如一个 List 组件中,其中一个 Item 组件需要执行 setState 方法,那么其他 Item 组件也都会执行一次 render 更新操作,其实这是没必要的更新,造成了性能的损耗,此时我们可以通过定制组件的 shouldComponentUpdate 来避免没必要的更新。

以下是比较未定制和定制 shouldComponentUpdate 生命周期对 render 更新影响例子:

let renderCount = 0;
class Message extends React.Component {
  render() {
    const { text, updateParentStateless, updateParentState } = this.props;
    renderCount += 1;
    return (
      <div>
        <h2>未定制 shouldComponentUpdate</h2>
        <p>来自父组件的内容:<strong>{text}</strong></p>
        <p>
          <button className="btn btn-default" onClick={updateParentStateless}>更新父组件无状态改变</button>
          <button className="btn btn-primary" onClick={updateParentState}>更新父组件改变状态</button>
        </p>
        <p>子组件render次数:<strong>{renderCount}</strong></p>
      </div>
    );
  }
}

let parentRenderCount = 0;
class App extends React.Component {
  state = {
    text: '初始化内容',
    updateCount: 0
  };
  updateParentStateless = () => {
    this.setState({});
  };
  updateParentState = () => {
    this.setState(state => {
      state.updateCount < 3 && state.updateCount++;
      return { text: `更新内容次数${state.updateCount}` };
    });
  };
  render() {
    parentRenderCount += 1;
    return (
      <div>
        <Message text={this.state.text} updateParentStateless={this.updateParentStateless} updateParentState={this.updateParentState} />
        <p>父组件render次数:<strong>{parentRenderCount}</strong></p>
      </div>
    );
  }
}

non-shouldComponentDidMount.gif

上面的例子,父组件更新改变子组件的 props 与否,子组件的 render 次数都跟父组件一致,说明父组件 render 后,会引起子组件的 render

接下来,我们修改子组件,定制 shouldComponentUpdate 生命周期函数:

class Message extends React.Component {
  shouldComponentUpdate(nextProps) {
    if (nextProps.text !== this.props.text) {
       return true;
    }
    return false;
  }
  render() {
    const { text, updateParentStateless, updateParentState } = this.props;
    renderCount += 1;
    return (
      <div>
        <h2>定制 shouldComponentUpdate</h2>
        <p>来自父组件的内容:<strong>{text}</strong></p>
        <p>
          <button className="btn btn-default" onClick={updateParentStateless}>更新父组件无状态改变</button>
          <button className="btn btn-primary" onClick={updateParentState}>更新父组件改变状态</button>
        </p>
        <p>子组件render次数:<strong>{renderCount}</strong></p>
      </div>
    );
  }
}

shouldComponentDidMount.gif

通过定制 shouldComponentUpdate 生命周期函数,判断 text 属性是否被修改,再决定是否更新组件,避免了不必要的 render 渲染,从而减少 React Diff 对虚拟 Dom 对比的开销。

同样的,我们也可以使用 PureComponent 组件对其进行优化,PureComponent 组件与 Component 组件相似,PureComponent 组件以浅比较(shallowEqual) props 和 state 的方式实现了 shouldComponentUpdate 函数。我们再次修改子组件:

class Message extends React.PureComponent {
  render() {
    const { text, updateParentStateless, updateParentState } = this.props;
    renderCount += 1;
    return (
      <div>
        <h2>PureComponent</h2>
        <p>来自父组件的内容:<strong>{text}</strong></p>
        <p>
          <button className="btn btn-default" onClick={updateParentStateless}>更新父组件无状态改变</button>
          <button className="btn btn-primary" onClick={updateParentState}>更新父组件改变状态</button>
        </p>
        <p>子组件render次数:<strong>{renderCount}</strong></p>
      </div>
    );
  }
}

PureComponent.gif

PureComponent 组件的浅比较,类似对象的浅复制,只会比较第一层,对于深层次的嵌套是无法准确比较的,因此,对于复杂数据类型 PureComponent 组件的浅比较无法达到预期,可以使用 Immutable 处理复杂的数据类型。

Immutable 数据结构优化

Immutable 是 facebook 在2014推出的持久性数据结构库,旨在解决 JavaScript 中固有的不可变(Immutability)问题,为应用程序提供不可变带来的所有好处。

Immutable 对象一旦创建,就不能再被更改,任何修改或添加删除操作都会返回一个新的 Immutable 对象。Immutable 实现的原理是 Persistent Data Structure(持久化数据结构),也就是使用旧数据创建新数据时,要保证旧数据同时可用且不变。同时为了避免 deepCopy 把所有节点都复制一遍带来的性能损耗,Immutable 使用了 Structural Sharing(结构共享),即如果对象树中一个节点发生变化,只修改这个节点和受它影响的父节点,其它节点则进行共享。请看下面动画:

Immutable-data-tree.gif

import { Map, List } from 'immutable';

const map1 = Map({
  a: 1,
  b: List([1, 2, 'aa'])
});

const map2 = map1.set('a', 2);

map1 === map2; // false
map1.get('a'); // 1
map2.get('a'); // 2
map1.get('b') === map2.get('b'); // true
上面代码 map1 的节点 a 修改后,map2 没有受到影响,map1 和 map2 共享了没有变化的 b 节点。

我们知道 PureComponent 组件的浅比较,对于深层次嵌套的数据结构是无法比较出差异的,而 Immutable 数据结构特有的持久化数据结构和结构共享特性,使得其每次修改的差异,浅比较都可以准确比较。

let renderCount = 0;
class Message extends React.PureComponent {
  render() {
    const { info, updateParentStateless, updateParentState } = this.props;
    renderCount += 1;
    return (
      <div>
        <h2>使用 Immutable 数据结构</h2>
        <p>来自父组件的内容:<strong>{info.get('text')}</strong></p>
        <p>
          <button className="btn btn-default" onClick={updateParentStateless}>更新父组件无状态改变</button>
          <button className="btn btn-primary" onClick={updateParentState}>更新父组件改变状态</button>
        </p>
        <p>子组件render次数:<strong>{renderCount}</strong></p>
      </div>
    );
  }
}

let parentRenderCount = 0;
class App extends React.Component {
  updateCount = 0;
  state = {
    info: Immutable.Map({
      text: '初始化内容'
    })
  };
  updateParentStateless = () => {
    this.setState({});
  };
  updateParentState = () => {
    this.setState(state => {
      this.updateCount < 3 && this.updateCount++;
      return { info: state.info.set('text', `更新内容次数${this.updateCount}`) };
    });
  };
  render() {
    parentRenderCount += 1;
    return (
      <div>
        <Message info={this.state.info} updateParentStateless={this.updateParentStateless} updateParentState={this.updateParentState} />
        <p>父组件render次数:<strong>{parentRenderCount}</strong></p>
      </div>
    );
  }
}

Immutable.gif

使用了 Immutable 数据类型,该数据每次修改都会返回一个新的引用,无节点改变时,返回当前引用。因此,点击“更新父组件改变状态”按钮,PureComponent 组件准确的比较出了 props 的改变。

propsbind 的优化

React 子组件获取父组件的数据及方法,大都是通过 props 传递,因此 props 对于组件是必不可少的,而不正确的使用 props 会造成不必要的性能消耗。bind 函数通常用于使调用方法时,该方法能获取当前组件的 this 指向当前组件。

props 优化

对于组件的 props 尽可能只传需要用到的,避免使用 {...props} 的方式传递,因为过多 props,或者层次传得太深,都会加重 shouldComponentUpdate 函数中数据对比的负担。因此,propsstate 的数据尽可能简单明了,扁平化。便于减少数据对比,数组遍历带来的性能消耗。

如果 props 是对象,则应使用定义变量的方式传递,避免 style={{color: '#000'}} 的方式传递,因为直接赋值,每次的 render 都会新建一个新的对象(即新的引用),导致 PureComponent 组件的浅比较失效。

bind 优化

绑定 this 使其指向当前组件的方式有以下3种:

1.constructor(构造函数)绑定

constructor(props) {
  super(props);
  this.handleClick = this.handleClick.bind(this);
}
handleClick(){};

2.使用时绑定

handleClick(){};
<a onClick={this.handleClick.bind(this)}>Confirm</a>
<a onClick={() => this.handleClick()}>Cancel</a>

3.定义时绑定

handleClick = () => {};
<a onClick={this.handleClick}>Confirm</a>

3种方式总结:
1.使用构造器bind的方法,每个实例都可以有效地共享函数体,从而更加有效的使用内存。但当要绑定的函数较多时,这种方法又显得相对的枯燥。因此,在知道实例不多,函数体不大的前提下,使用箭头函数更加快捷。
2.由于绑定是在 render 中执行,而 render 是会执行多次的,每次 bind 和箭头函数都会产生一个新的函数,PureComponent 组件浅比较会失效,因而带来了额外的开销。
3.综合三种写法,第三种是目前最优写法。


React key 的优化

在上面我们分析了对于同一层级的子节点使用 key 时,对 element diff 是有很大帮助的。当修改节点时,加上 key 值有助于 React diff 算法结合 Virtual DOM 找到最合适的方式进行 diff,最大限度的实现高效 diff。当然我们使用 key 时,应使用稳定且唯一的值做 key,不推荐使用 index,因为 index 对于遍历结果并不稳定。

如下例:

const items = sortBy(this.state.sortingTime, this.props.items);
return items.map(item => <img src={item.src} />);
如果顺序发生改变,React 会对元素进行 diff 操作并确定出最高效的操作是改变其中几个 img 元素的 src 属性。虽然如此,但是还是有了 diff 的计算时间,效率其实已经非常低了。

而当我增加一个唯一的 key

return items.map(item => <img src={item.src} key={item.id} />);
当添加了唯一 key 值 React 得出的结论就不是 diff,而是直接使用 insertBefore 操作,而这个操作是移动 DOM 节点最高效的办法。

比如有一老师批阅很多学生的作业,老师辛苦阅完了所有作业,发现某个学生作业有错误,就让该学生更改,该学生作业更正后,重新放到老师批改的作业当中,告诉老师要老师帮忙重新批改。如果这些作业都没有写学生名字(即唯一 key),此时老师分不清哪本是更正过的作业,只能辛苦地再次重新批阅。而如果这些学生的作业都写了自己名字(即都有一个唯一 key),老师只需要找到名字是该学生的作业批阅即可。

注:每个 key 值是唯一的,且,在组件内部也不能通过 this.props.key 获取。


组件开发方式优化

React 对于组件的理念是“组合优于继承”,因此,尽可能地细化组件,使其解耦,可以减少父层组件对子层组件的影响,增加组件复用性。

比如一个 list 组件包含多个 input,而 input 需要绑定当前组件的 state 来控制输入,如果不拆分 input 组件,这会导致,其中一个 input 输入改变 state 会影响其他 input 进行 DOM diff 操作。

而如果将 input 拆分成独立 PureComponent 的组件,则当前组件输入改变 state 并不会影响父层 list 组件渲染,从而大大节省了 DOM diff 的开支。

React 组件应尽可能的使用纯函数组件,因为,纯函数组件是无状态组件,省去了 React 标准组件生命周期函数执行等步骤,也可以大大提高组件的渲染效率。

总结

React 其高效灵活等特性,让我们开发变得更简单高效,即使我们不考虑优化随意操纵数据与组件,也能获得不错的用户体验,但是随着应用功能的增加,业务逻辑的复杂,页面变得卡顿,操作不顺畅了,此时就需要我们从各个方面做优化了。所以,我们在开发的过程中,应时刻注意代码的潜在性能问题。相信当你从上面列举优化方面着手开发或者优化你的应用,这将让你的 React 应用性能更高,用户体验更好!


sam_ray
1 声望0 粉丝

任风吹干流过的泪和汗,总有一天我有属于我的天!