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

开发 开发工具
原计划 2019 年发布的 Vue3,又经过一年的再次打磨,终于于去年 9 月正式发布。随后,不少 UI 组件库都积极参与适配,去年 12 月,Element-plus(Element-ui 官方升级版)也发布了 beta 版。

[[395993]]

背景

原计划 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 也是默默提供着数据响应式。

  1. const observe = (data) => { 
  2.   Object.keys(data).forEach((key) => { 
  3.     const initValue = data[key]; 
  4.     let value = initValue; 
  5.     if (typeof initValue === 'object') { 
  6.       observe(initValue); 
  7.       return
  8.     } 
  9.     Object.defineProperty(data, key, { 
  10.       enumerable: true
  11.       configurable: true
  12.       get() { 
  13.         console.log('visit key value ='key, value); 
  14.         return value; 
  15.       }, 
  16.       set(val) { 
  17.         console.log(`[${key}]changed,old value=${value}, new value = ${val}`); 
  18.         if(value !== val) { 
  19.           value = val; 
  20.         } 
  21.       } 
  22.     }); 
  23.   }); 
  24. }; 
  25. const data = {}; 
  26. Array.from(new Array(100), () => "").forEach((item, i) => { 
  27.   data[i] = { value: i * 2 }; 
  28. }); 
  29. console.time(); 
  30. observe(data); 
  31. console.timeEnd(); // default: 0.225ms 
  32. data.a = { b: 1 }; 
  33. data.a.b = 2; 

如上所示,Vue2 的数据响应式是通过 Object.defineProperty 实现,这是一个深度遍历的过程,无论 data 中包含多少层数据,都需要全部遍历一遍。深度遍历,给对象的每个自身属性添加 defineProperty,需要不小的性能开销,同时后面新增到 this 中的属性不提供响应式监听,因此我们需要使用诸如this.$set这种方式去添加新属性。

Proxy 就没有这个问题,如下所示。

  1. const observe = (data) => { 
  2.  return new Proxy(data, { 
  3.   get(target, key, receiver) { 
  4.    console.log('visit'key); 
  5.    return Reflect.get(target, key, receiver); 
  6.   }, 
  7.   set(target, key, value, receiver) { 
  8.    console.log(`[${key}]changed, value = ${value}`); 
  9.    Reflect.set(target, key, typeof value === 'object' ? observe(value) : value, receiver); 
  10.   } 
  11.  }); 
  12. }; 
  13. let data = {}; 
  14. Array.from(new Array(100), () => "").forEach((item, i) => { 
  15.   data[i] = { value: i * 2 }; 
  16. }); 
  17. console.time(); 
  18. const proxy = observe(data); 
  19. console.timeEnd(); // default: 0.041ms 
  20. proxy.a = { b: 1 }; // [a]changed, value = [object Object] 
  21. proxy.a.b = 2; // visit a \n [b]changed, value = 2 

Proxy 不但使得 data 获得了新属性的响应性,整个响应式处理过程的效率还提升了数倍,由此带来了 Vue3 的大部分性能提升。

Composition API

为了保持对 Vue2 的向下兼容,Vue3 中仍然支持纯 Option 配置的书写方式,这为升级提供了便利,平移 Vue2 的代码,只需少量改动,便可正常运行。

同时考虑到上手难度,Vue3 的顶层代码风格与 Vue2 保持一致,依然是 export 一个对象,对象包含了一系列的配置,其中便有 setup 入口函数。我们先来看一段代码,然后逐个解读。

  1. import { defineComponent, ref, reactive, toRefs, watch, watchEffect, computed, onMounted } from "vue"
  2. export default defineComponent({ 
  3.  setup(props, context) { 
  4.     const selectRef = ref(null) // 作为下拉框的ref引用 
  5.     const state = reactive({ // 响应式数据,类似于Vue2的this 
  6.      num: 0, 
  7.     }); 
  8.     const { init } = toRefs(props); 
  9.     watch(() => state.num, (newVal, oldVal) => { 
  10.      console.log(newVal, oldVal); 
  11.     }); 
  12.     watchEffect(() => { 
  13.      console.log(state.num); 
  14.     }); 
  15.     const num2 = computed(() => state.num + 1); 
  16.     onMounted(() => { 
  17.      state.loaded = true
  18.     }); 
  19.     return { selectRef, state, num2, init, context }; 
  20.   } 
  21. }); 

setup 作为入口函数,包含两个参数,分别是响应式的 props 外部参数,以及 context 对象,context 包含 attrs、emit、expose、props、slots 五个参数,如下所示:

在 Vue3 的设计里,setup,以及从 vue 对象中解构出来的各种生命周期函数,执行优先级高于 Vue2 中的各种生命周期钩子,因此

  1. beforeCreate() { 
  2.  console.log('beforeCreate'); 
  3. }, 
  4. created() { 
  5.  console.log('create'); 
  6. }, 
  7. setup() { 
  8.  console.log('setup'); 
  9. }, 

这段代码的输出依次是 setup、beforeCreate、created。

ref、reactive

setup 中,第一句const selectRef = ref(null);,这里定义的是一个响应式的数据,可传递给 template 或 render,用于下拉框组件或下拉框 dom 绑定引用。为什么使用 ref,不使用 reactive 呢?ref 和 reactive 都可以给数据添加响应性,ref 一般用于给 js 基本数据类型添加响应性(当然也支持非基本类型的 object),reactive 只能用于代理非基本数据类型。null 是基本数据类型,只能使用 ref,那既然如此,为什么不在所有情况都使用 ref 呢?我们来看一段代码:

  1. const num = ref(0); 
  2. num.value = 1; 
  3. const obj = { a: 1 }; 
  4. const refObj = ref(obj); 
  5. const reactiveObj = reactive(obj); 
  6. refObj.value.a = 2; 
  7. reactiveObj.a = 3; 
  8. 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

  1. const num = ref(0); 
  2. const state = reactive({ 
  3.  num: 0, 
  4. }); 
  5. const obj = { num: 0 }; 
  6. watch(num, (newVal, oldVal) => { 
  7.  console.log("num", newVal, oldVal); 
  8. }); 
  9. watch(() => state.num, (newVal, oldVal) => { 
  10.  console.log("num", newVal, oldVal); 
  11. }); 
  12. watch(() => obj.num, () => { 
  13.   console.log("这里不会执行"); 
  14. }); 
  15. num++; 
  16. state.num++; 
  17. obj.num++; 

如上,watch api,它需要接受一个具有返回值的 getter 函数或者 ref(如() => state.num,ref)。

如果需要监听多个值,如下所示:

  1. const num1 = ref(0); 
  2. const num2 = ref(0); 
  3. watch([num1, num2], ([newNum1, newNum2], [prevNum1, prevNum2]) => { 
  4.   console.log([newNum1, newNum2], [prevNum1, prevNum2]); 
  5. }); 
  6. num1.value = 1; // [1, 0], [0, 0] 
  7. num2.value = 2; // [1, 2], [1, 0] 

可见多个数据的每次更新都会触发 watch。想要监听一个嵌套的对象,跟 Vue2 一样,依旧需要使用 deep 选项,如下所示:

  1. const state = reactive({ 
  2.   attr: { 
  3.     id: 1, 
  4.   }, 
  5. }); 
  6. watch(() => state, (currState, prevState) => { 
  7.   console.log(currState.attr.id, prevState.attr.id, currState === prevState, currState === state); // 2, 2, truetrue 
  8. }, { deep: true }); 
  9. watch(() => state.attr.id, (currId, prevId) => { 
  10.   console.log(currId, prevId); // 2, 1 
  11. }); 
  12. state.attr.id = 2; 

看到差别了吗?监听响应式对象时,返回的是对象的引用,因此 currState,prevState 指向是同一个最新的 state,如果需要获取变化前的值,建议返回监听的属性,如watch(() => state.attr.id),刚好 state.attr.id 是一个基本类型的值,那么 deep 也不需要。

watchEffect 是 Vue3 新增的 api,watchEffect 会自动运行一次,用于自动收集依赖,但不支持获取变化前的值,除此之外,与 watch 用法一致。那么 watchEffect 适用什么场景呢?这也是我刚上手 Vue3 的困惑之一。我们来看一段代码:

  1. const rights = { 
  2.   admin: ["read""write"], 
  3.   user: ["read"], 
  4. }; 
  5. const state = reactive({ 
  6.   rights: ""
  7. }) 
  8. const userInfo = reactive({ role: "user" }); 
  9. userInfo.name = "Tom"
  10. userInfo.role = "admin"
  11. watch(() => userInfo.role, (newVal, oldVal) => { 
  12.  state.rights = rights[newVal]; 
  13. }); 
  14. watchEffect(() => { 
  15.  state.rights = rights[userInfo.role]; 
  16. }); 

以上代码中,watch 中的逻辑只能在 userInfo 变化后执行,因此 state.rights 不会提供初始值,相反,watchEffect 中 state.rights 由于自动依赖收集,获得了一次赋值的机会。

这样做的好处是什么呢?在实际项目中,userInfo.role 可能是一个全局 store 中的数据,用户登录进来后,就会通过接口获取初始值,我们并不能确认,用户进到其中一个页面时,userInfo.role 的值是否已经被接口更新,且 userInfo 变化前的值我们也不关心,watchEffect 就非常适合这种场景,它会自动进行一次初始化,并且在变化后,及时更新值。

watch 和 watchEffect 的监听会在组件销毁时自动取消,除此之外,可以通过它们返回的函数手动取消监听,如下所示:

  1. const stopWatch = watch(selectRef, (newVal, oldVal){}); 
  2. const stopWatchEffect = watchEffect(selectRef, (newVal, oldVal){}); 
  3. setTimeout(stopWatch, 1000); 
  4. setTimeout(stopWatchEffect, 1000); 

watchEffect 更多的用法,请参考官方文档。

computed

computed 的使用如下:

  1. const num = ref(1); 
  2. const num2 = computed(() => num * 2); 
  3. num2.value++; // error 

num2 是一个不可变的 ref 对象,不能直接对它的 value 属性赋值。

computed 还可以接收一个带有 get 和 set 函数的对象,来创建一个可读写的 ref 对象,如下所示:

  1. const num3 = computed({ 
  2.  get: () => num.value * 2, 
  3.  set: (val) => { 
  4.   num.value = val; 
  5.  }, 
  6. }); 
  7. num3.value = 100; 
  8. console.log(num.value, num3.value); // 100 200 

自定义 Hooks

Vue3 的 Composition 之所以这样实现,主要原因就是为了便于代码拆分,降低耦合,我们不妨来实现一个自定义的 hooks。

  1. // page.vue 
  2. import useCount from "./useCount"
  3. export default { 
  4.   setup() { 
  5.     const { num, double, plus } = useCount(1); 
  6.     return { num, double, plus }; 
  7.   }, 
  8. }; 
  9. // useCount.js 
  10. import { ref, computed } from "vue"
  11. export default (value) => { 
  12.   const num = ref(value); 
  13.   const double = computed(() => num.value * 2); 
  14.   const plus = (val) => num.value + val; 
  15.   return { num, double, plus }; 
  16. }; 

useCount.js 就是一个自定义的 hooks,得益于 Vue3 的全局 API,我们可以轻松做到代码拆分。Vue3 的 setup 聚合了所有的逻辑,容易产生面条代码,合理使用自定义 hooks,可以有效的减少面条代码,提升代码可维护性。并且 Vue3 的 hooks 比 react 更加简单高效,不会多次执行,不受调用顺序影响,不存在闭包陷阱等等,几乎可以没有任何心智负担的使用。

新的生命周期钩子

看到这里,相信你对 Vue3 的生命周期已经有一些了解了,我们不妨来做个梳理。

Vue3 几乎内置了所有的 Vue2 生命周期钩子,也就是说,刚开始升级项目至 Vue3 时,可以直接使用 Vue2 的钩子,方便平滑升级,如上图左下角所示,有两个钩子发生了替换,beforeDestory 被替换成了 beforeUnmount,destoryed 被替换成了 unmounted。完整的钩子对比如下:

除了 setup 外,Vue3 的其他生命周期钩子都添加了 on 前缀,更加规范统一。新的钩子需要在 setup 中使用,如下所示:

  1. import { onMounted } from "vue"
  2. export default { 
  3.   setup() { 
  4.     onMounted(() => { 
  5.       console.log("onMounted"); 
  6.     }); 
  7.   }, 
  8. }; 

Tree-Shaking

Vue3 一共开放了 113 个 API,我们可以通过如下方式引用:

  1. 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 层级的减少,以及渲染性能的提升,某些时候,如下所示:

  1. <!-- child.vue --> 
  2. <template> 
  3.  <td>{{ title }}</td> 
  4.   <td>{{ subtitle }}</td><!-- Vue2中template出现了多个根节点,无法编译通过 --> 
  5. </template> 
  6. <!-- parent.vue --> 
  7. <template> 
  8.   <table
  9.     <tr> 
  10.       <child /> 
  11.     </tr> 
  12.   </table
  13. </template> 

在 Vue2 中,这意味着我们没办法在 child.vue 的 template 中加入多个 td 节点,多个 td 可以被 tr 包裹,如果 child.vue 根节点替换为 tr,那么就会跟 parent.vue 的 tr 冲突。

同样的代码,在 Vue3 中就能正确编译通过,这是因为 Vue3 中,组件的 template 被一层不可见的 Fragment 包裹,组件天生支持多个根节点的布局。

Teleport

Teleport 是 Vue3 新增的组件,即传送门,Teleport 能够在不改变组件内部元素父子关系的情况下,将子元素”传送“到其他节点下加载,如下所示:

  1. <template> 
  2.   <div class="container" style="width: 100px; height: 100px; overflow: hidden"
  3.     <div class="dialog" style="width: 500px; height: 400px;"
  4.       ... 
  5.     </div> 
  6.   </div> 
  7. </template> 

dialog 直接挂载在 container 下,超出部分将不可见。加一层 Teleport,我们可以轻松将 dialog 展示出来。

  1. <template> 
  2.   <div class="container" style="width: 100px; height: 100px; overflow: hidden"
  3.     <teleport to="body"
  4.       <div class="dialog" style="width: 500px; height: 400px;"
  5.         ... 
  6.       </div> 
  7.     </teleport> 
  8.   </div> 
  9. </template> 

dialog 依然处于 container 内部,仅仅只是被挂载到 body 上,逻辑关系不变,展示也不会遮挡。

Suspense

Vue2 中,我们经常写这样的 loading 效果,如下所示:

  1. <template> 
  2.   <div class="container"
  3.     <div v-if="init"
  4.       <list /> 
  5.     </div> 
  6.     <div v-else
  7.       loading~~ 
  8.     </div> 
  9.   </div> 
  10. </template> 

Vue3 中,我们可以通过 Suspense 的两个插槽实现以上功能,如下所示:

  1. <template> 
  2.   <div class="container"
  3.     <Suspense> 
  4.       <template #default
  5.         <list /> 
  6.       </template> 
  7.       <template #fallback> 
  8.         loading~ 
  9.       </template> 
  10.     </Suspense> 
  11.   </div> 
  12. </template> 
  13. <script> 
  14.   import { defineAsyncComponent } "vue"
  15.   export default { 
  16.     components: { 
  17.       list: defineAsyncComponent(() => import("@/components/list.vue")), 
  18.     }, 
  19.   }; 
  20. </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 柱状图:

  1. import { createRenderer, h } from "vue"
  2. const renderer = createRenderer({ 
  3.   createElement: (tag) => ({ tag }), 
  4.   patchProp: (el, key, prev, next) => { el[key] = next; }, 
  5.   insert: (child, parent) => { parent.nodeType === 1 && draw(child) }, 
  6. }); 
  7. let canvas 
  8. let ctx; 
  9. const draw = (el, noClear) => { 
  10.   if (!noClear) { 
  11.     ctx.clearRect(0, 0, canvas.width, canvas.height); 
  12.   } 
  13.   // 柱状图绘制逻辑 
  14.   if (el.tag == 'chart') { 
  15.     const { data } = el; 
  16.     const barWidth = canvas.width / 10; 
  17.     const gap = 20; 
  18.     const paddingLeft = (data.length * barWidth + (data.length - 1) * gap) / 2; 
  19.     const paddingBottom = 10; 
  20.     // x轴 
  21.     // 柱状图 
  22.     data.forEach(({ title, count, color }, index) => { 
  23.       const x = paddingLeft + index * (barWidth + gap); 
  24.       const y = canvas.height - paddingBottom - count
  25.       ctx.fillStyle = color; 
  26.       ctx.fillRect(x, y, barWidth, count); 
  27.     }); 
  28.   } 
  29.   // 递归绘制⼦节点 
  30.   el.childs && el.childs.forEach(child => draw(child, true)); 
  31. }; 
  32. const createCanvasApp = (App) => { 
  33.   const app = renderer.createApp(App); 
  34.   const { mount } = app; 
  35.   app.config.isCustomElement = (tag) => tag === 'chart'
  36.   app.mount = (selector) => { 
  37.     canvas = document.createElement('canvas'); 
  38.     ctx = canvas.getContext('2d'); 
  39.     document.querySelector(selector).appendChild(canvas); 
  40.     mount(canvas); 
  41.   }; 
  42.   return app; 
  43. }; 
  44. createCanvasApp({ 
  45.   setup() { 
  46.     const data = [ 
  47.       { title: '数据A'count: 200, color: 'brown' }, 
  48.       { title: '数据B'count: 300, color: 'skyblue' }, 
  49.       { title: '数据C'count: 50, color: 'gold' }, 
  50.     ]; 
  51.     return () => h("chart", { data }); 
  52.   }, 
  53. }).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)

 

 

责任编辑:武晓燕 来源: 51CTO专栏
相关推荐

2021-12-06 10:07:48

开源项目Vue3

2009-06-28 22:55:00

SAN惠普存储

2024-01-03 18:01:48

Code技巧开发

2024-01-26 18:04:21

编辑器Code主题

2021-12-01 08:11:44

Vue3 插件Vue应用

2023-12-09 18:02:34

工具Code插件

2023-11-26 09:04:10

Vue性能

2024-03-08 08:40:25

2024-11-06 10:16:22

2009-07-03 12:59:40

Servlet配置

2022-11-30 12:41:03

戴尔

2022-03-21 15:24:27

ThingWorxDPMPTC

2021-11-30 08:19:43

Vue3 插件Vue应用

2023-11-28 09:03:59

Vue.jsJavaScript

2020-09-19 21:15:26

Composition

2017-03-21 09:11:21

Android开发效率TemplateBui

2022-09-06 12:20:30

Vue3CVCRUD

2010-04-21 09:23:09

.NET 4

2024-08-12 15:40:54

2023-04-27 11:07:24

Setup语法糖Vue3
点赞
收藏

51CTO技术栈公众号