方案一:两个仓库
效仿 Vue,建两个仓库,一个适配 v2,一个适配 v3,取名 xxx
和 xxx-next
。
优势:
- 有大量的社区实践,能直接从仓库名区分版本。
劣势:
- 仓库存在两个大版本号同时维护的场景,比如 v2.x 支持 Vue2,v3.x 支持 Vue3。
- 需要同时维护两套代码,此外,其中仓库工程化部分相同,存在大量重复代码。
- 如果之后要支持新特性或调整构建相关改动,需要同时处理两边代码,成本较大。
方案二:两个分支
与方案一类似,在仓库中建两个分支 v2 和 v3,分别支持 Vue 的两个版本。
优势与劣势与方案一相同,唯一不同是只需要一个仓库,但是维护成本同样很大。
以上两种方案都需要维护两套代码,那么有没有一种解决方案是只用一套代码就能搞定的呢
方案三:使用 vue-demi
什么是 vue-demi
?
vue-demi
是一个让你可以开发同时支持 Vue2 和 3 的通用的 Vue 库的开发工具,而无需担心用户安装的版本。官方仓库,是由 Vue 团队核心成员 antfu 开发的。vueuse
, vue-charts
等包都使用了它。
有兴趣的可以去看看 作者对 vue-demi 的介绍
使用方法
任何与 Vue 相关的 API,都不再从原先的 vue
引入,而是从 vue-demi
引入。
import { ref, reactive, defineComponent } from 'vue-demi'
其余代码就像平常开发 Vue 时一样的去 coding 和发布就行了!
vue-demi
原理
往往使用起来越简单的代码,隐藏在其之下的原理就越值得探究。那么 vue-demi 究竟有什么黑科技呢?
vue-demi
利用了 NPM 的 postinstall
钩子。当用户安装所有包后,脚本将开始检查已安装的 Vue 版本,并根据 Vue 版本返回对应的代码。在使用 Vue2 时,如果没有安装 @vue/composition-api
,它也会自动安装。
以下摘取了部分核心代码:
const Vue = loadModule('vue'); // 加载 vue
function switchVersion(version, vue) {
// 将提前写好的文件 index 文件 copy 进去
copy('index.cjs', version, vue);
copy('index.mjs', version, vue);
copy('index.d.ts', version, vue);
if (version === 2) {
updateVue2API(); // 在 Vue2 时,安装 @vue/composition-api
}
}
// 判断版本号,将对应的文件写入 vue-demi 的导出文件
if (Vue.version.startsWith('2.')) {
switchVersion(2);
} else if (Vue.version.startsWith('3.')) {
switchVersion(3);
}
回到方案上来看:
优势:
- 开发者没有心智负担,和平时开发 Vue 项目的开发体验一致。
- 只需要维护一套代码,代码库也不会出现两个大版本同时维护的情况,开发成本低。
劣势:
- 使用 Vue2 的开发者需要额外安装
@vue/composition-api
,会稍微提升代码体积。
结论
为了让项目能低成本,快速地支持 Vue3(私心也想体验一些新的轮子)。
最终我选择方案三:使用 vue-demi。
迁移过程
安装 vue-demi
npm i vue-demi
# or
yarn add vue-demi
将 vue
和 @vue/composition-api
添加到 package.json
的 peerDependencies
中。
{
"dependencies": {
"vue-demi": "latest"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^2.0.0 || >=3.0.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
},
"devDependencies": {
"vue": "^3.0.0"
},
}
代码改造
Vue 插件
改造前:
const MyPlugin = {
/**
* install function
* @param {Vue} Vue
* @param {Object} options
*/
install (Vue, options = {}) {
... // 插件的默认参数处理
// 全局注册组件
Vue.component('my-component', MyComponent);
},
};
export default MyPlugin;
由于 Vue3 中插件的 install
方法传入的不再是 Vue 构造函数
,而是 app 实例
,这里只需要调整形参名即可:Vue
-> app
。
改造后:
const MyPlugin = {
/**
* install function
* @param {App} app
* @param {Object} options
*/
install (app, options = {}) {
... // 插件的默认参数处理
// 全局注册组件
app.component('my-component', MyComponent);
},
};
export default MyPlugin;
Vue 单文件组件
为了支持 Vue3,我们需要尽可能的使用 Vue3 的新语法。同时,也为了让代码改动尽可能小,我这次没有使用 setup API。
组件定义
改造前:
代码是 Vue2 组件定义语法,定义一个组件对象并向外默认导出。
export default {
name: ...
props: ...
watch: ...
};
在 Vue3 中,我们使用 defineComponent
这个全新的 API,用于 TypeScript
的类型推导,包裹该组件对象。
不一样的是,这里的 defineComponent
需要从 vue-demi
引入。
改造后:
import { defineComponent } from 'vue-demi'; // 需要从 `vue-demi` 引入
export default defineComponent({
name: ...
props: ...
watch: ...
});
渲染函数 render
改造前:
- Vue2 中渲染函数 render 方法会提供一个
createElement
的方法,通常我们用作h
。 - 要在 render 方法中获取当前的默认(
default
)插槽 VNode,我们可以使用this.$slots.default
。
render(h) {
...
const slot = this.$slots.default; // 默认插槽
return h('div', null, slot); // 将传入的默认插槽内容使用 div 包裹
}
Vue3 中 render 方法不再提供 h
方法,需要自行从 vue
引入。同样,这里也从 vue-demi
引入。
获取默认插槽需要将 this.$slots.default
作为方法调用 this.$slots.default()
。
但 this.$slots.default
无法从 vue-demi
引入,又与当前运行时的 Vue 版本有关,该怎么办呢?
vue-demi
为我们提供了两个额外的 API,isVue2
和 isVue3
,用于判断当前的环境。
改造后:
import { h, isVue2 } from 'vue-demi'; // 需要从 `vue-demi` 引入
render(h2) {
...
// vue2
if (isVue2) {
const slot = this.$slots.default; // 默认插槽
return h2('div', null, slot);
}
// vue3
const slot = this.$slots.default(); // 默认插槽
return h('div', null, slot);
}
跨组件通信
改造前:
我们可以很容易的使用 Vue2 中的 $emit
和 $on
来实现事件总线(EventBus
)。在我的这个库中, 子组件需要派发事件到指定的祖先组件,我借鉴了 element-ui 利用 `emit` 和 `on` 的实现:
- 祖先组件
<Ancestor>
在生命周期中监听事件
created() {
this.$on('event', handler)
}
- 子组件不断通过
$parent
找到指定的祖先组件,找到后利用parent.$emit.call(parent, event, args)
向祖先元素派发事件。
// 派发事件到指定祖先组件
export default defineComponent({
...
methods: {
$_dispatchComponent(componentName, event, args) {
let parent = this.$parent || this.$root;
let name = parent.$options.name;
// 通过循环不断向上查找 name 一致的组件
while (parent && (!name || name !== componentName)) {
parent = parent.$parent;
if (parent) {
name = parent.$options.name;
}
}
if (parent) {
parent.$emit.call(parent, event, args); // 找到后,派发事件
}
},
},
},
Vue3 中,由于移除了 $on
,实现事件总线已经没办法使用 Vue 自身的 API 了。
我们需要借助第三方库来完成,例如 mitt 或 tiny-emitter。这里我选择了 mitt
,API 够用,也比较轻量。
改造后:
- 祖先组件使用
emitter.on
代替$on
:
import mitt from 'mitt';
export default defineComponent({
...
created() {
this.emitter = mitt();
this.emitter.on(event, handler); // 监听事件
},
beforeUnmount() {
this.emitter.all.clear(); // 解绑事件
}
})
- 子组件派发事件的方法从
parent.$emit
改成parent.emitter.emit
。
parent.emitter.emit(event, args);
项目源码
- github 仓库:https://github.com/Leecason/vue-rough-notation
- 在线地址:Vue App
小结
- 我们可以利用
vue-demi
来开发同时支持 Vue2 和 vue3 的第三方包,开发和迁移成本小。 - 使用
vue-demi
的开发体验与平时开发 Vue 一致,心智负担小。 vue-demi
为我们提供了额外的 API,isVue2
和isVue3
,用于判断当前的环境。- 在 Vue3 中实现事件总线,需要借助第三方包,如
mitt
或tiny-emitter
。
链接:https://www.zhihu.com/question/475451857/answer/2377600057