前端框架
-React Compiler 介紹 - 甚麼是 compiller?他有哪些好處?
React 19 除了更新了很多有關表單和異步操作的 hook 以外,最令人興奮的還是 React Compiler 的消息。
那究竟 React Compiler 是甚麼?又為什麼讓人興奮?今天這篇文或從淺到深好好介紹讓你知道。
❗注意:
- 建議先理解 React 中的 React.memo、useMemo、useCallback 再來看這篇文章。
- React Compiler 並不是 React 19 的更新內容,只是先釋出 beta 版本。
甚麼是 Compiler?
Compiler 是一種工具,它可以將一種程式語言,轉換成另一種程式語言,舉例來說,將 TypeScript 轉換成瀏覽器看得懂的 JavaScript,又或是將 JSX 轉換成 React 需要的 JS 物件。
但要真正裡解 Compiler 在框架中扮演的角色,我們要來先看看前端框架的分類,基本上前端框架可以分成三種,運行時 (Runtime)、純編譯 (Compile-time)、編譯 + 運行時混合 (Hybrid)。
運行時 Runtime
我們先看看純運行的框架。假設現在框架有一個 Render 函數,我們可以給這個函數一個數據結構,然後這個 Render 就會根據這個結構將數據渲染成 DOM 元素。
const obj = {
tag: 'div',
children: [
{
tag: 'p',
children: 'Hello World'
}
]
};
接著傳給 Render 函數。
function render (obj, root) {
const el = document.createElement(obj.tag);
if (typeof obj.children === 'string') {
// 如果 children 是字串,就直接新增 text node
const text = document.createTextNode(obj.children);
el.appendChild(text);
} else if (obj.children) {
// 如果不是字串,就遞迴 render 函數
obj.children.forEach((child) => render(child, el))
}
root.appendChild(el);
}
// 接著就可以使用先前定義的數據結構,並掛載到 body
render(obj, document.body)
但每次要使用 render 函數時,我們都必須手動寫一個複雜的數據結構,很麻煩又不直觀,我們能不能直接寫一個類似 HTML 的標籤來表示這個數據結構?
運行加編譯混合 (Hybrid)
為了解決這個問題,我們需要引入編譯的手段,將 HTML 標籤編譯成 JS 的數據結構,這樣不就可以使用 render 函數了嗎?所以我們寫了一個 Compiler 函數用來編譯 (先不用管如何實現。)
const htmlStr = `
<div>
<p>Hello World</p>
</div>
`
// 使用 Compiler 編譯
const obj = Compiler(html)
// 編譯成 👇
/*
const obj = {
tag: 'div',
children: [
{
tag: 'p',
children: 'Hello World'
}
]
};
*/
這不就是 JSX 嗎?沒錯,所以 React 是一個運行加編譯的框架。將比較符合開發直覺的 JSX 編譯成 JS 物件。
到這邊已經製作了一個蠻好用的框架了。雖然上面的程式碼準確來說是運行時編譯的,這會增加一定的效能開銷,因此我們也可以再構建時(build)時就執行 Compiler 函數將內容提前編譯,這樣運行時就不用編譯了。
純編譯 (Compile-time)
但居然我們都能在建構(Build)時編譯了,那為何不直接編譯成命令式程式碼呢,這樣就又不用 render 函數,減少更多性能開銷了。例如以下 👇
<div>
<p>Hello World</p>
</div>
// 編譯成 👇
const div = document.createElement('div');
const p = document.createElement('p');
p.innerText = 'Hello World';
div.appendChild(p);
document.body.appendChild(div);
這樣效能不就更好嗎?
沒錯,但犧牲的是一定的靈活性。Svelte 就是純編譯框架,所以性能非常好。
而 Vue 和 React 都算是運行加編譯混合的框架。
React Compiler 是什麼?
而最近 React 團隊推出的 React Compiler 就是加強編譯的部分,優化編譯後的程式碼,提高程式的性能。
React Compiler 要解決甚麼問題?
在了解 React Compiler 做了甚麼之前,應該要先看看以往 React 遇到甚麼問題。
以往 React rerender 的問題
在 State 更新時,React 組件都會重新渲染一次,但有些組件並不需要跟著重新渲染,舉個例子。
先定義一個 TodoList 組件,可以傳入 todos
、clearTodosFn
,每當渲染這個組件時,就會及 console render TodoList Component
。
const TodoList = ({ todos, clearTodosFn }) => {
console.log("render TodoList component");
return (
<>
<ul>
{todos.map((todo) => (
<li key={todo}>{todo}</li>
))}
</ul>
<button onClick={clearTodosFn}>clear</button>
</>
);
};
接著簡單定義一個 Username 組件,可以傳入參數來決定 username,並引用 TodoList Component。每當渲染 Username 組件的時候也會 console 出 render Username component
const Username = ({ name }) => {
console.log("render Username component"); // 👈
return (
<div>
<p>{name}'s todo</p>
<TodoList />
</div>
);
};
最後將上面 Username
和 TodoList
放進 App 裡,並在 App 中定義 todos 和 clearTodosFn
傳入 TodoList
,並寫一個 calculateTodosCount
來計算總共的 todos, 最後定義一個 count state 用來觸發 rerender。
export default function App() {
const [count, setCount] = useState(0);
const [todos, setTodos] = useState(["learn React", "buy milk"]);
const clearTodosFn = () => {
console.log("clear todos");
setTodos([]);
};
const calculateTodosCount = (todos) => {
console.log("calcuate todo count");
return todos.length;
};
const totalTodosCount = calculateTodosCount(todos);
return (
<div className="App">
<Username name={"ThisWeb"} />
<TodoList todos={todos} clearTodosFn={clearTodosFn} />
<p>Total Todos Count : {totalTodosCount} </p>
<hr />
<p>{count}</p>
<button onClick={() => setCount(count + 1)}>Add</button>
</div>
);
}
每當點擊 add 按鈕時,都會因為 count state 的更新,重新渲染 Username 和 TodoList 這兩個組件,這其實很沒必要,因為 Username 和 TodoList 並沒有任何變化,參考下方影片 👇
可以發現每次更新狀態時,都會重新計算 todo count 並重新 render Username 和 TodoList
為了避免這件事情,我們可以使用 useCallback、useMemo、React.memo 等 Hook 或 API 來解決。
使用 React.memo、useCallback、useMemo 來避免 rerender
要避免不必要的 rerender,我們可以使用 React.memo、useCallback、useMemo,這裡簡單介紹一下
- React.memo 當傳入組件的 props 沒有改變時,就不會 rerender
- useCallback 避免在每次渲染時重新創建函數
- useMemo 避免不必要的重新計算
我們可以利用這三個 Hook、API 來優化我們的程式碼。
先在 TodoList 裡使用 memo,這樣只要傳入的 todos、clearTodosFn 沒有改變,就不會重新渲染這個組件。
const TodoList = memo(({ todos, clearTodosFn }) => {
console.log("render TodoList component");
return (
<>
<ul>
{todos.map((todo) => (
<li key={todo}>{todo}</li>
))}
</ul>
<button onClick={clearTodosFn}>clear</button>
</>
);
});
同理,我們也在 Username 使用 memo。
const Username = memo(({ name }) => {
console.log("render Username component");
return (
<div>
<p>{name}'s todo</p>
</div>
);
});
最後在 App 使用 useCallback 和 useMemo 來避免不必要的重新計算。
- 在 clearTodosFn 使用 useCallback 是為了避免每次渲染時重新創建 clearTodosFn 導致重新渲染 TodoList
export default function App() {
const [count, setCount] = useState(0);
const [todos, setTodos] = useState(["learn React", "buy milk"]);
const clearTodosFn = useCallback(() => { // 👈 useCallback
console.log("clear todos");
setTodos([]);
}, []);
const calculateTodosCount = (todos) => {
console.log("calcuate todo count");
return todos.length;
};
const totalTodosCount = useMemo(() => calculateTodosCount(todos), [todos]);
// 👆 useMemo
return (
<div className="App">
<Username name={"ThisWeb"} />
<TodoList todos={todos} clearTodosFn={clearTodosFn} />
<p>Total Todos Count : {totalTodosCount} </p>
<hr />
<p>{count}</p>
<button onClick={() => setCount(count + 1)}>Add</button>
</div>
);
}
此時再更新 count state 就可以避免任何不必要的渲染的。
但可以發現這樣分常麻煩,開發的心智負擔非常大,我們需要時時刻刻注意那些狀態會造成不必要的重新渲染,而過度使用這些 Hook 對效能來說也不一定好。
React Compiler 解決使用 React.memo、useMemo、useCallback 的心智負擔
因此 React 推出的 React Compiler 就是在解決這部分的問題,他會在 Compiler 階段對程式碼優化,自動去判斷哪些組件需要重新渲染,哪些不需要。
簡單說就是,未來我們不再需要使用 React.memo、useMemo、useCallback 的功能了~!
那他背後究竟做了哪些事情呢?讓我們來看看
React Compiler 的背後原理
以 Username 組件為例子
function Username ({ name }) {
console.log("render Username component");
return (
<div>
<p>{name}'s todo</p>
</div>
);
};
以往 React 會只是單純將 JSX compile 變成 JS 物件而已,所以每當狀態改變時都會重新渲染。
Current Compilerfunction Username({
name
}) {
console.log("render Username component");
return __jsx("div", null, __jsx("p", null, name, "'s todo")); // 👈 將 jsx 轉換成 JS 物件
}
而現在 React Compiler 會自動 cache props 或 state,當 props 或 state 沒有變化時,直接返回原來的值,而不是額外執行 __jsx
函數。
function Username(t0) {
const $ = _c(2);
const { name } = t0;
console.log("render Username component");
let t1;
if ($[0] !== name) { // 👈 當 props - name 有變更時
t1 = __jsx("div", null, __jsx("p", null, name, "'s todo")); // 👈 計算新的 t1
$[0] = name; // 👈 將 name 記錄在 $[0]
$[1] = t1; // 👈 將 t1 記錄在 $[1]
} else { // 👈 當 props - name 沒有變更時
t1 = $[1]; // 👈 直接取得記錄過的 $[1]
}
return t1;
}
State 也是相同的道理,假設現在有一個 Counter 組件,裡面有 counte state
function Counter() {
const [count, setCount] = useState(1);
return (
<div>
<p>{count}</p>
</div>
)
}
React Compiler 會將其編譯成
New React Compilerfunction Counter() {
const $ = _c(2);
const [count] = useState(1);
let t0;
if ($[0] !== count) { // 👈 當 state - count 有變更時才會計算新的值
t0 = __jsx("div", null, __jsx("p", null, count));;
$[0] = count;
$[1] = t0;
} else {
t0 = $[1];
}
return t0;
}
小結
到這邊相信你已經能大概理解 React Compiler 的作用了,他除了減少我們的心智負擔,也增加了 React 的效能。
只可惜目前還在 beta 階段,官方還不建議直接在 production 環境使用,但仍然很期待未來的發展~!
那今天就這樣,下篇貼文見。