使用 esbuild 编写开发构建脚本

2025-09-14 13:17:28

上一篇已经把 monorepo 的目录结构搭起来了。

这一篇会使用 esbuild 开发一个构建脚本

node scripts/dev.js reactivity --format esm

因为在 monorepo 中,每个模块都是一个独立的包: 当执行这条命令时,可以打包 packages/reactivity,并输出对应格式的产物。 所以我们约定一套规则:

  • 命令的第一个位置参数表示要打包的包名
  • --format 表示输出格式
  • 每个包的入口都是 packages/<target>/src/index.ts
  • 每个包的输出目录都是 packages/<target>/dist

解析命令行参数

Node 内置的 parseArgs 可以用来解析命令行参数。

这里需要同时支持两类参数:

  • reactivity:位置参数,用来表示要打包的包
  • --format esm:选项参数,用来表示打包格式
import { parseArgs } from 'node:util'

const {
  values: { format },
  positionals
} = parseArgs({
  allowPositionals: true,
  options: {
    format: {
      type: 'string',
      short: 'f',
      default: 'esm'
    }
  }
})

关键是 allowPositionals: true。 如果不启用,parseArgs 默认不会接收 reactivity 这种位置参数。

执行:

node scripts/dev.js reactivity --format esm

解析结果:

format // 'esm'
positionals // ['reactivity']

这样就能知道当前要打包哪个包。

获取目标包入口

有了 positionals 之后,就可以拿到目标包名:

const target = positionals.length ? positionals[0] : 'vue'

接下来按照约定拼出入口文件:

const entry = resolve(__dirname, `../packages/${target}/src/index.ts`)

不过还有一个问题:如果 scripts/dev.js 使用的是 ESM,文件里不能直接使用 CommonJS 里的 __dirname

需要自己通过 import.meta.url 创建:

import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'

const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)

这样就能在 ESM 脚本里继续使用 __dirname 来处理路径。

配置 esbuild

输出文件按照约定生成:

const outfile = resolve(__dirname, `../packages/${target}/dist/${target}.${format}.js`)

完整的构建配置如下:

import { createRequire } from 'node:module'

const require = createRequire(import.meta.url)
const pkg = require(`../packages/${target}/package.json`)

esbuild
  .context({
    // 入口文件
    entryPoints: [entry],
    // 输出文件
    outfile,
    // 输出格式:esm、cjs、iife
    format,
    // cjs 通常给 Node 使用,其他格式先按浏览器环境处理
    platform: format === 'cjs' ? 'node' : 'browser',
    // 开启 sourcemap,方便调试源码
    sourcemap: true,
    // 把依赖一起打进产物中
    bundle: true,
    // iife 格式下挂到全局对象上的名字
    globalName: pkg.buildOptions?.name
  })
  .then(ctx => ctx.watch())

这里使用的是 context().watch(),这样启动脚本后,修改源码会自动重新构建。

配置子包 package.json

为了让构建脚本可以读取包信息,每个子包都需要补充自己的 package.json

shared 为例:

{
  "name": "@vue/shared",
  "version": "1.0.0",
  "description": "工具函数",
  "sideEffects": false,
  "main": "dist/shared.cjs.js",
  "module": "dist/shared.esm.js",
  "files": [
    "dist",
    "index.js"
  ],
  "buildOptions": {
    "name": "VueShared",
    "formats": [
      "esm",
      "cjs",
      "iife"
    ]
  }
}

完整代码

import { createRequire } from 'node:module'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { parseArgs } from 'node:util'
import esbuild from 'esbuild'

const {
  values: { format },
  positionals
} = parseArgs({
  allowPositionals: true,
  options: {
    format: {
      type: 'string',
      short: 'f',
      default: 'esm'
    }
  }
})

const target = positionals.length ? positionals[0] : 'vue'
const require = createRequire(import.meta.url)

const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)

const entry = resolve(__dirname, `../packages/${target}/src/index.ts`)

const outfile = resolve(
  __dirname,
  `../packages/${target}/dist/${target}.${format}.js`
)
const pkg = require(`../packages/${target}/package.json`)

esbuild
  .context({
    // 入口文件
    entryPoints: [entry],
    // 输出文件
    outfile,
    // 打包格式 cjs esm iife
    format,
    // 打包平台 node borwser
    platform: format === 'cjs' ? 'node' : 'browser',
    // 开启sourcemap 方便调试
    sourcemap: true,
    // 把所有依赖打包到一个文件
    bundle: true,
    globalName: pkg.buildOptions.name
  })
  .then(ctx => ctx.watch())