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

13 阅读17分钟

这份文档面向前端小白想自己造组件库的开发者。 它不只是讲"@ui-lib/core 是怎么做的",而是讲清楚:任何一个 Vue 3 组件库,应该如何被一步步从零构建起来,直到生产发布

读完它,你应该能:

  • 看懂任何成熟组件库(Element Plus / Naive UI / Arco)的源码结构
  • 自己从一个空目录搭一个能发 npm 的组件库
  • 知道每一行配置"为什么这么写"

阅读建议:不要跳读。架构是地基,组件是上层建筑,构建发布是收尾,进阶是装修。顺序读完一遍胜过反复翻看任何一节。


目录


第 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 组件库都可以拆成四块:

ui-lib/
├── src/           ① 库源码  → 会被打包,发布到 npm
├── playground/    ② 调试场  → 不发布,本地热更新调样式
├── docs/          ③ 文档站  → 不发布到 npm,但要部署到网站
└── scripts/       ④ 工具脚本 → 不发布,辅助构建

为什么必须分这四块?

  • src/ 干净:只放发布的东西,产物可预测
  • playground/ 是"破坏性试验场",写新组件随便玩,不污染源码
  • docs/ 给用户看,展示 API 与示例
  • scripts/ 隔离一次性脚本(批量生成、CSS 编译等)

本仓库结构完整对照见 运行指南.md §3

2.2 src/ 内部的分层

这是最关键的一步。新手最容易犯的错是把所有东西堆 components/。正确的分层:

src/
├── components/        组件本体 (一个组件一个目录)
├── hooks/             跨组件复用的逻辑 (useNamespace, useZIndex 等)
├── utils/             纯函数工具 (withInstall, dom 判断等)
├── locale/            i18n 语言包
├── config-provider/   全局上下文组件 (ConfigProvider)
├── theme/             SCSS 主题与变量
└── index.ts           库总入口

每一层有明确职责依赖方向:

index.ts
   ↑
components/  ←── 依赖 hooks/utils/locale/theme
   ↑
hooks/  ←── 只依赖 utils
   ↑
utils/  ←── 零依赖,纯函数

依赖方向单向、自底向上。utils 不能反向 import components,否则会形成循环依赖,构建会爆炸。

2.3 单个组件目录的"5 件套"

每个组件目录长这样:

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 章)
  • 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≥ 9pnpm -v
Git任意现代版本git --version

详细环境准备见 运行指南.md §1

3.2 初始化项目

mkdir my-ui-lib && cd my-ui-lib
pnpm init
git init

3.3 安装核心依赖

# 运行时依赖 (会进入用户 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
typescriptTS 编译器
vite构建工具
@vitejs/plugin-vue让 Vite 能处理 .vue 文件
vue-tscVue 版的 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(开发期)

{
  "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 复用)

{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "declaration": true,
    "emitDeclarationOnly": true,
    "outDir": "dist"
  },
  "include": ["src/**/*"],
  "exclude": ["**/__tests__/**", "playground/**", "docs/**"]
}

vite.config.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

{
  "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

<!-- 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>
// src/components/button/index.ts
import Button from './Button.vue';
export const UiButton = Button;
export default Button;
// src/index.ts
export * from './components/button';
import { UiButton } from './components/button';
export default {
  install(app: any) {
    app.component('UiButton', UiButton);
  },
};

3.7 第一次构建

pnpm vite build

如果一切就绪,dist/ 下应该出现 index.mjsindex.cjsindex.d.tscomponents/button/Button.vue.mjs至此你已经有了一个可发布的最小组件库

后面的章节都是在这个骨架上加肉


第 4 章 组件设计:从 Button 开始的六步法

这一章给你一套所有组件都能套用的设计模板。本项目所有组件都遵守这套结构,改 1 个组件后,改其他组件你已经熟门熟路。

4.1 单文件组件六步结构

<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 nameapp.component('UiButton') 时需要全局注册失败
④ Props/Emits公共 API 契约改默认值会 break 用户
⑤ ns + classes类名统一可被 ConfigProvider 改前缀写死 ui- 前缀,定制困难
⑥ 事件业务逻辑入口disabled 状态点击事件还会触发,bug

4.2 Props 设计原则

4.2.1 用 TypeScript 接口,不用 runtime validator

// ✅ 推荐:写一份 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' });
// ❌ 旧风格:重复声明,且 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'中间值,小或大都需要明确指定
disabledfalse默认可交互
loadingfalse默认无动画,性能最低

默认值原则:让"不传任何 prop"的 <UiButton> 也能正确工作,且观感不冒险。

4.2.3 Props 命名:三条军规

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

4.3 Emits 设计原则

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

关键约定:v-modelupdate:xxx

// 让 <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 设计原则

<!-- 默认 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:

<!-- ❌ 过度设计 -->
<UiButton>
  <template #icon><UiIcon name="plus" /></template>
  <template #content>添加</template>
  <template #suffix>(beta)</template>
</UiButton>

实际上简单文本用 prop 更顺手:

<!-- ✅ -->
<UiButton icon="plus">添加</UiButton>

判断标准:用户传入的是简单值(字符串、数字)→ prop;用户传入的是任意 vnode(图标、组件、嵌套结构)→ slot。


第 5 章 主题系统:SCSS + CSS Variables 双层架构

这是新手最容易迷糊的部分。SCSS 和 CSS Variables 不是二选一,它们是合作关系

5.1 双层架构的核心思想

SCSS (构建期)            CSS Variables (运行时)
   ↓                         ↓
处理:                    处理:
- 模块化 @use            - 颜色、间距等"可变 token"
- BEM mixin              - 用户可动态覆盖
- @each 批量生成         - 暗色模式 / 主题切换
- 嵌套语法

SCSS 是工程化工具,生成 CSS。CSS Variables 是 CSS 自身的功能,运行时可变。

5.2 主题目录结构

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

/* 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 组件样式只引用变量,不写死值

/* 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 暗色模式:就是覆盖变量

/* 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;
}

切换暗色就是一行代码:

document.documentElement.classList.toggle('dark');

所有组件无感知,因为它们引用的是变量。

5.6 为什么不能用 SCSS 变量做主题?

/* ❌ 这样写,主题一编译就锁死了 */
$primary: #4f46e5;
.ui-button--primary { background: $primary; }

SCSS 变量编译期就被替换成具体值,产物里只有颜色不再有变量名。要换主题必须重编源码,运行时无法切换。

而 CSS Variables 在浏览器里运行时解析:

/* ✅ 产物长这样 */
.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。本项目所有类名都遵循:

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:

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 也能模板直用

// 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;
}

效果(给用户看的):

// 方式 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 就能改全库类名前缀

// useNamespace 内部
const prefix = inject<string>(namespaceKey, 'ui');

如果用户在外层包了:

<ConfigProvider namespace="acme">
  <App />
</ConfigProvider>

所有组件类名变成 acme-buttonacme-button--primary...,避免和其他库的 ui- 撞名

6.3 useZIndex:管理弹层堆叠

弹窗、提示、下拉菜单都要 z-index。手写 9999 是反模式:

// src/hooks/useZIndex.ts (简化版)
let current = 2000;
export function useZIndex() {
  current += 1;
  return { current };
}

每次开一个新弹层,z-index 自增,保证后开的盖住先开的

6.4 useClickOutside:点击外部关闭

// 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:全局上下文注入

<!-- 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 行代码足够:

// 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 };
}

使用:

<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.htmlindex.mjs / index.cjs
依赖全部打包进去Vue 等标记为 external,由用户提供
优化minify 优先tree-shaking 友好优先
多入口单 HTML每个组件一个入口

7.2 秘密 2:多入口让按需引入成为可能

// 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'],
  },
}

产物:

dist/
├── index.mjs                          ← 总入口(全量)
└── components/
    ├── button/index.mjs               ← 单组件入口
    ├── input/index.mjs
    └── ...

用户可以:

// 全量
import { UiButton } from '@ui-lib/core';
// 单组件
import { UiButton } from '@ui-lib/core/components/button';

7.3 秘密 3:preserveModules: true 是 tree-shaking 的真正基础

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 不被打包进库

rollupOptions: {
  external: ['vue', '@vueuse/core'],
}

如果不写 external,Vue 会被打包进 dist/,用户的 Vue 和库内的 Vue 是两个不同实例,后果:

  • provide/inject 跨实例失效
  • ref/reactive 跨实例的响应式不生效
  • bundle 体积翻倍

external 告诉 Vite:这些依赖在用户那边已经有了,我只生成"引用",不真正打包。

配套的 package.json:

"peerDependencies": { "vue": "^3.3.0" }

明示"我需要 vue,但请用户自己安装"。

7.5 秘密 5:sideEffects 决定 CSS 不被裁掉

"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 调用挂掉。

[error] Search string not found: "/supportedTSExtensions = .*(?=;)/"

解决:vite-plugin-dts 内部的编程式 API(它走 vue-tsc 的 module API,绕过 monkey-patch)。配置:

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:

// 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 最终产物全图

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 按需库的产物 + 用户的 bundlerpreserveModules + sideEffects
② CSS 按需库提供按组件拆分的 CSSbuild-styles.mjs 脚本
③ 自动 import第三方插件unplugin-vue-components

8.2 段 ① — JS tree-shaking

用户代码:

import { UiButton } from '@ui-lib/core';

Bundler 看 dist/index.mjs:

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(简单)

import '@ui-lib/core/theme';
// 一行,全库样式都来,gzip 后通常 < 10kB,可接受

路 B:组件级 CSS(精细)

import { UiButton } from '@ui-lib/core/components/button';
import '@ui-lib/core/components/button/style.css';

每个组件都要写两行,手动管理负担大,所以才有段 ③。

8.4 段 ③ — 自动按需(unplugin-vue-components)

用户在自己的 vite 配置里加 resolver:

// 用户的 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`,
            };
          }
        },
      ],
    }),
  ],
};

然后用户模板里写:

<UiButton />

插件自动注入:

import { UiButton } from '@ui-lib/core/components/button';
import '@ui-lib/core/components/button/style.css';

用户零手动 import,产物零冗余,这就是按需引入的完整体验。

8.5 验证按需是否生效

pnpm build  # 在用户项目里跑构建
ls -la dist/assets/*.js  # 看产物 JS 大小

只用了 Button 的应用,产物 JS 里不应该出现 UiInputUiSelect 等组件代码。可以 grep "UiInput" 验证。


第 9 章 文档与 playground:让用户用得起来

9.1 playground 与 docs 的分工

playgrounddocs
目标受众库的开发者(你自己)库的使用者
是否打包到 npm否(但要部署到网站)
工具ViteVitePress
内容跑通组件的最小 demo详细 API 文档 + 多种使用示例
入口pnpm devpnpm docs:dev

9.2 playground 怎么搭

playground/
├── index.html
├── vite.config.ts        # 用 alias 指向 src/
├── src/
│   ├── main.ts           # createApp + use 整库
│   └── App.vue           # 所有组件的 demo
// 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 的最简配置

docs/
├── .vitepress/
│   ├── config.ts         # 站点元数据 + 侧边栏
│   └── theme/index.ts    # 全局注册组件
├── index.md              # 首页
├── guide/installation.md
├── guide/quickstart.md
└── components/button.md
// 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 文件都可以直接写:

<UiButton type="primary">Demo</UiButton>

9.4 每个组件文档应该包含什么

章节内容
何时使用1–2 句场景描述
基础示例最小可运行 demo
常用变体type / size / disabled 等
API 表 - Props列表:名字 / 类型 / 默认值 / 说明
API 表 - Events列表:名字 / 参数 / 说明
API 表 - Slots列表:名字 / 说明

参考 docs/components/ 现有组件文档。


第 10 章 测试与质量保障

10.1 测试金字塔(组件库版)

        ┌─────────────────┐
        │  e2e / 视觉回归 │  ← 少:1 个串通文档站
        └─────────────────┘
       ┌────────────────────┐
       │  集成测试 (a11y)   │  ← 少量:Modal/Tooltip 等弹层
       └────────────────────┘
   ┌──────────────────────────────┐
   │  组件单元测试 (vitest)        │  ← 主力:每个组件 3-10 条
   └──────────────────────────────┘

10.2 单元测试的最小集合

每个组件至少测 3 类场景:

// 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

// 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 类型检查也是测试

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)

0.0.10.0.2   patch  修 bug,完全兼容
0.0.10.1.0   minor  加功能,完全兼容(向后兼容)
0.0.11.0.0   major  破坏性改动,可能 break 用户

0.x.y 阶段 视为"未稳定",任何升版都可能 break;1.0 之后 严格遵守 semver。

11.3 发布流程(简易版)

# 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)

# 一次性安装
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 示例)

# .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 为什么不用声明式

<!-- ❌ 声明式 -->
<UiMessage v-if="show" type="success" content="保存成功" />

用户每次提示都要在模板里加组件、管理 show 状态,业务代码冗余。axios 拦截器、router beforeEach 等非组件代码场景下完全不可用

12.1.2 函数式实现核心

// 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 三大雷区

雷区错误示例修正
顶层用 windowconst w = window.innerWidth包进 onMountedisBrowser 守护
顶层用 documentdocument.body.appendChild(...)同上
引用浏览器 API 的 setupsetup() { observer.observe(...) }onMounted 内执行

12.2.2 isBrowser helper

// 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)
Modalrole="dialog" aria-modal="true",Esc 键关闭,挂载后 focus 入口
Tooltiprole="tooltip"
Messagerole="status"(屏幕阅读器会播报)
Input关联 <label>,有 aria-invalid 反馈错误

12.3.2 键盘导航

<!-- 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

<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 变量做主题色

$primary: #4f46e5;
.btn { color: $primary; }

编译期就锁死,运行时不能切换。用 CSS Variables

13.3 ❌ 在 SFC 里写硬编码颜色

.ui-button { background: #4f46e5; }  /* ❌ */
.ui-button { background: var(--ui-color-primary); }  /* ✅ */

13.4 ❌ 把 Vue 写进 dependencies

"dependencies": { "vue": "^3.3.0" }  // ❌

必须是 peerDependencies。否则用户和你的 Vue 是两个实例,reactivity 失效。

13.5 ❌ sideEffects: false

"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 直接挂在父元素上

父元素有 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 分钟:初始化

mkdir my-mini-ui && cd my-mini-ui
pnpm init
git init && echo "node_modules\ndist" > .gitignore

A.2 第 3–8 分钟:装依赖

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:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "jsx": "preserve",
    "lib": ["DOM", "ES2020"],
    "skipLibCheck": true
  },
  "include": ["src/**/*"]
}

vite.config.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 关键字段:

{
  "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:

<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:

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:

import { MiniButton } from './components/button';
import type { App } from 'vue';

export { MiniButton };

export default {
  install(app: App) {
    app.use(MiniButton);
  },
};

A.5 第 25–28 分钟:构建

pnpm build
ls dist/
# 应该看到 index.mjs, index.cjs, index.d.ts,
# 以及 components/button/ 下的 Button.vue.mjs 等

A.6 第 28–30 分钟:发布(可选)

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.tshappy-dom + vue-test-utils
类型生成tsconfig.lib.json给 vite-plugin-dts 用
BEMsrc/hooks/useNamespace.ts类名生成器
installsrc/utils/install.tsapp.use() 适配
SSRsrc/utils/dom.tsisBrowser 守护
主题变量src/theme/base/var.scssCSS Variables 默认值
暗色覆盖src/theme/dark/html.dark 下的变量
全局上下文src/config-provider/ConfigProvider.vuenamespace / locale / size 注入
i18nsrc/locale/useLocale + 语言包
函数式组件src/components/message/index.tscreateApp 模式参考
packagepackage.jsonexports / 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 跑通 mini 版,再回头读本项目的源码,然后扩你自己的组件

任何疑问,回到对应章节,配合 ARCHITECTURE 的 ADR 和 DEVELOPMENT 的实现细节对照阅读,问题大半能自己解决。

— 完 —