这份文档面向前端小白 和想自己造组件库的开发者 。 它不只是讲"@ui-lib/core 是怎么做的",而是讲清楚:任何一个 Vue 3 组件库,应该如何被一步步从零构建起来,直到生产发布。
读完它,你应该能:
- 看懂任何成熟组件库(Element Plus / Naive UI / Arco)的源码结构
- 自己从一个空目录搭一个能发 npm 的组件库
- 知道每一行配置"为什么这么写"
阅读建议 :不要跳读。架构 是地基,组件 是上层建筑,构建发布 是收尾,进阶是装修。顺序读完一遍胜过反复翻看任何一节。
目录
- [第 0 章 序言:什么是组件库,为什么自己造一个](#第 0 章 序言:什么是组件库,为什么自己造一个 "#%E7%AC%AC-0-%E7%AB%A0-%E5%BA%8F%E8%A8%80%E4%BB%80%E4%B9%88%E6%98%AF%E7%BB%84%E4%BB%B6%E5%BA%93%E4%B8%BA%E4%BB%80%E4%B9%88%E8%87%AA%E5%B7%B1%E9%80%A0%E4%B8%80%E4%B8%AA")
- [第 1 章 心智模型:组件库 vs 业务项目](#第 1 章 心智模型:组件库 vs 业务项目 "#%E7%AC%AC-1-%E7%AB%A0-%E5%BF%83%E6%99%BA%E6%A8%A1%E5%9E%8B%E7%BB%84%E4%BB%B6%E5%BA%93-vs-%E4%B8%9A%E5%8A%A1%E9%A1%B9%E7%9B%AE")
- [第 2 章 架构设计:目录、分层、模块边界](#第 2 章 架构设计:目录、分层、模块边界 "#%E7%AC%AC-2-%E7%AB%A0-%E6%9E%B6%E6%9E%84%E8%AE%BE%E8%AE%A1%E7%9B%AE%E5%BD%95%E5%88%86%E5%B1%82%E6%A8%A1%E5%9D%97%E8%BE%B9%E7%95%8C")
- [第 3 章 工程脚手架:从空目录到 hello world](#第 3 章 工程脚手架:从空目录到 hello world "#%E7%AC%AC-3-%E7%AB%A0-%E5%B7%A5%E7%A8%8B%E8%84%9A%E6%89%8B%E6%9E%B6%E4%BB%8E%E7%A9%BA%E7%9B%AE%E5%BD%95%E5%88%B0-hello-world")
- [第 4 章 组件设计:从 Button 开始的六步法](#第 4 章 组件设计:从 Button 开始的六步法 "#%E7%AC%AC-4-%E7%AB%A0-%E7%BB%84%E4%BB%B6%E8%AE%BE%E8%AE%A1%E4%BB%8E-button-%E5%BC%80%E5%A7%8B%E7%9A%84%E5%85%AD%E6%AD%A5%E6%B3%95")
- [第 5 章 主题系统:SCSS + CSS Variables 双层架构](#第 5 章 主题系统:SCSS + CSS Variables 双层架构 "#%E7%AC%AC-5-%E7%AB%A0-%E4%B8%BB%E9%A2%98%E7%B3%BB%E7%BB%9Fscss--css-variables-%E5%8F%8C%E5%B1%82%E6%9E%B6%E6%9E%84")
- [第 6 章 横切关注点:hooks / utils / locale / ConfigProvider](#第 6 章 横切关注点:hooks / utils / locale / ConfigProvider "#%E7%AC%AC-6-%E7%AB%A0-%E6%A8%AA%E5%88%87%E5%85%B3%E6%B3%A8%E7%82%B9hooks--utils--locale--configprovider")
- [第 7 章 构建产物:从源码到 npm 包的 7 个秘密](#第 7 章 构建产物:从源码到 npm 包的 7 个秘密 "#%E7%AC%AC-7-%E7%AB%A0-%E6%9E%84%E5%BB%BA%E4%BA%A7%E7%89%A9%E4%BB%8E%E6%BA%90%E7%A0%81%E5%88%B0-npm-%E5%8C%85%E7%9A%84-7-%E4%B8%AA%E7%A7%98%E5%AF%86")
- [第 8 章 按需引入:tree-shaking 的真正原理](#第 8 章 按需引入:tree-shaking 的真正原理 "#%E7%AC%AC-8-%E7%AB%A0-%E6%8C%89%E9%9C%80%E5%BC%95%E5%85%A5tree-shaking-%E7%9A%84%E7%9C%9F%E6%AD%A3%E5%8E%9F%E7%90%86")
- [第 9 章 文档与 playground:让用户用得起来](#第 9 章 文档与 playground:让用户用得起来 "#%E7%AC%AC-9-%E7%AB%A0-%E6%96%87%E6%A1%A3%E4%B8%8E-playground%E8%AE%A9%E7%94%A8%E6%88%B7%E7%94%A8%E5%BE%97%E8%B5%B7%E6%9D%A5")
- [第 10 章 测试与质量保障](#第 10 章 测试与质量保障 "#%E7%AC%AC-10-%E7%AB%A0-%E6%B5%8B%E8%AF%95%E4%B8%8E%E8%B4%A8%E9%87%8F%E4%BF%9D%E9%9A%9C")
- [第 11 章 发布到 npm:版本、CHANGELOG、CI](#第 11 章 发布到 npm:版本、CHANGELOG、CI "#%E7%AC%AC-11-%E7%AB%A0-%E5%8F%91%E5%B8%83%E5%88%B0-npm%E7%89%88%E6%9C%ACchangelogci")
- [第 12 章 进阶专题:函数式组件、SSR、a11y、性能](#第 12 章 进阶专题:函数式组件、SSR、a11y、性能 "#%E7%AC%AC-12-%E7%AB%A0-%E8%BF%9B%E9%98%B6%E4%B8%93%E9%A2%98%E5%87%BD%E6%95%B0%E5%BC%8F%E7%BB%84%E4%BB%B6ssra11y%E6%80%A7%E8%83%BD")
- [第 13 章 反模式清单:这些坑请绕开](#第 13 章 反模式清单:这些坑请绕开 "#%E7%AC%AC-13-%E7%AB%A0-%E5%8F%8D%E6%A8%A1%E5%BC%8F%E6%B8%85%E5%8D%95%E8%BF%99%E4%BA%9B%E5%9D%91%E8%AF%B7%E7%BB%95%E5%BC%80")
- [附录 A:从零搭一个 mini 组件库的 30 分钟手把手](#附录 A:从零搭一个 mini 组件库的 30 分钟手把手 "#%E9%99%84%E5%BD%95-a%E4%BB%8E%E9%9B%B6%E6%90%AD%E4%B8%80%E4%B8%AA-mini-%E7%BB%84%E4%BB%B6%E5%BA%93%E7%9A%84-30-%E5%88%86%E9%92%9F%E6%89%8B%E6%8A%8A%E6%89%8B")
- [附录 B:本项目核心文件速查](#附录 B:本项目核心文件速查 "#%E9%99%84%E5%BD%95-b%E6%9C%AC%E9%A1%B9%E7%9B%AE%E6%A0%B8%E5%BF%83%E6%96%87%E4%BB%B6%E9%80%9F%E6%9F%A5")
- [附录 C:延伸阅读](#附录 C:延伸阅读 "#%E9%99%84%E5%BD%95-c%E5%BB%B6%E4%BC%B8%E9%98%85%E8%AF%BB")
第 0 章 序言:什么是组件库,为什么自己造一个
0.1 一句话定义
组件库 = 一组可复用的、可发布到 npm 的、有统一设计语言的 UI 构件。
业务项目里的"几个 .vue 文件"不是组件库,因为它:
- 没有独立的发布版本
- 没有跨项目复用的封装边界
- 没有文档、没有 props 类型导出、没有 tree-shaking 保证
把"几个 .vue 文件"升格成组件库,要补的就是这些东西。本文档每一章都对应补一种东西。
0.2 为什么自己造,而不是直接用 Element Plus
理由有三档,强弱依次递减:
| 档位 | 理由 | 适用人群 |
|---|---|---|
| 强理由 | 公司有独特设计语言、需要私有组件库支撑产品矩阵 | 大中厂、设计驱动型团队 |
| 中理由 | 学习目的,想搞懂底层 | 进阶前端工程师 |
| 弱理由 | "我觉得 Element 太重了" | 注意:这个理由有 80% 的概率是错觉,不要冲动开坑 |
如果只是弱理由,先在业务项目里抽出 3--5 个组件试试再说 。组件库真正难的不是写出第一个 Button,而是把 50 个组件保持一致的设计语言、统一的 API 风格、稳定的版本节奏。
0.3 本指南采用什么参考实现
本指南基于本仓库 @ui-lib/core (d:/opencode/dev/ui-lib),它的特征:
- Vue 3 + TypeScript +
<script setup> - 单包(非 monorepo)发布
- Vite 库模式构建
- SCSS + CSS Variables 主题
- 已实现 10+ 核心组件,可作完整对照
凡是文档里说"参见 src/xxx"的,你都可以直接打开对照阅读。
第 1 章 心智模型:组件库 vs 业务项目
这一章不写代码,只校准思维方式。很多人组件库做不好,不是技术问题,是没切换心智。
1.1 五个根本差异
| 维度 | 业务项目 | 组件库 |
|---|---|---|
| 用户 | 最终用户(产品使用者) | 其他前端工程师 |
| 打包 | 打成一个 bundle 部署 | 打成 npm 可分发的多文件产物 |
| 依赖 | 想装什么装什么 | 必须把 Vue 列为 peerDependencies,不能内嵌 |
| 耦合 | 业务可以横向耦合 | 组件之间必须解耦,任意组合可工作 |
| API 稳定性 | 改完就上线 | 一旦发布出去,改 API 等于 break 别人的项目 |
1.2 三条戒律
戒律 1:不要把业务逻辑写进组件库。 组件库只关心 "UI 怎么呈现"、"事件怎么触发",不关心 "提交订单时调用哪个接口"。后者是业务的事。 如果发现某个组件耦合了具体业务,把那部分逻辑作为 prop / event 暴露出来,让调用方传入。
戒律 2:不要在组件库里直接 import 'axios'、import 'pinia'。 任何依赖都会变成用户的负担。除非必要(如 dayjs 用于 DatePicker),否则让用户传入 或让用户自己实现。
戒律 3:每个公共 API 都是契约,改它要付迁移成本。 组件库发布出去后,你新增 prop 是兼容的,但改 prop 名字、删 event、改默认值 都会让用户升级时痛苦。所以第一版设计要慢,不要急着上。
1.3 一个例子:为什么 Button 看起来简单,做起来不简单
写一个 <button> 标签 30 秒。写一个生产级的 <UiButton> 要考虑:
- type(default/primary/success/warning/danger/info)
- size(small/medium/large)
- 状态:disabled / loading / round / plain
- 自定义颜色 / 图标插槽 / 完整 a11y
- 点击事件、自定义 native attrs 透传
- 主题切换、暗色模式
- SSR 安全(不能依赖 window)
- 类型导出(用户能
import type { ButtonProps }) - 单元测试覆盖核心交互
- 文档示例可被复制运行
这就是"业务里抽组件"和"组件库做组件"的差距。前者写了能用就行;后者要做"所有用户场景都能用"。
第 2 章 架构设计:目录、分层、模块边界
2.1 顶层目录的"四方分立"
任何 Vue 组件库都可以拆成四块:
css
ui-lib/
├── src/ ① 库源码 → 会被打包,发布到 npm
├── playground/ ② 调试场 → 不发布,本地热更新调样式
├── docs/ ③ 文档站 → 不发布到 npm,但要部署到网站
└── scripts/ ④ 工具脚本 → 不发布,辅助构建
为什么必须分这四块?
src/干净:只放发布的东西,产物可预测playground/是"破坏性试验场",写新组件随便玩,不污染源码docs/给用户看,展示 API 与示例scripts/隔离一次性脚本(批量生成、CSS 编译等)
本仓库结构完整对照见 运行指南.md §3。
2.2 src/ 内部的分层
这是最关键的一步。新手最容易犯的错是把所有东西堆 components/ 里。正确的分层:
scss
src/
├── components/ 组件本体 (一个组件一个目录)
├── hooks/ 跨组件复用的逻辑 (useNamespace, useZIndex 等)
├── utils/ 纯函数工具 (withInstall, dom 判断等)
├── locale/ i18n 语言包
├── config-provider/ 全局上下文组件 (ConfigProvider)
├── theme/ SCSS 主题与变量
└── index.ts 库总入口
每一层有明确职责 和依赖方向:
bash
index.ts
↑
components/ ←── 依赖 hooks/utils/locale/theme
↑
hooks/ ←── 只依赖 utils
↑
utils/ ←── 零依赖,纯函数
依赖方向单向、自底向上。utils 不能反向 import components,否则会形成循环依赖,构建会爆炸。
2.3 单个组件目录的"5 件套"
每个组件目录长这样:
bash
components/button/
├── Button.vue # SFC 单文件组件
├── button.scss # 样式 (BEM 命名)
├── types.ts # Props / Emits TypeScript 类型
├── index.ts # withInstall 包装的出口
└── __tests__/Button.test.ts # 单元测试
为什么必须 5 件套,不能合并?
Button.vue和button.scss分开:样式独立编译成 CSS,支持按需引入(见[第 8 章](#第 8 章 "#%E7%AC%AC-8-%E7%AB%A0%E6%8C%89%E9%9C%80%E5%BC%95%E5%85%A5tree-shaking-%E7%9A%84%E7%9C%9F%E6%AD%A3%E5%8E%9F%E7%90%86"))types.ts独立:用户可以import type { ButtonProps } from '@ui-lib/core/components/button/types',IDE 提示更友好index.ts独立:这是"对外的门面",负责导出 +withInstall包装__tests__/与组件同目录:测试和代码靠近,改组件时容易找到对应测试
参考实现:src/components/button/。
2.4 模块边界的"三个不要"
新手设计模块时,记住三条:
- 不要让
utils/引用 Vue 的 reactivity (ref、reactive)。utils 是纯函数,需要 reactivity 的逻辑放hooks/。 - 不要让组件直接读全局变量 (
window.someConfig)。所有运行时配置走ConfigProvider注入。 - 不要让
locale/内部出现具体组件名。语言包是数据,组件是消费者,反过来会形成倒挂依赖。
第 3 章 工程脚手架:从空目录到 hello world
这一章手把手搭一个最小可运行的组件库骨架。所有命令都假定你在 Windows + Git Bash 环境(macOS/Linux 同样适用)。
3.1 环境前提
| 工具 | 版本要求 | 检查命令 |
|---|---|---|
| Node.js | ≥ 18(推荐 20 LTS) | node -v |
| pnpm | ≥ 9 | pnpm -v |
| Git | 任意现代版本 | git --version |
详细环境准备见 运行指南.md §1。
3.2 初始化项目
bash
mkdir my-ui-lib && cd my-ui-lib
pnpm init
git init
3.3 安装核心依赖
bash
# 运行时依赖 (会进入用户 node_modules)
pnpm add vue # 注意:实际要标记为 peerDependency,见 §3.5
# 开发依赖 (构建工具链)
pnpm add -D typescript vite @vitejs/plugin-vue vue-tsc \
vite-plugin-dts sass \
@types/node \
vitest @vue/test-utils happy-dom
每个包的作用:
| 包 | 用途 |
|---|---|
vue |
组件库的运行时,后面要改成 peer |
typescript |
TS 编译器 |
vite |
构建工具 |
@vitejs/plugin-vue |
让 Vite 能处理 .vue 文件 |
vue-tsc |
Vue 版的 tsc,生成 .d.ts 时用到 |
vite-plugin-dts |
让 Vite 在构建时自动产出 .d.ts(底层调用 vue-tsc 的编程式 API,绕过 vue-tsc CLI 的 TS 5.9 兼容性问题 ,见 ARCHITECTURE.md ADR-0008) |
sass |
编译 SCSS |
vitest + @vue/test-utils + happy-dom |
单元测试三件套 |
3.4 关键配置文件
tsconfig.json(开发期)
json
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"jsx": "preserve",
"lib": ["DOM", "ES2020"],
"types": ["vite/client"],
"skipLibCheck": true
},
"include": ["src/**/*", "playground/**/*"]
}
tsconfig.lib.json(类型生成期,被 vite-plugin-dts 复用)
json
{
"extends": "./tsconfig.json",
"compilerOptions": {
"declaration": true,
"emitDeclarationOnly": true,
"outDir": "dist"
},
"include": ["src/**/*"],
"exclude": ["**/__tests__/**", "playground/**", "docs/**"]
}
vite.config.ts(库构建)
ts
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import dts from 'vite-plugin-dts';
import { resolve } from 'path';
export default defineConfig({
plugins: [
vue(),
dts({ tsconfigPath: './tsconfig.lib.json' }),
],
build: {
lib: {
entry: resolve(__dirname, 'src/index.ts'),
formats: ['es', 'cjs'],
fileName: (format) => `index.${format === 'es' ? 'mjs' : 'cjs'}`,
},
rollupOptions: {
external: ['vue'],
output: {
preserveModules: true,
preserveModulesRoot: 'src',
// ⭐ 保留目录结构,这是按需 tree-shaking 的前提
},
},
cssCodeSplit: true,
emptyOutDir: true,
},
});
完整生产配置参见本项目的 vite.config.ts。
3.5 调整 package.json
jsonc
{
"name": "@my/ui",
"version": "0.0.1",
"type": "module",
"main": "dist/index.cjs",
"module": "dist/index.mjs",
"types": "dist/index.d.ts",
"files": ["dist", "README.md"],
// ⭐ sideEffects 告诉打包工具:只有 css 有副作用,其他可以放心 tree-shake
"sideEffects": ["**/*.css", "**/*.scss"],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
},
"./theme": "./dist/theme/index.css",
"./components/*": "./dist/components/*/index.mjs"
},
// ⭐ peerDependencies 而不是 dependencies
"peerDependencies": { "vue": "^3.3.0" }
}
两个关键字段重点解释:
exports:Node 14+ 引入的"公开 API 清单"。一旦写了它,用户只能 引入这里列出的子路径。这是封装内部实现细节的关键。详见 DEVELOPMENT.md §3.3。sideEffects:["**/*.css"]告诉 Rollup/webpack:JS 文件没有副作用,可以放心删;CSS 文件有副作用,引入了就要保留。错配会导致按需失效或样式丢失。
3.6 写第一个 hello world
vue
<!-- src/components/button/Button.vue -->
<script setup lang="ts">
defineOptions({ name: 'UiButton' });
defineProps<{ type?: 'primary' | 'default' }>();
</script>
<template>
<button :class="['ui-button', `ui-button--${$props.type ?? 'default'}`]">
<slot />
</button>
</template>
ts
// src/components/button/index.ts
import Button from './Button.vue';
export const UiButton = Button;
export default Button;
ts
// src/index.ts
export * from './components/button';
import { UiButton } from './components/button';
export default {
install(app: any) {
app.component('UiButton', UiButton);
},
};
3.7 第一次构建
bash
pnpm vite build
如果一切就绪,dist/ 下应该出现 index.mjs、index.cjs、index.d.ts 和 components/button/Button.vue.mjs。至此你已经有了一个可发布的最小组件库。
后面的章节都是在这个骨架上加肉。
第 4 章 组件设计:从 Button 开始的六步法
这一章给你一套所有组件都能套用的设计模板。本项目所有组件都遵守这套结构,改 1 个组件后,改其他组件你已经熟门熟路。
4.1 单文件组件六步结构
vue
<script setup lang="ts">
// ① 引入 Vue 和库内基础工具
import { computed } from 'vue';
import { useNamespace } from '../../hooks/useNamespace';
// ② 引入本组件的 Props/Emits 类型
import type { ButtonProps, ButtonEmits } from './types';
// ③ 命名 --- defineOptions 决定 app.component(name, ...) 时的名字
defineOptions({ name: 'UiButton' });
// ④ Props 与 Emits 声明
const props = withDefaults(defineProps<ButtonProps>(), {
type: 'default',
size: 'medium',
});
const emit = defineEmits<ButtonEmits>();
// ⑤ BEM 命名空间 + 派生 class
const ns = useNamespace('button');
const classes = computed(() => [
ns.b(),
ns.m(props.type),
ns.m(props.size),
ns.is('disabled', props.disabled),
ns.is('loading', props.loading),
]);
// ⑥ 事件处理函数
function onClick(e: MouseEvent) {
if (props.disabled || props.loading) return;
emit('click', e);
}
</script>
<template>
<button :class="classes" :disabled="disabled || loading" @click="onClick">
<span v-if="loading" :class="ns.e('spinner')" />
<span :class="ns.e('content')"><slot /></span>
</button>
</template>
每一步都不可少:
| 步 | 作用 | 错的话会发生什么 |
|---|---|---|
| ① 引入工具 | 复用 hooks | 自己重写 BEM 拼字符串,容易出错 |
| ② 类型 | 编译期约束 + IDE 提示 | 用户传错 prop 类型时无报错 |
| ③ defineOptions name | app.component('UiButton') 时需要 |
全局注册失败 |
| ④ Props/Emits | 公共 API 契约 | 改默认值会 break 用户 |
| ⑤ ns + classes | 类名统一可被 ConfigProvider 改前缀 | 写死 ui- 前缀,定制困难 |
| ⑥ 事件 | 业务逻辑入口 | disabled 状态点击事件还会触发,bug |
4.2 Props 设计原则
4.2.1 用 TypeScript 接口,不用 runtime validator
ts
// ✅ 推荐:写一份 TS,Vue 编译宏自动产出 runtime 校验
export interface ButtonProps {
type?: 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'info';
size?: 'small' | 'medium' | 'large';
disabled?: boolean;
loading?: boolean;
}
// 组件里:
const props = withDefaults(defineProps<ButtonProps>(), { type: 'default' });
ts
// ❌ 旧风格:重复声明,且 IDE 推导差
const props = defineProps({
type: { type: String, default: 'default' },
size: { type: String, default: 'medium' },
// ...类型信息只在运行时有,IDE 提示不出枚举值
});
Vue 3.3+ 的 defineProps<T>() 是编译宏 ,会被 Vue 编译器静态分析,产物里既有类型也有 runtime 校验。详见 DEVELOPMENT.md §4.4。
4.2.2 默认值要"合理且最低风险"
| Prop | 默认值 | 为什么 |
|---|---|---|
type |
'default' |
灰色按钮中性,不强调任何动作 |
size |
'medium' |
中间值,小或大都需要明确指定 |
disabled |
false |
默认可交互 |
loading |
false |
默认无动画,性能最低 |
默认值原则 :让"不传任何 prop"的 <UiButton> 也能正确工作,且观感不冒险。
4.2.3 Props 命名:三条军规
- 布尔值用形容词 :
disabled、loading、closable,不要isDisabled这种冗余前缀 - 枚举值用单数名词 :
type、size、placement,不要types复数 - 回调统一前缀 :Vue 用 events 而不是回调 prop,所以本规则在 Vue 体系下变成 emits 名用动词 (
click、change、update:modelValue)
4.3 Emits 设计原则
ts
// types.ts
export interface ButtonEmits {
(e: 'click', event: MouseEvent): void;
}
关键约定:v-model 用 update:xxx
ts
// 让 <UiSwitch v-model="value" /> 正常工作
export interface SwitchEmits {
(e: 'update:modelValue', value: boolean): void;
(e: 'change', value: boolean): void; // 额外的语义事件
}
update:modelValue 是 Vue 3 v-model 的约定 emit 名。多个 v-model 时是 update:open、update:visible 等。
何时只用 emits,何时用 prop 函数
| 情景 | 用 | 例 |
|---|---|---|
| 通知"发生了什么" | emit | @click、@change |
| 让用户决定"是否允许" | prop 函数 | :beforeClose="() => boolean" |
4.4 Slots 设计原则
vue
<!-- 默认 slot:核心内容 -->
<UiButton>点我</UiButton>
<!-- 具名 slot:辅助元素 -->
<UiButton>
<template #icon><UiIcon name="plus" /></template>
添加
</UiButton>
<!-- 作用域 slot:把组件内部数据传出去,让用户自定义渲染 -->
<UiSelect>
<template #option="{ option }">
<span class="custom">{{ option.label }}</span>
</template>
</UiSelect>
设计原则:
- 内容性的就用默认 slot
- 装饰性的(icon、suffix、prefix)用具名 slot
- 数据驱动的 list 项渲染用作用域 slot
4.5 一个常见错觉:slot 不是 prop 的"高级替代品"
新手有时为了"灵活",把所有内容都做成 slot:
vue
<!-- ❌ 过度设计 -->
<UiButton>
<template #icon><UiIcon name="plus" /></template>
<template #content>添加</template>
<template #suffix>(beta)</template>
</UiButton>
实际上简单文本用 prop 更顺手:
vue
<!-- ✅ -->
<UiButton icon="plus">添加</UiButton>
判断标准 :用户传入的是简单值 (字符串、数字)→ prop;用户传入的是任意 vnode(图标、组件、嵌套结构)→ slot。
第 5 章 主题系统:SCSS + CSS Variables 双层架构
这是新手最容易迷糊的部分。SCSS 和 CSS Variables 不是二选一,它们是合作关系。
5.1 双层架构的核心思想
diff
SCSS (构建期) CSS Variables (运行时)
↓ ↓
处理: 处理:
- 模块化 @use - 颜色、间距等"可变 token"
- BEM mixin - 用户可动态覆盖
- @each 批量生成 - 暗色模式 / 主题切换
- 嵌套语法
SCSS 是工程化工具,生成 CSS。CSS Variables 是 CSS 自身的功能,运行时可变。
5.2 主题目录结构
csharp
src/theme/
├── base/
│ ├── var.scss # ⭐ 所有 CSS 变量的默认值
│ ├── mixin.scss # SCSS mixin (BEM 生成器等)
│ └── common.scss # reset / 全局基础
├── dark/
│ └── css-vars.scss # ⭐ 暗色模式下变量覆盖
├── index.scss # 主题总入口(被脚本编译为 index.css)
└── dark.scss # 暗色入口
完整对照见 src/theme/。
5.3 设计 token:var.scss
scss
/* src/theme/base/var.scss */
:root {
/* 主色 */
--ui-color-primary: #4f46e5;
--ui-color-primary-light: #6366f1;
--ui-color-primary-dark: #4338ca;
/* 背景 */
--ui-bg-color: #ffffff;
--ui-bg-color-soft: #f9fafb;
/* 文字 */
--ui-text-color-primary: #1f2937;
--ui-text-color-secondary: #6b7280;
/* 间距 */
--ui-spacing-xs: 4px;
--ui-spacing-sm: 8px;
--ui-spacing-md: 12px;
--ui-spacing-lg: 16px;
/* 圆角、阴影、动效... */
--ui-radius-md: 6px;
--ui-transition-base: 0.2s ease;
}
5.4 组件样式只引用变量,不写死值
scss
/* src/components/button/button.scss */
.ui-button {
padding: var(--ui-spacing-sm) var(--ui-spacing-lg);
background: var(--ui-bg-color);
color: var(--ui-text-color-primary);
border-radius: var(--ui-radius-md);
transition: background var(--ui-transition-base);
&--primary {
background: var(--ui-color-primary);
color: #fff;
&:hover {
background: var(--ui-color-primary-light);
}
}
}
禁止 写 color: #4f46e5 这种硬编码。任何颜色都要走变量。
5.5 暗色模式:就是覆盖变量
scss
/* src/theme/dark/css-vars.scss */
html.dark {
--ui-bg-color: #1f2937;
--ui-bg-color-soft: #111827;
--ui-text-color-primary: #f9fafb;
--ui-text-color-secondary: #d1d5db;
/* 主色保持不变,深浅辅色微调 */
--ui-color-primary-light: #818cf8;
}
切换暗色就是一行代码:
js
document.documentElement.classList.toggle('dark');
所有组件无感知,因为它们引用的是变量。
5.6 为什么不能用 SCSS 变量做主题?
scss
/* ❌ 这样写,主题一编译就锁死了 */
$primary: #4f46e5;
.ui-button--primary { background: $primary; }
SCSS 变量编译期就被替换成具体值,产物里只有颜色不再有变量名。要换主题必须重编源码,运行时无法切换。
而 CSS Variables 在浏览器里运行时解析:
css
/* ✅ 产物长这样 */
.ui-button--primary { background: var(--ui-color-primary); }
/* 浏览器在渲染时去 :root 找 --ui-color-primary 的当前值 */
用户在自己的应用里写 :root { --ui-color-primary: #ff0; } 就能换色,完全不需要重编组件库。
5.7 CSS Variables 的限制与对策
| 限制 | 对策 |
|---|---|
不能在 SCSS 函数里参与计算 lighten(var(--x)) 不行 |
浅深变体预定义为独立变量(--ui-color-primary-light) |
不能用 SCSS 的 @each 批量生成 var() |
用 SCSS 变量先生成枚举,再每条用 var() 引用 |
完整决策背景见 ARCHITECTURE.md ADR-0002。
5.8 BEM 命名约定
BEM = Block / Element / Modifier。本项目所有类名都遵循:
css
ui-button /* Block */
ui-button__icon /* Element (Block 的内部元素) */
ui-button--primary /* Modifier (Block 的变体) */
ui-button__icon--rotating /* Element + Modifier */
is-disabled /* 状态类 */
类名通过 useNamespace 自动生成,源码见 src/hooks/useNamespace.ts:
ts
const ns = useNamespace('button');
ns.b() // 'ui-button'
ns.e('icon') // 'ui-button__icon'
ns.m('primary') // 'ui-button--primary'
ns.is('disabled', true) // 'is-disabled'
好处:
- 类名前缀(
ui-)可通过ConfigProvider改成acme- - 拼写错误被函数挡住
- SCSS 和 JS 两侧的类名规则强制一致
第 6 章 横切关注点:hooks / utils / locale / ConfigProvider
这一章讲组件库里"不属于任何具体组件,但所有组件都要用"的几个模块。
6.1 withInstall:让组件既能 app.use 也能模板直用
ts
// src/utils/install.ts
import type { App, Plugin } from 'vue';
export function withInstall<T>(component: T, name?: string) {
(component as any).install = (app: App) => {
const compName = name ?? (component as any).name;
if (!compName) throw new Error('component must have a name');
app.component(compName, component as any);
};
return component as T & Plugin;
}
效果(给用户看的):
ts
// 方式 1:全局注册整库
app.use(UI);
// 方式 2:全局注册单个组件
app.use(UiButton);
// 方式 3:组件级别 import,模板里用
import { UiButton } from '@ui-lib/core';
// 模板: <UiButton />
只用一个 install 函数,支撑了 3 种使用方式。这是 Vue 插件协议的标准做法。
6.2 useNamespace:类名生成器
(已在 §5.8 介绍)
它做了一件被低估的事:让 ConfigProvider 一行 prop 就能改全库类名前缀。
ts
// useNamespace 内部
const prefix = inject<string>(namespaceKey, 'ui');
如果用户在外层包了:
vue
<ConfigProvider namespace="acme">
<App />
</ConfigProvider>
所有组件类名变成 acme-button、acme-button--primary...,避免和其他库的 ui- 撞名。
6.3 useZIndex:管理弹层堆叠
弹窗、提示、下拉菜单都要 z-index。手写 9999 是反模式:
ts
// src/hooks/useZIndex.ts (简化版)
let current = 2000;
export function useZIndex() {
current += 1;
return { current };
}
每次开一个新弹层,z-index 自增,保证后开的盖住先开的。
6.4 useClickOutside:点击外部关闭
ts
// src/hooks/useClickOutside.ts (简化版)
export function useClickOutside(
target: Ref<HTMLElement | null>,
handler: () => void,
) {
function onClick(e: MouseEvent) {
if (!target.value) return;
if (!target.value.contains(e.target as Node)) handler();
}
onMounted(() => document.addEventListener('click', onClick));
onBeforeUnmount(() => document.removeEventListener('click', onClick));
}
下拉菜单、Popover 都用得上。把这种"通用交互模式"抽成 hook,组件代码会简洁很多。
6.5 ConfigProvider:全局上下文注入
vue
<!-- src/config-provider/ConfigProvider.vue (示意) -->
<script setup lang="ts">
import { computed, provide } from 'vue';
import { namespaceKey } from '../hooks/useNamespace';
import { localeKey } from '../locale/useLocale';
const props = defineProps<{
namespace?: string;
locale?: LocaleMessages;
size?: 'small' | 'medium' | 'large';
}>();
provide(namespaceKey, props.namespace ?? 'ui');
provide(localeKey, computed(() => props.locale ?? zhCN));
provide('uiSize', computed(() => props.size ?? 'medium'));
</script>
<template>
<slot />
</template>
ConfigProvider 是"组件库的总控制台",所有跨组件配置走这里:
| 配置 | 用途 |
|---|---|
namespace |
改类名前缀避免冲突 |
locale |
国际化 |
size |
全局默认尺寸 |
zIndex |
起始 z-index |
实现细节见 src/config-provider/ConfigProvider.vue。
6.6 i18n:useLocale 的极简实现
不用 vue-i18n(太重)。自研 30 行代码足够:
ts
// src/locale/useLocale.ts (简化版)
import { computed, inject } from 'vue';
import { zhCN } from './lang/zh-CN';
export const localeKey = Symbol('uiLocale');
function getValueByPath(obj: any, path: string): string {
return path.split('.').reduce((acc, k) => acc?.[k], obj) ?? path;
}
export function useLocale() {
const locale = inject<any>(localeKey, computed(() => zhCN));
const t = (path: string) => getValueByPath(locale.value, path);
return { t, locale };
}
使用:
vue
<script setup>
const { t } = useLocale();
</script>
<template>
<button>{{ t('ui.modal.confirm') }}</button>
</template>
为什么不依赖 vue-i18n?详见 ARCHITECTURE.md ADR-0006。
第 7 章 构建产物:从源码到 npm 包的 7 个秘密
这一章是整个文档的技术含量最高的部分 。讲清楚
pnpm build之后,dist/是怎么来的、为什么这么组织、用户怎么用。
7.1 秘密 1:Vite 库模式 ≠ Vite 应用模式
vite dev 启动 dev server,处理的是"用户应用"。 vite build 默认也是"应用"模式,把入口打成一个 SPA bundle。 库模式 通过 build.lib 配置开启,产物是给其他项目作为依赖引入的。
应用模式 vs 库模式的差异:
| 应用 | 库 | |
|---|---|---|
| 产物入口 | index.html |
index.mjs / index.cjs |
| 依赖 | 全部打包进去 | Vue 等标记为 external,由用户提供 |
| 优化 | minify 优先 | tree-shaking 友好优先 |
| 多入口 | 单 HTML | 每个组件一个入口 |
7.2 秘密 2:多入口让按需引入成为可能
ts
// vite.config.ts
const componentEntries = {
'components/button/index': 'src/components/button/index.ts',
'components/input/index': 'src/components/input/index.ts',
// ... 10 个组件
};
build: {
lib: {
entry: {
index: 'src/index.ts',
...componentEntries,
},
formats: ['es', 'cjs'],
},
}
产物:
bash
dist/
├── index.mjs ← 总入口(全量)
└── components/
├── button/index.mjs ← 单组件入口
├── input/index.mjs
└── ...
用户可以:
ts
// 全量
import { UiButton } from '@ui-lib/core';
// 单组件
import { UiButton } from '@ui-lib/core/components/button';
7.3 秘密 3:preserveModules: true 是 tree-shaking 的真正基础
ts
output: {
preserveModules: true,
preserveModulesRoot: 'src',
}
不开:Rollup 把所有源码合并到一个 bundle 文件。 开了:1:1 输出每个源文件。
| 模式 | dist 文件数 | tree-shaking 效果 |
|---|---|---|
| 不开 preserveModules | ~5 | 即使用一个 Button,整个 bundle 也会被解析 |
| 开 preserveModules | ~100+ | 每个组件独立 chunk,用什么打什么 |
为什么后者能 tree-shake? 因为用户的 bundler(Vite/webpack)对每个模块做静态分析,只要发现某个文件没被任何代码 import,就丢掉。preserveModules 让每个组件是一个独立可丢弃的单元。
7.4 秘密 4:external: ['vue'] 让 Vue 不被打包进库
ts
rollupOptions: {
external: ['vue', '@vueuse/core'],
}
如果不写 external,Vue 会被打包进 dist/,用户的 Vue 和库内的 Vue 是两个不同实例,后果:
provide/inject跨实例失效ref/reactive跨实例的响应式不生效- bundle 体积翻倍
external 告诉 Vite:这些依赖在用户那边已经有了,我只生成"引用",不真正打包。
配套的 package.json:
json
"peerDependencies": { "vue": "^3.3.0" }
明示"我需要 vue,但请用户自己安装"。
7.5 秘密 5:sideEffects 决定 CSS 不被裁掉
json
"sideEffects": ["**/*.css", "**/*.scss"]
bundler 的 tree-shaking 默认对"有副作用的 import"很保守:
- 写
false:所有 import 都可裁,CSS 会被错误裁掉 - 写
true或不写:所有 import 都保留,tree-shaking 失效 - 写
["**/*.css"]:只有 CSS 有副作用,JS 可裁,CSS 保留 ✅
7.6 秘密 6:.d.ts 由 vite-plugin-dts 产出,不调用 vue-tsc CLI
历史背景:vue-tsc 通过 monkey-patch TypeScript 内部实现 .vue 处理。TS 5.9 改了被 patch 的源码,CLI 调用挂掉。
ini
[error] Search string not found: "/supportedTSExtensions = .*(?=;)/"
解决:用 vite-plugin-dts 内部的编程式 API(它走 vue-tsc 的 module API,绕过 monkey-patch)。配置:
ts
plugins: [
vue(),
dts({ tsconfigPath: './tsconfig.lib.json' }),
],
7.7 秘密 7:SCSS 的产物路径要靠脚本控制
直接让 Vite 处理 SFC 内联 <style lang="scss"> 有两个问题:
- 产物 CSS 名称不可控(
Button.vue.css不易引用) - 单组件 CSS 缺少主题变量定义(变量在
theme/base/var.scss)
解决:写一个独立的 build 脚本,把每个组件的 SCSS 编译成自带变量定义的 CSS:
js
// scripts/build-styles.mjs (核心思路)
import sass from 'sass';
import { writeFileSync } from 'fs';
for (const name of components) {
const wrapper = `
@use 'theme/base/var.scss';
@use 'components/${name}/${name}.scss';
`;
const { css } = sass.compileString(wrapper, { loadPaths: ['src'] });
writeFileSync(`dist/components/${name}/style.css`, css);
}
效果:dist/components/button/style.css 既有 Button 自身样式,也含必要的 :root { --ui-color-primary: ... },单独引入也能正常显示。
完整背景见 ARCHITECTURE.md ADR-0009。
7.8 最终产物全图
css
dist/
├── index.mjs / index.cjs ⭐ 总入口
├── index.d.ts 类型
├── components/
│ └── button/
│ ├── Button.vue.mjs 编译后 SFC
│ ├── Button.vue.cjs
│ ├── Button.vue.d.ts SFC 类型
│ ├── index.mjs ⭐ 组件入口
│ ├── index.cjs
│ ├── index.d.ts
│ ├── types.d.ts Props/Emits 类型
│ └── style.css ⭐ 组件独立 CSS
├── theme/
│ ├── index.css ⭐ 全量 CSS
│ └── dark.css ⭐ 暗色 CSS
├── hooks/, utils/, locale/, config-provider/ 保留目录结构
⭐ 是用户最常 import 的入口。其他文件是支撑性的。
第 8 章 按需引入:tree-shaking 的真正原理
8.1 三段式按需
用户想要"只用 Button,不打包 50 个其他组件",需要三件事同时成立:
| 段 | 谁负责 | 关键配置 |
|---|---|---|
| ① JS 按需 | 库的产物 + 用户的 bundler | preserveModules + sideEffects |
| ② CSS 按需 | 库提供按组件拆分的 CSS | build-styles.mjs 脚本 |
| ③ 自动 import | 第三方插件 | unplugin-vue-components |
8.2 段 ① --- JS tree-shaking
用户代码:
ts
import { UiButton } from '@ui-lib/core';
Bundler 看 dist/index.mjs:
js
export { UiButton } from './components/button/index.mjs';
export { UiInput } from './components/input/index.mjs';
// ...
UiInput 没被任何代码用,加上 sideEffects: ["**/*.css"](其他无副作用),整段 UiInput 链路被裁掉。
前提 :preserveModules: true 让 button 和 input 是分开的文件,而非合并 bundle。
8.3 段 ② --- CSS 按需
JS tree-shaking 不会作用于 CSS。如果用户只想引用到的组件的 CSS,两条路:
路 A:全量 CSS(简单)
ts
import '@ui-lib/core/theme';
// 一行,全库样式都来,gzip 后通常 < 10kB,可接受
路 B:组件级 CSS(精细)
ts
import { UiButton } from '@ui-lib/core/components/button';
import '@ui-lib/core/components/button/style.css';
每个组件都要写两行,手动管理负担大,所以才有段 ③。
8.4 段 ③ --- 自动按需(unplugin-vue-components)
用户在自己的 vite 配置里加 resolver:
ts
// 用户的 vite.config.ts
import Components from 'unplugin-vue-components/vite';
export default {
plugins: [
Components({
resolvers: [
(name) => {
if (name.startsWith('Ui')) {
const kebab = name.slice(2).replace(/([A-Z])/g, '-$1').toLowerCase().slice(1);
return {
name: `Ui${name.slice(2)}`,
from: `@ui-lib/core/components/${kebab}`,
sideEffects: `@ui-lib/core/components/${kebab}/style.css`,
};
}
},
],
}),
],
};
然后用户模板里写:
vue
<UiButton />
插件自动注入:
ts
import { UiButton } from '@ui-lib/core/components/button';
import '@ui-lib/core/components/button/style.css';
用户零手动 import,产物零冗余,这就是按需引入的完整体验。
8.5 验证按需是否生效
bash
pnpm build # 在用户项目里跑构建
ls -la dist/assets/*.js # 看产物 JS 大小
只用了 Button 的应用,产物 JS 里不应该出现 UiInput、UiSelect 等组件代码。可以 grep "UiInput" 验证。
第 9 章 文档与 playground:让用户用得起来
9.1 playground 与 docs 的分工
| playground | docs | |
|---|---|---|
| 目标受众 | 库的开发者(你自己) | 库的使用者 |
| 是否打包到 npm | 否 | 否(但要部署到网站) |
| 工具 | Vite | VitePress |
| 内容 | 跑通组件的最小 demo | 详细 API 文档 + 多种使用示例 |
| 入口 | pnpm dev |
pnpm docs:dev |
9.2 playground 怎么搭
bash
playground/
├── index.html
├── vite.config.ts # 用 alias 指向 src/
├── src/
│ ├── main.ts # createApp + use 整库
│ └── App.vue # 所有组件的 demo
ts
// playground/vite.config.ts (核心)
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@ui-lib/core': resolve(__dirname, '../src/index.ts'),
},
},
});
关键 :alias 让 playground 直接吃 src/ 源码,而不是 dist/,改组件马上看到效果(热更新)。
9.3 docs:VitePress 的最简配置
bash
docs/
├── .vitepress/
│ ├── config.ts # 站点元数据 + 侧边栏
│ └── theme/index.ts # 全局注册组件
├── index.md # 首页
├── guide/installation.md
├── guide/quickstart.md
└── components/button.md
ts
// docs/.vitepress/theme/index.ts
import DefaultTheme from 'vitepress/theme';
import UI from '../../../src';
import '../../../src/theme/index.scss';
export default {
...DefaultTheme,
enhanceApp({ app }) {
app.use(UI);
},
};
之后任何 .md 文件都可以直接写:
md
<UiButton type="primary">Demo</UiButton>
9.4 每个组件文档应该包含什么
| 章节 | 内容 |
|---|---|
| 何时使用 | 1--2 句场景描述 |
| 基础示例 | 最小可运行 demo |
| 常用变体 | type / size / disabled 等 |
| API 表 - Props | 列表:名字 / 类型 / 默认值 / 说明 |
| API 表 - Events | 列表:名字 / 参数 / 说明 |
| API 表 - Slots | 列表:名字 / 说明 |
参考 docs/components/ 现有组件文档。
第 10 章 测试与质量保障
10.1 测试金字塔(组件库版)
ruby
┌─────────────────┐
│ e2e / 视觉回归 │ ← 少:1 个串通文档站
└─────────────────┘
┌────────────────────┐
│ 集成测试 (a11y) │ ← 少量:Modal/Tooltip 等弹层
└────────────────────┘
┌──────────────────────────────┐
│ 组件单元测试 (vitest) │ ← 主力:每个组件 3-10 条
└──────────────────────────────┘
10.2 单元测试的最小集合
每个组件至少测 3 类场景:
ts
// src/components/button/__tests__/Button.test.ts
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import Button from '../Button.vue';
describe('Button', () => {
// ① 渲染:默认插槽和基础 class
it('renders slot content', () => {
const wrapper = mount(Button, { slots: { default: 'Click' } });
expect(wrapper.text()).toBe('Click');
expect(wrapper.classes()).toContain('ui-button');
});
// ② Props 影响 class
it('applies type modifier', () => {
const wrapper = mount(Button, { props: { type: 'primary' } });
expect(wrapper.classes()).toContain('ui-button--primary');
});
// ③ 事件触发
it('emits click event', async () => {
const wrapper = mount(Button);
await wrapper.trigger('click');
expect(wrapper.emitted('click')).toBeTruthy();
});
// ④ disabled 不触发事件
it('does not emit when disabled', async () => {
const wrapper = mount(Button, { props: { disabled: true } });
await wrapper.trigger('click');
expect(wrapper.emitted('click')).toBeFalsy();
});
});
10.3 配置 vitest
ts
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
test: {
environment: 'happy-dom', // 模拟 DOM (比 jsdom 快)
include: ['src/**/__tests__/**/*.test.ts'],
},
});
10.4 类型检查也是测试
bash
pnpm typecheck # 跑 vue-tsc --noEmit,捕获 TS 错误
CI 里必须跑这一步。组件库的类型导出是公开 API,类型错误等于功能错误。
10.5 视觉回归测试(进阶)
如果设计语言敏感(改 padding 影响所有按钮),可加:
- Chromatic / Percy:截图对比
- Storybook + jest-image-snapshot:本地截图对比
首版不必,后期组件成熟后再补。
第 11 章 发布到 npm:版本、CHANGELOG、CI
11.1 发布前 8 项检查清单
-
pnpm test全部通过 -
pnpm typecheck无错误 -
pnpm build产物完整(dist/index.*、所有组件、theme CSS) -
dist/文件夹存在且非空 -
package.json的version已升 -
package.json的files: ["dist"]字段正确 -
README.md中的示例代码可用 - CHANGELOG 已更新
11.2 版本号 (semver)
kotlin
0.0.1 → 0.0.2 patch 修 bug,完全兼容
0.0.1 → 0.1.0 minor 加功能,完全兼容(向后兼容)
0.0.1 → 1.0.0 major 破坏性改动,可能 break 用户
0.x.y 阶段 视为"未稳定",任何升版都可能 break;1.0 之后 严格遵守 semver。
11.3 发布流程(简易版)
bash
# 1) 升版本
npm version patch # 0.1.0 → 0.1.1
# 自动会改 package.json 的 version 并 git tag
# 2) 构建
pnpm build
# 3) 发布
pnpm publish --access public
# scoped 包 (@xxx/yyy) 必须加 --access public,否则默认私有
11.4 推荐流程(changesets)
bash
# 一次性安装
pnpm add -D -w @changesets/cli
pnpm changeset init
# 每次有可发布变更时:
pnpm changeset
# 交互式选择 patch/minor/major + 写 changelog 说明
# 发布
pnpm changeset version # 自动升版本号 + 生成 CHANGELOG.md
git commit -am "release"
pnpm publish
changesets 的优势:变更说明和版本号绑定,CHANGELOG 不会漏写。多人协作时尤其重要。
11.5 CI 自动化(GitHub Actions 示例)
yaml
# .github/workflows/release.yml
name: Release
on:
push:
branches: [main]
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
- uses: actions/setup-node@v4
with:
node-version: 20
registry-url: https://registry.npmjs.org
- run: pnpm install --frozen-lockfile
- run: pnpm test
- run: pnpm typecheck
- run: pnpm build
- uses: changesets/action@v1
with:
publish: pnpm publish
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_TOKEN 在 npm 官网生成,设置到 GitHub repo Secrets。
第 12 章 进阶专题:函数式组件、SSR、a11y、性能
12.1 函数式组件:Message.success('hi')
不是 React 的"函数组件",是 Vue 里的"命令式 API"。
12.1.1 为什么不用声明式
vue
<!-- ❌ 声明式 -->
<UiMessage v-if="show" type="success" content="保存成功" />
用户每次提示都要在模板里加组件、管理 show 状态,业务代码冗余。axios 拦截器、router beforeEach 等非组件代码场景下完全不可用。
12.1.2 函数式实现核心
ts
// src/components/message/index.ts(简化)
import { createApp, h, ref } from 'vue';
import MessageItem from './MessageItem.vue';
import { isBrowser } from '../../utils/dom';
let containerEl: HTMLElement | null = null;
const instances = ref<Array<{ id: number; options: MessageOptions }>>([]);
let seed = 0;
function ensureContainer() {
if (containerEl || !isBrowser) return;
containerEl = document.createElement('div');
containerEl.className = 'ui-message-container';
document.body.appendChild(containerEl);
createApp({
setup() {
return () =>
instances.value.map(inst =>
h(MessageItem, { key: inst.id, options: inst.options, onClose: () => close(inst.id) }),
);
},
}).mount(containerEl);
}
function open(options: MessageOptions) {
if (!isBrowser) return { close: () => {} };
ensureContainer();
const id = ++seed;
instances.value.push({ id, options });
return { close: () => close(id) };
}
function close(id: number) {
instances.value = instances.value.filter(i => i.id !== id);
}
export const Message = {
success: (content: string) => open({ type: 'success', content }),
error: (content: string) => open({ type: 'error', content }),
// ...
};
完整代码见 src/components/message/index.ts。
12.1.3 三个设计要点
- 独立的 Vue app :
createApp()创建游离子应用,不污染业务的虚拟 DOM 树,也不依赖业务的 router/store - 单例容器 :
ensureContainer懒创建,所有Message.*共用一个 container - SSR 守护 :
isBrowser判断,服务端调用是 no-op
12.2 SSR 安全
12.2.1 三大雷区
| 雷区 | 错误示例 | 修正 |
|---|---|---|
顶层用 window |
const w = window.innerWidth |
包进 onMounted 或 isBrowser 守护 |
顶层用 document |
document.body.appendChild(...) |
同上 |
| 引用浏览器 API 的 setup | setup() { observer.observe(...) } |
改 onMounted 内执行 |
12.2.2 isBrowser helper
ts
// src/utils/dom.ts
export const isBrowser = typeof window !== 'undefined' && typeof document !== 'undefined';
库内任何 window/document 访问都先过这个守护。
12.3 a11y(可访问性)
12.3.1 最低要求
| 组件 | a11y 要求 |
|---|---|
| Button | 用原生 <button> 而不是 <div>(自带 a11y) |
| Modal | role="dialog" aria-modal="true",Esc 键关闭,挂载后 focus 入口 |
| Tooltip | role="tooltip" |
| Message | role="status"(屏幕阅读器会播报) |
| Input | 关联 <label>,有 aria-invalid 反馈错误 |
12.3.2 键盘导航
vue
<!-- Modal: Esc 关闭 -->
<script setup>
import { onMounted, onBeforeUnmount } from 'vue';
function onEsc(e: KeyboardEvent) {
if (e.key === 'Escape') emit('update:open', false);
}
onMounted(() => document.addEventListener('keydown', onEsc));
onBeforeUnmount(() => document.removeEventListener('keydown', onEsc));
</script>
12.4 性能优化
12.4.1 大列表用虚拟滚动(Table、Select 多选等)
借助 @vueuse/core 的 useVirtualList 或自研。首版可以不做,但要在文档里注明"列表 > 200 行可能卡"。
12.4.2 减少不必要的 reactive
vue
<script setup>
// ❌ 每次输入都 re-render 父组件
const props = defineProps<{ items: Item[] }>();
const filtered = computed(() => props.items.filter(...));
// ✅ 用 shallowRef 避免深层响应
const items = shallowRef<Item[]>([]);
</script>
12.4.3 组件级 lazy load(进阶)
对 Modal、Drawer 等不常用的组件,用 defineAsyncComponent 实现按需异步加载。首版不必。
第 13 章 反模式清单:这些坑请绕开
按踩坑频率从高到低排列:
13.1 ❌ 在组件库内直接 import Tailwind / Element Plus
→ 用户被迫装这些依赖。组件库要零业务依赖。
13.2 ❌ 用 SCSS 变量做主题色
scss
$primary: #4f46e5;
.btn { color: $primary; }
编译期就锁死,运行时不能切换。用 CSS Variables。
13.3 ❌ 在 SFC 里写硬编码颜色
scss
.ui-button { background: #4f46e5; } /* ❌ */
.ui-button { background: var(--ui-color-primary); } /* ✅ */
13.4 ❌ 把 Vue 写进 dependencies
json
"dependencies": { "vue": "^3.3.0" } // ❌
必须是 peerDependencies。否则用户和你的 Vue 是两个实例,reactivity 失效。
13.5 ❌ sideEffects: false
json
"sideEffects": false // ❌ CSS 会被错误裁掉
"sideEffects": ["**/*.css", "**/*.scss"] // ✅
13.6 ❌ 不写 external
Vue 被打包进 dist/,产物体积爆炸,且与用户 Vue 实例冲突。
13.7 ❌ 不开 preserveModules
所有源码合并到一个文件,tree-shaking 失效,用户用一个组件相当于全引入。
13.8 ❌ 直接修改已发布的 prop 名
type: 'primary' 改成 variant: 'primary',所有用户的代码都要改。一旦发布,加 prop 是兼容的,改/删 prop 是破坏性的。
13.9 ❌ 组件库内 import 'axios' 用网络
组件库是 UI 层,不该有副作用网络请求。Upload 之类需要时,让用户传入上传函数。
13.10 ❌ Modal 直接挂在父元素上
父元素有 transform、filter 等 CSS 属性时,会变成 position: fixed 的参考系,弹层定位错乱。用 Vue 的 <Teleport to="body">。
13.11 ❌ 单元测试只测 happy path
disabled 状态点击会不会还触发 emit?loading 时再点会发生什么?测试边界条件比测正常用法重要。
13.12 ❌ 一上来就追求 50+ 组件
参考 ARCHITECTURE.md ADR-0004:首版 10 个组件,精细打磨比 50 个粗糙组件价值大十倍。
附录 A:从零搭一个 mini 组件库的 30 分钟手把手
完整可跑通的最小例子。新建一个空文件夹照做,30 分钟内你会得到一个能 npm publish 的组件库。
A.1 第 0--3 分钟:初始化
bash
mkdir my-mini-ui && cd my-mini-ui
pnpm init
git init && echo "node_modules\ndist" > .gitignore
A.2 第 3--8 分钟:装依赖
bash
pnpm add -D vue typescript vite @vitejs/plugin-vue \
vite-plugin-dts sass \
vitest @vue/test-utils happy-dom \
@types/node
A.3 第 8--15 分钟:配置文件
tsconfig.json:
json
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"jsx": "preserve",
"lib": ["DOM", "ES2020"],
"skipLibCheck": true
},
"include": ["src/**/*"]
}
vite.config.ts:
ts
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import dts from 'vite-plugin-dts';
import { resolve } from 'path';
export default defineConfig({
plugins: [vue(), dts()],
build: {
lib: {
entry: resolve(__dirname, 'src/index.ts'),
formats: ['es', 'cjs'],
fileName: (f) => `index.${f === 'es' ? 'mjs' : 'cjs'}`,
},
rollupOptions: {
external: ['vue'],
output: { preserveModules: true, preserveModulesRoot: 'src' },
},
},
});
package.json 关键字段:
jsonc
{
"name": "@my/mini-ui",
"version": "0.0.1",
"type": "module",
"main": "dist/index.cjs",
"module": "dist/index.mjs",
"types": "dist/index.d.ts",
"files": ["dist"],
"sideEffects": ["**/*.css", "**/*.scss"],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
},
"peerDependencies": { "vue": "^3.3.0" },
"scripts": { "build": "vite build" }
}
A.4 第 15--25 分钟:写第一个组件
src/components/button/Button.vue:
vue
<script setup lang="ts">
defineOptions({ name: 'MiniButton' });
defineProps<{ type?: 'primary' | 'default' }>();
defineEmits<{ (e: 'click', ev: MouseEvent): void }>();
</script>
<template>
<button :class="['mini-btn', `mini-btn--${$props.type ?? 'default'}`]" @click="$emit('click', $event)">
<slot />
</button>
</template>
<style>
.mini-btn { padding: 8px 16px; border-radius: 6px; }
.mini-btn--default { background: #f3f4f6; }
.mini-btn--primary { background: #4f46e5; color: white; }
</style>
src/components/button/index.ts:
ts
import Button from './Button.vue';
import type { App } from 'vue';
(Button as any).install = (app: App) => app.component('MiniButton', Button);
export const MiniButton = Button as typeof Button & { install: (app: App) => void };
export default MiniButton;
src/index.ts:
ts
import { MiniButton } from './components/button';
import type { App } from 'vue';
export { MiniButton };
export default {
install(app: App) {
app.use(MiniButton);
},
};
A.5 第 25--28 分钟:构建
bash
pnpm build
ls dist/
# 应该看到 index.mjs, index.cjs, index.d.ts,
# 以及 components/button/ 下的 Button.vue.mjs 等
A.6 第 28--30 分钟:发布(可选)
bash
npm login
pnpm publish --access public
至此你拥有了一个能被 pnpm add @my/mini-ui 安装、能正常 import { MiniButton } 使用的组件库。
后面要做的事情(按优先级)就是:
- 加
useNamespace(本指南第 5 章) - 把样式抽到独立
.scss文件并配build-styles.mjs脚本(第 7 章秘密 7) - 加
ConfigProvider(第 6 章) - 加单元测试(第 10 章)
- 加 VitePress 文档(第 9 章)
- 加 10 个核心组件(本项目源码即模板)
附录 B:本项目核心文件速查
| 类别 | 文件 | 用途 |
|---|---|---|
| 库入口 | src/index.ts | 公开 API 出口 |
| 库构建 | vite.config.ts | 多入口 / external / preserveModules |
| 单测 | vitest.config.ts | happy-dom + vue-test-utils |
| 类型生成 | tsconfig.lib.json | 给 vite-plugin-dts 用 |
| BEM | src/hooks/useNamespace.ts | 类名生成器 |
| install | src/utils/install.ts | app.use() 适配 |
| SSR | src/utils/dom.ts | isBrowser 守护 |
| 主题变量 | src/theme/base/var.scss | CSS Variables 默认值 |
| 暗色覆盖 | src/theme/dark/ | html.dark 下的变量 |
| 全局上下文 | src/config-provider/ConfigProvider.vue | namespace / locale / size 注入 |
| i18n | src/locale/ | useLocale + 语言包 |
| 函数式组件 | src/components/message/index.ts | createApp 模式参考 |
| package | package.json | exports / sideEffects / peerDeps |
| 样式编译 | scripts/build-styles.mjs | 组件 SCSS → CSS |
附录 C:延伸阅读
C.1 本项目其他文档
| 文档 | 看它解决什么问题 |
|---|---|
| ARCHITECTURE.md | 为什么这么做 --- 9 条 ADR 架构决策记录 |
| DEVELOPMENT.md | 怎么做的 --- 实现原理细节 |
| 运行指南.md | 怎么用 --- 命令操作手册 + 排错 |
| ROADMAP.md | 接下来做什么 --- 后续版本规划 |
阅读顺序建议:本指南 → ARCHITECTURE → DEVELOPMENT → 运行指南(从抽象到具体)。
C.2 外部资料
Vue 3 核心机制
构建产物
优秀组件库源码(从轻量到完整)
- Naive UI --- TS 设计可参考
- Element Plus --- 大型 monorepo 实战
- Arco Design Vue --- 企业级范式
工具链
结语
组件库是工程能力的综合体现:
- 对设计语言的敏感 --- token 怎么命名,API 怎么取舍
- 对构建工具链的理解 --- 为什么要 preserveModules,sideEffects 怎么配
- 对用户体验的同理心 --- 用户拿到包,从第一个 import 到上线生产,每一步是否顺滑
读完本指南,你应该不再觉得"组件库是个神秘的黑盒"。它只是一组遵守特定工程约定的、可被发布的 Vue 文件。
剩下的事就是动手------先在 [附录 A](#先在 附录 A 跑通 mini 版,再回头读本项目的源码,然后扩你自己的组件 "#%E9%99%84%E5%BD%95-a%E4%BB%8E%E9%9B%B6%E6%90%AD%E4%B8%80%E4%B8%AA-mini-%E7%BB%84%E4%BB%B6%E5%BA%93%E7%9A%84-30-%E5%88%86%E9%92%9F%E6%89%8B%E6%8A%8A%E6%89%8B") 跑通 mini 版,再回头读本项目的源码,然后扩你自己的组件。
任何疑问,回到对应章节,配合 ARCHITECTURE 的 ADR 和 DEVELOPMENT 的实现细节对照阅读,问题大半能自己解决。
--- 完 ---