嘿,日安!

Day 2 - 開發環境建置:monorepo

11th September 2025
Front-end
Vue3
IT鐵人賽2025
從零到一打造 Vue3 響應式系統
Last updated:14th September 2025
9 Minutes
1716 Words

前言

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 → 安裝全部專案的相依套件。

環境建置

  1. 我們先建立一個資料夾,執行 pnpm init
  2. 新增pnpm-workspace.yaml,並且我們要管理 packages 下面的子專案。
1
packages:
2
- 'packages/*'
  1. 在根目錄下新增 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 API
27
// "DOM":瀏覽器環境的 API,例如 document, window
28
"lib": ["ESNext", "DOM"],
29
30
// 自訂路徑對應(Path Mapping)
31
// "@vue/*" 會對應到 "packages/*/src"
32
// 例如 import { reactive } from "@vue/reactivity"
33
// 會被解析到 packages/reactivity/src
34
"paths": {
35
"@vue/*": ["packages/*/src"]
36
},
37
38
// 基準目錄,用來搭配 paths 做相對解析
39
"baseUrl": "./"
40
}
41
}
  1. 新增 packages 資料夾,裡面會加入許多子專案,包含響應系統等等。
  2. 執行 pnpm i typescript esbuild @types/node -D -w-w表示是安裝在 workspace。
  3. 執行 pnpm i vue -w ,安裝 vue,之後更好可以比較。
  4. 執行 npx tsc --init,初始化專案下的 typescript。
  5. 在根目錄的 package.json 中加上 type:module
    • .js 會讓 Node.js 預設將 .js 檔案視為 ES Module (ESM)。
    • .cjs 如果沒有這個設定,.js 檔案會被當作 CommonJS 模組處理。
  6. 接下來我們在package資料夾下新增三個子專案目錄reactivitysharedvue,以及下方檔案:
    • 響應式模組 reactivity: reactivity/src/index.tsreactivity/package.json
    • 工具函式 shared: shared/src/index.tsshared/package.json
    • 核心功能 vue: vue/src/index.tsvue/package.json
  7. 為了讓我們的子專案有跟 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
}
  1. 執行 pnpm i @vue/shared --workspace --filter @vue/reactivity 將工具函式專案安裝到響應式模組。
  2. 接著在根目錄下新增一個script/dev.js
    • 在根目錄的package.json加入script:node scripts/dev.js --format esm指令 開發時,我們會透過執行這個腳本來啟動。它會使用 esbuild 進行即時編譯,並在首次編譯後持續監聽檔案變動。
script/dev.js
1
/**
2
* 打包「開發環境」使用的腳本
3
*
4
* 用法示例:
5
* node scripts/dev.js --format esm
6
* node scripts/dev.js -f cjs reactive
7
*
8
* - 位置參數(第一個)用來指定要打包的子套件名稱(對應 packages/<name>)
9
* - --format / -f 指定輸出格式:esm | cjs | iife(預設 esm)
10
*/
11
12
import { parseArgs } from 'node:util'
13
import { resolve, dirname } from 'node:path'
14
import { fileURLToPath } from 'node:url'
92 collapsed lines
15
import esbuild from 'esbuild'
16
import { createRequire } from 'node:module'
17
18
/**
19
* 解析命令列參數
20
* allowPositionals: 允許使用位置參數(例如 reactive)
21
* options.format: 支援 --format 或 -f,型別為字串,預設 'esm'
22
*/
23
const {
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 / __dirname
39
* - ESM 沒有這兩個全域變數,因此透過 import.meta.url 轉換得到
40
*/
41
const __filename = fileURLToPath(import.meta.url)
42
const __dirname = dirname(__filename)
43
44
/**
45
* 在 ESM 中建立一個 require()
46
* - 用來載入 CJS 風格資源(例如 JSON)
47
*/
48
const require = createRequire(import.meta.url)
49
50
/**
51
* 解析要打包的 target
52
* - 若有提供位置參數,取第一個;否則預設打包 packages/vue
53
*/
54
const target = positionals.length ? positionals[0] : 'vue'
55
56
/**
57
* 入口檔案(固定指向 packages/<target>/src/index.ts)
58
*/
59
const entry = resolve(__dirname, `../packages/${target}/src/index.ts`)
60
61
/**
62
* 決定輸出檔路徑
63
* - 命名慣例:<target>.<format>.js
64
* 例:reactive.cjs.js / reactive.esm.js
65
*/
66
const outfile = resolve(__dirname, `../packages/${target}/dist/${target}.${format}.js`)
67
68
/**
69
* 讀取目標子套件的 package.json
70
* - 常見做法是從中讀 buildOptions.name,作為 IIFE/UMD 的全域變數名
71
* - 若 package.json 沒有 buildOptions,請自行調整
72
*/
73
const 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;否則視為 browser
82
* - sourcemap: 方便除錯
83
* - bundle: 把相依打進去(單檔輸出)
84
* - globalName: IIFE/UMD 下掛在 window 的全域名稱(esm/cjs 不會用到)
85
*/
86
esbuild
87
.context({
88
entryPoints: [entry], // 入口檔
89
outfile, // 輸出檔
90
format, // 輸出格式:esm | cjs | iife
91
platform: format === 'cjs' ? 'node' : 'browser',// 目標平台:node 或 browser
92
sourcemap: true, // 產生 source map
93
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 寫一個導出函式
1
export function fn(a, b) {
2
return a + b;
3
}
  • 執行pnpm dev,你應該會在package/reactivity/dist/reactivity.esm.js 看到以下內容
packages/reactivity/src/index.ts
1
function fn(a, b) {
2
return a + b;
3
}
4
export {
5
fn
6
};

那就代表環境建置成功了!

檔案結構如下:

default

Article title:Day 2 - 開發環境建置:monorepo
Article author:日安
Release time:11th September 2025