[React] 單向資料流介紹以及 component 初探

Monica
32 min readMar 15, 2024

--

《React 思維進化》 筆記系列
1. [React] DOM, Virtual DOM 與 React element
2. [React] 了解 JSX 與其語法、畫面渲染技巧
3. [React] 單向資料流介紹以及 component 初探
4. [React] 認識狀態管理機制 state 與畫面更新機制 reconciliation
5. [React] 在子 component 觸發父 component 資料更新、深入理解 batch update 與 updater function
6. [React] 了解 immutable state 與 immutable update 方法
7. [React] 認識 component 的生命週期、了解每次 render 都有自己版本的資料
8. [React] React 中的副作用處理、初探 useEffect
9. [React] 對 hooks 的 dependencies 誠實,以維護資料流的連動
10. [React] React 18 effect 函式執行兩次的原因及 useEffect 常見情境
11. [React] 認識 useCallback、useMemo,了解 hooks 運作原理

前言

承接上篇 [React] 了解 JSX 與其語法、畫面渲染技巧,此篇主要敘述的是《React 思維進化》 2–6 ~ 2–7 章節的筆記,若有錯誤歡迎大家回覆告訴我~

什麼是單向資料流

單向資料流(one-way dataflow)是目前前端框架中蠻主流的設計模式,了解單向資料流有助於我們理解前端框架的設計理念,因此我們來看看什麼是單向資料流吧~

單向資料流是一種以資料驅動畫面的設計模式,其資料流向是:原始資料➡️模板與渲染邏輯➡️畫面結果,過程都是單向且不可逆的,只有在資料改變時才會觸發畫面的改變,畫面本身不能逆向修改資料、畫面也不會在沒更新資料時就被任意修改;單向資料流的關鍵策略是「資料與畫面分離,且維持單一的資料來源」。

而為什麼要維持這種單向的資料流向呢?限制資料更新與畫面結果的變因有助於:

  • 提高可維護性:不只讓應用程式具備靈活度,讓整個應用能應變更多的情境,也讓程式碼更好維護,變因被限縮後,開發者會比較容易找到問題所在
  • 提升程式碼可讀性:開發者在閱讀理解程式碼時,因為知道這是單向資料流,比較知道如何去追蹤理解程式碼,如:知道某個資料更新會連動到某模板邏輯,然後影響某個畫面的更新,只需追蹤這過程中發生的事,就可理解導致畫面更新的邏輯了
  • 減少資料意外出錯的風險:因為畫面不會更改到原始資料,即使使用者不正確操作畫面也不會影響到資料
  • 更好的效能優化:可準確知道畫面依據哪些資料而變化,能依據此關係做進一步的效能優化

實現單向資料流的渲染策略

在前端開發中,通常會將資料和畫面分開處理以實現單向資料流,當資料更新後,再操作 DOM element 來讓畫面更新。以下以純 JavaScript 環境說明兩種能實現單向資料流的畫面更新策略。

策略一:資料更新後,人工判斷並手動修改所有應連動更新的 DOM element

假設有一個 Counter 畫面,資料是 counter 的值(counterValue)和按鈕被點擊的次數(clickTimes),點擊按鈕後,counterValue的值會 +2,clickTimes的值會+1,接著更新畫面;程式碼如下:

let counterValue = 0;
let clickTimes = 0;

function incrementCounterAndUpdateDOM(index) {
//先更新原始資料
counterValue += 2;
clickTimes += 1;
// 資料更新後,需要具體知道這次資料的更新會影響到的 DOM 範圍,並且手動一一去更新:
//只操作資料更新會影響的元素
document
.querySelector('#counter-value > span')
.textContent = counterValue;

// 修改 counter value 資料後,也需要更新點擊的總次數
//只操作資料更新會影響的元素
document
.querySelector('#click-time')
.textContent = clickTimes;

//補充:資料更新只會選取需要更新的 DOM 元素來更新,其他不變的元素(如:標題)則維持跟初始渲染一樣的值,不會被改動
}

function initialRender() {
// 只有初始化 render 時才會渲染整個畫面,包含標題和 counterValue, clickTimes
document.body.innerHTML = `
<div id="counter-wrapper">
<h1>My Counter</h1>
<p>點擊按鈕來讓 counter 每次都 +2</p>
<p id="counter-value">counter: <span>${counterValue}<span></p>
<button id="increment-btn">Clicked <span id="click-time">${clickTimes} </span> times</button>
</div>
`;

// increment button 事件綁定
const incrementButton = document.getElementById('increment-btn');
incrementButton.addEventListener('click', () => {
// 範例行為:increment counter 和 clickTimes
incrementCounterAndUpdateDOM();
});
}

initialRender();

過程中,被更新的資料會連動影響畫面的哪些 DOM element,需依賴開發者自行判斷(以上面範例程式碼來說,開發者須自行判斷會影響到 counter 的值和按鈕中的點擊次數文字),如何操作 DOM 的細節也要開發者手動操作(開發者要知道如何選到目標 DOM element,再更新 DOM 的內容)

此策略的優缺點:

  • 優點:最小化 DOM 操作
    如果開發者 DOM 操作足夠簡潔,可盡量減少多餘 DOM 操作造成的效能浪費。以上述範例來說,只更動需要變動的文字內容,其他不動,即可減少不必要的 DOM 操作
  • 缺點:應用龐大時,依靠人為判斷易出錯
    - 當資料更改會連動大量或複雜的畫面更新時,人為處理就容易遺漏或出錯,例如可能資料變動後需要更新數十個 DOM element,但開發者遺漏更新某些 DOM element,導致畫面結果不如預期
    - 畫面有問題時,很難快速定位是哪個環節出錯,即使資料沒問題,也可能是操作 DOM element 時有錯而導致畫面結果不如預期(如上一點的例子,可能是開發者遺漏更新 DOM element,而非資料有錯),這會導致單向資料流遵循的「畫面是資料的延伸結果」已不可靠

因此,此策略完全依賴開發者的人為判斷和對 DOM 的精確操作,在大型應用中會比較不可靠、難以維護。

策略二:資料更新後,一律將整個畫面的 DOM element 全部清除,再以最新的原始資料全部重繪

承接策略一的範例,改成在資料更新後一律重繪整個畫面,不管資料更新是影響哪些畫面元素。

當我們更新 counterValueclickTimes 後,不需要知道這次資料更新會需要連動更新哪些畫面元素,而是清空整個畫面的DOM,再依據最新資料重新繪製整個畫面。

let counterValue = 0;
let clickTimes = 0;

function handleIncrementButtonClick() {
// 範例行為:increment counter 和 clickTimes
counterValue += 2;
clickTimes += 1;

// 在更新資料後,不需要判斷這次資料更新具體會影響到的 DOM elements 有哪些,
// 一律呼叫 renderScreen() 來將整個畫面的 DOM elements 都清除後再全部重繪
renderScreen();
}

function renderScreen() {
// 每次要繪製新畫面之前,都先把整個瀏覽器畫面全部清空
document.body.innerHTML = '';

// 依據目前的最新資料,重新繪製一次整個畫面的所有 DOM elements
document.body.innerHTML = `
<div id="counter-wrapper">
<h1>My Counter</h1>
<p>點擊按鈕來讓 counter 每次都 +2</p>
<p id="counter-value">counter: <span>${counterValue}<span></p>
<button id="increment-btn">Clicked <span id="click-time">${clickTimes} </span> times</button>
</div>
`;

// 重新綁定 increment button 事件
document
.getElementById('increment-btn')
.addEventListener('click', handleIncrementButtonClick);
}

renderScreen();

過程中,開發者不用管資料更新的方式是什麼(例如:新增、修改或刪除),只要一律全部清空畫面,再根據最新的資料重新繪製畫面即可。

此策略的優缺點:

  • 優點:開發者負擔降低
    只需關注模板定義和資料更新的處理,不需手動維護資料連動到畫面的操作,實現單向資料流較直覺簡單
  • 缺點:效能浪費
    因為是整個畫面清除後再重新繪製,與更新的資料無關的 DOM element 也會被全部移除再重繪,當應用程式更複雜時,就會產生效能問題,影響使用者體驗

前端框架的處理策略

上述渲染策略都有其缺點,而大多數前端框架能透過一些特殊設計來解決這些問題,讓開發者在應用這些策略時,能同時享有優點並解決缺點。Vue.js 就是採取策略一,透過追蹤資料與模板間的關係,當資料更新時,自動找到與資料變化相關的 DOM 元素並更新,可省掉開發者手動維護資料連動畫面的操作;而 React 則採取策略二,接下來就來看看 React 如何實現一律重繪渲染策略吧~

React 的一律重繪渲染策略

在前面的章節提過,採用 Virtual DOM 的概念可優化效能,當畫面要更新時,會產生新的 Virtual DOM 畫面結構,並比較新舊的 Virtual DOM 的差異,再根據差異處操作最小範圍的實際 DOM,藉此減少效能浪費;而前面策略二又說,一律重繪策略會刪除又重建一些不需要被更新的 DOM element,而導致效能問題。

🧠!上面這兩點聽起來好像可以湊一起欸~如果一律重繪實際 DOM 會造成效能浪費,那改成一律重繪 Virtual DOM 呢?因為 Virtual DOM 只是 JavaScript 中的普通物件資料,不像實際 DOM 會和瀏覽器的一系列渲染行為綁定,重繪 Virtual DOM 會比重繪實際 DOM 更節省效能;這是個很好的想法,而 React 就是用這種方式,在一律重繪的策略二中,改用一律重繪 Virtual DOM 的方法來解決效能問題,而 React 中的 Virtual DOM 就是 React element,因此就是一律重新產生 React element~

資料更新時,React 會以新版本資料重新繪製新版的 React element,再和舊的 React element 比較,找出差異處,最後只更新這些差異處對應的實際 DOM element,示意圖如下:

因此,在 React 提及「render」通常是指「產生 React element 的流程,「re-render」則是指「重繪 React element」,以新的資料重新產生新的 React element。

延續上面範例,來看看 React 是如何渲染畫面的~

初始畫面渲染
在第一次畫面渲染時,實際 DOM 還沒有任何元素,React 會將完整的 React element 結構對應到實際 DOM element 上,此時的 React element 會是第一個版本。

// 原始資料 counterValue 為 0,clickTimes 為 0
const reactElement1 = (
<>
<div id="counter-wrapper">
<h1>My Counter</h1>
<p>點擊按鈕來讓 counter 每次都 +2</p>
{/* counterValue 的值*/}
<p id="counter-value">counter: <span>0<span></p>
{/* clickTimes 的值*/}
<button id="increment-btn">Clicked <span id="click-time"> 0 </span> times</button>
</div>
</>
);

更新畫面的渲染
當使用者點擊一次按鈕時,counterValue 被更新為 2,clickTimes 被更新為 1,React 不會修改舊的 React element(記得之前提過,React element 在建立後是不可被事後修改的嗎?),而是以新版資料再次產生一個完整畫面的、新的 React element:

// 原始資料 counterValue 為 2,clickTimes 為 1
const reactElement2 = (
<>
<div id="counter-wrapper">
<h1>My Counter</h1>
<p>點擊按鈕來讓 counter 每次都 +2</p>
{/* counterValue 的值*/}
<p id="counter-value">counter: <span>2<span></p>
{/* clickTimes 的值*/}
<button id="increment-btn">Clicked <span id="click-time"> 1 </span> times</button>
</div>
</>
);

產生新的 React element 後,會和舊的 React element 比較並找出差異處,並只更新這些差異處對應的實際 DOM element,其餘皆不動。

📑 React 各種觀念與核心機制幾乎都是為了實現單向資料流,畫面管理與資料機制都圍繞這策略而打造;了解此策略有助於之後學習更多 React 的管理機制哦~

畫面組裝的藍圖:component 初探

什麼是 component

component(元件)是一段可重用的程式碼,這段程式碼是開發者自定義的畫面元件藍圖,負責處理特定範圍的畫面內容或邏輯。可透過不同 component 來組合出整個畫面,如:sidebar、menu、footer 等,component 內也可包含 component,嵌套後組合成更複雜的 component。

通常我們會根據商業邏輯或重用性,來設計一個自定義的 component。例如一個商品列表的畫面,在列表區塊會包含數個商品品項的子區塊,及分頁控制項,就可分為下列 component:

  • ProductList:最外層的列表,包含 ProductListItem 和 Pagination 兩種子 component
  • ProductListItem:每一個商品項目的區塊,商品資訊不同,但樣式與排版相同、可重用
  • Pagination:將分頁控制抽象化,讓其他列表也可重用分頁功能

拆分元件的好處是什麼?拆分元件有助於提高程式碼可重用性,也讓維護與管理程式碼更有效率,例如如果設計師說商品項目的樣式要更改,開發者只需要更改 ProductListItem 這個 component 的樣式即可,不需要更改太多地方,就可讓整體樣式一起被更改,讓維護、修改程式碼變得更有效率。

定義 component

React component 可透過 JavaScript 的函式來定義,此函式接收開發者自訂格式的 props (properties,屬性) 資料作為參數,並回傳一個 React element 作為畫面結構區塊。component 的名稱可自定義,但 component function 名稱的首字母必須大寫。

export default function MyTitle(props){
return (
//這裡的 JSX 語法在轉譯後是 React element 方法的呼叫,所以這 function 回傳的是一個 React element
<h1>I'm a title</h1>
)
}

一個定義好的 component 是記載「一段產生特定結構 React element 的流程」,也就是紀錄如何產生一段 React element,例如:如何得到需要的值、如何運算或判斷要產生哪段 React element 等等,這些邏輯會依據需求一併被封裝進元件內。而函式適合用來定義一段邏輯或流程,是 React 可以用函式來定義 component 的原因之一。

補充:React component 可以用普通 JavaScript 函式來定義,也可用 class 語法來定義。React 16.8 後,function component 搭配 hooks 逐漸取代傳統的 class component,成為主流選擇;因此新開發者建議直接學 function component 搭配 hooks

呼叫 component

自定義的 component 可透過 React element 的形式被呼叫:

const reactElement = <MyTitle />;
//JSX 會被轉譯成:
const reactElement = React.createElement(MyTitle);

React.createElement 第一個參數(元素類型)是 component function 時,就能建立這個 component 定義的 React element。

React 看到 component function 後,就會執行 component function,再將其回傳的畫面區塊放置到原本呼叫自定義 component 的地方,最後再和其他區塊一起被轉為實際的 DOM element。

function MyTitle(props){
return (
<h1>I'm a title</h1>
)
}

//MyTitle 被呼叫三次,component function 就會被執行三次
//最後將 component function 回傳的 React element 放到 <MyTitle /> 放置的位子上
const reactElement = (
<ul>
<li>
<MyTitle />
</li>
<li>
<MyTitle />
</li>
<li>
<MyTitle />
</li>
</ul>
);

藍圖與實例
進一步說明 component function 與呼叫 component 的差異:

  • component function:是一份描述特徵、流程與行為的「藍圖」,是特定畫面的產生流程與邏輯,不是已經產生好的畫面。(有點類似:欸我預計要這麼做!這是我的規劃啦,但我還沒做XD)
  • 呼叫 component:產生實際的「實例」,呼叫 component 以後,就會根據藍圖實際產出東西,但產出的東西彼此之間是獨立存在、互不影響的

舉例來說,component function 就像是一份如何製作抹茶巴斯克的食譜或配方,是一份藍圖,告訴你可以按照這流程製作,而我們可以按照這食譜製作很多份相同的抹茶巴斯克,也就是產出實際的「實例」,但每一份抹茶巴斯克都是獨立的,不會吃了抹茶巴斯克 A 而導致抹茶巴斯克 B 也變少了;而 component function 是一份配方,代表實際製作時我們可以根據需求、客製化產出不同甜度的抹茶巴斯克。

上述範例的 MyTitle 這個 component function 就是一份「如何產出特定模樣的標題」的藍圖,而以 <MyTitle /> 呼叫 component 時則是實際產出實例,建立 React element。

補充:藍圖與實例
當我們在程式設計中,用一個函式或模板描述某種特徵、處理流程或行為時,會稱這函式是一種「藍圖」;而根據藍圖產生的實際個體,稱為該藍圖的「實例」,每個實例都有獨立狀態,不受其他實例影響。

Import 與 export component

實際開發時,通常會將 component function 定義在獨立檔案中,並搭配 ES module 語法,依據匯出方式不同,會有對應的匯入語法。

匯出的方式分為 default export 與 named export:

  • default export:一個檔案中只能有一個 default export
function MyComponent(){
//component 內容
}
export default MyComponent
  • named export:一個檔案中可以有多個 named export
export function ComponentA(){
//component 內容
}
export function ComponentB(){
//component 內容
}

匯入的方式則依據匯出的方式而定:

  • 如果是用 default export
import MyComponent from './MyComponent'
  • 如果是用 named export
import {ComponentA, ComponentB} from './MyComponents'

在慣例上通常會讓一個檔案有一個主要的 component 來作為 default export,且檔案命名與 component 名稱相同。

Props

什麼是 props ?「props(properties)」這個機制可讓我們將特定參數從外部傳給 component,讓 component 可根據參數進行客製化的流程或邏輯,讓 component 更有彈性。React component 的 props 抽取我們關注的特性,並封裝其他實作細節在 component 中,當我們使用 component 時,只須根據需求傳遞不同 props,就能有效重用,而不用知道 component 內部的實現細節。

如何傳入 props? 可在呼叫 component 時傳入 props,這些 props 會自動被打包成一個 JavaScript 物件,實際執行 component function 時此物件會作為函式參數傳入。

// 以下是呼叫 component 時傳入 props 的範例
// 調用兩次 ProductListItem component,但傳入不同 props,以呈現不同資訊
const reactElement = (
<div>
<ProductListItem
title="Book"
price={180}
imageUrl="./book.jpg"
/>
<ProductListItem
title="pencil"
price={100}
imageUrl="./pencil.jpg"
/>
<div/>
)

React 沒有限制 component 的 props 可以傳遞什麼樣的資料型別,一切由開發者自行決定,基本資料型別、物件、陣列、函式、React element(其本身只是個 JavaScript 物件)都可作為 props 的值。

接收與使用 props
component function 接收的第一個參數是 props 物件,此物件會包含調用 component 時傳入的各種值,可直接取用 props 物件,或用物件解構的方式取得 props 內容。

  • 取得 props 物件
export default function ProductListItem(props){
return (
<div>
<h2>{props.title}</h2>
<img src={props.imageUrl}/>
<p>${props.price}</p>
</div>
)
}
  • 以物件解構的方式取得內容,習慣上更建議這樣做,會讓開發更有效率
export default function ProductListItem({titile, price, imageUrl}){
return (
<div>
<h2>{title}</h2>
<img src={imageUrl}/>
<p>${price}</p>
</div>
)
}

Props 唯獨、不可被修改
React 的 props 是唯獨的,目的是維護單向資料流可靠性,讓資料源頭保持不變以便追蹤。如果在 component 內部隨意修改 props 會導致無法找到資料改變的源頭、難以預測原始資料與畫面結果的因果關係、難以追蹤導致畫面錯誤的根源等等非預期性錯誤。

開發環境的 React 會以 Object.freeze(props) 來將 props 物件凍結起來,以避免開發者不安全的修改 props。

export default function ProductListItem(props){
//⛔️ 這是錯誤示範! 請不要這樣修改 props!
// 開發環境的 React 會讓你的 props 修改無法產生效果
props.price = props.price * 0.9;

return (
<div>
<h2>{props.title}</h2>
<img src={props.imageUrl}/>
<p>${props.price}</p>
</div>
)
}

如果需要以 props 的值做其他運算,可創建新變數,將依據 props 的值計算而產生的值儲存在新變數。

export default function ProductListItem(props){
//✅ 另外用新變數存放運算結果
const discountPrice = props.price * 0.9;

return (
<div>
<h2>{props.title}</h2>
<img src={props.imageUrl}/>
<p>${props.price}</p>
</div>
)
}

⚠️ 不過 React 針對 props 做的 Object.freeze(props) 輔助阻擋仍有其限制,有時候 React 還是無法偵測到 props 被更改,而導致畫面產生錯誤行為,因此還是需要開發者自己有意識地去避免踩雷。

特殊的 props:children
children
prop 賦值方式和其他 prop 不同,會在呼叫 component 時將 children prop 的值寫在開標籤和閉標籤之間,像這樣:<MyComponent>你好,我是 children prop 的值</MyComponent>

在 component 內,可透過 props.children 將值取出:

export default function MyComponent(props){
return <div>{props.children}</div>
}

children prop 常見用途是用來設計「畫面容器」類型的 component,由component 提供容器的結構或樣式,但不寫死容器內的具體內容,具體容器內容是呼叫 component 時再由外部透過 props 傳入。例如我們想要有個有圓角、淺灰底的卡片樣式,但卡片內容是什麼則由呼叫時決定,就可以這樣寫:

function Card(props){
//透過 .card 定義Card的樣式,但容器內容由外部提供
return (
<div className='card'>
{props.children}
</div>
)
}

function App(){
return (
<Card>
<h1>Hello React!</h1>
<p>I am learning React</p>
</Card>
)
}

上篇文章有提到「React element 的子元素支援型別」,要定義一個對應實際 DOM element 類型的 React element 時,其子元素只能是特定型別,因為子元素會轉為實際的 DOM element,像函式或物件就不可作為子元素(也就是不可作為 children prop),例如: <div>{{ id: 1, name: "foo" }}</div>這樣寫是不行的,物件{ id: 1, name: "foo" }沒辦法轉為實際 DOM element 渲染在畫面上。

⬆️這裡之前筆誤,<div>{[1,2,3]}</div>可以渲染在畫面上的哦,因為陣列子元素都是數值,數值可被轉為字串直接印出,更多說明請見這篇

然而,component 類型的 React element 的 children prop 沒有類型限制,可填入任何型別,因為自定義 component 的 children prop 會如何應用由開發者自行決定,不一定會用在渲染畫面元素,舉例如下:

// 傳入一個函式作為 MyButton 的 children
// MyButton 內沒有將 children 渲染在畫面上,而是作為事件綁定的函式
// 補充:不過 children prop 還是較常用在容器類型的 component,例如上面 Card 的例子
function MyButton(props){
return(
<button onClick={props.children}>
click meeee
</button>
)
}

function App(){
return (
<MyButton>
{()=>console.log('Hello React!')}
</MyButton>
)
}

由此可看出,children prop 和其他一般 props 一樣,沒有類型限制,children 除了傳值方式較特別,其他特性其實都和一般的 props 特性相同~

父 component 與子 component

component 內可呼叫其他 component 作為子 component,可藉此來組裝較複雜的畫面。以一個常見的商品列表為例,我們可分為 ProductListItem、ProductList、App 三個 component,他們的嵌套關係如下:

  • ProductListItem:根據傳入的 props 顯示商品詳細資訊
export default function ProductListItem({
title,
price,
discountRange,
imageUrl,
}) {
return (
<div className="product-list-item">
<h2>{title}</h2>
<p>價格:{price}</p>
{/* 如果 discountRange 存在,才會顯示 price * discountRange 的折扣價格 */}
{Boolean(discountRange) && <p>折扣價:{price * discountRange}</p>}
<img src={imageUrl} />
</div>
);
}
  • ProductList:可根據「熱門商品」資料或「特價商品」資料(藉由傳入的 products prop 資料來分辨)來顯示不同列表
import ProductListItem from "./ProductListItem";
export default function ProductList({ products }) {
return (
<div className="product-list">
{products.map((product) => (
<ProductListItem
key={product.id}
title={product.title}
price={product.price}
discountRange={product.discountRange}
imageUrl={product.imageUrl}
/>
))}
</div>
);
}
  • App:傳入不同的商品列表資料給 ProductList,分別展示「熱門商品」和「特價商品」
import ProductList from "./ProductList";
const popularProducts = [
{ id: 1, titile: "popularItem1", price: 1200, imageUrl: "1.jpg" },
{ id: 2, titile: "popularItem2", price: 800, imageUrl: "2.jpg" },
];
const onSaleProducts = [
{
id: 3,
titile: "onSaleItem1",
price: 600,
discountRange: 0.8,
imageUrl: "3.jpg",
},
{
id: 4,
titile: "onSaleItem2",
price: 400,
discountRange: 0.7,
imageUrl: "4.jpg",
},
];

export default function App() {
return (
<div className="App">
<h1>熱門商品</h1>
<ProductList products={popularProducts} />

<h1>特價商品</h1>
<ProductList products={onSaleProducts} />
</div>
);
}

從上述例子可看出,拆分 component 可讓每個檔案、每個 component 的職責更明確且單一,component 有各自的任務,組合後就可渲染完整畫面,可看出這樣的拆分有助於提高程式碼可讀性和可重用性。

Component 的 render 與 re-render

當我們以<ComponentName /> 呼叫 component 時,React 在繪製畫面時就會執行 component function 內的邏輯,並在最後回傳一段 React element,而這過程就稱為 component 的「一次 render」。

如果 component function 回傳的 React element 有呼叫其他 component 作為子 component,就會接著觸發子 component 的一次 render,層層往下觸發,直到不再遇到子 component 為止,最後組成可對應實際 DOM element 的 React element 結構。

大概的流程是這樣:

  1. 呼叫父 component
  2. 執行父 component 的一次 render
  3. 父 component 回傳 React element
  4. 發現回傳的 React element 包含子 component
  5. 觸發執行子 component 的一次 render
  6. 回傳子 component 的 React element
  7. 確認此 React element 內沒有其他子 componet (如果有就繼續觸發子 component 渲染)
  8. 將子 component 回傳的 React element 組裝進父 component 的 React element
  9. 組裝成可對應實際 DOM element 的 React element 樹狀結構
  10. 以這結構產生或更新實際 DOM

接下來以包含多層 component 的範例來說明 render 的過程:

function MyComponent1() {
console.log("render MyComponent1");
return (
<div className="MyComponent1-wrapper">
<h1>I am MyComponent1</h1>
<MyComponent2 />
<MyComponent2 />
</div>
);
}

function MyComponent2() {
console.log("render MyComponent2");
return (
<div className="MyComponent2-wrapper">
<h2>I am MyComponent2</h2>
<MyComponent3 />
<MyComponent3 />
</div>
);
}

function MyComponent3() {
console.log("render MyComponent3");
return (
<div className="MyComponent3-wrapper">
<h3>I am MyComponent3</h3>
</div>
);
}

export default function App() {
return <MyComponent1 />;
}

可從 console 來看 render 的順序:

整個 render 流程由上至下、由外至內,React 會在每個子 component render 完成後,再繼續處理下一個 component,呼叫流程示意圖如下:

從一律重繪策略到 component 的 re-render
當 component function 首次呼叫並執行時,會執行第一次 render,產生初始狀態畫面,而當 component 內部狀態更新時,React 會再次執行 component function 以產生新版本的畫面,稱為「re-render」,re-render只是重新產生 React element,而操作實際 DOM,因此不會直接連動瀏覽器渲染引擎的渲染。

為什麼 component 命名中的首字母必須為大寫?

因為 transpiler 會透過標籤的首字母大小寫來判斷這是對應實際 DOM element,還是對應自定義 component。

當標籤類型名稱首字母為小寫,例如<p> ,transpiler 會將 p 視為字串,轉譯成 React.createElement('p');;當標籤類型名稱首字母為大寫,例如<MyTitle> ,transpiler 會將MyTitle視為變數,轉譯成 React.createElement(MyTitle);

另外,自定義 component 命名的首字母若使用大寫,能讓開發者更方便判斷是否為自定義 component,對開發體驗也比較好。

最後,補充作者 Zet 提出的問題:「component 這東西設計的本質與意義是什麼?」

此問題核心關鍵是「依據需求及邏輯意義進行抽象化」,開發者根據需求將關心的特徵、行為歸納出來後,設計一套適用於特定情境和範圍的流程,並將實作細節封裝起來以便重用,這一套被封裝起來的東西就是 component;也因此設計 component 時我們應該思考:這個 component 預計服務的情境有哪些? 表達的意義範圍邊界到哪? 再根據這些方向設計 props 與資料流、或拆分更多 component…等。

如有任何問題歡迎聯絡、不吝指教✍️

--

--