寫了一個檔案列表,call api後需要重新渲染。看起來很無害:
function FileList({ page }: { page: number }) {
const [files, setFiles] = useState<File[]>([]);
const params = { page, size: 20 };
useEffect(() => {
api.getFiles(params).then(setFiles);
}, [params]);
return <FileTable files={files} />;
}
跑起來,Network 面板開始像瀑布一樣往下流。同一支 API,一秒鐘重複多次,沒有停下來的跡象。
這在 Angular 也是寫得出來的錯,但只能說較少會遇到——因為 ngOnInit 根本不給你機會這樣寫。
Angular 的做法很直白:它在元件的一生中設了幾個時間點,你想在哪個點做事,就實作對應的介面。
export class FileListComponent implements OnInit, OnDestroy {
ngOnInit() {
// 元件初始化完成,input 已經第一次設定好
}
ngOnChanges(changes: SimpleChanges) {
// 每次 input 變動
}
ngAfterViewInit() {
// 子元件與 DOM 都就緒了
}
ngOnDestroy() {
// 元件要被銷毀了
}
}
這套設計有三個特徵:
每個時機都有名字。 你不用思考「現在是什麼階段」,方法名稱已經告訴你了。而且很直白!
順序是固定的。 ngOnChanges → ngOnInit → ngAfterViewInit → ngOnDestroy,框架照這個順序呼叫,不會變。
你回答的是「什麼時候」。 整套心智模型建立在時間軸上:元件出生、更新、死亡,你在對應的點掛上程式碼。
這也是為什麼 Angular 工程師轉 React 時,第一個問題永遠是那句:
所以
ngOnInit跟其他生命週期對應到什麼?
答案是:沒有對應的東西。不是藏起來了,是這個概念被拿掉了。
useEffect 看起來很像生命週期鉤子沒錯啦,但它的設計意圖完全不同。官方文件對它的定義是:把元件跟外部系統同步。
差別在於回答的問題變了:
| 要回答的問題 | |
|---|---|
| Angular | 什麼時候做這件事? |
| React | 這件事依賴什麼? |
useEffect 的第二個參數不是「執行時機」,是依賴清單。你列出這個副作用用到了哪些值,React 負責在那些值變動時重新同步。至於它在元件生命中的哪個階段跑,你不需要知道,也不應該在意。
useEffect(() => { ... }, []); // ≈ ngOnInit
useEffect(() => { ... }, [value]); // ≈ ngOnChanges(但只看 value)
useEffect(() => { ... }); // 每次 render 後都跑
useEffect(() => { return () => {...} }, []); // ≈ ngOnDestroy
這張表是拐杖可以協助我們,但不是真相。 它能幫你撐過第一週,但你愈用它,愈寫不出正確的 React——因為它把你留在舊有「時機」的思維裡,而 bug 全都藏在「依賴」那一側。
剛剛上面列表提到的無限迴圈,就是這麼來的。
回頭看那段程式碼:
const params = { page, size: 20 };
useEffect(() => {
api.getFiles(params).then(setFiles);
}, [params]);
我的想法是:「params 變了才重新抓資料。」
但 React 比較依賴項的方式是 Object.is——也就是參考比較,不是內容比較。
而 params 是一個物件字面量,寫在元件函式本體裡。每一次 render,這行都會重新執行,產生一個全新的物件:
{ page: 1, size: 20 } === { page: 1, size: 20 } // false
內容一模一樣,但 React 只看參考,所以每次都判定「依賴變了」。
於是迴圈成形:
render → 建立新的 params → 依賴變了 → 跑 effect
→ setFiles → 觸發 render → 建立新的 params → ...
沒有任何一步是錯的。React 完全照我寫的做——是我以為自己在比較內容,實際上在比較身分。
// 1. 搬進 effect 裡(最推薦)
useEffect(() => {
api.getFiles({ page, size: 20 }).then(setFiles);
}, [page]);
// 2. 拆成原始值,讓比較退化成值比較
useEffect(() => {
api.getFiles({ page, size }).then(setFiles);
}, [page, size]);
// 3. 真的需要保留物件時,才用 useMemo 固定參考
const params = useMemo(() => ({ page, size: 20 }), [page]);
第一種最好。依賴陣列裡只放原始值(字串、數字、布林),是我現在的硬規則——物件、陣列、函式一旦出現在裡面,就要停下來想三秒。
這也適用於函式:寫在元件裡的函式每次 render 也是新的參考,所以 useCallback 存在的理由跟 useMemo 一樣,不是效能,是參考穩定。
還記得 Day 2 那個 useMemo(() => new ApiService(), []) 嗎?同一件事的不同面貌。參考相等性是 React 的地基,而 Angular 把它埋起來了。
ngOnDestroy 只說對了一半useEffect 可以回傳一個函式,多數人第一次看到會直接對應到 ngOnDestroy:
useEffect(() => {
const timer = setInterval(tick, 1000);
return () => clearInterval(timer);
}, []);
但它不只在元件卸載時執行。每一次 effect 要重跑之前,上一次的清除函式都會先跑一遍。
useEffect(() => {
const ws = connect(roomId);
return () => ws.close();
}, [roomId]);
roomId 從 A 換成 B,順序是:關掉 A 的連線 → 建立 B 的連線。舊的一定會被收乾淨,你不用自己寫「如果已經有連線就先關掉」那段判斷。
這個概念 Angular 沒有對應物。ngOnDestroy 只在死亡時呼叫一次,中途的 input 變動要自己在 ngOnChanges 裡手動清理——這正是 takeUntilDestroyed 跟一堆 unsubscribe 樣板存在的原因。
所以更準確的說法是:清除函式不是「解構子」,是「復原上一次同步」。 想通這件事,useEffect 的設計就講得通了——它從頭到尾在做的就是「維持同步」,而不是「在某個時機做某件事」。
順帶一提:開發模式下你會發現 effect 跑了兩次、連線建了兩條。那是 StrictMode 故意的,用意就是逼你把清除函式寫對。這個坑留到 Week 4 講 WebSocket 時細講。
有趣的是,Signals 時代的 Angular 走了一樣的路:
export class FileListComponent {
page = input.required<number>();
constructor() {
effect(() => {
this.loadFiles(this.page()); // 讀到 page(),就自動建立依賴
});
}
}
effect() 也是「依賴變了就重跑」,不是「在某個時機執行」。概念跟 useEffect 一致。
之前有跟前輩聊過,其實現在框架互相概念會有不少抄來抄去。
但有一個關鍵差異:Angular 的依賴是自動追蹤的。 你在 effect 裡讀了哪些 signal,框架就記得哪些;React 要你手動列出來,列錯了就是 bug——漏列會拿到過期的值,多列會多跑,列了不穩定的參考就會無限迴圈。
ngOnInit 不是不見了,是整個以時間為軸的心智模型被換掉了。
Angular 問你「什麼時候做」,你回答一個時機。React 問你「這件事依賴什麼」,你回答一份清單——然後由框架決定什麼時候跑。
這個轉換最難的地方不在語法,在於你得放棄「元件有生命階段」這個直覺。只要還在腦中把 useEffect(fn, []) 翻譯成 ngOnInit,你遲早會寫出那個無限迴圈——因為你在回答一個 React 沒有問的問題。
而背後真正的分水嶺還是 JavaScript 本身:Object.is、參考相等性、每次 render 重新產生的字面量。這些在 Angular 裡從來不需要想,因為模板綁定跟生命週期把它們全包起來了。到了 React,它們直接決定你的程式會不會把瀏覽器燒起來。
Angular 幫你管時間,React 要你管依賴。 管不好的代價,就會變成一個轉個不停的迴圈風扇。
明天聊變更偵測:沒有 Zone.js,也沒有 Signals,React 到底怎麼知道畫面該重畫。