首頁 > 軟體

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

2022-07-09 22:00:34

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前端框架實現原理的資料請關注it145.com其它相關文章!


IT145.com E-mail:sddin#qq.com