vue3 script setup 定稿

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 語法,或許可以查看它。

參考資料

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章