前言

这篇文章的主要意义是:针对想要从vue2快速迁移到vue3的朋友,快速理解vue3的新特性。

为了让大家更好的理解,我会从设计者的角度来讲为什么vue3要这样设计api。

vue3新特性思维导图:

第一部分、vue2的痛点——代码糅杂

从vue2到vue3,最大的改动就是组合式API——setup。

为什么要这样改?

在vue2项目中,当一个组件的代码量超过300行时,业务逻辑分散的问题很容易出现。比如下面这张图,大家可以直观的看到代码交叉在一起,错综复杂。

新增或者修改一个需求,就需要分别在data,methods,computed里修改 ,滚动条反复上下移动

尤其对于那些一开始没有编写这些组件的人来说,这会导致组件难以阅读和理解。

同时,如果想把一段业务代码从中抽离出来,要花不少的时间——你要不停找和业务相关的变量、方法,有一些地方复制漏了还容易出bug(不说了,都是血与泪o(╥﹏╥)o)

特别是大项目的时候,随随便便就是几百个组件,我们需要共享和重用代码,但是却vue2的语法导致很难抽离业务代码复用。

那我们就想啊, 如果可以按照逻辑进行分割,将上面这张图变成下边这张图,是不是就清晰很多了呢, 这样的代码可读性和可维护性都更高:

针对这个问题,虽然vue2.x 版本给出的解决方案—— Mixin, 但是使用 Mixin 也会遇到让人苦恼的问题:

命名冲突问题
不清楚暴露出来的变量的作用
逻辑重用到其他 component 经常遇到问题
好,问题来了,如果你是设计者,你如何解决代码的共享和复用的问题??

我打个分割线,你可以思考几分钟。

好了,公布答案:在语法上,允许把业务逻辑写在单独的js文件里,就可以解决了。

而vue3也是这样做的,Vue3直接抄了一手React1.68版本推出的「hook」,「hook」的含义就是上面提到的——需要大量复用的业务函数,「hook」的作用类似于vue2中的mixin技术,但是功能更强。

说简单点——「hook」就是具有高度复用性的业务js文件。

那么问题来了,如果要允许把业务逻辑写在单独的js文件里,你就不能像原来一样,变量必须写在vue文件的data里,方法必须写在vue文件的methods里,否则就没办法把代码抽离出去。

因此,我们就需要一套新的语法,「hook」的实现就是这套新的语法——Vue3的组合式API。

第二部分、Vue3的组合式API(Composition API)

一、setup

1.setup的定义

setup 是 Vue3.x 新增的一个选项, 他是组件内使用组合式API的入口, 所有的组合API函数都在此使用, 只在初始化时执行一次。

setup的意义:允许你把vue2中的data的变量、method的方法、compute、watch等等API,直接写在JS里。

2.setup的语法

setup有两种语法,分别是vue3.0的setup函数版,和vue3.2以上的setup语法糖版,我们一个个来看。

1)写法1:vue3.0,setup函数

为方便大家理解,我把vue2.0的写法也贴上去了,setup写法看下方代码:

<template>
  <h2>{{count}}</h2>
  <hr>
  <button @click="update">更新</button>
</template>
 
<script>
import {
  ref
} from 'vue'
export default {
 
  /* 在Vue3中依然可以使用data和methods配置, 但建议使用其新语法实现 */
  // data () {
  //   return {
  //     count: 0
  //   }
  // },
  // methods: {
  //   update () {
  //     this.count++
  //   }
  // }
 
  /* 使用vue3的composition API */
  setup () {
 
    // 定义响应式数据 ref对象 后面会讲
    const count = ref(1)
    console.log(count)
 
    // 更新响应式数据的函数
    function update () {
      // alert('update')
      count.value = count.value + 1
    }
    
    // 暴露给 template
    return {
      count,
      update
    }
  }
}
</script>

setup函数的注意点:

①声明变量需要ref、reactive

作为vue2.0data的语法的代替,vue3提供了新的响应式变量声明方法——ref、reactive,这个后面会详细讲。

②暴露变量必须 return

setup方法一般都返回一个对象: 为模板提供数据, 也就是模板中可以直接使用此对象中的所有属性/方法。

所以,最后要把注册的对象和方法 return 给模板,这个不要忘了。如果你发现模板的变量没有正确更新,可以检查一下这个属性有没有return。

③method绑定函数的语法变化

原来在method绑定函数的语法,到vue3中变为了,在setup内声明函数,然后return即可。

④setup方法的参数

setup中不能访问 Vue2 中最常用的this对象(后面会讲),这就带来了一个问题:

在vue2语法中,组件的this.props、子组件的this.$refs.xxx,原来这些依赖this对象的api就要调整。

vue3把这些依赖this对象的api,放在了setup 参数里,使用setup时,它接受两个参数:

参数1:props:

组件传入的属性,setup 函数中的 props 是响应式的,当传入新的 prop 时,它将被更新。

export default {
  props: {
    title: String
  },
  setup(props) {
    console.log(props.title)
  }
}

注意:因为 props 是响应式的,你不能使用 ES6 解构,它会消除 prop 的响应性。

如果需要解构 prop,可以在 setup 函数中使用 toRefs 函数来完成此操作:

import { toRefs } from 'vue'
 
setup(props) {
  const { title } = toRefs(props)
 
  console.log(title.value)
}

如果 title 是可选的 prop,则传入的 props 中可能没有 title 。在这种情况下,toRefs 将不会为 title 创建一个 ref 。你需要使用 toRef (比toRefs少了个s)替代它:

import { toRef } from 'vue'
setup(props) {
  const title = toRef(props, 'title')
  console.log(title.value)
}

参数2:context

接下来说一下第二个参数context,我们前面说了setup中不能访问 Vue2 中最常用的this对象,所以context中就提供了this中最常用的三个属性:attrs、slot 和emit,分别对应 Vue2.x 中的 $attr属性、slot插槽 和$emit发射事件。并且这几个属性都是自动同步最新的值,所以我们每次使用拿到的都是最新值。

export default {
  setup(props, context) {
    // Attribute (非响应式对象,等同于 $attrs)
    console.log(context.attrs)
 
    // 插槽 (非响应式对象,等同于 $slots)
    console.log(context.slots)
 
    // 触发事件 (方法,等同于 $emit)
    console.log(context.emit)
 
    // 暴露公共 property (函数)
    console.log(context.expose)
  }
}

2)写法2:vue3.2以上,setup语法糖

Vue3.2 中 只需要在 script 标签上加上 setup 属性,组件在编译的过程中代码运行的上下文是在 setup() 函数中,无需return,template可直接使用。

要使用这个语法,需要将 setup attribute 添加到 script 代码块上:

<script setup>
console.log('hello script setup')
</script>

里面的代码会被编译成组件 setup() 函数的内容。这意味着与普通的 script 只在组件被首次引入的时候执行一次不同,script setup 中的代码会在每次组件实例被创建的时候执行。

setup语法糖的细节:

①顶层的绑定会被暴露给模板

当使用 script setup 的时候,任何在 script setup 声明的顶层的绑定 (包括变量,函数声明,以及 import 引入的内容) 都能在模板中直接使用:

<script setup>
// 变量
const msg = 'Hello!'
 
// 函数
function log() {
  console.log(msg)
}
</script>
 
<template>
  <div @click="log">{{ msg }}</div>
</template>

②引入的组件自动注册

import 导入的内容也会以同样的方式暴露。意味着可以在模板表达式中直接使用导入的 helper 函数,并不需要通过 methods 选项来暴露它:

<script setup>
import { capitalize } from './helpers'
</script>
 
<template>
  <div>{{ capitalize('hello') }}</div>
</template>

③获取props、emit从函数参数改成了引入API

之前setup内获取props、emit是通过setup函数参数传递的,由于移除了setup方法,这些参数的获取换成了APIdefineProps 和 defineEmits,具体使用会在后面props、emit修改中讲到。

3.setup注意点:

1)setup中没有this

在 setup() 内部,this 不是该活跃实例的引用,因为 setup() 是在解析其它组件选项之前被调用的,所以 setup() 内部的 this 的行为与其它选项中的 this 完全不同。这使得 setup() 在和其它选项式 API 一起使用时可能会导致混淆。

2)setup的执行时机

我在学习过程中看到很多文章都说 setup 是在 beforeCreate和created之间, 这个结论是错误的。实践是检验真理的唯一标准, 于是自己去检验了一下:

export default defineComponent({
  beforeCreate() {
    console.log("----beforeCreate----");
  },
  created() {
    console.log("----created----");
  },
  setup() {
    console.log("----setup----");
  },
});

setup 执行时机是在 beforeCreate 之前执行,详细的可以看后面生命周期讲解。

二、响应式对象的绑定——reactive、ref

接下来要讲vue3组合式API中2个最重要的响应式API——reactive和ref,这两兄弟的作用是什么呢?

实际上就是vue2中的data,定义出响应式的对象。要知道,在setup函数里,如果用var随便定义一个变量,然后return给模板,是无法实现模板驱动视图的,即你修改了变量的值,视图不会跟着变,所以就需要一个生产响应式对象的工厂函数——也就是ref,vue称为这个对象为ref对象。

下面一个个的来看:

1.ref

作用: 定义一个数据的响应式
语法: const xxx = ref(initValue):
创建一个包含响应式数据的引用(reference)对象
js中操作数据: xxx.value
模板中操作数据: 不需要.value
一般用来定义一个基本类型的响应式数据
如果用ref对象/数组, 内部会自动将对象/数组转换为reactive的代理对象。

const count = ref(0)
console.log(count.value) // 0
 
count.value++
console.log(count.value) // 1

值得注意的是, ref 不仅可以实现响应式,还可以用于模板的DOM元素。

语法:

//HTML:<input type="text" ref="inputRef">
//"inputRef"是template中,元素设置的ref值
const inputRef = ref<HTMLElement|null>(null)

我们用一段代码来演示一下:

<template>
    <p ref="elemRef">今天是周一</p>
</template>
 
<script>
import { ref, onMounted } from 'vue'
 
export default {
    name: 'RefTemplate',
    setup(){
        const elemRef = ref(null)
 
        onMounted(() => {
            console.log('ref template', elemRef.value.innerHTML, elemRef.value)
        })
 
        return{
            elemRef
        }
    }
}
</script>

复制代码
此时浏览器的显示效果如下所示:

我们通过在模板中绑定一个 ref ,之后在生命周期中调用,最后浏览器显示出该 DOM 元素。所以说, ref 也可以用来渲染模板中的DOM元素。

2.reactive

作用: 定义对象的响应式
const proxy = reactive(obj): 接收一个普通对象然后返回该普通对象的响应式代理器对象
不能用于基本数据类型,例如字符串、数字、boolean 等,方法参数只能json或对象。
响应式转换是“深层的”:会影响对象内部所有嵌套的属性。
内部基于 ES6 的 Proxy 实现,通过代理对象(proxy)操作源对象内部数据都是响应式的。
reactive 注册的对象本身是响应式的,但reactive 对象取出的所有属性值都是非响应式的,这个时候就需要用到toRefs函数,后面会讲相应的案例。

<template>
  <h2>name: {{state.name}}</h2>
  <h2>age: {{state.age}}</h2>
  <h2>wife: {{state.wife}}</h2>
  <hr>
  <button @click="update">更新</button>
</template>
 
<script>
/* 
reactive: 
    作用: 定义多个数据的响应式
    const proxy = reactive(obj): 接收一个普通对象然后返回该普通对象的响应式代理器对象
    响应式转换是“深层的”:会影响对象内部所有嵌套的属性
    内部基于 ES6 的 Proxy 实现,通过代理对象操作源对象内部数据都是响应式的
*/
import {
  reactive,
} from 'vue'
export default {
  setup () {
    /* 
    定义响应式数据对象
    */
    const state = reactive({
      name: 'tom',
      age: 25,
      wife: {
        name: 'marry',
        age: 22
      },
    })
    console.log(state, state.wife)
 
    const update = () => {
      state.name += '--'
      state.age += 1
      state.wife.name += '++'
      state.wife.age += 2
    }
 
    return {
      state,
      update,
    }
  }
}
</script>

3.reactive与ref的使用细节

ref用来处理基本类型数据, reactive用来处理对象(递归深度响应式),不懂基本类型数据和引用数据类型的区别戳这里看原理
如果用ref对象/数组, 内部会自动将对象/数组转换为reactive的代理对象

<script>
  import { reactive, ref, toRefs } from 'vue'
  export default {
    /* 在Vue3中的reactive, ref,就相当于这里data定义变量的作用 */
    // data() {
    //   return {
    //     name: "",
    //     state: {
    //       name: 'Jerry',
    //     },
    //   }
    // }
    setup () {
      // ref声明响应式数据,用于声明基本数据类型
      const name = ref('Jerry')
      // 修改
      name.value = 'Tom'
    
      // reactive声明响应式数据,用于声明引用数据类型
      const state = reactive({
        name: 'Jerry',
        sex: '男'
      })
      // 修改
      state.name = 'Tom'
 
      
      return {
          name,
          state,
        }
  }
}
</script>

4.toRef和toRefs:将对象的一个或多个属性转换为ref

1)toRef

作用:可以用来为源响应式对象上的一个 property 新创建一个 ref。然后,ref 可以被传递,它会保持对其源 property 的响应式连接。

const state = reactive({
  foo: 1,
  bar: 2
})
 
const fooRef = toRef(state, 'foo')
 
fooRef.value++
console.log(state.foo) // 2
 
state.foo++
console.log(fooRef.value) // 3

应用:当你要将 prop 的 ref 传递给复合函数时,toRef 很有用:

问题:props作为参数接受时,是非响应式的。

解决:利用 toRef方法需要把它变成响应式的。

export default {
  setup(props) {
    useSomeFeature(toRef(props, 'foo'))
  }
}

即使源 property 不存在,toRef 也会返回一个可用的 ref。这使得它在使用可选 prop 时特别有用,可选 prop 并不会被 toRefs 处理。

2)toRefs

作用:将响应式对象转换为普通对象,其中结果对象的每个 property 都是指向原始对象相应 property 的 ref。

应用:当从合成函数返回响应式对象时,toRefs 非常有用,这样消费组件就可以在不丢失响应式的情况下对返回的对象进行分解使用

问题:reactive 对象取出的所有属性值都是非响应式的(reactive 注册的对象本身是响应式的,只是他的属性不是响应式,别搞混了)

解决:利用 toRefs 可以将一个响应式 reactive 对象的所有原始属性转换为响应式的 ref 属性

<template>
  <h2>App</h2>
  <h3>foo: {{foo}}</h3>
  <h3>bar: {{bar}}</h3>
  <h3>foo2: {{foo2}}</h3>
  <h3>bar2: {{bar2}}</h3>
 
 
</template>
 
<script lang="ts">
import { reactive, toRefs } from 'vue'
/*
toRefs:
  将响应式对象中所有属性包装为ref对象, 并返回包含这些ref对象的普通对象
  应用: 当从合成函数返回响应式对象时,toRefs 非常有用,
        这样消费组件就可以在不丢失响应式的情况下对返回的对象进行分解使用
*/
export default {
 
  setup () {
 
    const state = reactive({
      foo: 'a',
      bar: 'b',
    })
 
    const stateAsRefs = toRefs(state)
 
    setTimeout(() => {
      state.foo += '++'
      state.bar += '++'
    }, 2000);
 
    const {foo2, bar2} = useReatureX()
 
    return {
      // ...state,
      ...stateAsRefs,
      foo2, 
      bar2
    }
  },
}
 
function useReatureX() {
  const state = reactive({
    foo2: 'a',
    bar2: 'b',
  })
 
  setTimeout(() => {
    state.foo2 += '++'
    state.bar2 += '++'
  }, 2000);
 
  return toRefs(state)
}
 
</script>

注意:toRefs 只会为源对象中包含的 property 生成 ref。如果要为特定的 property 创建 ref,则应当使用 toRef

5.vue2.x 与 vue3.x 响应式(重要)

其实在 Vue3.x 还没有发布 bate 的时候, 很火的一个话题就是Vue3.x 将使用 Proxy 取代 Vue2.x 版本的 Object.defineProperty。没有无缘无故的爱,也没有无缘无故的恨。为何要将Object.defineProperty换掉呢,咋们可以简单聊一下。我刚上手 Vue2.x 的时候就经常遇到一个问题,数据更新了啊,为何页面不更新呢?什么时候用$set更新,什么时候用$forceUpdate强制更新,你是否也一度陷入困境。后来的学习过程中开始接触源码,才知道一切的根源都是 Object.defineProperty。对这块想要深入了解的小伙伴可以看这篇文章 为什么 Vue3.0 不再使用 defineProperty 实现数据监听?

要详细解释又是一篇文章,这里就简单对比一下Object.defineProperty 与 Proxy

.Object.defineProperty只能劫持对象的属性, 而 Proxy 是直接代理对象

由于Object.defineProperty只能劫持对象属性,需要遍历对象的每一个属性,如果属性值也是对象,就需要递归进行深度遍历。但是 Proxy 直接代理对象, 不需要遍历操作

2.Object.defineProperty对新增属性需要手动进行Observe

因为Object.defineProperty劫持的是对象的属性,所以新增属性时,需要重新遍历对象, 对其新增属性再次使用Object.defineProperty进行劫持。也就是 Vue2.x 中给数组和对象新增属性时,需要使用$set才能保证新增的属性也是响应式的, $set内部也是通过调用Object.defineProperty去处理的。

想了解更详细的内容戳这里。

三、计算属性与监听

1.computed

与vue2.0的computed功能一致,只是使用方式变为了API引入,有两种形态:

只有getter
有getter和setter

<script setup>
import {
  reactive,
  ref,
  computed,
} from 'vue'
 
  const user = reactive({
      firstName: 'A',
      lastName: 'B'
    })
 
    // 只有getter的计算属性
    const fullName1 = computed(() => {
      console.log('fullName1')
      return user.firstName + '-' + user.lastName
    })
 
    // 有getter与setter的计算属性
    const fullName2 = computed({
      get () {
        console.log('fullName2 get')
        return user.firstName + '-' + user.lastName
      },
 
      set (value: string) {
        console.log('fullName2 set')
        const names = value.split('-')
        user.firstName = names[0]
        user.lastName = names[1]
      }
    })
 
</script>

2.watch 与 watchEffect 的用法

watch 函数用来侦听特定的数据源,并在回调函数中执行副作用。默认情况是惰性的,也就是说仅在侦听的源数据变更时才执行回调。

watch(source, callback, [options])
参数说明:

source: 可以支持 string,Object,Function,Array; 用于指定要侦听的响应式变量
callback: 执行的回调函数
options:支持 deep、immediate 和 flush 选项。
接下来我会分别介绍这个三个参数都是如何使用的, 如果你对 watch 的使用不明白的请往下看:

1)侦听 reactive 定义的数据

import { defineComponent, ref, reactive, toRefs, watch } from "vue";
export default defineComponent({
  setup() {
    const state = reactive({ nickname: "xiaofan", age: 20 });
 
    setTimeout(() => {
      state.age++;
    }, 1000);
 
    // 修改age值时会触发 watch的回调
    watch(
      () => state.age,
      (curAge, preAge) => {
        console.log("新值:", curAge, "老值:", preAge);
      }
    );
 
    return {
      ...toRefs(state),
    };
  },
});

2)侦听 ref 定义的数据

const year = ref(0);
 
setTimeout(() => {
  year.value++;
}, 1000);
 
watch(year, (newVal, oldVal) => {
  console.log("新值:", newVal, "老值:", oldVal);
});

3)侦听多个数据

上面两个例子中,我们分别使用了两个 watch, 当我们需要侦听多个数据源时, 可以进行合并, 同时侦听多个数据:

watch([() => state.age, year], ([curAge, newVal], [preAge, oldVal]) => {
console.log("新值:", curAge, "老值:", preAge); console.log("新值:", newVal,
"老值:", oldVal); });

4)侦听复杂的嵌套对象

我们实际开发中,复杂数据随处可见, 比如:

const state = reactive({
  room: {
    id: 100,
    attrs: {
      size: "140平方米",
      type: "三室两厅",
    },
  },
});
watch(
  () => state.room,
  (newType, oldType) => {
    console.log("新值:", newType, "老值:", oldType);
  },
  { deep: true }
);

如果不使用第三个参数deep:true, 是无法监听到数据变化的。前面我们提到,默认情况下,watch 是惰性的, 那什么情况下不是惰性的, 可以立即执行回调函数呢?其实使用也很简单, 给第三个参数中设置immediate: true即可。关于flush配置,还在学习,后期会补充

5)stop 停止监听

我们在组件中创建的watch监听,会在组件被销毁时自动停止。如果在组件销毁之前我们想要停止掉某个监听, 可以调用watch()函数的返回值,操作如下:

const stopWatchRoom = watch(() => state.room, (newType, oldType) => {
    console.log("新值:", newType, "老值:", oldType);
}, {deep:true});
 
setTimeout(()=>{
    // 停止监听
    stopWatchRoom()
}, 3000)

还有一个监听函数watchEffect, 在我看来watch已经能满足监听的需求,为什么还要有watchEffect呢?虽然我没有 get 到它的必要性,但是还是要介绍一下watchEffect,首先看看它的使用和watch究竟有何不同。

import { defineComponent, ref, reactive, toRefs, watchEffect } from "vue";
export default defineComponent({
  setup() {
    const state = reactive({ nickname: "xiaofan", age: 20 });
    let year = ref(0)
 
    setInterval(() =>{
        state.age++
        year.value++
    },1000)
 
    watchEffect(() => {
        console.log(state);
        console.log(year);
      }
    );
 
    return {
        ...toRefs(state)
    }
  },
});

执行结果首先打印一次state和year值;然后每隔一秒,打印state和year值。从上面的代码可以看出, 并没有像watch一样需要先传入依赖,watchEffect会自动收集依赖, 只要指定一个回调函数。在组件初始化时, 会先执行一次来收集依赖, 然后当收集到的依赖中数据发生变化时, 就会再次执行回调函数。所以总结对比如下:

watchEffect 不需要手动传入依赖
watchEffect 会先执行一次用来自动收集依赖
watchEffect 无法获取到变化前的值, 只能获取变化后的值
上面介绍了 Vue3 Composition API的部分内容, 还有很多非常好用的 API, 建议直接查看官网 composition-api。其实我们也能进行自定义封装。

四、v-model

1.v-model 变更

在使用 Vue 3 之前就了解到 v-model 发生了很大的变化, 使用过了之后才真正的 get 到这些变化, 我们先纵观一下发生了哪些变化, 然后再针对的说一下如何使用:

变更:在自定义组件上使用v-model时, 属性以及事件的默认名称变了
变更:v-bind的.sync修饰符在 Vue 3 中又被去掉了, 合并到了v-model里
新增:同一组件可以同时设置多个 v-model
新增:开发者可以自定义 v-model修饰符

2.v-model 的update事件

在 Vue2 中, 在组件上使用 v-model其实就相当于传递了value属性, 并触发了input事件:

<!-- Vue 2 -->
<search-input v-model="searchValue"><search-input>
 
<!-- 相当于 -->
<search-input :value="searchValue" @input="searchValue=$event"><search-input>

这时v-model只能绑定在组件的value属性上,那我们就不开心了, 我们就像给自己的组件用一个别的属性,并且我们不想通过触发input来更新值,在.sync出来之前,Vue 2 中这样实现:

// 子组件:searchInput.vue
export default {
    model:{
        prop: 'search',
        event:'change'
    }
}

修改后, searchInput 组件使用v-model就相当于这样:

<search-input v-model="searchValue"><search-input>
<!-- 相当于 -->
<search-input :search="searchValue" @change="searchValue=$event"><search-input>

但是在实际开发中,有些场景我们可能需要对一个 prop 进行 “双向绑定”, 这里以最常见的 modal 为例子:modal 挺合适属性双向绑定的,外部可以控制组件的visible显示或者隐藏,组件内部关闭可以控制 visible属性隐藏,同时 visible 属性同步传输到外部。组件内部, 当我们关闭modal时, 在子组件中以 update:PropName 模式触发事件:

this.$emit('update:visible', false)

然后在父组件中可以监听这个事件进行数据更新:

<modal :visible="isVisible" @update:visible="isVisible = $event"></modal>

此时我们也可以使用v-bind.sync来简化实现:

<modal :visible.sync="isVisible"></modal>

上面回顾了 Vue2 中v-model实现以及组件属性的双向绑定,那么在 Vue 3 中应该怎样实现的呢?在 Vue3 中, 在自定义组件上使用v-model, 相当于传递一个modelValue 属性, 同时触发一个update:modelValue事件:

<modal v-model="isVisible"></modal>
<!-- 相当于 -->
<modal :modelValue="isVisible" @update:modelValue="isVisible = $event"></modal>
如果要绑定属性名, 只需要给v-model传递一个参数就行, 同时可以绑定多个v-model:

<modal v-model:visible="isVisible" v-model:content="content"></modal>
 
<!-- 相当于 -->
<modal
    :visible="isVisible"
    :content="content"
    @update:visible="isVisible"
    @update:content="content"
/>

不知道你有没有发现,这个写法完全没有.sync什么事儿了, 所以啊,Vue 3 中又抛弃了.sync写法, 统一使用v-model。

3.v-model 参数

默认情况下,组件上的 v-model 使用 modelValue 作为 prop 和 update:modelValue 作为事件。我们可以通过向 v-model 传递参数来修改这些名称:

<my-component v-model:title="bookTitle"></my-component>

在本例中,子组件将需要一个 title prop 并发出 update:title 事件来进行同步:

app.component('my-component', {
  props: {
    title: String
  },
  emits: ['update:title'],
  template: `
    <input
      type="text"
      :value="title"
      @input="$emit('update:title', $event.target.value)">
  `
})

五、组件通信

1.父传子:props

如果你用的是vue3.0,那么直接翻回看上面的setup函数获取props的方法。

如果用的是vue3.2以上,使用的是setup语法糖,那么获取props的方式是defineProps()方法,无需引入,直接看代码吧:

子组件

<template>
  <span>{{props.name}}</span>
  // 可省略【props.】
  <span>{{name}}</span>
</template>
 
<script setup>
  // import { defineProps } from 'vue'
  // defineProps在<script setup>中自动可用,无需导入
  // 需在.eslintrc.js文件中【globals】下配置【defineProps: true】
 
  // 声明props
  const props = defineProps({
    name: {
      type: String,
      default: ''
    }
  })  
</script>

父组件

<template>
  <child name='Jerry'/>  
</template>
 
<script setup>
  // 引入子组件(组件自动注册)
  import child from './child.vue'
</script>

2.子传父:emit

emit的也是一样,vue3.0和vue3.2获取方法不一样,vue3.0直接看上文setup函数参数部分。

在vue3.2以上版本的setup语法糖下,获取emit使用的是defineEmits方法,使用方法如下:

子组件

<template>
  <span>{{props.name}}</span>
  // 可省略【props.】
  <span>{{name}}</span>
  <button @click='changeName'>更名</button>
</template>
 
<script setup>

  // import { defineEmits, defineProps } from 'vue'
  // defineEmits和defineProps在<script setup>中自动可用,无需导入
  // 需在.eslintrc.js文件中【globals】下配置【defineEmits: true】、【defineProps: true】
    
  // 声明props
  const props = defineProps({
    name: {
      type: String,
      default: ''
    }
  }) 
  // 声明事件
  const emit = defineEmits(['updateName'])
  
  const changeName = () => {
    // 执行
    emit('updateName', 'Tom')
  }
</script>

父组件

<template>
  <child :name='state.name' @updateName='updateName'/>  
</template>
 
<script setup>
  import { reactive } from 'vue'
  // 引入子组件
  import child from './child.vue'
 
  const state = reactive({
    name: 'Jerry'
  })
  
  // 接收子组件触发的方法
  const updateName = (name) => {
    state.name = name
  }
</script>

3.祖孙通信:provide 与 inject

provide和inject提供依赖注入,功能类似 2.x 的provide/inject,是为了弥补props和emit只能子父通信的缺陷,实现跨层级组件(祖孙)间通信。

父组件:

<template>
  <h1>父组件</h1>
  <p>当前颜色: {{color}}</p>
  <button @click="color='red'">红</button>
  <button @click="color='yellow'">黄</button>
  <button @click="color='blue'">蓝</button>
  
  <hr>
  <Son />
</template>
 
<script lang="ts">
import { provide, ref } from 'vue'
/* 
- provide` 和 `inject` 提供依赖注入,功能类似 2.x 的 `provide/inject
- 实现跨层级组件(祖孙)间通信
*/
 
import Son from './Son.vue'
export default {
  name: 'ProvideInject',
  components: {
    Son
  },
  setup() {
    
    const color = ref('red')
 
    provide('color', color)
 
    return {
      color
    }
  }
}
</script>

子组件:

<template>
  <div>
    <h2>子组件</h2>
    <hr>
    <GrandSon />
  </div>
</template>
 
<script lang="ts">
import GrandSon from './GrandSon.vue'
export default {
  components: {
    GrandSon
  },
}
</script>

孙子组件:

<template>
  <h3 :style="{color}">孙子组件: {{color}}</h3>
  
</template>
 
<script lang="ts">
import { inject } from 'vue'
export default {
  setup() {
    const color = inject('color')
 
    return {
      color
    }
  }
}
</script>

4.子组件ref变量和defineExpose

在标准组件写法里,子组件的数据都是默认隐式暴露给父组件的,但在 script-setup 模式下,所有数据只是默认 return 给 template 使用,不会暴露到组件外,所以父组件是无法直接通过挂载 ref 变量获取子组件的数据。
如果要调用子组件的数据,需要先在子组件显示的暴露出来,才能够正确的拿到,这个操作,就是由 defineExpose 来完成。
子组件

<template>
  <span>{{state.name}}</span>
</template>
 
<script setup>
  import { reactive, toRefs } from 'vue'
  // defineExpose无需引入
  // import { defineExpose, reactive, toRefs } from 'vue'
 
  // 声明state
  const state = reactive({
    name: 'Jerry'
  }) 
    
  // 将方法、变量暴露给父组件使用,父组件才可通过ref API拿到子组件暴露的数据
  defineExpose({
    // 解构state
    ...toRefs(state),
    // 声明方法
    changeName () {
      state.name = 'Tom'
    }
  })
</script>

父组件

<template>
  <child ref='childRef'/>  
</template>
 
<script setup>
  import { ref, nextTick } from 'vue'
  // 引入子组件
  import child from './child.vue'
 
  // 子组件ref
  const childRef = ref('childRef')
  
  // nextTick
  nextTick(() => {
    // 获取子组件name
    console.log(childRef.value.name)
    // 执行子组件方法
    childRef.value.changeName()
  })
</script>

五、自定义可复用的功能函数:Hooks

hook不是api,表示的是可复用的业务代码,是一种代码思想。当我们维护大型项目时,针对组件中大量重复的业务逻辑,就需要把这些代码重构为hook,以方便复用。

hook的使用思路如下:

Composition API 指抽离逻辑代码到一个函数;
函数的命名约定为 useXxxx 格式(React hooks也是);
在 setup 中引用 useXxx 函数。
为了方便大家理解,我们写了一个实现加减的例子, 这里可以将其封装成一个 hook, 我们约定这些「自定义 Hook」以 use 作为前缀,和普通的函数加以区分。useCount.ts 实现:

import { ref, Ref, computed } from "vue";
 
type CountResultProps = {
  count: Ref<number>;
  multiple: Ref<number>;
  increase: (delta?: number) => void;
  decrease: (delta?: number) => void;
};
 
export default function useCount(initValue = 1): CountResultProps {
  const count = ref(initValue);
 
  const increase = (delta?: number): void => {
    if (typeof delta !== "undefined") {
      count.value += delta;
    } else {
      count.value += 1;
    }
  };
  const multiple = computed(() => count.value * 2);
 
  const decrease = (delta?: number): void => {
    if (typeof delta !== "undefined") {
      count.value -= delta;
    } else {
      count.value -= 1;
    }
  };
 
  return {
    count,
    multiple,
    increase,
    decrease,
  };
}

接下来看一下在组件中使用useCount这个 hook:

<template>
  <p>count: {{ count }}</p>
  <p>倍数: {{ multiple }}</p>
  <div>
    <button @click="increase()">加1</button>
    <button @click="decrease()">减一</button>
  </div>
</template>
 
<script lang="ts">
import useCount from "../hooks/useCount";
 setup() {
    const { count, multiple, increase, decrease } = useCount(10);
        return {
            count,
            multiple,
            increase,
            decrease,
        };
    },
</script>

如果是用Vue2.x 实现,业务代码就会分散在data,method,computed等, 如果刚接手项目,实在无法快速将data字段和method关联起来,而 Vue3 的方式可以很明确的看出,将 count 相关的逻辑聚合在一起, 看起来舒服多了, 而且useCount还可以扩展更多的功能。

六、生命周期

我们可以直接看生命周期图来认识都有哪些生命周期钩子 (图片是根据官网翻译后绘制的):

从图中我们可以看到 Vue3.0 新增了setup,这个在前面我们也详细说了, 然后是将 Vue2.x 中的beforeDestroy名称变更成beforeUnmount; destroyed 表更为 unmounted,作者说这么变更纯粹是为了更加语义化,因为一个组件是一个mount和unmount的过程。其他 Vue2 中的生命周期仍然保留。

生命周期图中并没包含全部的生命周期钩子, 还有其他的几个, 全部生命周期钩子如图所示:

我们可以看到有三点变化:

beforeCreate和created被setup替换了(但是 Vue3 中你仍然可以使用, 因为 Vue3 是向下兼容的, 也就是你实际使用的是 vue2 的)。
钩子命名都增加了on; 通过在生命周期钩子前面加上 “on” 来访问组件的生命周期钩子。
Vue3.x 还新增用于调试的钩子函数onRenderTriggered和onRenderTricked

第三部分、其他旧语法变更

一、插槽slot

1.具名插槽

在 Vue2.x 中, 具名插槽的写法:

<!--  子组件中:-->
<slot name="title"></slot>

在父组件中使用:

<template slot="title">
    <h1>歌曲:成都</h1>
<template>

对于具名插槽,vue3的使用只是语法更改为了: v-slot指令,而且只能用于template 元素 (只有一种例外情况)。

举个例子,有时我们需要多个插槽。例如对于一个带有如下模板的 base-layout 组件:

<div class="container">
  <header>
    <!-- 我们希望把页头放这里 -->
  </header>
  <main>
    <!-- 我们希望把主要内容放这里 -->
  </main>
  <footer>
    <!-- 我们希望把页脚放这里 -->
  </footer>
</div>

对于这样的情况,slot 元素有一个特殊的 attribute:name。通过它可以为不同的插槽分配独立的 ID,也就能够以此来决定内容应该渲染到什么地方:

<div class="container">
  <header>
    <slot name="header"></slot>
  </header>
  <main>
    <slot></slot>
  </main>
  <footer>
    <slot name="footer"></slot>
  </footer>
</div>

一个不带 name 的 slot 出口会带有隐含的名字“default”。

在向具名插槽提供内容的时候,我们可以在一个 template 元素上使用 v-slot 指令,并以 v-slot 的参数的形式提供其名称:

<base-layout>
  <template v-slot:header>
    <h1>Here might be a page title</h1>
  </template>
 
  <template v-slot:default>
    <p>A paragraph for the main content.</p>
    <p>And another one.</p>
  </template>
 
  <template v-slot:footer>
    <p>Here's some contact info</p>
  </template>
</base-layout>

现在 template 元素中的所有内容都将会被传入相应的插槽。

渲染的 HTML 将会是:

<div class="container">
  <header>
    <h1>Here might be a page title</h1>
  </header>
  <main>
    <p>A paragraph for the main content.</p>
    <p>And another one.</p>
  </main>
  <footer>
    <p>Here's some contact info</p>
  </footer>
</div>

再次强调,v-slot 只能添加在 template 上 (只有一种例外情况)。

2.作用域插槽

在vue2中,如果我们要在 slot 上面绑定数据,可以使用作用域插槽,实现如下:

// 子组件
<slot name="content" :data="data"></slot>
<script>
export default {
    data(){
        return{
            data:["走过来人来人往","不喜欢也得欣赏","陪伴是最长情的告白"]
        }
    }
}
</script>
<!-- 父组件中使用 -->
<template slot="content" slot-scope="scoped">
    <div v-for="item in scoped.data">{{item}}</div>
<template>

在 Vue2.x 中具名插槽和作用域插槽分别使用slot和slot-scope来实现, 在 Vue3.0 中将slot和slot-scope进行了合并同意使用。Vue3.0 中v-slot:

<!-- 父组件中使用 -->
 <template v-slot:content="scoped">
   <div v-for="item in scoped.data">{{item}}</div>
</template>
 
<!-- 也可以简写成: -->
<template #content="{data}">
    <div v-for="item in data">{{item}}</div>
</template>

二、nextTick

setup内无法使用this,vue2的这种写法已经不能使用:

this.nextTick(()=>{
    ...
})

所以在vue3中,nextTickAPI 需要通过 ES Module的引用方式进行具名引用:

<script setup>
  import { nextTick } from 'vue'
    
  nextTick(() => {
    // ...
  })
</script>

其他受影响的 API

和nextTick一样,需要this对象的API都要用具名导入了,这是一个比较大的变化,以下 API 均有影响:

Vue.nextTick
Vue.observable(用 Vue.reactive 替换)
Vue.version
Vue.compile(仅限完整版本时可用)
Vue.set(仅在 2.x 兼容版本中可用)
Vue.delete(与上同)

三、路由

1.路由useRoute和useRouter

在setup中,调用路由的方法为:useRoute, useRouter,代码如下:

<script setup>
  import { useRoute, useRouter } from 'vue-router'
    
  // 必须先声明调用
  const route = useRoute()
  const router = useRouter()
    
  // 路由信息
  console.log(route.query)
 
  // 路由跳转
  router.push('/newPage')
</script>

2.路由导航守卫

调用路由守卫是这两个API:onBeforeRouteLeave, onBeforeRouteUpdate

<script setup>
  import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
    
  // 添加一个导航守卫,在当前组件将要离开时触发。
  onBeforeRouteLeave((to, from, next) => {
    next()
  })
 
  // 添加一个导航守卫,在当前组件更新时触发。
  // 在当前路由改变,但是该组件被复用时调用。
  onBeforeRouteUpdate((to, from, next) => {
    next()
  })
</script>

四、Vuex

store

Vue3 中的Vuex不再提供辅助函数写法,而是使用useStore方法:

<script setup>
  import { useStore } from 'vuex'
  import { key } from '../store/index'
 
  // 必须先声明调用
  const store = useStore(key)
    
  // 获取Vuex的state
  store.state.xxx
 
  // 触发mutations的方法
  store.commit('fnName')
 
  // 触发actions的方法
  store.dispatch('fnName')
 
  // 获取Getters
  store.getters.xxx
</script>

第四部分、vue3新组件

一、Fragment(片断)

在Vue2中: 组件必须有一个根标签
在Vue3中: 组件可以没有根标签, 内部会将多个标签包含在一个Fragment虚拟元素中
好处: 减少标签层级, 减小内存占用

<template>
    <h2>aaaa</h2>
    <h2>aaaa</h2>
</template>

二、Suspense(异步组件)

Suspense是 Vue3.x 中新增的特性, 那它有什么用呢?别急,我们通过 Vue2.x 中的一些场景来认识它的作用。Vue2.x 中应该经常遇到这样的场景:

<template>
<div>
    <div v-if="!loading">
        ...
    </div>
    <div v-if="loading">
        加载中...
    </div>
</div>
</template>

在前后端交互获取数据时, 是一个异步过程,一般我们都会提供一个加载中的动画,当数据返回时配合v-if来控制数据显示。如果你使用过vue-async-manager这个插件来完成上面的需求, 你对Suspense可能不会陌生,Vue3.x 感觉就是参考了vue-async-manager.Vue3.x 新出的内置组件Suspense, 它提供两个template slot, 刚开始会渲染一个 fallback 状态下的内容, 直到到达某个条件后才会渲染 default 状态的正式内容, 通过使用Suspense组件进行展示异步渲染就更加的简单。:::warning 如果使用 Suspense, 要返回一个 promise :::Suspense 组件的使用:

  <Suspense>
        <template #default>
            <async-component></async-component>
        </template>
        <template #fallback>
            <div>
                Loading...
            </div>
        </template>
  </Suspense>

asyncComponent.vue:

<template>
<div>
    <h4>这个是一个异步加载数据</h4>
    <p>用户名:{{user.nickname}}</p>
    <p>年龄:{{user.age}}</p>
</div>
</template>
 
<script>
import { defineComponent } from "vue"
import axios from "axios"
export default defineComponent({
    setup(){
        const rawData = await axios.get("http://xxx.xinp.cn/user")
        return {
            user: rawData.data
        }
    }
})
</script>

从上面代码来看,Suspense 只是一个带插槽的组件,只是它的插槽指定了default 和 fallback 两种状态。

PS:Suspense 是一个试验性的新特性,其 API 可能随时会发生变动。生产环境请勿使用。

三、Teleport(传送门)

Teleport 是 Vue3.x 新推出的功能, 没听过这个词的小伙伴可能会感到陌生;翻译过来是传送的意思,可能还是觉得不知所以,没事下边我就给大家形象的描述一下。

1.Teleport 是什么呢?
Teleport 就像是哆啦 A 梦中的「任意门」,任意门的作用就是可以将人瞬间传送到另一个地方。有了这个认识,我们再来看一下为什么需要用到 Teleport 的特性呢,看一个小例子:在子组件Header中使用到Dialog组件,我们实际开发中经常会在类似的情形下使用到 Dialog ,此时Dialog就被渲染到一层层子组件内部,处理嵌套组件的定位、z-index和样式都变得困难。Dialog从用户感知的层面,应该是一个独立的组件,从 dom 结构应该完全剥离 Vue 顶层组件挂载的 DOM;同时还可以使用到 Vue 组件内的状态(data或者props)的值。简单来说就是,即希望继续在组件内部使用Dialog, 又希望渲染的 DOM 结构不嵌套在组件的 DOM 中。此时就需要 Teleport 上场,我们可以用包裹Dialog, 此时就建立了一个传送门,可以将Dialog渲染的内容传送到任何指定的地方。接下来就举个小例子,看看 Teleport 的使用方式

2.Teleport 的使用
我们希望 Dialog 渲染的 dom 和顶层组件是兄弟节点关系, 在index.html文件中定义一个供挂载的元素:

<body>
  <div id="app"></div>
  <div id="dialog"></div>
</body>
定义一个Dialog组件Dialog.vue, 留意 to 属性, 与上面的id选择器一致:

<template>
  <teleport to="#dialog">
    <div class="dialog">
      <div class="dialog_wrapper">
        <div class="dialog_header" v-if="title">
          <slot name="header">
            <span>{{ title }}</span>
          </slot>
        </div>
      </div>
      <div class="dialog_content">
        <slot></slot>
      </div>
      <div class="dialog_footer">
        <slot name="footer"></slot>
      </div>
    </div>
  </teleport>
</template>

最后在一个子组件Header.vue中使用Dialog组件, 这里主要演示 Teleport 的使用,不相关的代码就省略了。header组件

<div class="header">
    ...
    <navbar />
    <Dialog v-if="dialogVisible"></Dialog>
</div>

Dom 渲染效果如下:图片. png可以看到,我们使用 teleport 组件,通过 to 属性,指定该组件渲染的位置与

同级,也就是在 body 下,但是 Dialog 的状态 dialogVisible 又是完全由内部 Vue 组件控制.

想了解更多?戳此看官方文档。

第五部分、其他新增特性

一、css变量注入

vue3更新了一个新特性, 新增了CSS变量注入,简单点说:就是允许你在CSS中引入JS变量。

那么怎样才能在 vue3 的style中使用script里声明的变量呢?

首先创建一个组件,组件型式长这样:

<template>
  <h1>{{ color }}</h1>
</template>
 
<script>
export default {
  data () {
    return {
      color: 'red'
    }
  }
}
</script>
 
<style vars="{ color }">
h1 {
  color: var(--color);
}
</style>

复制代码
首先要在style标签中写个vars=”{}”,再在大括号里写上你在data中声明过的值。

如果有多个变量的话,变量之间需要使用逗号进行分隔:

<template>
  <h1>Vue</h1>
</template>
 
<script>
export default {
  data () {
    return {
      border: '1px solid black',
      color: 'red'
    }
  }
}
</script>
 
<style vars="{ border, color }" scoped>
h1 {
  color: var(--color);
  border: var(--border);
}
</style>

复制代码
再来试一下这个变量是不是响应式的,动态改变script标签中的this.opacity值会不会引起视图的变化呢?来试一下:

<template>
  <h1>Vue</h1>
</template>
 
<script>
export default {
  data () {
    return {
      opacity: 0
    }
  },
  mounted () {
    setInterval(_ => {
      this.opacity >= 1 && (this.opacity = 0)
      this.opacity += 0.2
    }, 300)
  }
}
</script>
 
<style vars="{ opacity }">
h1 {
  color: rgb(65, 184, 131);
  opacity: var(--opacity);
}
</style>

运行结果:

可以看到每300毫秒我们就改变一下 this.opacity 的值,它会映射到 CSS 变量上去,this.opacity 变了,–opacity 的值就会随之变化,视图也会随着数据的更新而相应的更新,这个特性简直太棒了!