升級 Vue3 大幅提升開發運行效率

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

之所以會有方案一,主要還是擔心 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 相關資料

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/vZvqCYECOHjFVakMywEa3w