通過 Preact 看 Diff 演算法細節

語言: CN / TW / HK

Preact 是 React 的一個精簡版,要讀 React 原始碼,我們得先來理解一下 React 的設計原理和設計思想,在這一點上, Preact 是一個很不錯的思路,我們可以從 Preact 這個簡易版的框架出發。

在介紹 Preact 的主渲染流程之前,我們先來簡單介紹一下什麼是 jsx。

一、JSX 簡介

在 React 中,jsx 是被推薦配合跟 React 同時使用的一個標籤語法,它通常以以下的形式出現:

const element = <h1>Hello, world!</h1>;

在頁面上,上面的內容就會被渲染成為一個 h1 標籤,其中的內容是 Hello,world! 就像下圖中這樣。

所以 jsx 實際上就是 js 表示式,可以使用所有 js 的語法規則。包括 for 迴圈、if 表示式等,我們會在打包工具中將其變成 HTML 節點插入到頁面當中。

在 React 使用 jsx 的表示式時,HTML 標籤或者 React 標籤會通過 React.createElement 函式,將所有的 jsx 都轉化為一個 js 物件,這個物件會記錄當前 DOM 節點的 attr、events 等屬性,最後渲染到頁面上。

使用 jsx 可以避免頻繁的 DOM 操縱,提升了瀏覽器的渲染效率。

更詳細的 jsx 介紹可以參考: http://zh-hans.reactjs.org/docs/jsx-in-depth.html

二、Preact 渲染主流程

2.1

從一個元件的渲染開始講起

Preact 是一個以元件為基礎的框架,最常用到的功能就是宣告一個元件,並且在頁面上渲染它,像下面這樣:

class Home extends Component {
constructor(props) {
super(props);
this.state = {
title: 'Hello'
}
}

changeTitle() {
this.setState({title: 'changed'});
}

render() {
return (
<div>
<button onClick={this.changeTitle.bind(this)}>改變頁面標題</button>
<h1>{this.state.title}</h1>
</div>
);
}
}

在這段程式碼中,我們用 class 的方式聲明瞭一個元件。在這個元件中,我們聲明瞭一個 render 方法來渲染當前元件的 DOM 節點,並且我們還給其中的 button 元素上綁定了一個 click 事件,這個事件的作用是去更改下面 h1 標籤中的文案,這其中的文案使用的是一個在元件中宣告的 state。

為了把這個元件渲染到頁面上,我們還需要呼叫在 Preact 中宣告的 render 函式。在這個函式中,需要傳入三個引數,當前想要渲染的元件、元件的父節點和替代節點。我們一般使用下面的方式來呼叫渲染函式。

render(<Home />, document.body);

可以見到,我們在這裡傳入了兩個引數,Preact 元件 Home 作為子元素和一個原生的 DOM 節點 document.body。我們在這個 render 函式中做的主要功能就是把 Home 這個元件渲染到父節點上面。下面我們就來仔細看看這個過程是怎麼完成的。

2.2

建立虛擬 DOM

進入 render 函式之後,第一個任務就是檢查傳入的掛載目標的父節點的型別。當前的父元素節點只能是一個原生的 HTML DOM,作者自己定了三種類型,分別是 ELEMENT_NODE、DOCUMENT_FRAGMENT_NODE 和 DOCUMENT_NODE。這三種類型分別代表了 HTML 中的元素節點、Fragment 片段節點和 document 節點。如果傳入的父節點不符合要求,會直接丟擲錯誤。我們這裡傳入的父節點是 body,在這裡的型別是一個程式碼片段,因此它的型別就是 Fragment。

那麼檢查完之後,我們會將當前傳入的這個 Preact 元件變成一個虛擬 DOM,並且放到當前父節點的 _children 屬性上,在這個時刻,使用 jsx 語法來寫的 Home 元件已經被轉化成為了一個 js 物件,其中包含了它的元件名稱、元件型別、子元素等屬性,下一步我們就需要把當前的這個物件轉化為一個虛擬 DOM。

2.2.1. 生成虛擬 DOM

那麼我們想把一個 Home 型別的物件轉化成為虛擬 DOM 就需要看一下 createElement 這個函式,在這個函式中,createVNode 是其中的重點,這個是在 Preact 中宣告的一個函式,專門用來將一個 Preact 元件物件轉化為虛擬 DOM 節點。在這個函式中接受 5 個引數,分別是:

/**
* Create a VNode (used internally by Preact)
* @param {import('./internal').VNode["type"]} type The node name or Component
* Constructor for this virtual node
* @param {object | string | number | null} props The properties of this virtual node.
* If this virtual node represents a text node, this is the text of the node (string or number).
* @param {string | number | null} key The key for this virtual node, used when
* diffing it against its children
* @param {import('./internal').VNode["ref"]} ref The ref property that will
* receive a reference to its created child
* @returns {import('./internal').VNode}
*/

將這些基本的元件資訊傳進去之後,首先會初始化一個基本的 VNode 物件,物件的各個值代表的含義如下:

const vnode = {
type, // 當前元件的型別,一般為生成元件的建構函式或者名稱
props, // 元件的 props 屬性,由上一級傳入
key, // 元件的 key 屬性,用來標識元件的一致性,由上一級傳入
ref, // 元件的 ref 屬性,用來標識元件中的不可變數,由上一級傳入
_children: null, // 當前節點的子節點
_parent: null, // 當前節點的父節點
_depth: 0, // 當前節點的 HTML 層級深度
_dom: null, // 當前節點的最外層 HTML DOM
_nextDom: undefined, // 當前節點的下一個 HTML DOM
_component: null, 當前節點的元件相關資訊
_hydrating: null,
constructor: undefined,
_original: original == null ? ++vnodeId : original // 當前 vNode 的唯一標識
};

2.2.2. 新增初始屬性

初始化好了這個物件之後,需要為這個物件新增各種與 VNode 有關的屬性,下一步是給當前的虛擬物件上新增 _proto_ 屬性,也就是隱式原型。新增的內容如下所示:

接下來會檢測 VNode 的 type 屬性,如果是非 string 型別,那麼就說明它不是一個原生的 HTML DOM,而是一個 Preact 型別的節點,則將 VNode 的 $$typeof 屬性設定為 REACT_ELEMENT_TYPE。走到這一步,一個 VNode 就生成好了,而當前的這個 VNode 其實是在一個 Fragment 元素中將真正要渲染的元素放在它的 props.children 上。

生成好了當前的 DOM 樹的結構,我們最後就要去用 diff 演算法將我們新生成的這個節點和原來的節點進行一個對比,通過最終對比的結果來進行渲染。但是其實這是我們元件的首次渲染,所以在 diff 的過程中和更新的 diff 演算法還是有很多的不同的。

三、Diff 演算法

diff 演算法在 Preact 中可以算是一個非常核心的演算法,其宗旨是為了讓我們能夠避免多餘的 DOM 樹的改動和渲染。通過將 diff 演算法和 VNode 相結合的方式,我們可以提前得知是哪些 DOM 元素髮生了改變,從而只更新這些已經發生了改變的元素,而不需要全域性重新整理,下面我們會通過流程圖的方式,來將這一步驟做一個更加詳細的講解, 這裡有一個流程圖,可以參照這個來看一下。

3.1

Diff 函式(標為藍色線)

首先,我們會進入到一個叫作 diff 的函式中,在這個函式中第一件事情,還是檢查。在進入到這個檢查邏輯中,首先一件事就是將這個 VNode 的 type 和父節點 parent 取出來,同時取出距離當前的這個 VNode 最近的父節點。拿到父節點之後就會去檢查當前的父節點型別是否合法,主要是檢查是否為 table 相關的節點,從而判斷其父節點是否合規,也會根據父元素節點不同的型別來採取對應的邏輯處理。

3.1.1. 初始化 comp

做完這一系列的檢查和操作之後,開始檢查傳入的新節點的型別,如果是 function,那麼會當作一個 Preact 元件來處理。並且會在 globalContext 上尋找這個 VNode 原來的 context 的 id, 如果在 globalContext 中沒有找到對應的 context,那麼就把 globalContext 賦值給 VNode 的上下文。

接下來會初始化這個新的元件,初始化一個新的元件首先就是要將當前的 VNode 的 props 和 context 傳遞給 Componnet 建構函式,然後初始化一個物件 comp。將當前 VNode 的 type 屬性當作 comp 的constructor,並且初始化 comp 的 render 函式。

3.1.2. 第一次呼叫生命週期

初始化 comp 的 state 屬性為空物件,並且用 _dirty 來標記 comp 沒有被更新過,是一個新元件。初始化_nextState 為 state 的值。接下來就是生命週期的呼叫,此時 comp 還沒有進行渲染。首先檢查的是 componentWillMount,如果這個生命週期函式在 comp 上面被定義了,那麼就會呼叫這個函式。接下來會檢查 componentDidMount 這個生命週期,如果這個生命週期被定義了,那麼這個函式就會被放到 _renderCallbacks 中,在 render 之後呼叫。

進行完上述的操作之後,將 comp 的 _dirty 設定為 false,_parentDom 設定為傳遞進來的父元素。

3.1.3. 獲取元件的子元素

接下來進行 doRender 操作,這一步的主要目的是獲取到 comp 的子元素。而 comp 的 render 函式就是返回當前的 props.children。而由於我們的 comp 是一個 Fragment 型別的元件,因此這一步返回的就是 Home 元件。

隨後會檢查當前的這個要渲染的節點是不是頂部的 React 節點來再次判斷當前拿到的是不是子節點,那麼拿到子節點之後,就要 diff 子節點了。

當前的棧幀如下:

3.2

Diff Children(標為綠色線)

3.2.1. 對比 newChildren 和 oldChildren

我們 diff 完了第一層節點之後,我們就要來 diff 子節點了。diff 子節點的第一步就是要通過原來的 oldNode 來取到上面的 oldChildren,並且和上面收集到的當前元件的子節點進行對比,從而起到更新的作用。然而我們當前是第一次渲染這個子節點,也就意味著沒有 oldNode,那麼其實也就是比真正的更新要少一些步驟。

我們從上面可以看到 Fragment 節點的子節點是 Home 元素,那麼我們這裡的 newOldChildren 列表裡的內容就只有當前的這個 Home 元素。

那麼我們首先遍歷新的子節點的資料,拿到一個子節點 newOldChildren,也就是 Home 元素。給當前子節點的 _parent 屬性上賦值為當前的父節點,將當前的子節點的節點深度 _depth + 1, 來標記當前的節點是在哪一層。

接下來我們拿到相同下標的舊的子節點 oldChildren,如果 oldChildren 和 newChildren 的 key 及 type 完全一致,那麼就直接使用新的節點,在舊的子節點列表中,將此節點的值設定為 undefined,當前棧幀如下:

接下來會再次進入 diff 函式,將當前的 oldChildren 和 newChildren 再次重複上面 diff 函式的邏輯。在呼叫當前的 newChildren 的 render 函式時,就相當於呼叫我們 Home 元件的 render 函式,那麼這段函式返回的就是一段 jsx。

render() {
return (
<div>
<button onClick={this.changeTitle.bind(this)}>改變頁面標題</button>
<h1>{this.state.title}</h1>
</div>
);
}

根據這段 jsx, 我們使用 createElement 函式,將其中的 div 標籤的 vNode 創建出來,也就是取到 Home 的子元素,然後再次進入 diffChildren 內。

在 diffChildren 中進行上面相同的操作之後,進入 diff 函式內,此時 diff 函式檢測到,當前的 vNode 已經是一個原生的 HTML 標籤了,而不是 React 元素,那麼就會進入到 diffElementNodes 內部。

當前的棧幀:

3.3

DiffElementNodes(標為橙色線)

diffElementNodes 這個函式主要是用來 diff 原生的 HTML 節點的,在這個函式中需要傳入 oldNode 的真正元素節點,也就是 oldNode 的 HTML 節點。

3.3.1. 建立 div 節點

首先來檢查一下當前這個標籤節點的型別,如果這個節點不是 svg 型別,也不為空,那麼會根據當前的元素型別通過 document.createElement 建立一個對應的節點。這裡我們就建立第一層的父元素 div 節點。

接下來我們會去檢查當前節點是否有子元素以及是否有 dangerouslySetInnerHTML 這個屬性。dangerouslySetInnerHTML 是在 DOM 上直接插入 HTML 片段的屬性,如果有這個屬性,那麼我們要為它賦值一個 _children 屬性。

3.3.2. 判斷是否為最底部節點

如果沒有 dangerouslySetInnerHTML 這個屬性,我們需要再判斷一下,當前的節點是不是已經是一個 "最底部" 的節點了。因為這關係到我們下一步該如何操作,如果當前的頁面已經是 "最底部" 節點,那麼意味著它不會再有 DOM 子節點了,但是如果是 DOM 節點或者 Preact 節點,就先 diff 新舊兩個節點上的 props, 並且將其中不是 children 和 key 的 props 進行更新。隨後繼續通過 diffChildren 重複上面的操作。

在當前的 div 元素中,還有 button 元素和 h1 元素,因此會再次進入 diffChildren 中進行對比。在這一步完成後最後將我們在這個函式中建立的新節點 div 返回給 diff 函式。

返回到 diff 函式之後,再次檢查當前生成的新 DOM 是否符合規範,再回到 diffChildren 中。在 diffChildren 中我們拿到新的 DOM 元素之後,就需要找一個合適的位置放置當前的 DOM 元素,就 div 這個元素來說,我們會將它插入到 parent 節點,也就是 Home 中,並且在這裡我們還會找到下一個即將渲染的節點。

接下來,我們將由 oldChildren 組成的列表中的每個引用都設定成 undefined,並且呼叫 unmount 生命週期。

解除安裝完之前的同級舊節點,我們就可以返回到第一層的 diff 函式中,給當前以 div 為基礎的元件的 _base 屬性設定為當前的 DOM節點。並且在最後拿出 commitQueue 中的渲染之後待執行的函式來執行。

最終的棧幀和執行的過程如下圖所示:

最終我們的 diff 過程就完成啦!

四、setStatez之後會怎樣

下面我們來對比一下改變狀態,也就是在 setState 中這個過程有什麼區別。

首先我們在 setState 中會傳入一個需要 update 的引數,一般來講是一個物件,裡面是要改變的 key: value 值。

this.setState({title: 'changed'});

首先在 setState 中會將 nextState 深拷貝一份出來。 update state 的方式是 遍歷 原來的 state 並且直接將新的 state 賦值給原來的 state,因此 state 是一個引用值。 在這一步,如果沒有傳入要更改的 update,則直接返回,不重新渲染。

export function assign(obj, props) {
for (let i in props) obj[i] = props[i];
return obj;
}

4.1

根據 depth 進行排序

接下來是將這個任務放入渲染佇列中,放入佇列之後,程式碼就會根據當前的任務佇列,執行渲染任務。執行渲染任務的時候,並不是按照放入渲染佇列的順序來執行的,而是先將渲染佇列按照當前的 node 深度層次升序排列,也就是 _depth 屬性,然後將渲染佇列清空,並遍歷之前佇列的副本,也就是先渲染外層的,再渲染裡面的節點。如果遍歷到當前的 component 的 _dirty 值為 true,那麼會去執行當前元件的渲染。

4.2

進行 Diff

元件渲染的第一步,依舊是進新舊元件的 diff。在這裡進行 diff 之前,我們首先要做一系列的準備工作。在準備工作中,我們將當前狀態改變的 component 的 _vnode 屬性拿出來, 當作新節點。然後將新節點的 _original 屬性加一,當作舊節點。並且儲存舊節點上最外層的 dom 節點作為 oldDom。

同樣的進入 diff 函式之後會進行和上面一樣的操作,但是在 comp 的生成過程是和上面不一樣的。上面初次渲染的時候 comp 是使用 Component 建構函式生成的一個例項,但是在有 oldNode 的情況下,comp 當前是 oldNode,需要更新的 state 會賦值到 _nextState 上面,並且儲存舊元件的 props 和 state。

4.3

執行生命週期

接下來會對 comp 上定義的生命週期進行檢查,如果新舊 props 有更新並且定義 componentWillReceiveProps,就會呼叫當前的這個函式。並且在之後會檢查當前的生命週期是否被定義,其中 componentWillUpdate 會在此刻呼叫。而 componentDidUpdate 則會放入渲染後的回撥函式中,也就是 commitQueue 中。

在呼叫完 componentWillUpdate 這個生命週期之後,comp 的 props 會被賦值成為新元件的值,而 state 也會被賦值為新元件上的 _nextState。當上面的準備工作就緒之後,我們就可以開始最外層元件的渲染了。

因為我們當前的元件是 update 而不是初始化掛載元件, 因此在最外層不需要套一個 Fregment 的元素,而是直接更新目標元件,因此我們可以直接呼叫元件的 render 函式,也就是:

render() {
return (
<div>
<button onClick={this.changeTitle.bind(this)}>改變頁面標題</button>
<h1>{this.state.title}</h1>
</div>
);
}

在執行這個渲染函式的時候,依舊是利用 diffChildren 和 diffElementNodes 這兩個函式,由最上層的 div 節點遞迴遍歷至 h1 節點,並且在每個節點便利的時候,都會對比節點上的 props 來進行更新。在遍歷到 h1 節點之後,我們會發現它的子節點是一個 text 型別,那麼在這種節點上,我們如果發現它的 props 和之前的舊節點不一樣,我們會直接把新的 props 賦值給新節點的 data 屬性。也就是說 diffElementNodes 節點會一直 diff 到最底層的 text 節點為止,然後更新 text 節點。

4.4

最終渲染

到 h1 節點中,我們會發現新的文案和舊的文案不一致,那麼我們此時就會將新的文案更新到節點上,從而觸發頁面對於這個節點的重新渲染,最後返回到 diff 函式中,將 commitQueue 中的函式全部執行完畢,當前的這個 setState 任務就執行完畢啦。

五、總結

通過以上的內容,我們對於 React 最核心的 Diff 邏輯有了一個瞭解。當然 React 的程式碼細節要複雜得多了,尤其是加了 Fiber 之後,它值得我們持續地去學習和探索。

✎✎✎

【更多內容】

編譯 | 現代瀏覽器結構