Day 2 - 开发环境建置:monorepo

前言

Vue 3 的源码由多个模块构成,除了我们常用的核心功能外,还包含了响应式、工具函数等多个独立模块。为了模拟 Vue 官方的开发环境,管理这些分散的模块,我们会采用 Monorepo 架构来进行项目管理,并且使用 pnpm workspace。

强烈建议大家一定要跟着动手编码,如果只是看,很容易停留在"知道"的层面。

什么是 Monorepo?

Monorepo 是一种代码管理方式,指将不同的项目放在单一的代码仓库 (repository) 中,对多个不同的项目进行版本控制。

Monorepo 的特点

  • 集中式开发:所有项目的代码都集中在同一个 repository 中。
  • 工具共享:因为统一管理,所以 CI/CD、代码风格工具等都可以共享,并且只需配置一次。
  • 统一版本控制:在 monorepo 中进行 commit,可以横跨多个子项目。

什么是 pnpm workspace?

pnpm workspace 是 pnpm 包管理工具提供的一个功能,核心目标是可以在 repo 内部安装依赖包,并且共享 node_module,子项目在 repo 中可以互相引用。

pnpm workspace 的特点

  • 依赖提升至根目录:节省磁盘空间。
  • 模块共享简单 :用 workspace:* 直接引用。
  • 集中管理 :一个命令可以管理所有子项目,例如 pnpm install → 安装全部项目的依赖包。

环境搭建

  1. 我们先创建一个文件夹,执行 pnpm init
  2. 新建 pnpm-workspace.yaml,并且我们要管理 packages 下面的子项目。
YAML 复制代码
packages:
  - 'packages/*'
  1. 在根目录下新建 tsconfig.json,这是 TypeScript 的配置文件(感谢 GPT 帮忙写的注释):
JSON 复制代码
{
  "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": "./"
  }
}
  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"

    • 这会让 Node.js 默认将 .js 文件视为 ES Module (ESM)。
    • 若没有此项设置,.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,简化后的内容如下:

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"
    ]
  }
}
JSON 复制代码
{
  "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"
    ]
  }
}
JSON 复制代码
{
  "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 中加入 "dev": "node scripts/dev.js --format esm" 命令。
    • 开发时,我们将通过执行此脚本来启动编译。它会使用 esbuild 进行实时编译,并在首次编译后持续监听文件变动。
JavaScript 复制代码
// 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)
  })
JSON 复制代码
{
  "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 中编写一个导出函数
TypeScript 复制代码
export function fn(a, b) {
  return a + b;
}
  • 执行 pnpm dev,你应该会在 packages/reactivity/dist/reactivity.esm.js 中看到以下内容
JavaScript 复制代码
// packages/reactivity/src/index.ts
function fn(a, b) {
  return a + b;
}
export {
  fn
};

那就代表环境搭建成功了!

相关推荐
艾小码2 小时前
还在用Vue 2硬撑?升级Vue 3的避坑指南来了!
前端·javascript·vue.js
鹏多多2 小时前
详解vue渲染函数render的使用
前端·javascript·vue.js
小意恩3 小时前
el-table表头做过滤
前端·javascript·vue.js
fury_1233 小时前
vue3:触发自动el-input输入框焦点
javascript·vue.js·elementui
小菜全3 小时前
ElementUI 组件概览
前端·vue.js·elementui
lichong9513 小时前
【混合开发】vue+Android、iPhone、鸿蒙、win、macOS、Linux之android 把assert里的dist.zip 包解压到sd卡里
android·vue.js·iphone
rookie fish4 小时前
Electron+Vite+Vue项目中,如何监听Electron的修改实现和Vue一样的热更新?[特殊字符]
前端·vue.js·electron
上优4 小时前
Vue3纯前端同源跨窗口通信移动AGV小车
前端·vue.js·状态模式
一只小阿乐4 小时前
vue-router 的实现原理
前端·javascript·vue.js·路由·vue-router