[實踐向] 從小白視角實現React的Fiber架構

寫在前邊

  • 創做本篇博客的初衷是,在瀏覽社區時發現了pomb.us/build-your-…這篇寶藏文章,該博主基於react16以後的fiber架構實現了一套react的簡易版本,很是有助於理解react工做原理。可是苦於只有英文版本,且偏向理論。html

  • 本着提高自我、貢獻社區的理念。在此記錄下學習歷程,並盡本身微薄之力對重點部分(結合本身理解)進行翻譯整理。但願對你們有所幫助。node

零、準備工做

  1. 建立項目(本身命名),下載文件包react

    $ mkdir xxx
    $ cd xxx
    $ yarn init -y / npm init -y
    $ yarn add react react-dom
    複製代碼
  2. 創建以下目錄結構npm

    - src/
     - myReact/
      - index.js
     - index.html
     - main.jsx
    複製代碼
  3. 初始化文件內容json

    //index.html
    <!DOCTYPE html>
    <html lang="en"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>React App</title> </head> <body> <div id="root"></div> <script src="main.jsx"></script> </body> </html>
    
    
    // main.jsx
    import React from "react";
    import ReactDom from "react-dom";
    const App = () => {
        return <div title="oliver">Hello</div>;
    };
    ReactDom.render(<App />, document.getElementById("root"));
    
    // myReact/index.js
    export default {}
    複製代碼
  4. 安裝 parcel 用於打包和熱更新數組

    $ yarn add parcel-bundler
    複製代碼

1、createElement的功能

功不可沒的babel

// main.jsx
const element = (
  <div id="foo"> <a>Hello</a> <span /> </div>
)
複製代碼

通過babel轉譯後的效果(使用plugin-transform-react-jsx插件,www.babeljs.cn/docs/babel-…):瀏覽器

const element = React.createElement(
  "div",	//type
  { id: "foo" },	//config
  React.createElement("a", null, "bar"),	//...children
  React.createElement("span")
)
複製代碼
  • babel的 plugin-transform-react-jsx 作的事情很簡單: 使用 React.createElement 函數來從處理.jsx文件中的jsx語法。
  • 這也就是爲何在.jsx文件中必須 import React from "react" 的緣由啦,不然插件會找不到React對象的!

配置babel

tips:筆者原本也打算使用 plugin-transform-react-jsx 插件,可是在調試中遇到了問題。查找後才知道最新版本的插件已經再也不是由 <h1>Hello World</h1>React.createElement('h1', null, 'Hello world') 的簡單轉換了(具體見zh-hans.reactjs.org/blog/2020/0…),故退而求其次選擇了功能相似的 transform-jsxbash

$ touch .babelrc
$ yarn add babel@transform-jsx
複製代碼
// .babelrc
{
    "presets": ["es2015"],
     "plugins": [
    [
      "transform-jsx",
      {
        "function": "React.createElement",
        "useVariables": true
      }
    ]
  ]
}
複製代碼
$ parcel src/index.html
複製代碼

此時頁面中能夠看到Hello字樣,說明咱們配置成功了!babel

動手實現createElement

transform-jsx 插件會將參數封裝在一個對象中,傳入createElement。markdown

// myReact/index.js
export function createElement(args) {
  const { elementName, attributes, children } = args;
  return {
    type:elementName,
    props: {
      ...attributes,
      children
    }
  };
}
複製代碼

考慮到children中還可能包含基本類型如string,number。爲了簡化操做咱們將這樣的children統一使用 TEXT_ELEMENT 包裹。

// myReact/index.js
export function createElement(type, config, ...children) {
  return {
    type,
    props: {
      ...attributes,
      children: children.map((child) =>
                typeof child === "object" ? child : createTextElement(child)
            ),
    }
  };
}
function createTextElement(text) {
  return {
    type: "TEXT_ELEMENT",
    props: {
      nodeValue: text,
      children: [],
    },
  }
}
export default { createElement }
複製代碼

React並不會像此處這樣處理基本類型節點,但咱們這裏這樣作:由於這樣能夠簡化咱們的代碼。畢竟這是一篇以功能而非細節爲主的文章。

看看效果

首先爲咱們本身的庫起個名字吧!

// .babelrc
{
    "presets": ["es2015"],
     "plugins": [
    [
      "transform-jsx",
      {
        "function": "OllyReact.createElement",
        "useVariables": true
      }
    ]
  ]
}
複製代碼

引入時就使用本身寫的名字吧!

// main.jsx
import OllyReact from "./myReact/index";
import ReactDom from "react-dom"
const element = (
    <div style="background: salmon"> <h1>Hello World</h1> <h2 style="text-align:right">—Oliver</h2> </div>
);
ReactDom.render(element, document.getElementById("root"));
複製代碼

此時頁面上已經出現了Hello , 這證實咱們的React.createElement已經基本實現了React的功能。

2、Render功能

接下來編寫render函數。

目前咱們只關注向DOM中添加內容。修改和刪除功能將在後續添加。

// React/index.js
export function render(element, container) {}
export default {
  //...省略
  render
};
複製代碼

細節實現

注意:

本小節每一步內容主要參考思路便可,詳細的邏輯順序會在底部彙總。

  • 首先使用對應的元素類型建立新DOM節點,並把該DOM節點加入股container中

    const dom = document.createElement(element.type)
    container.appendChild(dom)
    複製代碼
  • 而後遞歸地爲每一個child JSX元素執行相同的操做

    element.props.children.forEach(child =>
        render(child, dom)
      )
    複製代碼
  • 考慮到TEXT節點須要特殊處理

    const dom =
        element.type == "TEXT_ELEMENT"
          ? document.createTextNode("")
          : document.createElement(element.type)
    複製代碼
  • 最後將元素的props分配給真實DOM節點

    Object.keys(element.props)
            .filter(key => key !== "children")	// children屬性要除去。
            .forEach(name => {
              dom[name] = element.props[name];
            });
    複製代碼

彙總:

export function render(element, container) {
  const dom = element.type === "TEXT_ELEMENT"
    ? document.createTextNode("")
    : document.createElement(element.type);
  Object.keys(element.props)
        .filter(key => key !== "children")
        .forEach(name => {
          dom[name] = element.props[name];
        });
  element.props.children.forEach(child =>
    render(child, dom)
  );
  container.appendChild(dom);
}
複製代碼

看看效果

// main.jsx
import OllyReact from "./myReact/index";
const element = (
    <div style="background: salmon"> <h1>Hello World</h1> <h2 style="text-align:right">—Oliver</h2> </div>
);
OllyReact.render(element, document.getElementById("root"));
複製代碼

此時看到咱們的render函數也能夠正常工做了!

小結

就是這樣!如今,咱們有了一個能夠將JSX呈現到DOM的庫(雖然它只支持原生DOM標籤且不支持更新 QAQ)。

3、concurrent mode 併發模式

實際上,以上的遞歸調用是存在問題的。

  1. 這樣的調用方式,一旦開始渲染,就不會中止,直到咱們渲染了完整的元素樹。若是元素樹很大,則可能會阻塞主線程太長時間。
  2. 即便瀏覽器須要執行諸如處理用戶輸入等高優先級的工做,也必須等待渲染完成。

所以React16的concurrent模式實現了一種異步可中斷的工做方式。它將把工做分解成幾個小單元,完成每一個單元后,若是須要執行其餘任何操做,則讓瀏覽器中斷渲染。

workLoop

let nextUnitOfWork = null

function workLoop(deadline) {
  let shouldYield = false
  while (nextUnitOfWork && !shouldYield) {
    nextUnitOfWork = performUnitOfWork(
      nextUnitOfWork
    )
    shouldYield = deadline.timeRemaining() < 1
  }
  requestIdleCallback(workLoop)
}

requestIdleCallback(workLoop)

function performUnitOfWork(nextUnitOfWork) {
  // todo
}
複製代碼
  • 咱們用 requestIdleCallback 來作一個循環。能夠將其requestIdleCallback視爲一種異步任務,瀏覽器將在主線程空閒時運行回調,而不是告訴咱們什麼時候運行。
  • requestIdleCallback還爲咱們提供了截止日期參數。咱們可使用它來檢查瀏覽器須要再次控制以前有多少時間。
  • 要開始使用循環,咱們須要設置第一個工做單元,而後編寫一個performUnitOfWork 函數。要求它不只執行當前工做單元,而且要返回下一個工做單元。

4、Fiber

爲了組織工做單元的結構,咱們須要一棵 Fiber 樹。

Fiber的功能

  1. 靜態數據結構(虛擬dom)
  2. 做爲架構:鏈接父、子、兄弟節點
  3. 做爲工做單元

Fiber Tree組織形式

  • 在render中建立一個 rootFiber 節點,並將它做爲第一個 nextUnitOfWork(a instance of Fiber) 傳入
  • performUnitOfWork 接受 nextUnitOfWork 做爲參數並作三件事:
    1. 將對應的fiber節點添加到DOM
    2. 建立該fiber節點的子fiber節點
    3. 選中下個工做單元

這樣的數據結構的目的就在於更方便地找到下個工做單元:

  1. 當前Fiber的工做執行完畢後,若是 fiber.child!==null ,則 fiber.child 節點將是下一個工做單元。
  2. 當前Fiber沒有子節點,則 fiber.sibling!==null 的狀況下, fiber.sibling 節點將是下一個工做單元。
  3. 當前Fiber節點 fiber.child===null && fiber.sibiling===null的狀況下,fiber.parent 節點的 sibling 節點將是下一個工做單元。
  4. 回到rootFiber證實完成了render工做。

重構代碼

// 將render方法中建立DOM元素的邏輯抽離出來
function createDom(fiber) {
  const dom =
    fiber.type == "TEXT_ELEMENT"
      ? document.createTextNode("")
      : document.createElement(fiber.type)
  const isProperty = key => key !== "children"
  Object.keys(fiber.props)
    .filter(isProperty)
    .forEach(name => {
      dom[name] = fiber.props[name]
    })
  return dom
}

// 在render節點中初始化rootFiber根節點
export function render(element, container) {
    nextUnitOfWork = {  //rootFiber
    dom: container,
    props: {
      children: [element]
    },
  }
}

function workLoop() {...}
function performUnitOfWork(){
    //todo
}
requestIdleCallback(workLoop)
複製代碼

改造完成後而後,當瀏覽器準備就緒時,它將調用咱們workLoop,咱們將開始在根目錄上工做。

performUnitOfWork

功能1
function performUnitOfWork() {
  //******** 功能1:建立dom ********
  if (!fiber.dom) {  //爲fiber節點綁定dom
    fiber.dom = createDom(fiber);
  }
  if (fiber.parent) {   //若存在父節點,則掛載到父節點下
    fiber.parent.dom.appendChild(fiber.dom);
  }
}
複製代碼
功能2
function performUnitOfWork() {
  ...
  //******** 功能2:爲jsx元素的children建立fiber節點並鏈接 ********
  const elements = fiber.props.children;
  let index = 0;
  let prevSibling = null;

  while (index < elements.length) {
    const element = elements[index];
    const newFiber = {
      type: element.type,
      props: element.props,
      parent: fiber,
      dom: null,
    };
    if (index === 0) {  //第一個子fiber爲children
      fiber.child = newFiber;
    } else {  //其餘子fiber依次用sibling做鏈接
      prevSibling.sibling = newFiber;
    }

    prevSibling = newFiber;
    index++;
  }
}
複製代碼
功能3
function performUnitOfWork() {
  ...
  //******** 功能3:返回下一個工做單元 ********
  if (fiber.child) return fiber.child;  //子節點存在,則返回子節點
  let nextFiber = fiber;
  while (nextFiber) {   //子節點不存在則查找兄弟節點 or 父節點的兄弟節點
    if (nextFiber.sibling) {
      return nextFiber.sibling;
    }
    nextFiber = nextFiber.parent;
  }
}
複製代碼

5、render階段 & commit階段

這裏咱們還有一個問題。

因爲每次在處理fiber時,都會建立DOM並插入一個新節點。而且fiber架構下的渲染是可打斷的。這就形成了用戶有可能看到不完整的UI。這不是咱們想要的。

所以咱們須要刪除插入dom的操做。

function performUnitOfWork(fiber) {
  if (!fiber.dom) {
    fiber.dom = createDom(fiber)
  }
  // if (fiber.parent) {
  // fiber.parent.dom.appendChild(fiber.dom)
  // }
  const elements = fiber.props.children
}
複製代碼

相反地,咱們追蹤 Fiber Tree 的根節點,稱之爲wipRoot

function render(element, container) {
  wipRoot = {
    dom: container,
    props: {
      children: [element],
    },
  }
  nextUnitOfWork = wipRoot
}
複製代碼

workLoop 完成後(不存在 nextUnitOfWork ),則使用 commitRootrenderer 提交整棵 Fiber 樹。

function workLoop() {
    ...
    if (!nextUnitOfWork && wipRoot) {
    	commitRoot()
  	}
    ...
}
複製代碼

使用commitWork來處理每個工做單元

function commitRoot() {
  commitWork(wipRoot.child)
  wipRoot = null
}

function commitWork(fiber) {
  if (!fiber) {
    return
  }
  const domParent = fiber.parent.dom
  domParent.appendChild(fiber.dom)
  commitWork(fiber.child)
  commitWork(fiber.sibling)
}
複製代碼

6、Reconcilation 協調

到如今爲止咱們只實現了添加DOM,那麼如何更新或刪除呢?

這就是咱們如今要作的:對比在render函數中接收的Fiber樹與上一次提交的Fiber樹的差別。

currentRoot

因此咱們須要一個指針,指向上一次的Fiber樹,不如稱之爲 currentRoot

let currentRoot = null
function commitRoot() {
  commitWork(wipRoot.child)
  currentRoot = wipRoot
  wipRoot = null
}
複製代碼

alternate

在每一個fiber節點數上,增長一個alternate屬性,指向舊的fiber節點。

function render(element, container) {
  wipRoot = {
    dom: container,
    props: {
      children: [element],
    },
    alternate: currentRoot,
  }
  nextUnitOfWork = wipRoot
}
複製代碼

reconcileChildren

從performUnitOfWork中提取建立 Fiber 節點的代碼,抽離成 reconcileChildren 方法。

在此方法中,咱們將新jsx元素與舊Fiber節點進行 diff

function reconcileChildren(fiber, elements) {
  let index = 0;
  let prevSibling = null;

  while (index < elements.length) {
    const element = elements[index];
    const newFiber = {
      type: element.type,
      props: element.props,
      parent: fiber,
      dom: null,
    };
    if (index === 0) {  //第一個子fiber爲children
      fiber.child = newFiber;
    } else {  //其餘子fiber依次用sibling做鏈接
      prevSibling.sibling = newFiber;
    }

    prevSibling = newFiber;
    index++;
  }
}
複製代碼

接下來是diff的詳細過程,這裏再也不贅述。

7、函數組件支持

目標:

import OllyReact from "./myReact/index";

const App = () => {
  const element = (
    <div style="background: salmon"> <h1>Hello World</h1> <h2 style="text-align:right">—Oliver</h2> </div>
  );
  return element;
};
OllyReact.render(<App/>, document.getElementById("root"));
複製代碼

函數組件與原生組件的主要區別:

  1. Fiber 節點上 Fiber.dom 爲null
  2. children 須要執行函數組件才能獲得,而不是直接從props裏獲取

函數組件的特殊處理

function performUnitOfWork() {
      const isFunctionComponent =
      fiber.type instanceof Function
      if (isFunctionComponent) {
        updateFunctionComponent(fiber)
      } else {
        updateHostComponent(fiber)
      }
      ...
}
    
function updateFunctionComponent(fiber) {
  const children = [fiber.type(fiber.props)];	// 經過執行函數組件,得到jsx元素
  reconcileChildren(fiber, children);
}
    
function updateHostComponent(fiber) {
  if (!fiber.dom) {
    fiber.dom = createDom(fiber);
  }
  reconcileChildren(fiber, fiber.props.children);
}
    
function commitWork() {
    ...
    let domParentFiber = fiber.parent;  //向上遍歷,直到找到帶有fiber.dom的父Fiber
    while (!domParentFiber.dom) {
      domParentFiber = domParentFiber.parent;
    }
    const domParent = domParentFiber.dom
}
    
function commitDeletion(fiber, domParent) { //在刪除節點時,咱們還須要繼續操做,直到找到帶有DOM節點的子節點爲止。
  if (fiber.dom) {
    domParent.removeChild(fiber.dom);
  } else {
    commitDeletion(fiber.child, domParent);
  }
}
複製代碼

8、Hooks

經典的計數器

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

爲Hook增長一些輔助變量吧

let wipFiber = null		//當前workInProgress Fiber節點
let hookIndex = null	//hooks下標

function updateFunctionComponent(fiber) {
  wipFiber = fiber
  hookIndex = 0
  wipFiber.hooks = []	//爲每一個fiber節點單獨維護一個hooks數組
  const children = [fiber.type(fiber.props)]
  reconcileChildren(fiber, children)
}
複製代碼

編寫useState

function useState(initial) {
  const oldHook =
    wipFiber.alternate &&
    wipFiber.alternate.hooks &&
    wipFiber.alternate.hooks[hookIndex]
  
  const hook = {
    state: oldHook ? oldHook.state : initial,	//存在舊值則使用舊值,不然使用初始值。
    queue: []
  }
  
  const actions = oldHook ? oldHook.queue : []
  actions.forEach(action => {	//遍歷舊hooks.queue中的每一個action,依次執行
    hook.state = action(hook.state)
  })
  
  const setState = action => {
    hook.queue.push(action)
    wipRoot = {	// 切換fiber tree
      dom: currentRoot.dom,
      props: currentRoot.props,
      alternate: currentRoot,
    }
    nextUnitOfWork = wipRoot	//從新設定nextUnitOfWork,觸發更新。
    deletions = []
  }
  wipFiber.hooks.push(hook)	//向hooks中push進當前的useState調用
  hookIndex++	// hooks數組下標 +1 , 指針後移
  return [hook.state, setState]
}
複製代碼

從本小節,咱們能夠獲得一些關於hooks的啓發。

  1. 爲何hooks不能寫在 if 中?

    • 在本例中:由於每個hook都按照調用順序被維護在fiber節點上的hooks數組中。若某個hooks在 if 語句中,則可能會打亂數組應有的順序。這樣會致使hook的對應出錯。

    • 在react中:使用next指針將hook串聯起來,這種狀況下一樣是不能容忍順序的打亂的。

      type Hooks = { 
          memoizedState: any, // 指向當前渲染節點 Fiber 
          baseState: any, // 初始化 initialState, 已經每次 dispatch 以後 newState 
          baseUpdate: Update<any> | null,// 當前須要更新的 Update ,每次更新完以後,會賦值上一個 update,方便 react 在渲染錯誤的邊緣,數據回溯 
          queue: UpdateQueue<any> | null,// UpdateQueue 經過 
          next: Hook | null, // link 到下一個 hooks,經過 next 串聯每一 hooks 
      }
      複製代碼
  2. capture Value特性

    • capture Value沒什麼特別的。它只是個閉包。

    • 每一次觸發rerender,都是去從新執行了函數組件。則上次執行過的函數組件的詞法環境應當被回收。可是因爲useEffect等hooks中保存了該詞法環境中的引用,造成了閉包,因此詞法環境仍然會存在一段時間。

相關文章
相關標籤/搜索