Vue3-03 从Vue2迁移

Vue3学习笔记-03 从Vue2迁移

Teleport

一个新增的内置组件,用来提供一种干净的方法,允许我们控制在DOM哪个父节点下渲染HTML,不必求助于全局状态或者将其拆分为两个组件

1
2
3
4
5
6
7
8
<teleport to="body">
<div v-if="modalOpen" class="model-wrapper">
<div class="modal">
<i class="el-icon-close" @click="modalOpen = false"></i>
<p>Hello</p>
</div>
</div>
</teleport>

Vue会将<teleport>标签内的内容渲染为<body>标签的子级

它接受两个参数,第一个参数to是有效的查询选择器,指定要挂载的目标元素,第二个参数disabled用来禁用<teleport>的功能,元素会在原组件位置渲染

注意,disabled状态变化后,将移动实际的DOM节点,而不是被销毁和重新创建,并且它还将保持任何组件实例的活动状态(例如播放的视频等)

在同一个目标上使用多个<teleport>组件,会将内容挂载到同一个目标元素,顺序就是简单的追加,稍后挂载的将位于目标元素中较早的挂载之后

v-for中的Ref数组

从单个绑定获取多个Ref时,需要将ref绑定到一个更灵活的函数上,在函数中,将函数的参数el推书预置的数组中:

1
<div v-for="item in list" :ref="setItemRef"></div>

选项式API:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
export default {
data() {
return {
itemRefs: []
}
},
methods: {
setItemRef(el) {
if (el) {
this.itemRefs.push(el)
}
}
},
beforeUpdate() {
this.itemRefs = []
},
updated() {
console.log(this.itemRefs)
}
}

组合式API:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { onBeforeUpdate, onUpdated } from 'vue'

export default {
setup() {
let itemRefs = []
const setItemRef = el => {
if (el) {
itemRefs.push(el)
}
}
onBeforeUpdate(() => {
itemRefs = []
})
onUpdated(() => {
console.log(itemRefs)
})
return {
setItemRef
}
}
}

注意:

  • itemRefs不必是数组,可以是一个对象
  • 如果需要,itemRef也可以是响应式的,可以被监听
  • 需要在onBeforeUpdate中对itemRefs重置,否则会出问题

异步组件(新增)

Vue3中函数式组件被定义为纯函数,异步组件的定义需要通过defineAsyncComponent方法显示定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { defineAsyncComponent } from 'vue'
import ErrorComponent from './components/ErrorComponent.vue'
import LoadingComponent from './components/LoadingComponent.vue'

// 不带选项的异步组件
const asyncPage = defineAsyncComponent(() => import('./NextPage.vue'))

// 带选项的异步组件
const asyncPageWithOptions = defineAsyncComponent({
loader: () => import('./NextPage.vue'),
delay: 200,
timeout: 3000,
errorComponent: ErrorComponent,
loadingComponent: LoadingComponent
})

相比于Vue2,component选项被重命名为loaderloader函数不接受resolvereject参数,必须始终返回Promise

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const { createApp, defineAsyncComponent } = Vue

const app = createApp({})

const AsyncComp = defineAsyncComponent(
() =>
new Promise((resolve, reject) => {
resolve({
template: '<div>I am async!</div>'
})
})
)

app.component('async-example', AsyncComp)

如果利用Webpack和ES2015的能力,可以使用import直接导入:

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

const AsyncComp = defineAsyncComponent(() =>
import('./components/AsyncComponent.vue')
)

app.component('async-component', AsyncComp)

defineAsyncComponent的完整用法:

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
import { defineAsyncComponent } from 'vue'

const AsyncComp = defineAsyncComponent({
// 工厂函数
loader: () => import('./Foo.vue')
// 加载异步组件时要使用的组件
loadingComponent: LoadingComponent,
// 加载失败时要使用的组件
errorComponent: ErrorComponent,
// 在显示 loadingComponent 之前的延迟 | 默认值:200(单位 ms)
delay: 200,
// 如果提供了 timeout ,并且加载组件的时间超过了设定值,将显示错误组件
// 默认值:Infinity(即永不超时,单位 ms)
timeout: 3000,
// 定义组件是否可挂起 | 默认值:true
suspensible: false,
/**
*
* @param {*} error 错误信息对象
* @param {*} retry 一个函数,用于指示当 promise 加载器 reject 时,加载器是否应该重试
* @param {*} fail 一个函数,指示加载程序结束退出
* @param {*} attempts 允许的最大重试次数
*/
onError(error, retry, fail, attempts) {
if (error.message.match(/fetch/) && attempts <= 3) {
// 请求发生错误时重试,最多可尝试 3 次
retry()
} else {
// 注意,retry/fail 就像 promise 的 resolve/reject 一样:
// 必须调用其中一个才能继续错误处理。
fail()
}
}
})

$attrs包含classstyle

Vue2中v-bind="$attrs"会把除了classstyle的属性应用到元素上,而Vue3中的$attrs则会包含classstyle

组件的inheritAttrs默认为true,这样加载组件的不被认作Props的Attribute会应用到子组件的根元素上,通过设置inheritAttrsfalse,这个默认行为会被取消,通过配合v-bind="$attrs"会把这些Attribute显性的绑定到非根元素上

$children

Vue2中可以通过$children访问当前组件实例的子组件,这个API在Vue3中取消了,需要使用ref来访问子组件

自定义指令

Vue3中自定义指令的钩子有了很大变化:

  • created(new):在元素的Attribute或事件侦听器创建前调用
  • bindbeforeMount
  • insertedmounted
  • beforeUpdated(new):在元素本身更新前调用
  • update移除,改为使用updated
  • componentUpdatedupdated
  • beforeUnmount(new),在卸载元素前调用
  • unbindunmounted

最终API如下:

1
2
3
4
5
6
7
8
const MyDirective = {
beforeMount(el, binding, vnode, prevVnode) {},
mounted() {},
beforeUpdate() {}, // 新
updated() {},
beforeUnmount() {}, // 新
unmounted() {}
}

Vue中通过binding.vnode访问组件实例:

1
2
3
mounted(el, binding, vnode) {
const vm = binding.instance
}

Data选项

Vue3的Data选项只接受返回objectfunction,不再接受object本身

Data的Mixin合并

在Vue2中,来自组件的Data与来自Mixin或者Extends的Data合并时,会执行深层次的合并,在Vue3中只会执行浅层次的合并:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const Mixin = {
data() {
return {
user: {
name: 'Jack',
id: 1
}
}
}
}
const CompA = {
mixins: [Mixin],
data() {
return {
user: {
id: 2
}
}
}
}

上面的Mixin,在Vue2中得到的data是:

1
2
3
4
5
6
{
user: {
id: 2,
name: 'Jack'
}
}

在Vue3中,得到的data是:

1
2
3
4
5
{
user: {
id: 2
}
}

对于依赖Mixin深度合并的行为,建议重构代码以完全避免这种依赖,因为Mixin的深度合并非常隐式,这让代码逻辑难以理解和调试

emits选项

Vue3中新增了emits选项,与props选项很类似。这个选项用来定义和校验(包括类型定义)组件能想父组件触发什么事件

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
const app = Vue.createApp({})

// 数组语法
app.component('todo-item', {
emits: ['check'],
created() {
this.$emit('check')
}
})

// 对象语法
app.component('reply-form', {
emits: {
// 没有验证函数
click: null,

// 带有验证函数
submit: payload => {
if (payload.email && payload.password) {
return true
} else {
console.warn(`Invalid submit event payload!`)
return false
}
}
}
})

emitsprops一样,可以接受对象作为验证参数

移除了v-on.native修饰符

Vue中,传递给带有v-on的组件的事件监听器,只有通过this.$emit触发,要将原生DOM监听器添加到子组件的根元素上,需要使用.native修饰符

1
2
3
4
<my-component
v-on:close="handleComponentEvent"
v-on:click.native="handleNativeClickEvent"
/>

在Vue3中,.native已经被移除,同时,上面提到的emits选项允许组件定义真正会被触发的事件,而为包含在emits选项中的事件监听器,Vue会把它们作为原生事件监听器添加到子组件的根元素中(除非在子组件中设置了inheritAtts: false

1
2
3
4
5
<script>
export default {
emits: ['close']
}
</script>

这样click事件就会添加到my-component的根元素上

要特别注意,需要将要出发的事件添加到emits中,否则就会出现事件被触发两次的问题,例如:

1
2
3
4
5
6
7
8
<template>
<button v-on:click="$emit('click', $event)">OK</button>
</template>
<script>
export default {
emits: [] // without declared event
}
</script>

父组件中为mu-button添加click监听器:

1
<my-button v-on:click="handleClick"></my-button>

这时click会被触发两次:

  • emit触发一次
  • 因为emits选项中未定义,所以my-button上定义的handleClick会被添加到子组件的根元素上,再次被触发

事件API

Vue2中的$on$off$once方法被移除了,也就是说在Vue3中,无法在通过下面的形式创建全EventHub全局事件监听器:

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
// eventHub.js

const eventHub = new Vue()
export default eventHub

// ChildComponent.vue
import eventHub from './eventHub'

export default {
mounted() {
// 添加 eventHub 监听器
eventHub.$on('custom-event', () => {
console.log('Custom event triggered!')
})
},
beforeDestroy() {
// 移除 eventHub 监听器
eventHub.$off('custom-event')
}
}

// ParentComponent.vue
import eventHub from './eventHub'

export default {
methods: {
callGlobalCustomEvent() {
eventHub.$emit('custom-event') // 当 ChildComponent 被挂载,控制台中将显示一条消息
}
}
}

Vue3移除了上述API,推荐使用mitt或者tiny-emitter来实现全局事件监听器

过滤器

Vue2中的过滤器被移除了,建议使用方法调用或者计算属性替换

如果在应用中定义了全局过滤器,可以通过全局属性在所有组件中使用:

1
2
3
4
5
6
7
8
// main.js
const app = createApp(App)

app.config.globalProperties.$filters = {
currencyUSD(value) {
return '$' + value
}
}

然后通过$filters对象修改所有模板:

1
2
3
4
<template>
<h1>Bank Account Balance</h1>
<p>{{ $filters.currencyUSD(accountBalance) }}</p>
</template>

注意此方式只能应用在方法中,不能在计算属性中使用,因为计算属性只有在单个组件的上下文中定义才有意义

多根节点组件支持

Vue中不支持多根节点组件,所以需要使用<div>包裹多个组件:

1
2
3
4
5
6
7
<template>
<div>
<header>...</header>
<main>...</main>
<footer>...</footer>
</div>
</template>

Vue3中组件支持包含多个根节点,但是需要开发者显示定义Attribute应该分布在哪个元素或者组件上:“

1
2
3
4
5
6
<!-- Layout.vue -->
<template>
<header>...</header>
<main v-bind="$attrs">...</main>
<footer>...</footer>
</template>

全局API

createApp

Vue中通过new Vue()创建根Vue实例,从同一个Vue构造函数创建的每个根实例共享相同的全局配置,导致了两个问题:

  1. 测试期间,全局配置很容易污染测试用例
  2. 同一个页面上的多个Vue副本无法使用不同的全局配置
1
2
3
4
5
6
7
// 这会影响两个根实例
Vue.mixin({
/* ... */
})

const app1 = new Vue({ el: '#app-1' })
const app2 = new Vue({ el: '#app-2' })

为了避免这个问题,引入了新的全局APIcreateApp,调用它返回一个应用实例:

1
2
3
import { createApp } from 'vue'

const app = createApp({})

在Vue中任何全局改变Vue的行为的API都移动到了应用实例上:

config.ingoredElements替换为config.isCustomElement

使用config.isCustomElement可以支持原生自定义元素,取值是一个函数,符合这个函数返回值的标签名都不会编译为Vue组件,而是作为原生的自定义元素出现

1
2
3
4
5
6
// Vue2.x
Vue.config.ignoredElements = ['my-el', /^ion-/]

// Vue3.x
const app = createApp({})
app.config.isCustomElement = tag => tag.startsWith('ion-')

Vue3中元素是否是Vue组件的检查是在模板编译阶段完成的,因此只有在使用运行时编译器才考虑配置此选项,回事在Vue CLI的配置中新的顶层选项

Vue.prototype替换为config.globalProperties

Vue2中通过为Vue的原型Vue.prototype添加属性,让所有组件都可以访问, 例如:

1
2
3
4
5
// 之前 - Vue 2
Vue.prototype.$http = () => {};

// 组件中
this.$http();

在Vue中被替换为config.globalProperties

1
2
3
// 之后 - Vue 3
const app = createApp({})
app.config.globalProperties.$http = () => {}

如果在选项是API中,仍然使用this.$http访问

要注意,如果使用了TypeScript,直接访问this.$http会报错,需要在shims-vue.d.ts添加如下的定义:

1
2
3
4
5
6
// Vue 原型上添加的东西,需要在此定义
declare module '@vue/runtime-core' {
interface ComponentCustomProperties {
$http: () => {};
}
}

具体可以参考这里

使用provide在编写插件时非常有用,可以替代globalProperties

全局Tree Shaking

在Vue2中,例如Vue.nextTick等AAPI,无法通过Webpack的tree-shaking移除,在Vue3.0中对全局和内部API进行了冲过,全局API只能通过ES模块构建的命名导出进行文旦,例如使用nextTick时:

1
2
3
4
5
import { nextTick } from 'vue'

nextTick(() => {
// 一些和DOM有关的东西
})

这样可以更好的支持Webpack的tree-shaking,Vue应用程序中未使用的全局API会从最终打包的产出中消除,减小打包体积

key属性

key不能相同,并且在Vue2中,<template>不能拥有key属性,需要为每个子节点设置key

1
2
3
4
5
<!-- Vue 2.x -->
<template v-for="item in list">
<div :key="item.id">...</div>
<span :key="item.id">...</span>
</template>

在Vue3中则可以设置在<template>上了

1
2
3
4
5
<!-- Vue 3.x -->
<template v-for="item in list" :key="item.id">
<div>...</div>
<span>...</span>
</template>

按键修饰符

Vue2中支持keyCodes作为修改v-on方法的途径:

1
2
3
4
5
<!-- 键码版本 -->
<input v-on:keyup.13="submit" />

<!-- 别名版本 -->
<input v-on:keyup.enter="submit" />

由于keyboradEvent.keyCode不再被推荐使用,所以建议对任何要用作修饰符的键使用kebab-cased大小写名称

移除$listeners

Vue2中的事件监听器通过$listeners访问,在Vue3中$listeners被移除了,事件监听器是$attrs的一部分,事件监听器现在只是以on为前缀的Attribute

Props默认函数中不能访问this

生成Prop默认值的工厂函数不能再访问this(虽然我以前也没这么用过),可以将组件接收到的原始Prop作为参数传递给默认函数,也可以在默认函数中使用Inject API

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { inject } from 'vue'

export default {
props: {
theme: {
default (props) {
// `props` 是传递给组件的原始值。
// 在任何类型/默认强制转换之前
// 也可以使用 `inject` 来访问注入的 property
return inject('theme', 'default-theme')
}
}
}
}

渲染函数

  • 函数h从Vue中导入,不再作为参数传递给render函数
  • 渲染函数参数更改,有状态组件和函数组件之间更一致
  • VNode的Prop结构更扁平
  • 注册组件时不能在使用字符串ID隐式查找已注册组件,需要使用导入的resolveComponent方法
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
// 2.x
Vue.component('button-counter', {
data() {
return {
count: 0
}
}
template: `
<button @click="count++">
Clicked {{ count }} times.
</button>
`
})

export default {
render(h) {
return h('button-counter')
}
}

// 3.x
import { h, resolveComponent } from 'vue'

export default {
setup() {
const ButtonCounter = resolveComponent('button-counter')
return () => h(ButtonCounter)
}
}

详细的参考渲染函数的介绍

插槽统一

移除了$scopedSlots,同时插槽定义为当前节点的子对象:

1
2
3
4
5
// 3.x Syntax
h(LayoutComponent, {}, {
header: () => h('div', this.header),
content: () => h('div', this.content)
})

需要以编程方式引用时,都被统一到了$slots中:

1
2
3
4
5
// 2.x 语法
this.$scopedSlots.header

// 3.x 语法
this.$slots.header()

过渡CSS类名更改

v-enter更改为v-enter-fromv-leave更改为v-leave-from

<transition-group>过渡组件

<transition-group>不再默认渲染根元素,但仍然可以用tagprop创建根元素。

v-model.sync

Vue2中

Vue2中在组件上使用v-model相当于绑定了value的Prop和input事件:

1
2
3
4
<ChildComponent v-model="pageTitle" />

<!-- 是以下的简写: -->
<ChildComponent :value="pageTitle" @input="pageTitle = $event" />

如果需要将属性或事件名更改为其他名称,需要在子组件中添加model选项进行修改:

1
2
3
4
5
<!-- ParentComponent.vue -->
<ChildComponent v-model="pageTitle" />

<!-- 是以下的简写: -->
<ChildComponent :title="pageTitle" @change="pageTitle = $event" />
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// ChildComponent.vue

export default {
model: {
prop: 'title',
event: 'change'
},
props: {
// 这将允许 `value` 属性用于其他用途
value: String,
// 使用 `title` 代替 `value` 作为 model 的 prop
title: {
type: String,
default: 'Default title'
}
}
}

某些情况下需要对一个Prop进行『双向绑定』,在Vue中可以使用update:propName来抛出事件,例如对于上面的ChildComponent来说,可以通过下面的方法更新父元素中的title

1
this.$emit('update:title', newValue)

父组件中可以监听该事件并更新本地数据:

1
<ChildComponent :title="pageTitle" @update:title="pageTitle = $event" />

.sync修饰符就是上面的代码的语法糖:

1
<ChildComponent :title.sync="pageTitle" />

Vue3中

Vue3里面,自定义组件的v-model相当于传递了modelValue的Prop,并接受抛出的update:modelValue事件:

1
2
3
4
5
6
7
8
<ChildComponent v-model="pageTitle" />

<!-- 是以下的简写: -->

<ChildComponent
:modelValue="pageTitle"
@update:modelValue="pageTitle = $event"
/>

如果要更改v-model传递的Prop名称,不再需要去子组件中配置model选项,而是给v-model传递一个参数即可:

1
2
3
4
<ChildComponent v-model:title="pageTitle" />

<!-- 是以下的简写: -->
<ChildComponent :title="pageTitle" @update:title="pageTitle = $event" />

这也可以作为.sync的替代,所以.sync就没有用武之地了,所以在Vue2中的sync直接替换为v-model即可

1
2
3
4
<ChildComponent :title.sync="pageTitle" />

<!-- 替换为 -->
<ChildComponent v-model:title="pageTitle" />

同时在同一个组件上,可以同时使用多个v-model

1
2
3
4
5
6
7
8
9
<ChildComponent v-model:title="pageTitle" v-model:content="pageContent" />

<!-- 是以下的简写: -->
<ChildComponent
:title="pageTitle"
@update:title="pageTitle = $event"
:content="pageContent"
@update:content="pageContent = $event"
/>

v-model自定义修饰符

Vue2中v-model提供了.trimlazy.number内置修饰符,Vue3中还提供了自定义修饰符的能力。

添加到v-model的修饰符通过modelModifiersProp 提供给组件,它接受一个default的对象,取值是一个函数,这个函数会返回一个对象,当父组件没有提供v-model的修饰符时,会从这个函数返回的对象里面取值

注意,modelModifiers是一个Prop,不是Vue实例的选项,它提供的并不是修饰符具体逻辑的实现,而是提供组件默认包含哪些修饰符,取值是布尔值

请注意,当组件的created生命周期钩子触发时,modelModifiersProp会包含capitalize,且其值为true——因为capitalize被设置在了写为v-model.capitalize="myText"v-model绑定上。

建议把组件中定义的修饰符显式的写在modelModifiersProp中,这样可以为TypeScript版本的组件提供正确的类型推导,也便于一眼看出组件中使用了哪些修饰符

另外,v-model自定义修饰符也支持多级串联

1
<v-model-child v-model.capitalize.test="msg" />

v-model-child组件中,我们会检查props.modelModifiers是否包含capitalizetest,并且编写一个处理器来更改emit发出的值:

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
<template>
<label>
请输入:
<input :value="modelValue" @input="onInput" />
</label>
</template>

<script lang="ts">
import {defineComponent} from 'vue';

export default defineComponent({
name: 'v-model-child',
props: {
modelValue: String,
modelModifiers: {
default: () => ({
capitalize: true
})
}
},
emits: ['update:modelValue'],
setup(props, {emit}) {
const capitalize = (val: string) => val.charAt(0).toUpperCase() + val.slice(1);
const isCapitalize = props.modelModifiers && props.modelModifiers.capitalize;

const onInput = (e: {target: HTMLInputElement}) => {
const originValue = e.target.value;
const value = isCapitalize ? capitalize(originValue) : originValue;
emit('update:modelValue', value);
};

return {
onInput
};
}
});
</script>

对于带有参数的(即自定义model使用的Prop名的v-modelv-model绑定,生成的自定义修饰符的Prop名称为参数名 + "Modifiers"

1
<my-component v-model:description.capitalize="myText"></my-component>
1
2
3
4
5
6
7
8
9
10
11
12
app.component('my-component', {
props: ['description', 'descriptionModifiers'],
emits: ['update:description'],
template: `
<input type="text"
:value="description"
@input="$emit('update:description', $event.target.value)">
`,
created() {
console.log(this.descriptionModifiers) // { capitalize: true }
}
})

v-forv-if的优先级

Vue2中,在同一个元素上应用v-ifv-forv-for优先级更高,而Vue3中v-if的优先级更高,也就是说v-if将没有权限访问v-for里面的变量,下面的代码会报错:

1
2
3
4
5
<!-- This will throw an error because property "todo" is not defined on instance. -->

<li v-for="todo in todos" v-if="!todo.isComplete">
{{ todo.name }}
</li>

所以要尽量避免在同一个元素上使用v-forv-if,可以把v-for移动到<template>中来修正:

1
2
3
4
5
<template v-for="todo in todos" :key="todo.name">
<li v-if="!todo.isComplete">
{{ todo.name }}
</li>
</template>

v-bind合并行为

Vue2中,如果一个元素同时定义了v-bind="object"和一个相同的单独的Property,那么单独的Property会覆盖object中的绑定:

1
2
3
4
5
<!-- template -->
<div id="red" v-bind="{ id: 'blue' }"></div>

<!-- result -->
<div id="red"></div>

在Vue3中覆盖结果取决于声明绑定的书序,后面绑定的结果会覆盖前面的绑定结果:

1
2
3
4
5
6
7
8
9
<!-- template -->
<div id="red" v-bind="{ id: 'blue' }"></div>
<!-- result -->
<div id="blue"></div>

<!-- template -->
<div v-bind="{ id: 'blue' }" id="red"></div>
<!-- result -->
<div id="red"></div>

对数组的监听

在Vue3中,使用watch监听一个数组时,默认只有数组被替换时才会触发回调,数组成员改变时不会触发回调,如果希望在数组改变时触发需要添加deep参数

1
2
3
4
5
6
7
8
watch: {
bookList: {
handler(val, oldVal) {
console.log('book list changed')
},
deep: true
},
}