35.以 React 為例,說(shuō)說(shuō)框架和性能(1)

在上一節(jié)課中,我們提到了性能優(yōu)化。在這個(gè)話題上,除了工程化層面的優(yōu)化和語(yǔ)言層面的優(yōu)化以外,框架性能也備受矚目。這一節(jié)課,我們就來(lái)聊聊框架的性能話題,并以 React 為例進(jìn)行分析。

主要知識(shí)點(diǎn)如下:


框架性能到底指什么

說(shuō)起框架的性能話題,很多讀者可能會(huì)想到「不要過(guò)早地做優(yōu)化」這條原則。實(shí)際上,大部分應(yīng)用的復(fù)雜度并不會(huì)對(duì)性能和產(chǎn)品體驗(yàn)構(gòu)成挑戰(zhàn)。畢竟在之前課程中我們學(xué)習(xí)到,現(xiàn)代化的框架憑借高效的虛擬 DOM diff 算法和(或)響應(yīng)式理念,以及框架內(nèi)部引擎,已經(jīng)做得較為完美了,一般項(xiàng)目需求對(duì)性能的壓力并不大。

但是對(duì)于一些極其復(fù)雜的需求,性能優(yōu)化是無(wú)法回避的。如果你開(kāi)發(fā)的是圖形處理應(yīng)用、DNA 檢測(cè)實(shí)驗(yàn)應(yīng)用、富文本編輯器或者功能豐富的表單型應(yīng)用,則很容易觸碰到性能瓶頸。同樣,作為框架的使用者,也需要對(duì)性能優(yōu)化有所了解,這對(duì)于理解框架本身也是有很大幫助的。

前端開(kāi)發(fā)自然離不開(kāi)瀏覽器,而性能優(yōu)化大都在和瀏覽器打交道。我們知道,頁(yè)面每一幀的變化都是由瀏覽器繪制出來(lái)的,并且這個(gè)繪制頻率受限于顯示器的刷新頻率,因此一個(gè)重要的性能數(shù)據(jù)指標(biāo)是每秒 60 幀的繪制頻率。這樣進(jìn)行簡(jiǎn)單的換算之后,每一幀只有 16.6ms 的繪制時(shí)間。

如果一個(gè)應(yīng)用對(duì)用戶的交互響應(yīng)處理過(guò)慢,則需要花費(fèi)很長(zhǎng)的時(shí)間來(lái)計(jì)算更新數(shù)據(jù),這就造成了應(yīng)用緩慢、性能低下的問(wèn)題,被用戶感知造成極差的用戶體驗(yàn)。對(duì)于框架來(lái)說(shuō),以 React 為例,開(kāi)發(fā)者不需要額外關(guān)注 DOM 層面的操作。因?yàn)?React 通過(guò)維護(hù)虛擬 DOM 及其高效的 diff 算法,可以決策出每次更新的最小化 DOM batch 操作。但實(shí)際上,使用 React 能完成的性能優(yōu)化,使用純?cè)?JavaScript 都能做到,甚至做得更好。只不過(guò)經(jīng)過(guò) React 統(tǒng)一處理后,大大節(jié)省了開(kāi)發(fā)成本,同時(shí)也降低了應(yīng)用性能對(duì)開(kāi)發(fā)者優(yōu)化技能的依賴。

因此現(xiàn)代框架的性能表現(xiàn),除了想辦法縮減自身的 bundle size 之外,主要優(yōu)化點(diǎn)就在于框架本身運(yùn)行時(shí)對(duì) DOM 層操作的合理性以及自身引擎計(jì)算的高效性。這一點(diǎn)我們會(huì)通過(guò)兩節(jié)課程來(lái)慢慢展開(kāi)。

React 的虛擬 DOM diff

React 主要通過(guò)以下幾種方式來(lái)保證虛擬的 DOM diff 算法和更新的高效性能:

  • 高效的 diff 算法
  • Batch 操作
  • 摒棄臟檢測(cè)更新方式

當(dāng)任何一個(gè)組件使用 setState 方法時(shí),React 都會(huì)認(rèn)為該組件變「臟」,觸發(fā)組件本身的重新渲染(re-render)。同時(shí)因其始終維護(hù)兩套虛擬的 DOM,其中一套是更新后的虛擬的 DOM;另一套是前一個(gè)狀態(tài)的虛擬的 DOM。通過(guò)對(duì)這兩套虛擬的 DOM 的 diff 算法,找到需要變化的最小單元集,然后把這個(gè)最小單元集應(yīng)用在真實(shí)的 DOM 當(dāng)中。

而通過(guò) diff 算法找到這個(gè)最小單元集后,React 采用啟發(fā)式的思路進(jìn)行了一些假設(shè),將兩棵 DOM 樹(shù)之間的 diff 成本由 O(n3) 縮減到 O(n)。

說(shuō)到這里,你一定很想知道 React 的那些大膽假設(shè)吧:

  • DOM 節(jié)點(diǎn)跨層級(jí)移動(dòng)忽略不計(jì)
  • 擁有相同類的兩個(gè)組件生成相似的樹(shù)形結(jié)構(gòu),擁有不同類的兩個(gè)組件生成不同的樹(shù)形結(jié)構(gòu)

根據(jù)這些假設(shè),ReactJS 采取的策略如下:

React 對(duì)組件樹(shù)進(jìn)行分層比較,兩棵樹(shù)只會(huì)對(duì)同一層級(jí)的節(jié)點(diǎn)進(jìn)行比較
當(dāng)對(duì)同一層級(jí)節(jié)點(diǎn)進(jìn)行比較時(shí),對(duì)于不同的組件類型,直接將整個(gè)組件替換為新類型組件

對(duì)于下圖所示的組件結(jié)構(gòu),我們可以想象:如果子組件 B 和 H 的類型同時(shí)發(fā)生變化,當(dāng)遍歷到 B 組件時(shí),直接進(jìn)行新組件的替換,減少了不必要的消耗。

  • 當(dāng)對(duì)同一層級(jí)節(jié)點(diǎn)進(jìn)行比較時(shí),對(duì)于相同的組件類型,如果組件的 state 或 props 發(fā)生變化,則直接重新渲染組件本身。開(kāi)發(fā)者可以嘗試使用 - - - -----
  • shouldComponentUpdate 生命周期函數(shù)來(lái)規(guī)避不必要的渲染。
    當(dāng)對(duì)同一層級(jí)節(jié)點(diǎn)進(jìn)行比較時(shí),開(kāi)發(fā)者可以使用 key 屬性來(lái)「聲明」同一層級(jí)節(jié)點(diǎn)的更新方式。

另外,setState 方法引發(fā)了「蝴蝶效應(yīng)」,并通過(guò)創(chuàng)新的 diff 算法找到需要更新的最小單元集,但是這些變更也并不一定立即同步產(chǎn)生。實(shí)際上,React 會(huì)進(jìn)行 setState 的 batch 操作,通俗地講就是「積攢歸并」一批變化后,再統(tǒng)一進(jìn)行更新。顯然這是出于對(duì)性能的考慮。

提升 React 應(yīng)用性能的建議

我們知道,React 渲染真實(shí)的 DOM 節(jié)點(diǎn)的過(guò)程由兩個(gè)主要過(guò)程組成:

  • 對(duì) React 內(nèi)部維護(hù)的虛擬的 DOM 進(jìn)行更新
  • 前后兩個(gè)虛擬 DOM 比對(duì),并將 diff 所得結(jié)果應(yīng)用于真實(shí)的 DOM 中的過(guò)程

這兩步極其關(guān)鍵,設(shè)想一下,如果虛擬的 DOM 更新很慢,那么重新渲染勢(shì)必會(huì)很耗時(shí)。本節(jié)我們就針對(duì)此問(wèn)題,對(duì)癥下藥,來(lái)了解更多的性能優(yōu)化小技巧。

最大限度地減少 re-render

為了提升 React 應(yīng)用性能,我們首先想到的就是最大限度地規(guī)避不必要的 re-render。但是當(dāng)狀態(tài)發(fā)生變化時(shí),重新渲染是 React 內(nèi)部的默認(rèn)行為,我們?nèi)绾伪WC不必要的渲染呢?

最先想到的一定是使用 shouldComponentUpdate 生命周期函數(shù),它旨在對(duì)比前后狀態(tài) state/props 是否出現(xiàn)了變更,根據(jù)是否變更來(lái)決定組件是否需要重新渲染。

實(shí)際上,還有很多方式,開(kāi)發(fā)者都可以給 React 發(fā)送「不需要渲染」的信號(hào)。

比如,無(wú)狀態(tài)組件返回同一個(gè) element 實(shí)例:如果 render 方法返回同一個(gè) element 實(shí)例,React 會(huì)認(rèn)為組件并沒(méi)有發(fā)生變化。請(qǐng)參考以下代碼:

class MyComponent extends Component {
  text = '';
  renderedElement = null;
  _render() {
    return <div>{this.props.text}</div>;
  }
  render() {
    if (!this.renderedElement || this.props.text !== this.text) {
      this.text = this.props.text;
      this.renderedElement = _render();
    }
    return this.renderedElement;
  }
}

熟悉 lodash 庫(kù)的讀者,可能會(huì)想到其帶來(lái)的 memoize 函數(shù),同樣可以用來(lái)簡(jiǎn)化上述代碼:

import memoize from 'lodash/memoize';

class MyComponent extends Component {
  _render = memoize((text) => <div>{text}</div>);
  render() {
    return _render(this.props.text);
  }
}

在之前介紹的高階組件的基礎(chǔ)上,我們不妨設(shè)想這樣一類高階組件:它能夠細(xì)粒度地控制組件的渲染行為。比如,某個(gè)組件僅僅在某一項(xiàng) props 變化時(shí)才會(huì)觸發(fā) re-render。這樣一來(lái),開(kāi)發(fā)者可以完全掌控組件渲染時(shí)機(jī),更有針對(duì)性地進(jìn)行渲染優(yōu)化。

這樣的方法有點(diǎn)類似于農(nóng)業(yè)灌溉上的「滴灌」技術(shù),它規(guī)避了代價(jià)昂貴的粗暴型灌溉,而是精準(zhǔn)地定位需求,從而達(dá)到節(jié)約水資源的目的。

在社區(qū)中,優(yōu)秀的 recompose 庫(kù)恰好可以滿足我們的需求。請(qǐng)參考如下代碼:

@onlyUpdateForKeys(['prop1', 'prop2'])
class MyComponent2 extends Component {
 render() {
     //...
 }
}

使用 @onlyUpdateForKeys 修飾器,MyComponent2 組件只在 prop1 和 prop2 變化時(shí)才進(jìn)行渲染;否則其他的 props 發(fā)生任何改變,都不會(huì)觸發(fā) re-render。

藏在 onlyUpdateForKeys 背后的「黑魔法」其實(shí)并不難理解,只需要在高階組件中調(diào)用 shouldComponentUpdate 方法,在 shouldComponentUpdate 方法中比較對(duì)象由完整的 props 轉(zhuǎn)為傳入的指定 props 即可。有興趣的讀者,可以翻閱 recompose 源碼進(jìn)行了解,其實(shí)思路即是如此。

規(guī)避 inline function 反模式

我們需要注意一個(gè)「反模式」。當(dāng)使用 render 方法時(shí),要留意 render 方法內(nèi)創(chuàng)建的函數(shù)或者數(shù)組等,這些創(chuàng)建可能是顯式地,也可能是隱式生成。因?yàn)檫@些新生成的函數(shù)或數(shù)組,在量大時(shí)會(huì)造成一定的性能負(fù)擔(dān)。同時(shí) render 方法經(jīng)常被反復(fù)執(zhí)行多次,也就是說(shuō)總有新的函數(shù)或數(shù)組被創(chuàng)建,這樣造成內(nèi)存無(wú)意義開(kāi)銷。往往性能更友好的做法只需要它們創(chuàng)建一次即可,而不是每次渲染都被創(chuàng)建。比如:

render() {
  return <Myinput onchange={this.props.update.bind(this)}/>
}

或者:

render() {
  return <Myinput onchange={()=>{this.props.update.bind(this)}}/>
}

對(duì)于 render 方法內(nèi)產(chǎn)生數(shù)組或其他類型的情況,也存在類似問(wèn)題:

render() {
  return <Subcomponent items={this.props.items ||  []} / >
}

這樣做會(huì)在每次渲染且 this.props.items 不存在時(shí)創(chuàng)建一個(gè)空數(shù)組。更好的做法是:

const EMPTY_ARRAY = []
render() {
  return <Subcomponent items={this.props.items || EMPTY_ARRAY}/>
}

事實(shí)上,不得不說(shuō),這些性能副作用或者優(yōu)化手段都「微乎其微」,并不是性能惡化的「罪魁禍?zhǔn)住?。但是理解這些內(nèi)容對(duì)我們編寫(xiě)出高質(zhì)量的代碼還是有幫助的。我們后續(xù)課程會(huì)針對(duì)這種情況進(jìn)行框架層面上的啟發(fā)式探索。

使用 PureComponent 保證開(kāi)發(fā)性能

PureComponent 大體與 Component 相同,唯一不同的地方是 PureComponent 會(huì)自動(dòng)幫助開(kāi)發(fā)者使用 shouldComponentUpdate 生命周期方法。也就是說(shuō),當(dāng)組件 state 或者 props 發(fā)生變化時(shí),正常的 Component 都會(huì)自動(dòng)進(jìn)行 re-render,在這種情況下,shouldComponentUpdate 默認(rèn)都會(huì)返回 true。但是 PureComponent 會(huì)先進(jìn)行對(duì)比,即比較前后兩次 state 和 props 是否相等。需要注意的是,這種對(duì)比是淺比較:

function shallowEqual (objA: mixed, objB: mixed) {
   if (is(objA, objB)) {
       return true;
   }

   if (typeof objA !== 'object' || objA === null ||
       typeof objB !== 'object' || objB === null) {
       return false;
   }

   const keysA = Object.keys(objA);
   const keysB = Object.keys(objB);

   if (keysA.length !== keysB.length) {
       return false;
   }

   for (let i = 0; i < keysA.length; i++) {
       if (
       !hasOwnProperty.call(objB, keysA[i]) ||
       !is(objA[keysA[i]], objB[keysA[i]])
       ) {
           return false;
       }
   }

   return true;
}

基于以上代碼,我們總結(jié)出使用 PureComponent 需要注意如下細(xì)節(jié):

  • 既然是淺比較,也就是說(shuō),當(dāng)與前一狀態(tài)下的 props 和 state 比對(duì)時(shí),如果比較對(duì)象是 JavaScript 基本類型,則會(huì)對(duì)其值是否相等進(jìn)行判斷;如果比較對(duì)象是 JavaScript 引用類型,比如 object 或者 array,則會(huì)判斷其引用是否相同,而不會(huì)進(jìn)行值比較;
  • 開(kāi)發(fā)者需要避免共享(mutate)帶來(lái)的問(wèn)題。

如果在一個(gè)父組件中對(duì) object 進(jìn)行了 mutate 的操作,若子組件依賴此數(shù)據(jù),且采用 PureComponent 聲明,那么子組件將無(wú)法進(jìn)行更新。盡管 props 中的某一項(xiàng)值發(fā)生了變化,但是它的引用并沒(méi)有發(fā)生變化,因此 PureComponent 的 shouldComponentUpdate 也就返回了 false。更好的做法是在更新 props 或 state 時(shí),返回一個(gè)新的對(duì)象或數(shù)組。

分析一個(gè)真實(shí)案例

設(shè)想一下,如果應(yīng)用組件非常復(fù)雜,含有一個(gè)具有很長(zhǎng) list 的組件,如果只是其中一個(gè)子組件發(fā)生了變化,那么使用 PureComponent 進(jìn)行對(duì)比,有選擇性地進(jìn)行渲染,一定是比所有列表項(xiàng)目都重新渲染劃算很多。

我們來(lái)看一個(gè)案例:簡(jiǎn)易實(shí)現(xiàn)一個(gè)采用 PureComponent 和不采用 PureComponent 的性能差別對(duì)比試驗(yàn)。假如在頁(yè)面中需要渲染非常多的用戶信息,所有的用戶信息都被維護(hù)在一個(gè) users 數(shù)組當(dāng)中,數(shù)組的每一項(xiàng)為一個(gè) JavaScript 對(duì)象,表示一個(gè)用戶的基本信息。User 組件負(fù)責(zé)渲染每一個(gè)用戶的信息內(nèi)容:

import User from './User';
const Users = ({ users }) => {
  return (
    <div>
      {users.map((item) => (
        <User {...item} />
      ))}
    </div>
  );
};

這樣做存在的問(wèn)題是:users 數(shù)組作為 Users 組件的 props 出現(xiàn),users 數(shù)組的第 K 項(xiàng)發(fā)生變化時(shí),users 數(shù)組即發(fā)生變化,Users 組件重新渲染導(dǎo)致所有的 User 組件都會(huì)進(jìn)行渲染。某個(gè) User 組件,即使非 K 項(xiàng)并沒(méi)有發(fā)生變化,這個(gè) User 組件不需要重新渲染,但也不得不必要的渲染。

在測(cè)試中,我們渲染了一個(gè)有 200 項(xiàng)的數(shù)組:

const arraySize = 200;
const getUsers = () =>
 Array(arraySize)
   .fill(1)
   .map((_, index) => ({
     name: 'John Doe',
     hobby: 'Painting',
     age: index === 0 ? Math.random() * 100 : 50
   }));

注意:在 getUsers 方法中,對(duì) age 屬性進(jìn)行了判斷,保證每次調(diào)用時(shí),getUsers 返回的數(shù)組只有第一項(xiàng)的 age 屬性不同,其余的全部為 50。在測(cè)試組件中,在 componentDidUpdate 中保證數(shù)組將會(huì)觸發(fā) 400 次 re-render,并且每一次只改變數(shù)組第一項(xiàng)的 age 屬性,其他的均保持不變。

const repeats = 400;
 componentDidUpdate() {
   ++this.renderCount;
   this.dt += performance.now() - this.startTime;
   if (this.renderCount % repeats === 0) {
     if (this.componentUnderTestIndex > -1) {
       this.dts[componentsToTest[this.componentUnderTestIndex]] = this.dt;
       console.log(
         'dt',
         componentsToTest[this.componentUnderTestIndex],
         this.dt
       );
     }
     ++this.componentUnderTestIndex;
     this.dt = 0;
     this.componentUnderTest = componentsToTest[this.componentUnderTestIndex];
   }
   if (this.componentUnderTest) {
     setTimeout(() => {
       this.startTime = performance.now();
       this.setState({ users: getUsers() });
     }, 0);
   }
   else {
     alert(`
       Render Performance ArraySize: ${arraySize} Repeats: ${repeats}
       Functional: ${Math.round(this.dts.Functional)} ms
       PureComponent: ${Math.round(this.dts.PureComponent)} ms
       Component: ${Math.round(this.dts.Component)} ms
     `);
   }
 }

下面對(duì)三種組件聲明方式進(jìn)行對(duì)比。

  • 函數(shù)式方式
export const Functional = ({ name, age, hobby }) => (
  <div>
    <span>{name}</span>
    <span>{age}</span>
    <span>{hobby}</span>
  </div>
);

  • PureComponent 方式
export class PureComponent extends React.PureComponent {
  render() {
    const { name, age, hobby } = this.props;
    return (
      <div>
        <span>{name}</span>
        <span>{age}</span>
        <span>{hobby}</span>
      </div>
    );
  }
}

  • 經(jīng)典 class 方式
export class Component extends React.Component {
  render() {
    const { name, age, hobby } = this.props;
    return (
      <div>
        <span> {name}</span>
        <span> {age}</span>
        <span> {hobby}</span>
      </div>
    );
  }
}

在使用 PureComponent 聲明的組件中,會(huì)自動(dòng)在觸發(fā)渲染前后進(jìn)行 {name, age, hobby} 對(duì)象值比較。如果沒(méi)有發(fā)生變化,則 shouldComponentUpdate 返回 false,以規(guī)避不必要的渲染。因此,使用 PureComponent 聲明的組件性能明顯優(yōu)于其他方式。在不同的瀏覽器環(huán)境下,可以得出:

  • 在 Firefox 下,PureComponent 收益 30%
  • 在 Safari 下,PureComponent 收益 6%
  • 在 Chrome 下,PureComponent 收益 15%

實(shí)際上,我們通過(guò)定義 changedItems 來(lái)表示變化數(shù)組的項(xiàng)目,array 表示所需渲染的數(shù)組。changedItems.length/array.length 的比值越小,表示數(shù)組中變化的元素也越少,React.PureComponent 涉及的性能優(yōu)化也越有必要實(shí)施,因?yàn)?React.PureComponent 通過(guò)淺比較規(guī)避了不必要的更新過(guò)程,而淺比較自身的計(jì)算成本一般都不值一提,可以節(jié)約成本。

當(dāng)然,PureComponent 也不是萬(wàn)能的,尤其是它的淺比較,需要開(kāi)發(fā)者格外注意。因此在特定情況下,開(kāi)發(fā)者根據(jù)需求自己實(shí)現(xiàn) shouldComponentUpdate 中的比較邏輯,將是更高效的選擇。

總結(jié)

性能優(yōu)化是前端開(kāi)發(fā)中一個(gè)永恒的話題,不同框架之間的性能對(duì)比也一直是各位開(kāi)發(fā)者關(guān)注的方面。性能涉及方方面面,如前端工程化、瀏覽器解析和渲染、比較算法等。本章主要介紹了 React 框架在性能上的優(yōu)劣、虛擬的 DOM 思想,以及在開(kāi)發(fā) React 應(yīng)用時(shí)需要注意的性能優(yōu)化環(huán)節(jié)和手段。 也許不是每個(gè)應(yīng)用都會(huì)面臨性能的問(wèn)題,如同社區(qū)中所說(shuō)的:「過(guò)早地進(jìn)行性能優(yōu)化是毫無(wú)必要的,但是開(kāi)發(fā)者在性能優(yōu)化方面的積累卻要時(shí)刻先行。」同時(shí),優(yōu)化手段也在與時(shí)俱進(jìn),不斷更新,需要開(kāi)發(fā)者時(shí)刻保持學(xué)習(xí)。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • react 框架性能優(yōu)化 前端性能監(jiān)控利器 1.Google Performance工具 2.react 性能查看...
    大鯊魚(yú)麻吉閱讀 1,005評(píng)論 0 1
  • 本文譯自《Optimizing React: Virtual DOM explained》,作者是Alexey I...
    YuyingWu閱讀 8,015評(píng)論 0 17
  • 這篇文章主要介紹了淺談react性能優(yōu)化的方法,小編覺(jué)得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過(guò)...
    880d91446f17閱讀 3,649評(píng)論 0 3
  • React 為高性能應(yīng)用設(shè)計(jì)提供了許多優(yōu)化方案,本文列舉了其中的一些最佳實(shí)踐。 在以下場(chǎng)景中,父組件和子組件通常會(huì)...
    Maco_wang閱讀 1,122評(píng)論 0 7
  • 表情是什么,我認(rèn)為表情就是表現(xiàn)出來(lái)的情緒。表情可以傳達(dá)很多信息。高興了當(dāng)然就笑了,難過(guò)就哭了。兩者是相互影響密不可...
    Persistenc_6aea閱讀 129,617評(píng)論 2 7

友情鏈接更多精彩內(nèi)容