作者:louiszhai,腾讯 IEG 前端开发工程师
Vue3 性能提升了 1.3~2 倍,SSR 性能提升了 2~3 倍,升级 Vue3 正是当下。
背景
原计划 2019 年发布的 Vue3,又经过一年的再次打磨,终于于去年 9 月正式发布。随后,不少 UI 组件库都积极参与适配,去年 12 月,Element-plus(Element-ui 官方升级版)也发布了 beta 版。
由于项目中用到了 Element-ui 组件,组件库未适配的情况下,不敢贸然升级 Vue3。Element-plus 发布后,又经过 1 个月的观察、测试和调研,发现 Element-plus 相对成熟(还有少量 bug,后续会讲),便开始尝试升级 Vue3。
如何升级 Vue3
有两种方案可以快速升级 Vue3:
-
一种是使用微前端轮子,我基于 qiankun2,搭建了 Vue3 项目基座,为了保证平稳升级,子项目继续使用 Vue2,然后不断的把子项目的页面迁移到基座项目。 -
另一种是,直接升级 Vue3,将项目中的 Vue2 依赖库升级到 Vue3 的最新版(当前最新版是v3.0.11),并且稍微改造 webpack 编译脚本,使之适配 Vue3。
之所以会有方案一,主要还是担心 Element-plus 不够稳定,如果有天坑,又无法绕过去,除了向饿了么团队提交 PR,微前端兜个底也是不错的应急措施。
就这样微前端方案又运行了 1 个月,部分页面已完成升级,运行良好,实践证明 Element-plus 比想象中稳定,这增加了我对于方案二的信心。考虑到还有少量业务复杂的页面,在微前端模式下,子项目的各种数据多经过一层 qiankun 的 proxy 代理,性能有损耗,影响了页面更新,于是一次性将剩余的页面全部迁移到 Vue3 项目中。
实践证明,除非比较复杂的项目,或者依赖组件库没升级等原因不适合升级外,常规情况下,升级 Vue3 都是一个不错的选择。
为什么要升级 Vue3
为什么要升级 Vue3,这是一个几乎不需要回答的问题。升级 Vue3 后,代码结构更加清晰内聚,响应式数据流更加可控,节省了很多心智成本,从而使得开发效率大幅提升。Vue3 还带来了很多新特性,框架层面运行性能更高(性能提升了 1.3 至 2 倍,SSR 性能提升了 2 至 3 倍),Composition API 使得代码拆分,函数封装更容易,复杂项目也随之更容易管理。
Vue2 中,相关的逻辑经常分散在 option 的 data、watch、computed、created、mounted 等钩子中,阅读一段代码,经常需要上下反复横跳,带来了部分阅读障碍。钩子又依赖 Vue 实例,代码封装基于天生携带钩子的 Mixin 去做,更加容易和相对方便。
但正因为如此,Mixin 的钩子容易不自觉的越界,插手到页面或组件的内部变量和方法管理过程中;甚至,多个不同的 Mixin,相互之间就很容易冲突,项目开发者,在引入 Mixin 和避免冲突之间需要保持微妙的平衡,不但增加心智负担,还带来了副产品:本身扑朔迷离的 this 变得更加不确定。因此,大型项目 Mixin 几乎都是一种反模式。
现在这些框架问题,都由 Vue3 的 Composition API 解决了。
Vue3 带来了哪些新特性
我们先看一些立马能感受到变化的特性。
Proxy 代理
这是一个一上手 Vue3 就能感知的变化。即使你在 Vue3 中编写 Vue2 风格的基于 option 的代码,Proxy 也是默默提供着数据响应式。
const observe = (data) => {
Object.keys(data).forEach((key) => {
const initValue = data[key];
let value = initValue;
if (typeof initValue === 'object') {
observe(initValue);
return;
}
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get() {
console.log('visit key value =', key, value);
return value;
},
set(val) {
console.log(`[${key}]changed,old value=${value}, new value = ${val}`);
if(value !== val) {
value = val;
}
}
});
});
};
const data = {};
Array.from(new Array(100), () => "").forEach((item, i) => {
data[i] = { value: i * 2 };
});
console.time();
observe(data);
console.timeEnd(); // default: 0.225ms
data.a = { b: 1 };
data.a.b = 2;
如上所示,Vue2 的数据响应式是通过 Object.defineProperty 实现,这是一个深度遍历的过程,无论 data 中包含多少层数据,都需要全部遍历一遍。深度遍历,给对象的每个自身属性添加 defineProperty,需要不小的性能开销,同时后面新增到 this 中的属性不提供响应式监听,因此我们需要使用诸如this.$set
这种方式去添加新属性。
Proxy 就没有这个问题,如下所示。
const observe = (data) => {
return new Proxy(data, {
get(target, key, receiver) {
console.log('visit', key);
return Reflect.get(target, key, receiver);
},
set(target, key, value, receiver) {
console.log(`[${key}]changed, value = ${value}`);
Reflect.set(target, key, typeof value === 'object' ? observe(value) : value, receiver);
}
});
};
let data = {};
Array.from(new Array(100), () => "").forEach((item, i) => {
data[i] = { value: i * 2 };
});
console.time();
const proxy = observe(data);
console.timeEnd(); // default: 0.041ms
proxy.a = { b: 1 }; // [a]changed, value = [object Object]
proxy.a.b = 2; // visit a \n [b]changed, value = 2
Proxy 不但使得 data 获得了新属性的响应性,整个响应式处理过程的效率还提升了数倍,由此带来了 Vue3 的大部分性能提升。
Composition API
为了保持对 Vue2 的向下兼容,Vue3 中仍然支持纯 Option 配置的书写方式,这为升级提供了便利,平移 Vue2 的代码,只需少量改动,便可正常运行。
同时考虑到上手难度,Vue3 的顶层代码风格与 Vue2 保持一致,依然是 export 一个对象,对象包含了一系列的配置,其中便有 setup 入口函数。我们先来看一段代码,然后逐个解读。
import { defineComponent, ref, reactive, toRefs, watch, watchEffect, computed, onMounted } from "vue";
export default defineComponent({
setup(props, context) {
const selectRef = ref(null) // 作为下拉框的ref引用
const state = reactive({ // 响应式数据,类似于Vue2的this
num: 0,
});
const { init } = toRefs(props);
watch(() => state.num, (newVal, oldVal) => {
console.log(newVal, oldVal);
});
watchEffect(() => {
console.log(state.num);
});
const num2 = computed(() => state.num + 1);
onMounted(() => {
state.loaded = true;
});
return { selectRef, state, num2, init, context };
}
});
setup 作为入口函数,包含两个参数,分别是响应式的 props 外部参数,以及 context 对象,context 包含 attrs、emit、expose、props、slots 五个参数,如下所示:
在 Vue3 的设计里,setup,以及从 vue 对象中解构出来的各种生命周期函数,执行优先级高于 Vue2 中的各种生命周期钩子,因此
beforeCreate() {
console.log('beforeCreate');
},
created() {
console.log('create');
},
setup() {
console.log('setup');
},
这段代码的输出依次是 setup、beforeCreate、created。
ref、reactive
setup 中,第一句const selectRef = ref(null);
,这里定义的是一个响应式的数据,可传递给 template 或 render,用于下拉框组件或下拉框 dom 绑定引用。为什么使用 ref,不使用 reactive 呢?ref 和 reactive 都可以给数据添加响应性,ref 一般用于给 js 基本数据类型添加响应性(当然也支持非基本类型的 object),reactive 只能用于代理非基本数据类型。null 是基本数据类型,只能使用 ref,那既然如此,为什么不在所有情况都使用 ref 呢?我们来看一段代码:
const num = ref(0);
num.value = 1;
const obj = { a: 1 };
const refObj = ref(obj);
const reactiveObj = reactive(obj);
refObj.value.a = 2;
reactiveObj.a = 3;
console.log(num, refObj, reactiveObj);
我们注意到,使用 ref api 时,数据变成了对象,值就是 value 属性的值,如果数据本身就是对象,依然会多一层 value 结构,而 reactive 没有这些副作用。同时,还有一个有意思的现象是,所有的源数据,都需要经过响应式 api 包裹,然后才能使用,这跟前面提到的 Proxy 原理有关,Proxy 代理数据时,需要基于返回的代理进行数据更新。
toRefs
除了 ref、reactive 外,还有一个常用的响应式 api——toRefs。为什么需要它,这是因为响应式对象,经过解构出来的属性不再具有响应性,toRefs 就是为了快速获得响应性的属性,因此这段代码const { init } = toRefs(props);
,就是为了获得响应式属性 init,想要保留 props 参数的响应性,建议这么做。
watch、watchEffect
const num = ref(0);
const state = reactive({
num: 0,
});
const obj = { num: 0 };
watch(num, (newVal, oldVal) => {
console.log("num", newVal, oldVal);
});
watch(() => state.num, (newVal, oldVal) => {
console.log("num", newVal, oldVal);
});
watch(() => obj.num, () => {
console.log("这里不会执行");
});
num++;
state.num++;
obj.num++;
如上,watch api,它需要接受一个具有返回值的 getter 函数或者 ref(如() => state.num,ref)。
如果需要监听多个值,如下所示:
const num1 = ref(0);
const num2 = ref(0);
watch([num1, num2], ([newNum1, newNum2], [prevNum1, prevNum2]) => {
console.log([newNum1, newNum2], [prevNum1, prevNum2]);
});
num1.value = 1; // [1, 0], [0, 0]
num2.value = 2; // [1, 2], [1, 0]
可见多个数据的每次更新都会触发 watch。想要监听一个嵌套的对象,跟 Vue2 一样,依旧需要使用 deep 选项,如下所示:
const state = reactive({
attr: {
id: 1,
},
});
watch(() => state, (currState, prevState) => {
console.log(currState.attr.id, prevState.attr.id, currState === prevState, currState === state); // 2, 2, true, true
}, { deep: true });
watch(() => state.attr.id, (currId, prevId) => {
console.log(currId, prevId); // 2, 1
});
state.attr.id = 2;
看到差别了吗?监听响应式对象时,返回的是对象的引用,因此 currState,prevState 指向是同一个最新的 state,如果需要获取变化前的值,建议返回监听的属性,如watch(() => state.attr.id)
,刚好 state.attr.id 是一个基本类型的值,那么 deep 也不需要。
watchEffect 是 Vue3 新增的 api,watchEffect 会自动运行一次,用于自动收集依赖,但不支持获取变化前的值,除此之外,与 watch 用法一致。那么 watchEffect 适用什么场景呢?这也是我刚上手 Vue3 的困惑之一。我们来看一段代码:
const rights = {
admin: ["read", "write"],
user: ["read"],
};
const state = reactive({
rights: "",
})
const userInfo = reactive({ role: "user" });
userInfo.name = "Tom";
userInfo.role = "admin";
watch(() => userInfo.role, (newVal, oldVal) => {
state.rights = rights[newVal];
});
watchEffect(() => {
state.rights = rights[userInfo.role];
});
以上代码中,watch 中的逻辑只能在 userInfo 变化后执行,因此 state.rights 不会提供初始值,相反,watchEffect 中 state.rights 由于自动依赖收集,获得了一次赋值的机会。
这样做的好处是什么呢?在实际项目中,userInfo.role 可能是一个全局 store 中的数据,用户登录进来后,就会通过接口获取初始值,我们并不能确认,用户进到其中一个页面时,userInfo.role 的值是否已经被接口更新,且 userInfo 变化前的值我们也不关心,watchEffect 就非常适合这种场景,它会自动进行一次初始化,并且在变化后,及时更新值。
watch 和 watchEffect 的监听会在组件销毁时自动取消,除此之外,可以通过它们返回的函数手动取消监听,如下所示:
const stopWatch = watch(selectRef, (newVal, oldVal){});
const stopWatchEffect = watchEffect(selectRef, (newVal, oldVal){});
setTimeout(stopWatch, 1000);
setTimeout(stopWatchEffect, 1000);
watchEffect 更多的用法,请参考官方文档。
computed
computed 的使用如下:
const num = ref(1);
const num2 = computed(() => num * 2);
num2.value++; // error
num2 是一个不可变的 ref 对象,不能直接对它的 value 属性赋值。
computed 还可以接收一个带有 get 和 set 函数的对象,来创建一个可读写的 ref 对象,如下所示:
const num3 = computed({
get: () => num.value * 2,
set: (val) => {
num.value = val;
},
});
num3.value = 100;
console.log(num.value, num3.value); // 100 200
自定义 Hooks
Vue3 的 Composition 之所以这样实现,主要原因就是为了便于代码拆分,降低耦合,我们不妨来实现一个自定义的 hooks。
// page.vue
import useCount from "./useCount";
export default {
setup() {
const { num, double, plus } = useCount(1);
return { num, double, plus };
},
};
// useCount.js
import { ref, computed } from "vue";
export default (value) => {
const num = ref(value);
const double = computed(() => num.value * 2);
const plus = (val) => num.value + val;
return { num, double, plus };
};
useCount.js 就是一个自定义的 hooks,得益于 Vue3 的全局 API,我们可以轻松做到代码拆分。Vue3 的 setup 聚合了所有的逻辑,容易产生面条代码,合理使用自定义 hooks,可以有效的减少面条代码,提升代码可维护性。并且 Vue3 的 hooks 比 react 更加简单高效,不会多次执行,不受调用顺序影响,不存在闭包陷阱等等,几乎可以没有任何心智负担的使用。
新的生命周期钩子
看到这里,相信你对 Vue3 的生命周期已经有一些了解了,我们不妨来做个梳理。
Vue3 几乎内置了所有的 Vue2 生命周期钩子,也就是说,刚开始升级项目至 Vue3 时,可以直接使用 Vue2 的钩子,方便平滑升级,如上图左下角所示,有两个钩子发生了替换,beforeDestory 被替换成了 beforeUnmount,destoryed 被替换成了 unmounted。完整的钩子对比如下:
除了 setup 外,Vue3 的其他生命周期钩子都添加了 on 前缀,更加规范统一。新的钩子需要在 setup 中使用,如下所示:
import { onMounted } from "vue";
export default {
setup() {
onMounted(() => {
console.log("onMounted");
});
},
};
Tree-Shaking
Vue3 一共开放了 113 个 API,我们可以通过如下方式引用:
import { ref, reactive, h, onMounted } from "vue";
通过 ES6 modules 的引入方式,能够被 AST 静态语法分析感知,从而可以只提取用到的代码片段,最终达到 Tree-Shaking 的效果,这样就使得 Vue3 最终打包出来的包更小,加载更快。据尤大去年 4 月在 B 站的直播:基本的 hello world 项目大小为 13.5kb,Composition API 仅有 11.75kb,包含所有的运行态仅 22.5kb。
Fragment
Vue3 中,Fragment 的引入,解决了组件需要被一个唯一根节点包裹的难题,带来的是 dom 层级的减少,以及渲染性能的提升,某些时候,如下所示:
<!-- child.vue -->
<template>
<td>{{ title }}</td>
<td>{{ subtitle }}</td><!-- Vue2中template出现了多个根节点,无法编译通过 -->
</template>
<!-- parent.vue -->
<template>
<table>
<tr>
<child />
</tr>
</table>
</template>
在 Vue2 中,这意味着我们没办法在 child.vue 的 template 中加入多个 td 节点,多个 td 可以被 tr 包裹,如果 child.vue 根节点替换为 tr,那么就会跟 parent.vue 的 tr 冲突。
同样的代码,在 Vue3 中就能正确编译通过,这是因为 Vue3 中,组件的 template 被一层不可见的 Fragment 包裹,组件天生支持多个根节点的布局。
Teleport
Teleport 是 Vue3 新增的组件,即传送门,Teleport 能够在不改变组件内部元素父子关系的情况下,将子元素”传送“到其他节点下加载,如下所示:
<template>
<div class="container" style="width: 100px; height: 100px; overflow: hidden">
<div class="dialog" style="width: 500px; height: 400px;">
...
</div>
</div>
</template>
dialog 直接挂载在 container 下,超出部分将不可见。加一层 Teleport,我们可以轻松将 dialog 展示出来。
<template>
<div class="container" style="width: 100px; height: 100px; overflow: hidden">
<teleport to="body">
<div class="dialog" style="width: 500px; height: 400px;">
...
</div>
</teleport>
</div>
</template>
dialog 依然处于 container 内部,仅仅只是被挂载到 body 上,逻辑关系不变,展示也不会遮挡。
Suspense
Vue2 中,我们经常写这样的 loading 效果,如下所示:
<template>
<div class="container">
<div v-if="init">
<list />
</div>
<div v-else>
loading~~
</div>
</div>
</template>
Vue3 中,我们可以通过 Suspense 的两个插槽实现以上功能,如下所示:
<template>
<div class="container">
<Suspense>
<template #default>
<list />
</template>
<template #fallback>
loading~
</template>
</Suspense>
</div>
</template>
<script>
import { defineAsyncComponent } "vue";
export default {
components: {
list: defineAsyncComponent(() => import("@/components/list.vue")),
},
};
</script>
Vue3 知识图谱
Vue3 还包括了一些其他常用更新,限于篇幅,这里先列出来,下篇再讲。
实际上,Vue3 带来的更新,远不止这些,为此我梳理了一个 Vue3 的知识图谱,尽可能囊括一些本文未提到的特性。
如上图,Vue 不但重写了 diff 算法,还在编译阶段做了很多优化,编译时优化可以通过这个网站看出来:https://vue-next-template-explorer.netlify.app/。
根据 Monterail 2 月份发布的第三版 Vue 生态报告,Vue 的流行度逐年上升,很多非 web 的可视化领域也可以基于 Vue 开发,特别是 Vue3 的渲染 API 的开放,使得基于 Vue 构建 Canvas、WebGL、小程序等应用更加方便,如下图所示,60 行代码实现一个简单的 Canvas 柱状图:
import { createRenderer, h } from "vue";
const renderer = createRenderer({
createElement: (tag) => ({ tag }),
patchProp: (el, key, prev, next) => { el[key] = next; },
insert: (child, parent) => { parent.nodeType === 1 && draw(child) },
});
let canvas
let ctx;
const draw = (el, noClear) => {
if (!noClear) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
}
// 柱状图绘制逻辑
if (el.tag == 'chart') {
const { data } = el;
const barWidth = canvas.width / 10;
const gap = 20;
const paddingLeft = (data.length * barWidth + (data.length - 1) * gap) / 2;
const paddingBottom = 10;
// x轴
// 柱状图
data.forEach(({ title, count, color }, index) => {
const x = paddingLeft + index * (barWidth + gap);
const y = canvas.height - paddingBottom - count;
ctx.fillStyle = color;
ctx.fillRect(x, y, barWidth, count);
});
}
// 递归绘制⼦节点
el.childs && el.childs.forEach(child => draw(child, true));
};
const createCanvasApp = (App) => {
const app = renderer.createApp(App);
const { mount } = app;
app.config.isCustomElement = (tag) => tag === 'chart';
app.mount = (selector) => {
canvas = document.createElement('canvas');
ctx = canvas.getContext('2d');
document.querySelector(selector).appendChild(canvas);
mount(canvas);
};
return app;
};
createCanvasApp({
setup() {
const data = [
{ title: '数据A', count: 200, color: 'brown' },
{ title: '数据B', count: 300, color: 'skyblue' },
{ title: '数据C', count: 50, color: 'gold' },
];
return () => h("chart", { data });
},
}).mount('#app');
运行结果如下图所示:
Vue3 相关资料
-
Vue3 官方网站 -
Vite 官方网站 -
Vue.js 2021 最新报告 -
Vue Template Explorer -
第四届 Vue Conf(预计 2021.5.22) -
ThisDot 线上分享会 PPT(2020.4.16) -
Vue Function-based API RFC(2020.1.22) -
State of Vue(2019.6.8) -
Vue3 最新进展(2018.11.24) -
现状与展望(2017.5.20)
视频号最新视频
5月28-29日
QECon全球软件质量&效能大会
欢迎关注
文章评论