React diff原理探究以及应用实践

抛砖引玉

React经过引入Virtual DOM的概念,极大地避免无效的Dom操做,已使咱们的页面的构建效率提到了极大的提高。可是如何高效地经过对比新旧Virtual DOM来找出真正的Dom变化之处一样也决定着页面的性能,React用其特殊的diff算法解决这个问题。Virtual DOM+React diff的组合极大地保障了React的性能,使其在业界有着不错的性能口碑。diff算法并不是React独创,React只是对diff算法作了一个优化,但倒是由于这个优化,给React带来了极大的性能提高,不由让人感叹React创造者们的智慧!接下来咱们就探究一下React的diff算法。前端

传统diff算法

在文章开头咱们提到React的diff算法给React带来了极大的性能提高,而以前的React diff算法是在传统diff算法上的优化。下面咱们先看一下传统的diff算法是什么样子的。react

传统diff算法经过循环递归对节点进行依次对比,效率低下,算法复杂度达到 O(n^3),其中 n 是树中节点的总数。具体是怎么算出来的,能够查看知乎上的一个回答。git

react的diff 从O(n^3)到 O(n) ,请问 O(n^3) 和O(n) 是怎么算出来?github

O(n^3) 到底有多可怕呢?这意味着若是要展现 1000 个节点,就要依次执行上十亿次 的比较,这种指数型的性能消耗对于前端渲染场景来讲代价过高了。而React却这个diff算法时间复杂度从O(n^3)降到O(n)。O(n^3)到O(n)的提高有多大,咱们经过一张图来看一下。算法

从上面这张图来看,React的diff算法所带来的提高无疑是巨大无比的。接下来咱们再看一张图:redux

从1979到2011,30多年的时间,才将时间复杂度搞到O(n^3),而React从开源到如今不过区区几年的时间,却一会儿干到O(n),到这里不由再次膜拜一下React的创造者们。 那么React这个牛逼的diff算法是如何作到的呢?

React diff原理

前面咱们讲到传统diff算法的时间复杂度为O(n^3),其中n为树中节点的总数,随着n的增长,diff所耗费的时间将呈现爆炸性的增加。react却利用其特殊的diff算法作到了O(n^3)到O(n)的飞跃性的提高,而完成这一壮举的法宝就是下面这三条看似简单的diff策略:bash

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

在上面三个策略的基础上,React 分别将对应的tree diff、component diff 以及 element diff 进行算法优化,极大地提高了diff效率。服务器

tree diff

基于策略一,React 对树的算法进行了简洁明了的优化,即对树进行分层比较,两棵树只会对同一层次的节点进行比较。antd

既然 DOM 节点跨层级的移动操做少到能够忽略不计,针对这一现象,React只会对相同层级的 DOM 节点进行比较,即同一个父节点下的全部子节点。当发现节点已经不存在时,则该节点及其子节点会被彻底删除掉,不会用于进一步的比较。这样只须要对树进行一次遍历,便能完成整个 DOM 树的比较。 react-router

策略一的前提是Web UI中DOM节点跨层级的移动操做特别少,但并无否认DOM节点跨层级的操做的存在,那么当遇到这种操做时,React是如何处理的呢?

接下来咱们经过一张图来展现整个处理过程:
A 节点(包括其子节点)整个被移动到 D 节点下,因为 React 只会简单地考虑同层级节点的位置变换,而对于不 同层级的节点,只有建立和删除操做。当根节点发现子节点中 A 消失了,就会直接销毁 A;当 D 发现多了一个子节点 A,则会创 建新的 A(包括子节点)做为其子节点。此时,diff 的执行状况:create A → create B → create C → delete A。

由此能够发现,当出现节点跨层级移动时,并不会出现想象中的移动操做,而是以 A 为根节点的整个树被从新建立。这是一种影响React性能的操做,所以官方建议不要进行 DOM 节点跨层级的操做。

在开发组件时,保持稳定的 DOM 结构会有助于性能的提高。例如,能够经过 CSS 隐藏或显示节点,而不是真正地移 除或添加 DOM 节点。

component diff

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

  • 若是是同一类型的组件,按照原策略继续比较 Virtual DOM 树便可。
  • 若是不是,则将该组件判断为 dirty component,从而替换整个组件下的全部子节点。
  • 对于同一类型的组件,有可能其 Virtual DOM 没有任何变化,若是可以确切知道这点,那么就能够节省大量的 diff 运算时间。所以,React容许用户经过shouldComponentUpdate()来判断该组件是否须要进行diff算法分析,可是若是调用了forceUpdate方法,shouldComponentUpdate则失效。

接下来咱们看下面这个例子是如何实现转换的:

转换流程以下:
当组件D变为组件G时,即便这两个组件结构类似,一旦React判断D和G是不一样类型的组件,就不会比较二 者的结构,而是直接删除组件D,从新建立组件G及其子节点。虽然当两个组件是不一样类型但结构类似时,diff会影响性能,但正如React官方博客所言:不一样类型的组件不多存在类似DOM树的状况,所以这种极端因素很难在实际开发过程当中形成重大的影响。

element diff

当节点处于同一层级时,diff 提供了 3 种节点操做,分别为 INSERT_MARKUP (插入)、MOVE_EXISTING (移动)和 REMOVE_NODE (删除)。

  • INSERT_MARKUP :新的组件类型不在旧集合里,即全新的节点,须要对新节点执行插入操做。
  • MOVE_EXISTING :旧集合中有新组件类型,且 element 是可更新的类型,generateComponentChildren 已调用 receiveComponent ,这种状况下 prevChild=nextChild ,就须要作移动操做,能够复用之前的 DOM 节点。
  • REMOVE_NODE :旧组件类型,在新集合里也有,但对应的 element 不一样则不能直接复用和更新,须要执行删除操做,或者 旧组件不在新集合里的,也须要执行删除操做。

旧集合中包含节点A、B、C和D,更新后的新集合中包含节点B、A、D和C,此时新旧集合进行diff差别化对比,发现B!=A,则建立并插入B至新集合,删除旧集合A;以此类推,建立并插入A、D和C,删除B、C和D。


咱们发现这些都是相同的节点,仅仅是位置发生了变化,但却须要进行繁杂低效的删除、建立操做,其实只要对这些节点进行位置移动便可。React针对这一现象提出了一种优化策略:容许开发者对同一层级的同组子节点,添加惟一 key 进行区分。 虽然只是小小的改动,性能上却发生了翻天覆地的变化!咱们再来看一下应用了这个策略以后,react diff是如何操做的。

经过key能够准确地发现新旧集合中的节点都是相同的节点,所以无需进行节点删除和建立,只须要将旧集合中节点的位置进行移动,更新为新集合中节点的位置,此时React 给出的diff结果为:B、D不作任何操做,A、C进行移动操做便可。

具体的流程咱们用一张表格来展示一下:

index 节点 oldIndex maxIndex 操做
0 B 1 0 oldIndex(1)>maxIndex(0),maxIndex=oldIndex
1 A 0 1 oldIndex(0)<maxIndex(1),节点A移动至index(1)的位置
2 D 3 1 oldIndex(3)>maxIndex(1),maxIndex=oldIndex
3 C 2 3 oldIndex(2)<maxIndex(3),节点C移动至index(2)的位置
  • index: 新集合的遍历下标。
  • oldIndex:当前节点在老集合中的下标。
  • maxIndex:在新集合访问过的节点中,其在老集合的最大下标值。

操做一栏中只比较oldIndex和maxIndex:

  • 当oldIndex>maxIndex时,将oldIndex的值赋值给maxIndex
  • 当oldIndex=maxIndex时,不操做
  • 当oldIndex<maxIndex时,将当前节点移动到index的位置

上面的例子仅仅是在新旧集合中的节点都是相同的节点的状况下,那若是新集合中有新加入的节点且旧集合存在 须要删除的节点,那么 diff 又是如何对比运做的呢?

index 节点 oldIndex maxIndex 操做
0 B 1 0 oldIndex(1)>maxIndex(0),maxIndex=oldIndex
1 E - 1 oldIndex不存在,添加节点E至index(1)的位置
2 C 2 1 不操做
3 A 0 3 oldIndex(0)<maxIndex(3),节点A移动至index(3)的位置

注:最后还须要对旧集合进行循环遍历,找出新集合中没有的节点,此时发现存在这样的节点D,所以删除节点D,到此 diff 操做所有完成。

一样操做一栏中只比较oldIndex和maxIndex,可是oldIndex可能有不存在的状况:

  • oldIndex存在
    1. 当oldIndex>maxIndex时,将oldIndex的值赋值给maxIndex
    2. 当oldIndex=maxIndex时,不操做
    3. 当oldIndex<maxIndex时,将当前节点移动到index的位置
  • oldIndex不存在
    1. 新增当前节点至index的位置

固然这种diff并不是天衣无缝的,咱们来看这么一种状况:

实际咱们只需对D执行移动操做,然而因为D在旧集合中的位置是最大的,致使其余节点的oldIndex < maxIndex,形成D没有执行移动操做,而是A、B、C所有移动到D节点后面的现象。针对这种状况,官方建议:

在开发过程当中,尽可能减小相似将最后一个节点移动到列表首部的操做。当节点数量过大或更新操做过于频繁时,这在必定程度上会影响React的渲染性能。


因为key的存在,react能够准确地判断出该节点在新集合中是否存在,这极大地提升了diff效率。咱们在开发过中进行列表渲染的时候,若没有加key,react会抛出警告要求开发者加上key,就是为了提升diff效率。可是加了key必定要比没加key的性能更高吗?咱们再来看一个例子:

如今有一集合[1,2,3,4,5],渲染成以下的样子:
<div>1</div>
<div>2</div>
<div>3</div>
<div>4</div>
<div>5</div>
---------------
如今咱们将这个集合的顺序打乱变成[1,3,2,5,4]。
1.加key
<div key='1'>1</div>             <div key='1'>1</div>     
<div key='2'>2</div>             <div key='3'>3</div>  
<div key='3'>3</div>  ========>  <div key='2'>2</div>  
<div key='4'>4</div>             <div key='5'>5</div>  
<div key='5'>5</div>             <div key='4'>4</div>  
操做:节点2移动至下标为2的位置,节点4移动至下标为4的位置。

2.不加key
<div>1</div>             <div>1</div>     
<div>2</div>             <div>3</div>  
<div>3</div>  ========>  <div>2</div>  
<div>4</div>             <div>5</div>  
<div>5</div>             <div>4</div>  
操做:修改第1个到第5个节点的innerText
---------------
若是咱们对这个集合进行增删的操做改为[1,3,2,5,6]。
1.加key
<div key='1'>1</div>             <div key='1'>1</div>     
<div key='2'>2</div>             <div key='3'>3</div>  
<div key='3'>3</div>  ========>  <div key='2'>2</div>  
<div key='4'>4</div>             <div key='5'>5</div>  
<div key='5'>5</div>             <div key='6'>6</div>  
操做:节点2移动至下标为2的位置,新增节点6至下标为4的位置,删除节点4。

2.不加key
<div>1</div>             <div>1</div>     
<div>2</div>             <div>3</div>  
<div>3</div>  ========>  <div>2</div>  
<div>4</div>             <div>5</div>  
<div>5</div>             <div>6</div> 
操做:修改第1个到第5个节点的innerText
---------------
经过上面这两个例子咱们发现:
因为dom节点的移动操做开销是比较昂贵的,没有key的状况下要比有key的性能更好。
复制代码

经过上面的例子咱们发现,虽然加了key提升了diff效率,可是未必必定提高了页面的性能。所以咱们要注意这么一点:

对于简单列表页渲染来讲,不加key要比加了key的性能更好

根据上面的状况,最后咱们总结一下key的做用:

  • 准确判断出当前节点是否在旧集合中
  • 极大地减小遍历次数

应用实践

示例代码地址:github.com/ruichengpin…

页面指定区域刷新

如今有这么一个需求,当用户身份变化时,当前页面从新加载数据。猛一看过去以为很是简单,没啥难度的,只要在componentDidUpdate这个生命周期里去判断用户身份是否发生改变,若是发生改变就从新请求数据,因而就有了如下这一段代码:

import React from 'react';
import {connect} from 'react-redux';
let oldAuthType = '';//用来存储旧的用户身份
@connect(
  state=>state.user
)
class Page1 extends React.PureComponent{
  state={
    loading:true
  }
  loadMainData(){
    //这里采用了定时器去模拟数据请求
    this.setState({
      loading:true
    });
    const timer = setTimeout(()=>{
      this.setState({
        loading:false
      });
      clearTimeout(timer);
    },2000);
  }
  componentDidUpdate(){
    const {authType} = this.props;
    //判断当前用户身份是否发生了改变
    if(authType!==oldAuthType){
      //存储新的用户身份
      oldAuthType=authType;
      //从新加载数据
      this.loadMainData();
    }
  }
  componentDidMount(){
    oldAuthType=this.props.authType;
    this.loadMainData();
  }
  render(){
    const {loading} = this.state;
    return (
      <h2>{`页面1${loading?'加载中...':'加载完成'}`}</h2>
    )
  }
}
export default Page1;
复制代码

看上去咱们仅仅经过加上一段代码就完成了这一需求,可是当咱们页面是几十个的时候,那这种方法就显得捉襟见肘了。哪有没有一个很好的方法来实现这个需求呢?其实很简单,利用react diff的特性就能够实现它。对于这个需求,实际上就是但愿当前组件能够销毁在从新生成,那怎么才能让其销毁并从新生成呢?经过上面的总结我发现两种状况,能够实现组件的销毁并从新生成。

  • 当组件类型发生改变
  • 当key值发生变化 接下来咱们就结合这两个特色,用两种方法去实现。

第一种:引入一个loading组件。切换身份时设置loading为true,此时loading组件显示;切换身份完成,loading变为false,其子节点children显示。

<div className="g-main">{loading?<Loading/>:children}</div> 
复制代码

第二种:在刷新区域加上一个key值就能够了,用户身份一改变,key值就发生改变。

<div className="g-main" key={authType}>{children}</div>
复制代码

第一种和第二种取舍上,我的建议的是这样子的:

若是须要请求服务器的,用第一种,由于请求服务器会有必定等待时间,加入loading组件可让用户有感知,体验更好。若是是不须要请求服务器的状况下,选用第二种,由于第二种更简单实用。

更加方便地监听props改变

针对这个需求,咱们喜欢将搜索条件封装成一个组件,查询列表封装成一个组件。其中查询列表会接收一个查询参数的属性,以下所示:

import React from 'react';
import {Card} from 'antd';
import Filter from './components/filter';
import Teacher from './components/teacher';
export default class Demo2 extends React.PureComponent{
  state={
    filters:{
      name:undefined,
      height:undefined,
      age:undefined
    }
  }
  handleFilterChange=(filters)=>{
    this.setState({
      filters
    });
  }
  render(){
    const {filters} = this.state;
    return <Card>
      {/* 过滤器 */}
      <Filter onChange={this.handleFilterChange}/> 
      {/* 查询列表 */}
      <Teacher filters={filters}/>
    </Card>
  }
}
复制代码

如今咱们面临一个问题,如何在组件Teacher中监听filters的变化,因为filters是一个引用类型,想监听其变化变得有些复杂,好在lodash提供了比较两个对象的工具方法,使其简单了。可是若是后期给Teacher加了额外的props,此时你要监听多个props的变化时,你的代码将变得比较难以维护。针对这个问题,咱们依旧能够经过key值去实现,当每次搜索时,从新生成一个key,那么Teacher组件就会从新加载了。代码以下:

import React from 'react';
import {Card} from 'antd';
import Filter from './components/filter';
import Teacher from './components/teacher';
export default class Demo2 extends React.PureComponent{
  state={
    filters:{
      name:undefined,
      height:undefined,
      age:undefined
    },
    tableKey:this.createTableKey()
  }
  createTableKey(){
    return Math.random().toString(36).substring(7);
  }
  handleFilterChange=(filters)=>{
    this.setState({
      filters,
      //从新生成tableKey
      tableKey:this.createTableKey()
    });
  }
  render(){
    const {filters,tableKey} = this.state;
    return <Card>
      {/* 过滤器 */}
      <Filter onChange={this.handleFilterChange}/> 
      {/* 查询列表 */}
      <Teacher key={tableKey} filters={filters}/>
    </Card>
  }
}
复制代码

即便后期给Teacher加入新的props,也没有问题,只需拼接一下key便可:

<Teacher key={`${tableKey}-${prop1}-${prop2}`} filters={filters} prop1={prop1} prop2={prop2}/>
复制代码

react-router中Link问题

先看一下demo代码:

import React from 'react';
import {Card,Spin,Divider,Row,Col} from 'antd';
import {Link} from 'react-router-dom';

const bookList = [{
  bookId:'1',
  bookName:'三国演义',
  author:'罗贯中'
},{
  bookId:'2',
  bookName:'水浒传',
  author:'施耐庵'
}]
export default class Demo3 extends React.PureComponent{
  state={
    bookList:[],
    bookId:'',
    loading:true
  }
  loadBookList(bookId){
    this.setState({
      loading:true
    });
    const timer = setTimeout(()=>{
      this.setState({
        loading:false,
        bookId,
        bookList
      });
      clearTimeout(timer);
    },2000);
  }
  componentDidMount(){
    const {match} = this.props;
    const {params} = match;
    const {bookId} = params;
    this.loadBookList(bookId);
  }
  render(){
    const {bookList,bookId,loading} = this.state;
    const selectedBook = bookList.find((book)=>book.bookId===bookId);
    return <Card>
      <Spin spinning={loading}>
        {
          selectedBook&&(<div>
            <img width="120" src={`/static/images/book_cover_${bookId}.jpeg`}/>
            <h4>书名:{selectedBook?selectedBook.bookName:'--'}</h4>
            <div>做者:{selectedBook?selectedBook.author:'--'}</div>
          </div>)
        }
        <Divider orientation="left">关联图书</Divider>
        <Row>
          {
            bookList.filter((book)=>book.bookId!==bookId).map((book)=>{
              const {bookId,bookName} = book;
              return <Col span={6}>
                <img width="120" src={`/static/images/book_cover_${bookId}.jpeg`}/>
                <h4><Link to={`/demo3/${bookId}`}>{bookName}</Link></h4>
              </Col>
            })
          }
        </Row>
      </Spin>
    </Card>
  }
}
复制代码

经过演示gif,咱们看到了地址栏的地址已经发生改变,可是并无咱们想象中那样重新走一遍componentDidMount去请求数据,这说明咱们的组件并无实现销毁并从新生成这么一个过程。解决这个问题你能够在componentDidUpdate去监听其改变:

componentDidUpdate(){
    const {match} = this.props;
    const {params} = match;
    const {bookId} = params;
    if(bookId!==this.state.bookId){
      this.loadBookList(bookId);
    }
  }
复制代码

前面咱们说过若是是后期须要监听多个props的话,这样子后期维护比较麻烦.一样咱们仍是利用key去解决这个问题,首页咱们能够将页面封装成一个组件BookDetail,而且在其外层再包裹一层,再去给BookDetail加key,代码以下:

import React from 'react';
import BookDetail from './bookDetail';
export default class Demo3 extends React.PureComponent{
  render(){
    const {match} = this.props;
    const {params} = match;
    const {bookId} = params;
    return <BookDetail key={bookId} bookId={bookId}/>
  }
}
复制代码

这样的好处是咱们代码结构更加清晰,后续拓展新功能比较简单。

结语:

  • React的高效得益于其Virtual DOM+React diff的体系。diff算法并不是react首创,react只是在传统diff算法作了优化。但由于其优化,将diff算法的时间复杂度一会儿从O(n^3)降到O(n)。
  • React diff的三大策略:
    • Web UI中DOM节点跨层级的移动操做特别少,能够忽略不计。
    • 拥有相同类的两个组件将会生成类似的树形结构,拥有不一样类的两个组件将会生成不一样的树形结构。
    • 对于同一层级的一组子节点,它们能够经过惟一 id 进行区分。
  • 在开发组件时,保持稳定的 DOM 结构会有助于性能的提高。
  • 在开发过程当中,尽可能减小相似将最后一个节点移动到列表首部的操做。
  • key的存在是为了提高diff效率,但未必必定就能够提高性能,记住简单列表渲染状况下,不加key要比加key的性能更好。
  • 懂得借助react diff的特性去解决咱们实际开发中的一系列问题。
相关文章
相关标签/搜索