vue3 script setup 定稿

guangzan 發表於 2021-07-17
Vue

vue script setup 已經官宣定稿。本文主要翻譯了來自 0040-script-setup 的內容。

摘要

在單檔案元件(SFC)中引入一個新的 <script> 型別 setup。它向模板公開了所有的頂層繫結。

基礎示例

<script setup>
  //imported components are also directly usable in template
  import Foo from './Foo.vue'
  import { ref } from 'vue'

  //write Composition API code just like in a normal setup ()
  //but no need to manually return everything
  const count = ref(0)
  const inc = () => {
    count.value++
  }
</script>

<template>
  <Foo :count="count" @click="inc" />
</template>
編譯輸出
import Foo from './Foo.vue'
import { ref } from 'vue'

export default {
  setup() {
    const count = ref(1)
    const inc = () => {
      count.value++
    }

    return function render() {
      return h(Foo, {
        count,
        onClick: inc,
      })
    }
  },
}

宣告 Props 和 Emits

<script setup>
  //expects props options
  const props = defineProps({
    foo: String,
  })
  //expects emits options
  const emit = defineEmits(['update', 'delete'])
</script>

動機

這個提案的主要目標是通過直接向模板公開 <script setup> 的上下文,減少在單檔案元件(SFC)中使用 Composition API 的繁瑣程度。

之前有一個關於 <script setup> 的提案 這裡,目前已經實現(但被標記為實驗性)。舊的提議選擇了匯出語法,這樣程式碼就能與未使用的變數配合得很好。

這個提議採取了一個不同的方向,基於我們可以在 eslint-plugin-vue 中提供定製的 linter 規則的前提下。這使我們能夠以最簡潔的語法為目標。

設計細節

使用 script setup 語法

要使用 script setup 語法,直接在 script 標籤中加入 setup 就可以了

<script setup>
  //syntax enabled
</script>

暴露頂級繫結

當使用 <script setup> 時,模板被編譯成一個渲染函式,被內聯在 setup 函式 scope 內。這意味著任何在 <script setup> 中宣告的頂級繫結(top level bindings)(包括變數和匯入)都可以直接在模板中使用。

<script setup>
  const msg = 'Hello!'
</script>

<template>
  <div>{{ msg }}</div>
</template>
編譯輸出
export default {
  setup() {
    const msg = 'Hello!'

    return function render() {
      //has access to everything inside setup () scope
      return h('div', msg)
    }
  },
}

注意到模板範圍的心智模型與 Options API 的不同是很重要的:當使用 Options API 時,<script> 和模板是通過一個 “渲染上下文物件” 連線的。當我們寫程式碼時,我們總是在考慮 “在上下文中暴露哪些屬性”。這自然會導致對 “在上下文中洩露太多的私有邏輯” 的擔憂。

然而,當使用 <script setup> 時,心理模型只是一個函式在另一個函式內的模型:內部函式可以訪問父範圍內的所有東西,而且因為父範圍是封閉的,所以沒有 "洩漏" 的問題。

使用元件

<script setup> 範圍內的值也可以直接用作自定義元件標籤名,類似於 JSX 中的工作方式。

<script setup>
  import Foo from './Foo.vue'
  import MyComponent from './MyComponent.vue'
</script>

<template>
  <Foo />
  <!-- kebab-case also works -->
  <my-component />
</template>
編譯輸出
import Foo from './Foo.vue'
import MyComponent from './MyComponent.vue'

export default {
  setup() {
    return function render() {
      return [h(Foo), h(MyComponent)]
    }
  },
}

使用動態元件

<script setup>
  import Foo from './Foo.vue'
  import Bar from './Bar.vue'
</script>

<template>
  <component :is="Foo" />
  <component :is="someCondition ? Foo : Bar" />
</template>
編譯輸出
import Foo from './Foo.vue'
import Bar from './Bar.vue'

export default {
  setup() {
    return function render() {
      return [h(Foo), h(someCondition ? Foo : Bar)]
    }
  },
}

使用指令

除了一個名為 v-my-dir 的指令會對映到一個名為 vMyDir 的 setup 作用域變數,指令的工作方式與此類似:

<script setup>
  import { directive as vClickOutside } from 'v-click-outside'
</script>

<template>
  <div v-click-outside />
</template>
編譯輸出
import { directive as vClickOutside } from 'v-click-outside'

export default {
  setup() {
    return function render() {
      return withDirectives(h('div'), [[vClickOutside]])
    }
  },
}

之所以需要 v 字首,是因為全域性註冊的指令(如 v-focus)很可能與本地宣告的同名變數發生衝突。v 字首使得使用一個變數作為指令的意圖更加明確,並減少了意外的 “shadowing”。

宣告 props 和 emits

為了在完全的型別推導支援下宣告 props 和 emits 等選項,我們可以使用 defineProps 和 defineEmits API,它們在 <script setup> 中自動可用,無需匯入。

<script setup>
  const props = defineProps({
    foo: String,
  })

  const emit = defineEmits(['change', 'delete'])
  //setup code
</script>
編譯輸出
export default {
  props: {
    foo: String,
  },
  emits: ['change', 'delete'],
  setup(props, { emit }) {
    //setup code
  },
}
  • defineProps 和 defineEmits 根據傳遞的選項提供正確的型別推理。
  • defineProps 和 defineEmits 是編譯器巨集(compiler macros ),只能在 <script setup> 中使用。它們不需要被匯入,並且在處理 <script setup> 時被編譯掉。
  • 傳遞給 defineProps 和 defineEmits 的選項將被從 setup 中提升到模組範圍。因此,這些選項不能引用在 setup 作用域內宣告的區域性變數。這樣做會導致一個編譯錯誤。然而,它可以引用匯入的繫結,因為它們也在模組範圍內。

使用 slots 和 attrs

<script setup> 中使用 slots 和 attrs 應該是比較少的,因為你可以在模板中直接訪問它們,如 $slots 和 $attrs。在罕見的情況下,如果你確實需要它們,請分別使用 useSlots 和 useAttrs 幫助函式(helpers)。

<script setup>
  import { useSlots, useAttrs } from 'vue'

  const slots = useSlots()
  const attrs = useAttrs()
</script>

useSlots 和 useAttrs 是實際的執行時函式,其返回值等價於 setupContext.slotssetupContext.attrs。它們也可以在 Composition API 函式中使用。

純型別的 props 或 emit 宣告

props 和 emits 也可以使用 TypeScript 語法來宣告,方法是向 defineProps 或 defineEmits 傳遞一個字面型別引數。

const props = defineProps<{
  foo: string
  bar?: number
}>()

const emit = defineEmits<{
  (e: 'change', id: number): void
  (e: 'update', value: string): void
}>()
  • defineProps 或 defineEmits 只能使用執行時宣告或型別宣告。同時使用兩者會導致編譯錯誤。
  • 當使用型別宣告時,等效的執行時宣告會從靜態分析中自動生成,以消除雙重宣告的需要,並仍然確保正確的執行時行為。
    • 在 dev 模式下,編譯器將嘗試從型別中推斷出相應的執行時驗證。例如這裡的 foo: String 是由 foo: string 型別推斷出來的。如果該型別是對匯入型別的引用,推斷的結果將是 foo: null (等於 any 型別),因為編譯器沒有外部檔案的資訊。
    • 在 prod 模式下,編譯器將生成陣列格式宣告以減少包的大小(這裡的 props 將被編譯成 ['msg'])。
    • 發出的程式碼仍然是具有有效型別的 TypeScript,它可以被其他工具進一步處理。
  • 截至目前,型別宣告引數必須是以下之一,以確保正確的靜態分析。
    • 一個型別的字面意義
    • 對同一檔案中的介面或型別字的引用

目前不支援複雜型別和從其他檔案匯入的型別。理論上,將來有可能支援型別匯入。

使用型別宣告時的預設 props

純型別的 defineProps 宣告的一個缺點是,它沒有辦法為 props 提供預設值。為了解決這個問題,提供了一個 withDefaults 編譯器巨集(compiler macros )。

interface Props {
  msg?: string
}

const props = withDefaults(defineProps<Props>(), {
  msg: 'hello',
})

這將被編譯成等效的執行時 props 預設選項。此外,withDefaults 為預設值提供了型別檢查,並確保返回的 props 型別對於那些確實有預設值宣告的屬性來說已經刪除了可選標誌(optional flags)。

頂級作用域 await

頂層的 await 可以直接在 <script setup> 裡面使用。由此產生的 setup () 函式將自動新增 async:

<script setup>
  const post = await fetch(`/api/post/1`).then(r => r.json())
</script>

此外,新增 await 的表示式將被自動編譯成一種格式,保留了當前元件例項上下文:

編譯輸出
import { withAsyncContext as _withAsyncContext } from 'vue'

const __sfc__ = {
  async setup(__props) {
    let __temp, __restore

    const post =
      (([__temp, __restore] = _withAsyncContext(() =>
        fetch(`/api/post/1`).then(r => r.json())
      )),
      (__temp = await __temp),
      __restore(),
      __temp)

    return () => {}
  },
}
__sfc__.__file = 'App.vue'
export default __sfc__

暴露元件的公共介面

在傳統的 Vue 元件中,所有暴露在模板上的東西都隱含地暴露在元件例項上,可以被父元件通過模板引用檢索到。也就是說,在這一點上,模板的渲染上下文和元件的公共介面(public interface)是一樣的。這是有問題的,因為這兩個用例並不總是完全一致。事實上,大多數時候,在公共介面方面是過度暴露的。這就是為什麼要在 Expose RFC 中討論一種明確的方式來定義一個元件的強制性公共介面。

通過 <script setup> 模板可以訪問宣告的變數,因為它被編譯成一個函式,從 setup () 函式範圍返回。這意味著所有宣告的變數實際上都不會被返回:它們被包含在 setup () 的閉包中。因此,一個使用 <script setup> 的元件將被預設關閉。也就是說,它的公共的強制性性介面將是一個空物件,除非繫結被明確地暴露出來。

要在 <script setup> 元件中明確地暴露屬性,可以使用 defineExpose 編譯器巨集(compiler macro)。

<script setup>
  const a = 1
  const b = ref(2)

  defineExpose({
    a,
    b,
  })
</script>
編譯輸出
import { defineComponent as _defineComponent } from 'vue'

const __sfc__ = _defineComponent({
  setup(__props, { expose }) {
    const a = 1
    const b = ref(2)

    expose({
      a,
      b,
    })

    return () => {}
  },
})
__sfc__.__file = 'App.vue'
export default __sfc__

當父元件通過模板 refs 獲得這個元件的例項時,檢索到的例項將是 { a: number, b: number } 的樣子。(refs 會像普通例項一樣自動解包)。通過編譯成的內容和 Expose RFC 中建議的執行時等價。

與普通 script 一起使用

有一些情況下,程式碼必須在模組範圍內執行,例如:。

  • 宣告命名的出口
  • 只應執行一次的全域性副作用。

在這兩種情況下,普通的 <script> 塊可以和 <script setup> 一起使用。

<script>
  performGlobalSideEffect()

  //this can be imported as `import { named } from './*.vue'`
  export const named = 1
</script>

<script setup>
  let count = 0
</script>
Compile Output
import { ref } from 'vue'

performGlobalSideEffect()

export const named = 1

export default {
  setup() {
    const count = ref(0)
    return {
      count,
    }
  },
}

name 的自動推導

Vue 3 SFC 在以下情況下會自動從元件的檔名推斷出元件的 name。

  • 開發警告格式化
  • DevTools 檢查
  • 遞迴的自我引用。例如,一個名為 FooBar.vue 的檔案可以在其模板中引用自己為 <FooBar/>

這比明確的註冊 / 匯入的元件的優先順序低。如果你有一個命名的匯入與元件的推斷名稱相沖突,你可以給它取別名。

import { FooBar as FooBarChild } from './components'

在大多數情況下,不需要明確的 name 宣告。唯一需要的情況是當你需要 <keep-alive> 包含或排除或直接檢查元件的選項時,你需要這個名字。

宣告額外的選項

<script setup> 語法提供了表達大多數現有 Options API 選項同等功能的能力,只有少數選項除外。

  • name
  • inheritAttrs
  • 外掛或庫所需的自定義選項

如果你需要宣告這些選項,請使用單獨的普通 <script> 塊,並使用匯出預設值。

<script>
  export default {
    name: 'CustomName',
    inheritAttrs: false,
    customOptions: {},
  }
</script>

<script setup>
  //script setup logic
</script>

使用限制

由於模組執行語義的不同,<script setup> 內的程式碼依賴於 SFC 的上下文。當移入外部的.js 或.ts 檔案時,可能會導致開發人員和工具的混淆。因此,<script setup> 不能與 src 屬性一起使用。

缺陷

工具的相容性

這種新的範圍模型將需要在兩個方面進行工具調整。

  • 整合開發環境需要為這個新的 <script setup> 模型提供專門的處理,以便提供模板表示式型別檢查 / 道具驗證等。

    截至目前,Volar 已經在 VSCode 中提供了對這個 RFC 的全面支援,包括所有 TypeScript 相關的功能。它的內部結構也被實現為一個語言伺服器,理論上可以在其他 IDE 中使用。

  • ESLint 規則如 no-unused-vars。我們在 eslint-plugin-vue 中需要一個替換規則,將 <script setup><template> 表示式都考慮在內。

採用策略

<script setup> 是可選的。現有的 SFC 使用不受影響。

未解決的問題

  • 純型別的 props/emits 宣告目前不支援使用外部匯入的型別。這在跨多個元件重複使用基本道具型別定義時非常有用。

在 Volar 的支援下,型別推理已經可以正常工作了,限制純粹在於 @vue/compiler-sfc 需要知道 props 的鍵值,以便生成正確的等效執行時宣告。

這在技術上是可行的,如果我們實現了跟蹤型別匯入、讀取和解析匯入 source 的邏輯。然而,這更像是一個實現範圍的問題,並不從根本上影響 RFC 設計的行為方式。

附錄

下面的章節僅適用於需要在各自的 SFC 工具整合中支援 <script setup> 的工具作者。

Transform API

@vue/compiler-sfc 包暴露了用於處理 <script setup> 的 compileScript 方法。

import { parse, compileScript } from '@vue/compiler-sfc'

const descriptor = parse(`...`)

if (descriptor.script || descriptor.scriptSetup) {
  const result = compileScript(descriptor) //returns SFCScriptBlock
  console.log(result.code)
  console.log(result.bindings) //see next section
}

編譯時需要提供整個描述符(the entire descriptor ),產生的程式碼將包括來自 <script setup> 和普通 <script>(如果存在)中的程式碼。上層工具(如 vite 或 vue-loader)有責任對編譯後的輸出進行正確組裝。

內聯與非內聯模式

在開發過程中,<script setup> 仍然編譯為返回的物件,而不是內聯渲染函式,原因有二:

  • Devtools 檢查
  • 模板熱過載(HMR)

內聯模板模式只在生產中使用,可以通過 inlineTemplate 選項啟用。

compileScript(descriptor, { inlineTemplate: true })

在內聯模式下,一些繫結(例如來自 ref () 呼叫的返回值)需要用 unref 進行包裝。

export default {
  setup() {
    const msg = ref('hello')

    return function render() {
      return h('div', unref(msg))
    }
  },
}

編譯器會執行一些啟發式方法來儘可能地避免這種情況。例如,帶有字面初始值的函式宣告和常量宣告將不會被 unref 包裹。

模板繫結優化

由 compiledScript 返回的 SFCScriptBlock 也暴露了一個 bindings 物件,這是在編譯期間收集的匯出的繫結後設資料。例如,給定以下 <script setup>

<script setup="props">
  export const foo = 1

  export default {
    props: ['bar'],
  }
</script>

bindings 物件將是。

{
  foo: 'setup-const',
  bar: 'props'
}

然後這個物件可以被傳遞給模板編譯器。

import { compile } from '@vue/compiler-dom'

compile(template, {
  bindingMetadata: bindings,
})

有了可用的繫結後設資料,模板編譯器可以生成程式碼,直接從相應的原始碼訪問模板變數,而不必通過渲染上下文代理。

<div>{{ foo + bar }}</div>
//code generated without bindingMetadata
//here _ctx is a Proxy object that dynamically dispatches property access
function render(_ctx) {
  return createVNode('div', null, _ctx.foo + _ctx.bar)
}

//code generated with bindingMetadata
//bypasses the render context proxy
function render(_ctx, _cache, $setup, $props, $data) {
  return createVNode('div', null, $setup.foo + $props.bar)
}

繫結資訊也被用於內聯模板模式,以生成更有效的程式碼。

實踐

最近,我使用 script setup 語法構建了一個應用 TinyTab —— 一個專注於搜尋的新標籤頁瀏覽器外掛。

  • 🌏 多語言
  • 🎨 切換主題風格
  • 🍍 自定義背景圖
  • 🌗 在深色和淺色模式之間切換或跟隨系統設定
  • ⛔ 自定義搜尋字尾(過濾規則等)
  • 🔍 設定任何你想要的搜尋引擎為預設
  • 📦 幾個開箱即用的引擎整合
  • 🎭 通過自定義字首快速切換搜尋引擎
  • 🌌 自定義任何支援 OpenSearch 的搜尋引擎
  • 🍉 匯出與匯入任何配置細節

如果你有興趣瞭解 vue3 script setup 語法,或許可以檢視它。

參考資料