Day04\examples\counter-app
Day03 學過,Props 是父元件傳給子元件的資料,特性是「由外部決定、元件內部只能讀不能改(唯讀)」。但如果一個元件想要「自己記住某個會隨時間改變的資料」——例如計數器目前的數字、輸入框目前打了什麼字、這個開關是打開還關閉——就不能只靠 Props,因為 Props 是外部傳進來的,元件自己沒辦法主動去修改它。
這時候就需要 State(狀態):元件自己宣告、自己擁有、而且可以隨時間改變的資料。
先看一個直覺但錯誤的寫法:
function Counter() {
let count = 0 // ❌ 這只是一般的區域變數
function handleIncrement() {
count = count + 1
console.log(count) // 數字確實變了……
}
return (
<div>
<h1>{count}</h1>
<button onClick={handleIncrement}>+1</button>
</div>
)
}
點擊按鈕後,count 的值在記憶體裡確實變成了 1、2、3……但畫面上的 <h1> 永遠顯示 0。原因是:一般的區域變數,重新賦值並不會讓 React 重新呼叫這個元件函式。React 只有在「重新渲染」時才會再跑一次 Counter(),拿到新的 JSX 去更新畫面;而普通變數的賦值,只是改了記憶體裡的值,沒有任何機制通知 React「畫面需要更新了」。而且更根本的問題是:函式每次被呼叫(重新渲染)時,let count = 0 這行都會重新執行一次,把 count 重設回 0,之前累加的結果根本不會被保留下來。
useState 要解決的,正是這兩個問題:
| Props | State | |
|---|---|---|
| 資料來源 | 由父元件從外部傳入 | 元件自己宣告、自己擁有 |
| 可否修改 | 唯讀,子元件不能直接改(Day03 提過 Object.freeze) |
可變,透過專用的 set 函式更新 |
| 誰決定何時改變 | 父元件重新傳入新的 props 時才會變 | 元件自己呼叫 setState 就能改變 |
| 改變後的結果 | 觸發使用這個元件的畫面重新渲染 | 觸發這個元件自己重新渲染 |
| 適合存放 | 元件外部就決定好、不需要元件自己修改的資料 | 元件內部需要「隨使用者互動或時間改變」的資料 |
簡單來說:Props 像是函式的參數(外部決定),State 則像是元件自己的記憶——而且這份記憶是「特權記憶」,只要更新它,React 就會自動重新畫一次畫面,這是一般 JavaScript 變數完全沒有的能力。
useState 的基本語法const [state, setState] = useState(initialState);
宣告一個元件內部的狀態變數,可直接呼叫
setState更新,更新後觸發重新渲染。
拆解這一行程式碼:

useState(0) 回傳的是一個長度為 2 的陣列 [count, setCount],用陣列解構賦值取出兩個值。這是刻意的設計:陣列解構是「依照順序」對應,你可以自由幫這兩個值取任何名字(count/setCount、age/setAge、isOpen/setIsOpen……),不需要像物件解構那樣被固定的屬性名稱綁住。這也是為什麼一個元件裡可以放心多次呼叫 useState,每次都能取一組獨立的名字:
function ProfileForm() {
const [name, setName] = useState('')
const [age, setAge] = useState(0)
const [isSubscribed, setIsSubscribed] = useState(false)
// ……
}
useState 屬於 React 的 Hook(所有以 use 開頭的 API 都是),Hooks 有一個重要規則:只能寫在元件(或自訂 Hook)的最頂層呼叫,不能寫在 if、for、巢狀函式裡面,而且每次渲染呼叫 Hook 的順序必須完全一致。原因會在下一節看到:React 內部是依照呼叫順序,把每個 useState 對應到專屬的一份記憶體(Hook 物件),如果順序在渲染之間改變,就會對應錯誤,讀到不屬於自己的 state。
// ❌ 錯誤:不能把 Hook 包在條件式裡面
function Counter({ enabled }) {
if (enabled) {
const [count, setCount] = useState(0) // 違反 Rules of Hooks
}
// ...
}
useState(initialState) 的參數,除了直接傳一個值(useState(0)、useState('')、useState([])),還可以傳一個函式,這個寫法稱為 Lazy Initializer(惰性初始化):
// 寫法一:直接傳值
const [count, setCount] = useState(0)
// 寫法二:Lazy Initializer,傳一個「回傳初始值」的函式
const [history, setHistory] = useState(() => {
console.log('這行只會在 State 初始化時印出')
return [{ id: 0, label: '初始化', value: 0 }]
})
// ❌ 效能陷阱:每次重新渲染,expensiveCompute() 都會被呼叫一次,
// 只是從第二次渲染開始,算出來的結果會被丟棄不用(因為 useState 只在掛載時採用它)。
const [data, setData] = useState(expensiveCompute())
// ✅ 正確寫法:傳「函式本身」,而不是「呼叫函式的結果」。
// expensiveCompute() 不會在每次重新渲染時重複執行,而只會在 State 初始化時執行。
const [data, setData] = useState(() => expensiveCompute())
判斷原則:當初始值可以直接取得,例如 0、''、false、[]、{},通常直接傳值即可;如果產生初始值需要執行額外工作,例如資料轉換、解析 JSON、讀取 localStorage 或較昂貴的計算,可以使用 Lazy Initializer,避免這些程式碼在每次重新渲染時重複執行。
⚠️ 不過要注意:上面「
expensiveCompute()只會在 State 初始化時執行」這句話,在開發環境啟用<StrictMode>時需要修正一下——React 可能會刻意呼叫 initializer 兩次,用來檢查這個函式是否夠「純」;其中一次的結果會被丟棄不用。Production 環境不會做這個額外檢查,只會呼叫一次。詳見下面的「三個重要注意事項」。
Lazy Initializer 的三個重要注意事項const [data, setData] = useState(() => createInitialData())
initialState(或 initializer 的回傳值),直接使用已經保存的 State,不會重新呼叫它。<StrictMode> 可能執行兩次:React 會刻意呼叫 initializer 兩次,協助檢查它是否為純函式;其中一次結果會被忽略。Production 環境不受這項檢查影響,只會呼叫一次。// ✅ Pure initializer:沒有副作用
function createInitialTodos() {
return [
{ id: 1, text: 'Learn React' },
{ id: 2, text: 'Learn useState' },
]
}
const [todos, setTodos] = useState(createInitialTodos)
createInitialTodos 只負責產生初始資料,沒有修改外部變數,也沒有操作 DOM,因此是純函式。接著直接傳入 useState(createInitialTodos),React 會在 State 初始化時呼叫它,取得回傳值作為初始 State。
let nextId = 0
// ❌ 不建議:修改了外部變數 nextId,不是純函式
function createInitialTodos() {
return [{ id: nextId++, text: 'Learn React' }]
}
const [todos, setTodos] = useState(createInitialTodos)
useState(someFunction) 傳進去的 someFunction 一律會被 React 當成 initializer 執行,而不是把函式本身存成 State。如果真的想把一個函式存進 State,必須用 () => someFunction 包起來:
function handleSomething() {
console.log('Hello')
}
// ❌ handleSomething 會被 React 當成 initializer 呼叫執行
const [callback, setCallback] = useState(handleSomething)
// ✅ 回傳「函式本身」,才會把 handleSomething 存進 State 而不執行它
const [callback, setCallback] = useState(() => handleSomething)
把上面 handleSomething(沒有 return、只印出訊息)跟前面「三個重要注意事項」用過的 createInitialTodos(回傳一個 todos 陣列)放在一起比較,可以更清楚看出「呼叫函式」「傳函式參考」「用箭頭函式包一層」這幾種寫法的差異:
function handleSomething() {
console.log('Hello')
}
function createInitialTodos() {
return [
{ id: 1, text: 'Learn React' },
{ id: 2, text: 'Learn useState' },
]
}
// (1) 直接呼叫,把「呼叫結果」傳給 useState
const [todosA, setTodosA] = useState(handleSomething())
// (2) 直接傳「函式參考」給 useState
const [todosB, setTodosB] = useState(handleSomething)
// (3) 用箭頭函式包一層,回傳「函式參考本身」
const [todosC, setTodosC] = useState(() => createInitialTodos)
// (4) 用箭頭函式包一層,回傳「呼叫後的結果」
const [todosD, setTodosD] = useState(() => createInitialTodos())
| 寫法 | useState 實際收到的參數 |
React 的行為 | 最終 State 的值 | 常見誤區 |
|---|---|---|---|---|
(1) useState(handleSomething()) |
handleSomething() 執行後的結果:undefined(因為 handleSomething 沒有 return) |
參數已經不是函式了(typeof undefined !== 'function'),React 直接把 undefined 當成初始值使用 |
undefined |
handleSomething() 是在呼叫 useState 之前,由 JavaScript 先求值的一般函式呼叫,所以每一次重新渲染都會被執行一次,不是只有初始化時才跑;如果函式有副作用(像這裡的 console.log),每次重繪都會再印一次 |
(2) useState(handleSomething) |
handleSomething 這個函式參考 |
typeof initialState === 'function' 成立,React 把它當成 Lazy Initializer,只在掛載時呼叫一次 handleSomething() |
undefined(handleSomething() 的回傳值) |
容易誤以為「把函式存進 State 了」,但實際上 React 已經把它執行掉並丟棄回傳值,State 拿到的不是函式本身,而是它執行後的結果 |
(3) useState(() => createInitialTodos) |
一個箭頭函式,回傳 createInitialTodos 這個函式參考(注意沒有加 () 呼叫它) |
React 只在掛載時呼叫這個箭頭函式一次,取得回傳值——也就是 createInitialTodos 這個函式本身(尚未執行) |
createInitialTodos 這個函式,不是陣列 |
如果原本目的是想產生「初始的 todos 陣列」,這裡忘了在 createInitialTodos 後面加 (),會拿到函式本身而不是陣列,之後對它呼叫 .map()、讀取 .length 都會報錯;這個寫法只有在「真的想把函式本身存成 State」(例如存一個 callback)時才是正確的,用途跟前面 useState(() => handleSomething) 一樣 |
(4) useState(() => createInitialTodos()) |
一個箭頭函式,呼叫 createInitialTodos() 並回傳它的結果 |
React 只在掛載時呼叫這個箭頭函式一次;箭頭函式內部再呼叫一次 createInitialTodos(),取得陣列 |
[{ id: 1, text: 'Learn React' }, { id: 2, text: 'Learn useState' }] |
這正是「用 Lazy Initializer 產生初始陣列」的正確寫法:createInitialTodos() 只會在掛載時執行一次,重新渲染不會再重複呼叫 |
一句話統整:
() =>,決定 React「什麼時候」執行裡面的函式:沒有包((1)、(2)),函式會在「現在」被執行——(1) 是每次渲染前都執行,(2) 是 React 當成 initializer 只在掛載時執行一次;有包((3)、(4)),才會透過 Lazy Initializer 的機制,保證只在掛載時執行一次。() 去呼叫它,決定最終存進 State 的是「函式本身」還是「函式執行後的回傳值」:不加 ()((3))存進去的是函式參考,適合「就是想把 callback 存成 State」的情境;加了 ()((4))存進去的才是計算出來的初始資料,適合「用函式產生初始值」的情境,也是本節「三個重要注意事項」示範 createInitialTodos 時真正想要的寫法。| 寫法 | 行為 |
|---|---|
useState(0) |
使用 0 作為初始值 |
useState(createInitialTodos()) |
先立即執行 createInitialTodos() |
useState(createInitialTodos) |
使用 createInitialTodos 作為 Lazy Initializer |
useState(() => createInitialTodos()) |
初始化時才執行 createInitialTodos() |
useState(() => createInitialTodos) |
將 createInitialTodos 本身存進 State |
setState 如何觸發重新渲染?這是今天最重要的觀念之一:
呼叫
setCount(...)並不是「立刻把目前的count變數改掉」,而是向 React 提出一次 State 更新要求,讓 React 在後續的渲染中使用新的 State。
例如:
function Counter() {
const [count, setCount] = useState(0)
function handleIncrement() {
console.log(count) // 0
setCount(count + 1)
console.log(count) // 還是 0
}
return <button onClick={handleIncrement}>{count}</button>
}
為什麼第二個 console.log(count) 還是 0?
因為 State 可以理解成某一次渲染(render)的快照(snapshot)。這次 React 執行 Counter() 時,count === 0,因此這次 render 所建立的 handleIncrement 裡,看到的 count 就是 0。呼叫 setCount(1) 並不會回頭修改這一次函式呼叫裡已經存在的 count 變數,而是告訴 React:下一次重新渲染時,這個 State 應該更新成 1。
可以把整個過程理解成:
使用者點擊按鈕
↓
執行 handleIncrement()
↓
呼叫 setCount(1)
↓
React 記錄這筆 State 更新
↓
React 排程重新渲染
↓
再次執行 Counter()
↓
useState 取得新的 count
↓
產生新的 JSX
↓
React 比較前後 UI
↓
必要時更新 DOM
因此下一次 React 再執行 Counter()、呼叫 useState(0) 時,取得的 count 才會是新的值 1。
setState 不代表立刻重新渲染React 也會對同一批發生的多筆 State 更新進行 Batching(批次處理)。例如:
function handleClick() {
setCount(1)
setName('ShaoYu')
setVisible(true)
}
不要把它理解成「setCount => render => setName => render => setVisible => render」這種每呼叫一次就重新渲染一次的線性流程。React 通常會先收集這一批 State 更新,等目前的事件處理函式執行完成後,再一次處理並重新渲染,藉此避免不必要的重複渲染。
這也能自然銜接下一節「連續呼叫 setCount(count + 1) 三次為什麼只 +1」的討論。
還有一個初學者常混淆的地方:React 重新渲染(re-render),不代表瀏覽器 DOM 一定會被修改。
重新渲染主要代表 React 會再次執行元件函式(例如 Counter()),取得新的 JSX,再拿去跟前一次的結果比較;只有真正發生變化、需要同步到畫面的部分,React 才會在後續的 Commit 階段更新 DOM。因此可以把 React 的更新流程簡化理解成:
Trigger(觸發更新)
↓
Render(重新執行元件、計算 UI)
↓
Commit(必要時修改 DOM)
另外,如果傳入的新 State 與目前 State 相同,React 可以透過 Object.is 判定沒有實際變化,並跳過不必要的重新渲染。例如 useState(0) 之後呼叫 setCount(0),因為 Object.is(0, 0) 為 true,React 可以判斷這次 State 並沒有真正改變,因此沒有必要重新渲染。
重點:
setState的真正意思不是「現在立刻修改變數」,而是「要求 React 在後續 render 使用新的 State」。目前這一次 render 中讀到的 State 仍然是原本的 snapshot。
setState 接受的參數,可以是「一個值」,也可以是「一個函式」。從 React GitHub 專案裡可以發現,判斷方式在 basicStateReducer:
// packages/react-reconciler/src/ReactFiberHooks.js
function basicStateReducer<S>(state: S, action: BasicStateAction<S>): S {
return typeof action === 'function' ? action(state) : action;
}
setCount(5) —— action 是數字 5,不是函式,所以直接拿 5 當作新的 state。setCount(prevCount => prevCount + 1) —— action 是一個函式,React 會呼叫 action(state),把「當時佇列裡最新的狀態」當作參數傳進去,用回傳值當作新的 state。function handleWrongTripleIncrement() {
setCount(count + 1) // count 是這次渲染的值,例如 0,所以這裡等於 setCount(1)
setCount(count + 1) // count 還是同一次渲染的 0,所以還是 setCount(1)
setCount(count + 1) // 一樣是 setCount(1)
// 結果:count 最後只會變成 1,而不是預期中的 3
}
問題出在:count 是這次函式呼叫(這次渲染)裡的固定值(透過 JavaScript 閉包(Closure)捕捉),三次呼叫用的都是同一個 count,所以三次 setCount(count + 1) 其實都在說「把 state 換成同一個數字」,並不是「在前一次的基礎上再加一次」。
function handleCorrectTripleIncrement() {
setCount((prevCount) => prevCount + 1) // 0 + 1 = 1
setCount((prevCount) => prevCount + 1) // 1 + 1 = 2
setCount((prevCount) => prevCount + 1) // 2 + 1 = 3
// 結果:count 正確變成 3
}
因為每一次傳入的都是「函式」,React 會依序執行 basicStateReducer:拿「上一步驟算出來的結果」當作這一步驟的 prevCount,三次呼叫依序疊加,最後正確得到 3。
經驗法則:只要新的 state 需要依賴「前一個 state」來計算,就一律使用函式式更新
setState(prev => ...),而不是setState(state值 + 1)。這樣不管呼叫幾次、呼叫的時機為何,結果都會正確疊加,不會受到 JavaScript 閉包(Closure)捕捉到舊值的影響。本篇範例的 Counter 元件特地做了「連續 +3」的兩個按鈕,可以直接點擊比較兩種寫法的實際差異。
前面第四節看到,React 判斷「要不要重新渲染」的其中一個依據,是用 Object.is 比較新舊狀態。這件事對陣列、物件這類「參照型別(Reference Type)」的 state 特別重要:
const [items, setItems] = useState(['蘋果', '香蕉'])
// ❌ 錯誤:直接修改原本的陣列
function addItemWrong(name) {
items.push(name) // 直接改動原本的陣列內容
setItems(items) // 傳進去的還是「同一個陣列」(同一塊記憶體參照)
}
// ✅ 正確:用展開運算符(spread)建立一個「新陣列」
function addItemCorrect(name) {
setItems((prevItems) => [...prevItems, name]) // 產生新的陣列,內容包含舊資料 + 新資料
}
items.push(name) 是直接修改(mutate)原本那個陣列物件本身,items 這個變數的參照(記憶體位址)完全沒有變。就算接著呼叫 setItems(items),React 內部用 Object.is(eagerState, currentState) 比較時,拿到的會是「同一個參照」,判斷成「沒有變化」,很可能就直接 bail out(跳過重新渲染),畫面完全不會更新,即使陣列的內容其實已經被改掉了——這是初學者很容易踩到、而且不容易察覺原因的 Bug。
正確的做法,是每次更新都建立一個全新的陣列或物件,而不是修改原本那一份:
| 想做的事 | ❌ 直接修改(錯誤) | ✅ 建立新的(正確) |
|---|---|---|
| 陣列新增一筆 | arr.push(x) |
[...arr, x] |
| 陣列刪除一筆 | arr.splice(i, 1) |
arr.filter((_, idx) => idx !== i) |
| 陣列修改某一筆 | arr[i] = x |
arr.map((item, idx) => (idx === i ? x : item)) |
| 物件修改某個欄位 | obj.name = 'x' |
{ ...obj, name: 'x' } |
這個「不可變更新」的原則,不只是為了讓 Object.is 判斷正確而已,也讓程式碼更好推理:每一次 state 的變化,都是一份全新、獨立的資料,不會有「別的地方偷偷改到同一份資料」的意外副作用。
今天的練習是做一個計數器元件,除了基本的加一、減一、重設,範例額外加了一份「操作歷程」清單,用來實際示範 Lazy Initializer 跟不可變更新這兩個觀念。
| State | 型別 | 用途 |
|---|---|---|
count |
number |
目前的計數值,透過 +1/-1/「重設」按鈕更新 |
history |
array |
操作歷程紀錄,示範用 Lazy Initializer 設定初始值、用展開運算符做不可變更新 |
// src/components/Counter.jsx
import { useState } from 'react'
let nextHistoryId = 1
// Lazy Initializer:只有 State 初始化時才會呼叫(重新渲染不會再呼叫)
// 注意:這裡的 nextHistoryId++ 修改了外部變數,嚴格來說不是純函式;
// 開發環境啟用 <StrictMode> 時,這個函式可能被連續呼叫兩次,
// nextHistoryId 也會因此多加一次,屬於範例中刻意保留、可觀察的陷阱。
function createInitialHistory() {
console.log('createInitialHistory() 執行了')
return [{ id: nextHistoryId++, label: '初始化', value: 0 }]
}
function Counter() {
const [count, setCount] = useState(0)
const [history, setHistory] = useState(createInitialHistory)
// 不可變更新:一律用展開運算符建立「新陣列」
function addHistory(label, value) {
setHistory((prevHistory) => [
...prevHistory,
{ id: nextHistoryId++, label, value },
])
}
function handleIncrement() {
const nextCount = count + 1
setCount(nextCount)
addHistory('+1', nextCount)
}
function handleDecrement() {
const nextCount = count - 1
setCount(nextCount)
addHistory('-1', nextCount)
}
function handleReset() {
setCount(0)
addHistory('重設', 0)
}
// ……(連續 +3 的兩個示範函式,見下一步)
}
留意 useState(createInitialHistory) 這裡傳的是 createInitialHistory 這個函式本身,沒有加 ()——這正是第三節提到的 Lazy Initializer 寫法。之後不管按了再多次按鈕、觸發再多次重新渲染,React 都不會再重新呼叫這個 initializer,只會沿用第一次的結果。
打開瀏覽器開發者工具的 Console 觀察 createInitialHistory() 執行了 這行 log:因為這個範例的 examples/counter-app 在 main.jsx 用了 <StrictMode>,開發環境下這行 log 很可能會印出兩次,nextHistoryId 也會因此多加一次(例如第一筆歷程紀錄的 id 可能是 2 而不是 1)。這是因為 createInitialHistory 用 nextHistoryId++ 修改了外部變數,不是純函式,而 React 在 <StrictMode> 開發環境下會刻意呼叫 initializer 兩次來檢查它是否夠純,其中一次結果會被丟棄。Production 環境不會做這項檢查,只會呼叫一次。
// ❌ 錯誤示範:連續三次直接設值,讀到的都是同一次渲染的 count,結果只 +1
function handleWrongTripleIncrement() {
setCount(count + 1)
setCount(count + 1)
setCount(count + 1)
addHistory('連續 +3(❌ 直接設值,結果只 +1)', count + 1)
}
// ✅ 正確做法:函式式更新,依序疊加,結果正確 +3
function handleCorrectTripleIncrement() {
setCount((prevCount) => prevCount + 1)
setCount((prevCount) => prevCount + 1)
setCount((prevCount) => prevCount + 1)
addHistory('連續 +3(✅ 函式式更新,結果 +3)', count + 3)
}
實際點擊這兩個按鈕,觀察畫面上的數字變化,就能直接驗證第五節講的「直接設值」與「函式式更新」差異:一個只會 +1,另一個才會正確 +3。
return (
<div className="counter-app">
<p className="count-label">目前計數</p>
<h1 className="count-display">{count}</h1>
<div className="button-group">
<button type="button" onClick={handleDecrement}>-1</button>
<button type="button" onClick={handleIncrement}>+1</button>
<button type="button" className="reset" onClick={handleReset}>重設</button>
</div>
<div className="button-group secondary">
<button type="button" onClick={handleWrongTripleIncrement}>連續 +3(❌ 直接設值)</button>
<button type="button" onClick={handleCorrectTripleIncrement}>連續 +3(✅ 函式式更新)</button>
</div>
<section className="history">
<h2>操作歷程</h2>
<ul>
{history.map((item) => (
<li key={item.id}>{item.label} → {item.value}</li>
))}
</ul>
</section>
</div>
)
}
export default Counter
history.map(...) 會在 Day06(條件渲染 & 列表渲染)詳細介紹,這裡先直接使用:每筆紀錄有獨立的 id(用來當 key),渲染出一行「操作 → 結果」的文字,讓「操作歷程」隨著每次按鈕點擊持續累加,直接把「不可變更新」的效果視覺化呈現出來。
App.jsx 掛載元件Day04\examples\counter-app\src\App.jsx
import './App.css'
import Counter from './components/Counter.jsx'
function App() {
return <Counter />
}
export default App
今天的練習重點單純聚焦在 useState 本身,所以 App 只需要掛載 <Counter />,不需要額外傳遞 props。
cd Day04/examples/counter-app
npm install
npm run dev
打開 http://localhost:5173/,可以實際操作:
createInitialHistory() 的 log;因為範例啟用了 <StrictMode>,開發環境下很可能會看到印出兩次(Production 環境只會印出一次)。
setState 會立刻同步改變變數的值:實際上 setState 只是「排程」一次重新渲染,當下這次函式呼叫裡的變數(例如 count)不會馬上改變,要等下一次渲染才會拿到新值。setCount(count + 1) 好幾次,容易因為 JavaScript 閉包(Closure)捕捉到同一個舊值而只生效一次;只要新狀態需要根據舊狀態計算,一律改用函式式更新 setCount(prev => prev + 1)。arr.push(x); setArr(arr),因為參照沒有改變,Object.is 比較會判斷成「沒有變化」,畫面很可能不會更新;務必用展開運算符等方式建立全新的陣列/物件。useState(expensiveCompute()) 會讓每次重新渲染都白白呼叫一次 expensiveCompute(),應該寫成 useState(() => expensiveCompute())。<StrictMode>,開發環境可能會刻意呼叫 initializer 兩次來檢查它是否為純函式;務必讓 initializer 保持 pure(不修改外部變數、不操作 DOM),否則兩次呼叫可能造成不一致的結果。