前言

您好,我是 Watanabe jin (@Sicut_study)。

我主要對於 React 的初學者進行教學。
我所指導的學生們利用 React 開發了各式各樣的優秀服務,然而即便具備實力,對於虛擬 DOM 和重新渲染的理解仍然不足,經常因此在錯誤調試上花費不少時間。
(雖然不需要深入理解也能使用,這也是它的魅力)

在開發 React 的過程中,深入理解「虛擬 DOM」和「重新渲染」對於錯誤處理性能提升非常重要。

這次的教程將透過僅僅使用 JavaScript 從零實現 React,來實現對虛擬 DOM 的理解。

我自己在製作這個教程的過程中也深入理解了虛擬 DOM。
我能夠撰寫出更符合 React 風格的代碼,深切感到這樣的學習是值得的。
希望初學者也能夠參與這個教程,因此我特別注意詳細的解釋。

「重新發明輪子」
身為工程師,聽過這句話的人應該不少。

重新發明輪子指的是在不知情或故意忽視已確立的技術或解決方案的情況下,再次從零開始進行製作的慣用語。

在 IT 業界,經常聽到「應該避免重新發明輪子」的建議。
然而,通過重新實現來深入理解 React,能讓我們寫出更優秀的代碼,因此我認為這是一種高效利用時間的學習方式。

透過本教程,你將能夠深入理解 React 的運作機制。
相信你會寫出更符合 React 風格的代碼!

我們還提供了視頻教材

本教材搭配更詳細解說的視頻。如果在文本教材中有不明白的地方,歡迎活用視頻。

目標對象

  • 曾接觸過 React 的人
  • 對虛擬 DOM 不太了解的人
  • 不明白重新渲染的人
  • 希望更深入理解 React 的人
  • 希望提升基礎能力的人

這個教程對於曾經接觸過 React 的人來說,約需 2 小時即可完成。

本次實作參考了以下文章,並進行了現代化的講解。

DOM 是什麼?

在討論虛擬 DOM 之前,先從更基本的概念開始講解。

DOM 是指用來表達用 HTML 寫成的網頁,讓程序(JavaScript)能夠理解和操作的模型。

image.png

引用

DOM 是由節點(元素)組成的樹狀結構(類似樹的結構),這被稱為 DOM 樹。
而且節點有幾種不同的類型。

image.png

在 DOM 樹中,節點之間的關係被稱為「父節點」、「子節點」、「兄弟節點」,這些在此次實作中會用到,大家要特別留意。

image.png

嘗試操作 DOM

DOM 是一個可以用 JavaScript 操作的模型,因此我們可以在瀏覽器的控制台中執行 JavaScript 代碼來對其進行操作。

請打開 https://google.com

右鍵點擊→選擇「檢查」或「檢查元素」來開啟開發者工具。

image.png

開啟開發者工具後,點擊上方的「Console」標籤。

image.png

那麼,讓我們來用 JavaScript 實際了解當前的 DOM 結構吧。

// 確認 document 物件
console.log(document);

// 獲取 HTML 元素(根元素)
console.log(document.documentElement);

// 獲取 body 元素
console.log(document.body);

// 獲取特定元素的方法(獲取 input 標籤元素)
console.log(document.querySelector('input')); // 獲取搜尋框的 input

image.png

我們確認了它的樹狀結構。
既然已經獲得了 DOM,讓我們來嘗試在頁面中新增一個新的h1 標籤吧!

// 建立新的 h1 元素(尚未顯示在頁面上)
const newHeading = document.createElement('h1');

// 設定元素的內容
newHeading.textContent = 'Hello World!';

// 在頁面最上方插入(這樣才能顯示在畫面上)
document.body.insertBefore(newHeading, document.body.firstChild);

image.png

我們已成功在畫面中添加了「Hello World!」。
接下來,我們來解釋執行的代碼。

const newHeading = document.createElement('h1');

這行代碼是在內存中建立一個新的 h1 元素(標題)。
此時尚未加入到 DOM 樹中,因此不會顯示在畫面上。

newHeading.textContent = 'Hello World!';

這行代碼是為該 h1 元素設置文本內容為「Hello World!」。

document.body.insertBefore(newHeading, document.body.firstChild);

這行代碼將新創建的元素插入到 DOM 中。
insertBefore 將第一個參數(newHeading)插入到第二個參數(document.body.firstChild,即 body 的第一個子元素)之前。這使得 h1 元素成為頁面 body 的第一個子元素,並顯示在畫面上。

DOM 的問題

在現代複雜的應用程序中,操作 DOM 時會出現一些問題。

image.png

  • 代碼容易變得複雜
  • 狀態管理困難
  • 容易發生性能問題

React 是解決這些問題的革命性庫
其中最大的創新在於虛擬 DOM 技術。

React 首先會創建一個虛擬的 DOM(實際的瀏覽器 DOM 的輕量級副本)。
然後,它會將這個虛擬的 DOM 與以前的虛擬 DOM 進行比較,只對有差異的虛擬節點對實際 DOM 上的節點進行變更。

這樣做能顯著減少直接操作 DOM 的渲染成本(渲染需要各種處理),從而提升性能。

接下來,我們將通過實際的 JavaScript 重新實現 React,深入理解虛擬 DOM 和渲染。

渲染的流程

在用 JavaScript 重新實現 React 之前,了解 React 的渲染過程的全貌將有助於我們更好地理解實作。
如果在實作過程中有不明白的地方,請隨時回到這一章節參考。

image.png

React 的渲染過程遵循以下步驟:

1. 定義組件 (JSX)

透過使用 JSX 定義組件並開始渲染。

// 使用 JSX 的組件定義
function MyComponent(props) {
  return <div className="container">Hello, {props.name}!</div>;
}

// 渲染的起點
ReactDOM.render(<MyComponent name="World" />, document.getElementById('root'));

2. 從 JSX 轉換為 React 元素

JSX 實際上會被轉換為以下格式的函數調用。

// Vite 進行轉換
function MyComponent(props) {
  return React.createElement(
    'div',
    { className: 'container' },
    'Hello, ',
    props.name,
    '!'
  );
}

// 開始渲染
ReactDOM.render(
  React.createElement(MyComponent, { name: 'World' }),
  document.getElementById('root')
);

我們撰寫的代碼雖然是這樣:

function MyComponent(props) {
  return <div className="container">Hello, {props.name}!</div>;
}

但 Vite 在內部轉換後執行成以下的形態。

function MyComponent(props) {
  return React.createElement(
    'div',
    { className: 'container' },
    'Hello, ',
    props.name,
    '!'
  );
}

3. 創建 React 元素

當調用 createElement 時,會創建以下物件(React 元素)。

{
  type: 'div', // 或者函數組件
  props: {
    className: 'container',
    children: ['Hello, ', 'World', '!']
  },
  key: null,
  ref: null
}

4. 渲染階段

將第三步創建的 React 元素分解為更小的單位(節點單位),並為每個元素創建一個保存組件信息的物件。

這被稱為Fiber(纖維),用於構建的樹狀結構稱為 Fiber 樹。可以把 Fiber 樹暫時理解為虛擬 DOM 樹。

Fiber 包含 ReactElement 的類型(如 div 或 h1 等)以及父元素等關於節點的信息。

const counterFiber = {
  // 基本信息
  type: Counter, // 函數本身的引用
  props: {
    initial: 0,
    children: []
  },

  // 函數組件不擁有 DOM 節點
  dom: null,

  // 樹狀結構
  parent: appFiber,    // App 組件等
  child: pFiber,       // p 元素的 Fiber
  sibling: null,
  alternate: null,

  // 副作用標記
  effectTag: 'UPDATE'
};

對每個 Fiber 進行與之前的 Fiber 樹進行比較。
若有變更,則在 Fiber 上記錄該變更。

const counterFiber = {
  type: Counter,
  props: {
    initial: 0,
    children: []
  },
  dom: null,
  parent: appFiber,
  child: pFiber,
  sibling: null,
  alternate: null,
  effectTag: 'UPDATE' // 表示已更新
};

透過在 Fiber 單位上細緻地處理 Fiber 樹,
當用戶在螢幕上進行操作(例如點擊菜單)時,可以暫停當前任務,優先處理用戶的操作。
這樣可以防止在渲染中畫面無法操作,從而提升用戶體驗。
(關於 Fiber 的詳細解說稍後會進行)

5. 提交階段

最後,對於新增、修改和刪除的節點進行實際的 DOM 操作。
通過查看第四步創建的 Fiber,就能知道該元素是新增、刪除還是更新,
然後檢查每個 Fiber,若有更新,則進行實際的 DOM 操作。
即實際上會反映到畫面上。

這裡所述的大致流程將是此實作的基本循環。
如果這種解釋並不完全明確也沒有關係,試著在實作 React 的過程中逐漸深入理解即可。

1. 環境搭建

首先使用 Vite 構建可以執行 JavaScript 的環境。
通常使用 React 時需要安裝庫,但本次我們僅用 JavaScript 來實現 React,因此只需要準備一個能運行 JavaScript 的環境即可。

$ node -v
v22.4.0

$ npm create vite@latest
> npx
> create-vite

✔ 專案名稱: … make-react
✔ 選擇框架: › Vanilla
✔ 選擇類型: › JavaScript

$ cd make-react
$ npm i
$ npm run dev

若在 http://localhost:5173 打開後顯示以下畫面則完成環境搭建。

image.png

2. 將 React 代碼轉換為 JavaScript

在這裡,我們將 React 的代碼逐步轉換為 JavaScript。
當然,因為這是無法進行差異更新的範例代碼,所以讓我們開始吧。
請在 VSCode 中打開前面創建的 make-react 目錄。

首先,將 main.js 中所有的代碼刪除。
來寫寫普通的 React 代碼(儘管 React 尚未安裝,因此這段代碼不會運行)

function MyComponent() {
  return <h1 title="foo">Hello</h1>;
}

const container = document.getElementById("root");
ReactDOM.render(<MyComponent />, container);

JSX 會經由 ViteBabel 等編譯工具轉換為 createElement 來生成物件的代碼。現在手動將代碼進行替換。

const element = React.createElement(
  "h1",
  { title: "foo" },
  "Hello"
);
const container = document.getElementById("root");
ReactDOM.render(element, container);

createElement 是一個接受三個參數的函數。

第一個參數 : Type(指定要創建的元素類型)
第二個參數 : Props(傳遞給元素的屬性對象)
第三個參數 : children(定義放置在 HTML 內部的內容(文本、其他 React 元素或子元素數組))

在調用 createElement 時,將轉換為以下類似的物件並返回。

{
  type: 'h1',
  props: {
    title: 'foo',
    children: ['Hello']
  }
}

如果要將 React.createElement 轉換為 JavaScript,則可以寫成如下代碼。

const element = {
  type: 'h1',
  props: {
    title: 'foo',
    children: ['Hello']
  }
};

接下來,替換 React 的部分為 JavaScript 的 ReactDom.render
根據之前在控制台中執行的方式,將 h1 元素添加到根元素中。

const element = {
  type: 'h1',
  props: {
    title: 'foo',
    children: ['Hello']
  }
};

const container = document.getElementById("root");
const node = document.createElement(element.type);
node.title = element.props.title;

const text = document.createTextNode(element.props.children);
node.appendChild(text);

container.appendChild(node);

appendChild 是 DOM 的基本方法,將子元素添加到父元素中。
這行代碼將創建的文本節點("Hello")放置於 h1 元素中。

用 HTML 表示如下:

<h1 title="foo">Hello</h1>

React 通過對 root 元素進行 DOM 的添加修改來變更畫面(渲染)。
因此,我們的畫面中也需要準備 root 元素。
請修正 index.html 如下。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite App</title>
  </head>
  <body>
    <!-- 將 id 從 app 改為 root -->
    <div id="root"></div>
    <script type="module" src="/src/main.js"></script>
  </body>
</html>

我們將 id 從 app 更改為 root。

<div id="root"></div>

這樣應該就能正常運行,因此請啟動伺服器並訪問。

$ npm run dev

請打開 http://localhost:5173

image.png

3. 實現 createElement

目前的狀況下,我們需要手動將以下物件進行轉換才能運行,因此我們將實現 createElement 來自動化此過程。

{
  type: 'h1',
  props: {
    title: 'foo',
    children: ['Hello']
  }
}

現在,讓我們將代碼回到使用 createElement 的樣式。

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

然後使用函數來準備 createElement,並調用我們自定義的函數而不再是 React.createElement。

function createElement(type, props, ...children) {
  return {
    type,
    props: {
      ...props,
      children: children.map((child) =>
        typeof child === "object" ? child : createTextElement(child)
      )
    }
  };
}

const element = createElement("h1", { title: "foo" }, "Hello");

const container = document.getElementById("root");
ReactDOM.render(element, container);

在 createElement 中,我們創建物件以符合我們希望的格式。

{
  type: 'h1',
  props: {
    title: 'foo',
    children: ...
  }
}

props 的部分稍微複雜一些,我們再詳細解釋一下。

props: {
  ...props,
  children: children.map((child) =>
    typeof child === "object" ? child : createTextElement(child)
  )
}

props 是第二個參數的 -{ title: "foo" },而 ...children 是第三個參數的 "Hello"

function createElement(type, props, ...children) {
  return {
    type,
    props: {
      ...props,
      children: children.map((child) =>
        typeof child === "object" ? child : createTextElement(child)
      )
    }
  };
}

...props 使得物件可擴展,如下結構,

{
  type: 'h1',
  props: {
    title: 'foo',
    children: ...
  }
}

例如當 props 變成 -{ title: "foo", name: "hoge" },則將變為如下形式。

{
  type: 'h1',
  props: {
    title: 'foo',
    name: 'hoge',
    children: ...
  }
}

JSX 在此時的樣式會是這樣的:

function MyComponent() {
  return <h1 title="foo" name="hoge">Hello</h1>;
}

children 則需進行更複雜的處理。

children: children.map((child) =>
  typeof child === "object" ? child : createTextElement(child)
)

children 接收的是可變長度的參數。

function createElement(type, props, ...children) {

若子元素包含多個結構,則 children 便會包含多個元素。

// JSX
function MyList() {
  return (
    <ul id="my-list">
      <li>項目1</li>
      <li>項目2</li>
      文本節點
      {123}
    </ul>
  );
}

// 編譯後的結果
createElement(
  "ul",
  { id: "my-list" },
  createElement("li", null, "項目1"),
  createElement("li", null, "項目2"),
  "文本節點",
  123
);

把每個子要素進行檢查,判斷是物件還是其他類型。
如果是物件則直接返回,否則將其轉換為文本元素再返回。

children: children.map((child) =>
  typeof child === "object" ? child : createTextElement(child)
)

為此,我們需要創建一個 createTextElement 函數。

function createTextElement(text) {
  return {
    type: "TEXT_ELEMENT",
    props: {
      nodeValue: text,
      children: []
    }
  };
}

在 JavaScript 中,字符串或數字並不被視為物件,因此直接使用無法當作虛擬 DOM 元素的對象,我們透過 createTextElement 將其轉換為相似結構的物件。

React 在內部使用 ReactText 這種類型來表示文本節點,但在重新實現 React 的過程中,我們使用了名為 TEXT_ELEMENT 的自訂類型。

至此,您應該能夠實現 createElement,讓我們來檢查一下。
為了能夠在 Vite 上啟動,我們需要手動創建以下代碼。

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: []
    }
  };
}

const element = createElement("h1", { title: "foo" }, "Hello");
console.log(element);

我們將手動創建伺服器啟動的代碼。

const container = document.getElementById("root");
const node = document.createElement("h1");
const text = document.createTextNode("Hello World!");
node.appendChild(text);
container.appendChild(node);

這樣應該就能正常運作,因此啟動伺服器並訪問。

$ npm run dev

請打開 http://localhost:5173

image.png

4. 重新實現 render

接下來,我們將自定義 render 函數,就像平常似的使用 ReactDom.render 一樣,準備自定義的物件來調用。

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

let nextUnitOfWork = null;
let wipRoot = null;
let currentRoot = 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);
}

const elements = fiber.props.children;
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;
let oldFiber = wipFiber.alternate && wipFiber.alternate.child;
let prevSibling = null;

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

const sameType = oldFiber && element && oldFiber.type === element.type;

if (sameType) {
  newFiber = {
    type: oldFiber.type,
    props: element.props,
    dom: oldFiber.dom,
    parent: wipFiber,
    alternate: oldFiber,
    effectTag: "UPDATE"
  };
}

if (element && !sameType) {
  newFiber = {
    type: element.type,
    props: element.props,
    dom: null,
    parent: wipFiber,
    alternate: null,
    effectTag: "PLACEMENT"
  };
}

if (oldFiber && !sameType) {
  oldFiber.effectTag = "DELETION";
  deletions.push(oldFiber);
}

if (oldFiber) {
  oldFiber = oldFiber.sibling;
}

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

prevSibling = newFiber;
index++;

}
}

const MyReact = {
createElement,
render
};

export default MyReact;

結語

您覺得如何?
這次我們嘗試僅用 JavaScript 重新實現 React。
通過這樣的實作過程,我相信大家會對虛擬 DOM 和重新渲染有更深入的理解。

我也發布了詳細講解的視頻,如果有興趣的話,歡迎查看!

JISOU 的成員招募中!

在程式設計輔導 JISOU 中,我們正在招募新成員。
想要在日本第一的輸出社群提升職業生涯嗎?
有興趣的人,請透過官網隨時申請諮詢!
▼▼▼


原文出處:https://qiita.com/Sicut_study/items/710ea707d4426011710f


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝11   💬6   ❤️6
472
🥈
我愛JS
📝1   💬5   ❤️4
92
🥉
AppleLily
📝1   💬4   ❤️1
53
#4
💬1  
5
#5
xxuan
💬1  
3
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次