Vue 3 的原始碼由多個模組構成,除了我們常用的核心功能外,還包含了響應式、工具函數等多個獨立模組。為了模擬 Vue 官方的開發環境,管理這些分散的模組,我們會採用 Monorepo 架構來進行專案管理,並且使用 pnpm workspace。
強烈建議大家一定要跟著動手編碼,如果只是看,很容易停留在「知道」的層面。
Monorepo 是一種程式碼管理方式,指將不同的專案放在單一的程式碼倉庫 (repository) 中,對多個不同的專案進行版本控制。
pnpm workspace 是 pnpm 套件管理工具提供的一個功能,核心目標是在 repo 內部安裝依賴包,並且共享 node_module
,子專案在 repo 中可以互相引用。
workspace:*
直接引用。pnpm install
→ 安裝全部專案的依賴包。我們先創建一個資料夾,執行 pnpm init
。
新建 pnpm-workspace.yaml
,並且我們要管理 packages
下面的子專案。
packages:
- 'packages/*'
在根目錄下新建 tsconfig.json
,這是 TypeScript 的配置文件(感謝 GPT 幫忙寫的註解):
{
"compilerOptions": {
// 編譯輸出 JavaScript 的目標語法版本
// ESNext:始終輸出為最新的 ECMAScript 標準
"target": "ESNext",
// 模組系統類型
// ESNext:使用最新的 ES Modules(import / export)
"module": "ESNext",
// 模組解析策略
// "node":模仿 Node.js 的方式來解析模組 (例如 node_modules, index.ts, package.json 中的 "exports")
"moduleResolution": "node",
// 編譯後的輸出目錄
"outDir": "dist",
// 允許直接導入 JSON 檔案,編譯器會將其視為一個模組
"resolveJsonModule": true,
// 是否啟用嚴格模式
// false:關閉所有嚴格型別檢查(較為寬鬆)
"strict": false,
// 編譯時需要引入的內建 API 定義檔(lib.d.ts)
// "ESNext":最新 ECMAScript API
// "DOM":瀏覽器環境的 API,例如 document, window
"lib": ["ESNext", "DOM"],
// 自定義路徑映射(Path Mapping)
// "@vue/*" 會映射到 "packages/*/src"
// 例如 import { reactive } from "@vue/reactivity"
// 會被解析到 packages/reactivity/src
"paths": {
"@vue/*": ["packages/*/src"]
},
// 基準目錄,用於 `paths` 選項的相對路徑解析
"baseUrl": "./"
}
}
新建 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
檔案視為 ES Module (ESM)。.js
檔案則會被當作 CommonJS 模組處理。接下來,我們在 package
資料夾下新建三個子專案目錄 reactivity
、shared
、vue
,以及下列檔案:
reactivity/src/index.ts
、reactivity/package.json
shared/src/index.ts
、shared/package.json
vue/src/index.ts
、vue/package.json
為了讓我們的子專案擁有和 Vue 官方包類似的配置,我們先將 node_modules/.pnpm/@vue+reactivity/reactivity/package.json
複製一份到 reactivity/package.json
,簡化後的內容如下:
{
"name": "@vue/reactivity",
"version": "1.0.0",
"description": "響應式模組",
"main": "dist/reactivity.cjs.js",
"module": "dist/reactivity.esm.js",
"files": [
"index.js",
"dist"
],
"sideEffects": false,
"buildOptions": {
"name": "VueReactivity",
"formats": [
"esm-bundler",
"esm-browser",
"cjs",
"global"
]
}
}
{
"name": "@vue/shared",
"version": "1.0.0",
"description": "工具函數",
"main": "dist/shared.cjs.js",
"module": "dist/shared.esm.js",
"files": [
"index.js",
"dist"
],
"sideEffects": false,
"buildOptions": {
"name": "VueShared",
"formats": [
"esm-bundler",
"esm-browser",
"cjs",
"global"
]
}
}
{
"name": "vue",
"version": "1.0.0",
"description": "vue 核心模組",
"main": "dist/vue.cjs.js",
"module": "dist/vue.esm.js",
"files": [
"dist"
],
"sideEffects": false,
"buildOptions": {
"name": "Vue",
"formats": [
"esm-bundler",
"esm-browser",
"cjs",
"global"
]
}
}
執行 pnpm i @vue/shared --workspace --filter @vue/reactivity
將工具函數專案作為依賴安裝到響應式模組中。
接著在根目錄下新建一個 scripts/dev.js
:
package.json
中加入 "dev": "node scripts/dev.js --format esm"
命令。// scripts/dev.js
/**
* 用於打包「開發環境」的腳本
*
* 用法示例:
* node scripts/dev.js --format esm
* node scripts/dev.js -f cjs reactive
*
* - 位置參數(第一個)用於指定要打包的子包名稱(對應 packages/<name>)
* - --format / -f 指定輸出格式:esm | cjs | iife(默認為 esm)
*/
import { parseArgs } from 'node:util'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import esbuild from 'esbuild'
import { createRequire } from 'node:module'
/**
* 解析命令行參數
* allowPositionals: 允許使用位置參數(例如 reactive)
* options.format: 支持 --format 或 -f,類型為字串,默認為 'esm'
*/
const { values: { format }, positionals } = parseArgs({
allowPositionals: true,
options: {
format: {
type: 'string',
short: 'f',
default: 'esm',
},
},
})
/**
* 在 ESM 模式下創建 __filename / __dirname
* - ESM 中沒有這兩個全域變數,因此需要透過 import.meta.url 進行轉換
*/
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
/**
* 在 ESM 中創建一個 require() 函數
* - 用於加載 CJS 樣式的資源(例如 JSON)
*/
const require = createRequire(import.meta.url)
/**
* 解析要打包的目標
* - 如果提供了位置參數,則取第一個;否則默認打包 packages/vue
*/
const target = positionals.length ? positionals[0] : 'vue'
/**
* 入口檔案(固定指向 packages/<target>/src/index.ts)
*/
const entry = resolve(__dirname, `../packages/${target}/src/index.ts`)
/**
* 決定輸出檔案路徑
* - 命名約定:<target>.<format>.js
* 例:reactive.cjs.js / reactive.esm.js
*/
const outfile = resolve(__dirname, `../packages/${target}/dist/${target}.${format}.js`)
/**
* 讀取目標子包的 package.json
* - 常見做法是從中讀取 buildOptions.name,作為 IIFE/UMD 的全域變數名
* - 如果 package.json 中沒有 buildOptions,請自行調整
*/
const pkg = require(`../packages/${target}/package.json`)
/**
* 創建 esbuild 編譯上下文並進入 watch 模式
* - entryPoints: 打包入口
* - outfile: 打包輸出檔案
* - format: 'esm' | 'cjs' | 'iife'
* - platform: esbuild 的目標平台('node' | 'browser')
* * 這裡示範:如果是 cjs,就傾向於 node;否則視為 browser
* - sourcemap: 方便調試
* - bundle: 將依賴打包進去(輸出為單檔)
* - globalName: IIFE/UMD 格式下掛載到 window 上的全域名稱(esm/cjs 格式下不會用到)
*/
esbuild
.context({
entryPoints: [entry], // 入口檔案
outfile, // 輸出檔案
format, // 輸出格式:esm | cjs | iife
platform: format === 'cjs' ? 'node' : 'browser', // 目標平台:node 或 browser
sourcemap: true, // 生成 source map
bundle: true, // 打包成單檔
globalName: pkg.buildOptions?.name, // IIFE/UMD 會用到;esm/cjs 可忽略
})
.then(async (ctx) => {
// 啟用 watch:監聽檔案變更並自動重新構建
await ctx.watch()
console.log(`[esbuild] watching "${target}" in ${format} mode → ${outfile}`)
})
.catch((err) => {
console.error('[esbuild] build context error:', err)
process.exit(1)
})
{
"name": "vue3-source-code",
"version": "1.0.0",
"description": "",
"main": "index.js",
"type": "module",
"scripts": {
"dev": "node scripts/dev.js reactivity --format esm"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@types/node": "^24.2.1",
"esbuild": "^0.25.9",
"typescript": "^5.9.2"
},
"dependencies": {
"vue": "^3.5.18"
}
}
在 packages/reactivity/src/index.ts
中編寫一個導出函數
export function fn(a, b) {
return a + b;
}
執行 pnpm dev
,你應該會在 packages/reactivity/dist/reactivity.esm.js
中看到以下內容
// packages/reactivity/src/index.ts
function fn(a, b) {
return a + b;
}
export { fn };
那就代表環境搭建成功了!