Vue 基础体系 · 第 59/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。
Vue 组件库发布:构建、类型、样式、按需加载和语义化版本
Vue 组件库发布不是把若干 .vue 文件压缩后上传 npm。一个可被其他项目稳定消费的组件库,至少要同时解决五类问题:
- 构建:源码如何转换为浏览器和打包器可以消费的产物。
- 类型:TypeScript 用户如何获得组件 props、事件、插槽和导出函数的类型提示。
- 样式:组件样式如何进入最终应用,如何避免样式丢失或污染全局。
- 按需加载:用户只使用一个组件时,其他组件是否会被打进应用。
- 语义化版本:代码、类型、样式和依赖发生变化时,如何判断应该发布哪个版本。
本文使用 Vue 3、Composition API、TypeScript 和 Vite。Vite 的库模式负责构建 JavaScript 和 CSS 产物,但不会自动替组件库设计公共 API、类型边界或版本策略。
一、先确定组件库的交付边界
一个组件库通常包含以下几层:
组件源码
├─ Vue SFC:模板、脚本、样式
├─ TypeScript:公共类型和实现类型
└─ CSS:组件样式和设计令牌
构建过程
├─ Vue SFC 编译
├─ TypeScript 类型检查与声明文件生成
├─ ESM / CommonJS 产物生成
└─ CSS 提取
npm 包
├─ package.json:入口、类型、导出路径、依赖
├─ dist/index.js
├─ dist/index.cjs
├─ dist/index.d.ts
└─ dist/style.css
这里有一个容易混淆的边界:
- Vite 构建主要产生运行时 JavaScript 和 CSS。
- **TypeScript 编译器或
vue-tsc**主要负责类型检查和.d.ts声明文件。 - **npm 的
package.json**决定消费者实际解析哪个文件。 - 消费者自己的打包器决定最终应用是否执行 Tree Shaking、代码分割和 CSS 优化。
因此,“构建成功”只说明库的构建流程完成,不代表消费者一定能正确导入类型、样式和组件。
二、创建一个最小可发布项目
目录可以从下面的结构开始:
wr-ui/
├─ src/
│ ├─ components/
│ │ └─ WrButton/
│ │ └─ WrButton.vue
│ ├─ index.ts
│ └─ style.css
├─ package.json
├─ tsconfig.json
├─ tsconfig.build.json
└─ vite.config.ts
安装依赖:
npm install vue
npm install -D typescript vite @vitejs/plugin-vue vue-tsc
这里的 vue 是组件库运行时依赖,但通常不应该被打包进组件库本身。原因是宿主应用已经有自己的 Vue 实例;如果组件库再内置一份 Vue,可能造成体积增加、响应式上下文不一致或运行时错误。
因此,Vue 通常应放入 peerDependencies,并在构建时标记为 external。后文会具体说明。
1. 编写组件
src/components/WrButton/WrButton.vue:
<script setup lang="ts">
export interface WrButtonProps {
/**
* 按钮原生类型
*/
type?: 'button' | 'submit' | 'reset'
/**
* 是否禁用
*/
disabled?: boolean
}
const props = withDefaults(defineProps<WrButtonProps>(), {
type: 'button',
disabled: false,
})
const emit = defineEmits<{
click: [event: MouseEvent]
}>()
function handleClick(event: MouseEvent) {
if (!props.disabled) {
emit('click', event)
}
}
</script>
<template>
<button
class="wr-button"
:type="type"
:disabled="disabled"
@click="handleClick"
>
<slot />
</button>
</template>
这段代码定义了三种公共能力:
type和disabled是公共 props;click是公共事件;- 默认插槽是公共内容入口。
withDefaults 不是运行时类型校验,它只是在 TypeScript 类型层面把带默认值的 prop 处理为可选,同时在运行时提供默认值。
2. 定义组件库入口
src/index.ts:
import WrButton from './components/WrButton/WrButton.vue'
import './style.css'
export { WrButton }
export type { WrButtonProps } from './components/WrButton/WrButton.vue'
入口文件有两个不同职责:
export { WrButton }暴露运行时组件;export type { WrButtonProps }暴露只存在于类型系统中的类型;import './style.css'声明组件库的公共样式入口。
export type 不会在生成的 JavaScript 中产生运行时导出。这样可以避免消费者把纯类型误认为运行时值。
3. 定义样式
src/style.css:
:root {
--wr-color-primary: #1677ff;
--wr-color-primary-hover: #4096ff;
--wr-color-disabled: #bfbfbf;
}
.wr-button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 32px;
padding: 0 14px;
border: 0;
border-radius: 6px;
color: #fff;
background: var(--wr-color-primary);
cursor: pointer;
transition: background-color 0.2s ease;
}
.wr-button:hover:not(:disabled) {
background: var(--wr-color-primary-hover);
}
.wr-button:disabled {
color: #fff;
background: var(--wr-color-disabled);
cursor: not-allowed;
}
组件的类名使用 wr- 前缀,是为了降低与宿主应用其他样式发生冲突的概率。但前缀不是隔离机制;如果需要真正的样式隔离,还要考虑 CSS Modules、Shadow DOM 或更严格的命名约定。
三、Vite 库模式到底构建了什么
Vite 的应用构建通常以一个 HTML 入口开始,而库模式以一个或多个 JavaScript 入口开始。库模式不会为你生成应用 HTML,它关注的是把库入口转换成可发布产物。
vite.config.ts:
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
build: {
lib: {
entry: fileURLToPath(
new URL('./src/index.ts', import.meta.url),
),
name: 'WrUI',
formats: ['es', 'cjs'],
fileName: (format) => {
return format === 'es' ? 'wr-ui.js' : 'wr-ui.cjs'
},
},
rollupOptions: {
external: ['vue'],
},
},
})
这里的关键配置分别表示:
plugins: [vue()]:让 Vite 能够编译.vue单文件组件。lib.entry:指定组件库的公共入口。formats: ['es', 'cjs']:生成 ESM 和 CommonJS 两种格式。external: ['vue']:不把 Vue 打包进产物。fileName:固定产物文件名,便于在package.json中声明。
ESM 与 CommonJS
ESM 使用静态导入导出:
import { WrButton } from 'wr-ui'
CommonJS 使用:
const { WrButton } = require('wr-ui')
现代前端应用通常优先使用 ESM,因为 ESM 的静态模块结构便于打包器分析未使用的导出。CommonJS 主要用于兼容仍然使用 require() 的工具或 Node.js 代码。
“同时输出 ESM 和 CommonJS”并不表示两个文件都必须被使用。消费者的模块解析条件会根据 package.json 的 exports、import 和 require 字段选择其中一个。
为什么要 external Vue
假设组件库产物把 Vue 也打包进去:
宿主应用
├─ Vue 实例 A
└─ 组件库内部的 Vue 实例 B
这会产生至少三类风险:
- 包体积重复;
- 宿主应用与组件库使用不同 Vue 实例;
- 某些依赖实例身份或上下文的能力出现异常。
将 Vue external 后,最终应用的依赖关系变成:
宿主应用
└─ Vue 实例 A
├─ 宿主业务代码使用
└─ 组件库使用
但 external 只影响“是否打包”,不会自动告诉 npm 安装 Vue。因此还需要在 peerDependencies 中声明兼容范围。
四、类型发布:运行时文件和声明文件是两条链路
TypeScript 的类型有一个重要事实:类型通常在构建后不会以 JavaScript 形式存在。
例如:
export interface WrButtonProps {
disabled?: boolean
}
这段接口不会产生运行时代码。消费者要获得类型提示,组件库必须发布 .d.ts 文件。
1. 配置 TypeScript
基础配置 tsconfig.json:
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}
moduleResolution: "Bundler" 适合现代打包器环境。它会按照现代包导出规则解析依赖,但这并不意味着所有旧工具都支持同样的解析行为。若组件库还要服务于旧版 TypeScript 或旧版构建工具,需要根据实际兼容范围验证。
专门用于声明文件的 tsconfig.build.json:
{
"extends": "./tsconfig.json",
"compilerOptions": {
"declaration": true,
"emitDeclarationOnly": true,
"outDir": "dist/types",
"declarationMap": true
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}
vue-tsc 能够理解 .vue 文件中的 TypeScript,而普通 tsc 通常不能独立完成这项工作。
2. 构建脚本
package.json:
{
"name": "wr-ui",
"version": "1.0.0",
"type": "module",
"files": [
"dist"
],
"scripts": {
"typecheck": "vue-tsc --noEmit",
"build:js": "vite build",
"build:types": "vue-tsc -p tsconfig.build.json",
"build": "npm run typecheck && npm run build:js && npm run build:types"
}
}
执行:
npm run build
预期结果类似:
dist/
├─ style.css
├─ wr-ui.js
├─ wr-ui.cjs
└─ types/
├─ components/
│ └─ WrButton/
│ └─ WrButton.vue.d.ts
└─ index.d.ts
实际文件名可能受 Vite、vue-tsc 和配置版本影响,因此发布前应直接检查 dist,不能只根据配置文件推测产物是否存在。
这条构建链路包含三个阶段:
flowchart LR
A[Vue / TS 源码] --> B[vue-tsc 类型检查]
A --> C[Vite + Vue 插件]
B --> D[.d.ts 声明文件]
C --> E[ESM / CJS JavaScript]
C --> F[CSS 产物]
D --> G[npm 包]
E --> G
F --> G
类型检查失败时,不应继续发布。因为 JavaScript 构建成功并不能证明公共类型正确。例如组件可能已经把某个 prop 改成必填,但声明文件没有被正确生成;消费者会在编译阶段遇到错误,而运行时测试可能完全正常。
五、用 package.json 把产物暴露成稳定 API
一个可用的 package.json 片段如下:
{
"name": "wr-ui",
"version": "1.0.0",
"type": "module",
"main": "./dist/wr-ui.cjs",
"module": "./dist/wr-ui.js",
"types": "./dist/types/index.d.ts",
"files": [
"dist"
],
"sideEffects": [
"**/*.css"
],
"exports": {
".": {
"types": "./dist/types/index.d.ts",
"import": "./dist/wr-ui.js",
"require": "./dist/wr-ui.cjs"
},
"./style.css": "./dist/style.css",
"./package.json": "./package.json"
},
"peerDependencies": {
"vue": "^3.3.0"
}
}
main、module、types 与 exports
main是传统 CommonJS 入口。module是许多工具使用的 ESM 入口,但它不是所有工具都必须遵守的统一标准。types指向 TypeScript 默认类型入口。exports是更明确的包导出边界。
当存在 exports 时,消费者不能随意访问包内未声明的文件。例如:
import { WrButton } from 'wr-ui'
允许,因为 "." 被声明了。
import 'wr-ui/style.css'
允许,因为 "./style.css" 被声明了。
import WrButton from 'wr-ui/dist/components/WrButton/WrButton.vue'
不应作为公共用法,因为该路径没有被导出,而且它依赖源码目录结构。后续重构目录时,这种导入会直接失效。
files 与 .npmignore
files: ["dist"] 限制发布到 npm 的文件范围。它不能替代构建验证,因为发布包中可能缺少 README、许可证或其他必要文件,也可能存在类型路径错误。
发布前可以执行:
npm pack --dry-run
预期输出中应至少包含:
dist/style.css
dist/wr-ui.js
dist/wr-ui.cjs
dist/types/index.d.ts
package.json
如果 dist/style.css 不在列表中,消费者即使正确导入 wr-ui/style.css 也会失败。如果 dist/types/index.d.ts 不在列表中,运行时可能正常,但编辑器和 TypeScript 会报告找不到类型。
六、样式发布:构建出来不等于消费者加载了
在入口中写入:
import './style.css'
Vite 通常会在库模式下把 CSS 提取为独立文件。组件库发布后,消费者需要显式导入:
import { createApp } from 'vue'
import App from './App.vue'
import 'wr-ui/style.css'
createApp(App).mount('#app')
如果只写:
import { WrButton } from 'wr-ui'
而没有导入 CSS,按钮可能能渲染,但表现为浏览器默认按钮样式。这是“JavaScript 成功,样式缺失”的典型故障。
为什么 CSS 要标记为 side effect
Tree Shaking 会尝试删除没有被使用的模块。对于纯函数模块,这通常是安全的;但 CSS 导入虽然没有 JavaScript 返回值,却会改变页面样式,因此属于副作用。
{
"sideEffects": [
"**/*.css"
]
}
这表示:
- 普通 JavaScript 模块可以继续被分析和裁剪;
- CSS 文件不能因为“没有被使用的导出”而被错误删除。
如果写成:
{
"sideEffects": false
}
某些打包器可能认为所有模块都没有副作用,从而移除样式导入。实际行为取决于打包器和版本,但这类配置本身已经表达了错误的语义。
全局样式与组件局部样式
全局样式适合放置:
- 设计令牌;
- 通用字体或颜色变量;
- 组件库统一的基础规则。
组件局部样式可以使用:
<style scoped>
.wr-button {
/* ... */
}
</style>
scoped 的实现通常是给元素和选择器增加编译时属性标记,例如:
<button data-v-abc123 class="wr-button">
它不是运行时 Shadow DOM,也不能阻止所有跨组件影响。以下内容仍需谨慎:
:global(...)选择器;- CSS 自定义属性;
body、html等全局选择器;- z-index、字体和布局规则;
- 第三方组件生成的 DOM。
样式 API 也是公共 API
如果消费者这样覆盖组件:
.wr-button {
border-radius: 0;
}
那么 .wr-button 就已经成为事实上的公共接口。即使 TypeScript 类型完全没有变化,删除这个类名或改变选择器优先级,也可能破坏消费者的视觉结果。
因此,发布说明不能只记录 props 和事件变化,也应记录:
- CSS 类名是否变化;
- CSS 变量是否删除或改名;
- 样式导入路径是否变化;
- 全局样式是否新增更高优先级规则。
七、按需加载包含三个不同问题
“按需加载”经常被混用。至少要区分:
- Tree Shaking:静态移除没有被使用的导出。
- 代码分割:通过动态导入让代码在需要时才下载。
- 按需样式:只加载某些组件的样式,而不是完整样式文件。
这三者的机制和结果不同。
1. Tree Shaking:静态删除未使用导出
入口文件:
export { default as WrButton } from './components/WrButton/WrButton.vue'
export { default as WrInput } from './components/WrInput/WrInput.vue'
export { default as WrDialog } from './components/WrDialog/WrDialog.vue'
消费者只使用:
import { WrButton } from 'wr-ui'
在满足以下条件时,消费者的 ESM 打包器通常可以只保留 WrButton:
- 组件库发布了 ESM;
- 导入是静态的;
- 组件没有通过入口文件执行全局注册;
- 包和依赖的副作用声明没有误导打包器;
- 消费者构建工具启用了生产优化。
Tree Shaking 的对象是“模块导出和副作用”,不是 npm 自动下载机制。用户安装 wr-ui 时,整个 npm 包仍然会下载;按需影响的是最终应用产物,而不是 npm 安装包大小。
反例:
import { createApp } from 'vue'
import * as Components from 'wr-ui'
const app = createApp(App)
for (const [name, component] of Object.entries(Components)) {
app.component(name, component)
}
这种写法在运行时遍历所有导出,通常会让打包器难以证明哪些组件可以安全删除。即使最终只使用一个组件,也可能保留整个组件集合。
2. 全量注册与局部注册
不推荐把全局注册作为唯一入口:
import * as Components from './components'
export default {
install(app: App) {
for (const [name, component] of Object.entries(Components)) {
app.component(name, component)
}
},
}
如果组件库提供插件安装能力,应将其作为额外导出,并保留具名组件导出:
import type { App } from 'vue'
import WrButton from './components/WrButton/WrButton.vue'
export { WrButton }
export default {
install(app: App) {
app.component('WrButton', WrButton)
},
}
消费者可以选择:
// 推荐给希望细粒度 Tree Shaking 的场景
import { WrButton } from 'wr-ui'
// 适合确实需要全局组件的场景
import WrUI from 'wr-ui'
app.use(WrUI)
全局注册的便利性与按需能力存在天然张力:插件若注册所有组件,就必须先把这些组件作为运行时值加载进来。
3. 动态导入才是真正的延迟下载
如果一个对话框只在用户点击后出现,可以在业务应用中动态导入:
const WrDialog = defineAsyncComponent(() =>
import('wr-ui').then((module) => module.WrDialog),
)
其运行过程是:
- 初始构建生成主包和异步 chunk;
- 页面初次加载主包;
defineAsyncComponent首次需要组件时执行import();- 浏览器请求异步 chunk;
- chunk 加载完成后渲染组件。
这与普通静态导入不同:
import { WrDialog } from 'wr-ui'
静态导入允许打包器在构建阶段分析依赖;动态导入则明确要求运行时分块加载。
异步组件还需要处理失败状态:
const WrDialog = defineAsyncComponent({
loader: () =>
import('wr-ui').then((module) => module.WrDialog),
timeout: 10000,
onError(error, retry, fail, attempts) {
if (attempts <= 2) {
retry()
} else {
fail(error)
}
},
})
这里的重试只适合临时网络失败。若 chunk 文件已经被部署删除、版本混用或代码本身存在异常,盲目重试不会修复问题,反而会延迟错误暴露。
4. 子路径导出可以支持更强的边界
如果组件库希望消费者直接导入单个组件,可以额外构建组件入口:
{
"exports": {
".": {
"types": "./dist/types/index.d.ts",
"import": "./dist/wr-ui.js",
"require": "./dist/wr-ui.cjs"
},
"./button": {
"types": "./dist/types/components/WrButton/index.d.ts",
"import": "./dist/button.js",
"require": "./dist/button.cjs"
},
"./style.css": "./dist/style.css"
}
}
消费者:
import { WrButton } from 'wr-ui/button'
但这要求构建系统真的生成 dist/button.js、dist/button.cjs 和对应声明文件。只写 exports 而没有对应文件,会得到模块找不到错误。
子路径导出不是 Tree Shaking 的必要条件。它是一种更明确的模块边界,适合大型组件库、独立发布组件或需要控制入口副作用的场景。
八、构建验证:不要只验证“命令退出码为 0”
组件库至少要验证四条链路:
JavaScript 入口 ──> ESM / CJS 能否导入
类型入口 ──> TypeScript 能否解析
样式入口 ──> CSS 文件是否存在且可导入
包边界 ──> npm 包是否只暴露预期路径
1. 检查 ESM
创建临时验证文件:
// verify-esm.mjs
import { WrButton } from './dist/wr-ui.js'
if (!WrButton) {
throw new Error('WrButton export is missing')
}
console.log('ESM import passed')
执行:
node verify-esm.mjs
这里不能直接把 Vue 组件当作 Node 页面渲染;验证目标是模块入口能够被解析,并且导出名称存在。
2. 检查 CommonJS
// verify-cjs.cjs
const { WrButton } = require('./dist/wr-ui.cjs')
if (!WrButton) {
throw new Error('WrButton export is missing')
}
console.log('CommonJS require passed')
执行:
node verify-cjs.cjs
如果 ESM 成功而 CommonJS 失败,常见原因包括:
exports.require指向了不存在的文件;.cjs、.js和"type": "module"组合不一致;- 构建实际只生成了 ESM;
- 某个依赖只提供 ESM,但被错误地放进了 CommonJS 路径。
3. 检查类型
创建一个临时消费者:
import { WrButton } from 'wr-ui'
const props: import('wr-ui').WrButtonProps = {
disabled: true,
}
console.log(WrButton, props)
使用消费者项目自己的 TypeScript 运行:
npx tsc --noEmit
如果出现:
Could not find a declaration file for module 'wr-ui'
应检查:
package.json的types是否指向真实文件;exports["."].types是否存在;.d.ts是否被files排除;- 声明文件中的相对路径是否仍然指向源码目录;
- 发布包而不是工作区源码是否被实际测试。
4. 测试真实 npm 包
工作区中的路径别名可能掩盖发布问题。更可靠的步骤是:
npm pack
这会生成类似:
wr-ui-1.0.0.tgz
在临时消费者中安装:
npm install ../wr-ui/wr-ui-1.0.0.tgz
然后分别验证:
import { WrButton } from 'wr-ui'
import 'wr-ui/style.css'
这样测试的是 npm 实际交付的内容,而不是仓库中未打包的文件。
九、依赖声明决定安装和运行时边界
组件库依赖可以粗略分为三类。
dependencies
组件运行时必需,并且组件库希望自动安装的依赖:
{
"dependencies": {
"some-runtime-helper": "^1.2.0"
}
}
这类依赖通常会被消费者一起安装,也可能被打包器打进最终应用,具体取决于构建配置。
peerDependencies
要求宿主应用提供,并且需要与宿主版本保持兼容的依赖:
{
"peerDependencies": {
"vue": "^3.3.0"
}
}
Vue 组件库通常把 Vue 放在这里。^3.3.0 的含义是允许 >=3.3.0 且小于 4.0.0 的版本,但实际可用范围还要受到 npm、包管理器和其他依赖约束影响。
如果组件使用了只在更高 Vue 版本存在的 API,却仍声明 ^3.3.0,就会形成虚假的兼容承诺。正确做法是:
- 降低实现到声明范围支持的能力;
- 或提高
peerDependencies的最低版本; - 或发布新的主版本,取决于对已有消费者的影响。
devDependencies
只用于组件库开发和构建:
{
"devDependencies": {
"vite": "^...",
"vue-tsc": "^...",
"@vitejs/plugin-vue": "^..."
}
}
构建工具通常不应成为消费者的运行时依赖。
十、语义化版本:版本号描述公共契约变化
语义化版本通常表示:
MAJOR.MINOR.PATCH
对版本 2.4.1:
2是主版本;4是次版本;1是修订版本。
一般约定:
- PATCH:向后兼容的错误修复;
- MINOR:向后兼容的新功能;
- MAJOR:不向后兼容的变化。
“向后兼容”必须针对公共契约判断,而不是只看实现代码是否变动。
1. 形式化判断
可以把组件库版本视为一组公共契约:
P = {
运行时导出,
导入路径,
props,
emits,
slots,
类型声明,
CSS 类名,
CSS 变量,
样式导入路径,
peerDependencies 范围
}
从旧版本 P_old 到新版本 P_new:
- 若新增能力但没有删除或收紧已有能力,通常是兼容扩展;
- 若修复实现错误且旧用法仍然成立,通常是 PATCH;
- 若已有合法用法在新版本失效,则是破坏性变化,应进入 MAJOR。
可以写成直观条件:
兼容变化:
所有旧消费者的合法调用,在新版本仍然合法
破坏变化:
存在至少一个旧消费者的合法调用,在新版本不再合法
这不是自动工具能完全判断的数学证明,因为“合法调用”还包含 CSS 覆盖、构建工具行为和运行时语义。
2. 版本变化示例
PATCH 示例
旧版本:
<WrButton :disabled="true" />
新版本修复了禁用按钮仍然触发点击的问题,但没有改变 props、事件名和导入路径。若原有行为被确认是 bug,通常可以发布 PATCH。
不过,如果某些消费者依赖这个错误行为,修复仍可能造成运行时变化。版本判断应以公开契约和明确文档为准,而不能简单把所有行为改变都归入 PATCH。
MINOR 示例
新增可选 prop:
interface WrButtonProps {
type?: 'button' | 'submit' | 'reset'
disabled?: boolean
loading?: boolean
}
旧代码仍然可以正常使用:
<WrButton>保存</WrButton>
因此通常属于 MINOR。
MAJOR 示例:删除 prop
旧代码:
<WrButton :disabled="isDisabled" />
新版本删除 disabled 后,模板类型检查失败或运行时失去禁用效果。这是破坏性变化。
MAJOR 示例:收紧类型
旧版本:
interface WrButtonProps {
type?: string
}
新版本:
interface WrButtonProps {
type?: 'button' | 'submit' | 'reset'
}
从运行时看,组件可能仍然接收字符串;但从 TypeScript 公共契约看,原来合法的:
<WrButton type="custom" />
变成类型错误。因此类型收紧同样可能需要 MAJOR。
MAJOR 示例:改变 CSS 公共接口
旧版本提供:
.wr-button {
/* ... */
}
消费者依赖该类名调整圆角。新版本改成完全不同的类名,组件仍能渲染,但消费者的覆盖规则失效。这是样式层面的破坏性变化。
3. 预发布版本
预发布版本通常写成:
2.0.0-beta.1
2.0.0-rc.1
预发布版本不是普通的 2.0.0。它通常用于让消费者提前验证即将发生的破坏性变化。
版本比较时,预发布版本低于对应的正式版本:
2.0.0-beta.1 < 2.0.0
但预发布版本的安装行为还受包管理器和版本范围影响,不能只根据字符串比较推断消费者一定会自动升级到 beta。
十一、版本范围和 peer 依赖的真实风险
假设组件库声明:
{
"peerDependencies": {
"vue": "^3.3.0"
}
}
这表达的是:组件库承诺支持 Vue 3.3 及其兼容的后续版本,而不是承诺支持所有未来行为。
如果组件库下一版本开始使用 Vue 3.5 才存在的 API,却不修改 peer 范围,消费者安装时可能没有冲突提示,但运行时会出现:
某 API is not a function
这是一种“声明范围比真实需求更宽”的发布错误。
相反,如果只是组件库内部修复了不依赖 Vue 新 API 的问题,不应无理由提高 Vue 的最低版本,否则会把不必要的升级压力传递给消费者。
十二、常见失败表现和诊断路径
失败一:组件可以导入,但没有样式
表现:
组件正常渲染,但按钮像浏览器默认按钮
诊断:
- 检查消费者是否写了
import 'wr-ui/style.css'; - 检查 npm 包中是否存在
dist/style.css; - 检查
exports["./style.css"]是否指向真实文件; - 检查构建后的 CSS 是否包含
.wr-button; - 检查宿主应用 CSS 是否以更高优先级覆盖了组件样式。
恢复方式是补充 CSS 导出和消费者导入,而不是在组件 JavaScript 中强行操作 DOM 注入样式。
失败二:编辑器没有 props 类型提示
表现:
WrButton 可以导入,但 props 被推断为 any,或提示找不到声明文件
诊断:
npm pack --dry-run
确认:
dist/types/index.d.ts
确实进入发布包。然后查看:
{
"types": "./dist/types/index.d.ts",
"exports": {
".": {
"types": "./dist/types/index.d.ts"
}
}
}
如果路径存在但仍失败,检查消费者 TypeScript 版本是否支持当前 exports 类型条件,以及组件库声明文件中是否引用了未发布的源码路径。
失败三:构建产物包含两份 Vue
可以检查产物:
grep -R "createVNode" dist
这不是充分证明,但可以帮助发现 Vue 代码是否被打入库中。更可靠的方式是检查构建分析报告或直接查看产物依赖。
修复方向:
rollupOptions: {
external: ['vue']
}
并确认 vue 位于 peerDependencies,而不是只放在 devDependencies。
失败四:消费者导入深层路径失败
表现:
Package subpath './dist/...' is not defined by "exports"
这通常是 exports 正在发挥作用:它阻止消费者访问未公开的内部路径。
如果该路径本来就不应是公共 API,应修改消费者导入方式:
import { WrButton } from 'wr-ui'
如果确实需要独立入口,则为它生成稳定产物并显式声明:
{
"exports": {
"./button": {
"import": "./dist/button.js",
"require": "./dist/button.cjs"
}
}
}
不要通过删除 exports 来掩盖路径设计问题,否则整个 dist 目录都会变成事实上的公共 API。
失败五:按需加载没有生效
表现:
只使用 WrButton,但最终包仍然很大
诊断顺序:
- 消费者是否使用 ESM 入口;
- 是否通过
import * as Components遍历所有组件; - 组件库入口是否执行全量注册;
- 是否把所有组件放在带副作用的模块中;
- 消费者构建是否为开发模式;
- 是否把整个库作为单个动态 chunk 加载;
- 是否将按需加载误解为 npm 安装包只下载一个组件。
Tree Shaking 只有在构建阶段发生。直接在浏览器中通过 <script> 加载 UMD 或 CommonJS 产物,不会自动获得现代 ESM Tree Shaking 的效果。
十三、一个完整的消费者用法
假设组件库已发布为 wr-ui@1.0.0:
npm install wr-ui
消费者项目:
// main.ts
import { createApp } from 'vue'
import App from './App.vue'
import 'wr-ui/style.css'
createApp(App).mount('#app')
业务组件:
<script setup lang="ts">
import { ref } from 'vue'
import { WrButton } from 'wr-ui'
const count = ref(0)
function handleClick(event: MouseEvent) {
console.log('clicked:', event.currentTarget)
count.value += 1
}
</script>
<template>
<WrButton
:disabled="count >= 3"
@click="handleClick"
>
点击次数:{{ count }}
</WrButton>
</template>
运行时状态变化如下:
count = 0
└─ disabled = false,点击后 emit click,count 变为 1
count = 1
└─ disabled = false,点击后 count 变为 2
count = 2
└─ disabled = false,点击后 count 变为 3
count = 3
└─ disabled = true,浏览器阻止 button 点击,组件内部也不 emit
这里有两层保护:
- 原生
<button disabled>会阻止用户交互; handleClick内部再次判断props.disabled,防止程序化触发或边界行为导致错误事件发出。
组件库发布时,事件是否触发、禁用状态是否阻断事件,都属于运行时公共契约。即使它们没有反映在文件入口中,也应纳入测试和版本判断。
十四、发布前的最小检查流程
一个实际发布流程可以是:
# 1. 类型检查
npm run typecheck
# 2. 构建 JavaScript、CSS 和声明文件
npm run build
# 3. 查看最终发布文件
npm pack --dry-run
# 4. 生成本地 tarball
npm pack
# 5. 在临时消费者中安装 tarball
npm install ../wr-ui/wr-ui-1.0.0.tgz
每一步的验证目标不同:
| 步骤 | 主要验证内容 | 失败风险 |
|---|---|---|
typecheck |
源码和 Vue 类型是否正确 | 错误类型进入发布流程 |
build |
JS、CSS、声明文件能否生成 | 入口或产物缺失 |
npm pack --dry-run |
实际会发布哪些文件 | .d.ts 或 CSS 被漏发 |
| 安装 tarball | 真实 npm 包能否被解析 | 工作区路径掩盖发布错误 |
| 消费者构建 | import、类型、样式和运行时是否协同 | 用户项目中才暴露问题 |
如果构建失败,应停止发布,而不是手动上传部分 dist 文件。手工修补会使 JavaScript、类型和 CSS 处于不同版本,形成更难诊断的“半发布”状态。
十五、最终应形成的公共契约
一个组件库的公共 API 不仅是:
export { WrButton }
还包括:
运行时:
WrButton 是否能导入
props 默认值和可接受值
emits 的事件名和触发条件
slots 的结构
install 插件行为
类型:
.d.ts 是否存在
props、事件和插槽是否准确
类型是否过度收紧或过度放宽
样式:
style.css 是否能导入
CSS 类名和变量是否稳定
全局样式是否产生意外污染
模块:
ESM 和 CommonJS 是否都可解析
子路径是否明确导出
未声明的深层路径是否不被依赖
依赖:
Vue 的 peer 版本范围是否真实
external 配置是否与依赖声明一致
版本:
新旧消费者的合法调用是否仍然成立
破坏性变化是否升级 MAJOR
构建解决“文件如何生成”,类型解决“调用是否合法”,样式解决“视觉契约如何交付”,按需加载解决“未使用代码如何避免进入最终应用”,语义化版本则解决“这些契约发生变化后如何让消费者判断风险”。
只有这几条链路同时闭合,组件库才不只是一个能被安装的 npm 包,而是一个边界清晰、可验证、可升级的 Vue 工程交付物。
系列导航与关联阅读
- 系列入口:Vue 完整学习路线:从响应式与组件到工程化、SSR 和生产交付
- 上一篇:Vue Monorepo:Workspace、共享包、构建图、版本和边界
- 下一篇:Vite 插件开发:Hook、虚拟模块、转换、HMR 和调试
官方资料
本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论