第 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 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 之后:

  • index.js:约 6KB

实际数字以 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

  • pnpm typecheck 通过
  • pnpm build:lib 通过
  • pnpm pack 检查产物结构(无 src/、无 docs/)
  • 在测试项目里装 tarball 跑一遍
  • 版本号符合 semver
  • 提交 git tag(推荐)

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