手寫系列-實現一個鉑金段位的 React

語言: CN / TW / HK

一、前言

本文基於 https://pomb.us/build-your-own-react/ 實現簡單版 React。

本文學習思路來自 卡頌-b站-React源碼,你在第幾層

模擬的版本為 React 16.8。

將實現以下功能:

  1. createElement(虛擬 DOM)
  2. render
  3. 可中斷渲染
  4. Fibers
  5. Render and Commit Phases
  6. 協調(Diff 算法)
  7. 函數組件
  8. hooks

下面上正餐,請繼續閲讀。

二、準備

1. React Demo

先來看看一個簡單的 React Demo,代碼如下:

const element = <div title="foo">hello</div>
const container = document.getElementById('container')
ReactDOM.render(element, container);

本例完整源碼見: reactDemo

在瀏覽器中打開 reactDemo.html,展示如下:

我們需要實現自己的 React,那麼就需要知道上面的代碼到底做了什麼。

1.1 element

const element = <div>123</div> 實際上是 JSX 語法。

React 官網 對 JSX 的解釋如下:

JSX 是一個 JavaScript 語法擴展。它類似於模板語言,但它具有 JavaScript 的全部能力。JSX 最終會被 babel 編譯為 React.createElement() 函數調用。

通過babel 在線編譯 const element = <div>123</div>

可知 const element = <div>123</div> 經過編譯後的實際代碼如下:

const element = React.createElement("div", {
  title: "foo"
}, "hello");

再來看看上文的 React.createElement 實際生成了一個怎麼樣的對象。

在 demo 中打印試試:

const element = <div title="foo">hello</div>
console.log(element)
const container = document.getElementById('container')
ReactDOM.render(element, container);

可以看到輸出的 element 如下:

簡化一下 element:

const element = {
    type: 'div',
    props: {
        title: 'foo',
        children: 'hello'
    }
}

簡單總結一下, React.createElement 實際上是生成了一個 element 對象,該對象擁有以下屬性:

  • type: 標籤名
  • props

    • title: 標籤屬性
    • children: 子節點

1.2 render

ReactDOM.render() 將 element 添加到 id 為 container 的 DOM 節點中,下面我們將簡單手寫一個方法代替 ReactDOM.render()

  1. 創建標籤名為 element.type 的節點;
const node = document.createElement(element.type)
  1. 設置 node 節點的 title 為 element.props.title;

    node["title"] = element.props.title
  2. 創建一個空的文本節點 text;

    const text = document.createTextNode("")
  3. 設置文本節點的 nodeValue 為 element.props.children;

    text["nodeValue"] = element.props.children
  4. 將文本節點 text 添加進 node 節點;

    node.appendChild(text)
  5. 將 node 節點添加進 container 節點

    container.appendChild(node)

本例完整源碼見: reactDemo2

運行源碼,結果如下,和引入 React 的結果一致:

三、開始

上文通過模擬 React,簡單代替了 React.createElement、ReactDOM.render 方法,接下來將真正開始實現 React 的各個功能。

1. createElement(虛擬 DOM)

上面有了解到 createElement 的作用是創建一個 element 對象,結構如下:

// 虛擬 DOM 結構
const element = {
    type: 'div', // 標籤名
    props: { // 節點屬性,包含 children
        title: 'foo', // title 屬性
        children: 'hello' // 子節點,注:實際上這裏應該是數組結構,幫助我們存儲更多子節點
    }
}

根據 element 的結構,設計了 createElement 函數,代碼如下:

/**
 * 創建虛擬 DOM 結構
 * @param {type} 標籤名
 * @param {props} 屬性對象
 * @param {children} 子節點
 * @return {element} 虛擬 DOM
 */
function createElement (type, props, ...children) {
    return {
        type,
        props: {
            ...props,
            children: children.map(child => 
                typeof child === 'object'
                ? child
                : createTextElement(child)
            )
        }
    }
}

這裏有考慮到,當 children 是非對象時,應該創建一個 textElement 元素, 代碼如下:

/**
 * 創建文本節點
 * @param {text} 文本值
 * @return {element} 虛擬 DOM
 */
function createTextElement (text) {
    return {
        type: "TEXT_ELEMENT",
        props: {
            nodeValue: text,
            children: []
        }
    }
}

接下來試一下,代碼如下:

const myReact = {
    createElement
}
const element = myReact.createElement(
  "div",
  { id: "foo" },
  myReact.createElement("a", null, "bar"),
  myReact.createElement("b")
)
console.log(element)

本例完整源碼見: reactDemo3

得到的 element 對象如下:

const element = {
    "type": "div", 
    "props": {
        "id": "foo", 
        "children": [
            {
                "type": "a", 
                "props": {
                    "children": [
                        {
                            "type": "TEXT_ELEMENT", 
                            "props": {
                                "nodeValue": "bar", 
                                "children": [ ]
                            }
                        }
                    ]
                }
            }, 
            {
                "type": "b", 
                "props": {
                    "children": [ ]
                }
            }
        ]
    }
}

JSX

實際上我們在使用 react 開發的過程中,並不會這樣創建組件:

const element = myReact.createElement(
  "div",
  { id: "foo" },
  myReact.createElement("a", null, "bar"),
  myReact.createElement("b")
)

而是通過 JSX 語法,代碼如下:

const element = (
    <div id='foo'>
        <a>bar</a>
        <b></b>
    </div>
)

在 myReact 中,可以通過添加註釋的形式,吿訴 babel 轉譯我們指定的函數,來使用 JSX 語法,代碼如下:

/** @jsx myReact.createElement */
const element = (
    <div id='foo'>
        <a>bar</a>
        <b></b>
    </div>
)

本例完整源碼見: reactDemo4

2. render

render 函數幫助我們將 element 添加至真實節點中。

將分為以下步驟實現:

  1. 創建 element.type 類型的 dom 節點,並添加至容器中;
/**
 * 將虛擬 DOM 添加至真實 DOM
 * @param {element} 虛擬 DOM
 * @param {container} 真實 DOM
 */
function render (element, container) {
    const dom = document.createElement(element.type)
    container.appendChild(dom)
}
  1. 將 element.children 都添加至 dom 節點中;
element.props.children.forEach(child => 
    render(child, dom)
)
  1. 對文本節點進行特殊處理;
const dom = element.type === 'TEXT_ELEMENT'
    ? document.createTextNode("")
    : document.createElement(element.type)
  1. 將 element 的 props 屬性添加至 dom;
const isProperty = key => key !== "children"
Object.keys(element.props)
    .filter(isProperty)
    .forEach(name => {
      dom[name] = element.props[name]
})

以上我們實現了將 JSX 渲染到真實 DOM 的功能,接下來試一下,代碼如下:

const myReact = {
    createElement,
    render
}
/** @jsx myReact.createElement */
const element = (
    <div id='foo'>
        <a>bar</a>
        <b></b>
    </div>
)

myReact.render(element, document.getElementById('container'))

本例完整源碼見: reactDemo5

結果如圖,成功輸出:

3. 可中斷渲染(requestIdleCallback)

再來看看上面寫的 render 方法中關於子節點的處理,代碼如下:

/**
 * 將虛擬 DOM 添加至真實 DOM
 * @param {element} 虛擬 DOM
 * @param {container} 真實 DOM
 */
function render (element, container) {
    // 省略
    // 遍歷所有子節點,並進行渲染
    element.props.children.forEach(child =>
        render(child, dom)
    )
    // 省略
}

這個遞歸調用是有問題的,一旦開始渲染,就會將所有節點及其子節點全部渲染完成這個進程才會結束。

當 dom tree 很大的情況下,在渲染過程中,頁面上是卡住的狀態,無法進行用户輸入等交互操作。

可分為以下步驟解決上述問題:

  1. 允許中斷渲染工作,如果有優先級更高的工作插入,則暫時中斷瀏覽器渲染,待完成該工作後,恢復瀏覽器渲染;
  2. 將渲染工作進行分解,分解成一個個小單元;

使用 requestIdleCallback 來解決允許中斷渲染工作的問題。

window.requestIdleCallback 將在瀏覽器的空閒時段內調用的函數排隊。這使開發者能夠在主事件循環上執行後台和低優先級工作,而不會影響延遲關鍵事件,如動畫和輸入響應。

window.requestIdleCallback 詳細介紹可查看文檔:文檔

代碼如下:

// 下一個工作單元
let nextUnitOfWork = null
/**
 * workLoop 工作循環函數
 * @param {deadline} 截止時間
 */
function workLoop(deadline) {
  // 是否應該停止工作循環函數
  let shouldYield = false
  
  // 如果存在下一個工作單元,且沒有優先級更高的其他工作時,循環執行
  while (nextUnitOfWork && !shouldYield) {
    nextUnitOfWork = performUnitOfWork(
      nextUnitOfWork
    )
    
    // 如果截止時間快到了,停止工作循環函數
    shouldYield = deadline.timeRemaining() < 1
  }
  
  // 通知瀏覽器,空閒時間應該執行 workLoop
  requestIdleCallback(workLoop)
}
// 通知瀏覽器,空閒時間應該執行 workLoop
requestIdleCallback(workLoop)

// 執行單元事件,並返回下一個單元事件
function performUnitOfWork(nextUnitOfWork) {
  // TODO
}

performUnitOfWork 是用來執行單元事件,並返回下一個單元事件的,具體實現將在下文介紹。

4. Fiber

上文介紹了通過 requestIdleCallback 讓瀏覽器在空閒時間渲染工作單元,避免渲染過久導致頁面卡頓的問題。

注:實際上 requestIdleCallback 功能並不穩定,不建議用於生產環境,本例僅用於模擬 React 的思路,React 本身並不是通過 requestIdleCallback 來實現讓瀏覽器在空閒時間渲染工作單元的。

另一方面,為了讓渲染工作可以分離成一個個小單元,React 設計了 fiber。

每一個 element 都是一個 fiber 結構,每一個 fiber 都是一個渲染工作單元。

所以 fiber 既是一種數據結構,也是一個工作單元。

下文將通過簡單的示例對 fiber 進行介紹。

假設需要渲染這樣一個 element 樹:

myReact.render(
  <div>
    <h1>
      <p />
      <a />
    </h1>
    <h2 />
  </div>,
  container
)

生成的 fiber tree 如圖:

橙色代表子節點,黃色代表父節點,藍色代表兄弟節點。

每個 fiber 都有一個鏈接指向它的第一個子節點、下一個兄弟節點和它的父節點。這種數據結構可以讓我們更方便的查找下一個工作單元。

上圖的箭頭也表明了 fiber 的渲染過程,渲染過程詳細描述如下:

  1. 從 root 開始,找到第一個子節點 div;
  2. 找到 div 的第一個子節點 h1;
  3. 找到 h1 的第一個子節點 p;
  4. 找 p 的第一個子節點, 如無子節點,則找下一個兄弟節點 ,找到 p 的兄弟節點 a;
  5. 找 a 的第一個子節點, 如無子節點,也無兄弟節點,則找它的父節點的下一個兄弟節點 ,找到 a 的 父節點的兄弟節點 h2;
  6. 找 h2 的第一個子節點,找不到,找兄弟節點,找不到,找父節點 div 的兄弟節點,也找不到,繼續找 div 的父節點的兄弟節點,找到 root;
  7. 第 6 步已經找到了 root 節點,渲染已全部完成。

下面將渲染過程用代碼實現。

  1. 將 render 中創建 DOM 節點的部分抽離為 creactDOM 函數;
/**
 * createDom 創建 DOM 節點
 * @param {fiber} fiber 節點
 * @return {dom} dom 節點
 */
function createDom (fiber) {
    // 如果是文本類型,創建空的文本節點,如果不是文本類型,按 type 類型創建節點
    const dom = fiber.type === 'TEXT_ELEMENT'
        ? document.createTextNode("")
        : document.createElement(fiber.type)

    // isProperty 表示不是 children 的屬性
    const isProperty = key => key !== "children"
    
    // 遍歷 props,為 dom 添加屬性
    Object.keys(fiber.props)
        .filter(isProperty)
        .forEach(name => {
            dom[name] = fiber.props[name]
        })
        
    // 返回 dom
    return dom
}
  1. 在 render 中設置第一個工作單元為 fiber 根節點;

fiber 根節點僅包含 children 屬性,值為參數 fiber。

// 下一個工作單元
let nextUnitOfWork = null
/**
 * 將 fiber 添加至真實 DOM
 * @param {element} fiber
 * @param {container} 真實 DOM
 */
function render (element, container) {
    nextUnitOfWork = {
        dom: container,
        props: {
            children: [element]
        }
    }
}
  1. 通過 requestIdleCallback 在瀏覽器空閒時,渲染 fiber;
/**
 * workLoop 工作循環函數
 * @param {deadline} 截止時間
 */
function workLoop(deadline) {
  // 是否應該停止工作循環函數
  let shouldYield = false
  
  // 如果存在下一個工作單元,且沒有優先級更高的其他工作時,循環執行
  while (nextUnitOfWork && !shouldYield) {
    nextUnitOfWork = performUnitOfWork(
      nextUnitOfWork
    )
    
    // 如果截止時間快到了,停止工作循環函數
    shouldYield = deadline.timeRemaining() < 1
  }
  
  // 通知瀏覽器,空閒時間應該執行 workLoop
  requestIdleCallback(workLoop)
}
// 通知瀏覽器,空閒時間應該執行 workLoop
requestIdleCallback(workLoop)
  1. 渲染 fiber 的函數 performUnitOfWork;
/**
 * performUnitOfWork 處理工作單元
 * @param {fiber} fiber
 * @return {nextUnitOfWork} 下一個工作單元
 */
function performUnitOfWork(fiber) {
  // TODO 添加 dom 節點
  // TODO 新建 filber
  // TODO 返回下一個工作單元(fiber)
}

4.1 添加 dom 節點

function performUnitOfWork(fiber) {
    // 如果 fiber 沒有 dom 節點,為它創建一個 dom 節點
    if (!fiber.dom) {
        fiber.dom = createDom(fiber)
    }

    // 如果 fiber 有父節點,將 fiber.dom 添加至父節點
    if (fiber.parent) {
        fiber.parent.dom.appendChild(fiber.dom)
    }
}

4.2 新建 filber

function performUnitOfWork(fiber) {
    // ~~省略~~
    // 子節點
    const elements = fiber.props.children
    // 索引
    let index = 0
    // 上一個兄弟節點
    let prevSibling = null
    // 遍歷子節點
    while (index < elements.length) {
        const element = elements[index]

        // 創建 fiber
        const newFiber = {
            type: element.type,
            props: element.props,
            parent: fiber,
            dom: null,
        }

        // 將第一個子節點設置為 fiber 的子節點
        if (index === 0) {
            fiber.child = newFiber
        } else if (element) {
        // 第一個之外的子節點設置為該節點的兄弟節點
            prevSibling.sibling = newFiber
        }

        prevSibling = newFiber
        index++
    }
}

4.3 返回下一個工作單元(fiber)

function performUnitOfWork(fiber) {
    // ~~省略~~
    // 如果有子節點,返回子節點
    if (fiber.child) {
        return fiber.child
    }
    let nextFiber = fiber
    while (nextFiber) {
        // 如果有兄弟節點,返回兄弟節點
        if (nextFiber.sibling) {
            return nextFiber.sibling
        }

        // 否則繼續走 while 循環,直到找到 root。
        nextFiber = nextFiber.parent
    }
}

以上我們實現了將 fiber 渲染到頁面的功能,且渲染過程是可中斷的。

現在試一下,代碼如下:

const element = (
    <div>
        <h1>
        <p />
        <a />
        </h1>
        <h2 />
    </div>
)

myReact.render(element, document.getElementById('container'))

本例完整源碼見: reactDemo7

如預期輸出 dom,如圖:

5. 渲染提交階段

由於渲染過程被我們做了可中斷的,那麼中斷的時候,我們肯定不希望瀏覽器給用户展示的是渲染了一半的 UI。

對渲染提交階段優化的處理如下:

  1. 把 performUnitOfWork 中關於把子節點添加至父節點的邏輯刪除;
function performUnitOfWork(fiber) {
    // 把這段刪了
    if (fiber.parent) {
       fiber.parent.dom.appendChild(fiber.dom)
    }
}
  1. 新增一個根節點變量,存儲 fiber 根節點;
// 根節點
let wipRoot = null
function render (element, container) {
    wipRoot = {
        dom: container,
        props: {
            children: [element]
        }
    }
    // 下一個工作單元是根節點
    nextUnitOfWork = wipRoot
}
  1. 當所有 fiber 都工作完成時,nextUnitOfWork 為 undefined,這時再渲染真實 DOM;
function workLoop (deadline) {
    // 省略
    if (!nextUnitOfWork && wipRoot) {
        commitRoot()
    }
    // 省略
}
  1. 新增 commitRoot 函數,執行渲染真實 DOM 操作,遞歸將 fiber tree 渲染為真實 DOM;
// 全部工作單元完成後,將 fiber tree 渲染為真實 DOM;
function commitRoot () {
    commitWork(wipRoot.child)
    // 需要設置為 null,否則 workLoop 在瀏覽器空閒時不斷的執行。
    wipRoot = null
}
/**
 * performUnitOfWork 處理工作單元
 * @param {fiber} fiber
 */
function commitWork (fiber) {
    if (!fiber) return
    const domParent = fiber.parent.dom
    domParent.appendChild(fiber.dom)
    // 渲染子節點
    commitWork(fiber.child)
    // 渲染兄弟節點
    commitWork(fiber.sibling)
}

本例完整源碼見: reactDemo8

源碼運行結果如圖:

6. 協調(diff 算法)

當 element 有更新時,需要將更新前的 fiber tree 和更新後的 fiber tree 進行比較,得到比較結果後,僅對有變化的 fiber 對應的 dom 節點進行更新。

通過協調,減少對真實 DOM 的操作次數。

1. currentRoot

新增 currentRoot 變量,保存根節點更新前的 fiber tree,為 fiber 新增 alternate 屬性,保存 fiber 更新前的 fiber tree;

let currentRoot = null
function render (element, container) {
    wipRoot = {
        // 省略
        alternate: currentRoot
    }
}
function commitRoot () {
    commitWork(wipRoot.child)
    currentRoot = wipRoot
    wipRoot = null
}

2. performUnitOfWork

將 performUnitOfWork 中關於新建 fiber 的邏輯,抽離到 reconcileChildren 函數;

/**
 * 協調子節點
 * @param {fiber} fiber
 * @param {elements} fiber 的 子節點
 */
function reconcileChildren (fiber, elements) {
    // 用於統計子節點的索引值
    let index = 0
    // 上一個兄弟節點
    let prevSibling = null

    // 遍歷子節點
    while (index < elements.length) {
        const element = elements[index]

        // 新建 fiber
        const newFiber = {
            type: element.type,
            props: element.props,
            parent: fiber,
            dom: null,
        }

        // fiber的第一個子節點是它的子節點
        if (index === 0) {
            fiber.child = newFiber
        } else if (element) {
        // fiber 的其他子節點,是它第一個子節點的兄弟節點
            prevSibling.sibling = newFiber
        }

        // 把新建的 newFiber 賦值給 prevSibling,這樣就方便為 newFiber 添加兄弟節點了
        prevSibling = newFiber
        
        // 索引值 + 1
        index++
    }
}

3. reconcileChildren

在 reconcileChildren 中對比新舊 fiber;

3.1 當新舊 fiber 類型相同時

保留 dom,僅更新 props,設置 effectTag 為 UPDATE;

function reconcileChildren (wipFiber, elements) {
    // ~~省略~~
    // oldFiber 可以在 wipFiber.alternate 中找到
    let oldFiber = wipFiber.alternate && wipFiber.alternate.child

    while (index < elements.length || oldFiber != null) {
        const element = elements[index]
        let newFiber = null

        // fiber 類型是否相同
        const sameType =
            oldFiber &&
            element &&
            element.type == oldFiber.type

        // 如果類型相同,僅更新 props
        if (sameType) {
            newFiber = {
                type: oldFiber.type,
                props: element.props,
                dom: oldFiber.dom,
                parent: wipFiber,
                alternate: oldFiber,
                effectTag: "UPDATE",
            }
        }
        // ~~省略~~
    }
    // ~~省略~~
}

3.2 當新舊 fiber 類型不同,且有新元素時

創建一個新的 dom 節點,設置 effectTag 為 PLACEMENT;

function reconcileChildren (wipFiber, elements) {
    // ~~省略~~
    if (element && !sameType) {
        newFiber = {
            type: element.type,
            props: element.props,
            dom: null,
            parent: wipFiber,
            alternate: null,
            effectTag: "PLACEMENT",
        }
    }
    // ~~省略~~
}

3.3 當新舊 fiber 類型不同,且有舊 fiber 時

刪除舊 fiber,設置 effectTag 為 DELETION;

function reconcileChildren (wipFiber, elements) {
    // ~~省略~~
    if (oldFiber && !sameType) {
        oldFiber.effectTag = "DELETION"
        deletions.push(oldFiber)
    }
    // ~~省略~~
}

4. deletions

新建 deletions 數組存儲需刪除的 fiber 節點,渲染 DOM 時,遍歷 deletions 刪除舊 fiber;

let deletions = null
function render (element, container) {
    // 省略
    // render 時,初始化 deletions 數組
    deletions = []
}

// 渲染 DOM 時,遍歷 deletions 刪除舊 fiber
function commitRoot () {
    deletions.forEach(commitWork)
}

5. commitWork

在 commitWork 中對 fiber 的 effectTag 進行判斷,並分別處理。

5.1 PLACEMENT

當 fiber 的 effectTag 為 PLACEMENT 時,表示是新增 fiber,將該節點新增至父節點中。

if (
    fiber.effectTag === "PLACEMENT" &&
    fiber.dom != null
) {
    domParent.appendChild(fiber.dom)
}

5.2 DELETION

當 fiber 的 effectTag 為 DELETION 時,表示是刪除 fiber,將父節點的該節點刪除。

else if (fiber.effectTag === "DELETION") {
    domParent.removeChild(fiber.dom)
}

5.3 UPDATE

當 fiber 的 effectTag 為 UPDATE 時,表示是更新 fiber,更新 props 屬性。

else if (fiber.effectTag === 'UPDATE' && fiber.dom != null) {
    updateDom(fiber.dom, fiber.alternate.props, fiber.props)
}

updateDom 函數根據不同的更新類型,對 props 屬性進行更新。

const isProperty = key => key !== "children"

// 是否是新屬性
const isNew = (prev, next) => key => prev[key] !== next[key]

// 是否是舊屬性
const isGone = (prev, next) => key => !(key in next)

function updateDom(dom, prevProps, nextProps) {
    // 刪除舊屬性
    Object.keys(prevProps)
        .filter(isProperty)
        .filter(isGone(prevProps, nextProps))
        .forEach(name => {
            dom[name] = ""
        })

    // 更新新屬性
    Object.keys(nextProps)
        .filter(isProperty)
        .filter(isNew(prevProps, nextProps))
        .forEach(name => {
            dom[name] = nextProps[name]
        })
}

另外,為 updateDom 添加事件屬性的更新、刪除,便於追蹤 fiber 事件的更新。

function updateDom(dom, prevProps, nextProps) {
    // ~~省略~~
    const isEvent = key => key.startsWith("on")
    //刪除舊的或者有變化的事件
    Object.keys(prevProps)
        .filter(isEvent)
        .filter(
          key =>
            !(key in nextProps) ||
            isNew(prevProps, nextProps)(key)
        )
        .forEach(name => {
          const eventType = name
            .toLowerCase()
            .substring(2)
          dom.removeEventListener(
            eventType,
            prevProps[name]
          )
        })

    // 註冊新事件
    Object.keys(nextProps)
        .filter(isEvent)
        .filter(isNew(prevProps, nextProps))
        .forEach(name => {
        const eventType = name
            .toLowerCase()
            .substring(2)
        dom.addEventListener(
            eventType,
            nextProps[name]
        )
    })
    // ~~省略~~
}

替換 creactDOM 中設置 props 的邏輯。

function createDom (fiber) {
    const dom = fiber.type === 'TEXT_ELEMENT'
        ? document.createTextNode("")
        : document.createElement(fiber.type)
    // 看這裏鴨
    updateDom(dom, {}, fiber.props)
    return dom
}

新建一個包含輸入表單項的例子,嘗試更新 element,代碼如下:

/** @jsx myReact.createElement */
const container = document.getElementById("container")

const updateValue = e => {
    rerender(e.target.value)
}

const rerender = value => {
    const element = (
        <div>
            <input onInput={updateValue} value={value} />
            <h2>Hello {value}</h2>
        </div>
    )
    myReact.render(element, container)
}

rerender("World")

本例完整源碼見: reactDemo9

輸出結果如圖:

7. 函數式組件

先來看一個簡單的函數式組件示例:

myReact 還不支持函數式組件,下面代碼運行會報錯,這裏僅用於比照函數式組件的常規使用方式。

/** @jsx myReact.createElement */
const container = document.getElementById("container")

function App (props) {
    return (
        <h1>hi~ {props.name}</h1>
    )
}

const element = (
    <App name='foo' />
)

myReact.render(element, container)

函數式組件和 html 標籤組件相比,有以下兩點不同:

  • 函數組件的 fiber 沒有 dom 節點;
  • 函數組件的 children 需要運行函數後得到;

通過下列步驟實現函數組件:

  1. 修改 performUnitOfWork,根據 fiber 類型,執行 fiber 工作單元;
function performUnitOfWork(fiber) {
    // 是否是函數類型組件
    const isFunctionComponent = fiber && fiber.type && fiber.type instanceof Function
    // 如果是函數組件,執行 updateFunctionComponent 函數
    if (isFunctionComponent) {
        updateFunctionComponent(fiber)
    } else {
    // 如果不是函數組件,執行 updateHostComponent 函數
        updateHostComponent(fiber)
    }
    // 省略
}
  1. 定義 updateHostComponent 函數,執行非函數組件;

非函數式組件可直接將 fiber.props.children 作為參數傳遞。

function updateHostComponent(fiber) {
    if (!fiber.dom) {
        fiber.dom = createDom(fiber)
    }
    reconcileChildren(fiber, fiber.props.children)
}
  1. 定義 updateFunctionComponent 函數,執行函數組件;

函數組件需要運行來獲得 fiber.children。

function updateFunctionComponent(fiber) {
    // fiber.type 就是函數組件本身,fiber.props 就是函數組件的參數
    const children = [fiber.type(fiber.props)]
    reconcileChildren(fiber, children)
}
  1. 修改 commitWork 函數,兼容沒有 dom 節點的 fiber;

4.1 修改 domParent 的獲取邏輯,通過 while 循環不斷向上尋找,直到找到有 dom 節點的父 fiber;

function commitWork (fiber) {
    // 省略
    let domParentFiber = fiber.parent
    // 如果 fiber.parent 沒有 dom 節點,則繼續找 fiber.parent.parent.dom,直到有 dom 節點。
    while (!domParentFiber.dom) {
        domParentFiber = domParentFiber.parent
    }
    const domParent = domParentFiber.dom
    // 省略
}

4.2 修改刪除節點的邏輯,當刪除節點時,需要不斷向下尋找,直到找到有 dom 節點的子 fiber;

function commitWork (fiber) {
    // 省略
    // 如果 fiber 的更新類型是刪除,執行 commitDeletion
     else if (fiber.effectTag === "DELETION") {
        commitDeletion(fiber.dom, domParent)
    }
    // 省略
}

// 刪除節點
function commitDeletion (fiber, domParent) {
    // 如果該 fiber 有 dom 節點,直接刪除
    if (fiber.dom) {
        domParent.removeChild(fiber.dom)
    } else {
    // 如果該 fiber 沒有 dom 節點,則繼續找它的子節點進行刪除
        commitDeletion(fiber.child, domParent)
    }
}

下面試一下上面的例子,代碼如下:

/** @jsx myReact.createElement */
const container = document.getElementById("container")

function App (props) {
    return (
        <h1>hi~ {props.name}</h1>
    )
}

const element = (
    <App name='foo' />
)

myReact.render(element, container)

本例完整源碼見: reactDemo10

運行結果如圖:

8. hooks

下面繼續為 myReact 添加管理狀態的功能,期望是函數組件擁有自己的狀態,且可以獲取、更新狀態。

一個擁有計數功能的函數組件如下:

function Counter() {
    const [state, setState] = myReact.useState(1)
    return (
        <h1 onClick={() => setState(c => c + 1)}>
        Count: {state}
        </h1>
    )
}
const element = <Counter />

已知需要一個 useState 方法用來獲取、更新狀態。

這裏再重申一下, 渲染函數組件的前提是,執行該函數組件 ,因此,上述 Counter 想要更新計數,就會在每次更新都執行一次 Counter 函數。

通過以下步驟實現:

  1. 新增全局變量 wipFiber;
// 當前工作單元 fiber
let wipFiber = null
function updateFunctionComponent(fiber) {
    wipFiber = fiber
    // 當前工作單元 fiber 的 hook
    wipFiber.hook = []
    // 省略
}
  1. 新增 useState 函數;
// initial 表示初始參數,在本例中,initial=1
function useState (initial) {
    // 是否有舊鈎子,舊鈎子存儲了上一次更新的 hook
    const oldHook =
        wipFiber.alternate &&
        wipFiber.alternate.hook

    // 初始化鈎子,鈎子的狀態是舊鈎子的狀態或者初始狀態
    const hook = {
        state: oldHook ? oldHook.state : initial,
        queue: [],
    }

    // 從舊的鈎子隊列中獲取所有動作,然後將它們一一應用到新的鈎子狀態
    const actions = oldHook ? oldHook.queue : []
    actions.forEach(action => {
        hook.state = action(hook.state)
    })

    // 設置鈎子狀態
    const setState = action => {
        // 將動作添加至鈎子隊列
        hook.queue.push(action)
        // 更新渲染
        wipRoot = {
            dom: currentRoot.dom,
            props: currentRoot.props,
            alternate: currentRoot,
        }
        nextUnitOfWork = wipRoot
        deletions = []
    }

    // 把鈎子添加至工作單元
    wipFiber.hook = hook
    
    // 返回鈎子的狀態和設置鈎子的函數
    return [hook.state, setState]
}

下面運行一下計數組件,代碼如下:

function Counter() {
    const [state, setState] = myReact.useState(1)
    return (
        <h1 onClick={() => setState(c => c + 1)}>
        Count: {state}
        </h1>
    )
}
const element = <Counter />

本例完整源碼見: reactDemo11

運行結果如圖:

本章節簡單實現了 myReact 的 hooks 功能。

撒花完結,react 還有很多實現值得我們去學習和研究,希望有下期,和大家一起手寫 react 的更多功能。

總結

本文參考pomb.us 進行學習,實現了包括虛擬 DOM、Fiber、Diff 算法、函數式組件、hooks 等功能的自定義 React。

在實現過程中小編對 React 的基本術語及實現思路有了大概的掌握,pomb.us 是非常適合初學者的學習資料,可以直接通過pomb.us 進行學習,也推薦跟着本文一步步實現 React 的常見功能。

本文源碼: github源碼

建議跟着一步步敲,進行實操練習。

希望能對你有所幫助,感謝閲讀~

別忘了點個贊鼓勵一下我哦,筆芯:heart:

參考資料

或者關注凹凸實驗室公眾號(AOTULabs),不定時推送文章。