# 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"; ```