React渲染效能優化

溜達向日葵發表於2018-07-24

效能優化

在React內部已經使用了許多巧妙的技術來最小化由於Dom變更導致UI渲染所耗費的時間。對於很多應用來說,使用React後無需太多工作就會讓客戶端執行效能有質的提升。然而,還是很其他更多的辦法來加速React程式。

使用生產模式來構建應用

如果在開發和使用的過程中感覺了React應用有明顯的效能問題,請先確認是否已經構建了壓縮後的生產包:

  • 在單頁面用中,打包之後的生產檔案應該是.min.js版本。
  • 對於Brunch(html打包工具:http://brunch.io/),打包命令需要包含-p標記。
  • 對於Browserify(UMD規範打包工具:http://browserify.org/),打包時需要增加生產配置引數—— NODE_ENV=production
  • 對於在建立React App時,需要執行 npm run build 命令,並按照說明操作。
  • 對於Rollup(JavaScript程式碼高效壓縮工具:https://rollupjs.org/),生產打包時需要在 commonjs 外掛之前使用 replace 外掛:
    plugins: [
      require(`rollup-plugin-replace`)({
        `process.env.NODE_ENV`: JSON.stringify(`production`)
      }),
      require(`rollup-plugin-commonjs`)(),
      // ...
    ]

    可以在這裡看到 一個完整的例子:see this gist

  • 使用Webpack打包,需要在打生產包的配置指令碼中增加以下配置和外掛:

    new webpack.DefinePlugin({
      `process.env`: {
        NODE_ENV: JSON.stringify(`production`)
      }
    }),
    new webpack.optimize.UglifyJsPlugin()

切記不要將開發模式的包釋出到生產環境,因為開發包中額外包含了許多用於輔助的測試的資訊,無論在載入還是執行時,它都比較慢。

使用chrome分析元件的渲染時間線

在開發模式中下你可以直接在chrome的效能工具中看到元件是如何裝載、更新和解除安裝的。例如下面的圖片展示的效果:

React 渲染效能優化

在chrome中按照以下步驟執行:

  1. 使用?react_perf作為url引數(例如:http://localhost:3000/?react_perf)
  2. 開啟chrome的開發工具Timeline,然後點選Record(左上角的紅色按鈕)。
  3. 執行你要監控的操作。請不要記錄超過20秒,這可能會導致chrome假死。
  4. 停止記錄。
  5. React事件將會批量記錄在User Timing標籤裡。

關於分析的資料,需要明確的是:渲染的時間只是一個相對的參考值,在構建成生產包之後,渲染的速度會更快。儘管如此,這些資料仍然能夠幫助我們分析是否有不相關的UI被錯誤的更新,以及UI更新的頻率和深度。

目前只有Chrome、Edge和IE支援這個特性,但是官方正在使用User Timing API 標準 讓更多瀏覽器支援這個特性。

手工避免重複渲染

React構建和維護了一個內部的虛擬Dom,這個Dom和真實的UI是相互對映的關係,他包含從使用者自定義元件中返回的各種React元素。這個虛擬的Dom使得React可以避免重複渲染相同的Dom節點並在訪問存在的節點時直接使用React的虛擬層資料,這樣設計的原因是重複渲染瀏覽器或web view的UI比操作一個JavaScript的物件要慢許多。在React Native也採用同樣的處理方式。

當元件的props和state變更時,React會將最新返回的元素與之前舊的元素進行對比來確定是否真的需要重新渲染真實的Dom。當他們不相等時,React會更新真實的Dom。

在某些情況下,可以在自定義元件中過載shouldComponentUpdate方法來加速觸發渲染的比對的過程。該方法的預設實現返回引數為true,此時React將按照原來的方式進行比對和渲染:

shouldComponentUpdate(nextProps, nextState) {
  return true;
}

如果在某些情況下能夠清晰的明確元件不需要重新渲染,可以在 shouldComponentUpdate 方法中返回 false,這樣會讓讓元件跳過整個渲染過程,包括不再呼叫當前元件和子元件的render()方法。

shouldComponentUpdate 的執行過程

下面是一個元件結構樹。圖中,“SCU”表示 shouldComponentUpdate 方法返回的值(綠色true,紅色fasle),“vDOMEq”表示React的匹配是否一致(綠色true,紅色fasle),有顏色的紅圈表示是否執行了UI重繪(綠色表示沒重繪,紅色表示執行重繪)。

React 渲染效能優化

在C2元件中,shouldComponentUpdate 方法返回了false,所以React不會判斷是否需要重新渲染C2並且不執行render()方法, 因此在C4和C5中不再執行shouldComponentUpdate 方法。

對於C1和C3,shouldComponentUpdate 都返回了true,所以React必須對著2個元件進行比對。對於C6,shouldComponentUpdate 返回true,而且比對的結果是需要UI重繪,因此C6會更新他們的真實Dom。

還有一個值得關心的元件是C8,React在這個元件中執行了render()方法,但是由於虛擬Dom並沒有發生變更,前後比對一致,所以並沒有發生真實Dom渲染。

在整個過程中React僅僅變更了C6元件的UI樣式,C8由於前後虛擬Dom一致因此沒有真正的執行UI渲染。C2、C2的子元件以及C7沒有執行render()方法。

一個shouldComponentUpdate的例子

在例子中,當props.color和state.count發生變更時進行UI渲染,我們在 shouldComponentUpdate 方法中進行檢查:

class CounterButton extends React.Component {
  constructor(props) {
    super(props);
    this.state = {count: 1};
  }

  shouldComponentUpdate(nextProps, nextState) {
    //只判斷props.color和nextState.count是否變更,其他情況均不渲染
    if (this.props.color !== nextProps.color) {
      return true;
    }
    if (this.state.count !== nextState.count) {
      return true;
    }
    return false;
  }

  render() {
    return (
      <button
        color={this.props.color}
        onClick={() => this.setState(state => ({count: state.count + 1}))}>
        Count: {this.state.count}
      </button>
    );
  }
}

在這段程式碼中,shouldComponentUpdate 僅僅檢查 props.color和 state.count是否發生變更,如果他們的值沒有修改,元件將不會發生任何更新。在實際使用中,元件往往比這個複雜,我們可以使用類似於“淺比較”(關於淺比較可以參看: Shallow Compare)的模式來比對所有的屬性或狀態是否發生變更。React提供了這個模式的一個實現元件,只要讓元件繼承自 React.PureComponent即可。我們可以將程式碼進行下面的修改:

//繼承自React.PureComponent
class CounterButton extends React.PureComponent {
  constructor(props) {
    super(props);
    this.state = {count: 1};
  }

  render() {
    return (
      <button
        color={this.props.color}
        onClick={() => this.setState(state => ({count: state.count + 1}))}>
        Count: {this.state.count}
      </button>
    );
  }
}

在大部分情況下,只要使用 React.PureComponent 就可以代替我們自己過載 shouldComponentUpdate方法,但是它僅僅適用於“淺比較”,所以這個元件不適用於props和state資料發生突變的情況。

附:資料突變(mutated)是指變數的引用沒有改變(指標地址未改變),但是引用指向的資料發生了變化(指標指向的資料發生變更)。例如const x = {foo:`foo`}。x.foo=`none` 就是一個突變。

在更復雜的資料結構中還會存在一些問題。例如下面的程式碼,我們希望ListOfWords 元件將words引數渲染成一個逗號分隔的字串,而父元件監控點選事件,每次點選都會增加一個單詞到列表中,但是下面的程式碼並不會正確工作:

class ListOfWords extends React.PureComponent {
  render() {
    return <div>{this.props.words.join(`,`)}</div>;
  }
}

class WordAdder extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      words: [`marklar`]
    };
    this.handleClick = this.handleClick.bind(this);
  }

  handleClick() {
    // 這段內容會導致程式碼不按照預期工作。
    const words = this.state.words;
    words.push(`marklar`);
    this.setState({words: words});
  }

  render() {
    return (
      <div>
        <button onClick={this.handleClick} />
        <ListOfWords words={this.state.words} />
      </div>
    );
  }
}

導致程式碼無法正常工作的原因是 PureComponent 僅僅對 this.props.words的新舊值進行“淺比較”。在words值在handleClick中被修改之後,即使有新的單詞被新增到陣列中,但是this.props.words的新舊值在進行比較時是一樣的(引用物件比較),因此 ListOfWords 一直不會發生渲染。

非突變資料的價值

有一個簡單的方法預防上面提到的問題,就是在使用prop和state時防止資料發生突變。例如下面的例如,我們用陣列的concat方法來代替等號“=”,這樣在concat後會產生一個新的陣列賦值給this.state.words:

handleClick() {
  this.setState(prevState => ({
    words: prevState.words.concat([`marklar`])
  }));
}

ES6支援列表擴充套件語法,因此我們更容易在es6中實現非突變的資料賦值,例如:

handleClick() {
  this.setState(prevState => ({
    words: [...prevState.words, `marklar`],
  }));
};

可以重寫傳統的賦值語句防止物件中的資料發生資料突變。下面的例子有一個名為 colormap 的物件,我們想在修改 colormap.right 的值時渲染元件,我們可以這樣重寫元件:

function updateColorMap(colormap) {
  colormap.right = `blue`; //淺拷貝,指標地址未變,資料發生變化。
}

可以使用 Object.assign 方法來防止資料突變:

function updateColorMap(colormap) {
  // 深拷貝,修改返回物件的地址
  return Object.assign({}, colormap, {right: `blue`});
}

修改後 updateColorMap 方法返回一個新的例項。需要注意的是某些瀏覽器不支援 Object.assign方法,我們需要使用polyfill(差異化抹平,比如我們引入了babel-polyfill)來解決這個問題。

有一個新的JavaScript方案是使用 擴充套件傳播特性(見 object spread properties )來解決資料突變問題,實現如下:

function updateColorMap(colormap) {
  return {...colormap, right: `blue`};
}

如果是構建React的App應用,那麼以上方法都能夠很好的支援,如果是在瀏覽器環境使用,需要引入polyfill機制。

使用不可變的資料結構

Immutable.js 是解決資料突變問題的另外一種解決方案。它提供不可變、持久化的集合。集合包含下列結構:

  • Immutable:一旦資料被建立,改集合不能在任何其他地方修改。
  • Persistent:可以從已有的的資料集合(例如set)來建立新的資料集合。在建立新的資料集合後,已有的資料集合依然有效。
  • 結構分享(Structural Sharing):使用和原始資料儘可能相似的結構建立新的資料集合,並將複製降至最低,儘可能的提高效率。

資料結構不可變的特性使跟蹤資料變化變得很簡單。任何變更將始終導致建立一個新的物件,所以我們只需要檢查引用(指標地址)是否已經被修改即可確定資料是否已經修改。例如在常規的JavaScript程式碼中:

const x = { foo: "bar" };
const y = x;
y.foo = "baz";
x === y; // true

儘管y的值已經被修改,但是它和x都是同一個引用(指向相同的地址),因此最後的比較語句會返回true。我們可以使用 immutable.js來修改程式碼:

const SomeRecord = Immutable.Record({ foo: null});
const x = new SomeRecord({ foo: `bar`});
const y = x.set(`foo`, `baz`);
x === y; // false

在這個例子中,由於x突變時使用了新的引用,我們可以安全的假設x已經發生改變。

還有兩個庫可以幫我們構建不可變資料: seamless-immutable and immutability-helper

不可變的資料結構為我們跟蹤資料物件變更提供了更加簡便的方式,這是我們快速實現shouldComponentUpdate方法的基礎。使用不可變資料後,可以為React提供不錯的效能提升。


相關文章