漫談 React Fiber

本文做者:葛星html

背景

React 實現了使用 Virtual DOM 來描述 UI 的方式,經過對比兩棵樹的差別最小化的更新 DOM,這樣使得用戶的代碼變的傻瓜,可是同時也來帶了一些問題。這個核心的問題就在於 diff 計算並不是是免費的,在元素較多的狀況下,整個 diff 計算的過程可能會持續很⻓時間,形成動畫丟幀或者很難響應用戶的操做,形成用戶體驗降低。前端

爲何會出現這個問題,主要是由於下面兩個緣由:react

  1. React < 15 的版本一直採用 Stack Reconciler 的方式進行 UI 渲染(之因此叫 Stack Reconciler 是相對於 Fiber Reconciler 而言) , 而 Stack Reconciler 的實現是採用了遞歸的方式,咱們知道遞歸是沒法被打斷,每當有須要更新的時候,React 會從須要更新的節點開始一直執行 diff ,這會消耗大量的時間。
  2. 瀏覽器是多線程的,包含渲染線程和 JS 線程,而渲染線程和 JS 線程是互斥的,因此當 JS 線程佔據大量時間的時候,UI 的響應也會被 block 住。

上面兩個緣由缺一不可,由於若是 JS 執行, UI 不會阻塞 ,其實用戶也不會有所感知。下面讓咱們看下比較常見的性能優化手段。git

常見的性能優化手段

通常咱們會採用下面的方式來優化性能github

防抖

對函數使用防抖的方式進行優化。這種方式將 UI 的更新推遲到用戶輸入完畢。這樣用戶在輸入的時候就不會感受到卡頓。瀏覽器

class App extends Component {
  onChange = () => {
    if (this.timeout) {
      clearTimeout(this.timeout);
    }
    this.timeout = setTimeout(
      () =>
        this.setState({
          ds: [],
        }),
      200
    );
  };
  render() {
    return (
      <div> <input onChange={this.onChange} /> <list ds={this.state.ds} /> </div>
    );
  }
}
複製代碼

使用 PureComponent || shouldComponentUpdate

經過 shouldComponentUpdate 或者 PureComponent 的方式進行優化。這種方式經過淺對比先後兩次的 props 和 state 讓 React 跳過沒必要要的 diff 計算。性能優化

class App extends Component {
  shouldComponentUpdate(nextProps, nextState) {
    return (
      !shallowEqual(nextProps, this.props) ||
      !shallowEqual(nextState, this.state)
    );
  }
  render() {
    return (
      <div> <input onChange={this.onChange} /> <list ds={this.state.ds} /> </div>
    );
  }
}
複製代碼

這種方式有下面三個須要注意的點:markdown

a. 只能採用淺比較的方式,這樣更深層次的對象更新的時候沒法比較,而若是採用深比較的方式,若是你比較對象的時間比 React diff 的時間還要久,得不償失。多線程

b. 對象的引用關係,在對於 state 的賦值的時候,主要注意對象的引用關係,好比下面的代碼就會讓這個組件沒法更新架構

class App extends PureComponent {
  state = {
    record: {},
  };
  componentDidMount() {
    const { record } = this.state;
    record.name = "demo";
    this.setState({
      record,
    });
  }
  render() {
    return <>{this.state.record.name}</>;
  }
}
複製代碼

c. 函數的執行值發生改變。這種狀況在於函數裏面用到了 props 和 state 以外的變量,這些變量可能發生了改變

class App extends PureComponent {
  cellRender = (value, index, record) => {
    return record.name + this.name;
  };
  render() {
    return <List cellRender={this.cellRender} />;
  }
}
複製代碼

對象劫持

經過相似於 Vue@2.x 和 Mobx 的方式實現觀察對象來進行局部更新。這種方式要求用戶在使用的時候避免使用 setState 方法。

@inject("color")
@observer
class Btn extends React.Component {
  render() {
    return (
      <button style={{ color: this.props.color }}>{this.props.text}</button>
    );
  }
}

<Provider color="red">
  <MessageList> <Btn /> </MessageList>
</Provider>;
複製代碼

對於這個例子,color 變化的時候, 只有 Button 會從新渲染。

其實對於80%的狀況,上面的三種方式已經知足這些場景的性能優化,可是上面所說的都是在應用層面的優化,其實對於開發者提出了必定的要求,有什麼方式能夠在底層進行一些優化呢?

RequestIdleCallback

很是慶幸的是瀏覽器推出了requestIdleCallback 的 API, 這個 API 可讓瀏覽器在空閒時期的時候執行腳本,大概如下面的方式使用:

requestIdleCallback((deadline) => {
  if (deadline.timeRemaining() > 0) {
  } else {
    requestIdleCallback(otherTasks);
  }
});
複製代碼

上面的例子主要是說若是瀏覽器在當前幀沒有空閒時間了,則開啓另外一個空閒期調用。(注:大概在 2018 年的時候, Facebook 拋棄了 requestIdleCallback 的原生 API,討論

image.png

以前咱們說過 React 的 diff 計算會花費大量的時間,因此咱們思考下若是咱們將 diff 計算放在裏面執行是否就能解決體驗的問題呢?答案是確定的,可是這會面臨下面幾個問題:

  1. 由於每次空閒的時間有限,因此要求程序在執行 diff 的時候須要將當前狀態保留下來,等待下次空閒的時候再次調用。這裏就涉及到可中斷,可恢復。
  2. 程序須要有優先級的概念。簡單的來講就是須要標誌哪些任務是高優先級的,哪些任務是低優先級的, 這樣纔有調度的依據。 因此 React Fiber 就是基於優先級的調度策略。看上面兩個問題,最重要的部分實際上是能夠中斷和恢復,如何實現中斷和恢復?

斐波那契數列的 Fiber

再看 React 的 Fiber 以前咱們先來研究下怎麼使用 Fiber 的思惟方式來改寫斐波那契數列,在計算機科學裏,有這樣一句話「任何遞歸的程序均可以使用循環實現」。爲了讓程序能夠中斷,遞歸的程序必須改寫爲循環。

遞歸下斐波那契數列寫法:

function fib(n) {
  if (n <= 2) {
    return 1;
  } else {
    return fib(n - 1) + fib(n - 2);
  }
}
複製代碼

若是咱們採用 Fiber 的思路將其改寫爲循環,就須要展開程序,保留執行的中間態,這裏的中間態咱們定義爲下面的結構,雖然這個例子並不能和 React Fiber 的對等。

function fib(n) {
  let fiber = { arg: n, returnAddr: null, a: 0 };
  // 標記循環
  rec: while (true) {
    // 當展開徹底後,開始計算
    if (fiber.arg <= 2) {
      let sum = 1;
      // 尋找父級
      while (fiber.returnAddr) {
        fiber = fiber.returnAddr;
        if (fiber.a === 0) {
          fiber.a = sum;
          fiber = { arg: fiber.arg - 2, returnAddr: fiber, a: 0 };
          continue rec;
        }
        sum += fiber.a;
      }
      return sum;
    } else {
      // 先展開
      fiber = { arg: fiber.arg - 1, returnAddr: fiber, a: 0 };
    }
  }
}
複製代碼

實際上 React Fiber 正是受到了上面的啓發,咱們能夠看到因爲 Fiber 的思路對執行程序進行了展開,大概相似於下面的結構,和程序執行的堆棧很是類似,這段代碼的意思是先像左邊同樣展開整個結構,當 fiber 的入參小於 2 的時候,再不斷的尋找父級知道沒有父節點,最後獲得 sum 值。

左側是展開的結構,右側是向上堆疊的調用棧示意圖

image.pngimage.png

因此 Fiber 比 Stack 的方式要花費更多的內存佔用和執行性能。這個例子有更直觀的展現。 可是爲何 React 基於 Fiber 的思路會讓 JS 執行性能提高呢,這是由於有其餘的優化在其中,好比不須要兼容舊有的瀏覽器,代碼量的縮減等等。

React Fiber 的結構

如今咱們來看一看一個 Fiber Node 的結構,以下圖所示,一個很是典型的鏈表的結構,這種設計方式實際也受上面展開堆棧方式的啓發,而相對於 15 版本而言,增長了不少屬性。

image.png

{
  tag, // 標記一些特殊的組件類型,好比Fragment,ContextProvider等
  type, // 組件的節點的真實的描述,好比div, Button等
  key, // key和15同樣,若是key一致,下次這個節點能夠被複用
  child, // 節點的孩子
  sibling, // 節點的兄弟節點
  return, // 實際上就是該節點的父級節點
  pendingProps, // 開始的時候設置pendingProps
  memoizedProps, // 結束的時候設置memoizedProps, 若是二者相同的話,直接複用以前的stateNode
  pendingWorkPriority, // 當前節點的優先級,
  stateNode, // 當前節點關聯的組件的instance
  effectTag // 標記當前的fiber須要被操做的類型,好比刪除,更新等等
  ...
}

複製代碼

咱們能夠採用上面相似遍歷展開的斐波那契數列同樣遍歷 Fiber Node 的 root ,其實就是一個比較簡單的鏈表遍歷方法。

Fiber 的衍生產物 Custom Renderer

在實施 Fiber 的過程當中,爲了更好的實現擴展性的需求,衍生出了 React Reconciler 這個獨立的包,咱們能夠經過這個玩意自定義一個 Custom Renderer。它定義了一系列標準化的接口,使咱們沒必要關心 Fiber 內部是如何工做的,就能夠經過虛擬 DOM 的方式驅動宿主環境。

一個較爲完整的探索 Custom Renderer 的例子

啓動方式

下面一個標準化的 Custom Renderer 的啓動代碼,咱們只須要實現 HostConfig 的部分就可使用 React Reconclier 的調度能力:

import Reconciler from 'react-reconclier';

const HostConfig = {};
const CustomRenderer = Reconciler(HostConfig)
let root;
const render = function(children, container) {
    if(!root) {
        root = CustomRenderer.createContainer(container);
    }
    CustomRenderer.updateContainer(children, root);
}

render(<App/>, doucment.querySelector('#root')
複製代碼

HostConfig 中最核心的方法是 createInstance,爲 type 類型建立一個實例,若是宿主環境是 Web ,能夠直接調用 createElement 方法

createInstance(type,props,rootContainerInstance,hostContext) {
   // 轉換props
   return document.createElement(
      type,
      props,
    );
 }
複製代碼

跨端實現

衍生一下,如今跨端的方案,基本上這種運行時的方案均可以利用 CustomRenderer 的思路,來實現一碼多端。舉個簡單的例子,假設了我寫了下面的代碼

function App() {
  return <Button />;
}
複製代碼

Button 具體應該使用什麼對應的實現渲染,能夠在createInstance裏作個攔截,固然也能夠對不一樣的端實現不一樣的 Renderer 。 下面一個僞代碼

Mobile Renderer

import { MobileButton } from 'xxx';

createInstance(type,props,rootContainerInstance,hostContext) {
   const components = {
   	Button: MobileButton
   }
   return new components[type](props) // 僞代碼
 }
複製代碼

API 設計的問題

雖然看起來 CustomRenderer 很好,實際上在整個 API 的設計上,爲了 Web 作了一些妥協。好比單獨爲文本設計的 shouldSetTextContentcreateTextInstance 方法,基本上是由於 Web 對某些元素文本操做的緣由,沒有辦法使用統一的 document.createElement,而必須使用document.createTextNode,其實在不少其餘的渲染場景下都不須要單獨實現這些方法或者直接返回 false

React DOM 的實現

export function shouldSetTextContent(type: string, props: Props): boolean {
  return (
    type === 'textarea' ||
    type === 'option' ||
    type === 'noscript' ||
    typeof props.children === 'string' ||
    typeof props.children === 'number' ||
    (typeof props.dangerouslySetInnerHTML === 'object' &&
      props.dangerouslySetInnerHTML !== null &&
      props.dangerouslySetInnerHTML.__html != null)
  );
}
複製代碼

其餘的一些 Renderer

export function shouldSetTextContent() {
  return false;
}
複製代碼

小結

本文主要探尋下 React Fiber 想要解決的問題,包括 Fiber 架構受到的一些啓發,及在實施了 Fiber 架構後的衍生產物 Custom Renderer 的應用,但願有更多的場景能夠利用到 Custom Renderer 的能力, 這裏提供一些社區常見的 Custom Renderer。最後,本文僅表明我的觀點,若有錯誤歡迎批評指正。

參考資料

ReactFiber

CallStack

requestIdleCallback

React Reconclier

本文發佈自 網易雲音樂大前端團隊,文章未經受權禁止任何形式的轉載。咱們常年招收前端、iOS、Android,若是你準備換工做,又剛好喜歡雲音樂,那就加入咱們 grp.music-fe(at)corp.netease.com!

相關文章
相關標籤/搜索