前言
Vue 3 的原始碼由多個模組構成,除了我們常用的核心功能外,還包含了響應式、工具函式等多個獨立模組。為了模擬 Vue 官方的開發環境,管理這些分散的模組,我們會採用 Monorepo 架構來進行專案管理,並且使用 pnpm workspace。
強烈建議大家一定要跟著 coding,只是看過,容易停留在僅是知道的階段。
什麼是 Monorepo?
Monorepo 是一個管理程式碼的方式,指將不同的專案在單一的程式碼倉庫 (repository) 中,對多個不同的專案進行版本控制。
Monorepo 的特點
- 集中式開發:所有專案的程式碼都集中在同一個 repository 中。
- 工具共享:因為統一管理,所以CICD、風格化工具等等都可以共用,並且只設定一次。
- 統一版本控制:在 monorepo 進行 commit,可以橫跨多個子專案。
什麼是 pnpm workspace?
pnpm workspace 是 pnpm 套件工具提供的一個功能,核心目標是可以在 repo 裡面安裝相依套件,並且共用 node_module
,子專案在 repo 中,可以互相引用。
pnpm workspace 的特點
- 相依套件提升至根目錄:節省空間。
- 模組共享簡單:用
workspace:*
直接引用。 - 集中管理:一個指令可以管理所有子專案,
pnpm install
→ 安裝全部專案的相依套件。
環境建置
- 我們先建立一個資料夾,執行
pnpm init
。 - 新增
pnpm-workspace.yaml
,並且我們要管理packages
下面的子專案。
1packages:2 - 'packages/*'
- 在根目錄下新增
tsconfig.json
,這是typescript 設定檔(偉哉GPT幫我寫註解):
1{2 "compilerOptions": {3 // 編譯輸出 JavaScript 的目標語法版本4 // ESNext:永遠輸出到最新的 ECMAScript 標準5 "target": "ESNext",6
7 // 模組系統類型8 // ESNext:使用最新的 ES Modules(import / export)9 "module": "ESNext",10
11 // 模組解析策略12 // "node":模仿 Node.js 的方式去解析模組 (例如 node_modules, index.ts, package.json 中的 "exports")13 "moduleResolution": "node",14
15 // 編譯後的輸出資料夾26 collapsed lines
16 "outDir": "dist",17
18 // 允許直接 import JSON 檔案,編譯器會把 JSON 當作模組19 "resolveJsonModule": true,20
21 // 是否啟用嚴格模式22 // false:關閉所有嚴格型別檢查(比較寬鬆)23 "strict": false,24
25 // 編譯時會包含哪些內建 API 定義檔(lib.d.ts)26 // "ESNext":最新 ECMAScript API27 // "DOM":瀏覽器環境的 API,例如 document, window28 "lib": ["ESNext", "DOM"],29
30 // 自訂路徑對應(Path Mapping)31 // "@vue/*" 會對應到 "packages/*/src"32 // 例如 import { reactive } from "@vue/reactivity"33 // 會被解析到 packages/reactivity/src34 "paths": {35 "@vue/*": ["packages/*/src"]36 },37
38 // 基準目錄,用來搭配 paths 做相對解析39 "baseUrl": "./"40 }41}
- 新增
packages
資料夾,裡面會加入許多子專案,包含響應系統等等。 - 執行
pnpm i typescript esbuild @types/node -D -w
,-w
表示是安裝在 workspace。 - 執行
pnpm i vue -w
,安裝 vue,之後更好可以比較。 - 執行
npx tsc --init
,初始化專案下的 typescript。 - 在根目錄的
package.json
中加上type:module
。.js
會讓 Node.js 預設將 .js 檔案視為 ES Module (ESM)。.cjs
如果沒有這個設定,.js 檔案會被當作 CommonJS 模組處理。
- 接下來我們在
package
資料夾下新增三個子專案目錄reactivity
、shared
、vue
,以及下方檔案:- 響應式模組 reactivity:
reactivity/src/index.ts
、reactivity/package.json
- 工具函式 shared:
shared/src/index.ts
、shared/package.json
- 核心功能 vue:
vue/src/index.ts
、vue/package.json
- 響應式模組 reactivity:
- 為了讓我們的子專案有跟 Vue 官方套件類似的設定,我們先將
node_modules/.pnpm/@vue+reactivity/reactivity/package.json
複製一份到reactivity/package.json
,簡化後的內容如下:
1{2 "name": "@vue/reactivity",3 "version": "1.0.0",4 "description": "響應式模組",5 "main": "dist/reactivity.cjs.js",6 "module": "dist/reactivity.esm.js",7 "files": [8 "index.js",9 "dist"10 ],11 "sideEffects": false,12 "buildOptions": {13 "name": "VueReactivity",14 "formats": [15 "esm-bundler",6 collapsed lines
16 "esm-browser",17 "cjs",18 "global"19 ]20 },21}
1{2 "name": "@vue/shared",3 "version": "1.0.0",4 "description": "工具函式",5 "main": "dist/shared.cjs.js",6 "module": "dist/shared.esm.js",7 "files": [8 "index.js",9 "dist"10 ],11 "sideEffects": false,12 "buildOptions": {13 "name": "VueShared",14 "formats": [15 "esm-bundler",6 collapsed lines
16 "esm-browser",17 "cjs",18 "global"19 ]20 }21}
1{2 "name": "vue",3 "version": "1.0.0",4 "description": "vue核心模組",5 "main": "dist/vue.cjs.js",6 "module": "dist/vue.esm.js",7 "files": [8 "dist"9 ],10 "sideEffects": false,11 "buildOptions": {12 "name": "Vue",13 "formats": [14 "esm-bundler",15 "esm-browser",5 collapsed lines
16 "cjs",17 "global"18 ]19 }20}
- 執行
pnpm i @vue/shared --workspace --filter @vue/reactivity
將工具函式專案安裝到響應式模組。 - 接著在根目錄下新增一個
script/dev.js
:- 在根目錄的
package.json
加入script:node scripts/dev.js --format esm
指令 開發時,我們會透過執行這個腳本來啟動。它會使用 esbuild 進行即時編譯,並在首次編譯後持續監聽檔案變動。
- 在根目錄的
1/**2 * 打包「開發環境」使用的腳本3 *4 * 用法示例:5 * node scripts/dev.js --format esm6 * node scripts/dev.js -f cjs reactive7 *8 * - 位置參數(第一個)用來指定要打包的子套件名稱(對應 packages/<name>)9 * - --format / -f 指定輸出格式:esm | cjs | iife(預設 esm)10 */11
12import { parseArgs } from 'node:util'13import { resolve, dirname } from 'node:path'14import { fileURLToPath } from 'node:url'92 collapsed lines
15import esbuild from 'esbuild'16import { createRequire } from 'node:module'17
18/**19 * 解析命令列參數20 * allowPositionals: 允許使用位置參數(例如 reactive)21 * options.format: 支援 --format 或 -f,型別為字串,預設 'esm'22 */23const {24 values: { format },25 positionals,26} = parseArgs({27 allowPositionals: true,28 options: {29 format: {30 type: 'string',31 short: 'f',32 default: 'esm',33 },34 },35})36
37/**38 * 在 ESM 模式下建立 __filename / __dirname39 * - ESM 沒有這兩個全域變數,因此透過 import.meta.url 轉換得到40 */41const __filename = fileURLToPath(import.meta.url)42const __dirname = dirname(__filename)43
44/**45 * 在 ESM 中建立一個 require()46 * - 用來載入 CJS 風格資源(例如 JSON)47 */48const require = createRequire(import.meta.url)49
50/**51 * 解析要打包的 target52 * - 若有提供位置參數,取第一個;否則預設打包 packages/vue53 */54const target = positionals.length ? positionals[0] : 'vue'55
56/**57 * 入口檔案(固定指向 packages/<target>/src/index.ts)58 */59const entry = resolve(__dirname, `../packages/${target}/src/index.ts`)60
61/**62 * 決定輸出檔路徑63 * - 命名慣例:<target>.<format>.js64 * 例:reactive.cjs.js / reactive.esm.js65 */66const outfile = resolve(__dirname, `../packages/${target}/dist/${target}.${format}.js`)67
68/**69 * 讀取目標子套件的 package.json70 * - 常見做法是從中讀 buildOptions.name,作為 IIFE/UMD 的全域變數名71 * - 若 package.json 沒有 buildOptions,請自行調整72 */73const pkg = require(`../packages/${target}/package.json`)74
75/**76 * 建立 esbuild 編譯 context 並進入 watch 模式77 * - entryPoints: 打包入口78 * - outfile: 打包輸出檔案79 * - format: 'esm' | 'cjs' | 'iife'80 * - platform: esbuild 的目標平台('node' | 'browser')81 * * 這裡示範:如果是 cjs,就傾向 node;否則視為 browser82 * - sourcemap: 方便除錯83 * - bundle: 把相依打進去(單檔輸出)84 * - globalName: IIFE/UMD 下掛在 window 的全域名稱(esm/cjs 不會用到)85 */86esbuild87 .context({88 entryPoints: [entry], // 入口檔89 outfile, // 輸出檔90 format, // 輸出格式:esm | cjs | iife91 platform: format === 'cjs' ? 'node' : 'browser',// 目標平台:node 或 browser92 sourcemap: true, // 產生 source map93 bundle: true, // 打包成單檔94 globalName: pkg.buildOptions?.name, // IIFE/UMD 會用到;esm/cjs 可忽略95 })96 .then(async (ctx) => {97 // 啟用 watch:監聽檔案變更並自動重建98 await ctx.watch()99 console.log(100 `[esbuild] watching "${target}" in ${format} mode → ${outfile}`101 )102 })103 .catch((err) => {104 console.error('[esbuild] build context error:', err)105 process.exit(1)106 })
1{2 "name": "vue3-source-code",3 "version": "1.0.0",4 "description": "",5 "main": "index.js",6 "type": "module",7 "scripts": {8 "dev": "node scripts/dev.js reactivity --format esm"9 },10 "keywords": [],11 "author": "",12 "license": "ISC",13 "devDependencies": {14 "@types/node": "^24.2.1",15 "esbuild": "^0.25.9",6 collapsed lines
16 "typescript": "^5.9.2"17 },18 "dependencies": {19 "vue": "^3.5.18"20 }21}
運行測試
- 在
package/reactivity/src/index.ts
寫一個導出函式
1export function fn(a, b) {2 return a + b;3}
- 執行
pnpm dev
,你應該會在package/reactivity/dist/reactivity.esm.js
看到以下內容
1function fn(a, b) {2 return a + b;3}4export {5 fn6};
那就代表環境建置成功了!
檔案結構如下: