前端框架

-

React Compiler 介紹 - 甚麼是 compiller?他有哪些好處?

this.web

React 19 除了更新了很多有關表單和異步操作的 hook 以外,最令人興奮的還是 React Compiler 的消息。

那究竟 React Compiler 是甚麼?又為什麼讓人興奮?今天這篇文或從淺到深好好介紹讓你知道。

❗注意:

  1. 建議先理解 React 中的 React.memo、useMemo、useCallback 再來看這篇文章。
  2. 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 組件,可以傳入 todosclearTodosFn,每當渲染這個組件時,就會及 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>
  );
};

最後將上面 UsernameTodoList 放進 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 Compiler
function 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 函數。

New React Compiler
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 Compiler
function 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 環境使用,但仍然很期待未來的發展~!

那今天就這樣,下篇貼文見。

文章參考

https://www.youtube.com/watch?v=PYHBHK37xlE

博客來-Vue.js設計實戰

相關系列文章