在瞭解了 React element 這種虛擬抽象層中的最小建構單位之後,我們來深入了解一下要如何讓 React elements 產生出對應的真實 DOM elements。這雖然是一個程式碼寫起來很容易的環節,然而確實地瞭解其流程的每一步到底是如何連貫運作的,對於內化「Virtual DOM 概念如何被實際應用在 React 中」的思維其實是不可忽略的基本功。
首先,React 將定義並產生 UI 的工作大致上分成兩大部分,Reconciler & Renderer:
得益於 React 將抽象層的管理(Reconciler),以及將抽象層在目標環境渲染成實際的畫面(Renderer)分拆成兩個部分,因此只要有支援其他目標環境的 Renderer 配合,React 其實也可以用於產生瀏覽器 DOM 以外的 UI 或畫面。像是用與產生原生 Android / iOS App UI 的 React Native、用於產生 PDF 文件的 React-pdf...等等。
除了 React 官方自己維護的 react-dom
以及 react-native
之外,還有各式各樣由社群的開源貢獻者們所維護的 React Renderer 正在蓬勃的發展中。
當然,在這些非瀏覽器環境的 Renderer 中所支援的 elements 類型自然就不是我們熟知的 DOM element 類型了,而是對應環境中的一些原生元素,像是 React Native 中的 Text
、 View
等等 Android / iOS App 原生元件的類型,透過專用的 Renderer 它們最後就能在 Android / iOS App 中產生原生的畫面 UI。
而在瀏覽器環境的前端開發中,我們會使用 React 官方所提供的 react-dom
作為 Renderer,來幫助我們產生並管理真實的 DOM。
以概念上來說,我們其實是指定瀏覽器畫面中的特定區塊,讓 React 對其擁有完全的管轄權來持續的進行 Virtual DOM ⇒ DOM 的單向轉換與同步。
因此我們需要先事前指定一個目標區塊作為容器,當每次當 Reconciler 產生了新的 Virtual DOM 並分析出哪些地方需要 DOM 操作以完成畫面更新的需求後,就會將這份需求任務傳遞給 Renderer。而 Renderer 就負責將這次的需求在你事先指定的目標容器中進行 DOM 的產生或操作,以完成瀏覽器畫面的實際 DOM 的更新。
為此你需要先在 Web App 的 HTML 原始碼中的 <body>
裡面放置一個空的容器元素,用來在 JavaScript 中指定為 Renderer 產生真實 DOM elements 的目標處。通常我們會放一個空的 <div>
當作容器,並加上一個值隨意的 id
,以便我們等等在 JavaScript 中取得這個 DOM element:
<body>
<div id="root-container">
<!-- 之後 React 轉換輸出的真實 DOM elements 就會注入到這裡 -->
</div>
</body>
然後我們就可以在 JavaScript 中取得這個容器元素,並以 ReactDOM.createRoot()
方法來事先用這個容器元素產生一個「root」,也就是 React 產生並管理 DOM elements 輸出結果的「畫面渲染管轄入口」:
import React from 'react';
import ReactDOM from 'react-dom/client'; // 用於瀏覽器 DOM 環境的 Renderer
// 取得在 HTML 中事先定義好的容器元素,以作為之後 React 產生 DOM elements 結果的輸出容器
const rootContainerElement = document.getElementById('root-container');
// 用這個容器元素來建立一個 React App 的畫面渲染管轄入口 (root)
const root = ReactDOM.createRoot(rootContainerElement);
// ....
接著我們就能以 root.render()
方法來將 React element 進行轉換渲染成真實的 DOM element:
import React from 'react';
import ReactDOM from 'react-dom/client'; // 用於瀏覽器 DOM 環境的 Renderer
// 取得在 HTML 中事先定義好的容器元素,以作為之後 React 產生 DOM elements 結果的輸出容器
const rootContainerElement = document.getElementById('root-container');
// 用這個容器元素來建立一個 React App 的畫面渲染管轄入口 (root)
const root = ReactDOM.createRoot(rootContainerElement);
// 先準備好一個 React element
const buttonReactElement = React.createElement(
'button', // 元素類型
{ id: 'button1' }, // 屬性
'I am a button' // 子元素
);
// 在這個 root 上將 React element 進行轉換渲染成真實的 DOM element
root.render(buttonReactElement);
然後就能在瀏覽器畫面中產生結果了,你會看到 React element 所對應產生的 DOM element(那個 button
) 被放置在 root 當初指定的容器元素(也就是那個 id 為 root-container
的 div
)裡面,:
Demo CodeSandBox:https://codesandbox.io/s/intelligent-snow-1i8e57
在將 React element 渲染到 root 對應的目標容器裡之後,這個容器元素以內的所有內容就通通交由 React 進行代理管轄與操作了。因此在大多數情況下我們都不建議你去手動操作或修改 React 管轄範圍內的 DOM elements,因為這有可能會導致 React 內部所認知的 Virtual DOM Tree 與對應的真實的 DOM Tree 有所不一致而不再維持完全同步的狀態,進而引發一些意外的問題或隱患。
另外,如上一個章節所提到的,React element 從概念上來說就像是在表達「某一個歷史時刻當時的畫面結構」,因此 React element 一旦被建立之後就永遠不能再修改。所以當我們想要更新畫面時,必須產生一組全新的 React element 來餵給 Renderer
import React from 'react';
import ReactDOM from 'react-dom/client';
const rootContainerElement = document.getElementById('root-container');
const root = ReactDOM.createRoot(rootContainerElement);
setInterval(
() => {
const reactElement = (
<div>
<h1>Hello world</h1>
<h2>Time is {new Date().toLocaleTimeString()}</h2>
</div>
);
root.render(reactElement);
},
1000
);
Demo CodeSandbox:https://codesandbox.io/s/fancy-fog-pt7um0
在以上範例中,當每經過一秒就會重新執行一次 setInterval
的 callback 來產生一個新的 React element 並重新呼叫一次 root.render()
。觀察以下實際運行結果:
從上面的結果中可以看到,當我們每次建立新的 React element 並呼叫 root 將其 render 成實際的 DOM 時,root 容器中的實際 DOM 結構就會被同步成與新的 React element 一致的樣子。並且你會發現,每次畫面更新時其實只有真正需要變化的部分 DOM 才會被操作到( <h2>
裡顯示時間的文字),而其它每次重新產生 React element 時也沒有任何差異的部分,則不會發生任何的 DOM 操作。
這其實就是我們前面有提到的,Virtual DOM 為什麼能透過最小化 DOM 操作來減少不必要的效能花費。因此,身為開發者在寫 React 時的效能優化關鍵會是在「如何有效率的建立 React element」或是「重用那些沒有變化需求的舊有的 React element」,而非著重在 DOM 操作上,因為 DOM 操作的部分 React 會自動替你做到最好,你只需要專注於管理 React elements 就好。
不過,我們實際開發時並不會像上面的範例那樣多次的呼叫 root.render()
,而是只會呼叫一次,並且搭配 stateful 的 component,由 component 以其內建的機制來觸發畫面的更新。在後面的章節,我們將會更深入介紹 React 是如何以 component 的內建機制來管理畫面更新的流程細節。
建議參考資源:
答案是原有的內容在 root.render()
執行後會被 React 以 React element 轉換產生出來的結果直接蓋掉。由於 root 容器的內的空間已經被 React 視為為完全管轄的範圍,因此 React 每次在請求 Renderer 去更新 DOM 畫面結果時,都會直接無視原本已有的 DOM 內容,只確保將 root 容器內的 DOM 內容同步成與來源的 React element 的結構對應一致。如同前面曾經提到過的,這是一個 Virtual DOM(也就是 React elements)=> DOM 單向的同步流程。
React 支援一個前端 App 中有多個 root 的存在。不過在大多情況下,如果你的前端 App 是一個完全的 SPA(Single-Page Application)的話,我們會建議只用一個 React root 來管理並控制整個前端 App,讓其擁有完整的 UI 管轄範圍。
不過當你的前端 App 並不是從零開始時就用 React 打造,而是與其他前端解決方案混合的話,我們就可以在各個想要小範圍使用 React 的地方建立多個 root 並分別管理,來做到比較輕量的整合。
document.body
來當作 root 的容器元素?看完上述的範例,你可能會有一個疑問:
「為什麼我們不直接使用 document.body
來當作 root 容器元素,而是要自己另外建一個 div
呢?document.body
也是一個合法的 DOM element,應該可以用來當 root 容器元素不是嗎?」
這其實是因為當你嘗試這麽做時,React 會發出一段警告:
大意上就是說 React 不建議直接以 document.body
來當作 createRoot()
的容器元素。這是因為其它各種第三方套件經常會針對 document.body
進行子元素的操作或修改,因此以其作為 React 的 root 容器元素的話,有可能會使 React 對於其內容 DOM elements 的控制與管理不穩定(被其他套件覆蓋或意外的修改等等),因此建議還是手動在 body
裡面另外建立一個元素來作為容器元素會更妥當。
在某些非近期所撰寫的 React 程式碼或文章中,你可能會看到與上述教學中不太一樣的 React DOM API。這是因為 createRoot
是 React 18 以上才有的新方法,在 ≤ React 17 的較舊版本中,呼叫 React DOM 進行 render 的 API 有所不同:
// 這段程式碼所用的 React 版本 <= 17
// 注意這裡是從 'react-dom' 進行 import,而不是 React 18 會用的 'react-dom/client'
import ReactDOM from 'react-dom';
const buttonReactElement = React.createElement(
'button',
{ id: 'button1' },
'I am a button'
);
const targetContainerDomElement = document.getElementById('root-container');
// 這裡沒有先 createRoot 而是直接從 ReactDOM 呼叫了 render 方法
ReactDOM.render(buttonReactElement, targetContainerDomElement);
在經過快要一年的努力後,本系列文的實體書版本推出了~其中新增並補充了許多鐵人賽版本中沒有的脈絡與細節,並以全彩印刷拉滿視覺上的閱讀體驗,現正熱銷中!有興趣的話歡迎參考看看,也歡迎分享給其他有接觸前端的朋友們,非常感謝大家~
《React 思維進化:一次打破常見的觀念誤解,躍升專業前端開發者》
目前首刷的軟精裝版本各大通路已經幾乎都銷售一空,接下來會再刷推出新的平裝版本:
天瓏(平裝版預購):
https://www.tenlong.com.tw/products/9786263337695
博客來(平裝版):
https://www.books.com.tw/products/0010982322
momo(平裝版):
https://www.momoshop.com.tw/goods/GoodsDetail.jsp?i_code=12528845