簡單來說:技術上各有千秋,沒有絕對的勝負,但「演進方向」正逐漸趨同。
Vue 3 (Proxy 響應式):【開發直覺度勝】
憑藉自動依賴收集(Dependency Tracking),寫起來最符合直覺(直接修改變數就自動更新 UI),且完全沒有 React 的閉包陷阱與過度重繪問題。
Angular (Signals):【架構嚴謹度勝】
Signals 兼具了 Vue 的「細粒度更新效能」與「明確的型別邊界」。在大型企業級專案中,它的可預測性與維護性非常強。
React (useState + Compiler):【心智模型簡潔度勝】
React 堅持「UI = f(state)」的純函式哲學。在 React Compiler 出現後,React 解決了過去被詬病「手動優化太痛苦(memo/useCallback)」的缺點,讓開發者既能享有簡單的單向資料流心智模型,又能獲得頂級的渲染效能。
在前端框架中,當資料變更或組件掛載時,我們經常需要執行「副作用(Side Effects)」——例如發送 API 請求、訂閱事件或手動操作 DOM。
本文將深入剖析 React 的 useEffect、Vue 3 的 watch / watchEffect 以及 Angular 的 effect / Lifecycle,並從 Fiber 內部機制 與 打包效能(Bundle Size) 進行全方位的對比。
在解構 useEffect 之前,我們需要先澄清 React 的核心架構:React 內部使用的是 Fiber Tree 與 WorkInProgress Tree,並沒有所謂「Fiber DOM」這種稱呼,而是 Fiber 節點 映射到 真實 DOM 節點(Host Component / Host Node)。
React 的渲染過程分為兩個階段:
WorkInProgress 樹。import { useState, useEffect } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
// ⚠️ 副作用會在 Commit Phase 結束、DOM 繪製完成後才同步/異步執行
let isMounted = true;
fetchUser(userId).then(data => {
if (isMounted) setUser(data);
});
// Cleanup 函式
return () => {
isMounted = false;
};
}, [userId]); // 依賴陣列(Dependency Array)
return <div>{user?.name}</div>;
}
memoizedState 環形單向鏈表。useEffect 的依賴陣列與 Cleanup 函式都會存在這條鏈表上。Vue 3 將副作用拆分為 watch(精準監聽) 與 watchEffect(自動追蹤)。
<script setup>
import { ref, watch, watchEffect, onUnmounted } from 'vue'
const userId = ref(1)
const user = ref(null)
// 1. watchEffect:自動收集 effect 內部讀取到的所有 ref 依賴
watchEffect(async (onCleanup) => {
let isCancelled = false
onCleanup(() => { isCancelled = true }) // 清理函式
const data = await fetchUser(userId.value)
if (!isCancelled) user.value = data
})
// 2. watch:明確指定監聽源 (類似 React 依賴陣列,但不會有閉包陷阱)
watch(userId, (newId, oldId) => {
console.log(`User changed from ${oldId} to ${newId}`)
})
</script>
watchEffect 在執行時會觸發 Getter,Vue 的 ReactiveEffect 物件會自動建立「狀態 $\rightarrow$ Effect」的雙向訂閱關係。<script setup> 僅執行一次,變數指標固定,絕不會發生依賴陣列漏寫導致的閉包快照問題。effect() 與生命週期現代 Angular 引進了 Signal 體系的 effect(),同時保留了傳統的生命週期鉤子(如 ngOnInit, ngOnDestroy)。
import { Component, signal, effect, inject, DestroyRef } from '@angular/core';
@Component({
selector: 'app-user',
template: `<div>{{ user()?.name }}</div>`
})
export class UserComponent {
userId = signal(1);
user = signal<any>(null);
constructor() {
// Angular Signal Effect:自動追蹤內部讀取到的 Signal
effect((onCleanup) => {
const id = this.userId(); // 自動建立依賴
const controller = new AbortController();
fetchUser(id, { signal: controller.signal }).then(data => this.user.set(data));
onCleanup(() => controller.abort()); // 清理邏輯
});
}
}
effect() 必須在 Injection Context(如 constructor)中建立,並自動綁定到組件的 DestroyRef 生命週期,避免記憶體洩漏。除了語法與運作機制外,框架的打包體積與樹搖效能(Tree-Shaking)也是現代前端評估的核心指標。
| 比較維度 | React (useEffect) |
Vue 3 (watch / watchEffect) |
Angular (effect / Signal) |
|---|---|---|---|
| 依賴指定方式 | 手動指定(依賴陣列 [dep]) |
自動追蹤 (watchEffect) 或 明確指定 (watch) |
自動追蹤 (讀取 Signal 自動綁定) |
| 執行時機 | 畫面渲染(Paint)完成後非步執行 | DOM 更新前/後可配置(預設 pre,可改 post) |
變更偵測(Change Detection)週期中同步/排程執行 |
| 內部底層結構 | Fiber 節點 memoizedState 鏈表 |
ReactiveEffect 訂閱者集合 (Set) |
Signal Dependency Graph 拓撲排序 |
| 閉包陷阱風險 | ⚠️ 高(需依賴 Linter 提示) | ❌ 無 | ❌ 無 |
| 框架 Runtime 體積 (Gzip) | ~42 KB (react + react-dom) |
~33 KB (vue) |
~75 KB - 110 KB (@angular/core 等) |
| Tree-Shaking 友善度 | 中等(Virtual DOM 核心不可拆分) | 極高(Composition API 模組化導出) | 高(Angular 17+ 採用獨立組件與 Signal 模組) |
useEffect**:強制開發者明確宣告依賴,雖然維護 Fiber 鏈表與應對閉包陷阱帶來了一定的負擔,但在配合 React 19 / Compiler 後,依賴陣列管理正在逐漸被自動化。watch/watchEffect**:結合了極小打包體積(~33KB Gzip)與自動依賴收集,在開發體驗與產出效能上達成了極佳平衡。effect**:雖然 Runtime 體積較大,但為大型系統提供了最強大的架構約束力與自動化清理機制。