vue 通用组件库开发
代码开源地址 https://github.com/X-sky/vue-uni-component
开发背景
在日常的 2B 业务开发中,我们不可避免的会遇到一个系统内部出现跨版本框架————甚至跨时代框架的使用。以我之前碰到的一个项目而言,项目横跨 jquery+LayUI ,vue2,vue2.7, vue3。但作为同一个项目,难免会有部分业务组件有所重合。
以往的做法是,将原有的业务组件迁移至新项目中。但考虑以下场景,有一个通用组件 反馈按钮 FeedbackDialog,目前已经完成了迁移,在该项目所有版本的代码中都有应用。也就是说,一个相同逻辑的业务,出现在四种不同的框架中。现在产品需求要求优化反馈弹窗。那么可想而知这个开发和测试的成本都是巨大的,开发需要在四个框架中分别进行该组建的修改,而测试也需要熟悉不同的框架出现在系统的哪个部分,并针对性的进行测试

一个跨框架的巨石应用
而如果这种时候,我们的 FeedbackDialog 组件可以避免在不同框架中重复开发,那么开发和测试都能够节约极大的工作量,如下图所示

使用跨框架通用组件的跨框架巨石应用
组件库开发
vue 组件库原理
在开发组件库之前,我们需要先搞清楚两个问题:
- 什么是组件
- 什么是组件库
尽管我们在开发过程中更多的仍然是扮演“调包工程师”的身份,但只有搞清楚这两个问题,我们才能知道自己要开发什么,自己在开发什么
什么是组件
我们日常开发中最常接触的就是组件开发。但这里首先需要澄清一个概念:组件 ≠ SFC。根据 vue 官网的定义
SFC 是一种特殊的文件格式,允许我们 Vue 组件的把模板、逻辑和样式聚合在一个文件中。Vue SFC 是一种框架特定(framework-specific)类型文件,因此必须被
@vue/compiler-sfc预编译成标准的 javascript 和 css。
因此,SFC 只是 vue 组件的一种表现形式,jsx,h 函数无不如是。但 SFC 是一个帮助我们理解组件的很好的切口————它最终会被编译为标准的 javascript。一个编译后的 SFC 是标准的 ES 模块,vue官网也提供了 SFC-playfround,可以更直观的看到它们是如何被编译的。
于是我们的第一个问题【什么是组件?】就得到了很好的回答:
Nothing magic, just javascript
app.use 的时候发生了什么
当我们使用各种基于 vue 的插件/组件的时候,我们熟悉了这样的用法:
// 摘自element-plus官网 https://element-plus.org/zh-CN/guide/quickstart.html
// main.ts
import { createApp } from 'vue';
import ElementPlus from 'element-plus';
import 'element-plus/dist/index.css';
import App from './App.vue';
const app = createApp(App);
app.use(ElementPlus);
app.mount('#app');或者是 vue2 版本的
// 摘自element-ui官网 https://element.eleme.cn/2.0/#/zh-CN/component/installation
import Vue from 'vue';
import ElementUI from 'element-ui';
import 'element-ui/lib/theme-chalk/index.css';
import App from './App.vue';
Vue.use(ElementUI);
new Vue({
el: '#app',
render: (h) => h(App)
});那么 .use() 到底做了哪些事情?查阅 vue2 版本的.useapi 以及 vue3 版本的.useapi,我们不难理解,神奇的 .use 归根到底只做了一件事:安装插件(Plugins)
.use 接收一个为 带有 install 方法的 Object,或者是作为 install 方法本身的 Function。而 install 方法接收两个参数:应用实例App 和 插件选项Options。在 vue2中,第一个参数是全局 Vue 对象
以 vue3 为例,我们可以在 runtime-core 源码 中找到对应的类型定义
type PluginInstallFunction<Options = any[]> = Options extends unknown[]
? (app: App, ...options: Options) => any
: (app: App, options: Options) => any;所以当我们在说“vue 组件库开发”的时候,这种说法或许太过特化。更通用的说法应该是:vue 插件开发。
至此,最开始提出的第二个问题我们也已经得到了解答:
组件库是一种插件
常用打包工具
目前前端社区流行的打包库主要有三种:webpack, rollup, esbuild。当然近期还有 rspack rolldown 等基于 Rust 的打包库兴起。但由于生态和稳定性的原因,不适合用于企业生产。也正是由于这个原因,也应该放弃 esbuild。尽管它基于 go 开发,速度非常快,但生态并不完善,对于我们接下去要做的项目来说,灵活度也稍显不足
尽管说起稳定性我们应该优先选择 webpack,但 webpack 其实并不适合作为组件库打包的工具。或者说,使用 webpack 打包组件库会较为麻烦,因为 webpack 会默认将所有依赖项打包进产物当中,为了得到合适的分发结果,需要做出很多额外的配置
并且就 vue 的生态发展趋势而言,拥抱 vite 几乎是不可避免的。而 vite 天然基于 rollup。因此最终选定 rollup 作为打包工具
方案决策
在选择方案的时候有两条路摆在我们面前:
- dispatch 模式
- adaptor 模式
dispatch 模式
使用 Vue SFC 进行开发,在编译阶段通过 monorepo/container 中不同版本的 template compiler 进行编译。最终由各个容器输出不同版本的产物
理论上来说,vue2 和 vue3 的模板语法差距其实并不大。vue2.7 不仅内置了 setup 支持,vue2 也可以通过 @vue/composition-api 结合 unplugin-vue2-script-setup 来进行语法层面的抹平。

这种模式的优点在于:
- 开发成本较低,不需要改变原有开发习惯。后续交付团队后,团队成员不需要了解实现细节即可开发
- 针对不同的版本进行编译。有一定优化机制
- 使用不同的
container进行分发编译,方便进行定制化配置, - 将不同版本
vue的代码进行了隔离
缺点:
- 舍弃了其他组件库的复用,所有组件都需要手撸
- 组件间引用的时候,需要放弃部分语法糖
- 后续如果出现破坏当前 SFC 模式的
vue版本,或者出现了新特性,仓库将无法持续更新(比如defineOptions语法糖由于unplugin-vite-script-setup没有提供,我们只能选择放弃该语法糖,或者对该仓库进行PR)
TIP
相较于我的这个 demo 示例,目前已经有相对更成熟的开源库使用了类似的思想,详见 tiny-vue。虽然不是使用 monorepo 进行分发,但也是使用了类似的思想,对不同版本的 vue 进行了编译层的分发转译
adaptor 模式
定义内置 adaptor-runtime 语法,使用自定义的模板语法 template syntax 进行开发,最终打包一套产物与不同版本的 runtime-adaptor,在应用中利用 adaptor 对不同版本的 vue 进行适配
初步考虑是类似当时最开始做小程序转 ArkUI 的思路,使用类 vue 的模板语法进行开发,单独开发一层adaptor-runtime,最终根据用户侧的 vue 版本不同使用不同的 runtime。
优点:
- 不需要多次编译,多版本
vue使用相同的产物,进一步减小业务层代码差异 - 使用
runtime-adaptor方式,可扩展性极强。理论上这种方式甚至可以通过对不同其他组件库如element-plus的适配,甚至其他框架如react,实现一套代码,处处使用的结果 - 从编译层到运行时都由组件库内部控制,相较于
dispatch模式委托vue/template-compiler和runtime/core更可控
缺点:
- 开发成本极高。需要团队实现
dsl,如果使用标准tsx,则需要团队成员开发时学习tsx。同时还需要根据目标产物实现不同的adaptor-runtime runtime-adaptor的存在会导致运行时有性能损耗
毫无疑问,作为独立开发而言,这个模式下的通用组件库开发无疑是地狱难度
PS. 看到这篇文章的时候组件库已经使用 dispatch 模式基本搭建完毕,而且其使用的 jsx transform 模式,不仅需要对 vue 不同版本差距有所了解,而且需要对 tsx 流程和产物有所了解,一开始就不在选择范围内
选择
考虑到组件库的使用场景,综合团队规模和后期维护的复杂度,最终选择了 dispatch 模式 进行开发
仓库结构
使用
monorepo实现dispatch模式
多版本模板编译支持
提到多版本 vue 的支持,自然绕不开 vue-demi。vue-demi 是 Vue 核心团队成员 antfu 开发的一个小工具,能够支持对 vue 代码引用的转发。Vueuse内部就是用了 vue-demi,从而实现对多版本的 vue 的支持。
但由于 vue-demi 只是对 vue 版本做了转发,因此如果是纯 js 库开发(例如 @vueuse/core)之类的库,不必关心_模板解析器冲突_的问题。而开发组件库则必须关注这个问题。因为不同版本的 vue 使用了不同版本的模板编译:
- vue3.x: vue/compiler-sfc
- vue2.7.x: vue/compiler-sfc@2.7
- vue2.x: vue-template-compiler
可以预见的是,即使使用render函数,我们也无法绕开版本问题。因此不如将这个问题提前到编译阶段解决。借助 pnpm 的 monorepo 模式,我们可以分别创建三个不同的 vue 仓库,利用各自不同的 package.json vite.config.ts 配置,编译多个版本的 vue 组件产物。
- containers/v2 -> @vue-uni-ui/v2
- containers/v2.7 -> @vue-uni-ui/v2.7
- containers/v3 -> @vue-uni-ui/v3
通过在 vite.config.ts 中配置 resolve.alias ,手动将 vue 以及 vue-demi 版本映射到对应的仓库内,例如containers/v3中的vite.config.ts需要配置的 alias 如下:
export default {
resolve: {
alias: {
vue: resolve(
__dirname,
'node_modules/vue3/dist/vue.runtime.esm-browser.js'
),
'vue-demi': resolve(ROOT_DIR, 'node_modules/vue-demi/lib/v3/index.mjs')
}
}
};理论上这样我们就暂时实现了不同的容器隔离。
但是实际上这里有一个潜在的问题,那就是 vue-template-compiler 的 vue 依赖是没有显性的规定在 peerDependencies 中的。因为 vue 的版本需要与 vue-template-compiler 的版本 完全一致,因此 vue-template-compiler 只是在其 index.js 的头部做了一次检测:
try {
var vueVersion = require('vue').version;
} catch (e) {}
var packageName = require('./package.json').name;
var packageVersion = require('./package.json').version;
if (vueVersion && vueVersion !== packageVersion) {
var vuePath = require.resolve('vue');
var packagePath = require.resolve('./package.json');
throw new Error(
'\n\nVue packages version mismatch:\n\n' +
'- vue@' +
vueVersion +
' (' +
vuePath +
')\n' +
'- ' +
packageName +
'@' +
packageVersion +
' (' +
packagePath +
')\n\n' +
'This may cause things to work incorrectly. Make sure to use the same version for both.\n' +
'If you are using vue-loader@>=10.0, simply update vue-template-compiler.\n' +
'If you are using vue-loader@<10.0 or vueify, re-installing vue-loader/vueify should bump ' +
packageName +
' to the latest.\n'
);
}这就导致我们如果在安装了多个 vue 仓库,那么 vue-template-compiler 实际引用的 vue 包将是不可控的。以 pnpm 为例,由于 vue-template-compiler 内部没有相关依赖声明,因此 pnpm-lock.yaml 也就没有相关的依赖绑定,则 require('vue') 完全依赖于 .pnpm 仓库内的 vue 版本。我的测试结果是 windows 下基本会报错,而 mac 下则不会报错。但开发环境不能依靠运气,这时候可以用 pnpm 提供的一个设置项 pnpm.packageExtensions 强制设置依赖。在根目录的 package.json 下添加如图所示的代码,给 vue-template-compiler 添加依赖项
"pnpm": {
"packageExtensions": {
"vue-template-compiler": {
"peerDependencies": {
"vue": "~2.6.14"
}
}
}
}注:这里的 ~2.6.14 需要与 containers/v2 中的vue-template-compiler 版本一致
同时,我们修改 path.ts 中的相关 VUE_LIB 代码,将 alias 映射为 containers 内部的 vue,将 vue-demi 直接映射为对应 vue-demi/lib 内部的 index.mjs。这样我们就实现了依赖的完全解耦。根目录的 vue 仅负责 @vue-uni-components 相关仓库的开发,而 dev 以及 build 则由 containers 的内部依赖负责。
style 支持
通过在不同容器内部编译对应的模板,我们已经可以实现通过对应的 compiler 编译 style 文件。通常设计组件库样式时需要的问题有两个:
- 如何设计统一的样式,以:
- 方便组件间的样式共享
- 保证组件的样式不受外部环境 css-reset 的影响
- 如何设计结构,使得用户可以自定义样式
目前市面上主流的组件库样式设计都会影响到全局。比如:
Vuetify中的 _reset.scsselement-plus中的 reset.scssarco-design中的 normalize.less 而且这些组件库都全局使用,且可能与其他组件库互斥。而vuetify通过提供对应的 scss 变量以供手动关闭 reset。
但按照当前所要实现的 vue通用组件库的使用场景,很可能是无法使用全局reset-css的,因此同样需要考虑提供配置项,在使用阶段判断是否需要重置。
另外,上述几个库都使用了 css 变量进行组件样式的统一,在使用阶段也使用 css 变量进行自定义主题设置。
Vuetify- arco-design
但是有几个缺点:
- 无法与 js 结合。如果 UI 涉及
echarts等图标库的使用,则无法避免在 js 额外维护主题色变量 - 覆写与重设涉及多个配置,较为复杂,其中
Vuetify还有config和scss两套主题色需要维护 - 如果不需要使用组件库,但是想要使用组件库的样式设计规范时(如调色板 palette, 文本规范 font 等),我们必须深入到
node_modules或者源码内部了解各个 css 变量名
因此我们考虑一套流程
- 开发时通过 js 定义 css 变量,组件内部使用变量名开发
- 使用时,在 hook 初始化的时候进行 css 变量注册,同时暴露变量提供给诸如
echarts等库使用。如果项目有 ts 支持,甚至可以提供变量名提示
组件库调试
源码调试
pnpm dev:3 pnpm dev:27 pnpm dev:2 三个脚本可以同时执行。在各自的 container 内部,resolve 会将依赖解析为正确的地址。
注意:在编写测试用例以及开发的时候,部分语法由于 vue2 与 vue3 的解析不同,因此需要使用更为通用的写法。例如 属性的双向绑定 操作,在 vue2 中,模板语法糖为 :visible.sync ; 而在 vue3 中,语法糖则为 v-model:visible,因此需要使用通用写法:
<script>
import { ref } from 'vue-demi';
const visible = ref('false');
const updateVisible = (v) => {
visible.value = v;
};
</script>
<template>
<uni-dialog :visible="visible" @update:visible="updateVisible"> </uni-dialog>
</template>如果每次都需要在 container 容器内部手写一个测试组件,那么加上 cdn 调试,我们会需要编写四次近似的组件。因此通过上述 通用写法 ,我们可以考虑新增一个测试组件仓库 @vue-uni-ui/components-test。在仓库内编写测试组件,然后取消 containers 中每个容器对 @vue-uni-ui/components 的依赖,修改为对测试仓库的依赖,然后引用对应的测试组件即可。因此我们也需要补充脚本,在 ui:create 的时候,补充自动新增对应的测试组件模板,避免免手动创建的心智负担
关于 @vue-uni-ui/components-test
尽管为了减小后续开发时候的心智负担,补充了 ui:create 命令进行自动化创建。但在实际开发中,会出现开发不按照开发文档进行开发的问题。过度依赖脚本命令,也是团队协作中的一个错误实践。或许需要通过在提交时补充校验,或者简化开发流程的方式,避免不同开发经手过的项目结构完全不同的问题
在后续集成了 unit-test 以及 e2e test 后,components-test 包将从代码中移除
产物调试
此调试仅需要在开发阶段进行。理论上框架搭建完成后,无需每次都进行所有产物的调试。只要编译成功,产物都将包含相同的业务逻辑
mjs
link:local 脚本。先在产物内部 link 对应的依赖,再全局 link 对应依赖。
cdn
cdn 形式的产物调试则相对来说简单很多。在 pnpm build 之后执行 pnpm dev:cdn 即可。pnpm dev:cdn 命令会自动执行复制 iife.js style.css 等文件至对应文件夹的命令,并执行 vite 调试。通过注释 cdn-playground/index.html 文件中不同版本的 vue 及其对应的内容。进行多版本产物的调试。
⚠Attention 注意:cdn 模式调试下,自定义组件有两点注意事项,这两点同时也是 html 标准的限制:
- 不能使用自闭合标签
<!-- 可以,但只会渲染第一个自定义组件 -->
<uni-template />
<!-- 会无效 -->
<uni-dialog />- 不能使用大驼峰形式组件
<!-- html会默认转化组件名,等效于 unitemplate。由于我们的注册名为 UniTemplate,因此 UniTemplate 或 uni-template 都是可行的。而unitemplate无法匹配到 -->
<UniTemplate />问题记录
关于模块化
// exportLib.js 导出
const foo = () => {};
export { foo };
// 使用
import { foo } from 'exportLib.js';
foo();
// 使用2
import * as exportLib from 'exportLib.js';
exportLib.foo();// 导出
const foo = () => {};
export { foo };
export default {
foo
};
// 使用
import exportLib, { foo } from 'exportLib.js';
exportLib.foo === foo; // true如果是第一种方式,在 import 引入的时候会显得有些啰嗦;但如果是第二种导出方式,在进行 cdn 或者 umd 方式导出的时候,如果我们要使用整体对象,就必须使用 .default 的方式获取默认导出
<script src="./exportLib.iife.js"></script>
<script>
__GLOBAL_EXPORT_LIB__.default.foo();
__GLOBAL_EXPORT_LIB__.foo();
</script>因此我们在第一种导出方式的基础上进行优化
// exportLib.js 导出
const foo = () => {};
const defaultExport = {
foo
};
export { defaultExport as default, foo };
// 使用
import exportLib, { foo } from 'exportLib.js';
foo();当然这样在 import * as exportLib from ‘exportLib.js’ 的时候就会出现 default 了。没有全部兼顾的方法,只能根据使用场景具体优化
关于创建服务式组件
vue2 和 vue3 的 render 模式不同。vue3 每一个组件都有对应的 appContext,可以通过 customRender 创建自定义渲染。element-plus 中的 MessageBox 就是使用了对应的方式,利用 render 函数实现 api 调用时,挂载组件。(render 就是 vue 内部利用 createRenderer创建的函数)。
但是 vue2 单实例的模式决定了 vue2 不会有所谓的 appContext,也不会有 render 这样的 api。因此只能通过 Vue.extend(comp) 的方式创建新的实例。因此我们的 util 中的 useComponentService 函数就不得不使用 vue-demi 的 isVue2 字段进行判断,造成输出代码的冗余。