从 Vue2 到 Vue3(二):语法

其实讲了那么多原理,目前还是不知道怎么写 Vue3,由是简单分享一下如何写的问题

起步

这是 Vue2 的写法

1
2
3
4
import Vue from 'vue'
import App from './App.vue'

new Vue({el: '#app', render: (h) => h(App)})

Vue3 的组合式 API 使用函数而不是声明选项的方式书写 Vue 组件,这也反应在了创建 Vue 实例上,工具方法需要引入,而不是直接挂载在 Vue 类上,这样做也有利于打包时的 TreeShaking 精确判断不必要的代码片段并将之移除

1
2
3
4
5
import { createApp } from 'vue'
import App from './App.vue'

const app = createApp(App)
app.mount('#app')

选项式到组合式

组合式 API (Composition API) 是一系列 API 的集合,使我们可以使用函数而不是声明选项的方式书写 Vue 组件。它是一个概括性的术语,涵盖了以下方面的 API[1]

  • 响应式 API:例如 ref()reactive(),使我们可以直接创建响应式状态、计算属性和侦听器。

  • 生命周期钩子:例如 onMounted()onUnmounted(),使我们可以在组件各个生命周期阶段添加逻辑。

  • 依赖注入:例如 provide()inject(),使我们可以在使用响应式 API 时,利用 Vue 的依赖注入系统。

那么为什么要有组合式 API?—— 1.更好的逻辑复用和2.更灵活的代码组织[2]

第1点,在 Vue2 中使用 mixins 多了会有这样的痛点:组件中使用了一些方法和变量,但是在文件中找不到它们的声明,于是只能寻着 mixins 一个一个找,费时费力不说,还有可能在定义变量或方法时不经意覆盖了本不该覆盖的属性,而且这样让多个 mixins 隐形耦合在一起,不利于多处复用[3]

声明:以下提到的Vue3的写法均指使用单文件组件及组合式 API时<script setup>语法下的写法,相比于普通的 <script>语法,它具有更多优势:[4]

  • 更少的样板内容,更简洁的代码。
  • 能够使用纯 TypeScript 声明 props 和自定义事件。
  • 更好的运行时性能 (其模板会被编译成同一作用域内的渲染函数,避免了渲染上下文代理对象)。
  • 更好的 IDE 类型推导性能 (减少了语言服务器从代码中抽取类型的工作)。

那么从现在 Vue 的单文件组件的风格转变为组合式 API吧。

props

Vue2 这么定义组件 props

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 最简方式
export default {
props: ['foo']
}

export default {
props: {
// 详细配置
loading: {
type: Boolean,
default: false
},
// 只声明类型
foo: String
}
}

而 Vue3 使用defineProps方法定义组件 props

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 最简方式
defineProps(['loading'])

// 兼容选项式配置
defineProps({
loading: {
type: Boolean,
default: false
}
})

// 推荐用法,其中的`?`表示可选
defineProps<{ loading?: boolean }>()

// 上述针对类型的 defineProps 声明的不足之处在于,它没有可以给 props 提供默认值的方式。此时需要使用 withDefaults 编译器宏
// 当配置了默认值时,一般同时需要将该 prop 置为可选,避免 Vue 警告
withDefaults(defineProps<{ loading?: boolean; bookNames?: string[] }>(), {
loading: false,
// 与选项式 API 一致,非原始类型的默认值需要用方法返回
bookNames: () => []
})

这些定义好的 props 可以直接在模板 template 中使用,Vue 已经做好了解析,但是如果想要在组合式 API 的别处使用,则需要接受withDefaults或者defineProps返回的值,如下:

1
2
3
const props = defineProps<{ loading?: boolean }>()

const loadingLabel = computed(() => props.loading ? '加载中...' : '加载完毕')

另外,Vue3 中,父组件传入的 props 严禁子组件修改,即使是对象的深层属性或者数组增减也一样,因此对于这种数据,应该显式使用defineModel声明为双向绑定的值,defineModel稍后讨论

data 和 methods

在选项式 API 里,data 需要是一个函数,为了防止多实例数据互相污染,而在组合式 API 里,没有显式的 data 和 methods,顶层的绑定会被暴露给模板,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
<script setup>
// 变量
const msg = 'Hello!'

// 函数
function log() {
console.log(msg)
}
</script>

<template>
<button @click="log">{{ msg }}</button>
</template>

要使用响应式,则使用ref()包裹数据,同时 ref 也支持预先定义数据类型:

1
2
const msg = ref('hello')
const info = ref<{name: string}>({name: '张三'})

如果不预先定义数据类型,则 ref 会推断内部数据类型,这在内部数据属性不完全的时候会有类型校验问题,假设有一个对象类型如下:

1
2
3
4
type DemoObj = {
name: string,
description?: string
}

而在定义数据的时候,还不确定 description 的内容,于是有可能这样写:

1
const demo = ref({ name: 'demo' })

于是当想要设置 description 时,IDE 会报错:

此时改为

1
const demo = ref<DemoObj>({ name: 'demo' })

或者使用类型断言(类型断言纯粹是一个编译时语法,同时,它也是一种为编译器提供关于如何分析代码的方法)

1
const demo = ref({ name: 'demo' } as DemoObj)

即可消除报错

computed

组合式 API 的computed是一个方法,需要从 vue 中引入,内部接收一个类似选项式 API 的方法或者对象,如下:

1
2
3
4
5
6
7
8
9
10
11
12
import { ref, computed } from 'vue'

const msg = ref('hello')
const loadingLabel = computed(() => msg.value === 'hello' ? '加载中...' : '加载完毕')
const computedMsg = computed({
get() {
return msg.value
},
set(val) {
msg.value = val
}
})

watch

组合式 API 中的watch很像原本的$watch,但是有以下两个主要的区别:

  1. watch 的第一个参数,不再支持键路径,它应该是一个响应式对象、返回一个值的函数、ref或者这三种类型的值的数组
  2. watch 的选项在原本的immediatedeep之外,还增加了以下选项:
    • flush:调整回调函数的刷新机制,这个稍后讨论
    • once:只执行一次回调函数
    • onTrack/onTrigger:调试侦听器的依赖

对于常用的监听路由参数的情况,应该以函数方式传入第一个参数,避免监听整个路由对象:

1
2
3
4
5
6
7
8
9
10
import { watch } from 'vue'
import { useRoute } from 'vue-router'

const route = useRoute()
watch(
() => route.query.id,
(n, o) => {
// some code
}
)

Vue3 还提供了一个watchEffect方法用于自动追踪所有依赖,并在依赖改变时触发副作用函数,watchEffet接收两个参数,第一个即是副作用函数,第二个是 watch 的选项,不过不包括onceimmediatedeep。在使用时,watchwatchEffect有三个区别:

  1. watch 更明确由哪个状态触发的侦听器,而 watchEffect 自动追踪依赖变化触发侦听器
  2. watch 可以访问侦听状态变化的前后值,watchEffect 则不可以
  3. watch 是懒执行副作用,而 watchEffect 会立刻执行副作用函数

默认情况下,侦听器将在组件渲染之前执行,在生命周期中,它在onBeforeUpdate之前调用。如果想在侦听器回调中能访问被 Vue 更新之后的所属组件的 DOM,你需要指明 flush: ‘post’ 选项:[5]

1
2
3
4
5
6
7
8
9
import { watch, watchEffect } from 'vue'

watch(source, callback, {
flush: 'post'
})

watchEffect(callback, {
flush: 'post'
})

还可以创建一个同步触发的侦听器,它会在 Vue 进行任何更新之前立即同步触发,而不是等到下一个事件循环才执行:[6]

1
2
3
4
5
6
7
8
9
import { watch, watchEffect } from 'vue'

watch(source, callback, {
flush: 'sync'
})

watchEffect(callback, {
flush: 'sync'
})

同步侦听器不会进行批处理,每当检测到响应式数据发生变化时就会触发。可以使用它来监视简单的布尔值,但应避免在可能多次同步修改的数据源 (如数组) 上使用。

<script setup>中同步创建的侦听器会自动绑定到宿主组件实例上,实例卸载时侦听器会自动停止。但是如果异步创建了侦听器,则必须通过返回值获取到其停止方法,并合适的时机手动停止它以防止内存泄漏,如:

1
2
3
4
5
6
7
8
import { watchEffect } from 'vue'

setTimeout(() => {
const unwatch = watchEffect(() => {})

// ...当该侦听器不再需要时
unwatch()
}, 100)

emit

为了在声明emits选项时获得完整的类型推导支持,可以使用defineEmitsAPI,其使用方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 最简方法
const emit = defineEmits(['change', 'delete'])

const emit = defineEmits<{
(e: 'change', id: number): void // 普通的函数类型声明
(e: 'update', value: string): void
}>()

// 3.3+:相比上一种语法,另一种更简洁的语法
const emit = defineEmits<{
change: [id: number] // 具名元组语法,中括号内是参数列表
update: [value: string]
}>()

// 使用 emit
emit('change', id) // 此处 IDE 会根据类型校验参数类型及个数

model

不像 Vue2,v-model通常只有一个绑定的值,Vue3 支持通过名字指定多个v-model,在不指定名字时,model 的默认名字是modelValue,这里为了省事,搬一下 Vue3 文档的示例:

1
2
3
4
5
6
7
8
9
10
11
12
// 声明 "modelValue" prop,由父组件通过 v-model 使用
const model = defineModel()
// 或者:声明带选项的 "modelValue" prop
const model = defineModel({ type: String })

// 在被修改时,触发 "update:modelValue" 事件
model.value = "hello"

// 声明 "count" prop,由父组件通过 v-model:count 使用
const count = defineModel("count")
// 或者:声明带选项的 "count" prop
const count = defineModel("count", { type: Number, default: 0 })

其他

  1. 对于选项式 API 中的其他选项,可以使用defineOptions编译宏来声明,而不必使用单独的<script>块:
1
2
3
4
5
6
7
8
// 可以使用组合式 API 完成定义的选项无法使用这个宏定义
defineOptions({
name: 'DemoComp',
inheritAttrs: false,
customOptions: {
/* ... */
}
})
  1. 组合式 API 不再默认暴露组件实例的所有属性和方法,因此无法通过在控制台中使用 Vue DevTools 像之前一样$vm.xxx访问变量或方法,父组件也无法直接通过$refs.childRef.someMethod()调用子组件的方法,如果特别需要暴露实例的变量和方法:
1
2
3
4
5
6
7
8
9
10
11
import { ref } from 'vue'

const a = ref(2)
const b = function() {
console.log(a.value)
}

defineExpose({
a,
b
})
1
2
3
4
5
6
7
8
9
<!-- 使用 -->
<template>
<ChildComponent ref="childRef" />
</template>

<script setup>
const childRef = ref(null)
ref.value.someMethod()
</script>
  1. Vue3 官方推荐使用pinia而不是vuex作为状态管理工具

  2. 生命周期基本类似于 Vue2,只是在 beforeCreate 前增加了 setup 的步骤,所有的生命周期钩子也需要声明引入,如import { onMounted } from 'vue'
    实例生命周期图

  3. 在模板渲染上下文中,只有顶级的 ref 属性才会被解包[7]。如果某一个变量本身不是响应式,但是其内部包含了一个响应式的属性,则在模板中使用时,需要手动解包: <span>{{ obj.refValue.value }}</span>

  4. 在定义变量或方法之前就使用它们,会引起编译失败。这个问题在以前使用选项式基本不会出现,但是使用了组合式以后很容易在随意排布方法和变量的时候,习惯性将用到的方法后置,从而在之前调用时产生报错

另外,TS 相关的知识,请参考TypeScript 入门教程等文章

IDE

其实关于 IDE 的问题,不该写到这里,但是实际开发中还是会遇到很多莫名其妙地报红问题,所以总结到这里,以 VS Code 为例

  1. 如果项目使用了 Vue3,那么应该安装Vue Language Features (Volar)插件 并在工作区暂时禁用Vetur
  2. 切换到 Vue 文件,在 VS Code 右下角的状态栏中,正确选择 TypeScript 版本为当前工作区的版本,而不是 VS Code 内置的版本
  3. 如果确认自己的代码无误,但 VS Code 仍然在报错,则应该考虑以下方案:
    • 如果报错在 TS 文件中,在 VS Code 中执行TypeScript: Reload Project命令
    • 如果报错在 Vue 文件中,在 VS Code 中执行Volar: Reload Project命令
    • 如果上述操作还没有解决问题,则重启当前 VS Code 窗口

最后,我基于slidev做了一个在线的演示文档,谨供参阅

参考


  1. 组合式API ↩︎

  2. 为什么要有组合式API? ↩︎

  3. 组合式API和Mixin的对比 ↩︎

  4. <script_setup> ↩︎

  5. post侦听器 ↩︎

  6. 同步侦听器 ↩︎

  7. 在模板中解包的注意事项 ↩︎