Monorepo 笔记
约 10019 字大约 33 分钟
2026-02-03
工程管理方案
在前端/全栈工程管理中,常见两种仓库组织方式:
常见monorepo管理工具:pnpm,npm,Yarn,Lerna,Nx,Turborepo,Rush...
任务编排工具(turbo/nx)
Multirepo(多仓库)
- 一个项目一个仓库
- 发布与权限管理清晰
- 适合独立产品、团队边界明确的场景
缺点:
- 公共代码难复用
- 多仓库版本同步困难
- 跨仓库协作成本高
Monorepo(单体仓库)
- 多个项目/包放在同一个仓库
- 统一依赖管理、统一规范
- 内部包可直接引用,开发体验极佳
适合:
- 大型业务系统
- 组件库 + 工具库 + 多应用共存
- 多端项目统一治理
目录结构
root/
├── apps/ # 应用层(页面/服务)
│ ├── web/ # 前端应用
│ ├── admin/ # 管理后台
│ └── server/ # 后端服务
│
├── packages/ # 公共基础设施包
│ ├── utils/ # 工具库
│ ├── ui/ # UI组件库
│ └── config/ # eslint/prettier共享配置
│
├── docs/ # 文档站点 / 规范说明
│
├── pnpm-workspace.yaml
├── package.json
└── tsconfig.base.jsonapps:业务应用层
- 页面类型的项目放在这里
- 可以自由编排前端、后端、微服务
packages:公共基建层
- 工具函数
- 组件库
- 通用配置
- 可发布 npm 包
docs:文档层
- 项目说明
- 设计文档
- 组件文档站
pnpm Workspace 基础
子包声明:pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
- "docs/*"该文件决定哪些目录会被识别为 workspace 子包。
根目录执行命令
pnpm --workspace-root init
# 简写
pnpm -w init作用:强制在根目录执行命令。
在指定子包中执行命令
方式 1:进入目录执行
cd packages/utils
pnpm dev方式 2:使用 -C
pnpm -C packages/utils dev包命名与依赖引用
package.json name 支持命名空间
{
"name": "@my-org/utils"
}作用域包(@开头)更适合 monorepo。
workspace 内部包引用
{
"dependencies": {
"@my-org/utils": "workspace:*"
}
}特点:
- pnpm 会自动软链接到本地包
- 永远使用最新代码
- 不需要发布即可开发
Node / pnpm 版本锁定
engines 字段
{
"engines": {
"node": ">=22.14.0",
"npm": ">=10.9.2",
"pnpm": ">=10.15.1"
}
}版本不一致会警告。
强制严格模式
在 .npmrc 中:
engine-strict=true不符合版本直接报错。
TypeScript 配置治理
推荐:根目录统一配置 + 子包覆盖。
安装
pnpm -Dw add typescript @types/node根目录:tsconfig.json
// tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"module": "esnext",
"target": "esnext",
"types": [],
"lib": ["esnext"],
"sourceMap": true,
"declaration": true,
"declarationMap": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"strict": true,
"verbatimModuleSyntax": false,
"moduleResolution": "bundler",
"isolatedModules": true,
"noUncheckedSideEffectImports": true,
"moduleDetection": "force",
"skipLibCheck": true
},
"exclude": ["node_modules", "dist"]
}子包继承
// apps/backend/tsconfig.json
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"types": ["node"],
"lib": ["esnext"]
},
"include": ["src"]
}// apps/frontend/tsconfig.json
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"types": ["node"],
"lib": ["esnext", "DOM"]
},
"include": ["src"]
}Prettier:统一格式化
安装到根目录:
pnpm -Dw add prettier忽略文件:.prettierignore
# .prettierignore
dist
public
.local
node_modules
pnpm-lock.yaml配置文件
// prettier.config.js
/**
* @type {import('prettier').Config}
* @see https://www.prettier.cn/docs/options.html
*/
export default {
// 指定最大换行长度
printWidth: 120,
// 缩进制表符宽度 | 空格数
tabWidth: 2,
// 使用制表符而不是空格缩进行 (true:制表符,false:空格)
useTabs: false,
// 结尾不用分号 (true:有,false:没有)
semi: true,
// 使用单引号 (true:单引号,false:双引号)
singleQuote: false,
// 在对象字面量中决定是否将属性名用引号括起来 可选值 "<as-needed|consistent|preserve>"
quoteProps: "as-needed",
// 在JSX中使用单引号而不是双引号 (true:单引号,false:双引号)
jsxSingleQuote: false,
// 多行时尽可能打印尾随逗号 可选值"<none|es5|all>"
trailingComma: "none",
// 在对象,数组括号与文字之间加空格 "{ foo: bar }" (true:有,false:没有)
bracketSpacing: true,
// 将 > 多行元素放在最后一行的末尾,而不是单独放在下一行 (true:放末尾,false:单独一行)
bracketSameLine: false,
// (x) => {} 箭头函数参数只有一个时是否要有小括号 (avoid:省略括号,always:不省略括号)
arrowParens: "avoid",
// 指定要使用的解析器,不需要写文件开头的 @prettier
requirePragma: false,
// 可以在文件顶部插入一个特殊标记,指定该文件已使用 Prettier 格式化
insertPragma: false,
// 用于控制文本是否应该被换行以及如何进行换行
proseWrap: "preserve",
// 在html中空格是否是敏感的 "css" - 遵守 CSS 显示属性的默认值, "strict" - 空格被认为是敏感的 ,"ignore" - 空格被认为是不敏感的
htmlWhitespaceSensitivity: "css",
// 控制在 Vue 单文件组件中 <script> 和 <style> 标签内的代码缩进方式
vueIndentScriptAndStyle: false,
// 换行符使用 lf 结尾是 可选值 "<auto|lf|crlf|cr>"
endOfLine: "auto",
// 这两个选项可用于格式化以给定字符偏移量(分别包括和不包括)开始和结束的代码 (rangeStart:开始,rangeEnd:结束)
rangeStart: 0,
rangeEnd: Infinity
};脚本命令
"scripts":{
"lint:prettier": "prettier --write \"**/*.{js,ts,mjs,cjs,json,tsx,css,less,scss,vue,html,md}\"",
}ESLint:统一代码规范
一份根配置管全局,同时允许各子包(apps/packages)按需“覆盖/细分”
安装依赖
pnpm -Dw add eslint @eslint/js globals typescript-eslint eslint-plugin-prettier eslint-config-prettier eslint-plugin-vue说明:
- eslint-config-prettier:关闭与 Prettier 冲突的 ESLint 规则(推荐)。
- eslint-plugin-prettier:把 Prettier 以 ESLint 规则形式跑起来(可选;团队里常见做法是“Prettier 单独跑”,不用插件)。
- eslint-plugin-vue:Vue.js 支持
- eslint:核心引擎
- @eslint/js:官方规则集
- globals:全局变量支持
- typescript-eslint:TypeScript 支持
eslint.config.js 相比 .eslintrc 更易组合、也更适合 monorepo 的“分段配置”。
完整配置
import { defineConfig } from "eslint/config";
import eslint from "@eslint/js";
import tseslint from "typescript-eslint";
import eslintPluginPrettier from "eslint-plugin-prettier";
import eslintPluginVue from "eslint-plugin-vue";
import globals from "globals";
import eslintConfigPrettier from "eslint-config-prettier/flat";
const ignores = ["**/dist/**", "**/node_modules/**", ".*", "scripts/**", "**/*.d.ts"];
export default defineConfig(
// 通用配置
{
ignores, // 忽略项
extends: [eslint.configs.recommended, ...tseslint.configs.recommended, eslintConfigPrettier], // 继承规则
plugins: {
prettier: eslintPluginPrettier
},
languageOptions: {
ecmaVersion: "latest", // ecma语法支持版本
sourceType: "module", // 模块化类型
parser: tseslint.parser // 解析器
},
rules: {
// 自定义
}
},
// 前端配置
{
ignores,
files: ["apps/frontend/**/*.{ts,js,tsx,jsx,vue}", "packages/components/**/*.{ts,js,tsx,jsx,vue}"],
extends: [...eslintPluginVue.configs["flat/recommended"], eslintConfigPrettier],
languageOptions: {
globals: {
...globals.browser
}
}
},
// 后端配置
{
ignores,
files: ["apps/backend/**/*.{ts,js}"],
languageOptions: {
globals: {
...globals.node
}
}
}
);// eslint.config.js
import js from '@eslint/js'
import globals from 'globals'
import tseslint from 'typescript-eslint'
import vue from 'eslint-plugin-vue'
import eslintConfigPrettier from 'eslint-config-prettier'
export default [
// 全局忽略(等价于 .eslintignore;建议集中写在这里)
{
ignores: [
'**/node_modules/**',
'**/dist/**',
'**/build/**',
'**/.output/**',
'**/.next/**',
'**/coverage/**',
'**/*.min.*',
'**/*.d.ts'
]
},
// JS 推荐规则
js.configs.recommended,
// TS 推荐规则
...tseslint.configs.recommended,
// 全局语言/运行时配置
{
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
globals: {
...globals.browser,
...globals.node
}
},
rules: {
// 团队的“全局统一规则”
'no-console': ['warn', { allow: ['warn', 'error'] }],
'no-debugger': 'warn'
}
},
// Vue 子包规则(仅对 *.vue 生效)
...vue.configs['flat/recommended'],
{
files: ['**/*.vue'],
rules: {
// 示例:按团队偏好调整
'vue/multi-word-component-names': 'off'
}
},
// Prettier 冲突处理(放在最后)
eslintConfigPrettier
]细分组合
可以用多个 config block 叠加:
ignores: 全局忽略files: 让某段规则仅匹配特定文件/目录rules: 为某类文件定义规则languageOptions: 运行时环境、parserOptions、globals 等
举例:给 apps 下的前端项目更严格,给 packages 下的库包更偏向发布质量:
{
files: ['apps/**/**/*.{ts,tsx,vue,js,jsx}'],
rules: {
// 前端业务:允许更灵活
}
},
{
files: ['packages/**/**/*.{ts,tsx,js,jsx}'],
rules: {
// 库包:更建议禁止默认导出等(按团队习惯)
}
}类型感知
如果希望启用更强的 TS 规则(需要读取 tsconfig),可以加一段 Type-Aware 配置。注意:它会更耗时,适合 CI 或对 packages 严格一些。
// 加在 configs 里(放在 TS recommended 之后)
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
parserOptions: {
// monorepo 常用:让 ESLint 自动找到各子包的 tsconfig
projectService: true,
tsconfigRootDir: import.meta.dirname
}
},
rules: {
// 示例:类型感知类规则(可按需选)
'@typescript-eslint/no-floating-promises': 'error'
}
}提示:如果遇到“找不到 tsconfig / project 太多导致慢”,可只对 packages/** 启用 type-aware,或在 CI 启用。
继承/覆盖
方式 A:只用根配置,用 files 做目录级细分(最省心)
- 所有规则都在根
eslint.config.js - 通过
files: ['apps/**']等细分
方式 B:抽共享配置包 packages/config(更工程化)
适合:多个 repo/多个 workspace 想复用同一套规范。
结构示例:
packages/
config/
eslint/
index.js
apps/
web/
eslint.config.jspackages/config/eslint/index.js 导出通用配置:
import js from '@eslint/js'
import globals from 'globals'
import tseslint from 'typescript-eslint'
import eslintConfigPrettier from 'eslint-config-prettier'
export function baseConfig() {
return [
{ ignores: ['**/dist/**', '**/node_modules/**'] },
js.configs.recommended,
...tseslint.configs.recommended,
{
languageOptions: {
globals: { ...globals.browser, ...globals.node }
}
},
eslintConfigPrettier
]
}子包 apps/web/eslint.config.js:
import { baseConfig } from '@my-org/config/eslint'
import vue from 'eslint-plugin-vue'
export default [
...baseConfig(),
...vue.configs['flat/recommended'],
{
files: ['**/*.vue'],
rules: { 'vue/multi-word-component-names': 'off' }
}
]这样“通用规则”放在 config 包里,子包只写差异,维护成本最低。
忽略文件
更推荐把忽略写进 eslint.config.js 的 ignores
也可以保留 .eslintignore,但建议最终统一到一处,避免两边不一致。
命令脚本
根目录 package.json:
{
"scripts": {
"lint:eslint": "eslint . --cache --cache-location .cache/eslint",
"lint:eslint:fix": "eslint . --fix --cache --cache-location .cache/eslint"
}
}子包定向 lint(可选):
pnpm --filter @my-org/utils lint:eslint--cache 能显著提升二次运行速度;.cache/ 记得加入 gitignore。
拼写检查
拼写检查这块常见有两条路:
- VS Code 插件(Code Spell Checker):上手快、体验好、个人开发很爽。
- 项目内置 cspell(推荐):把规则写进仓库,做到“谁拉代码谁一致”。
结论:插件可以用(提升体验),但 团队规范一定要落到仓库里(CI 可执行、人人一致)。
VS Code 插件
优点
- 实时提示拼写问题,反馈非常快
- 支持一键修复、忽略、加入字典
- 对写英文注释、文档、变量名很友好
局限
- 插件属于“个人环境”:有人装有人不装、版本也可能不同
- 规则与字典不一定跟仓库走,CI 无法强制
- 多编辑器(WebStorm、Vim)团队不好统一
因此更推荐:
- 把 cspell 配置写进仓库(统一规则)
- 插件作为增强(更好用的交互)
cspell
- 配置文件在仓库(
cspell.json),新人 clone 下来即生效 - 命令行可运行:本地、pre-commit、CI 都能用
- 自定义字典可版本管理(团队专有名词、接口字段、品牌词、缩写等)
- 可精确忽略路径/文件类型,避免对构建产物、锁文件等“误伤”
安装
pnpm -Dw add cspell @cspell/dict-lorem-ipsum可按需换成相关技术栈字典,例如:
@cspell/dict-typescript@cspell/dict-node@cspell/dict-html/@cspell/dict-css等
配置文件
含自定义字典,在根目录新建 cspell.json:
{
"import": ["@cspell/dict-lorem-ipsum/cspell-ext.json"],
"caseSensitive": false,
"dictionaries": ["custom-dictionary"],
"dictionaryDefinitions": [
{
"name": "custom-dictionary",
"path": "./.cspell/custom-dictionary.txt",
"addWords": true
}
],
"ignorePaths": [
"**/node_modules/**",
"**/dist/**",
"**/build/**",
"**/lib/**",
"**/docs/**",
"**/vendor/**",
"**/public/**",
"**/static/**",
"**/out/**",
"**/tmp/**",
"**/*.d.ts",
"**/package.json",
"**/*.md",
"**/stats.html",
"eslint.config.mjs",
".gitignore",
".prettierignore",
"cspell.json",
"commitlint.config.js",
".cspell"
]
}然后创建字典文件:./.cspell/custom-dictionary.txt
monorepo
pnpm
vitepress
husky
lintstaged团队实践建议
- 把“业务专有词/缩写/产品名/接口字段”都放进这个文件
- PR 里如果出现新名词,就顺手补到字典里(减少反复报警)
命令脚本
根目录 package.json:
{
"scripts": {
"lint:spellcheck": "cspell lint \"(packages|apps)/**/*.{js,ts,mjs,cjs,json,tsx,css,less,scss,vue,html,md}\""
}
}- 生成物、依赖、锁文件会产生大量噪音
- 只扫“会被人读/改”的源码与文档,性价比最高
配合
团队标准:以仓库里的 cspell.json 为准(CI / pre-commit 跑它)
个人体验:安装 Code Spell Checker 插件
插件会读取项目里的 cspell 配置(通常能自动识别)
常用能力:
- Add Word to Workspace Dictionary(一键加入
custom-dictionary.txt) - Ignore Word / Ignore Line
- Quick Fix 批量修正
这样可以实现:
- 每个人开发时都有即时提示(插件)
- 提交前/CI 一定能兜底(命令行 cspell)
Git 提交规范
Commitizen + Commitlint + cz-git 这一套的目标是:
- 统一提交信息格式(Conventional Commits)
- 提交时交互式引导(不用背模板)
- 在 pre-commit / commit-msg 阶段拦截不合规提交
忽略文件
# .gitignore
# Node
node_modules/
dist/
build/
.env
.env.*
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# IDE
.vscode/
.idea/
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# OS
.DS_Store
Thumbs.db
# TypeScript
*.tsbuildinfo
# Misc
coverage/
*.local
*.cache
*.tmp
# Git
.git/安装
pnpm -Dw add @commitlint/cli @commitlint/config-conventional commitizen cz-git| 依赖 | 作用 |
|---|---|
| @commitlint/cli | 工具的核心 |
| @commitlint/config-conventional | 一套现成的规则集:基于 Conventional Commits(如 feat: ...、fix: ...) |
| commitizen | 提供 git cz / cz 的交互式提交体验(问答式生成 commit message) |
| cz-git | 一个更工程化、更可配置的 Commitizen adapter(可自定义 type、scope、提示文案、emoji 等) |
Conventional Commits
典型格式:
<type>(<scope>): <subject>type:提交类型,例如feat/fix/docs/chore…scope:影响范围(可选),例如web/utils/ui…(monorepo 里很有用)subject:一句话摘要
配置
"scripts": {
"commit": "git-cz"
},
"config": {
"commitizen": {
"path": "node_modules/cz-git"
}
}
}scripts.commit:
- 执行
pnpm commit时,实际上跑的是git-cz(cz-git 提供的交互命令)。 - 建议在团队里把“提交入口”固定为这一个命令,降低差异。
config.commitizen.path:
- 告诉 commitizen:使用哪个 adapter 生成提交信息。
- 这里指向
cz-git,也就是:commitizen 负责框架,cz-git 负责交互实现。
cz-git 更方便做工程化定制(例如:scope 列表、提示文案、多语言、emoji、校验规则等)。
commitlint.config.js
commitlint.config.js 的作用:
- 定义“什么样的 commit message 才算合格”
- 会在
commit-msg钩子里运行 - 不合规就 直接阻止这次提交
完整配置
/** @type {import('cz-git').UserConfig} */
export default {
extends: ["@commitlint/config-conventional"],
rules: {
// @see: https://commitlint.js.org/#/reference-rules
"body-leading-blank": [2, "always"],
"footer-leading-blank": [1, "always"],
"header-max-length": [2, "always", 108],
"subject-empty": [2, "never"],
"type-empty": [2, "never"],
"subject-case": [0],
"type-enum": [
2,
"always",
[
"feat",
"fix",
"docs",
"style",
"refactor",
"perf",
"test",
"build",
"ci",
"chore",
"revert",
"wip",
"workflow",
"types",
"release"
]
]
},
prompt: {
types: [
{ value: "feat", name: "✨ 新功能: 新增功能" },
{ value: "fix", name: "🐛 修复: 修复缺陷" },
{ value: "docs", name: "📚 文档: 更新文档" },
{ value: "refactor", name: "📦 重构: 代码重构(不新增功能也不修复 bug)" },
{ value: "perf", name: "🚀 性能: 提升性能" },
{ value: "test", name: "🧪 测试: 添加测试" },
{ value: "chore", name: "🔧 工具: 更改构建流程或辅助工具" },
{ value: "revert", name: "⏪ 回滚: 代码回滚" },
{ value: "style", name: "🎨 样式: 格式调整(不影响代码运行)" }
],
scopes: ["root", "backend", "frontend", "components", "utils"],
allowCustomScopes: true,
skipQuestions: ["body", "footerPrefix", "footer", "breaking"], // 跳过“详细描述”和“底部信息”
messages: {
type: "📌 请选择提交类型:",
scope: "🎯 请选择影响范围 (可选):",
subject: "📝 请简要描述更改:",
body: "🔍 详细描述 (可选):",
footer: "🔗 关联的 ISSUE 或 BREAKING CHANGE (可选):",
confirmCommit: "✅ 确认提交?"
}
}
};最小可用配置(推荐先用 conventional):
// commitlint.config.js
export default {
extends: ['@commitlint/config-conventional'],
}常见扩展:
export default {
extends: ['@commitlint/config-conventional'],
rules: {
// type 必须是这些之一(可按团队增删)
'type-enum': [
2,
'always',
['feat', 'fix', 'docs', 'style', 'refactor', 'perf', 'test', 'chore', 'ci', 'build', 'revert']
],
// subject 不允许句号结尾
'subject-full-stop': [2, 'never', '.'],
// subject 不能为空
'subject-empty': [2, 'never']
}
}cz-git 的配置
cz-git 官方文档明确说明:配置入口取决于你的项目 是否使用 commitlint。
如果你已经安装了 commitlint,那么 cz-git 推荐直接把交互配置写进 commitlint 的配置文件 里。
一份配置文件同时管理两件事:
rules:commitlint 校验规则prompt:cz-git 交互规则(types/scopes/messages 等)
如果你的项目暂时不接入 commitlint,但仍希望使用 cz-git 的交互式提交能力,文档说明你可以单独创建 cz-git 配置文件,这种情况下配置文件只负责 prompt(交互),不涉及 commitlint 的 rules。
Husky + lint-staged
Husky
pnpm -Dw add husky
pnpx husky init在 .husky/pre-commit:
#!/usr/bin/env sh
pnpm lint:prettier && pnpm lint:eslint && pnpm lint:spellcheckpackage.json:
{
"scripts": {
"prepare": "husky"
}
}lint-staged
lint-staged 用于检查暂存区文件
安装:
pnpm -Dw add lint-staged配置:
// .lintstagedrc.js
export default {
"*.{js,ts,cjs,jsx,tsx,css,scss,vue,html,md}": ["cspell lint"],
"*.{js,ts,vue,md}": ["prettier --write", "eslint"]
}还可以自己写 node 脚本,然后加入 node xxx.js 到数组中
测试
Vitest 常见在前端的原因
- 和 Vite 生态贴得紧(ESM、TS、路径解析、插件、速度)
- 启动快、热、watch 体验很好
- 跑浏览器相关(jsdom/happy-dom)很顺滑
Jest 常见在工具库/后端的原因
- 历史沉淀太深,生态庞大(mock、snapshot、transform、覆盖率)
- Node/后端项目模板里几乎默认就有
- 对 CommonJS/老项目迁移成本低(当然现在也能 ESM,但配置会复杂些)
测试类型:
- Unit(单元测试):函数/模块级别,不依赖外部系统
- Integration(集成测试):跨模块/依赖数据库/服务(可能需要 docker/测试环境)
- E2E(端到端):从用户视角跑完整流程(Playwright/Cypress)
建议脚本命名上就分开:
test:默认跑 unit(快,commit/PR 必跑)test:integration:只在需要时或 CI 特定阶段跑test:e2e:通常只对 apps,且在 CI 单独 job
monorepo 下最稳的结构是:根目录提供共享配置包,每个 package/app 用同一套基础配置,子包只覆盖:
- test 环境(node/jsdom)
- alias/路径
- 特定 setup(比如 polyfill、mock server)
- 特定 include/exclude(比如 e2e 不跑)
根目录放基础配置文件,子包引用它
- 根
jest.base.config.js - 根
vitest.base.ts - 子包
jest.config.js/vitest.config.ts只 extend
测试框架会自动扫描 test 文件夹,Jest 默认识别:__tests__ 和 *.test.* / *.spec.* ,Vitest 默认识别 *.test.ts / *.spec.ts,真正决定扫描的是:测试框架的 testMatch/include 配置 + 文件名模式
打包与发布
统一打包
“打包”这件事经常被误解成:根目录一个 build 把所有东西一起打完就行。实际上更合理的模型是:
- apps/:应用项目(Vite / Next / Nest / Nuxt…)本身就有 build 能力
- packages/:公共库(utils、ui、sdk、config…)才是 Monorepo 打包体系的重点
- 根目录 build:不是“负责公共包”这么简单,而是统一编排:按依赖顺序把 apps & packages 的 build 串起来/并行起来,并做缓存、增量等优化(Turbo/Nx 的价值就在这)
根目录的 build 应该是“编排入口”:让每个 workspace 自己执行自己的 build;至于具体怎么打,是各子包自己决定的。
所以根目录一般写成:
{
"scripts": {
"build": "turbo build",
"dev": "turbo dev",
"lint": "turbo lint"
}
}然后每个子项目(无论 apps 还是 packages)在自己的 package.json 里提供一致的脚本名:
builddev(或start)linttest
Turbo/Nx 才能统一调度。
“统一打包,一个库依赖另一个库”,实际有两种主流做法:
做法 A(推荐):每个包自己有 build,根目录编排顺序
packages/a依赖packages/b- 那么
a:build依赖b:build - 编排工具自动按 DAG 顺序跑(Turbo:
dependsOn: ["^build"])
这套维护成本低,扩展性最好。
做法 B:根目录写一个超级脚本 build.js 去扫描 packages 并逐个打
也能做,但你得自己解决:
- 依赖拓扑排序(先打上游)
- 并发控制
- 增量判断(哪些包需要重打)
- 缓存
- 跨平台脚本细节
除非你有很强的定制需求,否则做法 A 更香。
最后需要在根目录统一入口:build / dev / watch
工具选型
纯工具库(utils、shared-types)
- tsc:最简单,但不做 bundling(不合并依赖)
- tsup:非常省心,一条命令出 esm/cjs/dts,适合多数 TS 库
- rollup:最可控,但配置多
推荐:tsup(快、配置少、产物靠谱)
组件库(Vue/React 组件 + 样式)
- Vite library mode:开发体验好,配置相对少
- Rollup:生态成熟(Vite 底层也是 Rollup),更细粒度控制 external、css 分拆等
推荐:Vue/React 组件库用 Vite library mode(够用且爽),复杂场景再下沉到 Rollup 插件细配。
子包自定义
在 monorepo 里,经常希望每个包能自定义构建行为,而统一脚本去识别。
例如你在包里写:
{
"name": "@org/ui",
"buildOptions": {
"format": ["esm", "cjs"],
"external": ["vue"],
"entry": "src/index.ts"
}
}然后 scripts/build.mjs 去读取 buildOptions,拼装 rollup/vite/tsup 的参数。
组件库打包
打包组件库需要把组件框架排除掉,比如 vue,不要让打包结果有 vue 的代码,这里有两个层面:
external(构建层)
告诉 bundler:这些依赖不要打进产物里。
- Rollup/Vite:
build.rollupOptions.external = ["vue"] - tsup:
--external vue
peerDependencies(包管理层)
告诉使用者:你必须自己安装这些依赖,我只是“兼容它”。
组件库的 package.json 通常要这样写:
{
"peerDependencies": {
"vue": "^3.0.0"
},
"dependencies": {
// 只放运行时真正需要且应该被你带上的依赖
}
}重点:external + peerDependencies 通常要一起做,否则就会出现:external 了 vue,但没写 peerDependencies -> 使用者没装 vue 也能装你的包,但运行直接炸
子包使用
workspace:* 是开发期最舒服的写法
{
"dependencies": {
"@org/ui": "workspace:*"
}
}这表示:
- 优先链接同仓的
@org/ui - 不从 npm 拉
- 版本对齐由 workspace 管
也可以指定版本号,比如想锁死:"@org/ui": "workspace:^1.0.0"
依赖包内嵌
一个现象:打包后发现 @org/xxx 似乎“内嵌”了,于是觉得使用者不需要再装 @org/xxx。
情况 A:确实把依赖打进 bundle(bundled dependencies)
比如把某个内部包打进了最终产物里(rollup 把它打平了)。
优点:使用者只装一个包就能跑
缺点:容易导致:
- 重复打包(多个包各自带一份相同代码)
- 版本冲突难排查
- tree-shaking 变差
- sourcemap/debug 变复杂
情况 B:你没打进去,只是 workspace 本地链接让你误以为“内嵌”
开发时 workspace:* 会让你觉得“我都能 import 到”,但发布后用户环境没有 workspace,就会暴露真实问题。
经验建议:
- 内部库之间:尽量保持“正常依赖关系 + 清晰 exports”,不要靠“打平内嵌”解决
- 除非你做的是一个“单文件 SDK”或“必须自包含”的产物
入口字段
一个坑:“发布了包,但用户导入时报错,读了 main 的 index.js,可是根本没有 index.js,因为产物在 dist,且 ESM/CJS 不匹配”
推荐用 exports(现代 Node/打包器都认)
比如你的产物是:
dist/index.mjs(ESM)dist/index.cjs(CJS)dist/index.d.ts(types)
推荐这样写:
{
"name": "@org/ui",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
},
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts"
}解释:
exports是第一优先级(更安全,避免用户乱 import 你内部文件)main/module/types保留是为了兼容老工具链(有些还看)
如果你只产 ESM,那就:
{
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
}
}并确保产物确实是 ESM(比如 .js 在 "type":"module" 下就是 ESM)。
类型声明(types)一定要有
否则 TS 用户会:
- 没类型提示
- 或直接
Could not find a declaration file for module ...
解决:
- 用
tsc --declaration或 tsup/vite 插件生成 dts - 并在 package.json 正确指向
types
内部包导入
场景:发布的包 @org/a 在源码里依赖了 @org/b:
- 在 monorepo 里,
@org/b通过workspace:*链接存在,所以你本地一切 OK - 但你发布到 npm 后,用户环境里没有
@org/b(因为你没发布它) - 如果你的构建产物里还保留了
require("@org/b")/import "@org/b",用户就会报错:找不到模块
解决思路只有三种
- 发布
@org/b(最干净) - 把
@org/b作为 dependency 并让用户安装它(仍然要求它可获取:要么也发布,要么私有 registry) - 构建时把
@org/b打进@org/a的产物(bundle in)(自包含)
如果你选择 3,就要明确:
- bundler 不 external
@org/b - 并且确保类型声明/源码映射也匹配(否则 TS 也会迷路)
版本号递增
本地开发时(monorepo 内)写:
"@org/ui": "workspace:*"但当“使用这个包的包”(比如 @org/app-core 或 @org/sdk)被 version / publish / pack 之后,你去看它最终产出的 package.json(或发布到 npm 的版本),会发现它变成了类似:
"@org/ui": "^1.3.2"这个 1.3.2 正是 @org/ui 自己 package.json 里的 version。
workspace:*(以及 workspace:^、workspace:~)是一种 “只在工作区内有意义的依赖写法”。
一旦要把某个包“变成工作区外可用的东西”(典型是 npm publish、pnpm pack,或用 changesets/lerna 做 version/publish),工具就必须把它转换成外部世界能理解的 semver 依赖范围。
所以它会做解析:
workspace:*→ “依赖 workspace 中那个包的当前版本”workspace:^→ “^当前版本”workspace:~→ “~当前版本”
因此你看到的就是:发布物里的依赖被替换成目标包的版本号范围。
严格说:不是 bundler(rollup/vite)在改 package.json,更多是:
- pnpm / npm 在 pack/publish 时的 workspace 协议处理
- 或者 changesets/lerna/rush 在
version/publish阶段做的“依赖范围重写”
升级范围
* ~ ^,这三个符号的区别是“允许升级的范围”,开发阶段写 workspace:*,pack 后变成 ^x.y.z”,一般是发布/打包工具按默认策略把它改成了 caret 范围(^),让依赖能自动吃到兼容更新。
*:不限制版本(非常宽)
*表示:任意版本都行- 等价于
>=0.0.0(极宽) - 风险:依赖可能突然装到很新的大版本(尤其 1.x 以后),引入 breaking change
为什么很多库/monorepo 开发时用 workspace:*?
- 在工作区里,
workspace:*主要意思是:永远用本地那个包(不用关心具体版本号) - 它是“开发便利”写法,不是“对外依赖策略”
^:允许升级“兼容版本”(主流默认)
^x.y.z 允许升级到 不改变左边第一个非零位 的最新版本:
^9.1.7允许:>=9.1.7 <10.0.0^1.2.3允许:>=1.2.3 <2.0.0
但对于 0.x,规则更保守(因为 0.x 被认为不稳定):
^0.2.3允许:>=0.2.3 <0.3.0^0.0.3允许:>=0.0.3 <0.0.4
所以你看到很多库写 "husky": "^9.1.7" 很正常:它想要“同一大版本内自动拿到 bugfix/minor”。
**~:只允许升级 patch(更保守)**
~x.y.z 允许升级到同一 minor 的最新 patch:
~9.1.7允许:>=9.1.7 <9.2.0~1.2.3允许:>=1.2.3 <1.3.0
适合特别怕依赖的 minor 更新带来行为变化的场景。
选型建议:
应用(apps):一般用 ^(升级友好)
库(packages):
- 对外发布、希望兼容升级:
^ - 极度保守、只吃补丁:
~
monorepo 内部互相依赖:
- 开发体验:
workspace:^(更推荐它,比workspace:*更明确) - 不希望工具乱猜策略,就别用裸
workspace:*
子包安装
当在某个包里写:
"@org/ui": "workspace:*"然后在 monorepo 根目录执行 pnpm i(或在任何子包执行安装,最终也是 workspace 安装),pnpm 并不会像从 npm registry 装包那样把 @org/ui 复制一份到 node_modules。
相反,它会做两件事:
- 在 pnpm 的全局 store/工作区结构里维护真实内容
- 在当前包的
node_modules/@org/ui位置放一个链接(symlink/shortcut),指向工作区里@org/ui这个包
所以你会看到:
node_modules/@org/ui确实存在- 但它不是一份拷贝,而是一个“软链接/快捷方式”
- 好处是:永远指向同一个工作区包,永远是最新的源码状态
链接只是让它指向工作区里的那个包目录,但到底读 src 还是读 dist,是由入口字段决定的。
“永远最新版”的前提:上游包必须先产出可消费的产物,因为如果 @org/ui 的入口指向 dist/**,但它还没 build,那么 dist/ 不存在,那消费者就会出现各种报错
因此,monorepo 开发里通常需要一个 watch 链路
为了让下游包(apps/其它 packages)始终吃到最新的 dist,一般会:
- 给
@org/ui提供dev脚本(watch build) - 根目录用 turbo/nx 起一个“联动 dev”:
比如思路是 pnpm dev 同时启动:
@org/ui的dev(watch 输出 dist)apps/web的 dev server(消费 dist)
这样改 ui 代码,ui 会重新构建 dist,下游立刻看到变化。
workspace 链接只是“把包连上了”,到底消费源码(src)还是消费产物(dist),取决于你怎么设计包的入口,以及应用的 bundler 能不能编译 workspace 源码。
确实可以直接读取源码(src),不需要先 build dist
在 Monorepo 里,@org/ui 是一个真实目录:
packages/ui/
src/
index.ts
dist/
package.json当下游写:
import { Button } from "@org/ui";pnpm 会在 node_modules/@org/ui 做软链接,最终指向:
packages/ui/所以理论上,下游完全可以直接编译:
packages/ui/src/index.ts而不是读 dist/index.js
但是“读源码”有很多隐含条件:
条件 A:下游 bundler 必须能编译 workspace 外部文件
很多框架默认认为:
node_modules里的东西是“已经编译好的 JS”- 不会再去处理 TS/JSX 源码
所以如果你让包入口指向 src:
{
"exports": {
".": "./src/index.ts"
}
}那么下游可能直接报错:
- Vite:默认不预编译 workspace TS(需要配置)
- Next:默认不 transpile node_modules(需要 transpilePackages)
- Jest:默认不处理 node_modules TS(需要 transform)
所以“读源码”必须配套:
- 应用侧 transpile workspace packages
条件 B:对外发布时不能暴露 src(通常不推荐)
Monorepo 内部读源码没问题,但一旦包要发布到 npm:
- 用户不会有 TS 编译环境
- 用户不希望安装一个包还要编译它
- 源码路径也不稳定
所以对外发布的库一般必须提供 dist:
- ESM/CJS
- d.ts
- sourcemap
因此最佳实践通常是:
- 开发期可以读源码(提升联调体验)
- 发布期一定读 dist(稳定交付)
总结:Monorepo 完全可以做到:下游直接 import workspace 包的源码,而不是 build 后的 dist,只是这要求应用侧 bundler 支持编译 workspace packages,并且这种方式通常只适合内部开发,不适合对外发布。
如果上游本来就是纯 JS(不需要编译),那当然可以直接源码消费,甚至这是最省事的情况。
子包入口声明
{
"main": "./dist/index.cjs.js",
"module": "./dist/index.esm.js",
"types": "./dist/index.d.ts"
}否则无法正确导入。
范围执行命令
pnpm --filter 在 monorepo 里就是“只对指定的那些 workspace 包执行命令”,很常用,尤其不想每次 -r 全仓跑的时候。
在 monorepo 里你有很多 workspace 包:
apps/webpackages/utilspackages/uipackages/api
如果你直接:pnpm -r build,它会对所有包跑 build,但很多时候只想:只跑某一个包的 build/test/lint,这就用 --filter。
基本形态:
pnpm --filter <selector> <command>比如
{
"scripts": {
"publish:utils": "pnpm --filter \"@name-org/utils\" publish"
}
}这会在 workspace 里只对 @name-org/utils 执行 pnpm publish。
发布经常还需要控制 registry / access / tag,这些参数可以继续加在后面。
selector(选择器):
(1) 按包名(最常用)
pnpm --filter @name-org/utils test(2) 按路径(也常用)
pnpm --filter ./packages/utils build(3) 通配符匹配
pnpm --filter "@name-org/*" lint
pnpm --filter "./packages/*" build高级玩法:只跑“相关包”
pnpm 的 filter 支持用 ... 表达依赖关系(上游/下游)
只跑这个包:
pnpm --filter @org/utils build这个包 + 它的依赖(上游)当要跑一个 app,但它依赖很多 packages,你希望先把依赖也 build 掉:
pnpm --filter @org/web... build... 的意思就是“包含依赖链”。
这个包 + 依赖它的包(下游/消费者)
比如你改了 @org/utils,你想把所有用到它的包也跑一遍测试:
pnpm --filter ...@org/utils test(这个方向在做影响面验证时特别有用。)
这个包 + 上游 + 下游(全相关)
你想把“受影响的一圈”都跑:
pnpm --filter ...@org/utils... test跟 -r(recursive)关系:
pnpm -r <cmd>:对所有 workspace 包执行pnpm --filter <sel> <cmd>:对筛选出的包执行
可以理解为 --filter 是“选择范围”,然后 pnpm 会在这个范围里递归执行命令。
在 package.json 里写 filter 命令的小建议(少踩坑)
(1)引号一定要处理好
"pnpm --filter \"@name-org/utils\" publish"很稳(兼容 shell)。
(2)推荐加 --dry-run 做发布演练
"publish:utils:dry": "pnpm --filter \"@name-org/utils\" publish --dry-run"(3)如果是 monorepo,多包发布建议用 changesets
一个超实用组合:只跑变更相关包
pnpm --filter "...[origin/main]" test含义:只对“相对于 main 分支有改动的包及其相关包”跑测试(具体语法/支持度取决于 pnpm 版本,但这是常见思路)。
发布准备
确认登录账号与 registry
npm whoami
npm config get registrynpm whoami 用来确认你现在到底登录的是哪个账号(尤其你在公司/个人账号之间切换时)。
@xxx/utils 里的 xxx 是一个 scope,它可以是:
- 用户 scope:
@你的用户名/*(例如你用户名叫alice,你可以发@alice/utils) - 组织 scope:
@某个组织/*(例如@my-org/utils,你需要是这个 org 的成员且有 publish 权限)
也就是说:如果你想发布到 @xxx/*,你要么:
- 登录的用户就是
xxx(用户 scope) - 要么你在 npm 上有一个叫
xxx的 organization,并且你有权限往里发布(组织 scope)
至于“付费”这块:付费主要影响私有包能力,不是说“不付费就不能用 org/scope”,npm 文档里明确讲:Teams 是为了组织成员发布/安装私有包等能力。
scoped 包(@xxx/name)默认会被当成 private 发布,如果你没有私有包权限,就会发布失败,解决方式是发布时指定 --access public。
命令行指定(最直观)
pnpm publish --access public写死在 package.json 的 publishConfig(更省心)
{
"publishConfig": {
"access": "public"
}
}pnpm publish 默认会检查:
- 当前分支是不是 publish-branch(默认 main/master)
- 工作区是否干净(没有未提交改动)
- 是否与远端同步
如果就是想“本地临时发一下/CI tag 场景/不想被这些规则卡”,可以加:
pnpm publish --no-git-checks同样也可以在 pnpm 配置里把 git-checks 关掉,但命令行最直接。
发布的内容最主流做法是:
- 发布 dist + types + README + LICENSE
- 不发布 src(可选)
files 白名单(推荐)
{
"files": [
"dist",
"types",
"README.md",
"LICENSE"
]
}或者.npmignore 黑名单
就算不发源码,npm 页面/包里依然可以通过 repository 字段链接到仓库,用户想看源码也能点过去。
发布命令
pnpm --filter "@my-org/utils" publish版本管理建议
发布前建议引入自动化版本管理工具:
- changesets(最推荐)
- rush
- lerna
实现:
- 自动 bump version
- 自动生成 changelog
- 多包联动发布
总结
Monorepo 的核心价值在于:
- 多项目统一治理
- 内部包即时共享
- 规范集中管理(eslint/prettier/ts)
- 自动化提交检查与发布
配合 pnpm workspace,可以实现:一个仓库管理所有应用 + 所有基础设施包,并保持高质量工程化。
Changesets
Changesets 是目前 pnpm / yarn / npm monorepo 场景下,最主流、最稳妥的,版本管理 + Changelog 生成 + 多包发布 方案之一。
它的设计目标不是“全自动猜版本”,而是:可控,可审计,适合多包仓库
Changesets 是什么?解决什么问题?
1️⃣ 在没有 Changesets 的 Monorepo 里,通常会遇到这些问题
- 多个包同时维护,不知道该给哪个包升版本
- 内部包互相依赖,升级一个包后,依赖它的包版本/依赖范围容易忘记更新
- 手动改
package.json version非常容易出错 - Changelog 要么不写,要么乱写,要么根本没人维护
- CI 发布流程复杂且不可控
2️⃣ Changesets 的核心职责(只做 3 件事)
Changesets 只关心这三件事:
记录“变更意图”
- 哪些包变了
- 这次是 patch / minor / major
- 变更说明(会进 changelog)
根据这些记录,自动计算版本号
- 自动递增版本
- 自动同步内部依赖的版本范围
生成并维护 CHANGELOG.md
注意:Changesets 不是 bundler,不是测试工具,也不是 commit 规范工具,它只管「版本和日志」
Changesets 在 Monorepo 中的整体工作流(全景)
一个完整生命周期是这样的:
开发者改代码
↓
pnpm changeset(生成 changeset 文件)
↓
changeset 文件随代码提交
↓
合并到 main
↓
pnpm changeset version(计算版本 & 写 changelog)
↓
pnpm changeset publish(发布到 npm)关键思想:
- 版本不是靠“猜”
- 而是靠你明确写下的 changeset 文件
安装
1️⃣ 在 monorepo 根目录:
pnpm add -D @changesets/cli2️⃣ 初始化
pnpm changeset init生成两个关键东西:
.changeset/
config.json之后所有的 changeset 文件,都会放在 .changeset/ 目录下。
配置详解
这是 Changesets 的“行为配置中心”。
一个典型、推荐的配置(独立版本模式):
{
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
}1️⃣ changelog
"changelog": "@changesets/cli/changelog"表示使用官方的 changelog 生成器。
效果:
- 每个包会生成 / 更新自己的
CHANGELOG.md - 按版本号分段
2️⃣ commit
"commit": falsefalse:Changesets 不自动帮你 commit- 推荐保持 false(更可控)
3️⃣ fixed(统一版本 / lockstep)
"fixed": []- 空数组:默认是独立版本(independent)
- 如果你想所有包同一个版本号,可以这样:
"fixed": [["@org/*"]]表示所有 @org/* 包共享同一个版本。
4️⃣ linked(联动版本,较少用)
"linked": []表示某些包版本必须保持一致,但不强制所有包一致,一般用得不多,新手可以忽略。
5️⃣ access
"access": "public"- 用于 scoped 包(
@org/pkg) - 等价于
npm publish --access public
避免发布时因为“默认私有”而失败。
6️⃣ baseBranch
"baseBranch": "main"表示:Changesets 默认认为 main 是发布基线分支
7️⃣ ⭐ updateInternalDependencies(非常关键)
"updateInternalDependencies": "patch"含义:当某个包升级后,依赖它的内部包至少 bump 一个 patch
- patch:修 bug,不改 API(x.y.(z+1))
- minor:加新功能,向后兼容(x.(y+1).0)
- major:破坏性变更((x+1).0.0)
举例:
@org/utils升级了@org/ui依赖@org/utils- 即使 ui 自己没改代码
- ui 也会自动 bump 一个 patch
这是为了避免:
- 依赖范围不匹配
- 发布后装不到正确版本
8️⃣ ignore
"ignore": []可以忽略某些包(比如 demo、playground)。
文件本身
1️⃣ 生成 changeset
pnpm changeset交互式流程会问你:
- 哪些包变了(多选)
- 每个包是 patch / minor / major
- 写一段说明(changelog 内容)
2️⃣ changeset 文件长什么样?
.changeset/sweet-ducks-sleep.md(名字随机)
---
"@org/utils": patch
"@org/ui": minor
---
- utils 修复了 xxx 问题
- ui 新增了 Button 的 yyy 能力这就是“变更日志的源头”。
3️⃣ changeset 文件的本质
你可以把它理解为:“一次发布意图的声明文件”
它不会:
- 改版本号
- 改 package.json
- 发布包
它只是一个账本记录。
自动递增版本
1️⃣ 核心命令
pnpm changeset version2️⃣ 这个命令会做什么?
它会:
- 读取
.changeset/*.md - 计算每个包的下一个版本号
- 更新各包的
package.json version - 更新内部依赖的版本范围(如
workspace:* → ^x.y.z) - 生成 / 更新每个包的
CHANGELOG.md - 删除已消费的 changeset 文件
changeset 文件在这里“被结算”了。
3️⃣ 版本递增规则来源
完全来源于你当初写 changeset 时选的:
- patch
- minor
- major
Changesets 不会猜,也不会推断 commit message。
发布阶段
1️⃣ 发布命令
pnpm changeset publish2️⃣ 它会发布哪些包?
只会发布:
- 非
private: true - 本地 version > npm 上已存在 version
- 满足 access 权限的包
它不会重复发布相同版本。
推荐 scripts
根目录 package.json:
{
"scripts": {
"changeset": "changeset",
"version-packages": "changeset version",
"release": "changeset publish"
}
}常见用法:
pnpm changeset # 记录变更
pnpm version-packages # 生成版本 & changelog
pnpm release # 发布总结:Changesets 是为 Monorepo 设计的“版本与日志账本系统”。它通过显式记录每一次变更意图,来可靠地自动递增版本号、同步内部依赖,并生成可维护的 changelog。
发布注意
@scope/* 里的 scope 必须是 npm 上存在的「用户」或「组织」,并且你对它有发布权限,不是看你登录账号叫啥,而是看你有没有权限往这个 scope 里发包。
你想发布包名:@notespace/utils
这意味着:你要往 @notespace 这个 scope 发布。那 npm 上必须存在 notespace 这个“主体”:
- 如果 npm 上有一个用户名叫
notespace:那它就是“用户 scope” - 或者 npm 上有一个 organization 叫
notespace:那它就是“组织 scope”
而你当前用户名是 yumengjh,所以:
- 你可以发布:
@yumengjh/utils(用户 scope 就是你的用户名) - 你也可以发布:
@notespace/utils,前提是你创建(或加入)一个叫notespace的 npm organization,并且你有权限发布 - 你不能凭空发布到一个不存在/你没权限的 scope(会报类似 404/权限问题)
如果你坚持包名必须是 @notespace/utils:是的,通常就得建一个 npm org 叫 notespace(或者这个名字已经被别人占了,那你得换 scope)。
如果你不在意 scope 名字:那就直接用 @yumengjh/utils 最省事:这里跟“项目名 notespace”没强绑定关系:项目叫 notespace ≠ npm scope 自动叫 notespace。
包名与发布关系
在一个 Monorepo 项目里,至少会同时存在三种“名字”,它们彼此独立:
1️⃣ 仓库根目录的 package.json name
- 这是项目名 / 仓库名
- 只用于:标识这个仓库,工具显示(pnpm、turbo、IDE)
- 不会决定 npm 发布名
- 不会自动变成 npm scope
项目叫 notespace ≠ npm 上自动有 @notespace/*
2️⃣ 子包的 package.json name(真正的 npm 包名)
- 这个 name 才是 npm 上发布的真实包名
- publish 时:发布的就是这个名字,不能在命令行“指定发布成另一个名字”
- npm 发布名 100% 由子包的
name字段决定
3️⃣ npm 账号名 / organization 名
- npm 上存在两类 scope:
- 用户 scope:
@用户名/* - 组织 scope:
@组织名/*
- 用户 scope:
要发布 @scope/pkg,必须满足:
- npm 上存在这个
scope - 你对它有 publish 权限