⚛️ Re: 從零開始的 React 再造之旅

React 是目前最流行的前端框架,不少讀者用 React 很溜,但想要深刻學習 React 的原理就會被官方源碼倉庫浩瀚如煙的代碼繞的暈頭轉向。今天咱們經過不依賴任何第三方庫的方式,拋棄邊界處理、性能優化、安全性等弱相關代碼手寫一個基礎版的 React,供你們學習和理解 React 的核心原理。javascript

注意:掘金不支持代碼片斷高亮和內嵌 CodeSandbox,推薦閱讀博客原文以得到最佳閱讀體驗。html

feynman

本文實現的是包含現代 React 最新特性 HooksConcurrent Mode 的版本,傳統 class 組件的方式稍有不一樣,不影響理解核心原理。本文函數、變量等標識符命名都和官方儘可能貼近,方便之後深刻官方源碼。前端

建議桌面端瀏覽本文,而且跟着文章手動敲一遍代碼加深理解。java

目錄總覽node

  • 0: 從一次最簡單的 React 渲染提及
  • I: 實現 createElement 函數
  • II: 實現 render 函數
  • III: 併發模式 / Concurrent Mode
  • IV: Fibers 數據結構
  • V: render 和 commit 階段
  • VI: 更新和刪除節點/Reconciliation
  • VII: 函數組件
  • VIII: 函數組件 Hooks

0: 從一次最簡單的 React 渲染提及

const element = <h1 title="hello">Hello World!</h1>;
const container = document.getElementById("root");
ReactDOM.render(element, container);
複製代碼

上面這三行代碼是一個再簡單不過的 React 應用:在 root 根結點上渲染一個 Hello World! h1 節點。react

第一步的目標是用原生 DOM 方式替換 React 代碼git

JSX

熟悉 React 的讀者都知道,咱們直接在組件渲染的時候返回一段相似 html 模版的結構,這個就是所謂的 JSX。JSX 本質上仍是 JS,是語法糖而不是 html 模版(相比 html 模版要學習千奇百怪的語法好比:{{#if value}},JSX 能夠直接使用 JS 原生的 && || map reduce 等語法更易學表達能力也更強)。通常須要 babel 配合@babel/plugin-transform-react-jsx 插件(babel 轉換過程不是本文重點,感興趣能夠閱讀插件源碼)轉換成調用 React.createElement,函數入參以下:github

React.createElement(
  type,
  [props],
  [...children]
)
複製代碼

例如上面的例子中的 <h1 title="hello">Hello World!</h1>,換成 createElement 調用就是:算法

const element = React.createElement(
  'h1',
  { title: 'hello' },
  'Hello World!'
);
複製代碼

React.createElement 返回一個包含元素(element)信息的對象,即:json

const element = {
  type: "h1",
  props: {
    title: "hello",
    // createElement 第三個及以後參數移到 props.children
    children: "Hello World!",
  },
};
複製代碼

react 官方實現還包括了不少額外屬性,簡單起見本文未涉及,參看官方定義

這個對象描述了 React 建立一個節點(node)所須要的信息,type 就是 DOM 節點的名字,好比這裏是 h1,也能夠是函數組件,後面會講到。props 包含全部元素的屬性(好比 title)和特殊屬性 children,children 能夠包含其餘元素,從根到葉也就能構成一顆完整的樹,也就是描述了整個 UI 界面。

爲了不含義不清,「元素」特指 「React elements」,「節點」特指 「DOM elements」。

ReactDOM.render

下面替換掉 ReactDOM.render 調用,這裏 React 會把元素更新到 DOM。

const element = {
  type: "h1",
  props: {
    title: "hello",
    children: ["Hello World!"],
  },
};
​
const container = document.getElementById("root");
​
const node = document.createElement(element.type);
node["title"] = element.props.title;
​
const text = document.createTextNode("");
text["nodeValue"] = element.props.children;
​
node.appendChild(text);
container.appendChild(node);
複製代碼

對比元素對象,首先用 element.type 建立節點,再把非 children 屬性(這裏是 title)賦值給節點。

而後建立 children 節點,因爲 children 是字符串,故建立 textNode 節點,並把字符串賦值給 nodeValue,這裏之因此用 createTextNode 而不是 innerText,是爲了方便以後統一處理。

再把 children 節點 text 插到元素節點的子節點上,最後把元素節點插到根結點即完成了此次 React 的替換。

像上面代碼 element 這樣 JSX 轉成的描述 UI 界面的對象就是所謂的 虛擬 DOM,相對的 node真實 DOMrender/渲染 過程就是把虛擬 DOM 轉換成真實 DOM 的過程。


I: 實現 createElement 函數

第一步首先實現 createElement 函數,把 JSX 轉換成 JS。如下面這個新的渲染爲例,createElement 就是把 JSX 結構轉成元素描述對象。

const element = (
  <div id="foo"> <a>bar</a> <b /> </div>
);
// 等價轉換 👇
const element = React.createElement(
  "div",
  { id: "foo" },
  React.createElement("a", null, "bar"),
  React.createElement("b")
);

const container = document.getElementById("root");
ReactDOM.render(element, container);
複製代碼

就像以前示例那樣,createElement 返回一個包含 type 和 props 的元素對象,描述節點信息。

// 這裏用了最新 ECMAScript 剩餘參數和展開語法(Rest parameter/Spread syntax),
// 參考 https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Operators/Spread_syntax
// 注意:這裏 children 始終是數組
function createElement(type, props, ...children) {
  return {
    type,
    props: {
      ...props,
      children: children.map(child =>
        typeof child === "object"
          ? child
          : createTextElement(child)
      ),
    },
  }
}
​
function createTextElement(text) {
  return {
    type: "TEXT_ELEMENT",
    props: {
      nodeValue: text,
      children: [],
    },
  }
}
複製代碼

children 可能包含字符串或者數字這類基礎類型值,給這裏值包裹成 TEXT_ELEMENT 特殊類型,方便後面統一處理。

注意:React 並不會包裹字符串這類值,若是沒有 children 也不會建立空數組,這裏簡單起見,統一這樣處理能夠簡化咱們的代碼。

咱們把本文的框架叫作 redact,以區別 react。示例 app 以下。

const element = Redact.createElement(
  "div",
  { id: "foo" },
  Redact.createElement("a", null, "bar"),
  Redact.createElement("b")
);
const container = document.getElementById("root");
ReactDOM.render(element, container);
複製代碼

可是咱們仍是習慣用 JSX 來寫組件,這裏還能用嗎?答案是能的,只須要加一行註釋便可。

/** @jsx Redact.createElement */
const element = (
  <div id="foo"> <a>bar</a> <b /> </div>
);
const container = document.getElementById("root");
ReactDOM.render(element, container);
複製代碼

注意第一行註釋 @jsx 告訴 babel 用 Redact.createElement 替換默認的 React.createElement。或者直接修改 .babelrc 配置文件的 pragma 項,就不用每次都添加註釋了。

{
  "presets": [
    [
      "@babel/preset-react",
      {
        "pragma": "Redact.createElement",
      }
    ]
  ]
}
複製代碼

II: 實現 render 函數

實現咱們的 render 函數,目前只須要添加節點到 DOM,刪除和更新操做後面再加。

function render(element, container) {
  // 建立節點
  const dom =
    element.type === "TEXT_ELEMENT"
      ? document.createTextNode("")
      : document.createElement(element.type);

  // 賦值屬性(props)
  const isProperty = key => key !== "children";
  Object.keys(element.props)
    .filter(isProperty)
    .forEach(name => {
      dom[name] = element.props[name]
    });

  // 遞歸遍歷子節點
  element.props.children.forEach(child =>
    render(child, dom)
  );

  // 插入父節點
  container.appendChild(dom);
}
複製代碼

上面的代碼放在了 CodeSandbox(在線開發環境),項目基於 Create React App 模版,試一試改下面的代碼驗證下。

redact-1


III: 併發模式 / Concurrent Mode

在咱們深刻其餘 React 功能以前,先對代碼重構,引入 React 最新的併發模式(截止本文發表該功能還未正式發佈)。

可能讀者會疑惑咱們目前連最基本的組件狀態更新都還沒實現就先實現併發模式,其實目前代碼邏輯還十分簡單,如今重構,比以後實現全部功能再回頭要容易不少,所謂積重難返就是這個道理。

有經驗的開發者很容易發現上面的 render 代碼有一個問題,渲染子節點時遞歸遍歷了整棵樹,當咱們頁面很是複雜時很容易阻塞主線程(和 stack over flow, 堆棧溢出),咱們都知道每一個頁面是單線程的(不考慮 worker 線程),主線程阻塞會致使頁面不能及時響應高優先級操做,如用戶點擊或者渲染動畫,頁面給用戶 「很卡,難用」 的負面印象,這確定不是咱們想要的。

所以,理想狀況下,咱們應該把 render 拆成更細分的單元,每完成一個單元的工做,容許瀏覽器打斷渲染響應更高優先級的的工做,這個過程即 「併發模式」。

這裏咱們用 requestIdleCallback 這個瀏覽器 API 來實現。這個 API 有點相似 setTimeout,不過不是咱們告訴瀏覽器何時執行回調函數,而是瀏覽器在線程空閒(idle)的時侯主動執行回調函數。

React 目前已經不用這個 API 了,而是用 調度器/scheduler 這個包,本身實現調度算法。但它們核心思路是相似的,簡化起見用 requestIdleCallback 足矣。

let nextUnitOfWork = null

function workLoop(deadline) {
  let shouldYield = false
  while (nextUnitOfWork && !shouldYield) {
    nextUnitOfWork = performUnitOfWork(
      nextUnitOfWork
    )
    // 回調函數入參 deadline 能夠告訴咱們在這個渲染週期還剩多少時間可用
    // 剩餘時間小於1毫秒就退出回調,等待瀏覽器再次空閒
    shouldYield = deadline.timeRemaining() < 1
  }
  requestIdleCallback(workLoop)
}

requestIdleCallback(workLoop)

// 注意,這個函數執行完本次單元任務以後要返回下一個單元任務
function performUnitOfWork(nextUnitOfWork) {
  // TODO
}
複製代碼

IV: Fibers 數據結構

爲了方便描述渲染樹和單元任務,React 設計了一種數據結構 「fiber 樹」。每一個元素都是一個 fiber,每一個 fiber 就是一個單元任務。

假如咱們渲染以下這樣一棵樹:

Redact.render(
  <div> <h1> <p /> <a /> </h1> <h2 /> </div>,
  container
)
複製代碼

用 Fiber 樹來描述就是:

render 函數咱們建立根 fiber,再把它設爲 nextUnitOfWork。在 workLoop 函數把 nextUnitOfWorkperformUnitOfWork 執行,主要包含如下三步:

  1. 把元素添加到 DOM
  2. 爲元素的後代建立 fiber 節點
  3. 選擇下一個單元任務,並返回

爲了完成這些目標須要設計的數據結構方便找到下一個任務單元。因此每一個 fiber 直接連接它的第一個子節點(child),子節點連接它的兄弟節點(sibling),兄弟節點連接到父節點(parent)。 示意圖以下(注意不一樣節點之間的高亮箭頭):

當咱們完成了一個 fiber 的單元任務,若是他有一個 子節點/child 則這個節點做爲 nextUnitOfWork。以下圖所示,當完成 div 單元任務以後,下一個單元任務就是 h1

若是一個 fiber 沒有 child,咱們用 兄弟節點/sibling 做爲下一個任務單元。以下圖所示,p 節點沒有 child 而有 sibling,因此下一個任務單元是 a 節點。

若是一個 fiber 既沒有 child 也沒有 sibling,則找到父節點的兄弟節點,。以下圖所示的 ah2

若是父節點沒有兄弟節點,則繼續往上找,直到找到一個兄弟節點或者到達 fiber 根結點。到達根結點即意味本次 render 任務所有完成。

把這個思路用代碼表達以下:

// 以前 render 的邏輯挪到這個函數
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;
}
function render(element, container) {
  // 建立根 fiber,設爲下一次的單元任務
  nextUnitOfWork = {
    dom: container,
    props: {
      children: [element]
    }
  };
}

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(fiber) {
  if (!fiber.dom) {
    fiber.dom = createDom(fiber);
  }

  // 子節點 DOM 插到父節點以後
  if (fiber.parent) {
    fiber.parent.dom.appendChild(fiber.dom);
  }

  // 每一個子元素建立新的 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.child = newFiber;
    } else {
      // 兄節點連接弟節點
      prevSibling.sibling = newFiber;
    }

    prevSibling = newFiber;
    index++;
  }
  // 返回下一個任務單元(fiber)
  // 有子節點直接返回
  if (fiber.child) {
    return fiber.child;
  }
  // 沒有子節點則找兄弟節點,兄弟節點也沒有找父節點的兄弟節點,
  // 循環遍歷直至找到爲止
  let nextFiber = fiber;
  while (nextFiber) {
    if (nextFiber.sibling) {
      return nextFiber.sibling;
    }
    nextFiber = nextFiber.parent;
  }
}
複製代碼

V: render 和 commit 階段

咱們的代碼還有一個問題。

每完成一個任務單元都把節點添加到 DOM 上。請記住,瀏覽器是能夠打斷渲染流程的,若是還沒渲染完整棵樹就把節點添加到 DOM,用戶會看到殘缺不全的 UI 界面,給人一種很不專業的印象,這確定不是咱們想要的。所以須要重構節點添加到 DOM 這部分代碼,整棵樹(fiber)渲染完成以後再一次性添加到 DOM,即 React commit 階段。

具體來講,去掉 performUnitOfWorkfiber.parent.dom.appendChild 代碼,換成以下代碼。

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
}

// 新增函數,提交根結點到 DOM
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);
}

function render(element, container) {
  // render 時記錄 wipRoot
  wipRoot = {
    dom: container,
    props: {
      children: [element],
    },
  };
  nextUnitOfWork = wipRoot;
}

let nextUnitOfWork = null;
// 新增變量,跟蹤渲染進行中的根 fiber
let wipRoot = null;

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

  // 當 nextUnitOfWork 爲空則表示渲染 fiber 樹完成了,
  // 能夠提交到 DOM 了
  if (!nextUnitOfWork && wipRoot) {
    commitRoot();
  }
  requestIdleCallback(workLoop);
}
// 一旦瀏覽器空閒,就觸發執行單元任務
requestIdleCallback(workLoop);

function performUnitOfWork(fiber) {
  if (!fiber.dom) {
    fiber.dom = createDom(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.child = newFiber;
    } else {
      prevSibling.sibling = newFiber;
    }

    prevSibling = newFiber;
    index++;
  }

  if (fiber.child) {
    return fiber.child
  }

  let nextFiber = fiber;
  while (nextFiber) {
    if (nextFiber.sibling) {
      return nextFiber.sibling;
    }
    nextFiber = nextFiber.parent;
  }
}
複製代碼

VI: 更新和刪除節點/Reconciliation

目前咱們只添加節點到 DOM,還沒考慮更新和刪除節點的狀況。要處理這2種狀況,須要對比上次渲染的 fiber 和當前渲染的 fiber 的差別,根據差別決定是更新仍是刪除節點。React 把這個過程叫 Reconciliation

所以咱們須要保存上一次渲染以後的 fiber 樹,咱們把這棵樹叫 currentRoot。同時,給每一個 fiber 節點添加 alternate 屬性,指向上一次渲染的 fiber。

代碼較多,建議按 render ⟶ workLoop ⟶ performUnitOfWork ⟶ reconcileChildren ⟶ workLoop ⟶ commitRoot ⟶ commitWork ⟶ updateDom 順序閱讀。

function createDom(fiber) {
  const dom =
    fiber.type === "TEXT_ELEMENT"
      ? document.createTextNode("")
      : document.createElement(fiber.type);

  updateDom(dom, {}, fiber.props);

  return dom;
}

const isEvent = key => key.startsWith("on");
const isProperty = key => key !== "children" && !isEvent(key);
const isNew = (prev, next) => key => prev[key] !== next[key];
const isGone = (prev, next) => key => !(key in next);

// 新增函數,更新 DOM 節點屬性
function updateDom(dom, prevProps = {}, nextProps = {}) {
  // 以 「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(prevProps)
    .filter(isProperty)
    .filter(isGone(prevProps, nextProps))
    .forEach(name => {
      dom[name] = "";
    });

  // 添加或者更新屬性
  Object.keys(nextProps)
    .filter(isProperty)
    .filter(isNew(prevProps, nextProps))
    .forEach(name => {
      // React 規定 style 內聯樣式是駝峯命名的對象,
      // 根據規範給 style 每一個屬性單獨賦值
      if (name === "style") {
        Object.entries(nextProps[name]).forEach(([key, value]) => {
          dom.style[key] = value;
        });
      } else {
        dom[name] = nextProps[name];
      }
    });

  // 添加新的事件處理函數
  Object.keys(nextProps)
    .filter(isEvent)
    .filter(isNew(prevProps, nextProps))
    .forEach(name => {
      const eventType = name.toLowerCase().substring(2);
      dom.addEventListener(eventType, nextProps[name]);
    });
}

function commitRoot() {
  deletions.forEach(commitWork);
  commitWork(wipRoot.child);
  currentRoot = wipRoot;
  wipRoot = null;
}

function commitWork(fiber) {
  if (!fiber) {
    return;
  }
  const domParent = fiber.parent.dom;
  if (fiber.effectTag === "PLACEMENT" && fiber.dom != null) {
    domParent.appendChild(fiber.dom);
  } else if (fiber.effectTag === "UPDATE" && fiber.dom != null) {
    updateDom(fiber.dom, fiber.alternate.props, fiber.props);
  } else if (fiber.effectTag === "DELETION") {
    domParent.removeChild(fiber.dom);
  }
  commitWork(fiber.child);
  commitWork(fiber.sibling);
}

function render(element, container) {
  wipRoot = {
    dom: container,
    props: {
      children: [element]
    },
    alternate: currentRoot
  };
  deletions = [];
  nextUnitOfWork = wipRoot;
}

let nextUnitOfWork = null;
let currentRoot = null;
let wipRoot = null;
let deletions = null;

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

  if (!nextUnitOfWork && wipRoot) {
    commitRoot();
  }

  requestIdleCallback(workLoop);
}

requestIdleCallback(workLoop);

function performUnitOfWork(fiber) {
  if (!fiber.dom) {
    fiber.dom = createDom(fiber);
  }

  const elements = fiber.props.children;
  // 本來添加 fiber 的邏輯挪到 reconcileChildren 函數
  reconcileChildren(fiber, elements);

  if (fiber.child) {
    return fiber.child;
  }
  let nextFiber = fiber;
  while (nextFiber) {
    if (nextFiber.sibling) {
      return nextFiber.sibling;
    }
    nextFiber = nextFiber.parent;
  }
}

// 新增函數
function reconcileChildren(wipFiber, elements) {
  let index = 0;
  // 上次渲染完成以後的 fiber 節點
  let oldFiber = wipFiber.alternate && wipFiber.alternate.child;
  let prevSibling = null;

  // 扁平化 props.children,處理函數組件的 children
  elements = elements.flat();

  while (index < elements.length || oldFiber != null) {
    // 本次須要渲染的子元素
    const element = elements[index];
    let newFiber = null;

    // 比較當前和上一次渲染的 type,即 DOM tag 'div',
    // 暫不考慮自定義組件
    const sameType = oldFiber && element && element.type === oldFiber.type;

    // 同類型節點,只需更新節點 props 便可
    if (sameType) {
      newFiber = {
        type: oldFiber.type,
        props: element.props,
        dom: oldFiber.dom, // 複用舊節點的 DOM
        parent: wipFiber,
        alternate: oldFiber,
        effectTag: "UPDATE" // 新增屬性,在提交/commit 階段使用
      };
    }
    // 不一樣類型節點且存在新的元素時,建立新的 DOM 節點
    if (element && !sameType) {
      newFiber = {
        type: element.type,
        props: element.props,
        dom: null,
        parent: wipFiber,
        alternate: null,
        effectTag: "PLACEMENT" // PLACEMENT 表示須要添加新的節點
      };
    }
    // 不一樣類型節點,且存在舊的 fiber 節點時,
    // 須要移除該節點
    if (oldFiber && !sameType) {
      oldFiber.effectTag = "DELETION";
      // 當最後提交 fiber 樹到 DOM 時,咱們是從 wipRoot 開始的,
      // 此時沒有上一次的 fiber,因此這裏用一個數組來跟蹤須要
      // 刪除的節點
      deletions.push(oldFiber);
    }

    if (oldFiber) {
      // 同步更新下一個舊 fiber 節點
      oldFiber = oldFiber.sibling;
    }

    if (index === 0) {
      wipFiber.child = newFiber;
    } else {
      prevSibling.sibling = newFiber;
    }

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

注意:這個過程當中 React 還用了 key 來檢測數組元素變化了位置的狀況,避免重複渲染以提升性能。簡化起見,本文未實現。

下面 CodeSandbox 代碼用了個小技巧,重複執行 render 實現更新界面的效果,動手改改試試。

redact-2


VII: 函數組件

目前咱們還只考慮了直接渲染 DOM 標籤的狀況,不支持組件,而組件是 React 是靈魂,下面咱們來實現函數組件。

以一個很是簡單的組件代碼爲例。

/** @jsx Redact.createElement */
function App(props) {
  return <h1>Hi {props.name}</h1>;
};

// 等效 JS 代碼 👇
function App(props) {
  return Redact.createElement(
    "h1",
    null,
    "Hi ",
    props.name
  )
}

const element = <App name="foo" />; const container = document.getElementById("root"); Redact.render(element, container); 複製代碼

函數組件有2個不一樣點:

  • 函數組件的 fiber 節點沒有對應 DOM
  • 函數組件的 children 來自函數執行結果,而不是像標籤元素同樣直接從 props 獲取,由於 children 不僅是函數組件使用時包含的子孫節點,還須要組合組件自己的結構

注意如下代碼省略了未改動部分。

function commitWork(fiber) {
  if (!fiber) {
    return;
  }

  // 當 fiber 是函數組件時節點不存在 DOM,
  // 故須要遍歷父節點以找到最近的有 DOM 的節點
  let domParentFiber = fiber.parent;
  while (!domParentFiber.dom) {
    domParentFiber = domParentFiber.parent;
  }
  const domParent = domParentFiber.dom;
  if (fiber.effectTag === "PLACEMENT" && fiber.dom != null) {
    domParent.appendChild(fiber.dom);
  } else if (fiber.effectTag === "UPDATE" && fiber.dom != null) {
    updateDom(fiber.dom, fiber.alternate.props, fiber.props);
  } else if (fiber.effectTag === "DELETION") {
    // 直接移除 DOM 替換成 commitDeletion 函數
    commitDeletion(fiber, domParent);
  }

  commitWork(fiber.child);
  commitWork(fiber.sibling);
}

// 新增函數,移除 DOM 節點
function commitDeletion(fiber, domParent) {
  // 當 child 是函數組件時不存在 DOM,
  // 故須要遞歸遍歷子節點找到真正的 DOM
  if (fiber.dom) {
    domParent.removeChild(fiber.dom);
  } else {
    commitDeletion(fiber.child, domParent);
  }
}

function performUnitOfWork(fiber) {
  const isFunctionComponent = fiber.type instanceof Function;
  // 本來邏輯挪到 updateHostComponent 函數
  if (isFunctionComponent) {
    updateFunctionComponent(fiber);
  } else {
    updateHostComponent(fiber);
  }
  if (fiber.child) {
    return fiber.child;
  }
  let nextFiber = fiber;
  while (nextFiber) {
    if (nextFiber.sibling) {
      return nextFiber.sibling;
    }
    nextFiber = nextFiber.parent;
  }
}

// 新增函數,處理函數組件
function updateFunctionComponent(fiber) {
  // 執行函數組件獲得 children
  const children = [fiber.type(fiber.props)];
  reconcileChildren(fiber, children);
}

// 新增函數,處理原生標籤組件
function updateHostComponent(fiber) {
  if (!fiber.dom) {
    fiber.dom = createDom(fiber);
  }
  reconcileChildren(fiber, fiber.props.children);
}
複製代碼

VIII: 函數組件 Hooks

支持了函數組件,還須要支持組件狀態 / state 才能實現刷新界面。

咱們的示例也跟着更新,用 hooks 實現經典的 counter,點擊計數器加1。

/** @jsx Redact.createElement */
function Counter() {
  const [state, setState] = Redact.useState(1)
  return (
    <h1 onClick={() => setState(c => c + 1)}> Count: {state} </h1>
  );
}
const element = <Counter />; const container = document.getElementById("root"); Redact.render(element, container); 複製代碼

注意如下代碼省略了未變化部分。

// 新增變量,渲染進行中的 fiber 節點
let wipFiber = null;
// 新增變量,當前 hook 的索引
let hookIndex = null;

function updateFunctionComponent(fiber) {
  // 更新進行中的 fiber 節點
  wipFiber = fiber;
  // 重置 hook 索引
  hookIndex = 0;
  // 新增 hooks 數組以支持同一個組件屢次調用 `useState`
  wipFiber.hooks = [];
  const children = [fiber.type(fiber.props)];
  reconcileChildren(fiber, children);
}

function useState(initial) {
  // alternate 保存了上一次渲染的 fiber 節點
  const oldHook =
    wipFiber.alternate &&
    wipFiber.alternate.hooks &&
    wipFiber.alternate.hooks[hookIndex];
  const hook = {
    // 第一次渲染使用入參,第二次渲染複用前一次的狀態
    state: oldHook ? oldHook.state : initial,
    // 保存每次 setState 入參的隊列
    queue: []
  };

  const actions = oldHook ? oldHook.queue : [];
  actions.forEach(action => {
    // 根據調用 setState 順序從前日後生成最新的 state
    hook.state = action instanceof Function ? action(hook.state) : action;
  });

  // setState 函數用於更新 state,入參 action
  // 是新的 state 值或函數返回新的 state
  const setState = action => {
    hook.queue.push(action);
    // 下面這部分代碼和 render 函數很像,
    // 設置新的 wipRoot 和 nextUnitOfWork
    // 瀏覽器空閒時即開始從新渲染。
    wipRoot = {
      dom: currentRoot.dom,
      props: currentRoot.props,
      alternate: currentRoot
    };
    nextUnitOfWork = wipRoot;
    deletions = [];
  };

  // 保存本次 hook
  wipFiber.hooks.push(hook);
  hookIndex++;
  return [hook.state, setState];
}
複製代碼

完整 CodeSandbox 代碼以下,點擊 Count 試試:

redact-3


結語

除了幫助讀者理解 React 核心工做原理外,本文不少變量都和 React 官方代碼保持一致,好比,讀者在 React 應用的任何函數組件裏斷點,再打開調試工做能看到下面這樣的調用棧:

  • updateFunctionComponent
  • performUnitOfWork
  • workLoop

調用棧

注意本文是教學性質的,還缺乏不少 React 的功能和性能優化。好比:在這些事情上 React 的表現和 Redact 不一樣。

  • Redact 在渲染階段遍歷了整棵樹,而 React 用了一些啓發性算法,能夠直接跳過某些沒有變化的子樹,以提升性能。(好比 React 數組元素推薦帶 key,能夠跳過無需更新的節點,參考官方文檔
  • Redact 在 commit 階段遍歷整棵樹, React 用了一個鏈表保存變化了的 fiber,減小了不少沒必要要遍歷操做。
  • Redact 每次建立新的 fiber 樹時都是直接建立 fiber 對象節點,而 React 會複用上一個 fiber 對象,以節省建立對象的性能消耗。
  • Redact 若是在渲染階段收到新的更新會直接丟棄已渲染的樹,再從頭開始渲染。而 React 會用時間戳標記每次更新,以決定更新的優先級。
  • 源碼還有不少優化等待讀者去發現。。。

參考

徵得原做者贊成,本文參考了 build-your-own-react 部份內容,推薦英文水平不錯讀者直接在桌面端閱讀原文以得到最佳閱讀體驗。

相關文章
相關標籤/搜索