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的特性去解決咱們實際開發中的一系列問題。
相關文章
相關標籤/搜索