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 版本的.use
api 以及 vue3 版本的.use
api,我们不难理解,神奇的 .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
字段进行判断,造成输出代码的冗余。