第 8 章:构建与发布
这一章讲 sag-icons 怎么从源码变成 npm 包。
读完这一章你会知道:
pnpm build:lib 的双步流水线每步做了什么
dist/ 目录里有什么、为什么这么组织
pnpm pack 本地验证产物
npm publish 怎么发版
- 怎么补 sag-icons 当前的 CSS 单独导出小坑
8.1pnpm build:lib 的完整流程
pnpm build:lib
# = tsc -p tsconfig.build.json && vite build
这是 sag-icons 的库构建命令。文档站构建(pnpm build)走另一条命令,不会进 npm 包。
第一步:tsc -p tsconfig.build.json
tsc -p tsconfig.build.json
跑 tsconfig.build.json(上一章讲过),产类型 + 模块化 JS:
dist/
index.js # 多文件 ESM(tsc 直接编译产物)
index.d.ts
data/
categories.d.ts
categories.js
icons/.../*.d.ts
icons/.../*.js
index.d.ts
index.js
labels.d.ts
labels.js
css/
svg-icon.d.ts
svg-icon.js
web-components/
register.d.ts
register.js
sag-icon.d.ts
sag-icon.js
tsc 只是把每个 .ts 编译成 .js,不打 bundle。每个文件独立。
第二步:vite build
跑 vite.config.ts,把 dist/index.js 打成一个 bundle:
vite.config.ts
build: {
emptyOutDir: false, // ← 关键
lib: {
entry: resolve(__dirname, "src/index.ts"),
formats: ["es"],
fileName: () => "index.js",
},
}
emptyOutDir: false 让 Vite 不清空 dist/——保留 tsc 产的所有 .d.ts,只覆盖 index.js。
最终 dist 长这样:
dist/
index.js # vite bundle(包含全部依赖)
index.js.map # sourcemap
index.d.ts # tsc 类型(保留)
data/... # tsc 编译产物(保留)
css/... # tsc 编译产物(保留)
web-components/... # tsc 编译产物(保留)
用户 import "@jujiuyey/sag-icons" 走 index.js,被打包器 tree-shake。
8.2 dist 的产物解析
sag-icons 当前 dist/ 结构:
dist/
├── index.js ← 主入口(vite bundle)
├── index.js.map ← sourcemap
├── index.d.ts ← 类型声明
├── data/
│ ├── categories.d.ts
│ ├── categories.js
│ ├── icons/... ← 各分类图标
│ ├── index.d.ts
│ ├── index.js
│ ├── labels.d.ts
│ └── labels.js
├── css/
│ ├── svg-icon.d.ts
│ └── svg-icon.js ← CSS 字符串 export
└── web-components/
├── register.d.ts
├── register.js
├── sag-icon.d.ts
└── sag-icon.js
实际目录结构:dist/
index.js —— 主入口
这是 vite 打出来的 bundle。所有 78 个图标字符串 + CSS 字符串 + Web Component 类 + 入口副作用都在这一个文件里。
用户 import "@jujiuyey/sag-icons" 只拉这一个文件。
data/icons/... —— 按需导入路径
每个图标是独立模块(虽然 tsc 编译后已经被 vite 静态分析)。理论上用户可以:
import { workbench } from "@jujiuyey/sag-icons/data/icons/nav/workbench";
但需要 package.json 的 exports 配子路径(第 7 章讲过)。
web-components/ —— 内部模块
sag-icon.ts 和 register.ts 是 tsc 编译产物,vite bundle 已经把它们打进了 index.js。这两个文件保留在 dist 是为了让 tsc --noEmit 用户能查类型(虽然这种情况下 type 已经在 index.d.ts 里了)。
实战中这些多文件产物主要是 tsc 编译时自然产生的,sag-icons 没特意清理。如果你追求极致精简,可以在 vite.config.ts 加 emptyOutDir: true(会清掉 .d.ts,需要在 vite 阶段单独处理类型)。
8.3 本地验证:pnpm pack
发布到 npm 前,建议本地打包一次模拟用户实际拿到的东西:
pnpm pack
# → 生成 sag-icons-0.0.1.tgz
然后在另一个项目里装这个 tarball:
# 假设在 ~/Projects/test-sag
cd ~/Projects/test-sag
pnpm add /Users/jujiuyey/Projects/sag-icons/sag-icons-0.0.1.tgz
或者建一个临时目录做 monkey test:
mkdir /tmp/sag-test && cd /tmp/sag-test
npm init -y
npm install /path/to/sag-icons-0.0.1.tgz
# 写个最简单的测试
cat > test.js <<'EOF'
import "@jujiuyey/sag-icons";
const el = document.createElement("sag-icon");
el.setAttribute("name", "workbench");
document.body.appendChild(el);
EOF
# 用 vite 起一个 dev server
npx vite
打开浏览器看 <sag-icon name="workbench"> 是否正确渲染。
检查清单
pnpm pack 后解包 tar -tzf sag-icons-0.0.1.tgz 应该看到:
- ✅
package/dist/index.js
- ✅
package/dist/index.d.ts
- ✅
package/dist/data/...
- ✅
package/package.json
- ✅
package/README.md
- ❌ 不应该有
package/src/(源码泄漏)
- ❌ 不应该有
package/docs/(文档站泄漏)
- ❌ 不应该有
package/node_modules/
files: ["dist"] 字段保证了只发 dist/ 目录。但 README.md 和 package.json 总是会被 npm 自动包含。
8.4 发版:npm publish
# 1. 确认版本号
vim package.json
# 改 "version": "0.0.1" → "0.0.2"
# 2. 跑构建
pnpm build:lib
# 3. 本地验证
pnpm pack
# 检查产物结构
# 4. 发版
npm publish
# scoped 包第一次发需要:
npm publish --access public
版本号规则
sag-icons 用 semver:
- 0.0.x —— 还没到 1.0,频繁改
- 0.1.0 —— 第一次"能用的版本"
- 1.0.0 —— API 稳定
- 1.x.y —— 修复 + 小特性
现在 sag-icons 是 0.0.1,还没到 1.0。这意味着 API 可能变。
1.0 之前,npm 文档建议每次发版都增加 minor 号。
changelog
sag-icons 当前没有强制 changelog 流程。如果你做严肃的开源库,建议加 changesets 或 release-it:
pnpm add -D @changesets/cli
pnpm changeset init
每次改动写一个 changeset:
pnpm changeset
# → "添加 workbench 图标"
发版时:
pnpm changeset version # 自动更新版本号
pnpm publish
8.5 子路径配置:补 sag-icons 的小坑
第 7 章提过,sag-icons 当前 exports 字段只配了根路径。这意味着用户不能这样用:
// ❌ 当前不支持(应该工作但不工作)
import { workbench } from "@jujiuyey/sag-icons/data/icons/nav/workbench";
import "@jujiuyey/sag-icons/css/svg-icon.css";
要补这个坑,需要:
步骤 1:扩展package.json 的exports
package.json
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./css": "./dist/svg-icon.css",
"./css/svg-icon.css": "./dist/svg-icon.css",
"./data": "./dist/data/index.js",
"./data/*": "./dist/data/*"
}
子路径通配符 ./data/* 把每个图标的子路径都开放:
import { workbench } from "@jujiuyey/sag-icons/data/icons/nav/workbench"; // ✅
import { ICON_PATHS } from "@jujiuyey/sag-icons/data"; // ✅
步骤 2:让 vite 拷 CSS 文件
vite.config.ts 当前没配 CSS 处理。需要加一个 plugin:
vite.config.ts
import { defineConfig } from "vite";
import { resolve } from "path";
import { copyFileSync, mkdirSync, existsSync } from "fs";
function copyCssPlugin() {
return {
name: "copy-css",
closeBundle() {
const src = resolve(__dirname, "src/css/svg-icon.css");
const distDir = resolve(__dirname, "dist");
if (!existsSync(distDir)) mkdirSync(distDir, { recursive: true });
copyFileSync(src, resolve(distDir, "svg-icon.css"));
},
};
}
export default defineConfig({
server: { open: "/preview.html" },
build: {
emptyOutDir: false,
lib: {
entry: resolve(__dirname, "src/index.ts"),
name: "SagIcons",
formats: ["es"],
fileName: () => "index.js",
},
sourcemap: true,
minify: false,
},
plugins: [copyCssPlugin()],
});
这只是示例。生产环境可以用 rollup-plugin-copy 之类。
步骤 3:让 tsc 也处理.css 导出
src/css/svg-icon.ts 当前导出字符串(被运行时注入用)。要让它也能作为 CSS 文件存在,可以:
- 保留字符串导出(运行时用)
- 额外提供
src/css/svg-icon.css 原始文件
这两个其实是重复的——sag-icons 现在就是重复:
src/css/svg-icon.css ← 原始 CSS 文件
src/css/svg-icon.ts ← 把 CSS 包成字符串导出
对应文件:src/css/svg-icon.css、src/css/svg-icon.ts
如果你不想维护两份,可以做个 build script 从 .ts 抽出字符串生成 .css:
scripts/extract-css.mjs
import fs from "node:fs";
import path from "node:path";
const ts = fs.readFileSync("src/css/svg-icon.ts", "utf-8");
const match = ts.match(/= `([\s\S]*?)`;/);
if (!match) throw new Error("CSS template literal not found");
fs.writeFileSync("src/css/svg-icon.css", match[1]);
加进 pnpm build:lib:
"build:lib": "node scripts/extract-css.mjs && tsc -p tsconfig.build.json && vite build"
这样维护一份 .ts,build 时自动生成 .css。
8.6 产物大小
sag-icons 整包(unminified,vite 产物):
index.js:约 25KB
index.js.map:约 30KB
- 所有
.d.ts:约 10KB
加 sourcemap 约 65KB。不加 sourcemap、minify 之后:
实际数字以 pnpm pack 后为准。
这是一个非常小的包——78 个图标 + Web Component runtime + CSS 字符串,总共 ~6KB gzip。
8.7 文档站构建(顺带讲一下)
sag-icons 有文档站,构建产物走 pnpm build(不是 pnpm build:lib):
"scripts": {
"dev": "rspress dev",
"build": "rspress build"
}
文档站产物在 doc_build/ 目录(项目里现有的)。不会进 npm 包(files: ["dist"] 不包含 doc_build/)。
对应文件:package.json:19-22、doc_build/
文档站部署是另一条 pipeline——Rspress 输出静态文件 → GitHub Actions → GitHub Pages。这套流程不在 npm 包的范围内,不在教程深入。
8.8 避坑清单
发版前必跑的三步验证:
# 1. 类型检查
pnpm typecheck
# 2. 构建产物
pnpm build:lib
# 3. 本地打包检查
pnpm pack
tar -tzf sag-icons-*.tgz | head -30
如果其中任何一步报错或产物异常,不要发。
常见事故
- ❌ 忘了
pnpm build:lib 就发版 → 用户装的还是旧版本
- ❌ 改了
tsconfig.json 影响 .d.ts 但没重 build → TS 类型错乱
- ❌
files: ["dist"] 漏了某新加的文件类型 → 用户 import 找不到
- ❌ 改了版本号没改 changelog → 用户不知道改了什么
安全发版 checklist
8.9 这一章的产出
到这里你应该能:
- 解释
pnpm build:lib 的双步流水线
- 解释
dist/ 的目录结构和每个文件的作用
- 跑
pnpm pack 验证产物
- 知道
npm publish 的流程
- 知道怎么补
exports 子路径和 CSS 单独导出
下一章(最后一章)讲发布后迭代——加一个新图标的 checklist、避坑、后续方向。
对应文件
| 内容 | 路径 |
|---|
| 构建脚本 | package.json:19-22 |
| 双步构建 | tsconfig.build.json + vite.config.ts |
| 产物目录 | dist/ |
| CSS 字符串 | src/css/svg-icon.ts |
| CSS 原始文件 | src/css/svg-icon.css |