iT邦幫忙

2026 iThome 鐵人賽

DAY 16
0
Modern Web

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

Day 16|沒有 NgModule 之後,牆要自己蓋

  • 分享至 

  • xImage
  •  

改了檔名,別的功能會壞掉

檔案功能的程式碼越寫越多,我決定整理一下:把 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 一開始就沒有這道牆。


二、在 ES module 裡,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
  • lint 規則:擋住伸手進別人內部的 import,固定依賴只能往下

Angular 的牆是編譯器蓋的,推不倒。React 的牆是約定加上 lint 蓋的,只要有人願意,還是推得倒。但推之前會先看到一行紅字。

明天從架構回到畫面:Angular 最引以為傲的 Reactive Forms,在 React 要用什麼來換?


上一篇
Day 15|我的環境變數是 undefined
下一篇
Day 17|表單一大就卡?用什麼比較快
系列文
Angular 工程師的 React 陣痛期:30 天心智模型重建 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言