升级 Vue3 大幅提升开发运行效率

2021年4月26日 350点热度 0人点赞 0条评论

图片


作者: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, {
      enumerabletrue,
      configurabletrue,
      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 = { b1 };
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 = { b1 }; // [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
     num0,
    });
    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 = { a1 };
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({
 num0,
});
const obj = { num0 };
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: {
    id1,
  },
});
watch(() => state, (currState, prevState) => {
  console.log(currState.attr.id, prevState.attr.id, currState === prevState, currState === state); // 2, 2, true, true
}, { deeptrue });
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/。

图片
Vue3 的开放生态

根据 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(00, 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'count200color'brown' },
      { title'数据B'count300color'skyblue' },
      { title'数据C'count50color'gold' },
    ];
    return () => h("chart", { data });
  },
}).mount('#app');

运行结果如下图所示:

图片

Vue3 相关资料

视频号最新视频

腾讯程序员

,赞 428


5月28-29日

QECon全球软件质量&效能大会

欢迎关注

图片

44050升级 Vue3 大幅提升开发运行效率

这个人很懒,什么都没留下

文章评论