组件库开发入门到生产(从零封装到 npm 发布)

这份文档面向前端小白想自己造组件库的开发者 。 它不只是讲"@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.vuebutton.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 模块边界的"三个不要"

新手设计模块时,记住三条:

  1. 不要让 utils/ 引用 Vue 的 reactivity (refreactive)。utils 是纯函数,需要 reactivity 的逻辑放 hooks/
  2. 不要让组件直接读全局变量 (window.someConfig)。所有运行时配置走 ConfigProvider 注入。
  3. 不要让 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.mjsindex.cjsindex.d.tscomponents/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 命名:三条军规

  1. 布尔值用形容词 :disabledloadingclosable,不要 isDisabled 这种冗余前缀
  2. 枚举值用单数名词 :typesizeplacement,不要 types 复数
  3. 回调统一前缀 :Vue 用 events 而不是回调 prop,所以本规则在 Vue 体系下变成 emits 名用动词 (clickchangeupdate:modelValue)

4.3 Emits 设计原则

ts 复制代码
// types.ts
export interface ButtonEmits {
  (e: 'click', event: MouseEvent): void;
}

关键约定:v-modelupdate: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:openupdate: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-buttonacme-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' }),
],

详见 ARCHITECTURE.md ADR-0008

7.7 秘密 7:SCSS 的产物路径要靠脚本控制

直接让 Vite 处理 SFC 内联 <style lang="scss"> 有两个问题:

  1. 产物 CSS 名称不可控(Button.vue.css 不易引用)
  2. 单组件 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 里不应该出现 UiInputUiSelect 等组件代码。可以 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.jsonversion 已升
  • package.jsonfiles: ["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 三个设计要点

  1. 独立的 Vue app :createApp() 创建游离子应用,不污染业务的虚拟 DOM 树,也不依赖业务的 router/store
  2. 单例容器 :ensureContainer 懒创建,所有 Message.* 共用一个 container
  3. SSR 守护 :isBrowser 判断,服务端调用是 no-op

12.2 SSR 安全

12.2.1 三大雷区

雷区 错误示例 修正
顶层用 window const w = window.innerWidth 包进 onMountedisBrowser 守护
顶层用 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/coreuseVirtualList 或自研。首版可以不做,但要在文档里注明"列表 > 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 之类需要时,让用户传入上传函数

父元素有 transformfilter 等 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 } 使用的组件库

后面要做的事情(按优先级)就是:

  1. useNamespace(本指南第 5 章)
  2. 把样式抽到独立 .scss 文件并配 build-styles.mjs 脚本(第 7 章秘密 7)
  3. ConfigProvider(第 6 章)
  4. 加单元测试(第 10 章)
  5. 加 VitePress 文档(第 9 章)
  6. 加 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 核心机制

构建产物

优秀组件库源码(从轻量到完整)

工具链


结语

组件库是工程能力的综合体现:

  • 设计语言的敏感 --- 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 的实现细节对照阅读,问题大半能自己解决。

--- 完 ---

相关推荐
KaMeidebaby4 小时前
卡梅德生物技术快报|单 B 细胞抗体制备:流程优化、表达系统适配与性能数据
前端·数据库·其他·百度·新浪微博
lichenyang4534 小时前
从鸿蒙 AI 聊天 Demo 学习 ArkUI V2:第一天上手记录
前端
进击的松鼠4 小时前
OpenClaw 的五层架构设计与解析
前端·架构·agent
JavaGuide4 小时前
Claude Code 新功能Agent View 发布:终于不用在一堆终端窗口里找 Agent 了!
前端·后端·agent
不简说4 小时前
前端可视化打印设计器sv-print,一口气更新了30版
前端·源码·产品
颖火虫盟主4 小时前
Claude Code Hook 系统详解与 Hello World 实操
前端·网络·数据库
JavaGuide5 小时前
Claude Code + BrowserAct,夯爆了!一句话让 AI 帮你操控浏览器。
前端·后端·ai编程
七十二時_阿川5 小时前
Electron WebContents 完全指南:页面渲染、导航控制与安全实战
前端·electron
用户11481867894845 小时前
Vue 开发者快速上手 Flutter(五) -状态管理路径
前端