实用 vue3 script setup

本文最后更新于:2022年11月18日 晚上

场景

官方文档

在两年前,vue3 发布之后不久创建了一个 sfc 提案,它允许所有 script 中顶级变量默认绑定到模板,以消除和 react 的开发者体验的差距之一。

大概长这样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<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>

第一感觉:现在真就 vue script 了
实际使用:真香

它确实解决了一些固有的问题

  • template 无法访问 js 值域
  • defineProps/defineEmits 无法复用类型定义

经过两年之后,它确实能够在生产中使用了,即便可能仍然不算完美,下面将描述它主要解决的两个问题以及与其他方法的差异

template 访问 js 值域

在 react 中,我们可以直接在 jsx 中访问任意 js 值域。例如 import 一个变量,并在 jsx 中使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
import { List } from 'antd'
import { uniq } from 'lodash-es'
import { useState } from 'react'

export function App() {
const [list] = useState([1, 2, 1])
return (
<List
dataSource={uniq(list)}
renderItem={(item) => <List.Item.Meta key={item} title={item} />}
/>
)
}

在 vue2 中,template 能访问的值必须注册到 vue 中,不管是 data/methods/components

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<script lang="ts">
import { defineComponent } from 'vue'
import { List, ListItemMeta } from 'ant-design-vue'
import { uniq } from 'lodash-es'

export default defineComponent({
components: { List, ListItemMeta },
data: () => ({
list: [1, 2, 1],
}),
methods: {
uniq,
},
})
</script>

<template>
<List :data-source="uniq(list)">
<template #renderItem="{ item }">
<ListItemMeta :title="item" />
</template>
</List>
</template>

在 vue3 中,虽然有了 hooks,但仍然未解决 js 值域的问题,只是可以通过 setup 统一暴露给 template

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<script lang="ts">
import { defineComponent, ref } from 'vue'
import { List, ListItemMeta } from 'ant-design-vue'
import { uniq } from 'lodash-es'

export default defineComponent({
components: { List, ListItemMeta },
setup() {
const list = ref([1, 2, 1])
return { list, uniq }
},
})
</script>

<template>
<List :data-source="uniq(list)">
<template #renderItem="{ item }">
<ListItemMeta :title="item" />
</template>
</List>
</template>

但 vue3 setup script 中,则部分解决了这个问题,所有在 script 中的值均可以在 template 中访问

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<script lang="ts" setup>
import { ref } from 'vue'
import { List, ListItemMeta } from 'ant-design-vue'
import { uniq } from 'lodash-es'
const list = ref([1, 2, 1])
</script>

<template>
<List :data-source="uniq(list)">
<template #renderItem="{ item }">
<ListItemMeta :title="item" />
</template>
</List>
</template>

可以看到,setup script 已经接近 react jsx 的使用体验了

当然,你可以在 https://sfc.vuejs.org/ 中查看 setup script 的实际编译结果,例如上面的代码会被编译为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
/* Analyzed bindings: {
"ref": "setup-const",
"List": "setup-maybe-ref",
"ListItemMeta": "setup-maybe-ref",
"uniq": "setup-maybe-ref",
"list": "setup-ref"
} */
import { defineComponent as _defineComponent } from 'vue'
import {
unref as _unref,
createVNode as _createVNode,
withCtx as _withCtx,
openBlock as _openBlock,
createBlock as _createBlock,
} from 'vue'

import { ref } from 'vue'
import { List, ListItemMeta } from 'ant-design-vue'
import { uniq } from 'lodash-es'

const __sfc__ = /*#__PURE__*/ _defineComponent({
__name: 'App',
setup(__props) {
const list = ref([1, 2, 1])

return (_ctx, _cache) => {
return (
_openBlock(),
_createBlock(
_unref(List),
{
'data-source': _unref(uniq)(list.value),
},
{
renderItem: _withCtx(({ item }) => [
_createVNode(
_unref(ListItemMeta),
{ title: item },
null,
8 /* PROPS */,
['title'],
),
]),
_: 1 /* STABLE */,
},
8 /* PROPS */,
['data-source'],
)
)
}
},
})
__sfc__.__file = 'App.vue'
export default __sfc__

局限性

尽管有些改进,但它也确实引发了一些问题

  • 无法在 setup 中使用 export,因为所有代码都会被编译到 setup 函数中

defineProps 复用类型定义

在 tsx 中,另一个很有用的功能是可以直接复用 ts 类型定义,而不需要定义单独的 PropType。实际上,在 react 早期,它也包含内置的 PropType 支持,但最终它们采用了 ts 的类型定义,而 vue 则一直保留了下来,并不得不忍受 ts 类型与 props 类型无法共享的问题,即便它们都在 ts 中。

例如在 tsx 中我们会这样编写类型

1
2
3
4
5
6
7
8
9
interface WindowMeta {
id: string
title: string
url: string
}

export function Window(props: { meta: WindowMeta }) {
return <pre>{JSON.stringify(props.meta, null, 2)}</pre>
}

在 vue3 没有 defineProps 之前

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<script lang="ts">
import { defineComponent, PropType } from 'vue'

interface WindowMeta {
id: string
title: string
url: string
}

export default defineComponent({
props: {
meta: {
type: Object as PropType<WindowMeta>,
required: true,
},
},
})
</script>

<template>
<pre>{{ JSON.stringify($props.meta, null, 2) }}</pre>
</template>

使用 defineProps 之后

1
2
3
4
5
6
7
8
9
10
11
<script lang="ts" setup>
interface WindowMeta {
id: string
title: string
url: string
}

defineProps<{
meta: WindowMeta
}>()
</script>

可以看到,可以直接复用 ts 类型了,不再需要单独定义 vue PropType。你可能发现 defineProps 没有被导入,是的,它们实际上是宏,实际上它们在编译后就不存在了,甚至不会被编译为 vue PropType。下面是编译结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/* Analyzed bindings: {
"meta": "props"
} */
import { defineComponent as _defineComponent } from 'vue'

const __sfc__ = /*#__PURE__*/ _defineComponent({
__name: 'App',
props: {
meta: null,
},
setup(__props) {
return () => {}
},
})
__sfc__.__file = 'App.vue'
export default __sfc__

局限性

虽然看起来不错,但它确实有局限性,例如

defineEmit 使用类型

在 react 中不存在 emits/attrs/slots,它们都被整合到 props 中(这是一个非常优雅而强大的设计)。在 vue3 中,emits 可以定义类型,但在 defineEmits 之前,仍然要通过定义值来推导类型。

例如

1
2
3
4
5
6
7
interface WindowMeta {
id: string
title: string
url: string
}

export function App(props: { onClick(item: WindowMeta): void }) {}

在 vue3 之前一般要这样写,定义对象并由 vue 根据值推断类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<script lang="ts">
import { defineComponent } from 'vue'

interface WindowMeta {
id: string
title: string
url: string
}

export default defineComponent({
// 注意,此处是一个 js 对象
emits: {
onClick(item: WindowMeta): void {},
},
})
</script>

在使用 setup script 后,可以使用 ts 类型

1
2
3
4
5
6
7
8
9
10
11
12
<script lang="ts" setup>
interface WindowMeta {
id: string
title: string
url: string
}

defineEmits<{
// 这里则是一个类型
(type: 'onClick', item: WindowMeta): void
}>()
</script>

当然,它会在编译时全部删除掉,仅用于开发时的代码提示和校验

1
2
3
4
5
6
7
8
9
10
11
12
/* Analyzed bindings: {} */
import { defineComponent as _defineComponent } from 'vue'

const __sfc__ = /*#__PURE__*/ _defineComponent({
__name: 'App',
emits: ['onClick'],
setup(__props) {
return () => {}
},
})
__sfc__.__file = 'App.vue'
export default __sfc__

也可以使用使用一些工具类型编写更符合直觉的接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<script lang="ts" setup>
import { UnionToIntersection } from 'utility-types'

type ShortEmits<T> = UnionToIntersection<
{
[P in keyof T]: T[P] extends (...args: any[]) => any
? (type: P, ...args: Parameters<T[P]>) => ReturnType<T[P]>
: never
}[keyof T]
>

interface WindowMeta {
id: string
title: string
url: string
}

defineEmits<
ShortEmits<{
onClick(item: WindowMeta): void
}>
>()
</script>

问题

它确实解决了一些开发上糟糕的体验,但也引发了一些问题

  • 必须使用 @vue/compiler-dom 才能处理 vue script 的代码,因为 setup script 意味着它不再是真正的 ts 代码了
  • 某些更激进的社区方案添加了各种各样的自定义宏,参考 unplugin-vue-macros

实用 vue3 script setup
https://blog.rxliuli.com/p/452a85351b7f4a31b56cf25eb9d3ba50/
作者
rxliuli
发布于
2022年11月10日
许可协议