Vue 基础体系 · 第 59/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。

Vue 组件库发布:构建、类型、样式、按需加载和语义化版本

Vue 组件库发布不是把若干 .vue 文件压缩后上传 npm。一个可被其他项目稳定消费的组件库,至少要同时解决五类问题:

  1. 构建:源码如何转换为浏览器和打包器可以消费的产物。
  2. 类型:TypeScript 用户如何获得组件 props、事件、插槽和导出函数的类型提示。
  3. 样式:组件样式如何进入最终应用,如何避免样式丢失或污染全局。
  4. 按需加载:用户只使用一个组件时,其他组件是否会被打进应用。
  5. 语义化版本:代码、类型、样式和依赖发生变化时,如何判断应该发布哪个版本。

本文使用 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>

这段代码定义了三种公共能力:

  • typedisabled 是公共 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.jsonexportsimportrequire 字段选择其中一个。

为什么要 external Vue

假设组件库产物把 Vue 也打包进去:

宿主应用
├─ Vue 实例 A
└─ 组件库内部的 Vue 实例 B

这会产生至少三类风险:

  1. 包体积重复;
  2. 宿主应用与组件库使用不同 Vue 实例;
  3. 某些依赖实例身份或上下文的能力出现异常。

将 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"
  }
}

mainmoduletypesexports

  • 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 自定义属性;
  • bodyhtml 等全局选择器;
  • z-index、字体和布局规则;
  • 第三方组件生成的 DOM。

样式 API 也是公共 API

如果消费者这样覆盖组件:

.wr-button {
  border-radius: 0;
}

那么 .wr-button 就已经成为事实上的公共接口。即使 TypeScript 类型完全没有变化,删除这个类名或改变选择器优先级,也可能破坏消费者的视觉结果。

因此,发布说明不能只记录 props 和事件变化,也应记录:

  • CSS 类名是否变化;
  • CSS 变量是否删除或改名;
  • 样式导入路径是否变化;
  • 全局样式是否新增更高优先级规则。

七、按需加载包含三个不同问题

“按需加载”经常被混用。至少要区分:

  1. Tree Shaking:静态移除没有被使用的导出。
  2. 代码分割:通过动态导入让代码在需要时才下载。
  3. 按需样式:只加载某些组件的样式,而不是完整样式文件。

这三者的机制和结果不同。

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

其运行过程是:

  1. 初始构建生成主包和异步 chunk;
  2. 页面初次加载主包;
  3. defineAsyncComponent 首次需要组件时执行 import()
  4. 浏览器请求异步 chunk;
  5. 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.jsdist/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'

应检查:

  1. package.jsontypes 是否指向真实文件;
  2. exports["."].types 是否存在;
  3. .d.ts 是否被 files 排除;
  4. 声明文件中的相对路径是否仍然指向源码目录;
  5. 发布包而不是工作区源码是否被实际测试。

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 的最低版本,否则会把不必要的升级压力传递给消费者。


十二、常见失败表现和诊断路径

失败一:组件可以导入,但没有样式

表现:

组件正常渲染,但按钮像浏览器默认按钮

诊断:

  1. 检查消费者是否写了 import 'wr-ui/style.css'
  2. 检查 npm 包中是否存在 dist/style.css
  3. 检查 exports["./style.css"] 是否指向真实文件;
  4. 检查构建后的 CSS 是否包含 .wr-button
  5. 检查宿主应用 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,但最终包仍然很大

诊断顺序:

  1. 消费者是否使用 ESM 入口;
  2. 是否通过 import * as Components 遍历所有组件;
  3. 组件库入口是否执行全量注册;
  4. 是否把所有组件放在带副作用的模块中;
  5. 消费者构建是否为开发模式;
  6. 是否把整个库作为单个动态 chunk 加载;
  7. 是否将按需加载误解为 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

这里有两层保护:

  1. 原生 <button disabled> 会阻止用户交互;
  2. 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、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。