三咲智子 Kevin Deng

三咲智子 Kevin Deng

Gen Z | Full Stack Developer 🏳️‍🌈
github
twitter
twitter

Vue 3.3 主要新特性詳解

前言#

嗨,這裡是三咲智子,Vue 核心團隊成員。Vue 3.3 的這次發布主要是為了改進 DX (開發者體驗),新增了一些語法糖和宏,以及 TypeScript 上的改善。

  • 泛型組件
  • 在 SFC(單文件組件)中導入外部 TS 類型
  • defineSlots 來定義插槽的類型
  • defineEmits 更便捷的語法
  • defineOptions 定義組件選項
  • (試驗性) 響應式的 props 解構
  • (試驗性) defineModel 語法糖
  • 廢棄 Reactivity Transform

在去年剛加入 Vue 的那段時間,我一直在為 Vue 3 貢獻 PR,但最近才最終在 Vue 3.3 中落地。Vue 3.3 從 Vue Macros 一共吸收了五六個特性,今天來淺談我自己貢獻的部分。

defineOptions 宏#

前情提要#

想想我們在有 <script setup> 之前,如果要定義 props, emits 可以輕而易舉地添加一個與 setup 平級的屬性。 但是用了 <script setup> 後,就沒法這麼幹了 —— setup 屬性已經沒有了,自然無法添加與其平級的屬性。為了解決這一問題,我們引入了 definePropsdefineEmits 這兩個宏。

但這只解決了 propsemits 這兩個屬性。如果我們要定義組件的 nameinheritAttrs 或其他自定義的屬性,還是得回到最原始的用法 —— 再添加一個普通的 <script> 標籤。這樣就會存在兩個 <script> 標籤。對我個人來說這是不可接受的。

  • 兩個 script 標籤可能會對 ESLint 插件或 Volar 有非預期的問題;
  • 如果兩個 script 標籤都存在 import 語句,Vue 會做一些奇怪的特殊處理;
  • 對於 DX (開發者體驗)來說,不僅麻煩,還難以理解。

現狀#

所以我們在 Vue 3.3 中新引入了 defineOptions 宏。顧名思義,主要是用來定義 Options API 的選項。我們可以用 defineOptions 定義任意的選項, props, emits, expose, slots 除外(因為這些可以使用 defineXXX 來做到)。我們甚至可以不用 <template> 標籤,而直接在 defineOptions 中用 h 函數或 JSX 寫渲染函數 render(當然並不推薦這樣用)。

🌰 例子#

<script setup>
defineOptions({
  name: 'Foo',
  inheritAttrs: false,
  // ... 更多自定義屬性
})
</script>

Vue SFC Playground

背後的故事#

關於這個特性的起源,是在重構 Element Plus 的組件為 <script setup> 的時候。對於組件庫來說,我們希望自定義組件的名字(name 屬性),而不是默認的文件名。但我又不希望回到原來的書寫方式,就寫了一個插件(夢開始的地方 🤣)unplugin-vue-define-options。幾經修改才確定和實現了目前 Vue 3.3 有的 defineOptions 宏。

提升靜態常量#

這個功能是一個 SFC (單文件組件)編譯器的優化。在 script 部分新增了 hoistStatic 選項。

模板中的 hoistStatic#

template 下的 hoistStatic 與它基本類似。Vue 編譯器有一個優化 —— 它能夠讓靜態的元素節點提升到頂級作用域。這樣僅在代碼被加載的時候執行一次,而不是每次執行 render 函數的時候被重複執行(在極端情況下可能也有其弊端)。

讓我們來看一個例子 🌰。

<template>
  <div id="title">Hello World</div>
</template>

Vue SFC Playground

以上這段代碼會被編譯成以下這段 JavaScript (非關鍵代碼已省略)

const _hoisted_1 = { id: 'title' }
function render(_ctx, _cache) {
  return _openBlock(), _createElementBlock('div', _hoisted_1, 'Hello World')
}

我們可以看到 _hoisted_1 變量就是被編譯器故意提升到頂層的代碼。如果關閉此功能,則它會在 render 函數內。

script 標籤的 hoistStatic#

在 Vue 3.3 之前,只有上述一個功能。我們在 Vue 3.3 也做了類似的優化 —— 如果一個常量的值是 primitive value(基本類型,即為 string, number, boolean, bigint, symbol, null, undefined),那麼該常量聲明會被提升到頂層。(注:symbol 目前並未被實現)

因為這些類型的常量是無法被改變的,所以它不論在哪被聲明,效果都是一樣的。

作用#

這個特性除了性能優化之外,還有一個更有用的地方。在 Vue 3.3 之前我們在使用宏時,是沒有辦法傳遞定義在 <script setup> 塊的變量的。看看下面的例子。

<script setup>
const name = 'Foo'
defineOptions({
  name,
})
</script>

Vue SFC Playground

我們會得到一個報錯。

[@vue/compiler-sfc] `defineOptions()` in <script setup> cannot reference locally declared variables because it will be hoisted outside of the setup() function. If your component options require initialization in the module scope, use a separate normal <script> to export the options instead.

這是因為之前提到的原因,defineProps 會添加一個與 setup 平級的 props 屬性,而 name 常量又是聲明在 setup 函數內的。我們無法在函數外部引用一個在其內部的變量,這是因為該變量壓根還沒有被初始化。以下代碼毫無疑問是錯誤的。

const __sfc__ = {
  props: [propName],
  setup(__props) {
    const propName = 'foo'
  },
}

在 Vue 3.3 之後,第 4 行的 propName 就會提升到第一行,那麼代碼就完全說得通了。本特性默認是開啟的。在一般情況下,開發者也無需感知該特性是存在的。

背後的故事#

開發這個特性的原因也與上個類似。是因為 Element Plus 在用 defineOptions 設置好 name 之後,又需要在某些條件下拋出一個異常。而異常需要帶有組件的名字,來方便用戶調試。

<script setup>
const name = 'ElButton'
defineOptions({
  name,
})
// ...
if (condition) {
  throw new Error(`${name}: something went wrong.`)
}
</script>

所以我希望避免重複的代碼,把組件名稱抽離成一個常量,並分別在 defineOptions 與抛異常的時候引用。

defineModel 宏#

動機#

這個宏是一個純粹的語法糖。在 Vue 3.3 之前,如果我們要定義一個雙向綁定的 prop,是一件挺麻煩的事情。

<script setup lang="ts">
const props = defineProps<{
  modelValue: number
}>()

const emit = defineEmits<{
  (evt: 'update:modelValue', value: number): void
}>()

// 更新值
emit('update:modelValue', props.modelValue + 1)
</script>

我們需要先定義 props,再定義 emits 。其中有許多重複的代碼。如果需要修改此值,還需要手動調用 emit 函數。

我在想:我們為什麼不封裝一個函數(宏)來簡化這個步驟呢?因此就有了 defineModel 宏。

🌰 例子#

<script setup>
const modelValue = defineModel()
modelValue.value++
</script>

以上繁瑣的 7 行代碼,在 Vue 3.3 中可以簡寫為兩行!

useVModel 的區別#

在 VueUse 中也有 useVModel 函數可以實現類似的效果。為什麼我們還要引入這個宏呢?

這是因為 VueUse 只是把 propsemit 函數結合後,封裝為一個 Ref,它無法為組件定義 propsemits。這意味著開發者仍需手動分別調用 definePropsdefineEmits

背後的故事#

😛 這個背後就沒什麼故事了,純粹是覺得寫著麻煩。起初是在 Vue Macros 做了 defineModel 的宏(現改名為 defineModels,與官方做出區分),用著效果還不錯。

在 SFC 導入外部類型#

背景#

在 Vue 3.2 發布以來,Vue 有一個參與熱度最高的 issue——如何在 defineProps 上使用外部的類型

為了解決這個問題,擺在我們面前的有兩條路。

  • Vue SFC 編譯器去調用 TypeScript 編譯器。計算出最終的類型,並確定它到底包含了哪些類型(String, Number, Boolean, Function 等)。
  • 我們自己實現一個簡易的 TypeScript 分析器,並解決大多數的場景。

眾所周知,TypeScript 的類型體操有多恐怖。如果真的要完美地解決這個 issue,Vue 的 SFC 編譯器就需要像 TypeScript 編譯器一樣,解析並計算出所有的類型。第一套方案雖好,但是也有個巨大的缺點:這必不可免地需要依賴 TypeScript 那套龐大而又臃腫的編譯器,會極大地拖慢構建的時長。

最後,在 Vue Macros 中我還是選擇了第二套方案。因此目前並不適合在宏中傳遞複雜的類型。不過這將在以後被逐步完善。

Vue 3.3 與 Vue Macros#

Vue Macros 實現了這套簡易分析器後,Vue core 也做了類似的實現。

但是就目前而言,仍有差異。Vue Macros 的迭代速度遠遠快於 Vue core 本身,Vue Macros 目前正在支持更多奇奇怪怪的語法,而 Vue 3.3 有部分語法仍不支持。

所以在未來,如果遇到了 Vue 無法解析的類型,不妨試試 Vue Macros。如果 Vue Macros 仍然不行,可以嘗試附帶最小可復現代碼,在 Vue Macros 倉庫發起一個 issue。或嘗試換一個更簡單、避免二次推斷的語法。

defineSlots 宏#

背景#

Vue 3.3 新增了 defineSlots 宏。我們可以使用 defineSlots 自己定義插槽的類型。這個宏在簡單的組件中幾乎用不到,但對於一些複雜的組件非常有用,尤其是這個特性與泛型組件一起使用。或是在 Volar 無法正確地推測出類型時,我們可以手動指定。

🌰 例子#

<script setup lang="ts">
const slots = defineSlots<{
  default(props: { foo: string; bar: number }): any
}>()
</script>

我們手動定義了 default 組件的插槽作用域的類型。

🌰 真的例子#

比如我們有一個分頁器組件,我們可以通過插槽來控制具體的 item 要如何渲染。

<script setup lang="ts" generic="T">
// 子組件 Paginator
defineProps<{
  data: T[]
}>()

defineSlots<{
  default(props: { item: T }): any
}>()
</script>
<template>
  <!-- 父組件 -->
  <Paginator :data="[1, 2, 3]">
    <template #default="{ item }">{{ item }}</template>
  </Paginator>
</template>

我們在傳遞了 data 參數,它是 number[],所以 item 也會被推斷為 numberitem 的類型會根據傳給 data 參數的類型而改變。

defineEmits 更便捷的語法#

這個特性也是一個純粹的語法糖。

例子#

<script setup lang="ts">
const emits = defineEmits<{
  (evt: 'update:modelValue', value: string): void
  (evt: 'change'): void
}>()

// ⬇️ Vue 3.3 之後
const emits = defineEmits<{
  'update:modelValue': [value: string],
  'change': []
}>()
</script>

在 Vue 3.3 以前我們需要多寫幾個字符,現在能省則省。

廢棄 Reactivity Transform 語法糖#

早在年初,Vue 團隊已經宣布廢棄 Reactivity Transform 語法。具體則會在 Vue 3.3 中被廢棄(deprecated),使用時會警告;在 Vue 3.4 則會被徹底移除。

我個人認為 Reactivity Transform 仍然有其用武之地的。

雖然被官方廢棄了,但功能被移動到了 Vue Macros。這意味著你仍可以不必著急遷移至老語法,使用插件仍可以正常使用以及後續的漏洞修復。

對於具體為什麼移除,可以閱讀此篇評論

後記#

其實總的來說,還是非常高興能看到 Vue 願意接受來自社區的建議和提案。這大概就是我對 Vue 3.3 做出的貢獻,關於更多特性可以閱讀 Vue 博客的文章

P.S. 如果有人願意將本篇文章翻譯至英文,請在翻譯好後提交內容到 sxzz/articles 倉庫,我將不勝感激!

關於 Vue Macros#

Vue Macros 目前是一個獨立於 Vue 官方的項目。不同於 Vue 官方,它的目的是為了探索一些不一樣的可能性。

我更願意看到一些更激進的想法,哪怕這個想法不那麼成熟。我們可以在 Vue Macros 先進一步實驗,等成熟了再嘗試合併到 Vue 官方倉庫中。

目前 Vue Macros 由我一個人維護中,希望能吸引更多來自社區的小夥伴參與建設! 💕

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。