iT邦幫忙

2025 iThome 鐵人賽

DAY 2
1
Vue.js

從零到一打造 Vue3 響應式系統系列 第 2

Day 2 - 基礎建設: Monorepo 與 pnpm Workspace 環境搭建

  • 分享至 

  • xImage
  •  

vue3

前言

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 下面的子專案。
packages:
  - 'packages/*'

typescript 設定

  1. 在根目錄下新增 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",

    // 允許直接 import JSON 檔案,編譯器會把 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": "./"
  }
}

檔案結構設定

  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,簡化後的內容如下:
{
  "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"
    ]
  }
}

監聽

  1. 執行 pnpm i @vue/shared --workspace --filter @vue/reactivity 將工具函式專案安裝到響應式模組。
  2. 接著在根目錄下新增一個scripts/dev.js
    • 在根目錄的package.json加入script:node scripts/dev.js --format esm指令
      開發時,我們會透過執行這個腳本來啟動。它會使用 esbuild 進行即時編譯,並在首次編譯後持續監聽檔案變動。
//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)

/**
 * 解析要打包的 target
 * - 若有提供位置參數,取第一個;否則預設打包 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 編譯 context 並進入 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"
  }
}

運行測試

  • package/reactivity/src/index.ts 寫一個導出函式
export function fn(a, b) {
  return a + b;
}
  • 執行pnpm dev,你應該會在package/reactivity/dist/reactivity.esm.js 看到以下內容
// packages/reactivity/src/index.ts
function fn(a, b) {
  return a + b;
}
export {
  fn
};

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

檔案結構如下:

files


同步更新《嘿,日安!》技術部落格


上一篇
Day 1 - 序:為什麼都有 AI 了,還要研究 Vue
下一篇
Day 3 - 核心概念: 從「訂閱者模式」看響應式設計
系列文
從零到一打造 Vue3 響應式系統10
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

1
Dylan
iT邦研究生 5 級 ‧ 2025-09-11 15:17:55

接著在根目錄下新增一個script/dev.js

這邊是不是錯字@@「接著在根目錄下新增一個scripts/dev.js」

heyrian iT邦新手 5 級 ‧ 2025-09-12 09:04:25 檢舉

是錯字 我修正了
感謝你認真看哈哈 我好感動

我要留言

立即登入留言