引言:本文介绍了目前流行的
pnpm workspace+changesets+turborepo构建npm包项目的方案,这套方案也适用于其他大型Monorepo项目。此外,还补充了前端工程下package.json、tsconfig和husky等配置知识以及CI/CD相关常识。
一、认识package.json
一个package.json代表了一个项目,可以通过npm/yarn/pnpm命令初始化一个package.json:
bash
pnpm init
然后自行完善package.json。下面我选举了几个著名npm包的package.json作为学习参考,并且重点介绍一些字段含义来帮我们理解项目。
1.比如 vueusenpm包的 packages/shared/package.json
json
{
"name": "@vueuse/shared",
"type": "module",
"version": "14.0.0",
"author": "Anthony Fu <https://github.com/antfu>",
"license": "MIT",
"funding": "https://github.com/sponsors/antfu",
"homepage": "https://github.com/vueuse/vueuse/tree/main/packages/shared#readme",
"repository": {
"type": "git",
"url": "git+https://github.com/vueuse/vueuse.git",
"directory": "packages/shared"
},
"bugs": {
"url": "https://github.com/vueuse/vueuse/issues"
},
"keywords": [
"vue",
"vue-use",
],
"sideEffects": false,
"exports": {
".": "./dist/index.js",
"./*": "./dist/*"
},
"main": "./dist/index.js",
"module": "./dist/index.js",
"unpkg": "./dist/index.iife.min.js",
"jsdelivr": "./dist/index.iife.min.js",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "tsdown",
"prepack": "pnpm run build",
"test:attw": "attw --pack --config-path ../../.attw.json ."
},
"peerDependencies": {
"vue": "^3.5.0"
}
}
2.比如ahooks的packages/hooks/package.json
json
{
"name": "ahooks",
"version": "3.9.6",
"description": "react hooks library",
"keywords": [
"ahooks",
"umi hooks",
"react hooks"
],
"main": "./lib/index.js",
"module": "./es/index.js",
"types": "./lib/index.d.ts",
"unpkg": "dist/ahooks.js",
"sideEffects": false,
"authors": {
"name": "brickspert",
"email": "brickspert.fjl@alipay.com"
},
"publishConfig": {
"registry": "https://registry.npmjs.org/"
},
"repository": "https://github.com/alibaba/hooks",
"homepage": "https://github.com/alibaba/hooks",
"scripts": {
"build": "gulp && webpack-cli",
"test": "vitest run --color",
"test:cov": "vitest run --color --coverage",
"tsc": "tsc --noEmit"
},
"files": [
"dist",
"lib",
"es",
"metadata.json",
"package.json",
"README.md"
],
"dependencies": {
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"license": "MIT",
"gitHead": "11f6ad571bd365c95ecb9409ca3050cbbfc9b34a"
}
3.element-plus的packages/element-plus/package.json
json
{
"name": "element-plus",
"version": "0.0.0-dev.1",
"description": "A Component Library for Vue 3",
"keywords": [
"element-plus",
],
"homepage": "https://element-plus.org/",
"bugs": {
"url": "https://github.com/element-plus/element-plus/issues"
},
"license": "MIT",
"main": "lib/index.js",
"module": "es/index.mjs",
"types": "es/index.d.ts",
"exports": {
".": {
"types": "./es/index.d.ts",
"import": "./es/index.mjs",
"require": "./lib/index.js"
},
"./global": {
"types": "./global.d.ts"
},
"./es": {
"types": "./es/index.d.ts",
"import": "./es/index.mjs"
},
"./lib": {
"types": "./lib/index.d.ts",
"require": "./lib/index.js"
},
"./es/*.mjs": {
"types": "./es/*.d.ts",
"import": "./es/*.mjs"
},
"./es/*": {
"types": [
"./es/*.d.ts",
"./es/*/index.d.ts"
],
"import": "./es/*.mjs"
},
"./lib/*.js": {
"types": "./lib/*.d.ts",
"require": "./lib/*.js"
},
"./lib/*": {
"types": [
"./lib/*.d.ts",
"./lib/*/index.d.ts"
],
"require": "./lib/*.js"
},
"./*": "./*"
},
"unpkg": "dist/index.full.js",
"jsdelivr": "dist/index.full.js",
"repository": {
"type": "git",
"url": "git+https://github.com/element-plus/element-plus.git"
},
"publishConfig": {
"access": "public"
},
"style": "dist/index.css",
"peerDependencies": {
"vue": "^3.2.0"
},
"dependencies": {
},
"devDependencies": {
},
"web-types": "web-types.json",
"browserslist": [
"> 1%",
"not ie 11",
"not op_mini all"
]
}
4.比如 naive-ui的 package.json (naive-ui项目不是monorepo)
json
{
"name": "naive-ui",
"version": "2.43.1",
"packageManager": "pnpm@9.5.0",
"description": "A Vue 3 Component Library. Fairly Complete, Theme Customizable, Uses TypeScript, Fast",
"author": "07akioni",
"license": "MIT",
"homepage": "https://www.naiveui.com",
"repository": {
"type": "git",
"url": "https://github.com/tusen-ai/naive-ui"
},
"keywords": [
"naive-ui",
],
"sideEffects": false,
"main": "lib/index.js",
"module": "es/index.mjs",
"unpkg": "dist/index.js",
"jsdelivr": "dist/index.js",
"types": "es/index.d.ts",
"files": [
"README.md",
"dist",
"es",
"generic",
"lib",
"volar.d.ts",
"web-types.json"
],
"scripts": {
"start": "pnpm run dev",
"dev": "pnpm run clean && pnpm run gen-version && pnpm run gen-volar-dts && NODE_ENV=development vite",
},
"web-types": "./web-types.json",
"peerDependencies": {
"vue": "^3.0.0"
},
"dependencies": {
},
"devDependencies": {
},
}
文件目录
1."type"字段
可以设置"type": "module" 或者"type": "commonjs"
含义 :这个字段指定了Node.js应该如何处理.js文件的模块系统。
作用:
- 当设置为
"module"时,所有.js文件都会被当作ES模块处理 - 这意味着您可以使用
import/export语法而不是require/module.exports - 如果没有设置或者设置为
"commonjs",则使用CommonJS模块系统
但是你要注意:js/ts文件是"谁"运行的,如果是Node,自然遵循上述原则。但如果是webpack/vite/rollup/tsdown这类构建工具其实"type"这个字段对这类js/ts文件约束不到,因为此时不管是import还是require写的,构建工具都应该能识别并转换到target(target指配置打包cjs还是esm格式)
a. vueuse声明了"type": "module" ,然后采用.js + "import"语法 b. 你还会发现很多项目也不爱写type:"module",然后有两种处理方式:
.mjs + "import"的语法,比如element-plus.js + "require"的语法,比如ahooks
vueuse项目部分截图
ahooks项目部分截图

P.S. 项目下
vitest.config.ts种都是采用import语法,不管你声没声明"type": "module",这是因为Node v12 模块系统就开始支持require和import两种语法了。
2."module"字段
含义 :当使用import ... from xx导入包时,构建工具(如webpack、rollup或vite等)用来识别ES模块版本的入口文件。
作用:
- 当打包工具支持ES模块时,会优先使用这个字段指定的文件作为入口
- 有助于实现tree-shaking(摇树优化),因为ES模块是静态分析的
在您的项目中 :"module": "./dist/index.mjs" 表示打包工具使用ESM导入时,从入口./dist/index.mjs这个文件导入。
3."main"字段
含义 :当使用require()导入包时,Node.js会查找这个字段指定的文件。
作用:
"main"字段指定了包的CommonJS入口点。- 这是传统的包入口点定义方式。
与"module"字段的区别:
"main": CommonJS入口(用于Node.js的require)"module": ES模块入口(用于打包工具的ESM的import/export)
4."exports"字段(现代替代方案)
含义:这是Node.js 12+引入的现代包入口点定义方式,提供了更精细的控制。
在您的项目中,可以这样配置
bash
"exports": {
".": {
"types": "./dist/index.d.ts", // TypeScript类型定义
"import": "./dist/index.js" // ES模块导入入口
}
"./*": "./dist/*"
}
如果你同时提供了ESM和CommonJS产物导出,你可以这样配置
json
"exports": {
".": {
"types": "./es/index.d.ts",
"import": "./es/index.mjs",
"require": "./lib/index.js"
}
}
解释
".": "./dist/index.js"- 当其他模块通过import ... from '@caikengren/uni-hooks'导入您的包时,Node.js将使用index.js文件作为入口点。"./*": "./dist/*"- 这允许导入包的子路径,例如import useXXX from '@caikengren/uni-hooks/useXXX'。这种模式允许访问包内的特定文件或子模块。"types": "./dist/index.d.ts"- 告诉 TypeScript 编译器在哪里找到该包的类型声明文件。
补充说明
exports应该比这比传统的 main/module 字段提供了更强大的控制能力。exports比较新,为了兼容性,一般也需要对应配置好main/module。- 控制包的访问边界。使用 exports 字段的一个重要好处是它可以保护包的内部文件。在没有 exports 字段的情况下,包的所有文件都可以被导入。而使用 exports 字段后,只有明确定义的路径才能被外部访问,这为包作者提供了更好的封装性。
5."files"字段
定义了npm上传到仓库时,需要上传哪些文件(目录),通常包含你打包的代码文件、package.json、README还有其他运行时要用到的文件 。
例如:类似ahooks,它针对Node CJS、浏览器ESM和浏览器unpkg的形式打包了三份代码,分别放在三个不同文件夹,这些文件夹都是要上传的。所以files字段定义了dist、lib和es。

发布配置
ts
"private": true,
"publishConfig": {
"tag": "1.1.0",
"registry": "https://registry.npmjs.org/",
"access": "public"
}
1."private"字段
1.private字段可以防止我们意外地将私有库发布到npm服务器。即当我们使用npm publish发布时不会被当做一个"npm包"被发布。 比如monorepo项目的根package.json文件(仅管理项目结构用,不是一个单独的npm包)。
2.但即使"private": true,当我配置了publishConfig,那么也是可以继续发布的。明确配置了publishConfig可以保证这个包是可以安全发布的。
3.默认 "private"字段为 true。
2."publishConfig"字段
1.当发布包时这个配置会起作用,在这里配置tag或仓库地址。
2."registry":你团队/公司的的npm仓库地址。
3."tag":默认就是 latest(表示发布的就是正式版)。有时候需要发布beta版本(公开测试),那么这个tag就起作用了。
4.假设我们的version: 1.1.1-beta.0,然后通过npm publish --tag beta发布(注意:此时发布命令多了一个--tag参数),如果不显示指定--tag,那么就会用到我们的publishConfig.tag作为这个tag值。
5.tag值的作用就是告诉仓库这是个beta版本。当有人如下安装时:
bash
npm i your-package@latest
6.不会安装最近发布的beta版,而是安装最近最新发布的latest版(发行版)。 如果需要安装beta版,需要手动指定版本:
bash
npm i your-package@1.1.1-beta.0
依赖配置
1."dependencies"
dependencies:声明的是项目的生产环境中所必须的依赖包。打包时会把这些依赖的代码打包进去(这些包往往是运行时起作用)。
json
"dependencies": {
"react": "^18.0.2",
"react-dom": "~18.0.2",
"lodash": "4.17.21"
}
这里每一项配置都是一个键值对(key-value), key表示模块名称,value表示模块的版本号。版本号遵循主版本号.次版本号.修订号的格式规定:
- 固定版本:
"lodash": "4.17.21"表示安装时只安装这个指定的版本; - 波浪号:
"react-dom": "~18.0.2"表示安装18.0.x的最新版本(不低于18.0.2),也就是说安装时不会改变主版本号和次版本号,修订号会安装最新的; - 插入号:
"react": "^18.0.2"表示安装18.x.x的最新版本(不低于18.0.2),也就是说安装时不会改变主版本号,次版本号和修订号会安装最新的;
通常推荐"^18.0.2"这种,保证大版本的最新。
2."devDependencies"
devDependencies:声明的是项目的开发环境、项目运行起来所必须的依赖包。打包时不会 把这些依赖的代码打包进去(这些包往往是编译时起作用)。像typescript,vite还有一些vite plugin和type包。
json
"devDependencies": {
"@types/node": "^20.10.0",
"@vitejs/plugin-vue": "^6.0.1",
"tsdown": "^0.15.12",
"typescript": "^5.4.0",
"vite": "^5.0.0",
}
3."peerDependencies"
peerDependencies:这个专门用于npm包项目。我的npm包项目和安装这个npm的项目可能依赖同一个模块(另外一个npm包),那么我的npm包打包时就不需要把这个模块打包进去。比如: 我的npm包是依赖vue框架,所有安装我这个npm包的项目都应该安装了vue依赖,那么就有 我的npm包package.json:
json
"name": "my-package",
"peerDependencies": {
"vue": "^3.4.0"
}
a.当别人项目的vue的大版本 和 我的npm包相同
json
"dependencies": {
"vue": "^3.4.1"
}
此时别人项目npm i my-package时可以安装成功的。my-package和别人项目一起依赖vue@3.4.1。
b.但是当别人项目的vue的大版本 和 我的npm包不同
json
"dependencies": {
"vue": "^2.4.1"
}
此时别人项目npm i my-package时无法成功,会提示别人(报错):
perl
npm ERR! Could not resolve dependency: npm ERR! peer vue@"^3.4.0 from my-package@1.1.1
此时有一些比较安全可以绕过的方式:
ts
npm i my-package --legacy-peer-deps
👉 适用于你明确知道自己在干什么,比如"这个库虽然没声明支持 vue 2,但其实可以工作"。(这是有一定风险的)
参考
关于前端大管家 package.json,你知道多少?
PACKAGE.JSON
在NPM上发布beta或alpha版
二、认识tsconfig配置和eslint config
tsconfig
tsconfig.json 是 TypeScript 项目的核心配置文件,用于控制 TypeScript 编译器(tsc)的行为。它决定了哪些文件会被编译、如何编译、以及输出结果的格式和位置。下面我将结合你的项目实际情况,详细解释常见的配置项及其作用:
1. compilerOptions
这是最重要的配置块,决定了编译器的具体行为。常见字段如下:
target:指定编译后的 JavaScript 版本,比如"ESNext"、"ES2020"。影响新语法的支持。module:指定模块系统,如"ESNext"、"CommonJS"。你的项目采用 ESM,通常设置为"ESNext"。lib:指定要包含在编译中的库,比如["DOM", "ESNext"],决定可用的全局类型。declaration:是否生成类型声明文件(.d.ts),通常库项目会开启。outDir:编译输出目录,比如"dist"。baseUrl: 通常设置为根目录".",它是ts中识别路径的"起点"。比如import ... from 'src/...',表示从.路径下找src目录。rootDir:源码根目录,决定哪些文件被编译。strict:开启所有严格类型检查选项,建议库项目开启。esModuleInterop:允许默认导入 CommonJS 模块,提升兼容性。skipLibCheck:跳过库文件的类型检查,加快编译速度。
2.paths 别名
TypeScript 仅在"编译期"识别路径别名,它不会影响运行时。要让别名在运行时也可用,需要让打包器(Vite/Rollup/tsdown)或 Node 的解析与之保持一致。必须先确保设置了baseUrl
常见配置:
json
// tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@caikengren/uni-hooks-shared": ["packages/uni-hooks-shared/index.ts"],
"@caikengren/uni-hooks": ["packages/uni-hooks/index.ts"]
}
}
}
运行时配合(示例以 Vite 为例):
ts
// vite.config.ts
import { defineConfig } from 'vite'
import path from 'node:path'
export default defineConfig({
resolve: {
alias: {
'@caikengren/uni-hooks-shared': path.resolve(__dirname, 'packages/uni-hooks-shared/index.ts'),
'@caikengren/uni-hooks': path.resolve(__dirname, 'packages/uni-hooks/index.ts'),
},
},
})
在库项目中,更推荐通过"包名"引用,而不是源码别名:
json
// packages/hooks/package.json
{
"name": "@caikengren/uni-hooks",
"dependencies": {
"@caikengren/uni-hooks-shared": "workspace:^"
}
}
这样在代码里直接:
ts
import { foo } from '@caikengren/uni-hooks-shared'
避免了别名与打包器的双重维护。
3. include 和 exclude
include:指定要编译的文件或目录(如["src"])。exclude:指定不编译的文件或目录(如["node_modules", "dist"])。
4.extends
用于"继承"一份基础配置,统一 Monorepo 多包的 TypeScript 行为。
推荐在根目录放置一份基准:
json
// tsconfig.json(根)
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["ESNext", "DOM"],
"strict": true,
"declaration": true,
"skipLibCheck": true
}
}
子包继承并按需覆盖:
json
// packages/uni-hooks-shared/tsconfig.json
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"]
}
可以多层继承,但保持链路清晰,避免在各包内重复配置相同选项。
P.S. 如果只有根目录有tsconfig.json,子包没有tsconfig.json,子包会受到根目录tsconfig.json的约束。
eslint config
关于js格式和规范上,传统都是eslint + prettier,但配置时比较繁琐,还要处理两者的冲突。参考antfu大佬的译文,可以使用@antfu/eslint-config(vueuse项目就使用了这个)来做eslint检查。
@antfu/eslint-config是由 Vue 核心团队成员、开源界大神 Anthony Fu 创建并维护的一套 ESLint 配置集合 。它是目前社区中最流行的 ESLint 配置之一(GitHub Star 数非常高),其核心特点是"固执己见" (Opinionated) 且"开箱即用"。它采用了 ESLint 最新的 Flat Config (扁平化配置) 系统。
核心亮点:
- 一站式解决方案 :它不仅仅是一个 ESLint 配置,它集成了 Prettier 的功能(通过 ESLint Stylistic),你不需要再安装 Prettier。
- 自动检测:它会自动检测你的项目中是否使用了 Vue、React、TypeScript、UnoCSS 等,并自动启用相关规则。
- 代码风格 :
- 无分号 (No Semicolons)
- 单引号 (Single Quotes)
- 尾随逗号 (Trailing Commas)
- 多文件支持:除了 JS/TS,还支持 JSON、YAML、Markdown、HTML 等文件的 lint 和格式化。
- 导入排序:内置了 eslint-plugin-simple-import-sort,自动把 import 整理得干干净净。
安装&配置
bash
pnpm i -wD eslint @antfu/eslint-config
虽然这个包主打"零配置",但实际开发中,我们通常需要根据团队习惯微调一些规则。
@antfu/eslint-config 的默认导出是一个工厂函数,它接受任意数量的参数。
js
// eslint.config.js
import antfu from '@antfu/eslint-config'
export default antfu(
{
// ============================================================
// 1. 全局功能配置 (Options)
// ============================================================
// 显式启用/禁用特定框架支持(默认会自动检测,但显式写出更清晰)
vue: true,
typescript: true,
// 格式化风格配置 (替代 Prettier)
stylistic: {
indent: 2, // 缩进 2 空格
quotes: 'single', // 单引号
jsx: true, // 支持 JSX
},
// 忽略文件 (相当于 .eslintignore)
ignores: [
'patches',
'playground',
'playgrounds',
'docs',
'**/types',
'**/cache',
'**/*.svg',
'.cursor',
'.trae',
'scripts',
],
},
// ============================================================
// 2. 具体规则覆盖 (Overrides)
// ============================================================
// 一般性规则覆盖
{
rules: {
// 允许使用 console.log (默认是 warn 或 error)
'no-console': 'off',
// 允许使用未使用的变量 (通常用于解构时忽略某些属性)
'unused-imports/no-unused-vars': 'warn',
// 允许使用 @ts-ignore(覆盖 antfu 默认的禁用策略)
'ts/ban-ts-comment': ['error', { 'ts-ignore': false }],
// 如果你真的想要分号 (虽然 antfu 默认是无分号的)
// 'style/semi': ['error', 'always'],
'style/max-statements-per-line': 'off',
'ts/ban-types': 'off',
'node/no-callback-literal': 'off',
'import/namespace': 'off',
'import/default': 'off',
'import/no-named-as-default': 'off',
'import/no-named-as-default-member': 'off',
'node/prefer-global/process': 'off',
'ts/unified-signatures': 'off',
'ts/no-unsafe-function-type': 'off',
'ts/no-dynamic-delete': 'off',
},
},
// 针对特定文件的规则覆盖
{
files: ['**/*.vue'],
rules: {
// Vue 组件名必须由多个单词组成?关掉它
'vue/multi-word-component-names': 'off',
},
},
{
files: ['**/*.json'],
rules: {
// 允许 JSON 文件末尾有逗号 (某些工具不支持,可能需要关掉)
'jsonc/comma-dangle': 'off',
},
},
)
配置lint script
json
{
"sciprts": {
"lint": "eslint --cache .",
"lint:fix": "eslint --cache --fix .",
}
}
--cache启用缓存,缓存会把上次已通过的文件记住,下一次只对变更或受影响的文件重新检查,提升速度。检查的目标路径是当前目录.,具体包含哪些文件由 ESLint 配置决定。- 加上
--fix自动修复可自动修复的 ESLint 违规(如缩进、引号、分号等)。不能自动修复的规则会保留为错误或警告,需要手动处理。
pnpm-lock文件
1.pnpm-lock.yaml 用于锁定整个 Workspace 的依赖树,保证"可重复安装"(同样的依赖版本与拓扑)。 2.如果没有这个lock文件,会发生什么呢? 比如我的依赖:
makefile
packageX: ^1.0.0
当第三方包 packageX发布了新版:1.1.0,那么再次安装(pnpm/yarn install)时都会安装1.1.0新版本的库,有时候可能会带来问题(尤其生产环境不推荐这样)。
3.关键点:
- 单仓 Monorepo 只有"根锁文件",子包不生成独立锁;所有安装动作最终写回根锁。
- 锁文件应提交到仓库,CI/同事机器据此实现一致的安装结果。
- 发布到 npm 不会携带锁文件;库消费者不受你的锁文件影响。
4.常用命令:
bash
# 严格模式(锁与 package.json 不一致时失败)
pnpm install --frozen-lockfile
# 仅更新锁文件(不下载依赖)
pnpm install --lockfile-only
5.与 npm/yarn 的差异:
- pnpm 采用"内容寻址存储(Content-Addressable Store)",远程包先存入全局
.pnpm-store,再以符号链接挂到node_modules,磁盘占用更小。 - Workspace 下通过
workspace:协议把本地包也以符号链接方式关联,升级内部版本时,由根锁统一反映变更。
6.多人协作建议:
- 不手改锁文件;依赖升级统一用
pnpm up <pkg>@<range>或在子包用--filter精准升级。 - 合并冲突时,优先保留最新一次"成功安装后"的锁版本。
参考
三、认识monorepo和相关工具
monorepo 介绍
1.monorepo 是多个包在同一个项目中管理的方式,比较流行的一种管理方式。 软件项目管理经历了三个阶段:
| 阶段 | 名称 | 管理方式 | 产生背景 / 特点 | 优势 | 劣势 |
|---|---|---|---|---|---|
| 1. | **Monolith (单体巨石应用)** | 单仓库管理所有项目代码 | 项目初期,业务复杂度低,所有代码集中在一个仓库中。 | 结构简单,初期管理方便。 | 1. 随着业务增长,代码量庞大,复杂度高。 2. 构建效率低下。 |
| 2. | **MultiRepo (多仓库多模块)** | 每个业务模块独立一个仓库 | 为解耦巨石应用,将项目拆分为多个模块,分别管理。 | 1. 模块解耦,复杂度降低。 2. 各模块可独立开发、测试、发布。 3. 构建效率提升。 | 1. 跨仓库代码共享困难 。 2. 依赖管理复杂 (底层模块升级,依赖方需手动更新)。 3. 仓库数量增多后,工程管理难度增加 。 4. 构建耗时增加(需按顺序构建多个仓库)。 |
| 3. | **MonoRepo (单仓库多模块)** | 单一仓库中管理多个项目或模块 | 为解决MultiRepo在模块数量增多后产生的管理问题。 | 1. 代码共享便捷 。 2. 共享依赖,减少包安装和包一致性 。 3. 共享工程配置 ,保证代码风格和质量一致。 4. 便于构建工具优化(如增量构建、并行构建)。 | 1. 仓库体积较大。 2. 需要工具支持来管理权限和优化构建性能。 |
2.monorepo项目目录结构:
lua
|-- node_modules
|-- packages
| |-- packageA
| | |-- package.json
| |-- packageB
| | |-- package.json
| |-- packageC
| | |-- package.json
|-- eslint.config.js
|-- package.json
1个package.json代表一个项目,最外面的package.json代表根项目,里面的package.json代表子包项目A、B和C等。 根项目存在的意义是:管理这些子包,比如共享根项目的依赖(npm包) 、子包间依赖自动link 和递归执行子包script命令等。
3.monorepo是一种管理规范,那么pnpm workspace协议就是用来支撑起这种规范的技术协议。具体做法:
根目录添加pnpm-workspace.yaml 配置文件:
yaml
packages:
- 'packages/*'
这样一来,/packages下的所有项目都会被 pnpm 识别为 workspace 的成员包
4.下面我们来看JS/TS项目领域,monorepo项目如何通过pnpm/changeset/turbo等工具解决下面三个问题:
- 共享代码/依赖,依赖管理
- 版本更新、自动tag和发布
- 并行/串行构建所有项目,处理构建顺序
5.后续我们用这个monorepo项目结构进行讲解。(记住这个结构,下面会多次用到)
go
|-- node_modules
|-- packages
| |-- hooks
| | |-- package.json ("name":"@caikengren/uni-hooks")
| |-- shared
| | |-- package.json ("name":"@caikengren/uni-hooks-shared")
| |-- use-request
| | |-- package.json ("name":"@caikengren/uni-use-request")
|-- eslint.config.js
|-- package.json

pnpm workspace 管理依赖
问题1:共享代码/依赖,依赖管理
这个问题可以拆成两个子问题来看:
- 多个子包依赖外部第三方npm包,比如依赖
vue/react,那么这些这些包是不是可以共享(而不是每个子包项目都安装一遍)? - 子包之间存在依赖,比如子包A依赖本地的子包B、C,但B、C都还没发布怎么依赖?能不能本地B、C链接到A的node_modules下呢?
1.pnpm i 安装依赖
针对上面的第一个问题,通过把依赖安装在根目录,那么子项目寻找依赖就会采用根目录的依赖,从而实现共享依赖 --workspace参数 根目录下安装vue:
bash
pnpm i -w vue
-w相当于--workspace,表示在根目录下安装依赖。
比如我要安装typescript:
bash
pnpm i -wD typescript
-D表示安装devDependencies,-w和-D可以合并为-wD。
安装完成后,只有根目录package.json会有依赖声明,如下:
json
"dependencies": {
"vue": "^3.4.0"
},
"devDependencies": {
"typescript": "^5.4.0",
}
--filter参数 比如我有一个@caikengren/uni-hooks子包,依赖了另外两个子包
@caikengren/uni-use-request@caikengren/uni-hooks-shared那么可以通过下面的方式,表示在@caikengren/uni-hooks这个子包中安装另外两个依赖。
shell
pnpm i @caikengren/uni-use-request@workspace:^ --filter @caikengren/uni-hooks
pnpm i @caikengren/uni-hooks-shared@workspace:^ --filter @caikengren/uni-hooks
--filter xxx表示在xxx子包下安装依赖,其中依赖的版本使用@workspace:^占位。
安装完成后 packages/uni-hooks目录的package.json会出现:
json
"dependencies": {
"@caikengren/uni-hooks-shared": "workspace:^",
"@caikengren/uni-use-request": "workspace:^"
}
@workspace:^ @workspace:^ 是一种特殊的协议前缀,用于在依赖声明中引用当前 workspace 中的本地包,并自动使用该包的版本号加上 ^ 语义化版本范围。
也就是说,pnpm 会:
- 在当前 workspace 中查找名为
my-local-pkg的本地包,不去远程下载了; - 获取它在
package.json中声明的版本号(比如1.2.3); - 将依赖解析为
^1.2.3,即允许安装兼容的次要版本更新(遵循 semver 规范); - 但前提是这个包必须存在于 workspace 中。如果不存在,构建会失败。
2.pnpm自动link
前面,我们利用@workspace:^在uni-hooks子包中安装了另外两个依赖:@caikengren/uni-hooks-shared 和@caikengren/uni-use-request,那么这两依赖实际是指向哪里呢?
自动link原理

1.pnpm i -w B@workspace:^ C@workspace:^ --filter A B,C被软链接到A的node_modules
2.pnpm i -w Other@5.0.0 Other实际上是安装到.pnpm store,.pnpm目录相当于.pnpm store的映射(内容同步的),.pnpm目录下的Other会被软链接到根目录的node_modules
拓展补充:ln是shell命令(ln -s相当于windows的快捷方式)。 ln -s(软链接)的文件可读写(需要原文件允许软链接读写),本质不是同一文件,而是创建了一个链接符号。 ln (硬链接)的文件可以读写,和原文件本质上是同一个文件,仅在不同地方展示了。
区分:
- pnpm 的特性:安装的远程依赖会被放到
.pnpm目录,然后软链接到node_modules,从而依赖共享,节省磁盘空间。 - pnpm workspace的特性: 自动link。通过
@workspace:^安装的本地依赖,直接软链接到node_modules,即自动link。
我们的项目中:
uni-hooks子包依赖本地@caikengren/uni-hooks-shared 和@caikengren/uni-use-request ,安装后你会发现node_modules下的依赖有箭头符号,说明是"符号链接"(软链接)

疑问: workspace:*和workspace:^的区别?
1.@caikengren/packageA@workspace:* 打包后 (产物/发布到 npm):
pnpm 会将其替换为当前 workspace 中该包的精确版本。
json
{
"dependencies": {
"@caikengren/packageA": "1.0.0"
}
}
2.@caikengren/packageA@workspace:^ 打包后 (产物/发布到 npm):
pnpm 会将其替换为以当前版本为基准的 (^) 范围。
json
{
"dependencies": {
"@caikengren/packageA": "^1.0.0"
}
}
changeset 发布
问题2:版本更新、changelog、自动tag和发布
changesets 主要关心 monorepo 项目下子项目版本的更新、changelog 文件生成、包的发布。一个 changeset 是个包含了在某个分支或者 commit 上改动信息的 md 文件,它会包含这样一些信息:
- 需要发布的包
- 包版本的更新层级(遵循 semver 规范)
- CHANGELOG 信息
1.项目准备
安装工具
bash
pnpm i -wD @changesets/cli
安装完成后,会在node_modules/.bin目录(依赖包提供的命令会出现在这)看到changeset,意味着可以用npx来运行这个命令。

初始化
bash
npx changeset init
会多出一个changeset的文件,如下

2.本地认证准备
假设你已经注册了一个npm账号(或私有仓库账号),然后通过terminal登录,在终端输入
bash
npm adduser
# 或者(效果一样)
npm login
3.发布流程
假设你已经改过代码并且提交了,准备发布新版本。
1.创建一个新的 changeset 文件。
bash
npx changeset add
说明:
- 这个命令会交互式地让你选择要升级的包(在 monorepo 中)、版本类型(patch / minor / major)以及填写变更描述。
- 生成的文件会保存在
.changeset/目录下,格式如clever-horses-fix.md。 - 这些文件后续会被用来决定如何 bump 版本和生成 changelog。
第一个问题:选择要改版的子包项目。「上下」键选择子包,「空格」键表示选择,「enter」确定最终选择。 changeset add是要依据commit提交信息

进入第二个问题:选择要递增的版本级别(依次是major/minor/patch )。按「enter」进入待办项目的下一个版本级别。(比如现在是major, 「enter」后进入minor)

进入第三个问题:总结

进入第四个问题:是否确认

最后,会多出一个changeset文件(md文件),描述了版本变更、changelog内容

2.根据 .changeset/ 中的所有 changeset 文件,自动 bump 相关包的版本,并更新依赖关系。
npx changeset version
说明:
- 它会读取所有未处理的 changeset,计算每个包的新版本号。
- 自动修改
package.json中的版本字段。 - 如果是 monorepo,还会更新内部依赖的版本(比如
pkg-a依赖pkg-b,且pkg-b升级了,这里会自动更新引用)。 - 同时会删除已处理的 changeset 文件(或将其归档)。

可以看出子包下面都多了/修改了CHANGELOG.md文件,并且package.json的版本号发生了变化(1.0.0->1.1.0)


3.将新版本的包发布到npm仓库
bash
npx changeset publish
- 说明 :
- 它会执行
npm publish对每个需要发布的包。 - 默认只发布那些版本号发生变化的包。
- 需要你已经登录到 npm(
npm whoami)。 - 此外,会给每个版本变动的包打tag.
- 它会执行
先提交和push代码
bash
git add .
git commit -m 'chore(release): v1.1.0'
git push
再发布(企业级发布,这一步通常属于CI,在云端流水线完成)
bash
npx changeset publish

然后在npm仓库 可以看到发布的三个包

turborepo 构建
问题3:并行/串行构建所有项目,处理构建顺序
1.介绍
为什么需要 Turborepo
- Monorepo 常见痛点:构建串行、慢;跨包依赖编排复杂;重复构建浪费时间。
yarn workspace通常串行构建,整体耗时长且不可控。pnpm workspace能并行,但对复杂依赖拓扑的顺序编排有限,且缺少内建的强缓存机制。- Turborepo面向这些问题,提供有序并行、增量与缓存的组合解法。
它解决了什么问题
- 构建编排:用任务依赖图(DAG)明确"谁先谁后",避免手工脚本胶水。
- 并行执行:独立包并行跑,依赖包按顺序跑,缩短全量构建时间。
- 增量构建:对输入做哈希,只重建被改动影响的任务,减少无效工作。
- 本地与远程缓存:同样输入直接复用产物;接入远程缓存后,CI 与开发者之间共享结果。
2.并行和构建编排
使用turbo初始化一个 Monorepo 的项目,有以下几个 package:
apps/web,依赖 sharedapps/docs,依赖 sharedpackages/shared,被 web 和 docs 依赖
目录如下:

yarn 命令 只能串行运行任务: yarn workspaces run lint && yarn workspaces run build && yarn workspaces run test
但是,要使用 Turborepo 可以更快地 完成相同的工作,您可以使用 turbo run lint build test:
你会发现有些任务是可以并行执行的------lint和test任务可以并行。还有些是执行顺序依赖的------web和docs 的构建依赖share构建完成。
配置如下:
json:turbo.json
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"test": {
"dependsOn": ["^build"]
},
"lint": {},
}
}
tasks:声明任务编排与产物输出,key是turbo run Xxx的Xxx。dependsOn:用^task表示"先跑上游依赖的同名任务",自动根据依赖图排序。outputs:声明任务产物(如dist/**),让 Turborepo准确缓存和复用构建结果。
关于^task的理解是,不同包的任务编排: 当前 package 执行 build 任务之前,需要 先运行它依赖的包(dependencies / devDependencies / peerDependencies) 的 build 任务。 当你配置了"dependsOn": ["^build"],那么「web和docs 的构建依赖share构建完成后,再执行」,否则不会。
关于dependsOn另外一种情况,没有^,表示同一个包中的任务编排。比如下面就表达了所有的test任务执行前,要先完成build命令。
json
{
"tasks": {
"test": { "dependsOn": ["build"] }
}
}
进一步,你还可以限定到某个包的 任务编排。比如下面表达了
json
"outputs": ["dist/**"]
},
"test": {
"dependsOn": ["^build"]
},
"lint": {},
}
}
3.缓存和增量构建
第一次trubo run build后,会生成缓存存放在 node_modules/.cache/turbo/目录下
第一次构建:还没产物(没有dist目录),记录子包项目文件的hash值。

第二次构建:子包文件的hash标识和之前的比较,如果没变化,则跳过构建。

配置如下:
json
//turbo.json
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"test": {
"dependsOn": ["^build"]
},
"lint": {},
}
}
P.S.
tasks是新字段名,对应旧pipeline,含义一样。
此外,还有cache配置和--filter命令参数:
cache:默认开启;可对 dev 等任务关闭缓存,保证开发时的即时性。
json:turbo.json
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"dev": {
"cache": false
},
}
}
--filter:按包名或路径过滤执行范围。
bash
turbo run <command> --filter=<package name>
构建(打包)工具
tsdown的主要功能
相关功能请查阅tsdown中文文档,目前可以看到vueuse项目已经采用了tsdown来打包库项目。
下面简单介绍下tsdown功能:
1.零配置TypeScript支持 tsdown内置了对TypeScript的完整支持,无需额外的配置:
json
{
"scripts": {
"build": "tsdown"
}
}
2.多格式输出 支持同时生成多种模块格式:
json
{
"tsdown": {
"format": ["esm", "cjs", "umd"],
"entry": "src/index.ts",
"outDir": "dist"
}
}
并且自动为每个输出格式生成对应的.d.ts声明文件:
bash
dist/
├── index.esm.js
├── index.esm.d.ts
├── index.cjs.js
├── index.cjs.d.ts
├── index.umd.js
└── index.umd.d.ts
3.Tree-shaking 极速Tree-shaking,自动移除未使用的代码:
typescript
// src/utils.ts
export function used() { return 'used'; }
export function unused() { return 'unused'; }
// src/index.ts
export { used } from './utils';
// unused函数会被自动移除
4.外部依赖处理 配置外部依赖,避免将依赖打包进输出文件:
json
{
"tsdown": {
"external": ["react", "vue"],
"globals": {
"react": "React",
"vue": "Vue"
}
}
}
Rollup vs Vite vs tsdown
Rollup - 经典选择
Rollup是最早专注于ESM打包的工具之一,以其优秀的Tree-shaking能力而闻名。
优势:
- 出色的Tree-shaking优化
- 生成简洁的ESM输出
- 丰富的插件生态系统
- 适合库开发
劣势:
- 配置相对复杂
- 开发模式下的HMR支持有限
- 构建速度相对较慢
Vite - 现代开发体验
Vite基于原生ESM,提供了极快的开发服务器和优化的构建流程。
优势:
- 闪电般的冷启动速度
- 即时的热模块替换(HMR)
- 开箱即用的TypeScript支持
- 现代化的开发体验
劣势:
- 主要用于应用开发
- 对于纯库打包可能过于复杂
- 依赖Node.js环境
tsdown - 专为ts库项目
tsdown是一个新兴的基于rolldown(基于rust)的打包工具,专门为TypeScript项目设计,结合了现代打包工具的优点。
性能对比
在实际项目中,三种工具的构建性能对比如下:
| 工具 | 冷启动时间 | HMR速度 | 输出大小 | 配置复杂度 |
|---|---|---|---|---|
| Rollup | 中等 | 慢 | 小 | 高 |
| Vite | 快 | 极快 | 中等 | 低 |
| tsdown | 极快 | 快 | 小 | 极低 |
选择建议
- 库开发:推荐使用tsdown,零配置、多格式输出、类型声明自动生成
- 应用开发:推荐使用Vite,优秀的开发体验和构建性能
- 传统项目:如果已有Rollup配置且运行良好,可以继续使用
参考
带你了解更全面的 Monorepo - 优劣、踩坑、选型
从零到一使用 turborepo + pnpm 搭建企业级 Monorepo 项目
Changesets: 流行的 monorepo 场景发包工具
五、CI/CD
解释:
- CI (Continuous Integration 持续整合)
- CD (Continuous Delivery/Deployment 持续交付/部署)
CI
CI的目标:
- 自动运行测试 :自动执行单元测试、集成测试等。例如:
npm test - 代码质量检查 :包括 ESLint、Prettier、TypeScript 类型检查等。例如:
npm run lint、npm run type-check - 构建验证:确保包可以正确构建(如编译 TypeScript 、打包等)。例如`npm run build
其中自动化测试、代码检查和构建验证一般在什么时候触发?
- push到主分支 (main, master)之前。必须的
- push到特征分支之前。可选
- 提PR之前。可选
常用的CI平台 :
| 平台 | 特点 |
|---|---|
| GitHub Actions | 免费、与 GitHub 深度集成,YAML 配置,社区生态丰富 |
| GitLab CI | 内置于 GitLab,适合私有部署 |
| Travis CI | 曾经流行,现逐渐被 GitHub Actions 取代 |
CD
1.产物管理 (Artifact Management) 下载构建产物: 从 CI 流程(如 Jenkins workspace、GitHub Actions artifacts)中拉取打包好的 dist 或 build 目录。给当前的发布包打上 Tag 或版本号。(如果是docker,则会构建镜像和推送到镜像仓库)
2.环境配置与注入 (Configuration Injection)
- 构建时注入:如果是静态站点,通常在 CI 阶段就通过 DefinePlugin 或 import.meta.env 写入了。
- 运行时注入(CD 阶段) 如果是 Docker 容器化部署,通常在 CD 阶段通过 K8s ConfigMap 或环境变量将配置注入到容器中。
3.部署执行 (Deployment Execution)
a. 静态资源部署 (SPA - Vue/React)
- 上传对象存储: 将 HTML/CSS/JS 上传到云厂商的对象存储(AWS S3, Aliyun OSS, Tencent COS)。
- 更新CDN:为了防止用户在发布过程中访问到 404 文件,通常会先上传带 Hash 的静态资源(JS/CSS),最后上传入口文件(index.html)。
b.服务端应用部署 (SSR - Next.js/Nuxt/Node BFF)
- 容器更新: 更新 Kubernetes (K8s) 的 Deployment 镜像版本,执行滚动更新(Rolling Update)。
- 服务重启: 传统服务器上使用 PM2 reload。
c.npm包这类工程产物
- 上传到npm仓库。使用
npx changeset publish或者npm publish完成上传。
Git规范配置 - husky
git规范方案
该方案需要安装以下依赖
- husky
- lint-staged
- commitizen
- cz-git
- @commitlint/cli
- @commitlint/config-conventional
bash
pnpm i -wD husky lint-staged commitizen cz-git @commitlint/cli @commitlint/config-conventional
1.husky拦截Git hooks
Git Hooks:Git 原生就自带一套"钩子"机制。在 .git/hooks/ 目录下,有一堆脚本(如 pre-commit.sample)。
- 机制:当你执行特定的 Git 命令(如 commit、push、merge)时,Git 会自动去检查这个目录下有没有对应的脚本文件。如果有,就执行它。
- 痛点:.git 目录是不会被提交到代码仓库的(它被 .gitignore 忽略)。这意味着,你在本地配置的钩子脚本,你的同事拉取代码后是看不到的,无法同步团队规范。
Husky 的核心作用就是"篡改"Git 查找钩子的路径,并将其指向项目代码中的位置(通常是根目录下的 .husky/ 文件夹),这个文件夹是可以提交到 Git 仓库共享给所有人的。
2.lint-staged 检查代码
每次提交代码前,我们希望"提交的代码"能通过eslint检查。这里有两个关键词:eslint检查和提交的代码。lint-staged就是用来做提交的代码eslint检查(而不是全部代码检查)。
3.commitlint校验commit信息
所有的git项目,都应该去遵循一个git规范,这个规范之一就是git commit的message。你肯定见过很多这样的message:chore(ci): xxx, feat(components): xxx。下面就介绍下常用的一种规范结构。
Commit Message 结构:
- Header(头部) : 必须包含,是Commit Message的核心部分。
- 类型 (type) : 标记Commit的类别,例如
feat(新功能),fix(修复bug),docs(文档),style(代码风格) 等。 - 范围 (scope): 可选,用于说明Commit影响的范围,如文件、组件或模块。
- 主题 (subject): 简短地描述本次提交的内容,通常不超过50个字符,首字母小写。
- 格式 :
type(scope): subject。
- 类型 (type) : 标记Commit的类别,例如
- Body(描述) : 可选,对Commit进行详细描述,可以分成多行。
- 解释提交的动机、解决的问题等。
- 每行建议不超过72个字符,以避免自动换行影响可读性。
- Footer(尾部) : 可选,用于关联Issue编号或标记重大性变更 (Breaking Changes)。
- 关联Issue : 使用如
Closes #123或Fixes #123。
- 关联Issue : 使用如
@commitlint/cli 用来校验 Git 提交信息是否符合约定的格式。配合 @commitlint/config-conventional 使用,可强制统一提交类型(如 feat、fix、docs、refactor、chore 等)与消息结构,提升版本管理与自动化发布的可靠性。
4.commitizen和cz-git 交互式提交
commitizen提供了cli(命令交互式)的方式来完成Commit Message填写。 cz-git 是 Commitizen 的一个适配器(adapter),通常配合 commitizen 使用。基于cz-git可以更灵活的控制你的提交Message规范。
配置工具
1.初始化husky目录
csharp
npx husky init

pre-commit是 Git 众多钩子中的一个,顾名思义,它在 Commit 之前 执行。也就是说这个文件里的脚本,会在git commit真正执行前执行,避免不规范的提交。
2.commitizen 配置
- Scripts: 添加 commit 命令来启动交互式提交。
- Config: 指定 commitizen 使用 cz-git 适配器。 修改
package.json:
json
{
"scripts": {
"prepare": "husky", //?
"commit": "git-cz" //使用git-cz 来做提交(多个git命令的组合)
},
"config": { //commitizen工具的配置
"commitizen": {
"path": "node_modules/cz-git"
}
}
}
请根据你项目中实际安装的 eslint/prettier 情况调整 lint-staged 里的命令)
2.lint-staged配置
- Lint-staged: 配置暂存区文件的校验规则。 修改
package.json:
json
{
"scripts": {
"prepare": "husky",
"commit": "git-cz"
},
"lint-staged": { // lint-staged工具的配置
"*.{js,ts,vue,jsx,tsx}": [
"eslint --fix",
"prettier --write"
],
"*.{json,md,css,scss}": [
"prettier --write"
]
}
}
添加husky钩子,pre-commit: 提交前执行 lint-staged(代码格式化/检查)。
bash
echo "npx lint-staged" > .husky/pre-commit
3. Commitlint & Cz-git配置
cz-git 的强大之处在于它可以直接读取 commitlint 的配置,从而实现"一份配置,两处生效"。
在根目录新建文件 commitlint.config.js (如果是 type: module 项目则为 .mjs 或 .cjs):
js
// commitlint.config.js
/** @type {import('cz-git').UserConfig} */
export default {
// 继承的规则
extends: ['@commitlint/config-conventional'],
// 自定义规则
rules: {
// type 类型定义,表示 git 提交的 type 必须在以下类型范围内
'type-enum': [
2,
'always',
[
'feat', // 新功能 feature
'fix', // 修复 bug
'docs', // 文档注释
'style', // 代码格式(不影响代码运行的变动)
'refactor', // 重构(既不增加新功能,也不是修复bug)
'perf', // 性能优化
'test', // 增加测试
'chore', // 构建过程或辅助工具的变动
'revert', // 回退
'build' // 打包
]
],
// subject 大小写不做校验
'subject-case': [0]
},
// cz-git 的交互配置(可选,用于定制交互界面)
prompt: {
useEmoji: true, // 是否使用 emoji
// 可以在这里自定义提示语,例如中文化
messages: {
type: '选择你要提交的类型 :',
scope: '选择一个提交范围(可选):',
customScope: '请输入自定义的提交范围 :',
subject: '填写简短精炼的变更描述 :\n',
body: '填写更加详细的变更描述(可选)。使用 "|" 换行 :\n',
breaking: '列举非兼容性重大的变更(可选)。使用 "|" 换行 :\n',
footerPrefixsSelect: '选择关联issue前缀(可选):',
customFooterPrefix: '输入自定义issue前缀 :',
footer: '列举关联issue (可选) 例如: #31, #I3244 :\n',
confirmCommit: '是否提交或修改commit ?'
},
// 设置 scope 范围(根据项目模块调整)
scopes: ['components', 'hooks', 'utils', 'styles', 'deps']
}
}
添加 Husky钩子,提交时校验 commit message 格式。
bash
echo "npx --no-install commitlint --edit \$1" > .husky/commit-msg
结果演示
现在有6个修改

bash
#添加到暂存区
git add .
# 执行commit script(替代git commit)
pnpm commit

六、实战monorepo形式的npm项目
关于这一块,可以参考我的一个项目 @caikengren/uni-hooks,后续我会根据这个项目展开写几篇博客和大家一起交流学习。