首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >升级 Vue3 大幅提升开发运行效率

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

作者头像
腾讯技术工程官方号
发布2021-04-27 15:40:09
1.8K0
发布2021-04-27 15:40:09
举报

作者: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/。

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(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 相关资料

视频号最新视频

5月28-29日

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

欢迎关注

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2021-04-26,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 腾讯技术工程 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 背景
  • 如何升级 Vue3
  • 为什么要升级 Vue3
  • Vue3 带来了哪些新特性
    • Proxy 代理
      • Composition API
        • ref、reactive
        • toRefs
        • watch、watchEffect
        • computed
      • 自定义 Hooks
        • 新的生命周期钩子
          • Tree-Shaking
            • Fragment
              • Teleport
                • Suspense
                • Vue3 知识图谱
                • Vue3 相关资料
                相关产品与服务
                灰盒安全测试
                腾讯知识图谱(Tencent Knowledge Graph,TKG)是一个集成图数据库、图计算引擎和图可视化分析的一站式平台。支持抽取和融合异构数据,支持千亿级节点关系的存储和计算,支持规则匹配、机器学习、图嵌入等图数据挖掘算法,拥有丰富的图数据渲染和展现的可视化方案。
                领券
                问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档