一、目标架构与工具职责
本文构建一个 pnpm Workspace + Turborepo + Changesets 的 TypeScript Monorepo:pnpm 管依赖和工作区协议,Turborepo 管任务图与缓存,Changesets 管版本意图、Changelog 和发布。三者职责不能混淆。
apps/web -----+
apps/admin ---+--> packages/ui --> packages/tokens
services/api -+--> packages/config
|
v
Turborepo task graph -> local/remote cache -> CI
Changesets -> version PR -> build/pack -> registry publish
二、初始化与版本固定
当前 pnpm 文档以 pnpm-workspace.yaml 为工作区入口;Changesets 新版要求较新的 Node/pnpm。生产仓库通过 packageManager、Corepack 和 lockfile 固定版本。
mkdir platform && cd platform
corepack enable
pnpm init
pnpm add -Dw turbo typescript vitest eslint @changesets/cli
pnpm changeset init
{
"name": "@acme/platform",
"private": true,
"packageManager": "pnpm@12.7.0",
"engines": {"node": ">=24"},
"scripts": {
"build": "turbo run build",
"test": "turbo run test",
"lint": "turbo run lint",
"typecheck": "turbo run typecheck"
}
}
版本仅为示例,落地时应选择团队验证过的具体版本并提交 pnpm-lock.yaml。
三、定义 Workspace 和 Catalog
# pnpm-workspace.yaml
packages:
- apps/*
- services/*
- packages/*
catalog:
typescript: ^6.0.0
vitest: ^4.0.0
sharedWorkspaceLockfile: true
disallowWorkspaceCycles: true
内部依赖使用 workspace:,确保本地缺包时安装直接失败,而不是意外从公共 registry 下载同名包。
{
"name": "@acme/web",
"dependencies": {
"@acme/ui": "workspace:^"
},
"devDependencies": {
"typescript": "catalog:"
}
}
pnpm install --frozen-lockfile
pnpm list -r --depth 0
pnpm --filter @acme/web why @acme/ui
四、为包建立清晰边界
共享包必须有明确入口和 exports,禁止应用跨目录引用源码内部路径。每个包只声明实际使用的依赖。
{
"name": "@acme/ui",
"version": "0.1.0",
"type": "module",
"exports": {
".": {"types": "./dist/index.d.ts", "import": "./dist/index.js"},
"./button": {"types": "./dist/button.d.ts", "import": "./dist/button.js"}
},
"files": ["dist"],
"scripts": {
"build": "tsc -p tsconfig.build.json",
"test": "vitest run",
"typecheck": "tsc --noEmit"
}
}
pnpm --filter @acme/ui build
pnpm --filter @acme/web... test
pnpm --filter ...@acme/web lint
省略号过滤语法需由团队文档统一,CI 中先用 --dry-run 确认影响范围。
五、配置 Turborepo 任务图
声明依赖和真实输出
dependsOn: ["^build"] 表示先构建依赖包。outputs 必须覆盖真实产物,否则缓存命中后目录不完整;无产物任务不要伪造输出。
{
"$schema": "https://turborepo.com/schema.json",
"ui": "tui",
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["$TURBO_DEFAULT$", ".env.example"],
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]
},
"test": {
"dependsOn": ["build"],
"outputs": ["coverage/**"]
},
"lint": {"outputs": []},
"typecheck": {"dependsOn": ["^build"], "outputs": []},
"dev": {"cache": false, "persistent": true}
}
}
pnpm turbo run build --dry=json > .artifacts/task-graph.json
pnpm turbo run build --summarize
pnpm turbo run test --filter='...[origin/main]'
六、让缓存正确而不是只追求命中率
所有隐式输入都要进入哈希
缓存键必须包含所有影响输出的源码、配置、环境变量和依赖版本。缺少环境变量会产生“命中但结果错误”的危险缓存。
{
"globalEnv": ["NODE_ENV"],
"globalPassThroughEnv": ["CI"],
"tasks": {
"build": {
"env": ["PUBLIC_API_BASE_URL"],
"passThroughEnv": ["SENTRY_AUTH_TOKEN"]
}
}
}
Token 只透传,不应成为日志或产物。缓存恢复后运行轻量验收,例如确认 manifest、bundle 和 source map 策略。
rm -rf apps/web/dist
pnpm turbo run build --filter=@acme/web
test -f apps/web/dist/manifest.json
pnpm turbo run build --filter=@acme/web
第二次应显示 cache hit,且产物可运行。
七、远程缓存与信任边界
远程缓存能在 CI/开发者间共享产物,但缓存写权限等同于构建供应链权限。使用短期凭据、受保护分支写入、PR 只读或隔离命名空间,并验证签名能力。
pnpm turbo login
pnpm turbo link
TURBO_TOKEN="$TURBO_TOKEN" TURBO_TEAM="$TURBO_TEAM" \
pnpm turbo run build
不要在 fork PR 暴露写 Token。发生依赖或构建器安全事件时,应具备按 scope 清理远程缓存和强制无缓存重建的流程。
八、Changesets 记录发布意图
每个影响公开包的变更在 PR 内创建 changeset,明确 major/minor/patch 和面向用户的描述。纯测试、内部重构若不影响发布可不创建,避免用空文件满足门禁。
pnpm changeset
pnpm changeset status
---
"@acme/ui": minor
"@acme/web": patch
---
为 Button 新增 loading 状态,并升级 Web 应用以使用新属性。
九、配置版本与内部依赖传播
{
"$schema": "https://unpkg.com/@changesets/config/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [],
"access": "restricted",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": ["@acme/web", "@acme/admin"]
}
公开包需显式 access: public 或包级 publishConfig.access。私有应用放入 ignore 前要确认它不会和发布包一起出现在同一个 changeset,避免发布失败。
十、CI:安装、影响分析和验证
name: verify
on: [pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: {fetch-depth: 0}
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm turbo run lint typecheck test build --filter='...[origin/main]'
- run: pnpm changeset status --since=origin/main
Actions 应固定到完整 commit SHA 以降低供应链风险;这里用标签便于阅读。CI 需要完整基准分支历史,否则 changed filter 会误判。
十一、版本 PR 与可信发布
先验证发布包再推送
推荐由机器人维护一个版本 PR:合并普通 changeset 后更新版本与 changelog;合并版本 PR 后执行 build、pack、内容检查和 publish。优先使用 registry trusted publishing/OIDC,避免长期 npm token。
pnpm changeset version
pnpm install --lockfile-only
pnpm -r build
pnpm -r test
pnpm -r pack --pack-destination .artifacts/packs
tar -tf .artifacts/packs/acme-ui-*.tgz | sort
pnpm changeset publish
git push --follow-tags
发布前检查 tarball 不含 .env、测试密钥、源码之外的内部文件;files 和 .npmignore 都要测试。
十二、循环依赖和幽灵依赖排障
pnpm 的严格依赖隔离能暴露未声明依赖。不要通过提升所有包到根依赖掩盖问题。
pnpm install --frozen-lockfile
pnpm -r exec node -p "require.resolve('typescript/package.json')"
pnpm list -r --depth Infinity > .artifacts/dependency-tree.txt
pnpm why some-package -r
工作区出现 cycle 警告时,提取共享类型到更低层包,或通过接口反转依赖;不要设置 ignoreWorkspaceCycles=true 后继续发布。
十三、缓存故障与可复现诊断
缓存异常时比较两次构建输入摘要、环境、lockfile 和产物哈希。先本地 --force 重建,再隔离远程缓存,避免直接删除全团队缓存。
pnpm turbo run build --force --summarize
find apps packages -path '*/dist/*' -type f -print0 | sort -z | xargs -0 sha256sum \
> .artifacts/dist-sha256.txt
git status --short
相同提交、相同环境产生不同哈希时,检查时间戳、随机数、绝对路径、网络下载和未声明环境变量。
十四、验收与官方资料
验收:干净 clone 可 frozen install;工作区依赖使用 workspace:;循环依赖阻断;任务图顺序正确;删除产物后缓存可完整恢复;环境变化会导致正确 miss;PR 不泄露缓存写凭据;Changeset 能正确传播内部版本;pack 内容最小;发布有回滚和 deprecate 流程。
- pnpm Workspace 与 workspace protocol 官方文档。
- Turborepo Caching、Task Configuration 与 CI 官方文档。
- Changesets Getting Started、Configuration、Versioning/Publishing 与 Automation 文档。
性能收益只有在构建仍然确定、缓存边界可信、发布物经过内容检查时才有价值。