React前端框架實現原理的理解

vdom

react 和 vue 都是基於 vdom 的前端框架,我們先聊下 vdom:

為什麼 react 和 vue 都要基於 vdom 呢?直接操作真實 dom 不行麼?

考慮下這樣的場景:

渲染就是用 dom api 對真實 dom 做增刪改,如果已經渲染瞭一個 dom,後來要更新,那就要遍歷它所有的屬性,重新設置,比如 id、clasName、onclick 等。

而 dom 的屬性是很多的:

有很多屬性根本用不到,但在更新時卻要跟著重新設置一遍。

能不能隻對比我們關心的屬性呢?

把這些單獨摘出來用 JS 對象表示不就行瞭?

這就是為什麼要有 vdom,是它的第一個好處。

而且有瞭 vdom 之後,就沒有和 dom 強綁定瞭,可以渲染到別的平臺,比如 native、canvas 等等。

這是 vdom 的第二個好處。

我們知道瞭 vdom 就是用 JS 對象表示最終渲染的 dom 的,比如:

{
    type: 'div',
    props: {
        id: 'aaa',
        className: ['bbb', 'ccc'],
        onClick: function() {}
    },
    children: []
}

然後用渲染器把它渲染出來。

但是要讓開發去寫這樣的 vdom 麼?

那肯定不行,這樣太麻煩瞭,大傢熟悉的是 html 那種方式,所以我們要引入編譯的手段。

dsl 的編譯

dsl 是 domain specific language,領域特定語言的意思,html、css 都是 web 領域的 dsl。

直接寫 vdom 太麻煩瞭,所以前端框架都會設計一套 dsl,然後編譯成 render function,執行後產生 vdom。

vue 和 react 都是這樣:

這套 dsl 怎麼設計呢?

前端領域大傢熟悉的描述 dom 的方式是 html,最好的方式自然是也設計成那樣。

所以 vue 的 template,react 的 jsx 就都是這麼設計的。

vue 的 template compiler 是自己實現的,而 react 的 jsx 的編譯器是 babel 實現

編譯成 render function 後再執行就是我們需要的 vdom。

接下來渲染器把它渲染出來就行瞭。

那渲染器怎麼渲染 vdom 的呢?

渲染 vdom

渲染 vdom 也就是通過 dom api 增刪改 dom。

比如一個 div,那就要 document.createElement 創建元素,然後 setAttribute 設置屬性,addEventListener 設置事件監聽器。

如果是文本,那就要 document.createTextNode 來創建。

所以說根據 vdom 類型的不同,寫個 if else,分別做不同的處理就行瞭。

沒錯,不管 vue 還是 react,渲染器裡這段 if else 是少不瞭的:

switch (vdom.tag) {
  case HostComponent:
    // 創建或更新 dom
  case HostText:
    // 創建或更新 dom
  case FunctionComponent: 
    // 創建或更新 dom
  case ClassComponent: 
    // 創建或更新 dom
}

react 裡是通過 tag 來區分 vdom 類型的,比如 HostComponent 就是元素,HostText 就是文本,FunctionComponent、ClassComponent 就分別是函數組件和類組件。

那麼問題來瞭,組件怎麼渲染呢?

這就涉及到組件的原理瞭:

組件

我們的目標是通過 vdom 描述界面,在 react 裡會使用 jsx。

這樣的 jsx 有的時候是基於 state 來動態生成的。如何把 state 和 jsx 關聯起來呢?

封裝成 function、class 或者 option 對象的形式。然後在渲染的時候執行它們拿到 vdom 就行瞭。

這就是組件的實現原理:

switch (vdom.tag) {
  case FunctionComponent: 
       const childVdom = vdom.type(props);
       render(childVdom);
       //...
  case ClassComponent: 
     const instance = new vdom.type(props);
     const childVdom = instance.render();
     render(childVdom);
     //...
} 

如果是函數組件,那就傳入 props 執行它,拿到 vdom 之後再遞歸渲染。

如果是 class 組件,那就創建它的實例對象,調用 render 方法拿到 vdom,然後遞歸渲染。

所以,大傢猜到 vue 的 option 對象的組件描述方式怎麼渲染瞭麼?

{
    data: {},
    props: {}
    render(h) {
        return h('div', {}, '');
    }
}

沒錯,就是執行下 render 方法就行:

 const childVdom = option.render();
 render(childVdom);

大傢可能平時會寫單文件組件 sfc 的形式,那個會有專門的編譯器,把 template 編譯成 render function,然後掛到 option 對象的 render 方法上:

所以組件本質上隻是對產生 vdom 的邏輯的封裝,函數的形式、option 對象的形式、class 的形式都可以。

就像 vue3 也有瞭函數組件一樣,組件的形式並不重要。

基於 vdom 的前端框架渲染流程都差不多,vue 和 react 很多方面是一樣的。但是管理狀態的方式不一樣,vue 有響應式,而 react 則是 setState 的 api 的方式。

真說起來,vue 和 react 最大的區別就是狀態管理方式的區別,因為這個區別導致瞭後面架構演變方向的不同。

狀態管理

react 是通過 setState 的 api 觸發狀態更新的,更新以後就重新渲染整個 vdom。

而 vue 是通過對狀態做代理,get 的時候收集以來,然後修改狀態的時候就可以觸發對應組件的 render 瞭。

有的同學可能會問,為什麼 react 不直接渲染對應組件呢?

想象一下這個場景:

父組件把它的 setState 函數傳遞給子組件,子組件調用瞭它。

這時候更新是子組件觸發的,但是要渲染的就隻有那個組件麼?

明顯不是,還有它的父組件。

同理,某個組件更新實際上可能觸發任意位置的其他組件更新的。

所以必須重新渲染整個 vdom 才行。

那 vue 為啥可以做到精準的更新變化的組件呢?

因為響應式的代理呀,不管是子組件、父組件、還是其他位置的組件,隻要用到瞭對應的狀態,那就會被作為依賴收集起來,狀態變化的時候就可以觸發它們的 render,不管是組件是在哪裡的。

這就是為什麼 react 需要重新渲染整個 vdom,而 vue 不用。

這個問題也導致瞭後來兩者架構上逐漸有瞭差異。

react 架構的演變

react15 的時候,和 vue 的渲染流程還是很像的,都是遞歸渲染 vdom,增刪改 dom 就行。

但是因為狀態管理方式的差異逐漸導致瞭架構的差異。

react 的 setState 會渲染整個 vdom,而一個應用的所有 vdom 可能是很龐大的,計算量就可能很大。

瀏覽器裡 js 計算時間太長是會阻塞渲染的,會占用每一幀的動畫、重繪重排的時間,這樣動畫就會卡頓。

作為一個有追求的前端框架,動畫卡頓肯定是不行的。但是因為 setState 的方式隻能渲染整個 vdom,所以計算量大是不可避免的。

那能不能把計算量拆分一下,每一幀計算一部分,不要阻塞動畫的渲染呢?

順著這個思路,react 就改造為瞭 fiber 架構。

fiber 架構

優化的目標是打斷計算,分多次進行,但現在遞歸的渲染是不能打斷的,有兩個方面的原因導致的:

  • 渲染的時候直接就操作瞭 dom 瞭,這時候打斷瞭,那已經更新到 dom 的那部分怎麼辦?
  • 現在是直接渲染的 vdom,而 vdom 裡隻有 children 的信息,如果打斷瞭,怎麼找到它的父節點呢?

第一個問題的解決還是容易想到的:

渲染的時候不要直接更新到 dom 瞭,隻找到變化的部分,打個增刪改的標記,創建好 dom,等全部計算完瞭一次性更新到 dom 就好瞭。

所以 react 把渲染流程分為瞭兩部分: render 和 commit。

render 階段會找到 vdom 中變化的部分,創建 dom,打上增刪改的標記,這個叫做 reconcile,調和。

reconcile 是可以打斷的,由 schedule 調度。

之後全部計算完瞭,就一次性更新到 dom,叫做 commit。

這樣,react 就把之前的和 vue 很像的遞歸渲染,改造成瞭 render(reconcile + schdule) + commit 兩個階段的渲染。

從此以後,react 和 vue 架構上的差異才大瞭起來。

第二個問題,如何打斷以後還能找到父節點、其他兄弟節點呢?

現有的 vdom 是不行的,需要再記錄下 parent、silbing 的信息。所以 react 創造瞭 fiber 的數據結構。

除瞭 children 信息外,額外多瞭 sibling、return,分別記錄著兄弟節點、父節點的信息。

這個數據結構也叫做 fiber。(fiber 既是一種數據結構,也代表 render + commit 的渲染流程)

react 會先把 vdom 轉換成 fiber,再去進行 reconcile,這樣就是可打斷的瞭。

為什麼這樣就可以打斷瞭呢?

因為現在不再是遞歸,而是循環瞭:

function workLoop() {
  while (wip) {
    performUnitOfWork();
  }
  if (!wip && wipRoot) {
    commitRoot();
  }
}

react 裡有一個 workLoop 循環,每次循環做一個 fiber 的 reconcile,當前處理的 fiber 會放在 workInProgress 這個全局變量上。

當循環完瞭,也就是 wip 為空瞭,那就執行 commit 階段,把 reconcile 的結果更新到 dom。

每個 fiber 的 reconcile 是根據類型來做的不同處理。當處理完瞭當前 fiber 節點,就把 wip 指向 sibling、return 來切到下個 fiber 節點。:

function performUnitOfWork() {
  const { tag } = wip;
  switch (tag) {
    case HostComponent:
      updateHostComponent(wip);
      break;
    case FunctionComponent:
      updateFunctionComponent(wip);
      break;
    case ClassComponent:
      updateClassComponent(wip);
      break;
    case Fragment:
      updateFragmentComponent(wip);
      break;
    case HostText:
      updateHostTextComponent(wip);
      break;
    default:
      break;
  }
  if (wip.child) {
    wip = wip.child;
    return;
  }
  let next = wip;
  while (next) {
    if (next.sibling) {
      wip = next.sibling;
      return;
    }
    next = next.return;
  }
  wip = null;
}

函數組件和 class 組件的 reconcile 和之前講的一樣,就是調用 render 拿到 vdom,然後繼續處理渲染出的 vdom:

function updateClassComponent(wip) {
  const { type, props } = wip;
  const instance = new type(props);
  const children = instance.render();
  reconcileChildren(wip, children);
}
function updateFunctionComponent(wip) {
  renderWithHooks(wip);
  const { type, props } = wip;
  const children = type(props);
  reconcileChildren(wip, children);
}

循環執行 reconcile,那每次處理之前判斷一下是不是有更高優先級的任務,就能實現打斷瞭。

所以我們在每次處理 fiber 節點的 reconcile 之前,都先調用下 shouldYield 方法:

function workLoop() {
  while (wip && shouldYield()) {
    performUnitOfWork();
  }
  if (!wip && wipRoot) {
    commitRoot();
  }
}

shouldYiled 方法就是判斷待處理的任務隊列有沒有優先級更高的任務,有的話就先處理那邊的 fiber,這邊的先暫停一下。

這就是 fiber 架構的 reconcile 可以打斷的原理。通過 fiber 的數據結構,加上循環處理前每次判斷下是否打斷來實現的。

聊完瞭 render 階段(reconcile + schedule),接下來就進入 commit 階段瞭。

前面說過,為瞭變為可打斷的,reconcile 階段並不會真正操作 dom,隻會創建 dom 然後打個 effectTag 的增刪改標記。

commit 階段就根據標記來更新 dom 就可以瞭。

但是 commit 階段要再遍歷一次 fiber 來查找有 effectTag 的節點,更新 dom 麼?

這樣當然沒問題,但沒必要。完全可以在 reconcile 的時候把有 effectTag 的節點收集到一個隊列裡,然後 commit 階段直接遍歷這個隊列就行瞭。

這個隊列叫做 effectList。

react 會在 commit 階段遍歷 effectList,根據 effectTag 來增刪改 dom。

dom 創建前後就是 useEffect、useLayoutEffect 還有一些函數組件的生命周期函數執行的時候。

useEffect 被設計成瞭在 dom 操作前異步調用,useLayoutEffect 是在 dom 操作後同步調用。

為什麼這樣呢?

因為都要操作 dom 瞭,這時候如果來瞭個 effect 同步執行,計算量很大,那不是把 fiber 架構帶來的優勢有毀瞭麼?

所以 effect 是異步的,不會阻塞渲染。

而 useLayoutEffect,顧名思義是想在這個階段拿到一些佈局信息的,dom 操作完以後就可以瞭,而且都渲染完瞭,自然也就可以同步調用瞭。

實際上 react 把 commit 階段也分成瞭 3 個小階段。

before mutation、mutation、layout。

mutation 就是遍歷 effectList 來更新 dom 的。

它的之前就是 before mutation,會異步調度 useEffect 的回調函數。

它之後就是 layout 階段瞭,因為這個階段已經可以拿到佈局信息瞭,會同步調用 useLayoutEffect 的回調函數。而且這個階段可以拿到新的 dom 節點,還會更新下 ref。

至此,我們對 react 的新架構,render、commit 兩大階段都幹瞭什麼就理清瞭。

總結

react 和 vue 都是基於 vdom 的前端框架,之所以用 vdom 是因為可以精準的對比關心的屬性,而且還可以跨平臺渲染。

但是開發不會直接寫 vdom,而是通過 jsx 這種接近 html 語法的 DSL,編譯產生 render function,執行後產生 vdom。

vdom 的渲染就是根據不同的類型來用不同的 dom api 來操作 dom。

渲染組件的時候,如果是函數組件,就執行它拿到 vdom。class 組件就創建實例然後調用 render 方法拿到 vdom。vue 的那種 option 對象的話,就調用 render 方法拿到 vdom。

組件本質上就是對一段 vdom 產生邏輯的封裝,函數、class、option 對象甚至其他形式都可以。

react 和 vue 最大的區別在狀態管理方式上,vue 是通過響應式,react 是通過 setState 的 api。我覺得這個是最大的區別,因為它導致瞭後面 react 架構的變更。

react 的 setState 的方式,導致它並不知道哪些組件變瞭,需要渲染整個 vdom 才行。但是這樣計算量又會比較大,會阻塞渲染,導致動畫卡頓。

所以 react 後來改造成瞭 fiber 架構,目標是可打斷的計算。

為瞭這個目標,不能變對比變更新 dom 瞭,所以把渲染分為瞭 render 和 commit 兩個階段,render 階段通過 schedule 調度來進行 reconcile,也就是找到變化的部分,創建 dom,打上增刪改的 tag,等全部計算完之後,commit 階段一次性更新到 dom。

打斷之後要找到父節點、兄弟節點,所以 vdom 也被改造成瞭 fiber 的數據結構,有瞭 parent、sibling 的信息。

所以 fiber 既指這種鏈表的數據結構,又指這個 render、commit 的流程。

reconcile 階段每次處理一個 fiber 節點,處理前會判斷下 shouldYield,如果有更高優先級的任務,那就先執行別的。

commit 階段不用再次遍歷 fiber 樹,為瞭優化,react 把有 effectTag 的 fiber 都放到瞭 effectList 隊列中,遍歷更新即可。

在dom 操作前,會異步調用 useEffect 的回調函數,異步是因為不能阻塞渲染。

在 dom 操作之後,會同步調用 useLayoutEffect 的回調函數,並且更新 ref。

所以,commit 階段又分成瞭 before mutation、mutation、layout 這三個小階段,就對應上面說的那三部分。

我覺得理解瞭 vdom、jsx、組件本質、fiber、render(reconcile + schedule) + commit(before mutation、mutation、layout)的渲染流程,就算是對 react 原理有一個比較深的理解瞭。

以上就是React前端框架實現原理的理解的詳細內容,更多關於React前端框架實現原理的資料請關註WalkonNet其它相關文章!

推薦閱讀: