前言#
嗨,這裡是三咲智子,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 宏#
- PR: https://github.com/vuejs/core/pull/5738
- RFC: https://github.com/vuejs/rfcs/discussions/430
- 開發實錄:https://www.bilibili.com/video/BV1uu411y7WE
前情提要#
想想我們在有 <script setup>
之前,如果要定義 props
, emits
可以輕而易舉地添加一個與 setup
平級的屬性。 但是用了 <script setup>
後,就沒法這麼幹了 —— setup
屬性已經沒有了,自然無法添加與其平級的屬性。為了解決這一問題,我們引入了 defineProps
與 defineEmits
這兩個宏。
但這只解決了 props
與 emits
這兩個屬性。如果我們要定義組件的 name
、 inheritAttrs
或其他自定義的屬性,還是得回到最原始的用法 —— 再添加一個普通的 <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>
背後的故事#
關於這個特性的起源,是在重構 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>
以上這段代碼會被編譯成以下這段 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/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 宏#
- PR: https://github.com/vuejs/core/pull/8018
- RFC: https://github.com/vuejs/rfcs/discussions/503
- 開發實錄:第一期,第二期
- Twitter: https://twitter.com/sanxiaozhizi/status/1644564064931307522
動機#
這個宏是一個純粹的語法糖。在 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 只是把 props
和 emit
函數結合後,封裝為一個 Ref
,它無法為組件定義 props
和 emits
。這意味著開發者仍需手動分別調用 defineProps
和 defineEmits
。
背後的故事#
😛 這個背後就沒什麼故事了,純粹是覺得寫著麻煩。起初是在 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 宏#
- PR: https://github.com/vuejs/core/pull/7982
- Twitter: https://twitter.com/sanxiaozhizi/status/1641378248448937984
背景#
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
也會被推斷為 number
。item
的類型會根據傳給 data
參數的類型而改變。
defineEmits 更便捷的語法#
- PR: https://github.com/vuejs/core/pull/7992
- Twitter: https://twitter.com/youyuxi/status/1641403989026820098
這個特性也是一個純粹的語法糖。
例子#
<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 由我一個人維護中,希望能吸引更多來自社區的小夥伴參與建設! 💕