iT邦幫忙

2026 iThome 鐵人賽

DAY 5
0
Modern Web

Angular 工程師的 React 陣痛期:30 天心智模型重建系列 第 5

Day 5:沒有 Zone.js,也沒有 Signals:React 怎麼知道要重畫

  • 分享至 

  • xImage
  •  

我改了資料,畫面沒反應

需求很小:按一個按鈕,把使用者年齡加一。

function Profile() {
  const [user, setUser] = useState({ name: 'Shawn', age: 30 });

  function handleBirthday() {
    user.age = 31;
    setUser(user);
  }

  return (
    <>
      <p>{user.name} — {user.age} 歲</p>
      <button onClick={handleBirthday}>生日快樂</button>
    </>
  );
}

按下去,畫面上還是 30。

再按一次,還是 30。打開 React DevTools 看 state,裡面明明白白寫著 age: 31資料是對的,畫面是舊的。

這在 Angular 沒遇過。因為之前怎麼改根本不重要。


一、Angular:框架負責偵查

Angular 的 this.user.age = 31 為什麼會更新畫面?因為它壓根不看你怎麼改的。

Zone.js 的做法是攔截。 它在啟動時把瀏覽器的非同步 API 全部換掉——setTimeoutaddEventListenerPromise、XHR,通通被包上一層。任何非同步任務結束,Zone.js 就知道「有事情發生了」,然後通知 Angular 跑一次變更偵測。

變更偵測做的事是:走過整棵 component tree,把每個模板綁定的當前值跟上次的值比對,不一樣就更新那一小塊 DOM。

所以它不在乎你是 user.age = 31 還是換一個新物件。反正它會重新讀 user.age,發現上次是 30、這次是 31,就去更新那個文字節點。

這是一套「你儘管改,我負責找出你改了什麼」的設計。代價是每次事件都要掃整棵樹,元件一多就會卡——這也是 Signals 跟 OnPush 想解決的問題。

Signals 換了策略但沒換立場:從「事後全掃」改成「讀取時記下依賴,變動時精準通知」。框架還是那個負責偵查的角色,只是從地毯式搜索換成了定點監控。


二、React:你不說,它不動

React 從一開始就拒絕當偵探。

它沒有攔截任何 API,沒有追蹤任何讀取。唯一能讓它動起來的方式,是你呼叫 setState

setUser(newUser);   // 我通知你:這裡變了

沒有這一行,你把 state 物件翻過來改一百次,React 也不知道、也不會動。

但光有這一行還不夠。React 收到通知後會做一次檢查:

Object.is(舊的 state, 新的 state)

相同就直接跳過,連 render 都不排。

回到我的 bug:

user.age = 31;    // 改的是物件內容,參考沒變
setUser(user);    // 傳進去的還是同一個物件

Object.is(user, user)true。React 判定「你說變了,但我看是同一個」,於是什麼都沒做。

又是 Object.is 昨天它讓 effect 停不下來,今天它讓畫面停住不動——同一個機制,兩種相反的災難。

修法只有一個方向:給它一個新的物件。

setUser({ ...user, age: 31 });

所以 React 強調不可變性(immutability)。你要讓 React 知道東西變了,唯一的語言就是換一個參考。


三、re-render 不是重畫,是先在草稿上改

這裡有一個 Angular 人很容易誤解的地方。至於React的渲染概念也是我一開始上手的時候讀 Zet的 <React 思維進化> 學到的。

聽到「state 一變,整個元件連同底下的子樹都要 re-render」,直覺反應會是:那不是把一大塊 DOM 砍掉重建嗎?一個文字改了,整張表格重畫?

不是。re-render 完全沒有碰到真實的 DOM。

React 的流程分兩段:

第一段,render phase——在草稿上畫。

React 重新呼叫你的元件函式,執行裡面的 JSX,得到一棵新的樹。但那棵樹不是 DOM,只是一堆普通的 JavaScript 物件:

{ type: 'p', props: { children: ['Shawn', ' — ', 31, ' 歲'] } }

這就是所謂的虛擬 DOM——真實 DOM 的一份輕量描述。建立它的成本只是產生幾個物件,跟操作真實 DOM 完全不是同一個量級。

第二段,commit phase——比對草稿,只改真正不同的地方。

React 拿新的樹跟上一次的樹做 diff,找出實際的差異,然後只對真實 DOM 下最小限度的指令。

以我那個元件來說,整個函式重跑了、整棵 JSX 重建了,但最後送到瀏覽器的操作只有一句:

textNode.nodeValue = '31';

這就是為什麼 React 敢用那麼笨的策略。 它不知道誰真的需要更新,所以乾脆全部重算——但因為「重算」發生在便宜的草稿層,而不是昂貴的真實 DOM 上,這個賭注多數時候划得來。

真實 DOM 之所以昂貴,是因為它背後牽動樣式計算、版面重排、重繪。而建立一個 { type: 'p', props: {...} } 物件,就只是分配一點記憶體。

所以看到 React DevTools 標示「這個元件 re-render 了」的時候,不用緊張。它的意思是「函式重跑了一次」,不是「畫面重畫了一次」。


四、那 Angular 為什麼不需要虛擬 DOM

這是我覺得整件事最有意思的地方——答案藏在 Day 3。

因為 Angular 的模板是一套編譯過的語言,編譯器知道這裡總共有哪些綁定。

<p>{{ user.name }} — {{ user.age }} 歲</p>

ng build 的時候,Angular 已經把這段拆解清楚:這裡有兩個插值、位置在哪、各自綁的是什麼。執行時它只要檢查那兩個值有沒有變,直接更新對應的節點就好。

它不需要「比對兩棵樹找出差異」,因為差異可能出現在哪裡,編譯期就已經知道了

React 沒有這個條件。JSX 是 JavaScript,裡面可以有 &&、三元、.map()、任意函式呼叫——編譯期沒有人能預知這次會產生什麼結構。

{files.length > 0 ? <Table /> : <Empty />}
{items.map(i => <Row key={i.id} />)}

所以 React 只能用執行期的辦法:把結果整個算出來,再跟上次比。

這兩件事是同一個選擇的兩面。Day 3 說「Angular 用受限的 DSL 換取編譯期的最佳化空間」——虛擬 DOM 的有無,就是那個最佳化空間最具體的體現。自由與可預測,只能挑一個。


五、三種「怎麼知道要重畫」

整理一下:

怎麼知道有東西變了 更新範圍 誰負責
Zone.js 攔截非同步 API,事件結束就掃 走訪整棵樹,比對每個綁定 框架
Signals 讀取時建立依賴圖 只有依賴該 signal 的節點 框架
React 你呼叫 setState 該元件往下的子樹重算,diff 後只改差異

Angular 兩代的演進都在回答同一個問題:「怎麼自動知道什麼變了?」從攔截到追蹤,愈來愈精準。

React 從頭到尾拒絕回答這個問題。它把責任丟回給你——你知道什麼時候變了,那你說。

那 React 的優化手段呢

memouseMemouseCallback 做的事,某種程度上就是手動補回 Signals 自動做到的事:告訴 React「這一塊沒變,跳過吧」。

const MemoRow = memo(Row);   // props 參考沒變就不重算

對照起來:

  • Angular 的 OnPush:也是靠參考比較決定要不要檢查這個元件——心智模型跟 memo 幾乎一樣
  • Angular 的 Signals:讀了什麼自動記住,不用你標記
  • React:每一處都要你自己標

而且 memo 的前提是 props 的參考要穩定,這就回到昨天的 useMemo——傳一個每次都新建的物件進去,memo 就等於沒裝。

這三個 hook 串起來看,會發現它們解決的其實是同一件事:在一個所有東西每次 render 都重生的世界裡,手動指定哪些東西應該活下來。


今天的結論

Angular 的變更偵測是一套偵查系統。Zone.js 攔截、Signals 追蹤,不管哪一代,框架都在想辦法自己弄清楚「什麼變了」。只要改資料,剩下的它處理。

React 的變更偵測是一套通報系統。它什麼都不偵查,等你呼叫 setState,然後用 Object.is 檢查參考有沒有換。換了就把那塊子樹重算一遍,在虛擬 DOM 上比對,只把差異寫進真實畫面。

所以那個 31 沒出現,不是 React 壞掉,是我用 Angular 的方式在講話——直接改物件,然後期待有人發現。在 React 的世界裡,沒有人在偵查。

一邊賭框架夠聰明,一邊賭開發者夠自律。三年的 Angular 讓我習慣了前者,而這一週我都在學後者。

明天聊 @Input/@Output:當父子溝通從雙向綁定變成單向資料流,那些原本一行 [(ngModel)] 解決的事情,要怎麼重新安排。


上一篇
Day 4 | 口袋少了生命週期鉤子,換成 useEffect 的陷阱
下一篇
Day 6|@Input/@Output → props 與 callback:歡迎來到單向資料流
系列文
Angular 工程師的 React 陣痛期:30 天心智模型重建9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言