关于react.js:Reactdiff原理及应用

2次阅读

共计 10531 个字符,预计需要花费 27 分钟才能阅读完成。

抛砖引玉

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 算法是什么样子的。

传统 diff 算法通过循环递归对节点进行顺次比照,效率低下,算法复杂度达到 O(n^3),其中 n 是树中节点的总数。

O(n^3) 到底有多可怕呢?这意味着如果要展现 1000 个节点,就要顺次执行上十亿次 的比拟,这种指数型的性能耗费对于前端渲染场景来说代价太高了。而 React 却这个 diff 算法工夫复杂度从 O(n^3)降到 O(n)。O(n^3)到 O(n)的晋升有多大,咱们通过一张图来看一下。

从下面这张图来看,React 的 diff 算法所带来的晋升无疑是微小无比的。接下来咱们再看一张图:

从 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 策略:

  • Web UI 中 DOM 节点跨层级的挪动操作特地少,能够忽略不计
  • 领有雷同类的两个组件将会生成类似的树形构造,领有不同类的两个组件将会生成不同的树形构造
  • 对于同一层级的一组子节点,它们能够通过惟一 id 进行辨别

在下面三个策略的根底上,React 别离将对应的 tree diff、component diff 以及 element diff 进行算法优化,极大地晋升了 diff 效率。

tree diff

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

既然 DOM 节点跨层级的挪动操作少到能够忽略不计,针对这一景象,React 只会对雷同层级的 DOM 节点进行比拟,即同一个父节点下的所有子节点。当发现节点曾经不存在时,则该节点及其子节点会被齐全删除掉,不会用于进一步的比拟。这样只须要对树进行一次遍历,便能实现整个 DOM 树的比拟。

策略一的前提是 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 则生效。

参考 React 实战视频解说:进入学习

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

转换流程如下:

当组件 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,maxIndex 变为 1
1 A 0 1 oldIndex(0)<maxIndex(1), 节点 A 挪动至 index(1)的地位
2 D 3 1 oldIndex(3)>maxIndex(1),maxIndex=oldIndex,maxIndex 变为 3
3 C 2 3 oldIndex(2)<maxIndex(3), 节点 C 挪动至 index(3)的地位
  • 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,maxIndex 变为 1
1 E 1 oldIndex 不存在,增加节点 E 至 index(1)的地位
2 C 2 1 oldIndex(2)>maxIndex(1),maxIndex=oldIndex,maxIndex 变为 2
3 A 0 2 oldIndex(0)<maxIndex(2), 节点 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 的作用:

  • 精确判断出以后节点是否在旧汇合中
  • 极大地缩小遍历次数

利用实际

页面指定区域刷新

当初有这么一个需要,当用户身份变动时,以后页面从新加载数据。猛一看过来感觉非常简单,没啥难度的,只有在 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 的个性去解决咱们理论开发中的一系列问题。
正文完
 0