Skip to content

[心得] Tech Book Community 線下小聚 -【Zet】React Render Props

· 26 min

前言#

哇…,又好久沒寫文章了嗚嗚,其實有一些內容想寫但是好忙碌🫠。

這篇先簡短紀錄一下昨天(7/19)參加 Tech Book Community 線下小聚的一點筆記和心得,有些需要消化的東西可能沒辦法寫得很清楚,內容可能較為片段零散,如果有任何想討論或有描述錯誤的地方,歡迎大家回覆告訴我~

想了解 Tech Book Community 是什麼的,可以參考此簡介,裡面也有加入社群的連結~

此次線下小聚邀請 Zet 大大分享 React 的 Render Props,Zet 大大就是《React 思維進化:一次打破常見的觀念誤解,躍升專業前端開發者》的作者本人🤩

Design Pattern 是什麼#

在進入正題 Render Props 之前,Zet 講了一些前情提要(?,第一點就是介紹何謂 Design Pattern。

雖然我之前在鐵人賽寫過 [Day 02] 設計模式簡介,不過 Zet 大大的解釋我覺得簡要明白~Design Pattern 就是被一再驗證、可重複套用的程式結構與協作範本。不是一個具體的技術、不是一段程式碼或是一套標準規則,它是程式設計前輩們在面對某些重複出現的問題時,認為好用的招式或解法。啊實際上要不要用這招式? 這招式適合我當下的情況嗎? 這就需要開發者自己判斷,不過當我們在看一個的 pattern 的介紹時,我們可以分幾個面向來看它,幫助我們判斷:

控制反轉與依賴注入#

Render Props 前情提要第二部分,是控制反轉與依賴注入。

控制反轉#

控制反轉(Inversion of Control, IoC)是一種程式設計的原則。意思是把流程的主導權(執行的時機或細節)交給外部機制(框架、函式庫、執行環境 API),程式主流程的呼叫端只專注提供「想做什麼事情(邏輯內容)」。

控制反轉示意圖(資料來源:自行繪製)

所謂的控制反轉,其中的「控制」是指程式執行流程與時機,而「反轉」是指主導權的方向。會說反轉是因為一般的程式流程主導權方向都是由主程式來控制執行細節和時機,但因為這裡主程式把執行細節和時機交給外部機制,因此反轉了原本的主導權,算是一種「我(主程式)信任你(外部機制)會幫我做好」的信任感? 一種下放權力嗎(??。

比較狹義的控制反轉會聚焦在物件導向設計時的狀況,不過我們還是能將「主控權反轉」的核心概念應用在其他非物件導向的情境中。

以上看起來十分抽象沒關係,Zet 大大舉了一個很熟悉的範例,就是手寫 for 和 Array map

const array1 = [1, 2, 3, 4, 5];
let array2 = [];
for (let i = 0; i <= array1.length - 1; i++) {
array2.push(array1[i] * 2);
}
console.log(array2); // [2, 4, 6, 8, 10]
const array1 = [1, 2, 3, 4, 5];
const array2 = array1.map(item => item * 2);
console.log(array2); // [2, 4, 6, 8, 10]

以上兩短程式碼中,可看出第一段是主程式自己用 for 迴圈處理資料的流程控制,但第二段是主程式呼叫了 map(外部機制) 並提供它想要的邏輯內容(每個 item * 2),而具體何時呼叫 item * 2 這個資料轉換、如何將資料放進新陣列,都由 map決定,主程式不會干涉。因此第二段使用 map 的方法,就屬於一種控制反轉的案例,它將主控權交給 map

其他控制反轉例如addEventListener,由瀏覽器主控處理,由瀏覽器負責在事件發生時執行邏輯,但主程式不會知道實際何時會執行。

不一定要有 callback 才算控制反轉#

主程式可透過宣告式資料作為「內容、想要的效果」給外部機制,外部機制再接手後續細節。

控制反轉沒有使用 callback 的案例如下:

React 的 reconciler 如何利用控制反轉#

這段大概是我當下最頭痛 🤯 的時刻了哈哈…。

在 react reconciliation 的 render phase 中,每一層 component function 在 render 時都只會回傳「描述下一層結構」的 React element,並不會立刻呼叫子 component 的函式。也就是說,當你以 component 為元素類型建立一個 React element 時,例如 const a = <Foo />,這裡只會建立一個 react element,但不會立刻呼叫 Foo component 這函式並渲染內容。

React 內部的 reconciler 才會真正決定「何時實際執行子 component 函式」,render 流程的主導權從 component 函式(使用端)反轉到 reconciler(框架),屬於控制反轉的案例。

也就是說,我們開發者建立了一個 react element,但這個 react element 什麼時候會被呼叫並渲染,是由 react 機制決定的。

流程拆解如下:

  1. 父元件被 Reconciler 呼叫 → 父元件回傳類型為子 component 的 React element。
  2. Reconciler 收到 react element 後,比較新舊元素節點的類型,再決定是否、何時呼叫子 component 的函式。
  3. 重複此遞迴,整棵樹形成一條「框架呼叫 → 元件回傳描述」的控制反轉鏈。

接著看看以下程式碼,了解 render 流程與控制反轉。

import { createRoot } from "react-dom/client";
function Child() {
console.log(1);
return <span>Child</span>;
}
function Parent() {
console.log(2);
const element = <Child />;
console.log(3);
return element;
}
console.log(4);
const appElement = <Parent />;
createRoot(document.getElementById("root")).render(appElement);
console.log(5);

console 印出的順序是 45231console.log(4); 這行會先被執行應該沒問題,接著const appElement = <Parent />;這裡只是建立 react element 但還不會實際執行 function 並渲染,再來執行 createRoot(document.getElementById("root")).render(appElement); 這行時,因為 react 的 createRoot非同步的,所以不會立刻執行 render,會先執行 console.log(5);

執行完 console.log(5); 後,react 的 reconciler 會開始呼叫 Parent 的 component function ,console 印出 2,然後 const element = <Child />; 這裡會建立一個 Child 的 react element ,但不會執行,所以會接著印出 3,並回傳一個 <Child /> React element。

當 reconciler 讀取到 <Child /> 時才真的觸發呼叫,此時 console 印出 1,並回傳對應 DOM element 的 React element <span>Child</span>

此時沒有子 component 需要繼續呼叫,所以 reconciler 會繼續後續流程。

React 為何要設計成「不立刻呼叫元件渲染」?#

為了要讓往下產生樹狀結構的流程不會一次全部做完、不可中斷。如果往下渲染子元件的流程每次都要一次全部完成,就會立刻形成一個很深的 call stack,就可能會長時間佔用 JavaScript 的 main thread,那瀏覽器就無法做其他事情,使用者的互動事件需要等 react 全部 render 完才能處理,這樣使用者會有網站卡住的感覺。更多可參考 React 18 的 concurrent render 說明。

react 為了避免堵塞瀏覽器 main thread,做了兩種狀況的流程拆分:

// 假設有一個需要渲染 10000 個項目的列表
function HugeList({ items }) {
return (
<ul>
{items.map(item => (
<ComplexItem key={item.id} data={item} />
))}
</ul>
);
}

如果同元件內有多樣內容要渲染,以前可能需要一次全部渲染完才能執行其他事件,但 react 現在可透過控制反轉的設計和 fiber 的架構來達到:

  1. 渲染一些項目後檢查執行時間
  2. 若執行太久,暫停出來讓 main thread 處理其他事務
  3. 下一個空閒時段繼續渲染剩餘項目

依賴注入#

依賴注入(dependency injection)是實踐控制反轉的手段之一,不過除了依賴注入,還有其他方式也可以實踐控制反轉,其他方式例如:依賴查找(dependency lookup)、策略模式(strategy pattern)等。

依賴注入意思是將系統需要的資料由外部傳入,而不是在內部自行建立或寫死,目的是將邏輯與資源分離。在 JavaScript 中,通常以「可替換的函式」或「宣告式資料」作為參數傳入。

範例:函式參數注入

// 依賴:fetcher (可真實 fetch、也可 mock)
function getUser(fetcher, id) {
return fetcher(`/api/users/${id}`).then(res => res.json());
}
// 實際情境呼叫
getUser(window.fetch, 1);
// 單元測試
const mockFetch = (url) => Promise.resolve({
json: () => ({ id: 1, name: 'Mock' })
});
getUser(mockFetch, 1);

getUser透過外部參數來決定 fetch 的方法,它不在乎 fetcher 函式如何實作,只在乎這個 fetcher 會回傳特定格式的 Promise。

在測試時,可注入 mock 實作當作參數,而不需要改動 getUser本身的實作內容。

Render props#

終於進入主題 render props,其實之前鐵人賽寫過 render props 這內容但自覺沒有寫很好🥲,Zet 大大的舉例更生動明確~

什麼是 render props#

render props 是一種 design pattern,它將「怎麼渲染 UI」這邏輯透過函式 prop 傳入(依賴注入)到子 component,由子 component 在適當時機呼叫(控制反轉)。

依賴注入的地方在於父 component 把一個 render function 當作 prop 傳入,子 component 不自己決定畫面。而控制反轉的地方是子 component 控制「何時、何處」執行函式,父 component 負責提供「想畫什麼」。

render props 示意圖(資料來源:自行繪製)

簡單程式範例如下。

function Child({ renderContent }) {
return renderContent();
}
function Parent() {
return <Child renderContent={() => <div>hello</div>} />;
}

🪶 render props 的前世(?:component 共用邏輯#

在 react hooks 出現以前,render props 主要用途是讓 component 之間可以共用邏輯,因為 hooks 出現前,class component 是定義狀態的唯一載體,如果要共用和 state 有關的邏輯就需要定義在 class component。

在 class component 時代,如果要讓 component 共用邏輯,可分兩種方式:

  1. Higner-Order Component(HOC):由父 component 將共用邏輯相關的資料以 props 傳遞給子 component
  2. Render Props:將「共用邏輯用途的 component」作為子 component,需求方作為父 component

在 hooks 出現以前,可以用這兩種方式共用邏輯,而又以 render props 更主流,因為 HOC 的方式會有命名衝突的問題。關於 HOC,可參考我之前鐵人賽寫 HOC 的文章

render props 共用邏輯的範例程式碼如下,但詳細的說明這裡就先不細講了,因為更重要的是 react hooks 時代來臨啦~

class Count extends React.Component {
state = {
count: this.props.initialValue
};
increment = () => this.setState({
count: this.state.count + 1
});
decrement = () => this.setState({
count: this.state.count - 1
});
render() {
return this.props.children({
count: this.state.count,
increment: this.increment,
decrement: this.decrement
});
}
}
function App (){
return (
<Count initialValue={0}>
{({ count, increment, decrement }) => (
<div>
<button onClick={decrement}>-</button>
<span>{count}</span>
<button onClick={increment}>+</button>
</div>
)}
</Count>
);
}

🌱 render props 的今生:component UI 的抽象化#

react hooks 出現後,純共用邏輯現在會採用 hooks,不會使用 render props,我們可以用 custom hooks 來定義共用邏輯。

因此現在 render props 的用途不會是純共用邏輯,而是用來做 component UI 的抽象化設計。render props 可透過控制反轉與依賴注入的特性來幫助我們做到某些情境的抽象化設計。

render props 實際的抽象化案例:lifting state#

此情境的目標是父 component 希望也能取得子 component 的 state,通常直覺想到的解法是將 state 的定義往上提到父 component 去。

例如以下,原本 CheckboxRowcheckedstate 是存在元件內。

import { useState } from "react";
function CheckboxRow({ label }) {
const [checked, setChecked] = useState(false);
const handleChange = () => {
setChecked((c) => !c);
};
return (
<div>
<input type="checkbox" checked={checked} onChange={handleChange} />
{label}
</div>
);
}
export default function App() {
return <CheckboxRow label="test" />;
}

但此時新需求是 App 需要利用 checkbox的勾選狀態來決定 checkbox 下方是否顯示 nested list,此時就會要把 checked state 的定義與相關邏輯提高到父 component 中。

import { useState } from "react";
function CheckboxRow({ label, checked, onChange }) {
return (
<div>
<input type="checkbox" checked={checked} onChange={onChange} />
{label}
</div>
);
}
export default function App() {
const [checked, setChecked] = useState(false);
const handleChange = () => {
setChecked((c) => !c);
};
return (
<div>
<CheckboxRow label="test" checked={checked} onChange={handleChange} />
{checked && (
<ul>
<li>item1</li>
<li>item1</li>
</ul>
)}
</div>
);
}

但這寫法的缺點是 checkbox nested list 相關的邏輯不能重用,任何想要做到「利用 checkbox的勾選狀態來決定 checkbox 下方是否顯示 nested list」的元件,都要複製貼上寫一樣的邏輯,並且再次定義 state。

此時就可用 render props pattern 解決這問題:

import { useState } from "react";
function CheckboxRow({ label, renderNestedList }) {
const [checked, setChecked] = useState(false);
const handleChange = () => {
setChecked((c) => !c);
};
let nestedList = null;
if (renderNestedList) {
nestedList = renderNestedList(checked);
}
return (
<>
<div>
<input type="checkbox" checked={checked} onChange={handleChange} />
{label}
</div>
{nestedList}
</>
);
}
export default function App() {
return (
<>
{/* checked 時才會顯示 nested list 的情境 */}
<CheckboxRow
label="test"
renderNestedList={(checked) => checked && (
<ul>
<li>item1</li>
<li>item1</li>
</ul>
)}
/>
{/* 無論如何都會顯示 nested list 的情境 */}
<CheckboxRow
label="test"
renderNestedList={() => (
<ul>
<li>item1</li>
<li>item1</li>
</ul>
)}
/>
</>
);
}

render props 可讓 state 寫在原本子元件中,把渲染 nested list 的邏輯以依賴注入的方式抽象化成一個 render function prop renderNestedList,在內部元件呼叫renderNestedList時,傳入 checked 作為參數,這樣重用CheckboxRow 時就可透過傳遞不同的 render function 並利用 checked 參數來做到客製化 nested list 渲染邏輯,兼顧可重用性和邏輯的彈性。

render props 實際的抽象化案例:render list item#

抽象化一個列表 UI 中項目的渲染邏輯為 props,例如以下範例,透過 renderPriceprops 提供客製化渲染的彈性,外部可以決定想怎麼渲染 price,但同時元件也定義了renderPrice的預設值,讓那些不想客製化的元件不需再另外定義 renderPrice,直接使用預設方式渲染即可。

function ProductList({
items,
title,
renderPrice = ({ price }) => "$" + price,
}) {
return (
<div>
<h3>{title}</h3>
{items.map((item) => (
<>
<div>{item.name}</div>
<div>{renderPrice(item)}</div>
<hr />
</>
))}
</div>
);
}
function DiscountProductList(props) {
return (
<ProductList
{...props}
renderPrice={({ price, discountRatio }) => (
<div>
<span style={{ textDecoration: "line-through" }}>${price}</span>
<span>${price * discountRatio}</span>
</div>
)}
/>
);
}
export default function App() {
return (
<>
<ProductList
title="水果列表"
items={[
{ name: "蘋果", price: 100 },
{ name: "西瓜", price: 200 },
]}
/>
<DiscountProductList
title="特價水果列表"
items={[
{ name: "橘子", price: 300, discountRatio: 0.7 },
{ name: "香蕉", price: 400, discountRatio: 0.9 },
]}
/>
</>
);
}

這方式兼顧封裝性與可重用性,將大多數固定的 UI 邏輯封裝在 component 內部,重用時盡量不用傳太多 props,只暴露需要客製化的 props 接口給外部。同時做到資料與 UI 分離,可從外部客製化部分 UI,並且 render props 的預設值可避免每次呼叫都需傳入 render function,提高重用性。

Render props 的優缺點#

優點

缺點

不適合的情境

容易誤用的情境

Best practice

QA#

開發大型專案如何決定資料夾結構? 狀態管理上依據甚麼經驗在管理工具?

以 feature 方向區分大方向層級,這些功能下有各自的 component 和 hooks,搭配 component unit pattern(將大 component 分好幾個檔案,最後用 index export 出來)。

大部分專案都不太需要第三方狀態管理工具(如:redux、zustand),除非有專門情境需要管理(例如 graphql 的 cache)或是需要細部的效能調教,才需要用第三方狀態工具。

建議剛入職 1–3 年的前端培養什麼能力?

學習的重點在於思考各種方案的流程

AI 可以產程式,還需要了解基本功嗎?

以現在來說,AI 可能還是會寫出不完全符合 best practice 的東西,也許當下可以跑但之後在別的情境就會失效,如果自己對機制完全不了解,就無法監督他。只要對 AI 講的需求不夠詳細,產出的程式還是會有問題,監督 AI 的能力才是核心能力

在新創公司怎麼培養找事情做的能力?

找有價值的事情做,所謂有價值的事情,可能是對自己的、對公司產品的、對開發流程體驗的價值,永遠都找得到事情。

想了也不一定要做,可以自己先研究就好。

在 AI 發達的現在,垂直加深對一個技術的理解 vs. 廣泛多元學習不同框架語言,哪一個效益比較大?

垂直加深。

在拆元件時,常在想會不會不小心拆太細?想聽講者分享到底該拆多細?

剛開始學習新技能時,會建議怎麼拿捏「先實作」與「了解背後原理」的平衡?要怎麼學習才不會一開始就鑽牛角尖?

小步迭代,先試著用用看,再去研究。小步嘗試實作,再去看原理,再看實作、再看原理,一直循環下去。

結語#

關於 render props 以及前情提要的控制反轉與依賴注入收穫蠻多的,其實日常開發中就已經在應用這些概念和原則了,只是我不知道而已~render props 之前開發時也用過這方式,當時只是想著我想要客製化這段邏輯,然後就抽成 render function 的 props 了XD,沒有在想什麼控制反轉依賴注入的哈哈,不過抽 props 時還是要注意抽象化的意義邊界,避免過度彈性,抽象化的邊界這點,自己覺得還是需要更多經驗🫠。最後很感謝 Lois 主辦聚會以及 Zet 大大精彩充實的分享!

實體小聚大合照👀