iT邦幫忙

2026 iThome 鐵人賽

DAY 6
0
Modern Web

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

Day 6|@Input/@Output → props 與 callback:歡迎來到單向資料流

  • 分享至 

  • xImage
  •  

元件裡改了資料,然後什麼都沒發生

檔案列表要支援重新命名。我在 FileRow 裡寫了這段:

interface FileRowProps {
  file: File;
}

export function FileRow({ file }: FileRowProps) {
  function handleRename(newName: string) {
    file.name = newName;
  }

  return <input defaultValue={file.name} onBlur={e => handleRename(e.target.value)} />;
}

改完離開輸入框。畫面沒動,父層的清單也沒動,什麼都沒發生。

我的第一個念頭是:「又是昨天那個 Object.is 吧,我改了物件內容但參考沒換。」

不對。這次的問題更前面一點——那份資料根本不歸我管,我從一開始就不該在這裡改它。


一、Angular:資料可以兩頭跑

往下傳跟往上傳,兩邊其實長得很像:

// Angular v20
export class FileRowComponent {
  file = input.required<File>();
  renamed = output<string>();
}
<app-file-row [file]="f" (renamed)="onRenamed($event)" />

React 那邊也是一個傳值、一個傳函式。這部分沒什麼好說的。

真正的差別有兩個。

第一個是雙向綁定。

<input [(ngModel)]="keyword" />

一行。值會自動填進去,使用者打字也會自動寫回 keyword。你不用寫任何一條接著的方法。

自訂元件也能做到,只要 input 跟 output 湊成一對:

<app-search-box [(value)]="keyword" />

第二個更關鍵:物件 input 傳下去的是同一個參考。

export class FileRowComponent {
  file = input.required<File>();

  rename(newName: string) {
    this.file().name = newName;   // 直接改,父層的畫面會跟著更新
  }
}

這能動,而且動得很順。因為變更偵測走訪整棵樹的時候,會重新讀取每個綁定的值——它不在乎是誰改的,只在乎現在的值跟上次一不一樣。

所以在 Angular,父層和子層其實在操作同一份東西。方便,但也代表當一個值莫名其妙變了的時候,嫌疑犯是整棵樹上的每一個元件。


二、React:props 是唯讀的

React 這邊只有一條規則:

props 是唯讀的。不是慣例,是規則。

function FileRow({ file }) {
  file.name = 'new';   // ❌ 不要這樣做
}

這行不會報錯,JavaScript 也擋不住你——物件參考傳進來了,你當然改得動。但它不會觸發任何更新,因為 React 沒有在偵查(這是昨天的結論)。它只認 setState,而 setState 在父層手上。

改了 props,你只是弄髒了別人的東西,而且沒人會知道。

所以資料要往上,只有一條路:父層把函式傳下來,子層呼叫它。

function FileRow({ file, onRename }: {
  file: File;
  onRename: (id: string, name: string) => void;
}) {
  return (
    <input
      defaultValue={file.name}
      onBlur={e => onRename(file.id, e.target.value)}
    />
  );
}
function FileList() {
  const [files, setFiles] = useState<File[]>([]);

  function handleRename(id: string, name: string) {
    setFiles(prev => prev.map(f => f.id === id ? { ...f, name } : f));
  }

  return files.map(f => (
    <FileRow key={f.id} file={f} onRename={handleRename} />
  ));
}

心智模型的差別在這裡:

Angular:父子共享同一份資料,資料沒有擁有者,拿得到就改得動。
React:子元件收到的是一份唯讀的副本,想改就去通知資料擁有者。

FileRow 從頭到尾不知道那份資料存在哪、會被怎麼改。它只知道「有人給了我一個 file,還給了我一個回報改資料的方法」。


三、連一個 input 都要自己接兩條線

單向資料流最有感的地方,是表單。

Angular:

<input [(ngModel)]="keyword" />

React:

<input
  value={keyword}
  onChange={e => setKeyword(e.target.value)}
/>

一個往下(value),一個往上(onChange)。

第一週我每寫一個 input 就在心裡嘆一次氣。但寫到第二十個的時候,我突然意識到一件事:

[(ngModel)] 做的也是這兩件事。

那個香蕉盒語法根本不是什麼特殊機制,它就是 [ngModel] 加上 (ngModelChange) 的語法糖。Angular 幫你把兩條線綁成一條,React 只是把它拆開來攤在你眼前。

[(ngModel)]="keyword"
  ≡ [ngModel]="keyword" (ngModelChange)="keyword = $event"

這種元件在 React 叫受控元件(controlled component)——它的值由 state 控制,自己不保存任何東西。你也可以選擇不控制它,讓 DOM 自己管值(就是上面那個 defaultValue),但那是另一個主題了。

大型表單怎麼處理,Week 3 會專門講。今天的重點只有一個:React 沒有把方向合併起來的語法,因為它不打算讓你忘記有兩個方向。


四、那狀態到底該住哪裡

單向資料流會逼你回答一個 Angular 不太需要面對的問題:這份資料應該住在誰身上?

因為資料只能往下流,所以規則很單純:

需要這份資料的元件們,它們最近的共同祖先就是家。

舉例:搜尋框跟結果列表是兄弟,兩個都要用到 keyword。那 keyword 就不能住在搜尋框裡,得提升到它們的父層。

這個動作叫狀態提升,是 React 最常見的重構之一。

在 Angular 你比較少做這件事,因為你有別的路:注入一個 service 就好。兩個毫不相干的元件都 inject(SearchService),資料自然共享,誰都不用改結構,平常在寫專案我也會把模組常用API放在service。

這就是 Day 2 那套 DI 的另一個用途——Angular 的 service 同時也是狀態共享的管道


五、Prop drilling

假設 keyword 提升到了頁面層,而真正要用它的元件在三層之下:

<FilePage>                    ← keyword 住這裡
  <Toolbar keyword={...}>     ← 不需要,只是路過
    <FilterBar keyword={...}> ← 不需要,只是路過
      <SearchBox keyword={...} />   ← 真正要用的人

中間那兩層完全不關心 keyword,卻不得不接下來再傳下去。這就是 prop drilling

代價不只是打字。真正的成本是:

  • 中間元件的介面被汙染了,它的 props 混進了一堆跟自己無關的東西
  • 之後要新增一個欄位,這條鏈上每一層都要改
  • 重構移動元件時,整條鏈都要重接

React 的解法有兩條:Context 可以讓子樹直接取用,但一變動整棵訂閱的子樹都會重繪(昨天那個 re-render 的故事),而且我在 Day 2 已經說過它的成本;另一條是狀態管理套件,讓資料脫離元件樹單獨存在——這個下週會專門講。

老實說,這是我目前覺得 React 最麻煩的地方。 在 Angular,一個 providedIn: 'root' 的 service 就解決了,不用思考誰是共同祖先、不用煩惱要不要引入 Context、不用擔心整棵子樹重繪。


六、那為什麼還要單向

寫到這裡都在講代價,該給單向資料流一個公道。

它真正的價值,在出事的時候。

當一個值莫名其妙變了,React 專案裡你只需要問一個問題:誰呼叫了那個 setState? 資料只有一個來源、只有一條流向,往上追一定追得到。

同樣的問題在 Angular 就不一定。如果那是一個被層層傳遞的物件,任何一個拿到它的元件都可能動過手;如果用了雙向綁定,值的變動可能發生在你根本沒看的那個檔案裡。

小專案感受不到差別,會覺得 Angular 比較好用,但其實也見人見智啦,對我來說一定是熟悉語法最好用。但在一個五十個元件、三年歷史、換過四手的企業系統裡,「這個欄位為什麼變成這樣」會從除錯變成考古。

React 用樣板換來的,是資料流的可追溯性。它不相信你會自律,所以直接把另一個方向的路封起來。

Angular 則是給你自由,順便給你一整套規範跟 code review 文化去約束它。兩邊都能寫出好系統,只是紀律的來源不同——一個靠團隊,一個靠框架。


今天的結論

@Input/@Output 其實有對應物,props 跟 callback 幾乎是一對一。真正不見的是雙向的路。

Angular 讓資料可以兩頭流動,父子共享同一份東西,改哪裡都算數。方便,但當系統長大,你得靠團隊紀律來維持秩序。甚至會不想用這麼多父子組合

React 只給你一個方向。子元件永遠只是資料的讀者,想改就得往上通報。代價是狀態提升、prop drilling、每個 input 兩條線——但換來的是,任何一個值的變動都有跡可循。

那個沒反應的 rename,不是 React 的 bug,是我還在用「大家共用一份資料」的方式寫程式。在 React 的世界裡,每一份資料都有明確的擁有者,而我當時不是。

Angular 相信你不會亂改,React 乾脆不給你機會。


上一篇
Day 5:沒有 Zone.js,也沒有 Signals:React 怎麼知道要重畫
下一篇
Day 7|沒有 @angular/cli之後:結構要自己立規矩
系列文
Angular 工程師的 React 陣痛期:30 天心智模型重建9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言