<!DOCTYPE >
<?xml encoding="UTF-8"> 不懂模組化就別談前端工程化

大家好 👋,我是 Moment,目前正在使用 Next.js、NestJS、LangChain 開發 DocFlow。這是一個面向 AI 場景的協同文件平台,整合了基於 Tiptap 的富文本編輯、NestJS 後端服務、即時協作與智能化工作流程等核心模組。

在這個專案的持續打磨過程中,我累積了不少實戰經驗,不只是 Tiptap 的深度客製化、編輯器效能優化和協同方案設計,也包含前端工程化建設、React 原始碼理解以及複雜專案架構實務。

如果你對 AI 全端開發、文件編輯器、前端工程化或者 React 原始碼相關內容感興趣,歡迎加入我的微信 yunmz777 一起交流。覺得專案不錯的話,也歡迎給 DocFlow 點個 star ⭐

image.png

前端工程化最基本的一步就是先學會模組化。簡單來說,模組化就是把一大坨程式碼,拆成一個個小塊,每個小塊只做一件事,這樣寫起來和維護都方便多了。而且模組化還能讓程式碼更容易被重複使用,比如寫好的請求封裝、表單驗證什麼的,以後就不用再重寫一遍。多人協作的時候,模組化能讓大家各做各的,互相不踩線。更重要的是,像 Webpack 這類打包工具,都是基於模組化才能更好工作。常見的模組化寫法有 CommonJS、ES Module 這些,學會了它們,工程化就有底子了。掌握模組化,相當於為前端工程化打好地基!

什麼是模組化

模組化的概念並不是一開始就有的。早期的網頁都靠一個個大檔案堆在一起,程式碼混亂又難維護。後來,專案越來越大,大家發現這樣不行,得把功能拆分開。於是就有了「模組化」的想法:把程式碼分成小模組,每個模組只做一件事。這樣一來,改東西的時候不容易出錯,也能更好地復用程式碼。模組化也讓多人一起開發時更有條理,減少衝突。現在常見的模組化方式有 CommonJS、ES Module 這些,都是讓程式碼更清晰、管理更方便。掌握模組化,寫專案會省心多了!

模組化的發展歷程

石器時代

我們把這個階段稱之為石器時代,因為這是最原始的階段,也是 JavaScript 剛被發明的時候(1995 年),它最早是被用來給網頁加點動態效果,並沒有考慮模組化。這就導致了幾個嚴重的問題:

  1. 全域變數污染
  2. 難以管理相依性
  3. 程式碼組織混亂

如下程式碼所示:

<div><div><div></div><span>js</span></div><div><div> <span>体验AI代码助手</span></div><div> <span>代码解读</span></div><div>复制代码</div></div></div>```
<span><span>// a.js</span></span>
<span><span>const</span> moment = <span>1</span>;</span>
<span></span>
<span><span>// b.js</span></span>
<span><span>const</span> moment = <span>2</span>;</span>

在 html 檔案中我們有這樣的程式碼來載入它們:

<div><div><div></div><span>html</span></div><div><div> <span>体验AI代码助手</span></div><div> <span>代码解读</span></div><div>复制代码</div></div></div>```
<span><span>html</span>></span>
<span><span>html</span> <span>lang</span>=<span>"en"</span>></span>
<span> <span>head</span>></span>
<span> <span>meta</span> <span>charset</span>=<span>"UTF-8"</span> /></span>
<span> <span>meta</span> <span>name</span>=<span>"viewport"</span> <span>content</span>=<span>"width=device-width, initial-scale=1.0"</span> /></span>
<span> <span>title</span>></span>Document<span><span>title</span>></span>
<span> <span><span>head</span>></span></span>
<span> <span>body</span>></span>
<span> <span>script</span> <span>src</span>=<span>"./a.js"</span>></span><span><span>script</span>></span>
<span> <span>script</span> <span>src</span>=<span>"./b.js"</span>></span><span><span>script</span>></span>
<span> <span><span>body</span>></span></span>
<span><span><span>html</span>></span></span>

很多時候我們會直接在檔案裡定義變數,無論是自己寫的程式碼、和其他開發成員合作時不同檔案裡的變數,還是引入的第三方庫中的全域變數,都會在全域作用域中共享同一個空間,這種方式在 <script></script> 標籤預設的全域執行環境下非常常見,也因此容易產生變數衝突或被覆蓋,導致全域污染和命名衝突。正是因為這樣的問題,後續才會有模組化方案來解決作用域隔離和相依性管理的痛點。

20250526193143

這樣的問題就非常容易產生了。

IIFE

IIFE(Immediately Invoked Function Expression)的全稱是立即執行函式表達式,意思是定義完就立即執行的函式。它是 JavaScript 中一種非常常見的語法結構,用來建立一個立即執行的函式作用域,避免污染全域變數。

它的基本語法如下所示:

<div><div><div></div><span>js</span></div><div><div> <span>体验AI代码助手</span></div><div> <span>代码解读</span></div><div>复制代码</div></div></div>```
<span>(<span>function</span> (<span></span>) {</span>
<span>  <span>// 这里是局部作用域</span></span>
<span>  <span>var</span> a = <span>1</span>;</span>
<span>  <span>console</span>.<span>log</span>(a);</span>
<span>})(); <span>// 立即执行</span></span>
<span></span>
<span><span>// 或者</span></span>
<span>(<span>function</span> (<span></span>) {</span>
<span>  <span>var</span> b = <span>2</span>;</span>
<span>  <span>console</span>.<span>log</span>(b);</span>
<span>})();</span>

這是藉由函式作用域,建立了一個私有空間(閉包)。在函式裡定義的變數、函式,只在這個作用域可見,外部無法存取。

<div><div><div></div><span>js</span></div><div><div> <span>体验AI代码助手</span></div><div> <span>代码解读</span></div><div>复制代码</div></div></div>```
<span>(<span>function</span> (<span></span>) {</span>
<span> <span>// 这里是局部作用域</span></span>
<span> <span>var</span> a = <span>1</span>;</span>
<span> <span>console</span>.<span>log</span>(a);</span>
<span>})(); <span>// 立即执行</span></span>
<span></span>
<span><span>// 或者</span></span>
<span>(<span>function</span> (<span></span>) {</span>
<span> <span>var</span> b = <span>2</span>;</span>
<span> <span>console</span>.<span>log</span>(b);</span>
<span>})();</span>
<span></span>
<span><span>console</span>.<span>log</span>(<span>typeof</span> a);</span>

最終輸出結果如下圖所示:

20250526195433

透過這種方式,IIFE 可以避免全域污染,並且能把內部變數封裝起來,外部無法存取;不過,它不如模組化方案直觀易讀,在模組化需求較多時,程式碼結構容易變得混亂。

CommonJS

為了解決 JavaScript 缺少模組化體系的問題,CommonJS 規範被提出了。它主要就是給 JavaScript 提供一個模組化的規範,讓我們可以像在其他語言那樣按需引入、按需匯出,把大專案拆成小塊再拼裝起來。

Node.js 正是借助 CommonJS 的模組體系,才讓模組化管理變得井然有序。比如:

<div><div><div></div><span>js</span></div><div><div> <span>体验AI代码助手</span></div><div> <span>代码解读</span></div><div>复制代码</div></div></div>```
<span><span>// a.js</span></span>
<span><span>const</span> moment = <span>require</span>(<span>"moment"</span>); <span>// 引入模块</span></span>
<span></span>
<span><span>module</span>.<span>exports</span> = { <span>sayHi</span>: <span>() =></span> <span>console</span>.<span>log</span>(<span>"hi"</span>) }; <span>// 导出模块</span></span>

這樣做,變數和功能都被封裝在自己的模組裡,不會再跑到全域作用域去亂七八糟。

### AMD

2011 年前後,瀏覽器端模組化開始流行,出現了 AMD(代表:RequireJS)。它出現的最主要原因是瀏覽器端載入檔案是非同步的,不能再用 CommonJS 的同步方式了。

[AMD](https://link.juejin.cn?target=https%3A%2F%2Fgithub.com%2Famdjs%2Famdjs-api%2Fwiki%2FAMD) 是 "Asynchronous Module Definition" 的縮寫,意思就是「非同步模組定義」。它採用非同步方式載入模組,模組的載入不會阻塞後續語句的執行。所有依賴這個模組的語句,都定義在一個回呼函式中,等到載入完成之後,這個回呼函式才會執行。

AMD 也採用 require() 語句載入模組,但不同於 CommonJS,它要求兩個參數:

<div><div><div></div><span>js</span></div><div><div> <span>体验AI代码助手</span></div><div> <span>代码解读</span></div><div>复制代码</div></div></div>```
<span><span>require</span>([<span>module</span>], callback);</span>

第一個參數 [module] 是一個陣列,裡面的成員就是要載入的模組;第二個參數 callback,則是載入成功之後的回呼函式。如果把前面的程式改寫成 AMD 形式,就是下面這樣:

<div><div><div></div><span>js</span></div><div><div> <span>体验AI代码助手</span></div><div> <span>代码解读</span></div><div>复制代码</div></div></div>```
<span><span>require</span>([<span>"math"</span>], <span>function</span> (<span>math</span>) {</span>
<span>  math.<span>add</span>(<span>2</span>, <span>3</span>);</span>
<span>});</span>

`math.add()` 與 `math` 模組的載入不是同步的,瀏覽器不會因此假死。所以很顯然,`AMD` 比較適合瀏覽器環境。

接下來編寫一個完整的 AMD 範例,如下程式碼:

<div><div><div></div><span>html</span></div><div><div> <span>体验AI代码助手</span></div><div> <span>代码解读</span></div><div>复制代码</div></div></div>```
<span><span>html</span>></span>
<span><span>html</span> <span>lang</span>=<span>"en"</span>></span>
<span> <span>head</span>></span>
<span> <span>meta</span> <span>charset</span>=<span>"UTF-8"</span> /></span>
<span> <span>meta</span> <span>name</span>=<span>"viewport"</span> <span>content</span>=<span>"width=device-width, initial-scale=1.0"</span> /></span>
<span> <span>title</span>></span>Document<span><span>title</span>></span>
<span> <span>script</span></span>
<span> <span>data-main</span>=<span>"main"</span></span>
<span> <span>src</span>=<span>"https://cdnjs.cloudflare.com/ajax/libs/require.js/2.3.6/require.min.js"</span></span>
<span> ><span><span>script</span>></span></span>
<span> <span><span>head</span>></span></span>
<span> <span>body</span>></span>
<span> <span>h1</span>></span>AMD 範例頁面<span><span>h1</span>></span>
<span> <span><span>body</span>></span></span>
<span><span><span>html</span>></span></span>

在這裡的程式使用了 RequireJS CDN,它的關鍵點是 data-main="main",它告訴 RequireJS:頁面載入完後去找 main.js 作為入口。

<div><div><div></div><span>js</span></div><div><div> <span>体验AI代码助手</span></div><div> <span>代码解读</span></div><div>复制代码</div></div></div>```
<span><span>// math.js</span></span>
<span><span>define</span>([], <span>function</span> (<span></span>) {</span>
<span>  <span>// 这是一个模块</span></span>
<span>  <span>return</span> {</span>
<span>    <span>add</span>: <span>function</span> (<span>a, b</span>) {</span>
<span>      <span>return</span> a + b;</span>
<span>    },</span>
<span>    <span>multiply</span>: <span>function</span> (<span>a, b</span>) {</span>
<span>      <span>return</span> a * b;</span>
<span>    },</span>
<span>  };</span>
<span>});</span>

這裡用了 define() 來定義一個模組,暴露 add 和 multiply 方法。

<div><div><div></div><span>js</span></div><div><div> <span>体验AI代码助手</span></div><div> <span>代码解读</span></div><div>复制代码</div></div></div>`` <span><span>// main.js</span></span> <span><span>require</span>([<span>"math"</span>], <span>function</span> (<span>math</span>) {</span> <span> <span>// 这里 math 就是 math.js 返回的模块对象</span></span> <span> <span>var</span> sum = math.<span>add</span>(<span>3</span>, <span>4</span>);</span> <span> <span>var</span> product = math.<span>multiply</span>(<span>3</span>, <span>4</span>);</span> <span></span> <span> <span>console</span>.<span>log</span>(<span>"3 + 4 ="</span>, sum);</span> <span> <span>console</span>.<span>log</span>(<span>"3 * 4 ="</span>, product);</span> <span></span> <span> <span>// 也可以在页面显示</span></span> <span> <span>var</span> resultDiv = <span>document</span>.<span>createElement</span>(<span>"div"</span>);</span> <span> resultDiv.<span>textContent</span> = <span>3 + 4 = <span>${sum}</span>, 3 * 4 = <span>${product}</span>`</span>;</span>
<span> <span>document</span>.<span>body</span>.<span>appendChild</span>(resultDiv);</span>
<span>});</span>
<span></span>
<span><span>console</span>.<span>log</span>(<span>111222</span>);</span>

透過使用 require(['math'], callback),瀏覽器遇到後會非同步載入 math.js,載入完畢後再執行回呼,在回呼裡就能拿到 math 模組的內容進行使用。

最終輸出結果如下圖所示:

20250528080714

UMD

CommonJS 和 AMD 在各自的領域(伺服器端與瀏覽器端)都很好地解決了模組化問題,但它們之間存在相容性問題。CommonJS 是同步載入模組的,適合伺服器端,因為檔案都在本地,載入速度快;而 AMD 是非同步載入模組的,適合瀏覽器端,因為網路請求是非同步的。這就導致一個問題:如何撰寫一份程式碼,既能在 Node.js 環境下運行,又能在瀏覽器環境下運行,同時還能相容 RequireJS 等 AMD 載入器?

為了解決這個問題,UMD(Universal Module Definition)誕生了。它是一種通用的模組定義規範,目標是建立一種可以相容 CommonJS、AMD 和全域變數這三種模組化方案的程式碼模式。它的核心想法是,透過一套條件判斷邏輯,偵測當前運行環境支援哪種模組化方案,然後以對應的方式定義和匯出模組。這樣,開發者就可以寫一份程式碼,無需修改就能在多種環境下使用。

那什麼情況下需要 UMD 呢?

  1. 跨環境相容性:如果你想撰寫一個 JavaScript 函式庫,既希望它能在 Node.js 專案中使用(透過 CommonJS 模組),也希望它能在瀏覽器中直接作為 <script></script> 標籤引入(暴露全域變數),同時還能被 RequireJS 等 AMD 載入器辨識,那麼 UMD 是一個非常理想的選擇。
  2. 解決 CommonJS 和 AMD 的衝突:CommonJS 是同步載入的,而 AMD 是非同步載入的。直接使用其中一種方案會導致在另一種環境中無法正常工作。UMD 透過判斷環境來選擇最合適的載入方式。
  3. 簡化開發流程:避免為不同環境撰寫多份模組程式碼,提高程式碼重用性。

接下來我們會借助 Rollup 來幫我們產出一個 UMD 格式的模組,首先安裝所需套件:

<div><div><div></div><span>bash</span></div><div><div> <span>体验AI代码助手</span></div><div> <span>代码解读</span></div><div>复制代码</div></div></div>```
<span>pnpm add rollup @rollup/plugin-node-resolve @rollup/plugin-commonjs -D</span>

接著我們在 src 目錄下分別建立一個 index.js 與 utils.js,並編寫如下程式:

<div><div><div></div><span>js</span></div><div><div> <span>体验AI代码助手</span></div><div> <span>代码解读</span></div><div>复制代码</div></div></div>`` <span><span>// utils.js</span></span> <span><span>export</span> <span>function</span> <span>add</span>(<span>a, b</span>) {</span> <span> <span>return</span> a + b;</span> <span>}</span> <span></span> <span><span>// index.js</span></span> <span><span>import</span> { add } <span>from</span> <span>"./utils"</span>;</span> <span></span> <span><span>export</span> <span>function</span> <span>greet</span>(<span>name</span>) {</span> <span> <span>return</span> <span>Hello, <span>${name}</span>! The sum is <span>${add(<span>2</span>, <span>3</span>)}</span>.</span>;</span> <span>}</span> <span></span> <span><span>export</span> <span>function</span> <span>farewell</span>(<span>name</span>) {</span> <span> <span>return</span> <span>Goodbye, <span>${name}</span>!`</span>;</span>
<span>}</span>

程式撰寫完成後我們要在專案根目錄建立一個 Rollup 設定檔:

<div><div><div></div><span>js</span></div><div><div> <span>体验AI代码助手</span></div><div> <span>代码解读</span></div><div>复制代码</div></div></div>```
<span><span>// rollup.config.js</span></span>
<span><span>import</span> resolve <span>from</span> <span>"@rollup/plugin-node-resolve"</span>;</span>
<span><span>import</span> commonjs <span>from</span> <span>"@rollup/plugin-commonjs"</span>;</span>
<span></span>
<span><span>export</span> <span>default</span> {</span>
<span>  <span>input</span>: <span>"src/index.js"</span>,</span>
<span>  <span>output</span>: {</span>
<span>    <span>file</span>: <span>"dist/moment.umd.js"</span>,</span>
<span>    <span>format</span>: <span>"umd"</span>,</span>
<span>    <span>name</span>: <span>"Moment"</span>,</span>
<span>    <span>globals</span>: {</span>
<span>      <span>// 如果你的库有外部依赖但不想打包进去,可以在这里配置</span></span>
<span>      <span>// 'dayjs': 'dayjs' // 例如,如果依赖 dayjs,并且希望从全局变量获取</span></span>
<span>    },</span>
<span>  },</span>
<span>  <span>plugins</span>: [<span>resolve</span>(), <span>commonjs</span>()],</span>
<span>};</span>

這時我們需要在 package.json 中加入一個打包指令:

<div><div><div></div><span>json</span></div><div><div> <span>体验AI代码助手</span></div><div> <span>代码解读</span></div><div>复制代码</div></div></div>```
<span> <span>"scripts"</span><span>:</span> <span>{</span></span>
<span> <span>"build"</span><span>:</span> <span>"rollup -c"</span></span>
<span> <span>}</span><span>,</span></span>

這時我們就可以使用 pnpm build 來執行打包了,最終會輸出一個 dist 目錄:

20250528083501

最終輸出的產物如下程式碼所示:

<div><div><div></div><span>js</span></div><div><div> <span>体验AI代码助手</span></div><div> <span>代码解读</span></div><div>复制代码</div></div></div>```
<span>(<span>function</span> (<span><span>global</span>, factory</span>) {</span>
<span>  <span>typeof</span> <span>exports</span> === <span>"object"</span> && <span>typeof</span> <span>module</span> !== <span>"undefined"</span></span>
<span>    ? <span>factory</span>(<span>exports</span>)</span>
<span>    : <span>typeof</span> define === <span>"function"</span> && define.<span>amd</span></span>
<span>    ? <span>define</span>([<span>"exports"</span>], factory)</span>
<span>    : ((<span>global</span> =</span>
<span>        <span>typeof</span> globalThis !== <span>"undefined"</span> ? globalThis : <span>global</span> || self),</span>
<span>      <span>factory</span>((<span>global</span>.<span>Moment</span> = {})));</span>
<span>})(<span>this</span>, <span>function</span> (<span><span>exports</span></span>) {</span>
<span>  <span>"use strict"</span>;</span>
<span></span>
<span>  <span>function</span> <span>add</span>(<span>a, b</span>) {</span>
<span>    <span>return</span> a + b;</span>
<span>  }</span>
<span></span>
<span>  <span>function</span> <span>greet</span>(<span>name</span>) {</span>
<span>    <span>return</span> <span>`Hello, <span>${name}</span>! The sum is <span>${add(<span>2</span>, <span>3</span>)}</span>.`</span>;</span>
<span>  }</span>
<span></span>
<span>  <span>function</span> <span>farewell</span>(<span>name</span>) {</span>
<span>    <span>return</span> <span>`Goodbye, <span>${name}</span>!`</span>;</span>
<span>  }</span>
<span></span>
<span>  <span>exports</span>.<span>farewell</span> = farewell;</span>
<span>  <span>exports</span>.<span>greet</span> = greet;</span>
<span>});</span>

上面這段程式就是一個典型的 UMD(Universal Module Definition)模式構建出來的產物。

它能夠檢測當前運行環境,並以最合適的方式匯出模組:

1. CommonJS 環境(如 Node.js):透過 module.exports 匯出 farewell 與 greet 函式。
2. AMD 環境(如 RequireJS):透過 define(["exports"], factory) 非同步定義並匯出模組。
3. 瀏覽器全域環境(無模組載入器):將模組內容掛載到全域物件 global.Moment 上。

簡而言之,這份程式讓我們的 JavaScript 函式庫能夠無縫地在 Node.js、支援 AMD 的瀏覽器以及普通瀏覽器環境中使用,大幅提高相容性。

當我們在 HTML 檔案中直接透過 <script src="./dist/moment.umd.js"></script> 引入這份 UMD 檔案時,它會偵測到當前是瀏覽器環境,並將模組內容掛載到全域物件 `global.Moment` 上。你就可以像使用任何全域變數一樣使用它:

<div><div><div></div><span>html</span></div><div><div> <span>体验AI代码助手</span></div><div> <span>代码解读</span></div><div>复制代码</div></div></div>```
<span><span>html</span>></span>
<span><span>html</span> <span>lang</span>=<span>"en"</span>></span>
<span> <span>head</span>></span>
<span> <span>meta</span> <span>charset</span>=<span>"UTF-8"</span> /></span>
<span> <span>meta</span> <span>name</span>=<span>"viewport"</span> <span>content</span>=<span>"width=device-width, initial-scale=1.0"</span> /></span>
<span> <span>title</span>></span>Document<span><span>title</span>></span>
<span> <span><span>head</span>></span></span>
<span> <span>body</span>></span>
<span> <span>h1</span>></span>umd 範例頁面<span><span>h1</span>></span>
<span> <span>script</span> <span>src</span>=<span>"./dist/moment.umd.js"</span>></span><span><span>script</span>></span>
<span> <span>script</span>></span><span></span>
<span> <span>console</span>.<span>log</span>(<span>Moment</span>);</span>
<span> <span><span>script</span>></span></span>
<span> <span><span>body</span>></span></span>
<span><span><span>html</span>></span></span>

最終輸出結果如下圖所示:

20250528084015

儘管 ES Module 已成為現代 JavaScript 模組化的主流,並在現代瀏覽器與 Node.js 中得到了原生支援,但 UMD 在向後相容與跨環境發布函式庫的場景中仍佔有一席之地。理解 UMD 有助於我們更好地理解 JavaScript 模組化的發展歷程以及不同模組化方案之間的相容性問題。

ESM

ES Module,也稱為 ECMAScript 模組,是 JavaScript 語言本身在 ES2015 (ES6) 標準中正式引入的官方模組化方案。它旨在成為 JavaScript 模組化的標準,在瀏覽器和 Node.js 環境中都能原生支援。

與 CommonJS、AMD 這類由社群提出的規範不同,ESM 是語言層面的原生支援,這讓它在語法、語意和效能上都具有獨特優勢。

深入理解 CommonJS

在 CommonJS 中,每一個被 require 的檔案,在 Node.js 內部都會被包裝成一個 Module 類的實例。這個 Module 實例帶有該模組的唯一識別(ID)、檔案路徑、父模組資訊、子模組相依、是否已載入等元資料。

最重要的是,它提供了一個 exports 物件,你的模組程式就是透過操作這個物件來決定要向外部暴露什麼內容。當你 require 這個模組時,你得到的就是這個 Module 實例的 exports 屬性。

<div><div><div></div><span>js</span></div><div><div> <span>体验AI代码助手</span></div><div> <span>代码解读</span></div><div>复制代码</div></div></div>```
<span><span>// 此类继承的是 WeakMap</span></span>
<span><span>const</span> moduleParentCache = <span>new</span> <span>SafeWeakMap</span>();</span>
<span></span>
<span><span>function</span> <span>Module</span>(<span>id = <span>""</span>, parent</span>) {</span>
<span>  <span>this</span>.<span>id</span> = id; <span>// 模块的识别符,通常是带有绝对路径的模块文件名</span></span>
<span>  <span>this</span>.<span>path</span> = path.<span>dirname</span>(id); <span>// 文件当前的路径</span></span>
<span></span>
<span>  /</span>
<span>   * 相当于给构造函数 <span>Module</span> 上添加了一个 <span>exports</span> 为空对象</span>
<span>   * 等同于这样的写法 <span>Module</span>.<span>exports</span> = {};</span>
<span>   */</span>
<span>  <span>setOwnProperty</span>(<span>this</span>, <span>"exports"</span>, {});</span>
<span></span>
<span>  <span>// 返回一个弱引用对象,表示调用该模块的模块</span></span>
<span>  moduleParentCache.<span>set</span>(<span>this</span>, parent);</span>
<span>  <span>updateChildren</span>(parent, <span>this</span>, <span>false</span>);</span>
<span></span>
<span>  <span>this</span>.<span>filename</span> = <span>null</span>; <span>// 模块的文件名,带有绝对路径</span></span>
<span>  <span>this</span>.<span>loaded</span> = <span>false</span>; <span>// 是否已经被加载过,用作缓存</span></span>
<span>  <span>this</span>.<span>children</span> = []; <span>// 返回一个数组,表示该模块要用到的其他模块</span></span>
<span>}</span>

我們撰寫如下程式:

<div><div><div></div><span>js</span></div><div><div> <span>体验AI代码助手</span></div><div> <span>代码解读</span></div><div>复制代码</div></div></div>```
<span><span>const</span> foo = <span>1</span>;</span>
<span></span>
<span><span>module</span>.<span>exports</span> = { foo };</span>
<span></span>
<span><span>console</span>.<span>log</span>(<span>module</span>);</span>

當我們直接列印 module,終端上會有如下輸出:

20250528093757

你看到的這個 module 物件,是 Node.js 在執行你的 index.js 檔案時,專門為這個檔案建立的一個「檔案袋」或「容器」。這個檔案袋裡裝著關於這個檔案(模組)的所有重要資訊:

  • id: '.': 這就像是你的檔案在這個程式裡的「身分證號」。當你是直接執行 node index.js 時,這個 index.js 就是入口,它的 id 會被標記為 .,表示它是整個程式的「根」。
  • path: '/Users/macmini/Desktop/前端工程化': 這是你的檔案所在的資料夾路徑。Node.js 在尋找你 require 的其他模組時,會用到這個路徑來決定從哪裡開始查找。
  • exports: { foo: 1 }: 這是最重要的!它是一個空盒子。你在這個 index.js 檔案裡寫的所有 module.exports = ...exports.xxx = ... 的程式碼,都是在往這個盒子裡放東西。當其他檔案 require 你的 index.js 時,它們拿到的就是這個 exports 盒子裡的內容。
  • filename: '/Users/macmini/Desktop/前端工程化/index.js': 這是你的檔案的完整名稱和路徑,就像你的檔案在電腦中的完整地址。
  • loaded: false: 這告訴我們你的檔案是否已經執行完畢。因為 console.log(module) 這行程式是在檔案執行過程中列印的,所以此時模組還沒有「載入完成」,還在運行,因此顯示為 false。等整個檔案程式碼都執行完了,它才會變成 true
  • children: []: 如果你的 index.js 裡有 require('其他檔案') 的話,那些「其他檔案」的 module 物件就會出現在這個陣列裡,表明你的檔案依賴了哪些模組。現在它是空的,表示你的 index.js 沒有直接 require 其他檔案。
  • paths: [...]: 這是 Node.js 在你 require('第三方套件名稱')(例如 require('lodash'))時,會去依序查找這些目錄以尋找 node_modules 資料夾。它從你檔案所在的資料夾開始,逐級向上查找。
  • Symbol(...) 開頭的屬性:這些是 Node.js 內部使用的一些特殊標記。例如,kIsMainModule: true 再次強調你的檔案是程式的主入口;kIsExecuting: true 則表示你的檔案程式正在執行中。這些通常對開發者來說是內部實作細節,但也能幫助我們理解模組的生命週期。

簡而言之,這個 module 物件就是 Node.js 對你的檔案在模組系統中的「檔案紀錄」,包含了它的身分資訊、當前狀態,以及如何與外部世界互動(透過 exports)的關鍵資料。

之所以會有這樣的輸出,是因為在 NodeJs 原始碼 中有這樣的實作:

<div><div><div></div><span>js</span></div><div><div> <span>体验AI代码助手</span></div><div> <span>代码解读</span></div><div>复制代码</div></div></div>```
<span><span>function</span> <span>Module</span>(<span>id = <span>""</span>, parent</span>) {}</span>
<span></span>
<span><span>/** <span>@type</span> {<span>Record<string module=""></string></span>} */</span></span>
<span></span>
<span><span>Module</span>.<span>_cache</span> = { <span>__proto__</span>: <span>null</span> };</span>
<span></span>
<span><span>/** <span>@type</span> {<span>Record<string string=""></string></span>} */</span></span>
<span></span>
<span><span>Module</span>.<span>_pathCache</span> = { <span>__proto__</span>: <span>null</span> };</span>
<span></span>
<span><span>/** <span>@type</span> {<span>Record<string filename:="" module="" string=""> void></string></span>} */</span></span>
<span></span>
<span><span>Module</span>.<span>_extensions</span> = { <span>__proto__</span>: <span>null</span> };</span>
<span></span>
<span><span>/** <span>@type</span> {<span>string[]</span>} */</span></span>
<span></span>
<span><span>let</span> modulePaths = [];</span>
<span></span>
<span><span>/** <span>@type</span> {<span>string[]</span>} */</span></span>
<span></span>
<span><span>Module</span>.<span>globalPaths</span> = [];</span>
<span></span>
<span><span>let</span> patched = <span>false</span>;</span>
<span></span>
<span><span>let</span> wrap = <span>function</span> (<span>script</span>) {</span>
<span>  <span>return</span> <span>Module</span>.<span>wrapper</span>[<span>0</span>] + script + <span>Module</span>.<span>wrapper</span>[<span>1</span>];</span>
<span>};</span>
<span></span>
<span><span>const</span> wrapper = [</span>
<span>  <span>"(function (exports, require, module, __filename, __dirname) { "</span>,</span>
<span>  <span>"\n});"</span>,</span>
<span>];</span>
<span></span>
<span><span>let</span> wrapperProxy = <span>new</span> <span>Proxy</span>(wrapper, {</span>
<span>  <span>__proto__</span>: <span>null</span>,</span>
<span></span>
<span>  <span>set</span>(<span>target, property, value, receiver</span>) {</span>
<span>    patched = <span>true</span>;</span>
<span></span>
<span>    <span>return</span> <span>ReflectSet</span>(target, property, value, receiver);</span>
<span>  },</span>
<span></span>
<span>  <span>defineProperty</span>(<span>target, property, descriptor</span>) {</span>
<span>    patched = <span>true</span>;</span>
<span></span>
<span>    <span>return</span> <span>ObjectDefineProperty</span>(target, property, descriptor);</span>
<span>  },</span>
<span>});</span>

在上面的程式中,`Module._cache` 是一個快取區,儲存所有已經載入並執行過的模組實例。當你 require 一個模組時,Node.js 會先檢查這個快取,如果模組已存在,就直接回傳快取中的實例,避免重複載入與執行,確保模組是單例。它儲存在 Node.js 進程的全域 JavaScript 堆記憶體中,作為 Module 這個建構函數(或類別)的一個靜態屬性(`Module._cache`),這表示它不屬於任何特定的模組實例,而是所有模組共享的一個全域資料結構。

wrap 函式和 wrapper 陣列是 CommonJS 模組機制的核心,`wrapper` 陣列包含了兩個字串:`(function (exports, require, module, __filename, __dirname) {` 是函式體的開始部分,`'\n});'` 是函式體的結束部分。

這個被包裝後的函式就是每個 CommonJS 模組被執行時所處的環境。它為你的模組提供了私有的作用域,並且注入了 `exports`、`require`、`module`、`__filename` 和 `__dirname` 這些區域變數,這樣你在模組裡才能直接使用它們,而不會污染全域作用域。

### module.exports 和 exports 的關係

我們繼續來看這裡的程式,這相當於給建構函數 Module 上添加了一個 exports 空物件,等同於這樣的寫法 `Module.exports = {}`。我們再往這個檔案後面看。

![20250528203423](https://i.imgur.com/o3H8TOx.jpeg)

在 `_compile` 原型方法上定義了一個 `exports` 用來保存 `Module.exports`,所以這也是為什麼 `module.exports === exports` 的原因,實際上是它們共享同一塊記憶體空間。

![20250528203750](https://i.imgur.com/GDxtvVm.jpeg)

雖然它們共享同一塊記憶體空間,但最終被匯出的還是 module.exports 而不是 exports。值得注意的是 CommonJS 匯出的是物件的參考(reference),透過 require 之後可以對其進行修改。

如下程式所示:

<div><div><div></div><span>js</span></div><div><div> <span>体验AI代码助手</span></div><div> <span>代码解读</span></div><div>复制代码</div></div></div>```
<span><span>// utils.js</span></span>
<span></span>
<span><span>const</span> object = {</span>
<span> <span>moment</span>: <span>"Moment"</span>,</span>
<span>};</span>
<span></span>
<span><span>setTimeout</span>(<span>() =></span> {</span>
<span> object.<span>moment</span> = <span>"靓仔"</span>;</span>
<span>}, <span>2000</span>);</span>
<span></span>
<span><span>module</span>.<span>exports</span> = {</span>
<span> object,</span>
<span>};</span>
<span></span>
<span><span>// main.js</span></span>
<span><span>const</span> bar = <span>require</span>(<span>"./utils"</span>);</span>
<span></span>
<span><span>console</span>.<span>log</span>(<span>"main.js"</span>, bar.<span>object</span>.<span>moment</span>); <span>// main.js Moment</span></span>
<span></span>
<span><span>setTimeout</span>(<span>() =></span> {</span>
<span> <span>console</span>.<span>log</span>(<span>"2秒之后输出 "</span>, bar.<span>object</span>.<span>moment</span>); <span>// 2秒之后输出 靓仔</span></span>
<span>}, <span>2000</span>);</span>

最終輸出結果如下圖所示:

20250528204706

驗證了我們前面的說法。

CommonJS 讀取模組的快取

在 Node.js 中,CommonJS 模組首次被 require() 後,其 module.exports 物件就會被快取到記憶體中。這表示,之後程式中任何地方再次 require() 同一個模組,Node.js 都不會重新載入與執行該模組的程式,而是直接回傳快取中的同一個實例。這種機制確保模組只載入一次,並作為單例存在於整個應用的生命週期中,從而優化效能並避免狀態混亂。

如下程式所示:

<div><div><div></div><span>js</span></div><div><div> <span>体验AI代码助手</span></div><div> <span>代码解读</span></div><div>复制代码</div></div></div>```
<span><span>// share.js</span></span>
<span><span>console</span>.<span>log</span>(<span>"---- share.js 模块正在被加载和执行 ----"</span>);</span>
<span></span>
<span><span>let</span> internalCounter = <span>0</span>;</span>
<span></span>
<span><span>function</span> <span>increment</span>(<span></span>) {</span>
<span>  internalCounter++;</span>
<span>}</span>
<span></span>
<span><span>function</span> <span>getCounter</span>(<span></span>) {</span>
<span>  <span>return</span> internalCounter;</span>
<span>}</span>
<span></span>
<span><span>// 导出一些内容,包括一个时间戳,用于验证是否是同一个实例</span></span>
<span><span>module</span>.<span>exports</span> = {</span>
<span>  increment,</span>
<span>  getCounter,</span>
<span>  <span>loadTimestamp</span>: <span>new</span> <span>Date</span>().<span>toISOString</span>(), <span>// 记录模块被加载的时间</span></span>
<span>};</span>
<span></span>
<span><span>console</span>.<span>log</span>(<span>"---- share.js 模块执行完毕 ----"</span>);</span>

建立第一個使用共享模組的模組(`moduleA.js`):

<div><div><div></div><span>js</span></div><div><div> <span>体验AI代码助手</span></div><div> <span>代码解读</span></div><div>复制代码</div></div></div>```
<span><span>// moduleA.js</span></span>
<span></span>
<span><span>console</span>.<span>log</span>(<span>" moduleA.js 开始执行 "</span>);</span>
<span></span>
<span><span>const</span> shared = <span>require</span>(<span>"./share"</span>); <span>// 第一次 require share</span></span>
<span>shared.<span>increment</span>(); <span>// 调用共享模块的方法</span></span>
<span>shared.<span>increment</span>(); <span>// 再次调用,计数器应该增加到 2</span></span>
<span></span>
<span><span>console</span>.<span>log</span>(<span>"moduleA.js 访问 share 计数器:"</span>, shared.<span>getCounter</span>());</span>
<span><span>console</span>.<span>log</span>(<span>"moduleA.js 访问 share 加载时间:"</span>, shared.<span>loadTimestamp</span>);</span>
<span></span>
<span><span>console</span>.<span>log</span>(<span>" moduleA.js 执行结束 "</span>);</span>
<span></span>
<span><span>// 导出 shared 模块的引用,方便 main.js 进一步验证</span></span>
<span><span>module</span>.<span>exports</span> = { <span>sharedModuleRef</span>: shared };</span>

建立第二個使用共享模組的模組(moduleB.js):

<div><div><div></div><span>js</span></div><div><div> <span>体验AI代码助手</span></div><div> <span>代码解读</span></div><div>复制代码</div></div></div>```
<span><span>// moduleB.js</span></span>
<span></span>
<span><span>console</span>.<span>log</span>(<span>"*** moduleB.js 开始执行 ***"</span>);</span>
<span></span>
<span><span>const</span> shared = <span>require</span>(<span>"./share"</span>); <span>// 第二次 require share (预期从缓存读取)</span></span>
<span>shared.<span>increment</span>(); <span>// 再次调用共享模块的方法,计数器应该增加到 3</span></span>
<span></span>
<span><span>console</span>.<span>log</span>(<span>"moduleB.js 访问 share 计数器:"</span>, shared.<span>getCounter</span>());</span>
<span><span>console</span>.<span>log</span>(<span>"moduleB.js 访问 share 加载时间:"</span>, shared.<span>loadTimestamp</span>);</span>
<span></span>
<span><span>console</span>.<span>log</span>(<span>"*** moduleB.js 执行结束 ***"</span>);</span>
<span></span>
<span><span>// 导出 shared 模块的引用</span></span>
<span><span>module</span>.<span>exports</span> = { <span>sharedModuleRef</span>: shared };</span>

接著我們建立一個主入口檔 `index.js`:

<div><div><div></div><span>js</span></div><div><div> <span>体验AI代码助手</span></div><div> <span>代码解读</span></div><div>复制代码</div></div></div>```
<span><span>// index.js</span></span>
<span></span>
<span><span>console</span>.<span>log</span>(<span>"--- index.js 开始执行 ---"</span>);</span>
<span></span>
<span><span>const</span> moduleAExports = <span>require</span>(<span>"./moduleA"</span>);</span>
<span><span>const</span> moduleBExports = <span>require</span>(<span>"./moduleB"</span>);</span>
<span></span>
<span><span>console</span>.<span>log</span>(<span>"\n--- 验证共享模块的实例 ---"</span>);</span>
<span></span>
<span><span>// 验证 moduleA 和 moduleB 得到的 share 引用是否相同</span></span>
<span><span>console</span>.<span>log</span>(</span>
<span> <span>"moduleA.js 和 moduleB.js 获得的 share 是同一个引用:"</span>,</span>
<span> moduleAExports.<span>sharedModuleRef</span> === moduleBExports.<span>sharedModuleRef</span></span>
<span>);</span>
<span></span>
<span><span>// 验证最终的计数器值</span></span>
<span><span>console</span>.<span>log</span>(</span>
<span> <span>"最终的共享模块计数器值:"</span>,</span>
<span> moduleAExports.<span>sharedModuleRef</span>.<span>getCounter</span>()</span>
<span>); <span>// 或者 moduleBExports.sharedModuleRef.getCounter()</span></span>
<span></span>
<span><span>console</span>.<span>log</span>(<span>"--- index.js 执行结束 ---"</span>);</span>

20250528210528

在上面的輸出結果中 share.js 被多次 require() 但最終只執行了一次,說明 share.js 只在 moduleA.js 第一次 require 它時被執行,之後無論 moduleB.js 再次 require 它,或你後續再做任何 require 操作,Node.js 都會直接從快取中取出它匯出的結果,不會重複執行模組檔案。

還有一個最直接、最明確的證據。=== 運算子用於比較兩個變數是否指向記憶體中的同一個物件。輸出為 true 毫不含糊地表明 moduleArequire 到的 share 參考和 moduleBrequire 到的 share 參考,它們指向的是記憶體中的同一個 JavaScript 物件。

require 查找細節

require(X) 中的 X 指向一個核心模組時,Node.js 會直接回傳對應的內建模組,並立即停止後續查找。這些核心模組,如 httpfsurlpathevents,是用 C/C++ 撰寫的,因此在效能上表現優異。它們在 Node.js 編譯時就被整合到二進位檔中,並在 Node 進程啟動時直接載入到記憶體,無需額外定位或編譯過程,從而實現極速載入。

20250528211143

X 是一個路徑(以 ./..// 開頭)時,Node.js 會嘗試解析它:

  • 如果 X 指向一個資料夾,Node.js 會依序查找該資料夾下的 index.jsindex.json,最後是 index.node 檔案。
  • 如果 X 指向一個檔案但沒有副檔名,Node.js 則會嘗試追加 .js.json.node 後綴來尋找對應檔案。

而當 X 既不是路徑也不是核心模組(即一個裸模組名稱,如 lodash)時,Node.js 會從目前目錄的 node_modules 資料夾開始,逐級向上查找父目錄中的 node_modules,直到檔案系統根目錄。如果遍歷所有這些路徑後仍未找到該模組,系統將報錯提示。

如下程式所示:

<div><div><div></div><span>js</span></div><div><div> <span>体验AI代码助手</span></div><div> <span>代码解读</span></div><div>复制代码</div></div></div>```
<span><span>console</span>.<span>log</span>(<span>module</span>.<span>paths</span>);</span>

![20250528211350](https://i.imgur.com/tvJtrMA.jpeg)

它會一層一層往上查找,如果沒有找到,會報找不到的錯誤:

![20250528211516](https://i.imgur.com/dnU7996.jpeg)

有了路徑之後,下面就是 `Module._findPath()` 的原始碼,用來確定哪個是正確的路徑,其中以下程式碼有省略的部分:

<div><div><div></div><span>js</span></div><div><div> <span>体验AI代码助手</span></div><div> <span>代码解读</span></div><div>复制代码</div></div></div>```
<span><span>Module</span>.<span>_findPath</span> = <span>function</span> (<span>request, paths, isMain</span>) {</span>
<span> <span>// 如果是绝对路径,则不在搜索,返回空</span></span>
<span> <span>const</span> absoluteRequest = path.<span>isAbsolute</span>(request);</span>
<span> <span>if</span> (absoluteRequest) {</span>
<span> paths = [<span>""</span>];</span>
<span> } <span>else</span> <span>if</span> (!paths || paths.<span>length</span> === <span>0</span>) {</span>
<span> <span>return</span> <span>false</span>;</span>
<span> }</span>
<span></span>
<span> <span>// 第一步:如果当前路径已在缓存中,就直接返回缓存</span></span>
<span> <span>const</span> cacheKey = request + <span>"\x00"</span> + <span>ArrayPrototypeJoin</span>(paths, <span>"\x00"</span>);</span>
<span> <span>const</span> entry = <span>Module</span>.<span>_pathCache</span>[cacheKey];</span>
<span> <span>if</span> (entry) <span>return</span> entry;</span>
<span></span>
<span> <span>let</span> exts;</span>
<span> <span>// 是否有后缀的目录斜杠</span></span>
<span> <span>const</span> trailingSlash = <span>"..."</span>; <span>//省略了很多代码</span></span>
<span> <span>// 是否相对路径</span></span>
<span> <span>const</span> isRelative = <span>"..."</span>; <span>// 省略了很多代码</span></span>
<span> <span>let</span> insidePath = <span>true</span>;</span>
<span> <span>if</span> (isRelative) {</span>
<span> <span>const</span> normalizedRequest = path.<span>normalize</span>(request);</span>
<span> <span>if</span> (<span>StringPrototypeStartsWith</span>(normalizedRequest, <span>".."</span>)) {</span>
<span> insidePath = <span>false</span>;</span>
<span> }</span>
<span> }</span>
<span></span>
<span> <span>// 遍历所有路径</span></span>
<span> <span>for</span> (<span>let</span> i = <span>0</span>; i length</span>; i++) {
<span> <span>const</span> curPath = paths[i];</span>
<span> <span>if</span> (insidePath && curPath && <span>_stat</span>(curPath) 1</span>) <span>continue</span>;
<span></span>
<span> <span>if</span> (!absoluteRequest) {</span>
<span> <span>const</span> exportsResolved = <span>resolveExports</span>(curPath, request);</span>
<span> <span>if</span> (exportsResolved) <span>return</span> exportsResolved;</span>
<span> }</span>
<span></span>
<span> <span>const</span> basePath = path.<span>resolve</span>(curPath, request);</span>
<span> <span>let</span> filename;</span>
<span></span>
<span> <span>const</span> rc = <span>_stat</span>(basePath);</span>
<span> <span>if</span> (!trailingSlash) {</span>
<span> <span>if</span> (rc === <span>0</span>) {</span>
<span> <span>// File.</span></span>
<span> <span>if</span> (!isMain) {</span>
<span> <span>if</span> (preserveSymlinks) {</span>
<span> filename = path.<span>resolve</span>(basePath);</span>
<span> } <span>else</span> {</span>
<span> filename = <span>toRealPath</span>(basePath);</span>
<span> }</span>
<span> } <span>else</span> <span>if</span> (preserveSymlinksMain) {</span>
<span> filename = path.<span>resolve</span>(basePath);</span>
<span> } <span>else</span> {</span>
<span> filename = <span>toRealPath</span>(basePath);</span>
<span> }</span>
<span> }</span>
<span></span>
<span> <span>if</span> (!filename) {</span>
<span> <span>if</span> (exts === <span>undefined</span>) exts = <span>ObjectKeys</span>(<span>Module</span>.<span>_extensions</span>);</span>
<span> <span>// 该模块文件加上后缀名,是否存在</span></span>
<span> filename = <span>tryExtensions</span>(basePath, exts, isMain);</span>
<span> }</span>
<span> }</span>
<span></span>
<span> <span>if</span> (!filename && rc === <span>1</span>) {</span>
<span> <span>if</span> (exts === <span>undefined</span>) exts = <span>ObjectKeys</span>(<span>Module</span>.<span>_extensions</span>);</span>
<span> <span>// 目录中是否存在 package.json</span></span>
<span> filename = <span>tryPackage</span>(basePath, exts, isMain, request);</span>
<span> }</span>
<span></span>
<span> <span>if</span> (filename) {</span>
<span> <span>// 将找到的文件路径存入返回缓存,然后返回</span></span>
<span> <span>Module</span>.<span>_pathCache</span>[cacheKey] = filename;</span>
<span> <span>return</span> filename;</span>
<span> }</span>
<span> }</span>
<span> <span>// 如果没有找打返回 false</span></span>
<span> <span>return</span> <span>false</span>;</span>
<span>};</span>

我們已經了解了核心模組因 C/C++ 實作而具有極高載入速度。然而,為了讓這些底層用 C/C++ 寫的內建模組能夠無縫地融入 JavaScript 的 CommonJS 模組體系並被 require 函式呼叫,其內部引入流程相當複雜。它需要經過多層的包裝與定義,包括 C/C++ 層的內建模組定義、JavaScript 核心模組的適配和包裝,最終才能在(JavaScript)檔案模組層面被正常引入與使用,以此確保相容性與效能的最佳平衡。

20250528212004

整個流程是:使用者在 JavaScript 中 require 一個核心模組 -> Node.js 的 JavaScript 層 NativeModule 辨識並處理 -> NativeModule 呼叫 process.binding 進入 C++ 層 -> C++ 層查找並載入對應的預編譯模組 -> C++ 模組將其功能以 JavaScript 物件的形式匯出,最終返回給使用者。這個複雜的分層設計,既保證核心模組的極致效能,又使其能夠無縫融入 Node.js 的 CommonJS 模組載入體系。

一旦 Node.js 確定了模組的準確路徑,就可以開始載入它了。你可能會好奇:require 函式究竟從何而來,為何在每個模組中都能「憑空」使用?它背後又執行了哪些操作?

實際上,require 並非一個全域變數。它是 Node.js 在執行每個 CommonJS 模組之前,透過模組包裝函式(就是我們之前提到的那個 (function (exports, require, module, __filename, __dirname) { ... });)作為區域參數,注入到該模組的作用域中的。

而這個注入的 require 函式,其核心功能正是來自於 Module 建構函數原型上的 require 方法,它負責執行模組的查找、載入、快取以及最終返回匯出內容的完整流程。

<div><div><div></div><span>js</span></div><div><div> <span>体验AI代码助手</span></div><div> <span>代码解读</span></div><div>复制代码</div></div></div>```
<span><span>Module</span>.<span><span>prototype</span></span>.<span>require</span> = <span>function</span> (<span>id</span>) {</span>
<span>  <span>// 进行简单的 id 变量的判断,需要传入的 id 是一个 string 类型。</span></span>
<span>  <span>validateString</span>(id, <span>"id"</span>);</span>
<span>  <span>if</span> (id === <span>""</span>) {</span>
<span>    <span>throw</span> <span>new</span> <span>ERR_INVALID_ARG_VALUE</span>(<span>"id"</span>, id, <span>"must be a non-empty string"</span>);</span>
<span>  }</span>
<span>  <span>// 默认为0,表示还没有使用过这个模块,每使用一次便自增一次</span></span>
<span></span>
<span>  requireDepth++;</span>
<span>  <span>try</span> {</span>
<span>    <span>// 用于检查是否有缓存,有则从缓存里查找</span></span>
<span>    <span>return</span> <span>Module</span>.<span>_load</span>(id, <span>this</span>, <span>/* isMain */</span> <span>false</span>);</span>
<span>  } <span>finally</span> {</span>
<span>    <span>// 每次结束后递减一个,用于判断递归的层次</span></span>
<span>    requireDepth--;</span>
<span>  }</span>
<span>};</span>

看完了 require 的流程,我們再看看建構函數的靜態方法 `_load`:

<div><div><div></div><span>js</span></div><div><div> <span>体验AI代码助手</span></div><div> <span>代码解读</span></div><div>复制代码</div></div></div>`` <span><span>Module</span>.<span>_load</span> = <span>function</span> (<span>request, parent, isMain</span>) {</span> <span> <span>let</span> relResolveCacheIdentifier;</span> <span> <span>if</span> (parent) {</span> <span> <span>debug</span>(<span>'Module._load REQUEST %s parent: %s'</span>, request, parent.<span>id</span>);</span> <span> relResolveCacheIdentifier = <span><span>${parent.path}</span>\x00<span>${request}</span>`</span>;</span>
<span> <span>// 以文件的绝对地址当成缓存 key</span></span>
<span> <span>const</span> filename = relativeResolveCache[relResolveCacheIdentifier];</span>
<span> <span>reportModuleToWatchMode</span>(filename);</span>
<span> <span>if</span> (filename !== <span>undefined</span>) {</span>
<span> <span>// 先通过 key 从缓存中获取模块</span></span>
<span> <span>const</span> cachedModule = <span>Module</span>.<span>_cache</span>[filename];</span>
<span> <span>if</span> (cachedModule !== <span>undefined</span>) {</span>
<span> <span>updateChildren</span>(parent, cachedModule, <span>true</span>);</span>
<span> <span>if</span> (!cachedModule.<span>loaded</span>)</span>
<span> <span>// 如果要加载的模块缓存已经存在,但是并没有完全加载好,这是解决循环引用的关键</span></span>
<span> <span>return</span> <span>getExportsForCircularRequire</span>(cachedModule);</span>
<span></span>
<span> <span>// 已经加载好的模块,直接从缓存中读取返回</span></span>
<span> <span>return</span> cachedModule.<span>exports</span>;</span>
<span> }</span>
<span> <span>// 判断缓存是否存在父模块中,存在则删除</span></span>
<span> <span>delete</span> relativeResolveCache[relResolveCacheIdentifier];</span>
<span> }</span>
<span> }</span>
<span></span>
<span> <span>// 判断是否为 node: 前缀的,也就是判断是否为原生模块</span></span>
<span> <span>if</span> (<span>StringPrototypeStartsWith</span>(request, <span>'node:'</span>)) {</span>
<span> <span>// Slice 'node:' prefix</span></span>
<span> <span>const</span> id = <span>StringPrototypeSlice</span>(request, <span>5</span>);</span>
<span></span>
<span> <span>const</span> <span>module</span> = <span>loadBuiltinModule</span>(id, request);</span>
<span> <span>if</span> (!<span>module</span>?.<span>canBeRequiredByUsers</span>) {</span>
<span> <span>throw</span> <span>new</span> <span>ERR_UNKNOWN_BUILTIN_MODULE</span>(request);</span>
<span> }</span>
<span></span>
<span> <span>return</span> <span>module</span>.<span>exports</span>;</span>
<span> }</span>

這個函式的核心邏輯是:它會首先檢查請求的模組是否已存在於內部快取中——如果已快取,則直接回傳其 exports 物件。如果模組帶有 node: 前綴(表示是顯式引入的內建模組),則會呼叫專門的 loadBuiltinModule() 方法處理並返回結果。此外,對於所有其他尚未載入過的模組,它會建立一個新的模組實例,執行其程式碼,並將最終匯出的結果保存到快取中,以供後續快速存取。

CommonJS 在檢測到循環引用時,會立即從快取中回傳模組目前已有的 exports 物件以解決問題。這表示,如果一個模組(A)在被 require 時發現它自己又 require 了另一個模組(B)而 B 又 require 了 A,它會立刻提供 A 目前已經匯出的部分內容。儘管這個 exports 物件可能是不完整的(尚缺少尚未執行的程式碼所匯出的屬性),但這種機制避免了死鎖,並允許模組執行繼續進行。

小結

require 的流程圖正如下圖所示:

20250528213120

Node.js 的 require 模組載入流程包含五個主要階段。首先是解析(Resolution),確定模組的精確路徑;接著是載入(Loading),讀取檔案內容。然後是包裝(Wrapping),將程式碼放入 CommonJS 函式封裝中;隨後進行執行(Evaluation),執行模組程式碼並產生匯出內容。最後,模組的匯出結果會被快取(Caching)起來,以確保後續對同一模組的 require 呼叫能高效直接取得快取實例。

CommonJS 模組的載入是同步的,這意味著它會阻塞後續程式碼執行。這在伺服器端因為檔案本地載入速度快而高效,但在瀏覽器中可能引發阻塞問題。它透過 module.exports 以物件形式匯出內容,並且對每個載入的模組都存在快取,確保無論何時何地 require 同一模組,都只會得到並操作同一個模組實例。這種快取機制不僅提升效能,也有效處理模組間的循環引用,避免死鎖。

深入理解 ES Modules

預設情況下,普通的 JavaScript 腳本(包括那些用於舊瀏覽器相容的 nomodule 腳本)會阻塞 HTML 解析和頁面渲染。為了避免這種阻塞行為,你可以為這些腳本加入 defer 屬性。帶有 defer 屬性的腳本會在 HTML 文件完全解析完畢後才開始執行,並且會按照它們在文件中出現的順序執行,有效避免阻塞頁面內容呈現。

20250528214610

deferasync 是 script 標籤的互斥可選屬性,用於控制腳本的載入與執行時機。

對於常規腳本(包括 <script nomodule=""></script> 腳本),defer 屬性確保腳本在 HTML 解析完成後才按順序執行,避免阻塞頁面渲染;而 async 屬性則允許腳本與 HTML 同步解析與下載,並在可用時立即執行,不保證其執行順序。

至於模組腳本(<script type="module"></script>),它們的預設行為就類似於 defer,即非同步載入並在 HTML 解析後執行。但如果為模組腳本明確指定 async 屬性,它及其所有依賴項都將與 HTML 解析並行載入,並一旦可用便立即執行,此時模組的執行順序不再保證。

當我們使用 ES Module(import / export)來撰寫前端程式時,JavaScript 引擎在背後會做很多「幕後工作」幫我們管理這些模組。例如:模組要有自己的作用域(不能全部放到全域變數裡亂七八糟),還要讓模組彼此間能互相匯入匯出,並保證變數不會被任意改動。

這些幕後工作就靠了模組記錄(Module Record)與模組環境記錄(Module Environment Record)等底層概念,它們屬於 JavaScript 引擎內部的資料結構,幫我們管理和組織模組。

Module Record

模組記錄(Module Record)用來封裝一個模組的匯入與匯出等結構化資訊。這些資訊在模組連結(linking)時非常關鍵,用來把各個模組的輸入輸出串聯起來。一個模組記錄通常包含四個欄位:

  1. Realm:用來建立當前模組的執行環境(執行境)。
  2. Environment:模組頂層的綁定環境記錄,在模組被連結時設定。
  3. Namespace:模組的命名空間物件,能讓外部透過執行時屬性存取模組的匯出。這個物件本身是「外來物件」,並且沒有建構子。
  4. HostDefined:這個欄位是留給宿主環境(host environments)使用,方便在模組中附加額外資訊。

Module Environment Record

模組環境記錄是 ECMAScript 中的一種特殊宣告性環境記錄,用來表示模組的外部作用域。和一般的作用域環境記錄不太一樣,它在支援普通變數綁定的同時,還特別提供了不可變的 import 綁定。這些 import 綁定讓模組內部可以間接存取另一個模組裡的變數,但又保證這些變數不能被修改。

換句話說,不可變綁定指的是模組引入別的模組時,雖然能使用這些匯入的變數,但不能在當前模組中直接更改它們,這也是模組化語法的一大特色。

ES Module 的解析流程

在開始之前,我們先大致了解整個流程是怎麼走的,先有一個概覽:

  1. 建構(Construction):瀏覽器根據模組的位址找到對應的 JS 檔案,透過網路下載,並把程式碼解析成一個內部的模組記錄(Module Record),為後續步驟做準備。
  2. 實例化(Instantiation):對模組進行實例化,分配記憶體空間,分析並處理模組內的 import 與 export 語句,讓這些變數在記憶體中有位置與映射關係。
  3. 執行(Evaluation):真正執行模組內的程式碼,計算值,並把值寫入記憶體,模組就正式被執行起來了。

Construction 建構階段

在這個階段,loader(載入器)負責模組的尋址與下載。它會先從入口檔開始載入,通常在 HTML 中使用 <script type="module"></script> 標籤來宣告這是一個模組檔。載入器會根據這個入口,去查找並下載模組程式碼,準備後續的實例化與執行。

20250528215643

模組會透過 import 語句來宣告所需的相依。在 import 宣告中,有一個模組聲明識別子(ModuleSpecifier),它告訴 loader 如何去查找下一個模組的位址。

20250528215735

每一個模組識別子都對應著一個模組記錄(Module Record),而每個模組記錄中包含了:

  • JavaScript 程式碼本身
  • 執行上下文
  • 以及四種重要的表項:ImportEntriesLocalExportEntriesIndirectEx...

原文出處:https://juejin.cn/post/7621443853846085674


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝13   💬10   ❤️2
381
🥈
我愛JS
📝2   💬9   ❤️2
94
🥉
💬1  
4
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
📢 贊助商廣告 · 我要刊登