檔案功能的程式碼越寫越多,我決定整理一下:把 features/files/FileRow.tsx 拆成兩個元件,順便改名。
改完,檔案頁跑得好好的。
然後設定頁壞了。
打開設定頁的程式碼一看,裡面有這一行:
import { FileRow } from '~/features/files/FileRow';
同事做其他頁面時,覺得我的 FileRow 很好用,就直接 import 過去了。他沒做錯什麼——那個檔案有 export,路徑也找得到,TypeScript 完全沒意見。
我也沒做錯什麼——那是我功能裡面的元件,我以為只有我在用。
問題是,沒有任何東西告訴我們,哪些是可以給別人用的,哪些不是。
在 Angular,這件事有一道牆幫我擋著。
NgModule 其實是一道牆Day 7 講過,core、shared、features 那套資料夾是 NgModule 的影子。今天要講的是影子背後那個本體,真正在做的事。
@NgModule({
declarations: [FileListComponent, FileRowComponent, FileToolbarComponent],
exports: [FileListComponent], // 👈 只有這個對外
})
export class FilesModule {}
declarations 是這個模組擁有的元件,exports 是它願意給別人用的元件。兩者不一樣。
別的模組 import 了 FilesModule,只能用 FileListComponent。想在模板裡寫 <app-file-row>,編譯器會直接報錯:
'app-file-row' is not a known element
這個錯誤我以前覺得很煩,每次新增元件忘記 export 就會遇到。現在才懂:它就是那道牆。 FileRowComponent 是這個功能的內部零件,外面的人碰不到,我想怎麼改就怎麼改。
公平地說,Angular 自己也在拆這道牆,突然想到進擊的巨人,Angular從牆上探出頭來?!。Standalone components 普及之後,每個元件自己宣告要 import 誰,NgModule 那層「功能級的邊界」變得可有可無。很多 Angular 團隊也開始靠 Nx 之類的工具補回邊界規則。
所以不只是 React 的問題,而是框架不幫你蓋牆之後,大家都得面對的問題。只是 React 一開始就沒有這道牆。
export 就等於全世界TypeScript 跟 JavaScript 的模組系統只有兩種狀態:
export:只有這個檔案自己看得到export:任何檔案都 import 得到
中間沒有「只給同一個功能用」這種選項。
但一個功能裡的元件,幾乎都需要 export——FileRow 要被 FileList 用,所以它得 export;一旦 export 了,全專案都能用。
NgModule 做的,正是在「檔案私有」跟「全世界公開」之間,多加了一層「功能內部」。React 沒有這一層,要自己蓋。
我最後蓋了兩道。
在每個功能的根目錄放一個 index.ts,明確列出對外公開的東西:
// features/files/index.ts
export { FileList } from './FileList';
export { useFiles } from './useFiles';
export type { FileItem } from './types';
其他功能要用,只能從入口拿:
import { FileList } from '~/features/files'; // ✅ 從入口
import { FileRow } from '~/features/files/FileRow'; // ❌ 直接伸手進去
這個 index.ts,就是 NgModule 的 exports 陣列。沒列在裡面的,就是內部零件。
等等——Day 7 不是說我們決定不用 barrel file?
是的。當時反對的是「每個資料夾都放一個 index.ts,而且用 export * 把所有東西都匯出去」,那樣會拖累 tree-shaking、容易製造循環引用,也完全失去篩選的意義。
這次不一樣:只在功能的最外層放一個,而且只具名列出少數幾個。 它不是為了少打幾個路徑,是為了劃出一條界線。
同一個工具,用法不同,目的也就不同。這大概是認命重建期學到的第一件事:別人說好或說不好的做法,要先搞清楚它在解決什麼問題。
入口檔只是一個約定。約定的問題是,它擋不住趕時間的人。 自動 import 的功能一按,路徑就直接指到內部檔案了。
所以要讓 ESLint 來擋。
// eslint.config.js
rules: {
'no-restricted-imports': ['error', {
patterns: [{
group: ['~/features/*/*'],
message: '請從功能的入口 import(例如 ~/features/files),不要直接引用內部檔案。',
}],
}],
},
~/features/files 可以,~/features/files/FileRow 就會直接報錯。功能內部的檔案彼此用相對路徑(./FileRow)引用,不受影響。
這就是那句 'app-file-row' is not a known element 的 React 版本。只是 Angular 是編譯器天生就會擋,這邊要自己寫規則。
光擋住伸手還不夠。還要規定誰可以依賴誰:
routes/ ← 最上層:組裝頁面
↓
features/ ← 功能:可以用下面的,但不能用上面的
↓
components/ ← 共用元件
lib/ ← 工具、API client
箭頭只能往下。lib/ 裡的 apiClient 不應該 import 任何功能裡的東西;components/ 裡的 Button 也不應該知道「檔案」是什麼。
用 eslint-plugin-import 的 no-restricted-paths 寫成規則:
'import/no-restricted-paths': ['error', {
zones: [
{ target: './app/lib', from: './app/features' },
{ target: './app/components', from: './app/features' },
],
}],
意思是:lib/ 跟 components/ 裡的檔案,不准 import features/ 的東西。
一旦方向被固定,還會順便避開一個很難查的 bug:循環引用。A import B、B 又 import A,JavaScript 在某些載入順序下會拿到還沒初始化的值,噴出 Cannot access 'X' before initialization。依賴只能往下走,就不會繞成一圈。
最後一個問題:設定頁的那個「最近編輯的檔案」小工具,到底該怎麼寫?
我的規則是:功能之間盡量不要互相 import。需要組在一起的,交給路由層。
// routes/settings.tsx
import { SettingsForm } from '~/features/settings';
import { RecentFiles } from '~/features/files';
export default function SettingsPage() {
return (
<>
<SettingsForm />
<RecentFiles limit={5} />
</>
);
}
settings 功能完全不知道 files 的存在。是路由這一層,把兩個功能放在同一頁上。
這跟 Day 6 講的 composition 是同一個想法:與其讓元件彼此牽扯,不如在上一層把它們組起來。Day 7 說 routes/files.tsx 是「功能對外的接口」,到這裡它多了一個身分:不同功能之間唯一的交會點。
RecentFiles 則是 files 功能新增、並且放進入口檔的元件。它用到了 FileRow,但那是 files 自己內部的事,改名、拆分都不會影響到外面。
NgModule 最有價值的地方,不是它的語法,而是它在「檔案私有」跟「全世界公開」之間,替你蓋了一道「功能內部」的牆。那句讓人煩躁的 is not a known element,就是這道牆在發揮作用。
React 沒有這道牆。ES module 只有兩種狀態:不 export,或 export 給全世界。
所以要自己蓋,我蓋了兩道:
index.ts,列出對外公開的東西,等於 NgModule 的 exports
Angular 的牆是編譯器蓋的,推不倒。React 的牆是約定加上 lint 蓋的,只要有人願意,還是推得倒。但推之前會先看到一行紅字。
明天從架構回到畫面:Angular 最引以為傲的 Reactive Forms,在 React 要用什麼來換?