# Complete Intro to React
## React Hooks
### 具名/匿名 Component
匿名(箭頭函式)
```jsx
const Pizza = (props) => {
return (
<div
className="pizza"
onClick={() => {
console.log("hi");
}}
>
<h1>{props.name}</h1>
<p>{props.description}</p>
<img src={props.image} alt={props.name} />
</div>
);
};
or
const pizza = function (){
...
}
export default Pizza;
```
具名
```jsx
export default function Pizza () {
...
}
```
**💡 具名函式在有錯誤時比較好追蹤,推推**
### UseEffect
```jsx
1. 只執行一次時,放空陣列
2. useEffect 不要放 async
useEffect(() => {
fetchPizzaTypes()
}, [])
```
💡 Vue 3 中的 computed 屬性在 React 沒有,React 要自己追蹤變數
**💡 如果把 `useEffect` 的空陣列拿掉,高機率會發生無限迴圈,因為 fetchPizzaTypes 裡面有 setPizzaTypes,狀態改變會一直 re-render**
**💡 React 中正常用法是不會直接把 async 函式當作 `useEffect` 的參數。這是因為 `useEffect` 的回呼函式預期回傳的是「清除函式」或「無回傳」,而 `async` 函式會回傳 `Promise`,兩者不符合導致 React 無法正確處理。**
### Stric Mode
**核心概念:** 它是一個**「僅限開發模式 (development mode)」**的輔助工具。在生產環境 (production mode) 中會被自動移除,**完全不影響效能**。
**主要功能:**
1. **故意執行兩次 (Double Invocation):**
- **什麼:** 會故意重複執行你組件的 `render` 函式以及 `useEffect` (的掛載與卸載)。
- **目的:** 為了抓出「不純的函式」(Impure Functions) 和「有副作用」(Side Effects) 且沒有正確清理的程式碼。
2. **警告「不安全」的語法:**
- **什麼:** 當你用了 React 團隊不推薦、未來可能出問題的寫法時,會在 console 跳警告。
- **範例:**
- 不安全的生命週期方法 (如 `componentWillMount`)。
- 即將過時的舊 API (如 string refs)。
### Dev tools
- 在 `console.log` 中輸入 `$r`, `$0` 可以 inspect 剛剛選取的元件、元素
- `useDeubugValue` 只有在 dev tool 出現
### useContext
### Props vs useContext
- **首選:Props Drilling (逐層傳遞)**
- **優點:** 數據流明確、易維護。
- **建議:** 雖然煩人,但盡量忍受。
- **`useContext`:謹慎使用**
- **缺點:** 增加維護複雜性。
- **使用時機:** 僅限於「**全域狀態 (App-Level State)**」。
- **範例:** 使用者登入 (User)、網站主題 (Theme)、購物車 (Cart)。
### Example
1. `context.jsx` - 建立盒子,這只是一個「容器」或「盒子」,它本身不包含任何資料。
```jsx
import { createContext } from "react";
// createContext() 參數是預設值,因為要放 useState Hook 所以預設值給陣列 & 函式
export const CartContext = createContext([[], function () {}]);
```
2. `App.jsx` - 使用 Provider 來「提供」value 給所有子組件
```jsx
import { StrictMode, useState } from "react";
import PizzaOfTheDay from "./PizzaOfTheDay";
import Order from "./Order";
import Header from "./Header";
import { CartContext } from "./contexts";
const App = () => {
const cartHook = useState([]);
return (
<StrictMode>
<CartContext.Provider value={cartHook}>
<div>
<Header />
<Order />
<PizzaOfTheDay />
</div>
</CartContext.Provider>
</StrictMode>
);
};
```
- `Header.jsx` - 取值,React 會找到最近的那個 `Provider`,並回傳它的該 context 的值。
```jsx
import { useContext } from "react";
import { CartContext } from "./contexts";
export default function Header() {
const [cart] = useContext(CartContext);
return (
<nav>
<h1 className="logo">Padre Gino's Pizza</h1>
<div className="nav-cart">
<span className="nav-cart-number">{cart.length}</span>
</div>
</nav>
);
}
```
### useReducer 相關觀念 QA
https://gemini.google.com/u/3/app/36da4cda920e3f68?pageId=none
## TanStack Router
## 建置 example
1. `vite.config.js`
```jsx
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { TanStackRouterVite } from '@tanstack/router-plugin/vite'
export default defineConfig({
server: {
proxy: {
'/api': {
target: '<http://localhost:3000>',
changeOrigin: true,
},
'/public': {
target: '<http://localhost:3000>',
changeOrigin: true,
},
},
},
plugins: [react(), TanStackRouterVite()],
})
```
1. `route/__root.jsx`
```jsx
import { useState } from "react";
import { createRootRoute, Outlet } from "@tanstack/react-router";
import { TanStackRouterDevtools } from "@tanstack/router-devtools";
import PizzaOfTheDay from "../PizzaOfTheDay";
import Header from "../Header";
import { CartContext } from "../contexts";
export const Route = createRootRoute({
component: () => {
const cartHook = useState([]);
return (
<>
<CartContext.Provider value={cartHook}>
<div>
<Header />
<Outlet />
<PizzaOfTheDay />
</div>
</CartContext.Provider>
<TanStackRouterDevtools />
</>
);
},
});
```
1. `App.jsx`
```jsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { RouterProvider, createRouter } from "@tanstack/react-router";
import { routeTree } from "./routeTree.gen";
const router = createRouter({ routeTree });
const App = () => {
return (
<StrictMode>
<RouterProvider router={router} />
</StrictMode>
);
};
const container = document.getElementById("root");
const root = createRoot(container);
root.render(<App />);
```
💡 TanStack router 檔案命名範例參考
|命名規則|意義|是否 Lazy|
|---|---|---|
|`__root.jsx`|**不是一個實際的頁面路由,所有路由的共同父層**|❌ 否|
|`index.lazy.jsx`|延遲載入頁面,**會被渲染在 `__root.jsx` 的 `<Outlet />` 裡面**|✅ 是|
|`$id.lazy.jsx`|動態路由(lazy)|✅ 是|
## Component example
**`order.lazy.jsx`**
```jsx
import { useEffect, useState, useContext } from "react";
import { createLazyFileRoute } from "@tanstack/react-router";
import Pizza from "../Pizza";
import Cart from "../Cart";
import { CartContext } from "../contexts";
export const Route = createLazyFileRoute("/order")({
component: Order,
});
function Order() {
// ... 其他程式碼
}
```
### **鐵律:**
> 只要 `component` 檔案放在 `routes/` 資料夾,就必須寫 `createFileRoute` 或 `createLazyFileRoute`
### **檔案名稱: `order.lazy.jsx`**
→ 告訴 **TanStack Router CLI** 工具:「這個檔案要生成 lazy route」
### **程式碼: `createLazyFileRoute("/order")`**
→ 告訴 **TypeScript 編譯器**:「這個 component 對應的路徑是 **`/order`**」
這樣才能實現:
```jsx
router.navigate({ to: "/order" }) // ✅ 自動補全
router.navigate({ to: "/odrre" }) // ❌ TypeScript 報錯
```
### **但實際上缺點如下**
- ❌ **檔名和函數參數容易不一致** (例如檔名寫 **`order.lazy.jsx`** 但函數寫 **`/orders`**)
- ❌ **重複定義路徑** (檔名一次、函數一次)
- ❌ **開發體驗差** (就像你現在的感受)
## TanStack Query
> 課程概述
>
> 有 TanStack Query 幾乎就不再用 useEffect
>
> 加 ESLint Plugin & TanStack Query devtools
## **useQuery 是什麼?**
**用一句話說:它讓你不用再寫 `useState` + `useEffect` 來抓資料!**
### **傳統方式 vs useQuery**
**❌ 傳統方式 (麻煩)**
```jsx
function PizzaList() {
const [pizzas, setPizzas] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setIsLoading(true);
fetch("/api/pizzas")
.then(res => res.json())
.then(data => {
setPizzas(data);
setIsLoading(false);
})
.catch(err => {
setError(err);
setIsLoading(false);
});
}, []);
if (isLoading) return <div>載入中...</div>;
if (error) return <div>錯誤: {error.message}</div>;
return <div>{pizzas.map(p => <div>{p.name}</div>)}</div>;
}
```
**✅ 使用 useQuery (超簡單)**
```jsx
import { useQuery } from "@tanstack/react-query";
function PizzaList() {
const { data, isLoading, error } = useQuery({
queryKey: ["pizzas"],
queryFn: () => fetch("/api/pizzas").then(res => res.json()),
});
if (isLoading) return <div>載入中...</div>;
if (error) return <div>錯誤: {error.message}</div>;
return <div>{data.map(p => <div>{p.name}</div>)}</div>;
}
```
**帶參數的範例**
```jsx
function PizzaDetail({ pizzaId }) {
const { data, isLoading } = useQuery({
// ⭐ 參數也要放進 queryKey
// ⭐ 告訴 TanStack Query:這是 pizza-{pizzaId} 的資料
queryKey: ["pizza", pizzaId],
queryFn: () => fetch(`/api/pizzas/${pizzaId}`).then(r => r.json()),
});
if (isLoading) return <div>載入中...</div>;
return <div>{data.name}</div>;
}
```
```jsx
// ❌ 錯誤做法
function PizzaDetail({ pizzaId }) {
const { data } = useQuery({
queryKey: ["pizza"], // 所有披薩都用同一個 key
queryFn: () => fetch(`/api/pizzas/${pizzaId}`).then(r => r.json()),
});
}
// 使用:
<PizzaDetail pizzaId={1} /> // 抓取 ID=1,快取到 ["pizza"]
<PizzaDetail pizzaId={2} /> // ⚠️ 因為 queryKey 一樣,直接用快取的 ID=1 資料!
// 結果:ID=2 的披薩會顯示 ID=1 的內容!
```
### **核心概念**
- **queryKey - 資料的唯一識別碼**
```jsx
queryKey: ["pizzas"] *// 抓取所有披薩*
queryKey: ["pizza", 1] *// 抓取 ID=1 的披薩*
queryKey: ["pizzas", "spicy"] *// 抓取辣味披薩*
```
**作用:**
- 識別這筆資料
- 自動快取(下次不用重新抓)
- 多個 component 共享同一筆資料
```jsx
// Component A
const { data } = useQuery({ queryKey: ["pizzas"], queryFn: fetchPizzas });
// Component B (不會重複發送請求,直接用快取!)
const { data } = useQuery({ queryKey: ["pizzas"], queryFn: fetchPizzas });
```
- **queryFn - 實際抓資料的函數**
```jsx
queryFn: () => fetch("/api/pizzas").then(res => res.json())
// **必須返回 Promise!**
```
- **queryKey 與 queryFn 的關係圖:**
```jsx
const { data } = useQuery({
// 步驟 1: TanStack Query 看 queryKey
queryKey: ["pizza", pizzaId],
// ↓
// 「有這個 key 的快取嗎?」
// - 有 → 直接返回快取資料
// - 沒有 → 執行 queryFn
// 步驟 2: 執行 queryFn 抓資料
queryFn: () => fetch(`/api/pizzas/${pizzaId}`).then(r => r.json()),
// ↓
// 把結果存到 ["pizza", pizzaId] 這個快取位置
});
```
**重要觀念:queryKey 陣列的順序和值都要完全一樣**
```jsx
// ✅ 這兩個是「相同」的快取(會重用)
queryKey: ["pizzas", "margherita", "M"]
queryKey: ["pizzas", "margherita", "M"]
// ❌ 這兩個是「不同」的快取(順序不同)
queryKey: ["pizzas", "margherita", "M"]
queryKey: ["pizzas", "M", "margherita"] // 順序不同!
```
**物件也可以放進 queryKey**
```jsx
// ✅ 這樣也可以(物件會被序列化)
queryKey: ["pizzas", { type: "margherita", size: "M" }]
// ✅ 物件屬性順序「不影響」(TanStack Query 會自動處理)
queryKey: ["pizzas", { type: "margherita", size: "M" }]
queryKey: ["pizzas", { size: "M", type: "margherita" }] // 一樣的快取!
```
## useMutation
### 為什麼要用 `useMutation`?
雖然可以用 `try/catch` 自己寫 API 呼叫,但 `useMutation` 提供了標準化的狀態機:
1. **自動狀態變數**:直接給你 `isPending`, `isError`, `isSuccess` 用於 UI 切換,不用手寫一堆 `useState`。
2. **生命週期 Hook**:`onMutate` (送出前), `onSuccess` (成功), `onError` (失敗), `onSettled` (結束),邏輯拆分清楚。
3. **進階功能**:支援 **Optimistic Updates (樂觀更新)**,在 API 回來前先偷改畫面,失敗再回滾,提升 UX。
### `useQuery` vs `useMutation`
核心差異:**意圖**與**觸發時機**。
#### 🔹 `useQuery` (讀取 / GET)
- **意圖**:向 Server 要資料
- **特性**:**有 Cache**、冪等 (Idempotent)
- **觸發**:**自動**。只要 Component Render 且 `enabled` 為 true,就會自動 fetch
- **Vue 類比**:`onMounted` 自動呼叫 API 並存入 `ref`
#### 🔸 `useMutation` (寫入 / POST, PUT, DELETE)
- **意圖**:改變 Server 狀態 (Side Effect)
- **特性**:**無 Cache**、一次性動作
- **觸發**:**手動**。必須呼叫 `.mutate()` 才會執行
- **Vue 類比**:API 呼叫 `method` ,綁在事件上
#### `useMutation` + `useQuery` 配合
`useMutation` 修改完資料後,`useQuery` 的 Cache 還是舊的。你需要手動建立連結:
修改成功 (`onSuccess`) ➝ 標記作廢 (`invalidateQueries`) ➝ 自動重抓 (Auto Refetch)
#### 完整 example
```jsx
import {
useQuery,
useMutation,
useQueryClient,
QueryClient,
QueryClientProvider,
} from '@tanstack/react-query'
import { getTodos, postTodo } from '../my-api'
// Create a client
const queryClient = new QueryClient()
function App() {
return (
// Provide the client to your App
<QueryClientProvider client={queryClient}>
<Todos />
</QueryClientProvider>
)
}
function Todos() {
// Access the client
const queryClient = useQueryClient()
// Queries
const query = useQuery({ queryKey: ['todos'], queryFn: getTodos })
// Mutations
const mutation = useMutation({
mutationFn: postTodo,
onSuccess: () => {
// Invalidate and refetch
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
return (
<div>
<ul>
{query.data?.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
<button
onClick={() => {
mutation.mutate({
id: Date.now(),
title: 'Do Laundry',
})
}}
>
Add Todo
</button>
</div>
)
}
render(<App />, document.getElementById('root'))
```
## Advanced React Techniques
## createPortal
`createPortal` lets you render some children into a different part of the DOM.
**對應 Vue 3 `<Teleport>`** 標籤
```jsx
import { createPortal } from 'react-dom';
// ...
<div>
<p>This child is placed in the parent div.</p>
{createPortal(
<p>This child is placed in the document body.</p>,
document.body
)}
</div>
```
### Parameters
- `children`: Anything that can be rendered with React, such as a piece of JSX (e.g. `<div />` or `<SomeComponent />`), a [Fragment](https://react.dev/reference/react/Fragment) (`<>...</>`), a string or a number, or an array of these.
- `domNode`: Some DOM node, such as those returned by `document.getElementById()`. The node must already exist. Passing a different DOM node during an update will cause the portal content to be recreated.
- **optional** `key`: A unique string or number to be used as the portal’s [key.](https://react.dev/learn/rendering-lists#keeping-list-items-in-order-with-key)
> [!NOTE] Caveat
> - Events from portals propagate according to the React tree rather than the DOM tree. For example, if you click inside a portal, and the portal is wrapped in `<div onClick>`, that `onClick` handler will fire. If this causes issues, either stop the event propagation from inside the portal, or move the portal itself up in the React tree.
## 課程範例(先 skip) %% fold %%
### createPortal
Portal 讓你把元件渲染到**父元件 DOM 樹以外**的地方,常用於 Modal、Toast、Tooltip 等需要脫離父元件 CSS 限制的場景 。
**對應 Vue**: Vue 3 的 **`<Teleport>`** 元件
**Modal 元件實作**
```jsx
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
const Modal = ({ children }) => {
// 1. 建立 ref 來儲存 DOM 容器
const elRef = useRef(null);
// 2. 檢查並建立 DOM 元素 (只在第一次執行)
if (!elRef.current) {
elRef.current = document.createElement("div");
}
// 3. 掛載和清理 DOM
useEffect(() => {
const modalRoot = document.getElementById("modal");
modalRoot.appendChild(elRef.current);
// cleanup: 元件卸載時移除 DOM
return () => modalRoot.removeChild(elRef.current);
}, []);
// 4. 使用 Portal 渲染內容
return createPortal(<div>{children}</div>, elRef.current);
};
export default Modal;
```
```jsx
function App() {
const [showModal, setShowModal] = useState(false);
return (
<>
<button onClick={() => setShowModal(true)}>打開彈窗</button>
{showModal && (
<Modal>
<h1>這是彈窗內容</h1>
<button onClick={() => setShowModal(false)}>關閉</button>
</Modal>
)}
</>
);
}
```
#### 執行流程詳解 %% fold %%
第一次渲染 (元件掛載)
```jsx
1. 建立 ref 物件
elRef = { current: null }
2. 檢查並建立空 div
elRef.current = document.createElement("div")
此時這個 div 是獨立的,還沒掛到頁面上
3. 執行 createPortal
return createPortal(<div>{children}</div>, elRef.current)
意思: 把 <div>{children}</div> 渲染到 elRef.current 這個 DOM 容器裡
⚠️ 注意: elRef.current 是「真實 DOM 元素」,不是 JSX!
4. useEffect 執行 (渲染完成後)
找到 #modal 元素
把 elRef.current (已包含渲染內容) 掛到 #modal 裡
```
最終的 DOM 結構
```jsx
<div id="root">
<button>打開彈窗</button>
</div>
<div id="modal">
<div> *<!-- elRef.current (createElement 建立的容器) -->*
<div> *<!-- createPortal 渲染的內容 -->*
<h1>這是彈窗內容</h1>
<button>關閉</button>
</div>
</div>
</div>
```
元件卸載時
```jsx
1. React 呼叫 useEffect 的 cleanup function
2. 執行 modalRoot.removeChild(elRef.current)
3. 整個容器從 #modal 中移除,避免記憶體洩漏
```
<aside> 💡
React 的渲染流程 :
1. 執行元件函數 (虛擬 DOM 建立)
2. 比對差異
3. 更新真實 DOM
4. **DOM 準備完成後才執行 useEffect** </aside>
## useRef
`useRef` lets you reference a value that’s not needed for rendering.
```jsx
import { useRef } from 'react';
const myRef = useRef(initialValue);
*// 回傳: { current: initialValue }*
```
### **核心特性**
- 改變 **`ref.current`** **不會觸發 re-render**
- 每次 render 都拿到**同一個物件**
- 值的更新是**同步且即時**的,不用等下次 render
### Caveat
> [!warning] **Do not write _or read_ `ref.current` during rendering.**
```jsx
function MyComponent() {
// 🚩 Don't write a ref during rendering
myRef.current = 123;
// 🚩 Don't read a ref during rendering
return <h1>{myOtherRef.current}</h1>;
}
```
You can read or write refs **from event handlers or effects instead**.
```jsx
function MyComponent() {
// ...
useEffect(() => {
// ✅ You can read or write refs in effects
myRef.current = 123;
});
// ...
function handleClick() {
// ✅ You can read or write refs in event handlers
doSomething(myOtherRef.current);
}
// ...
}
```
If you _have to_ read [or write](https://react.dev/reference/react/useState#storing-information-from-previous-renders) something during rendering, [use state](https://react.dev/reference/react/useState) instead.
### 常用場景
+ 操作 DOM
+ 存取不影響畫面的資料
+ 避免 recreate ref content
```jsx
function Video() {
const playerRef = useRef(null);
if (playerRef.current === null) {
playerRef.current = new VideoPlayer();
}
// Normally, writing or reading `ref.current` during render is not allowed. However, it’s fine in this case because the result is always the same, and the condition only executes during initialization so it’s fully predictable.
```
> [!NOTE] 與 Vue ref 的關鍵差異
> **關鍵差異**: Vue 的 **`ref()`** 是**響應式系統的一部分**,React 的 **`useRef`** 是**脫離響應式系統**的工具
>
|**特性**|**React useRef**|**Vue ref**|
|---|---|---|
|存取方式|**`.current`**|**`.value`**|
|是否響應式|**否**,改變不觸發更新|**是**,改變會觸發更新|
|主要用途|DOM 存取、儲存非響應式資料|響應式資料 + DOM 存取|
|更新時機|同步即時|下次 tick|
## Class Component
> [!NOTE] 核心對照表
> | Function Component | Class Component | 說明 |
| -------------------------------------- | -------------------------------- | -------- |
| `const [state, setState] = useState()` | `this.state` + `this.setState()` | 管理狀態 |
| `useEffect(() => {}, [])` | `componentDidMount()` | 元件掛載後執行 |
| `useEffect(() => { return () => {} })` | `componentWillUnmount()` | 元件卸載前執行 |
| `useEffect(() => {}, [dep])` | `componentDidUpdate()` | 依賴更新時執行 |
| `const value = props.name` | `const value = this.props.name` | 讀取 props |
### 範例對照
#### Function Component
```jsx
import { useState, useEffect } from 'react';
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
console.log('元件掛載了');
return () => console.log('元件卸載了');
}, []);
const handleClick = () => {
setCount(count + 1);
};
return (
<div>
<p>Count: {count}</p>
<button onClick={handleClick}>+1</button>
</div>
);
}
```
#### Class Component (同樣功能)
```jsx
import React from 'react';
class Counter extends React.Component {
constructor(props) {
super(props);
// state 在這裡定義
this.state = {
count: 0
};
// 綁定 this (重要!)
this.handleClick = this.handleClick.bind(this);
}
// 元件掛載後執行
componentDidMount() {
console.log('元件掛載了');
}
// 元件卸載前執行
componentWillUnmount() {
console.log('元件卸載了');
}
// 事件處理函數
handleClick() {
// 更新 state
this.setState({ count: this.state.count + 1 });
}
// 必須有 render 方法
render() {
return (
<div>
<p>Count: {this.state.count}</p>
<button onClick={this.handleClick}>+1</button>
</div>
);
}
}
export default Counter;
```
##### ==Class Component 重點說明==
##### 1. Constructor (建構函數)
```jsx
constructor(props) {
super(props); // 必須先呼叫,否則會報錯
this.state = { count: 0 }; // 初始化 state
}
```
##### 2. this 的綁定問題
```jsx
// ❌ 錯誤:this 會是 undefined
<button onClick={this.handleClick}>
// ✅ 方法 1: 在 constructor 綁定
this.handleClick = this.handleClick.bind(this);
// ✅ 方法 2: 使用箭頭函數 (推薦)
handleClick = () => {
this.setState({ count: this.state.count + 1 });
}
```
> [!NOTE] **為什麼要綁 this?**
> + 因為在 JavaScript 的 Class 中,你定義的 `handleClick()` 方法,預設並不會自動綁定 `this`
> + 當你把它(`this.handleClick`)當作「值」傳給 `onClick={...}` 之後,**未來**觸發事件時,React 呼叫它,它其實是在一個**錯誤的上下文 (Context)** 中被執行的,所以它內部的 `this` 會是 `undefined`(或 `window`),而不是你的 Component 實例。
> [!NOTE] **`bind` 和「賦值」做了什麼?**
> - `this.handleClick.bind(this)`:這會**建立一個「全新的函式」**。這個新函式跟原本的 `handleClick` 內容一模一樣,但差別在於它的 `this` **永遠被鎖定**在 `constructor` 當下指向的 `this`(也就是你的 Component 實例)。
>
> - `this.handleClick = ...`:這是一個「**賦值**」動作。你把這個「新建立的、已綁定的函式」,_覆蓋_掉 Class 實例上原本那個「未綁定的 `handleClick`」。
>
##### 3. 更新 State
```jsx
// ❌ 錯誤:不能直接修改
this.state.count = 1;
// ✅ 正確:使用 setState
this.setState({ count: 1 });
// ✅ 使用前一個 state
this.setState((prevState) => ({
count: prevState.count + 1
}));
```
##### 4. 生命週期方法
```jsx
class MyComponent extends React.Component {
// 掛載階段
componentDidMount() {
// 元件第一次渲染後執行
// 等於 useEffect(() => {}, [])
}
// 更新階段
componentDidUpdate(prevProps, prevState) {
// 元件更新後執行
// 等於 useEffect(() => {}, [dependency])
if (prevState.count !== this.state.count) {
console.log('count 改變了');
}
}
// 卸載階段
componentWillUnmount() {
// 元件移除前執行
// 等於 useEffect cleanup function
}
render() {
return <div>{this.state.count}</div>;
}
}
```
### ErrorBoundaries
**ErrorBoundaries 是 React 的安全網,**捕捉「渲染(Rendering)過程」中發生的錯誤**,顯示 fallback 畫面而不是讓整個網站變白畫面**
> [!warning] 只有 Class Component 可以實作 Error Boundaries
> Function Component 都用 `react-error-boundary` 套件
## Testing
## 安裝套件
- **`vitest`**: 測試運行的引擎 (Test Runner)。
- **`@testing-library/react`**:用來 Render React 組件並進行操作的工具庫。
- **`happy-dom`**: 模擬瀏覽器環境的 DOM 實作(給 Node.js 用的)。
```
npm install -D vitest@2.1.3 @testing-library/react@16.0.1 happy-dom@15.7.4
```
```jsx
// vite.config.js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
// 🔥 這裡!告訴 Vitest 使用 happy-dom 來模擬瀏覽器環境
environment: 'happy-dom',
},
})
```
> [!NOTE] `-D` 是什麼意思?
> `-D` 其實是 `--save-dev` 的縮寫。
> 當你加上 `-D` 時,npm 會把這些套件寫入 `package.json` 中的 **`"devDependencies"`** 區塊,而不是一般的 `"dependencies"` 區塊。
> 什麼時候要加 `-D`?什麼時候不用?
>
> 這個判斷標準其實非常簡單,只要問自己一個問題: **「當我的使用者在瀏覽器打開網頁(或 App 上線運行)時,需不需要這段程式碼?」**
## 純展示元件 example
```tsx
afterEach(cleanup)
test("alt test renders on Pizza inage", async () => (
const name = "My Favorite Pizza";
const src = "https://picsum.photos/200";
const screen = render(
<Pizza name={name} description="super cool pizza" image={src} />
);
const img = screen.getByRole("img");
expect(img.src).toBe(src);
expect(img.alt).toBe(name);
});
```
這段 code 的邏輯就是標準的測試三部曲:**Arrange (準備) -> Act (執行) -> Assert (斷言/檢查)**。
#### 1. 準備假資料 (Arrange)
```jsx
const name = "My Favorite Pizza";
const src = "https://picsum.photos/200";
```
因為是在測試環境,沒有真的 API 資料,所以我們自己定義一些字串當作假資料 (Mock Data)。
#### 2. 渲染組件 (Act)
```jsx
const screen = render(
<Pizza name={name} description="super cool pizza" image={src} />
);
```
- **`render()`**:這是 React Testing Library 的核心 function。它會在一個「虛擬的 DOM」中把你的 `<Pizza />` 組件畫出來。
- 你可以把它想像成:機器人在記憶體裡面偷偷開了一個瀏覽器頁面,把你的組件掛上去。
#### 3. 抓取元素 (Act / Query)
```jsx
const img = screen.getByRole("img");
```
- **`screen.getByRole("img")`**:這一步是在模擬使用者的行為。
- 它的意思是:「在剛剛畫出來的畫面中,幫我找出扮演『圖片 (img)』角色的那個 HTML 元素」。
- **`getByRole`**: 這是 React Testing Library 提倡的查詢方式。比起用 `querySelector('.my-class')`,用 `Role` 查詢更接近螢幕閱讀器 (Screen Reader) 的運作方式,能確保網頁親和力。
#### 4. 驗收結果 (Assert)
```jsx
expect(img.src).toBe(src);
expect(img.alt).toBe(name);
```
- **`expect(...).toBe(...)`**:這就是判官。
- 第一行檢查:剛剛抓到的那張圖片,它的 `src` 屬性網址,是不是我剛剛傳進去的那個網址?
- 第二行檢查:那張圖片的 `alt` (替代文字) 屬性,是不是我剛剛傳進去的 `name`?
#### `cleanup` 的作用
它負責在每一個測試 (`test` 或 `it`) 跑完之後,把剛剛 `render` 出來的 React 組件從虛擬 DOM 上 **卸載 (Unmount)** 並清空。確保下一個測試進來時,面對的是一張乾淨的白紙。
---
#### 這段 Code 想要教會你什麼觀念?
除了基本的測試語法外,它隱含了兩個 React 開發的重要觀念:
1. **Props 的傳遞驗證**: 它在確保你的 Component 內部寫法是正確的。如果你的 `<Pizza>` 組件內部寫錯,例如忘記把 `props.image` 綁定到 `<img src={...}>` 上,這個測試就會失敗 (Fail)。這是用來防止開發者手殘改壞程式碼。
2. **重視無障礙網頁 (Accessibility / a11y)**: 注意它的測試標題叫 `"alt test renders on Pizza image"`,而且它特別檢查了 `img.alt`。 這是在教導開發者:**「圖片一定要有替代文字 (alt text)」**。如果你在開發 Component 時偷懶沒寫 `alt`,測試就會報錯。這是一種透過測試來強制執行 Coding Style 的手段。
## Mock Request Example(課程範例用 fetch not axios)
React 聯絡表單整合測試 (Integration Test)
**技術線:** Vitest + React Testing Library (RTL) + TanStack Query + TanStack Router
套件 :`npm i -D vitest-fetch-mock@0.3.0`
**核心測試邏輯**
整合測試的目標不是測實作細節(Implementation Details),而是測試 **「使用者行為流程」**:
1. **Arrange:** 準備假環境 (Mock API、Router Context)。
2. **Act:** 模擬使用者填寫表單、點擊按鈕。
3. **Assert:** * **對使用者:** 驗證畫面是否出現「成功訊息」。
- **對開發者:** 驗證程式是否真的發出正確的 API 請求 (Spy)。
```jsx
// 檔案:src/__tests__/contact.lazy.test.jsx
import { render, screen } from "@testing-library/react";
import { expect, test, vi } from "vitest";
import createFetchMock from "vitest-fetch-mock";
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
// 從路由定義檔匯入 Route 物件,目標是測試裡面的 Component
import { Route } from "../routes/contact.lazy";
// 1. 初始化 Mock 工具 (把 Vitest 的工具箱 vi 借給 fetch-mock 用)
const fetchMocker = createFetchMock(vi);
fetchMocker.enableMocks();
test("can submit contact form", async () => {
// === Step 1: Arrange (佈置環境) ===
// 設定 API 假回應 (Spy): 下一次 fetch 呼叫時,直接回傳這個 JSON,不要真的連網
fetchMocker.mockResponseOnce(JSON.stringify({ status: "ok" }));
// 建立全新的 QueryClient (因為實際 code 有用到 useContext,在測試時也要寫)
const queryClient = new QueryClient();
// Render 元件
// 必須包裹 QueryClientProvider (因為實際 code 有用到 useContext,在測試時也要寫)
// Route.options.component 就是把真正的 Component 從路由設定檔挖出來
render(
<QueryClientProvider client={queryClient}>
<Route.options.component />
</QueryClientProvider>
);
// 準備測試資料
const testData = {
name: "Brian",
email: "test@example.com",
message: "Hello World",
};
// === Step 2: Act (模擬使用者行為) ===
// 模擬填寫表單 (RTL 哲學:用 Placeholder 抓元素,模擬使用者視覺)
const nameInput = screen.getByPlaceholderText("Name");
const emailInput = screen.getByPlaceholderText("Email");
const msgTextArea = screen.getByPlaceholderText("Message");
nameInput.value = testData.name;
emailInput.value = testData.email;
msgTextArea.value = testData.message;
// 模擬點擊按鈕
const btn = screen.getByRole("button");
btn.click(); // 這會觸發 onSubmit -> 執行 fetch
// === Step 3: Assert (驗證結果) ===
// A. 驗證 UI (Async): 等待 "Submitted" 標題出現
// findByRole 會輪詢 (Polling) DOM,直到元素出現或超時
const h3 = await screen.findByRole("heading", { level: 3 });
expect(h3.innerText).toContain("Submitted");
// B. 驗證 API 行為 (Spy): 檢查間諜的小本本
const requests = fetchMocker.requests();
// 確保按鈕按一次,API 只打一次 (防手抖、防邏輯錯誤)
expect(requests.length).toBe(1);
// 確保打對 URL
expect(requests[0].url).toBe("/api/contact");
// 確保送出的資料格式正確 (Header, Body)
expect(fetchMocker).toHaveBeenCalledWith("/api/contact", {
body: JSON.stringify(testData),
headers: { "Content-Type": "application/json" },
method: "POST",
});
});```
### 解析 & QA
#### Q1: 為什麼需要 `createFetchMock(vi)`?為什麼不直接寫假資料?
- **原因:** 因為實際 code 有 call API (程式碼內部寫的是 `fetch()`),在測試環境 (Node.js) 執行會報錯,所以要用 `createFetchMock(vi)`。
- **Spy 的作用:** 我們需要一個「間諜」來攔截 `fetch`。
1. **阻斷連線:** 防止真的打出去。
2. **假裝回應:** 回傳 `{ status: "ok" }` 讓前端跑完流程。
3. **紀錄行為:** 紀錄「你有沒有打電話?」、「參數對不對?」。如果不 Mock,我們就無法驗證 `POST` 的資料是否正確。
- **註:** 如果用 Axios,則需用 `vi.mock('axios')`。
#### Q2: 為什麼檔名要有 `Lazy`?測試也要 Lazy?
- **解答:** 完全不用。
- **原因:** 純粹是 **TanStack Router** 的檔案命名慣例 (`contact.lazy.jsx`)。測試檔取名 `contact.lazy.test.jsx` 只是為了在檔案總管排在一起好找而已。
#### Q3: `screen.findByRole` 是什麼?為什麼不抓 `<h3>`?
- **`findBy...` vs `getBy...`:**
- `getBy`:找不到馬上報錯(適合測一開始就在的東西)。
- `findBy`:**回傳 Promise**,會等待元素出現(適合測 API 回來後的 Async UI 更新)。
- **`Role`:** 強制你寫語意化 HTML。`{ level: 3 }` 對應 `<h3>`。這能確保你的網頁對螢幕閱讀器是友善的。
#### Q4: `Route.options.component` 是什麼黑魔法?
- **原理:** `import { Route } from ...` 匯入的是一個由 TanStack Router 產生的**設定物件**。
- **結構:** 真正的 React Component 被包在該物件的 `options.component` 屬性裡。
- **識別:** 程式是靠 `import` 的**檔案路徑**來區分是哪一頁的元件,而不是靠變數名稱。
#### Q5: `vi` 是什麼?
- **解答:** Vitest 的工具箱 (Utility Object)。
- **用途:** 用來製造間諜 (Spy)、控制時間 (Fake Timers)、模擬模組 (Mocks)。`fetch-mock` 需要借用 `vi` 的能力來實作攔截功能。
#### Q6: 假如測試有 Call 不同的 API,它怎麼知道是哪一次?
問到痛點了!這就是 `toHaveBeenCalledWith` 的特性與限制。
**情境:** 假設你的元件依序呼叫了兩支 API:
1. POST `/api/login`
2. GET `/api/user`
##### **寫法 1:使用 `toHaveBeenCalledWith` (寬鬆檢查)**
```jsx
// 檢查「有沒有」呼叫過 login? (有就好,不管它是第幾次)
expect(fetchMocker).toHaveBeenCalledWith("/api/login", ...); // PASS
// 檢查「有沒有」呼叫過 user? (有就好)
expect(fetchMocker).toHaveBeenCalledWith("/api/user", ...); // PASS
```
##### **寫法 2:使用 `toHaveBeenNthCalledWith` (嚴格順序檢查)** 如果你在乎順序,或者 URL 很像,怕混淆,要用 `Nth` (第 N 次):
```jsx
// 斷言:第 1 次呼叫必須是 Login
expect(fetchMocker).toHaveBeenNthCalledWith(1, "/api/login", {
method: "POST",
// ...
});
// 斷言:第 2 次呼叫必須是 User
expect(fetchMocker).toHaveBeenNthCalledWith(2, "/api/user", {
method: "GET",
// ...
});
```
##### **寫法 3:手動檢查 `requests` 陣列 (最直觀,推薦給資深者)** 這就是你在上一段 code 看到的寫法,這其實最靈活:
```jsx
const requests = fetchMocker.requests();
// 檢查第一次
expect(requests[0].url).toBe("/api/login");
expect(requests[0].method).toBe("POST");
// 檢查第二次
expect(requests[1].url).toBe("/api/user");
expect(requests[1].method).toBe("GET");
```
## Testing Custom Hooks
+ hook
```jsx
import { useState, useEffect, useDebugValue } from "react";
export const usePizzaOfTheDay = () => {
const [pizzaOfTheDay, setPizzaOfTheDay] = useState(null);
// Debug 用:這行只會在 React DevTools 顯示,不影響邏輯
useDebugValue(pizzaOfTheDay ? `${pizzaOfTheDay.id}` : "loading ...");
useEffect(() => {
async function fetchPizzaOfTheDay() {
const response = await fetch("/api/pizza-of-the-day");
const data = await response.json();
// 更新 State:這會觸發 Component 重新渲染 (Re-render)
setPizzaOfTheDay(data);
}
fetchPizzaOfTheDay();
}, []);
// 4. 回傳當下的 State (第一次是 null,第二次 Re-render 後才有資料)
return pizzaOfTheDay;
};
```
+ Testing
```jsx
import { expect, test, vi } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import createFetchMock from "vitest-fetch-mock";
import { usePizzaOfTheDay } from "../usePizzaOfTheDay";
// --- 設定 Fetch Mock 環境 (攔截網路請求) ---
const fetchMocker = createFetchMock(vi);
fetchMocker.enableMocks();
// --- 準備假資料 (Mock Data) ---
const testPizza = {
id: "calabrese",
name: "The Calabrese Pizza",
category: "Supreme",
description: "lol pizza from Calabria",
image: "/public/pizzas/calabrese.webp",
size: { S: 12.25, M: 16.25, L: 20.25 },
};
// --- 測試案例 1: 驗證初始狀態(同步 case) ---
test("gives null when first called", async () => {
// 1. 告訴 Fetch 這次要回傳什麼假資料
fetch.mockResponseOnce(JSON.stringify(testPizza));
// 2. 執行 Hook (在測試環境模擬 React 運作)
const { result } = renderHook(() => usePizzaOfTheDay());
// 3. 驗證 First Call:此時 API 還在跑,所以應該是 null
expect(result.current).toBeNull();
});
// --- 測試案例 2: 驗證 API 回來後的狀態(非同步 case) ---
test("to call the API and give back the pizza of the day", async () => {
fetch.mockResponseOnce(JSON.stringify(testPizza));
const { result } = renderHook(() => usePizzaOfTheDay());
// 1. 等待更新 有 await 很重要 (Wait For)
await waitFor(() => {
// 2. 驗證 State 已經變成我們的假資料了
expect(result.current).toEqual(testPizza);
});
// 3. 驗證 Fetch 確實有被呼叫,且 URL 正確
expect(fetchMocker).toBeCalledWith("/api/pizza-of-the-day");
});
```
### 觀念補充
#### 為什麼 API 請求放在 `useEffect`?
- **Vue 直覺**:在 `setup()` 裡直接寫 `fetch()`,因為 `setup` 只會在元件初始化時執行**一次**。
- **React 真相**:Function Component 每次渲染(Render)都會**從頭到尾重新執行**整個函式。
- 如果不包在 `useEffect`:`Render` -> `Fetch` -> `setState` -> `Re-render` -> `Fetch`... (**無限迴圈**)
- 包在 `useEffect([], ...)`:告訴 React「這段程式碼只在**掛載後 (Mounted)** 執行一次」。
- **useEffect 在 render 完之後才會執行**
#### Vue vs React:架構與測試思維差異
##### 為什麼 React 測試比較「麻煩」?
- **Vue (白箱測試)**:
- Vue 的響應式系統 (Reactive System) 是基於物件代理 (Proxy),脫離元件也能運作。
- 測試可以直接呼叫 function 或修改 `ref.value`。
- **React (黑箱測試)**:
- Hooks (`useState`, `useEffect`) 高度依賴 React Runtime
- 必須用 `renderHook` 來模擬完整的瀏覽器渲染流程。
##### State 更新的禁忌
- **Vue**: 可以在 `setup` 根目錄直接修改變數 (e.g., `count.value++`),沒問題。
- **React**: **絕對禁止**在 Component Function Body 直接呼叫 `setState`,這會導致無限迴圈。
- ✅ 合法地點 1:**Event Handlers** (onClick, onChange...) - 最常用。
- ✅ 合法地點 2:**useEffect** (API, 訂閱)
npm i -D @vitest/coverage-v8@2.1.3
npm i -D @vitest/ui@2.1.3
npm i -D @vitest/browser@2.1.3 playwright@1.48.0 vitest-browser-react@0.0.1
## Snapshot Testing
簡單來說,這是在幫你的 Component **「拍一張照片」** 並存檔
```jsx
test("snapshot with nothing in cart", () => {
// 1. 渲染元件
const { asFragment } = render(<Cart cart={[]} />);
// 2. 拍快照並比對
expect(asFragment()).toMatchSnapshot();
});
```
- **`asFragment()`**:把目前渲染出來的 DOM 結構變成一段文字(類似 HTML 字串)。
- **`toMatchSnapshot()`**:
- **第一次跑測試時**:Vitest 會自動在旁邊生出一個 `__snapshots__` 資料夾,裡面有一個檔案把這段 HTML 存起來(這就是「快照」)。
- **第二次以後跑測試**:它會把「這次渲染的 HTML」跟「上次存的檔案」做比對。
- **完全一樣** 🟢 通過。
- **差一個字** 🔴 失敗 (報錯說 DOM 改變了)。
### Snapshot Testing 的意義是什麼?
它的核心價值是 **「防止意外的 UI 變更 (Regression Testing)」**。
**想像一個情境:** 你寫了一個很複雜的 `Cart` 元件,裡面有一堆 class 和結構。 某天你不小心改到了共用的 CSS 或是誤刪了一行 code,導致 `Cart` 的版跑掉了。
- **沒有快照**:你可能要跑起來看畫面才發現(或者根本沒發現)。
- **有快照**:測試會直接報錯:「嘿!原本這裡是 `<div>`,怎麼變成 `<span>` 了?」,強迫你去檢查這個改變是不是你預期的。
### 部分人不鼓勵 Snapshot Testing?
#### ❌ 不被鼓勵的原因(為什麼大家後來討厭它):
1. **太敏感 (Brittle)**: 你只是改了一個無關緊要的 class (例如 `margin-top: 10px` 改成 `12px`),或者改了一點點文案,測試就掛了。這會造成很多「假警報」。
2. **開發者的惰性 (The "Update" Trap)**: 這是最致命的。當測試失敗時,工具會問你:「要更新快照嗎?(Press 'u' to update)」。 因為變動太頻繁,很多工程師根本**不看**哪裡變了,直接無腦按 `u` 更新快照讓測試通過。
> **結果:** 快照測試變成了「只要程式碼沒報錯就通過」,失去了「檢查 UI 正確性」的意義。
3. **沒有測到行為**: 它只測了「結構長怎樣」,沒測「按鈕能不能按」、「資料對不對」。
#### ✅ **特定場景還是好用**
它適合拿來測靜態結構,對於那些 **「永遠不應該變動」** 的靜態元件(例如:法律條款頁面、設定檔 Config Object),Snapshot 還是最快能確保它沒被亂改的方法。
## Vitest Plugins
### A. Code Coverage (`@vitest/coverage-v8`)
- **它是什麼:** 這是用來檢查 **「你的測試到底覆蓋了多少程式碼」** 的工具。
- **功能:** 跑完測試後,它會告訴你:「嘿,你寫了 100 行程式碼,但測試只跑過了其中 60 行,剩下的 40 行 (40%) 從來沒被執行過,可能有 Bug 哦!」
- **原理:** 利用 V8 引擎(跟 Chrome 一樣的核心)來即時追蹤程式碼執行狀況。
### B. Vitest UI (`@vitest/ui`)
- **它是什麼:** 一個網頁版的圖形介面。
- **功能:** 你不用只看終端機 (Terminal) 黑底白字的 Log。打指令 `npx vitest --ui` 後,它會開一個網頁:
- 可以看到所有測試檔案的清單。
- 可以看到測試失敗的具體報錯。
- **最酷的功能**:它有一個 **Module Graph**,能畫出你的 Component 依賴了誰,視覺化你的程式架構。
### C. Browser Mode (`@vitest/browser` + `playwright` + `vitest-browser-react`)
- **它是什麼:** 讓你的單元測試在 **「真的瀏覽器」** 裡跑,而不是在 Node.js 的模擬環境 (JSDOM) 裡跑。
- **為什麼需要:**
- 一般 React 測試 (RTL) 是跑在 **JSDOM**(一個用 JS 寫的假瀏覽器環境)。它很快,但有些東西它不支援(例如:Canvas 繪圖、複雜的 CSS Layout 計算、某些新的 Web API)。
- 裝了這個,Vitest 就會呼叫 **Playwright** 去開一個無頭瀏覽器 (Headless Chrome),在裡面跑你的測試 code。
- **關係:** `vitest-browser-react` 是用來讓 Vitest 懂得怎麼在瀏覽器裡渲染 React 元件的橋樑。
## React 19
## "use client" & "use server"
**React 19 才正式推出**,Next.js 之前就可以用是為了提早支援 Server Components,當時使用的是 React 的實驗性版本。
## ## Web Components 一級支援
## Form Actions
核心變革:從 `onSubmit` 到 `action`
React 19 引入了對 `<form>` 元素的原生支援,目的是**減少樣板程式碼 (Boilerplate)**,讓 React 自動處理表單的繁瑣操作。
### 語法對照表
| **特性** | **舊版 React (React 18 以前)** | **React 19 Actions** |
| -------- | -------------------------- | ------------------------ |
| **觸發屬性** | `<form onSubmit={save}>` | `<form action={save}>` |
| **阻止重整** | 必須手動寫 `e.preventDefault()` | **React 自動處理** (不用寫了) |
| **獲取資料** | `new FormData(e.target)` | **自動注入** `formData` 作為參數 |
| **參數類型** | Event Object (`e`) | `FormData` Object |
### 範例
```jsx
// ❌ 舊寫法 (Manual)
function addToCart(e) {
e.preventDefault(); // 很煩,每次都要寫
const formData = new FormData(e.target); // 很煩,還要自己挖
// ... fetch api
}
// ✅ React 19 新寫法 (Automatic)
function addToCart(formData) {
// 直接拿資料開始做事!
const id = formData.get("productId");
// ... fetch api
}
// 從 onSubmit 變 action
return <form action={addToCart}>...</form>;
```
### 補充 'user server'
`action={fn}`,它可以是前端的 API 請求,加上 `"use server"` 也可以是直接執行後端資料庫邏輯(Server Actions)寫 sql 語法,
### useFormStatus
#### 用途
這是一個專門用來解決「Loading 狀態傳遞地獄 (Prop Drilling)」**的新 Hook。
#### 解決了什麼問題?
- **痛點**:以前若表單正在提交 (Pending),你想把深層的 `<Input>` 或 `<Button>` 設為 `disabled`,你必須在最外層 `<form>` 宣告一個 `isLoading` state,然後一層一層傳下去 (`Form -> div -> div -> Input`)。
- **解法**:`useFormStatus` 就像一個無線電接收器。只要在 `<form>` **內部**的任何子元件呼叫它,就能自動偵測父層表單的狀態。
```jsx
// 不需要從父層接收 props!
function SubmitButton() {
// 自動往上找最近的 <form> 問狀態
const { pending } = useFormStatus();
return <button disabled={pending}>Add to Cart</button>;
}
```
## use & Suspense
### 父元件:架構與保護網
```jsx
function ErrorBoundaryWrappedPastOrderRoutes(props) {
const [page, setPage] = useState(1);
// 1. 【關鍵】:獲取 Promise
// 這裡不解構 { data, isLoading },而是直接拿 .promise
// 前提:tanstack query 需設定 experimental_prefetchInRender: true,讓它在 Render 期間就產生 Promise
const loadedPromise = useQuery({
queryKey: ["past-orders", page],
queryFn: () => getPastOrders(page),
staleTime: 30000,
}).promise;
return (
// 2. 【第一層保護】:錯誤邊界
// 當 loadedPromise 被 reject (API 失敗) 時,這裡會攔截錯誤
<ErrorBoundary fallback={<div>Something went wrong!</div>}>
{/* 3. 【第二層保護】:Loading 邊界 */}
{/* 當 loadedPromise 還在 Pending 時,React 會顯示這個 fallback */}
<Suspense
fallback={
<div className="past-orders">
<h2>Loading Past Order ...</h2>
</div>
}
>
{/* 4. 【傳遞 Promise】:像傳遞接力棒一樣傳給子元件 */}
<PastOrdersRoute
loadedPromise={loadedPromise}
page={page}
setPage={setPage}
{...props}
/>
</Suspense>
</ErrorBoundary>
);
}
```
### 子元件
```jsx
// 接收父元件傳來的 loadedPromise
function PastOrdersRoute({ page, setPage, loadedPromise }) {
// 1. 【關鍵】:使用 React 19 的 use()
// 這行程式碼會「暫停」元件渲染,直到 Promise 解開
// - Pending: 暫停 -> 顯示父層 Suspense fallback
// - Resolved: 恢復 -> 把資料塞進 data
// - Rejected: 崩潰 -> 觸發父層 ErrorBoundary
const data = use(loadedPromise);
// --- 程式碼執行到這裡,代表 data 一定有值 (Happy Path) ---
const [focusedOrder, setFocusedOrder] = useState();
// (下略:其他的 query 邏輯...)
// ...
return (
// 這裡可以直接 render data,完全不用判斷 isLoading
<div>
{/* 使用 data 渲染畫面 */}
</div>
);
}
```
### 觀念補充
#### 1. `useQuery(...).promise` 是什麼?
這其實是 **TanStack Query (React Query)** 提供的一個特殊屬性(特別是為了配合 React Suspense 模式)。
- **一般模式**:`useQuery` 回傳 `data`, `isLoading`, `error`。你必須自己寫 `if (isLoading) return <Loading />`。
- **Promise 模式**:
- 所以我們不看 `data` 或 `isLoading`。
- 以 **Promise ** 本身判斷(也就是 `loadedPromise`)。
==**`loadedPromise` 的作用**:我們把這個把它交給 React 的新功能 `use()` 去處理。==
#### 2. `use` 是幹嘛用的?(React 19 新功能)
你可以把 `use(promise)` 想像成一個 **「會自動暫停元件渲染的 `await`」**。
- **以前的寫法 (條件渲染)**:
```jsx
const { data, isLoading } = useQuery(...);
if (isLoading) return <Spinner />; // 手動切換 UI
return <div>{data.title}</div>;
```
缺點:每個元件都要重複寫這段判斷,而且資料載入邏輯跟 UI 綁死。
- React 19 寫法 (`use` + Suspense)**:
```jsx
// 當 React 執行到這一行...
const data = use(loadedPromise);
// 如果 Promise 還沒解開 (Pending),React 會直接「暫停」渲染這個元件,
// 並自動往上找最近的 <Suspense> 顯示 fallback。
// 等到 Promise 解開 (Resolved),React 會「恢復」渲染,並把資料塞進 data。
return <div>{data.title}</div>; // 這裡永遠只會拿到「成功後的資料」
```
**優點**:你的 Component 變得非常乾淨!程式碼裡面完全沒有 `if (isLoading)`,因為只要能執行到 `return`,資料一定已經準備好了。
#### 3. `Suspense` 與 `ErrorBoundary` 的角色分工
- **`<Suspense>` (管 Loading)**:
- 當 `use(promise)` 發現資料還在載入時,元件會「暫停」。
- `Suspense` 負責接手,顯示 `fallback` (例如:Loading Past Order ...)。
- **一句話:它處理「等待中」的狀態。**
- **`<ErrorBoundary>` (管 Error)**:
- 如果 `use(promise)` 發現這個 Promise **失敗了 (Rejected)**,`ErrorBoundary` 負責接手顯示錯誤畫面 (例如:Oops, something went wrong)
- **一句話:它處理「Render error」的狀態。**
這兩者通常會包在一起使用(Async Boundary),形成一個完整的非同步保護網。
#### 4. 為什麼要改用這種寫法?原本的 `isLoading` 不好嗎?
原本的 `isLoading ? <Loading /> : <Content />` 當然能用,但在大型應用中有兩個缺點:
1. **Waterflow (瀑布流) 問題**: 如果是傳統寫法,父元件 render -> 抓資料 -> isLoading -> 抓完 -> render 子元件 -> 子元件抓資料... 會變成一階一階的慢。 使用 Suspense 模式,可以更容易實現「盡早抓取 (Render-as-you-fetch)」,讓 React 更聰明地調度渲染時機。
2. **程式碼分離 (Separation of Concerns)**:
- **UI 元件 (`PastOrdersRoute`)**:只專注於「有資料時要怎麼顯示」。
- **父元件/架構層 (`Suspense`/`ErrorBoundary`)**:專注於「載入中或出錯時要怎麼辦」。
這樣你的 UI 元件會變得非常「純粹」且好測試。
#### 5. fetch on mount VS fetch on render
##### Fetch-on-Mount (掛載後抓取)
- **機制**:Render -> 執行 `useEffect` -> 發出請求。
- **關鍵字**:`useEffect`、TanStack `useQuery`。
- **Vue 對照**:`onMounted(() => fetch())`。
- **缺點**:會有瀑布流 (Waterfall) 問題。
##### Fetch-on-Render / Render-as-You-Fetch (渲染時抓取) — React 19 新潮
- **機制**:開始 Render (**立刻發出請求 (丟出 Promise)** )-> 暫停 (Suspend) -> 顯示 Fallback。
- **關鍵字**:`Suspense`、`use()`、`experimental_prefetchInRender` for TanStack Query。
- **Vue 對照**:`script setup` 頂層 `await fetch()` (搭配 Vue Suspense)。
- **優點**:早點開始抓,體驗較好。
## React Compiler
開發者不再需要手動寫 `useMemo` 與 `useCallback`,效能優化變成編譯器的責任。
- **支援版本**:React 17, 18, 19 (官方強烈建議 18+)。
- **穩定性**:已在 Instagram、Facebook 的 Production 環境運行。
- **React 19**:原生支援,**不需要**額外安裝 runtime 套件。
- **React 17 / 18**:核心不認識 Compiler 產出的快取語法,**必須**額外安裝 `react-compiler-runtime` 這個 Polyfill。
### 1. 環境檢查 (Health Check)
在安裝前,先檢查專案代碼庫是否相容
```
npx react-compiler-healthcheck@beta
```
- **成功指標**:顯示大部分組件可被優化 (Successfully compiled X of Y components),且無不相容的 Libraries。
### 2. 安裝 Compiler
目前仍為 Beta 版,可能會有版本依賴警告,建議加上 `--force`。
```
npm install -D babel-plugin-react-compiler@beta --force
```
### 3. 配置 (以 Vite 為例)
修改 `vite.config.js`,在 React plugin 的 Babel 設定中加入 Compiler。
JavaScript
```jsx
// vite.config.js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [
react({
babel: {
plugins: [
["babel-plugin-react-compiler", { target: "19" }] // 若是 React 18 可改為 "18"
],
},
}),
],
})
```
### 4. 驗證是否生效 (React DevTools)
這是最重要的一步,確認 Compiler 是否真的在工作。
1. 啟動專案 (`npm run dev`)。
2. 開啟瀏覽器開發者工具 (F12)。
3. 切換到 **Components** 分頁 (React DevTools)。
4. 觀察組件樹狀圖。
**成功訊號**: 你會在 Component 名稱旁邊看到一個 **「Memo (✨ 魔法棒/星星)」** 的標記。
這代表 React Compiler 已經自動對該組件進行了 Memoization 優化。
### 5. 其他指令
- **手動排除 (Escape Hatch)**:如果不希望某個組件被優化,可在檔案頂部加上指令:
```
"use no memo";
```