那天在做一個檔案上傳列表頁。空清單的時候不該顯示表格,很簡單的需求:
{files.length && <FileTable files={files} />}
但跑起來,畫面正中央出現一個孤零零的 0。
沒有報錯、沒有警告,就是一個 0 安靜地待在那裡。我盯著愣了一下,第一個念頭是:「這個數字是誰印的?」
答案是我自己。而且這行程式碼在 Angular 裡根本不可能寫得出來。
先講 Angular。同樣的需求:
@if (files().length) {
<app-file-table [files]="files()" />
}
這裡的 @if 看起來像 JavaScript,但它不是。Angular 的模板是一套獨立的語言,是語法糖,寫完之後由編譯器轉成 render 指令,跟你的 TypeScript 是兩個世界。
也因為是獨立語言,它可以對你設限。模板裡不能宣告變數、不能寫 if/else 陳述式、不能呼叫 window、不能塞一整段運算邏輯進去——你只能寫「表達式」,而且是被限制過的表達式。
這些限制不是偷懶,是刻意的。理由有三個:
編譯器能做最佳化。 因為語法有限,Angular 知道每個綁定的依賴是什麼,才有辦法做精準的變更偵測。
模板不會變成垃圾場。 你想在模板裡寫複雜商業邏輯?寫不了,只能搬回 .ts檔。這個限制救過很多專案。
AOT 能在編譯期抓錯。 型別對不上、property 拼錯,ng build 就會失敗,不用等到執行時。
最重要的是:@if 是控制流程,不是運算。 它不會「回傳一個值」,它只是決定那段 DOM 要不要存在。條件是 0 也好、'' 也好、null 也好,結果就是不渲染。不會有髒東西跑出來,就像不會有不負責任的PM亂搞事。
React 這邊,第一件要重新理解的事情是:
JSX 不是模板語言,不是你認知的那種HMTL。它是 JavaScript 的語法糖。
這行:
<div className="box">Hello</div>
編譯之後長這樣(現代的 automatic runtime):
jsx('div', { className: 'box', children: 'Hello' })
就只是一個函式呼叫,回傳一個描述畫面的物件。沒有編譯期的模板語言,沒有專屬語法——它就是 JS。
所以沒有 @if,也不需要 @if。因為 JS 本來就有條件判斷,你直接用就好。
但代價來了:JSX 的大括號裡只能放「表達式」,不能放「陳述式」。
// ❌ 不行,if 是陳述式,沒有回傳值
<div>{ if (show) { <Panel /> } }</div>
// ✅ 三元運算子是表達式
<div>{ show ? <Panel /> : null }</div>
// ✅ && 也是表達式
<div>{ show && <Panel /> }</div>
這就是為什麼 React 的條件渲染滿滿都是 && 跟 ? :——不是大家喜歡這樣寫,是大括號裡只放得下表達式。
Angular 的 @if 是控制流程;React 的 && 是一個會產生值的運算。這個差別,就是那個 0 的來源。
回到我的 bug。
JavaScript 的 && 從來就不是「布林運算」,它是短路取值:左邊為 falsy 就回傳左邊,否則回傳右邊。
0 && <FileTable /> // → 0 (不是 false!)
3 && <FileTable /> // → <FileTable />
'' && <FileTable /> // → ''
null && <FileTable /> // → null
所以當 files.length 是 0,整個表達式的值就是數字 0。它被交給 React 去渲染。
而 React 的渲染規則是這樣的:
| 值 | 畫面上 |
|---|---|
false |
不顯示 |
null |
不顯示 |
undefined |
不顯示 |
true |
不顯示 |
0 |
顯示 0 |
NaN |
顯示 NaN |
看到問題了。0 是一個合法的、有意義的內容——React 沒有理由幫你吞掉它。你寫 {count} 想顯示數量是 0 的時候,它當然得印出來。
React 沒有做錯任何事。是我把一個數字丟給它,然後期待它猜到我要的是布林。更精確的講法是:不是 React 猜錯,是你(開發者)在心裡執行了一次型別轉換(number → boolean),但程式碼裡沒有真的寫出那個轉換。
// 1. 明確比較(我現在都用這個)
{files.length > 0 && <FileTable files={files} />}
// 2. 強制轉布林
{!!files.length && <FileTable files={files} />}
// 3. 三元運算子,意圖最清楚
{files.length ? <FileTable files={files} /> : null}
第一種最好,因為它把「我要判斷的是數量大於零」這件事寫出來了。&& 前面放非布林值,在 React 專案裡幾乎都是 bug 的前兆。
Angular 有 @else:
@if (files().length) {
<app-file-table />
} @else {
<app-empty-state />
}
React 只能用三元:
{files.length > 0 ? <FileTable /> : <EmptyState />}
巢狀兩層以上會變得很難讀。多數人的做法是提早 return,或乾脆拆成兩個元件——這也是 React 元件通常比 Angular 元件細碎的原因之一。
@for 變成 .map(),track 變成 key清單渲染是同樣的故事:Angular 給你語法,React 要你自己用 JS。
@for (file of files(); track file.id) {
<app-file-row [file]="file" />
} @empty {
<app-empty-state />
}
{files.map(file => (
<FileRow key={file.id} file={file} />
))}
track 跟 key 做的是同一件事:告訴框架「這一項是誰」,這樣資料變動時才知道哪些 DOM 可以重用、哪些要重建。沒有它,框架只能靠位置猜,猜錯的結果就是輸入框的內容跑到別人身上、動畫亂跳、狀態錯位。
兩個差異值得記:
Angular v17 之後 track 是強制的,不寫編譯不過。React 的 key 只會在 console 給你一個警告,跑起來照樣能動——然後在某個改動順序的場景突然壞掉。
@empty 沒有對應物。 React 要自己寫:
{files.length > 0
? files.map(f => <FileRow key={f.id} file={f} />)
: <EmptyState />}
至於「用 index 當 key」這個經典陷阱,兩邊的後果是一樣的,就不多說了——只有清單順序永遠不變時才能用。
Angular 的模板不能寫複雜運算,所以給了你 pipe:
{{ file.createdAt | date:'yyyy/MM/dd' }}
{{ user$ | async }}
React 沒有 pipe,因為不需要——你直接呼叫函式:
{formatDate(file.createdAt, 'yyyy/MM/dd')}
更簡單、更直覺、不用註冊、不用 import 到模組裡。但少了兩件事。
一是 pure pipe 的快取。 Angular 的純 pipe 在輸入沒變時不會重算,React 這邊每次 render 都會真的執行一次。大部分情況無所謂,遇到重運算就得自己 useMemo。
二是 | async。 這個真的沒有等價物,而且是我目前最想念的東西。訂閱、解訂閱、變更偵測,一根管線全包。React 這邊要自己處理生命週期——這件事會在下週 RxJS 那幾天變成主戲。
Angular 的模板是一個有保護機制的 DSL。自己發明一套專門描述畫面的語法,它限制你能寫什麼,換來編譯期檢查、最佳化空間,以及「模板不會變成大型垃圾場」的保證。@if 是控制流程,不會有值漏到畫面上。
JSX 是沒有保護機制的 JavaScript。你可以在裡面做任何 JS 做得到的事,代價是 JS 的所有脾氣你也一併繼承了——&& 的短路取值、0 是 falsy 但也是合法內容、表達式與陳述式的分別。這些都不是 React 的規則,是 JavaScript 的規則。
昨天說「寫 React 需要更懂 JS 底層」,那個 0 就是最好的例子。它不是 React 的坑,是認知在常常使用某種方式下,對 && 的理解一直停留在「布林運算」——之前寫 Angular,較少有機會踩坑到浮出水面,因為模板不會讓我這樣寫。
框架的保護機制,有時候擋掉的不一定是錯誤,還有你發現自己不懂的機會。