当前位置: 首页 > article >正文

单组件的编写

项目搭好了,第一个需要了解的是 Vue 组件的变化,由于这部分篇幅会非常大,所以会分成很多个小节,一部分一部分按照开发顺序来逐步了解。

因为 Vue 3 对 TypeScript 的支持真的是太完善了,并且 TypeScript 的发展趋势和市场需求度越来越高,所以接下来都将直接使用 TypeScript 进行编程。

TIP

对 TypeScript 不太熟悉的开发者,建议先阅读 快速上手 TypeScript 一章,有了一定的语言基础之后,再一边写代码一边加深印象。

温馨提示​

这是笔者写于 2024-02-20 的补充提示,为读者解答为何前面的章节都未提及 script-setup 组件语法的信息。

笔者在执笔之初,考虑到降低初学过程中的各种心智负担,以及当时处于 script-setup 组件语法尚未完全稳定的背景下,所以在最后一章 高效开发 之前的章节,都只提供了组件的标准写法的讲解。

除非遇到框架进行了 Breaking Change (例如从 Vue 2 升级到了 Vue 3 这种大版本更新),否则标准写法是始终有效的,在笔者多年的学习经历和实际收获里,也是比较相信一点:不论学习什么语言或者框架,只要基础打的好,未来总是可以轻松地举一反三去解决遇到的各类问题。

因此如果读者通过各类脚手架创建的 Vue 3 项目,在组件看到 <script setup> 这种风格的组件,以及诸如 defineXxx 风格的 API ,这是 .vue 组件所支持的一种语法糖,可以随时在 高效开发 一章了解相关的 API 用法。

另外从 Vue 3.4 版本之后所新增的 API 未在本书的介绍范围内,请读者在阅读的过程中也可以查阅官方的 API 一起学习,不论是本书还是其他计算机书籍,官方文档永远是最好的查询手册。

TIP

语法糖(英语:Syntactic sugar)是由英国计算机科学家彼得·兰丁发明的一个术语,指计算机语言中添加的某种语法,这种语法对语言的功能没有影响,但是更方便程序员使用。语法糖让程序更加简洁,有更高的可读性。

全新的 setup 函数​

在开始编写 Vue 组件之前,需要了解两个全新的前置知识点:

  • 全新的 setup 函数,关系到组件的生命周期和渲染等问题
  • 写 TypeScript 组件离不开的 defineComponent API

setup 的含义​

Vue 3 的 Composition API 系列里,推出了一个全新的 setup 函数,它是一个组件选项,在创建组件之前执行,一旦 props 被解析,便作为组合式 API 的入口点。

TIP

说的通俗一点,就是在使用 Vue 3 生命周期的情况下,整个组件相关的业务代码,都可以放在 setup 里执行。

因为在 setup 之后,其他的生命周期才会被启用(点击了解:组件的生命周期)。

基本语法:

ts

// 这是一个基于 TypeScript 的 Vue 组件
import { defineComponent } from 'vue'

export default defineComponent({
  setup(props, context) {
    // 在这里声明数据,或者编写函数并在这里执行它

    return {
      // 需要给 `<template />` 用的数据或函数,在这里 `return` 出去
    }
  },
})

可以发现在这段代码里还导入了一个 defineComponent API ,也是 Vue 3 带来的新功能,下文的 defineComponent 的作用 将介绍其用法。

在使用 setup 的情况下,请牢记一点:不能再用 this 来获取 Vue 实例,也就是无法和 Vue 2 一样,通过 this.foo 、 this.bar() 这样来获取实例上的数据,或者执行实例上的方法。

关于全新的 Vue 3 组件编写,笔者将在下文一步步说明。

setup 的参数使用​

setup 函数包含了两个入参:

参数类型含义是否必传
propsobject由父组件传递下来的数据
contextobject组件的执行上下文

第一个参数 props :

它是响应式的,当父组件传入新的数据时,它将被更新。

TIP

请不要解构它,这样会让数据失去响应性,一旦父组件发生数据变化,解构后的变量将无法同步更新为最新的值。

可以使用 Vue 3 全新的响应式 API toRef / toRefs 进行响应式数据转换,下文将会介绍全新的响应式 API 的用法。

第二个参数 context :

context 只是一个普通的对象,它暴露三个组件的 Property :

属性类型作用
attrs非响应式对象未在 Props 里定义的属性都将变成 Attrs
slots非响应式对象组件插槽,用于接收父组件传递进来的模板内容
emit方法触发父组件绑定下来的事件

因为 context 只是一个普通对象,所以可以直接使用 ES6 解构。

平时使用可以通过直接传入 { emit } ,即可用 emit('xxx') 来代替使用 context.emit('xxx'),另外两个功能也是如此。

但是 attrs 和 slots 请保持 attrs.xxxslots.xxx 的方式来使用其数据,不要进行解构,虽然这两个属性不是响应式对象,但对应的数据会随组件本身的更新而更新。

两个参数的具体使用,可查阅 组件之间的通信 一章详细了解。

defineComponent 的作用​

defineComponent 是 Vue 3 推出的一个全新 API ,可用于对 TypeScript 代码的类型推导,帮助开发者简化掉很多编码过程中的类型声明。

比如,原本需要这样才可以使用 setup 函数:

ts

import { Slots } from 'vue'

// 声明 `props` 和 `return` 的数据类型
interface Data {
  [key: string]: unknown
}

// 声明 `context` 的类型
interface SetupContext {
  attrs: Data
  slots: Slots
  emit: (event: string, ...args: unknown[]) => void
}

// 使用的时候入参要加上声明, `return` 也要加上声明
export default {
  setup(props: Data, context: SetupContext): Data {
    // ...

    return {
      // ...
    }
  },
}

每个组件都这样进行类型声明,会非常繁琐,如果使用了 defineComponent ,就可以省略这些类型声明:

ts

import { defineComponent } from 'vue'

// 使用 `defineComponent` 包裹组件的内部逻辑
export default defineComponent({
  setup(props, context) {
    // ...

    return {
      // ...
    }
  },
})

代码量瞬间大幅度减少,只要是 Vue 本身的 API , defineComponent 都可以自动推导其类型,这样开发者在编写组件的过程中,只需要维护自己定义的数据类型就可以了,可专注于业务。

组件的生命周期​

在了解了 Vue 3 组件的两个前置知识点后,不着急写组件,还需要先了解组件的生命周期,这个知识点非常重要,只有理解并记住组件的生命周期,才能够灵活地把控好每一处代码的执行,使程序的运行结果可以达到预期。

升级变化​

从 Vue 2 升级到 Vue 3 ,在保留对 Vue 2 的生命周期支持的同时,Vue 3 也带来了一定的调整。

Vue 2 的生命周期写法名称是 Options API (选项式 API ), Vue 3 新的生命周期写法名称是 Composition API (组合式 API )。

Vue 3 组件默认支持 Options API ,而 Vue 2 可以通过 @vue/composition-api 插件获得 Composition API 的功能支持(其中 Vue 2.7 版本内置了该插件, 2.6 及以下的版本需要单独安装)。

为了减少理解成本,笔者将从读者的使用习惯上,使用 “ Vue 2 的生命周期” 代指 Options API 写法,用 “ Vue 3 的生命周期” 代指 Composition API 写法。

关于 Vue 生命周期的变化,可以从下表直观地了解:

Vue 2 生命周期Vue 3 生命周期执行时间说明
beforeCreatesetup组件创建前执行
createdsetup组件创建后执行
beforeMountonBeforeMount组件挂载到节点上之前执行
mountedonMounted组件挂载完成后执行
beforeUpdateonBeforeUpdate组件更新之前执行
updatedonUpdated组件更新完成之后执行
beforeDestroyonBeforeUnmount组件卸载之前执行
destroyedonUnmounted组件卸载完成后执行
errorCapturedonErrorCaptured当捕获一个来自子孙组件的异常时激活钩子函数

可以看到 Vue 2 生命周期里的 beforeCreate 和 created ,在 Vue 3 里已被 setup 替代。

熟悉 Vue 2 的开发者应该都知道 Vue 有一个全局组件 <KeepAlive /> ,用于在多个组件间动态切换时缓存被移除的组件实例,当组件被包含在 <KeepAlive /> 组件里时,会多出两个生命周期钩子函数:

Vue 2 生命周期Vue 3 生命周期执行时间说明
activatedonActivated被激活时执行
deactivatedonDeactivated切换组件后,原组件消失前执行

TIP

虽然 Vue 3 依然支持 Vue 2 的生命周期,但是不建议混搭使用,前期可以继续使用 Vue 2 的生命周期作为过渡阶段慢慢适应,但还是建议尽快熟悉并完全使用 Vue 3 的生命周期编写组件。

使用 3.x 的生命周期​

在 Vue 3 的 Composition API 写法里,每个生命周期函数都要先导入才可以使用,并且所有生命周期函数统一放在 setup 里运行。

如果需要达到 Vue 2 的 beforeCreate 和 created 生命周期的执行时机,直接在 setup 里执行函数即可。

以下是几个生命周期的执行顺序对比:

ts

import { defineComponent, onBeforeMount, onMounted } from 'vue'

export default defineComponent({
  setup() {
    console.log(1)

    onBeforeMount(() => {
      console.log(2)
    })

    onMounted(() => {
      console.log(3)
    })

    console.log(4)
  },
})

最终将按照生命周期的顺序输出:

js

// 1
// 4
// 2
// 3

组件的基本写法​

如果想在 Vue 2 里使用 TypeScript 编写组件,需要通过 Options API 的 Vue.extend 语法,或者是另外一种风格 Class Component 的语法声明组件,其中为了更好地进行类型推导, Class Component 语法更受开发者欢迎。

但是 Class Component 语法和默认的组件语法相差较大,带来了一定的学习成本,对于平时编写 JavaScript 代码很少使用 Class 的开发者,适应时间应该也会比较长。

因此 Vue 3 在保留对 Class Component 支持的同时,推出了全新的 Function-based Component ,更贴合 JavaScript 的函数式编程风格,这也是接下来要讲解并贯穿全文使用的 Composition API 新写法。

Composition API 虽然也是一个步伐迈得比较大的改动,但其组件结构并没有特别大的变化,区别比较大的地方在于组件生命周期和响应式 API 的使用,只要掌握了这些核心功能,上手 Vue 3 非常容易!

看到这里可能有开发者心里在想:

“这几种组件写法,加上视图部分又有 Template 和 TSX 的写法之分,生命周期方面 Vue 3 对 Vue 2 的写法又保持了兼容,在 Vue 里写 TypeScript 的组合方式一只手数不过来,在入门时选择合适的编程风格就遇到了困难,可怎么办?”

不用担心!笔者将九种常见的组合方式以表格的形式进行对比, Vue 3 组件最好的写法一目了然!

回顾 Vue 2​

在 Vue 2 ,常用以下三种写法声明 TypeScript 组件:

适用版本基本写法视图写法
Vue 2Vue.extendTemplate
Vue 2Class ComponentTemplate
Vue 2Class ComponentTSX

其中最接近 Options API 的写法是使用 Vue.extend API 声明组件:

ts

// 这是一段摘选自 Vue 2 官网的代码演示
import Vue from 'vue'

// 推荐使用 Vue.extend 声明组件
const Component = Vue.extend({
  // 类型推断已启用
})

// 不推荐这种方式声明
const Component = {
  // 这里不会有类型推断,
  // 因为 TypeScript 不能确认这是 Vue 组件的选项
}

而为了更好地获得 TypeScript 类型推导支持,通常使用 Class Component 的写法,这是 Vue 官方推出的一个装饰器插件(需要单独安装):

ts

// 这是一段摘选自 Vue 2 官网的代码演示
import Vue from 'vue'
import Component from 'vue-class-component'

// @Component 修饰符注明了此类为一个 Vue 组件
@Component({
  // 所有的组件选项都可以放在这里
  template: '<button @click="onClick">Click!</button>',
})

// 使用 Class 声明一个组件
export default class MyComponent extends Vue {
  // 初始数据可以直接声明为实例的 property
  message: string = 'Hello!'

  // 组件方法也可以直接声明为实例的方法
  onClick(): void {
    window.alert(this.message)
  }
}

可在 Vue 2 官网的 TypeScript 支持 一章了解更多配置说明。

了解 Vue 3​

Vue 3 从设计初期就考虑了 TypeScript 的支持,其中 defineComponent 这个 API 就是为了解决 Vue 2 对 TypeScript 类型推导不完善等问题而推出的。

在 Vue 3 ,至少有以下六种写法可以声明 TypeScript 组件:

适用版本基本写法视图写法生命周期版本官方是否推荐
Vue 3Class ComponentTemplateVue 2×
Vue 3defineComponentTemplateVue 2×
Vue 3defineComponentTemplateVue 3
Vue 3Class ComponentTSXVue 2×
Vue 3defineComponentTSXVue 2×
Vue 3defineComponentTSXVue 3

其中 defineComponent + Composition API + Template 的组合是 Vue 官方最为推荐的组件声明方式,本书接下来的内容都会以这种写法作为示范案例,也推荐开发者在学习的过程中,使用该组合进行入门。

下面看看如何使用 Composition API 编写一个最简单的 Hello World 组件:

vue

<!-- Template 代码和 Vue 2 一样 -->
<template>
  <p class="msg">{{ msg }}</p>
</template>

<!-- Script 代码需要使用 Vue 3 的新写法-->
<script lang="ts">
// Vue 3 的 API 需要导入才能使用
import { defineComponent } from 'vue'

// 使用 `defineComponent` 包裹组件代码
// 即可获得完善的 TypeScript 类型推导支持
export default defineComponent({
  setup() {
    // 在 `setup` 方法里声明变量
    const msg = 'Hello World!'

    // 将需要在 `<template />` 里使用的变量 `return` 出去
    return {
      msg,
    }
  },
})
</script>

<!-- CSS 代码和 Vue 2 一样 -->
<style scoped>
.msg {
  font-size: 14px;
}
</style>

可以看到 Vue 3 的组件也是 <template /> + <script /> + <style /> 的三段式组合,上手非常简单。

其中 Template 沿用了 Vue 2 时期类似 HTML 风格的模板写法, Style 则是使用原生 CSS 语法或者 Less 等 CSS 预处理器编写。

但需要注意的是,在 Vue 3 的 Composition API 写法里,数据或函数如果需要在 <template /> 中使用,就必须在 setup 里将其 return 出去,而仅在 <script /> 里被调用的函数或变量,不需要渲染到模板则无需 return 。

响应式数据的变化​

响应式数据是 MVVM 数据驱动编程的特色, Vue 的设计也是受 MVVM 模型的启发,相信大部分开发者选择 MVVM 框架都是因为数据驱动编程比传统的事件驱动编程要来得方便,而选择 Vue ,则是方便中的方便。

TIP

Model-View-ViewModel (简称 MVVM ) 是一种软件架构模式,将视图 UI 和业务逻辑分开,通过对逻辑数据的修改即可驱动视图 UI 的更新,因此常将这种编程方式称为 “数据驱动” ,与之对应的需要操作 DOM 才能完成视图更新的编程方式则称为 “事件驱动” 。

设计上的变化​

作为最重要的一个亮点, Vue 3 的响应式数据在设计上和 Vue 2 有着很大的不同。

回顾 Vue 2​

Vue 2 是使用了 Object.defineProperty API 的 getter/setter 来实现数据的响应性,这个方法的具体用法可以参考 MDN 的文档: Object.defineProperty - MDN 。

下面使用 Object.defineProperty 实现一个简单的双向绑定 demo ,亲自敲代码试一下可以有更多的理解:

html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>DefineProperty Demo</title>
  </head>
  <body>
    <!-- 输入框和按钮 -->
    <div>
      <input type="text" id="input" />
      <button onclick="vm.text = 'Hello World'">设置为 Hello World</button>
    </div>
    <!-- 输入框和按钮 -->

    <!-- 文本展示 -->
    <div id="output"></div>
    <!-- 文本展示 -->

    <script>
      // 声明一个响应式数据
      const vm = {}
      Object.defineProperty(vm, 'text', {
        set(value) {
          document.querySelector('#input').value = value
          document.querySelector('#output').innerText = value
        },
      })

      // 处理输入行为
      document.querySelector('#input').oninput = function (e) {
        vm.text = e.target.value
      }
    </script>
  </body>
</html>

这个小 demo 实现了这两个功能:

  1. 输入框的输入行为只修改 vm.text 的数据,但会同时更新 output 标签的文本内容
  2. 点击按钮修改 vm.text 的数据,也会触发输入框和 output 文本的更新

当然 Vue 做了非常多的工作,而非只是简单的调用了 Object.defineProperty ,可以在官网 深入 Vue 2 的响应式原理 一章了解更多 Vue 2 的响应式原理。

了解 Vue 3​

Vue 3 是使用了 Proxy API 的 getter/setter 来实现数据的响应性,这个方法的具体用法可以参考 MDN 的文档: Proxy - MDN 。

同样的,也来实现一个简单的双向绑定 demo ,这次使用 Proxy 来实现:

html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Proxy Demo</title>
  </head>
  <body>
    <!-- 输入框和按钮 -->
    <div>
      <input type="text" id="input" />
      <button onclick="vm.text = 'Hello World'">设置为 Hello World</button>
    </div>
    <!-- 输入框和按钮 -->

    <!-- 文本展示 -->
    <div id="output"></div>
    <!-- 文本展示 -->

    <script>
      // 声明一个响应式数据
      const vm = new Proxy(
        {},
        {
          set(obj, key, value) {
            document.querySelector('#input').value = value
            document.querySelector('#output').innerText = value
          },
        }
      )

      // 处理输入行为
      document.querySelector('#input').oninput = function (e) {
        vm.text = e.target.value
      }
    </script>
  </body>
</html>

这个 demo 实现的功能和使用 Object.defineProperty 的 demo 是完全一样的,也都是基于 setter 的行为完成数据更新的实现,那么为什么 Vue 3 要舍弃 Object.defineProperty ,换成 Proxy 呢?

主要原因在于 Object.defineProperty 有以下的不足:

  1. 无法侦听数组下标的变化,对例如 arr[i] = newValue 这样的操作无法实时响应
  2. 无法侦听数组长度的变化,例如通过 arr.length = 10 去修改数组长度,无法响应
  3. 只能侦听对象的属性,对于整个对象则需要遍历,针对多级对象更是要通过嵌套来深度侦听
  4. 使用 Object.assign() 等方法给对象添加新属性时,也不会触发更新
  5. 更多细节上的问题 …

这也是为什么 Vue 2 要提供一个 Vue.set API 的原因,可以在官网 Vue 2 中检测变化的注意事项 一章了解更多说明。

而这些问题在 Proxy 都可以得到解决,可以在官网 深入 Vue 3 的响应式原理 一章了解更多这部分的内容。

用法上的变化​

本书只使用 Composition API 编写组件,这是使用 Vue 3 的最大优势。

TIP

虽然官方文档在各个 API 的使用上都做了一定的举例,但在实际使用过程中可能会遇到一些问题,常见的情况就是有些数据用着用着就失去了响应,或者是在 TypeScript 里出现类型不匹配的报错等等。

当然,一般遇到这种情况并不一定是框架的 BUG ,而可能是使用方式不对,本章节将结合笔者最初入门 Vue 3 时遇到的问题和解决问题的心得,复盘这些响应式 API 的使用。

相对于 Vue 2 在 data 里声明后即可通过 this.xxx 调用响应式数据,在 Vue 3 的生命周期里没有了 Vue 实例的 this 指向,需要导入 ref 、reactive 等响应式 API 才能声明并使用响应式数据。

ts

// 这里导入的 `ref` 是一个响应式 API
import { defineComponent, ref } from 'vue'

export default defineComponent({
  setup() {
    // 通过响应式 API 创建的变量具备了响应性
    const msg = ref<string>('Hello World!')
  },
})

由于 Vue 3 新的 API 非常多,但有些 API 的使用场景却不多,因此本书当前只对常用的部分 API 的使用和常见问题进行说明,更多的 API 可以在官方文档的 响应性 API 一章查阅。

响应式 API 之 ref​

ref 是最常用的一个响应式 API,它可以用来定义所有类型的数据,包括 Node 节点和组件。

没错,在 Vue 2 常用的以 this.$refs.xxx 取代 document.querySelector('.xxx') 来获取 Node 节点的方式,也是使用了这个 API 。

类型声明​

在开始使用 API 之前,需要先了解在 TypeScript 中如何声明 Ref 变量的类型。

API 本身的类型​

先看 API 本身, ref API 是一个函数,通过接受一个泛型入参,返回一个响应式对象,所有的值都通过 .value 属性获取,这是 API 本身的 TS 类型:

ts

// `ref` API 的 TS 类型
function ref<T>(value: T): Ref<UnwrapRef<T>>

// `ref` API 的返回值的 TS 类型
interface Ref<T> {
  value: T
}

因此在声明变量时,是使用尖括号 <> 包裹其 TS 类型,紧跟在 ref API 之后:

ts

// 显式指定 `msg.value` 是 `string` 类型
const msg = ref<string>('Hello World!')

再回看该 API 本身的类型,其中使用了 T 泛型,这表示在传入函数的入参时,可以不需要手动指定其 TS 类型, TypeScript 会根据这个 API 所返回的响应式对象的 .value 属性的类型,确定当前变量的类型。

因此也可以省略显式的类型指定,像下面这样声明变量,其类型交给 TypeScript 去自动推导:

ts

// TypeScript 会推导 `msg.value` 是 `string` 类型
const msg = ref('Hello World')

对于声明时会赋予初始值,并且在使用过程中不会改变其类型的变量,是可以省略类型的显式指定的。

而如果有显式的指定的类型,那么在一些特殊情况下,初始化时可以不必赋值,这样 TypeScript 会自动添加 undefined 类型:

ts

const msg = ref<string>()
console.log(msg.value) // undefined

msg.value = 'Hello World!'
console.log(msg.value) // Hello World!

因为入参留空时,虽然指定了 string 类型,但实际上此时的值是 undefined ,所以实际上这个时候的 msg.value 是一个 string | undefined 的联合类型。

对于声明时不知道是什么值,在某种条件下才进行初始化的情况,就可以省略其初始值,但是切记在调用该变量的时候对 .value 值进行有效性判断。

而如果既不显式指定类型,也不赋予初始值,那么会被默认为 any 类型,除非真的无法确认类型,否则不建议这么做。

API 返回值的类型​

细心的开发者还会留意到 ref API 类型里面还标注了一个返回值的 TS 类型:

ts

interface Ref<T> {
  value: T
}

它是代表整个 Ref 变量的完整类型:

  • 上文声明 Ref 变量时,提到的 string 类型都是指 msg.value 这个 .value 属性的类型
  • 而 msg 这个响应式变量,其本身是 Ref<string> 类型

如果在开发过程中需要在函数里返回一个 Ref 变量,那么其 TypeScript 类型就可以这样写(请留意 Calculator 里的 num 变量的类型):

ts

// 导入 `ref` API
import { ref } from 'vue'
// 导入 `ref` API 的返回值类型
import type { Ref } from 'vue'

// 声明 `useCalculator` 函数的返回值类型
interface Calculator {
  // 这里包含了一个 Ref 变量
  num: Ref<number>
  add: () => void
}

// 声明一个 “使用计算器” 的函数
function useCalculator(): Calculator {
  const num = ref<number>(0)

  function add() {
    num.value++
  }

  return {
    num,
    add,
  }
}

// 在执行使用计算器函数时,可以获取到一个 Ref 变量和其他方法
const { num, add } = useCalculator()
add()
console.log(num.value) // 1

上面这个简单的例子演示了如何手动指定 Ref 变量的类型,对于逻辑复用时的函数代码抽离、插件开发等场景非常有用!当然大部分情况下可以交给 TypeScript 自动推导,但掌握其用法,在必要的时候就派得上用场了!

变量的定义​

在了解了如何对 Ref 变量进行类型声明之后,面对不同的数据类型,相信都得心应手了!但不同类型的值之间还是有少许差异和注意事项,例如上文提及该 API 可以用来定义所有类型的数据,包括 Node 节点和组件,具体可以参考下文的示例。

基本类型​

对字符串、布尔值等基本类型的定义方式,比较简单:

ts

// 字符串
const msg = ref<string>('Hello World!')

// 数值
const count = ref<number>(1)

// 布尔值
const isVip = ref<boolean>(false)
引用类型​

对于对象、数组等引用类型也适用,比如要定义一个对象:

ts

// 先声明对象的格式
interface Member {
  id: number
  name: string
}

// 在定义对象时指定该类型
const userInfo = ref<Member>({
  id: 1,
  name: 'Tom',
})

定义一个普通数组:

ts

// 数值数组
const uids = ref<number[]>([1, 2, 3])

// 字符串数组
const names = ref<string[]>(['Tom', 'Petter', 'Andy'])

定义一个对象数组:

ts

// 声明对象的格式
interface Member {
  id: number
  name: string
}

// 定义一个对象数组
const memberList = ref<Member[]>([
  {
    id: 1,
    name: 'Tom',
  },
  {
    id: 2,
    name: 'Petter',
  },
])

DOM 元素与子组件​

除了可以定义数据,ref 也有熟悉的用途,就是用来挂载节点,也可以挂在子组件上,对应在 Vue 2 时常用的 this.$refs.xxx ,起到获取 DOM 元素信息的作用。

模板部分依然是熟悉的用法,在要引用的 DOM 上添加一个 ref 属性:

vue

<template>
  <!-- 给 DOM 元素添加 `ref` 属性 -->
  <p ref="msg">请留意该节点,有一个 ref 属性</p>

  <!-- 子组件也用同样的方式添加 -->
  <Child ref="child" />
</template>

在 <script /> 部分有三个最基本的注意事项:

  1. 在 <template /> 代码里添加的 ref 属性的值,对应 <script /> 里使用 ref API 声明的变量的名称;

  2. 请保证视图渲染完毕后再执行 DOM 或组件的相关操作(需要放到生命周期的 onMounted 或者 nextTick 函数里,这一点在 Vue 2 也是一样);

  3. 该 Ref 变量必须 return 出去才可以给到 <template /> 使用,这一点是 Vue 3 生命周期的硬性要求,子组件的数据和方法如果要给父组件操作,也要 return 出来才可以。

配合上面的 <template /> ,来看看 <script /> 部分的具体例子:

ts

import { defineComponent, onMounted, ref } from 'vue'
import Child from '@cp/Child.vue'

export default defineComponent({
  components: {
    Child,
  },
  setup() {
    // 定义挂载节点,声明的类型详见下方附表
    const msg = ref<HTMLElement>()
    const child = ref<InstanceType<typeof Child>>()

    // 请保证视图渲染完毕后再执行节点操作 e.g. `onMounted` / `nextTick`
    onMounted(() => {
      // 比如获取 DOM 的文本
      console.log(msg.value.innerText)

      // 或者操作子组件里的数据
      child.value.isShowDialog = true
    })

    // 必须 `return` 出去才可以给到 `<template />` 使用
    return {
      msg,
      child,
    }
  },
})

关于 DOM 和子组件的 TS 类型声明,可参考以下规则:

节点类型声明类型参考文档
DOM 元素使用 HTML 元素接口HTML 元素接口
子组件使用 InstanceType 配合 typeof 获取子组件的类型typeof 操作符

TIP

单纯使用 typeof Child 虽然可以获得 Child.vue 组件的 Props 和方法等提示,但目前在 VSCode 的类型推导还不够智能,缺乏更有效的代码补全支持。

上文使用的 InstanceType<T> 是 TypeScript 提供的一个工具类型,可以获取构造函数类型的实例类型,因此将组件的类型声明为 InstanceType<typeof Child> ,不仅可以得到更完善的类型提示,在编程过程中还可以让编辑器提供更完善的代码补全功能。

另外,关于这一小节,有一个可能会引起 TS 编译报错的情况是,一些脚手架创建出来的项目会默认启用 --strictNullChecks 选项,导致案例中的代码无法正常编译,出现如下报错:

bash

❯ npm run build

> hello-vue3@0.0.0 build
> vue-tsc --noEmit && vite build

src/views/home.vue:27:7 - error TS2532: Object is possibly 'undefined'.

27       child.value.isShowDialog = true
         ~~~~~~~~~~~


Found 1 error in src/views/home.vue:27

这是因为在默认情况下 null 和 undefined 是所有类型的子类型,但开启了 strictNullChecks 选项之后,会使 null 和 undefined 只能赋值给 void 和它们各自,这是一个更为严谨的选项,可以保障程序代码的健壮性,但对于刚接触 TypeScript 不久的开发者可能不太友好。

有以下几种解决方案可以参考:

  1. 在涉及到相关操作的时候,对节点变量增加一个判断:

ts

// 添加 `if` 分支,判断 `.value` 存在时才执行相关代码
if (child.value) {
  // 读取子组件的数据
  console.log(child.value.num)

  // 执行子组件的方法
  child.value.sayHi('Use `if` in `onMounted` API.')
}
  1. 通过 TS 的可选符 ? 将目标设置为可选,避免出现错误(这个方式不能直接修改子组件数据的值):

ts

// 读取子组件的数据(留意 `.num` 前面有一个 `?` 问号)
console.log(child.value?.num)

// 执行子组件的方法(留意 `.sayHi` 前面有一个 `?` 问号)
child.value?.sayHi('use ? in onMounted')
  1. 在项目根目录下的 tsconfig.json 文件里,显式关闭 strictNullChecks 选项,关闭后,需要开发者在写代码的时候,自行把控好是否需要对 null 和 undefined 进行判断:

json

{
  "compilerOptions": {
    // ...
    "strictNullChecks": false
  }
  // ...
}
  1. 使用 any 类型代替,但是写 TypeScript 还是尽量不要使用 any ,满屏的 AnyScript 不如直接使用 JavaScript

变量的读取与赋值​

前面在介绍 API 类型的时候已经了解,通过 ref 声明的变量会全部变成对象,不管定义的是什么类型的值,都会转化为一个 Ref 对象,其中 Ref 对象具有指向内部值的单个 Property .value

也就是说,任何 Ref 对象的值都必须通过 xxx.value 才可以正确获取。

请牢记上面这句话,初用 Vue 3 的开发者很多 BUG 都是由这个问题引起的(包括笔者刚开始使用 Vue 3 的那段时间,嘿嘿)。

读取变量​

平时对于普通变量的值,读取的时候都是直接调用其变量名即可:

ts

// 读取一个字符串
const msg: string = 'Hello World!'
console.log(msg)

// 读取一个数组
const uids: number[] = [1, 2, 3]
console.log(uids[1])

而 Ref 对象的值的读取,切记!必须通过 .value !

ts

// 读取一个字符串
const msg = ref<string>('Hello World!')
console.log(msg.value)

// 读取一个数组
const uids = ref<number[]>([1, 2, 3])
console.log(uids.value[1])
为变量赋值​

普通变量需要使用 let 声明才可以修改其值,由于 Ref 对象是个引用类型,所以可以使用 const 声明,直接通过 .value 修改。

ts

// 声明一个字符串变量
const msg = ref<string>('Hi!')

// 等待 1s 后修改它的值
setTimeout(() => {
  msg.value = 'Hello!'
}, 1000)

因此日常业务中,像在对接服务端 API 的接口数据时,可以自由地使用 forEachmapfilter 等方法操作 Ref 数组,或者直接重置它,而不必担心数据失去响应性。

ts

const data = ref<string[]>([])

// 提取接口的数据
data.value = api.data.map((item: any) => item.text)

// 重置数组
data.value = []

为什么突然要说这个呢?因为涉及到下一部分的知识,关于 reactive API 在使用上的注意事项。

响应式 API 之 reactive​

reactive 是继 ref 之后最常用的一个响应式 API 了,相对于 ref ,它的局限性在于只适合对象、数组。

TIP

使用 reactive 的好处就是写法跟平时的对象、数组几乎一模一样,但它也带来了一些特殊注意点,请留意赋值部分的特殊说明。

类型声明与定义​

reactive 变量的声明方式没有 ref 的变化那么大,基本上和普通变量一样,它的 TS 类型如下:

ts

function reactive<T extends object>(target: T): UnwrapNestedRefs<T>

可以看到其用法还是比较简单的,下面是一个 Reactive 对象的声明方式:

ts

// 声明对象的类型
interface Member {
  id: number
  name: string
}

// 定义一个对象
const userInfo: Member = reactive({
  id: 1,
  name: 'Tom',
})

下面是 Reactive 数组的声明方式:

ts

const uids: number[] = reactive([1, 2, 3])

还可以声明一个 Reactive 对象数组:

ts

// 对象数组也是先声明其中的对象类型
interface Member {
  id: number
  name: string
}

// 再定义一个为对象数组
const userList: Member[] = reactive([
  {
    id: 1,
    name: 'Tom',
  },
  {
    id: 2,
    name: 'Petter',
  },
  {
    id: 3,
    name: 'Andy',
  },
])

变量的读取与赋值​

虽然 reactive API 在使用上没有像 ref API 一样有 .value 的心智负担,但也有一些注意事项要留意。

处理对象​

Reactive 对象在读取或者修改字段的值时,与普通对象是一样的,这部分没有太多问题。

ts

// 声明对象的类型
interface Member {
  id: number
  name: string
}

// 定义一个对象
const userInfo: Member = reactive({
  id: 1,
  name: 'Tom',
})

// 读取用户名
console.log(userInfo.name)

// 修改用户名
userInfo.name = 'Petter'
处理数组​

但是 Reactive 数组和普通数组会有一些区别。

普通数组在 “重置” 或者 “修改值” 时都可以直接操作:

ts

// 定义一个普通数组
let uids: number[] = [1, 2, 3]

// 从另外一个对象数组里提取数据过来
uids = api.data.map((item: any) => item.id)

// 合并另外一个数组
let newUids: number[] = [4, 5, 6]
uids = [...uids, ...newUids]

// 重置数组
uids = []

Vue 2 在操作数组的时候,也可以和普通数组一样处理数据的变化,依然能够保持响应性,但在 Vue 3 ,如果使用 reactive 定义数组,则不能这么处理,必须只使用那些不会改变引用地址的操作。

笔者刚开始接触时,按照原来的思维去处理 reactive 数组,于是遇到了 “数据变了,但模板不会更新的问题” ,如果开发者在学习的过程中也遇到了类似的情况,可以从这里入手排查问题所在。

举个例子,比如要从服务端 API 接口获取翻页数据时,通常要先重置数组,再异步添加数据,如果使用常规的重置,会导致这个变量失去响应性:

ts

let uids: number[] = reactive([1, 2, 3])

/**
 * 不推荐使用这种方式,会丢失响应性
 * 异步添加数据后,模板不会响应更新
 */
uids = []

// 异步获取数据后,模板依然是空数组
setTimeout(() => {
  uids.push(1)
}, 1000)

要让数据依然保持响应性,则必须在关键操作时,不破坏响应性 API ,以下是推荐的操作方式,通过重置数组的 length 长度来实现数据的重置:

ts

const uids: number[] = reactive([1, 2, 3])

/**
 * 推荐使用这种方式,不会破坏响应性
 */
uids.length = 0

// 异步获取数据后,模板可以正确的展示
setTimeout(() => {
  uids.push(1)
}, 1000)

特别注意​

不要对 Reactive 数据进行 ES6 的解构 操作,因为解构后得到的变量会失去响应性。

比如这些情况,在 2s 后都得不到新的 name 信息:

ts

import { defineComponent, reactive } from 'vue'

interface Member {
  id: number
  name: string
}

export default defineComponent({
  setup() {
    // 定义一个带有响应性的对象
    const userInfo: Member = reactive({
      id: 1,
      name: 'Petter',
    })

    // 在 2s 后更新 `userInfo`
    setTimeout(() => {
      userInfo.name = 'Tom'
    }, 2000)

    // 这个变量在 2s 后不会同步更新
    const newUserInfo: Member = { ...userInfo }

    // 这个变量在 2s 后不会再同步更新
    const { name } = userInfo

    // 这样 `return` 出去给模板用,在 2s 后也不会同步更新
    return {
      ...userInfo,
    }
  },
})

响应式 API 之 toRef 与 toRefs​

相信各位开发者看到这里时,应该已经对 ref 和 reactive API 都有所了解了,为了方便开发者使用, Vue 3 还推出了两个与之相关的 API : toRef 和 toRefs ,都是用于 reactive 向 ref 转换。

各自的作用​

这两个 API 在拼写上非常接近,顾名思义,一个是只转换一个字段,一个是转换所有字段,转换后将得到新的变量,并且新变量和原来的变量可以保持同步更新。

API作用
toRef创建一个新的 Ref 变量,转换 Reactive 对象的某个字段为 Ref 变量
toRefs创建一个新的对象,它的每个字段都是 Reactive 对象各个字段的 Ref 变量

光看概念可能不容易理解,来看下面的例子,先声明一个 reactive 变量:

ts

interface Member {
  id: number
  name: string
}

const userInfo: Member = reactive({
  id: 1,
  name: 'Petter',
})

然后分别看看这两个 API 应该怎么使用。

使用 toRef​

先看这个转换单个字段的 toRef API ,了解了它的用法之后,再去看 toRefs 就很容易理解了。

API 类型和基本用法​

toRef API 的 TS 类型如下:

ts

// `toRef` API 的 TS 类型
function toRef<T extends object, K extends keyof T>(
  object: T,
  key: K,
  defaultValue?: T[K]
): ToRef<T[K]>

// `toRef` API 的返回值的 TS 类型
type ToRef<T> = T extends Ref ? T : Ref<T>

通过接收两个必传的参数(第一个是 reactive 对象, 第二个是要转换的 key ),返回一个 Ref 变量,在适当的时候也可以传递第三个参数,为该变量设置默认值。

以上文声明好的 userInfo 为例,如果想转换 name 这个字段为 Ref 变量,只需要这样操作:

ts

const name = toRef(userInfo, 'name')
console.log(name.value) // Petter

等号左侧的 name 变量此时是一个 Ref 变量,这里因为 TypeScript 可以对其自动推导,因此声明时可以省略 TS 类型的显式指定,实际上该变量的类型是 Ref<string> 。

所以之后在读取和赋值时,就需要使用 name.value 来操作,在重新赋值时会同时更新 name 和 userInfo.name 的值:

ts

// 修改前先查看初始值
const name = toRef(userInfo, 'name')
console.log(name.value) // Petter
console.log(userInfo.name) // Petter

// 修改 Ref 变量的值,两者同步更新
name.value = 'Tom'
console.log(name.value) // Tom
console.log(userInfo.name) // Tom

// 修改 Reactive 对象上该属性的值,两者也是同步更新
userInfo.name = 'Jerry'
console.log(name.value) // Jerry
console.log(userInfo.name) // Jerry

这个 API 也可以接收一个 Reactive 数组,此时第二个参数应该传入数组的下标:

ts

// 这一次声明的是数组
const words = reactive(['a', 'b', 'c'])

// 通过下标 `0` 转换第一个 item
const a = toRef(words, 0)
console.log(a.value) // a
console.log(words[0]) // a

// 通过下标 `2` 转换第三个 item
const c = toRef(words, 2)
console.log(c.value) // c
console.log(words[2]) // c
设置默认值​

如果 Reactive 对象上有一个属性本身没有初始值,也可以传递第三个参数进行设置(默认值仅对 Ref 变量有效):

ts

interface Member {
  id: number
  name: string
  // 类型上新增一个属性,因为是可选的,因此默认值会是 `undefined`
  age?: number
}

// 声明变量时省略 `age` 属性
const userInfo: Member = reactive({
  id: 1,
  name: 'Petter',
})

// 此时为了避免程序运行错误,可以指定一个初始值
// 但初始值仅对 Ref 变量有效,不会影响 Reactive 字段的值
const age = toRef(userInfo, 'age', 18)
console.log(age.value)  // 18
console.log(userInfo.age) // undefined

// 除非重新赋值,才会使两者同时更新
age.value = 25
console.log(age.value)  // 25
console.log(userInfo.age) // 25

数组也是同理,对于可能不存在的下标,可以传入默认值避免项目的逻辑代码出现问题:

ts

const words = reactive(['a', 'b', 'c'])

// 当下标对应的值不存在时,也是返回 `undefined`
const d = toRef(words, 3)
console.log(d.value) // undefined
console.log(words[3]) // undefined

// 设置了默认值之后,就会对 Ref 变量使用默认值, Reactive 数组此时不影响
const e = toRef(words, 4, 'e')
console.log(e.value) // e
console.log(words[4]) // undefined
其他用法​

这个 API 还有一个特殊用法,但不建议在 TypeScript 里使用。

在 toRef 的过程中,如果使用了原对象上面不存在的 key ,那么定义出来的 Ref 变量的 .value 值将会是 undefined 。

ts

// 众所周知, Petter 是没有女朋友的
const girlfriend = toRef(userInfo, 'girlfriend')
console.log(girlfriend.value) // undefined
console.log(userInfo.girlfriend) // undefined

// 此时 Reactive 对象上只有两个 Key
console.log(Object.keys(userInfo)) // ['id', 'name']

如果对这个不存在的 key 的 Ref 变量进行赋值,那么原来的 Reactive 对象也会同步增加这个 key,其值也会同步更新。

ts

// 赋值后,不仅 Ref 变量得到了 `Marry` , Reactive 对象也得到了 `Marry`
girlfriend.value = 'Marry'
console.log(girlfriend.value) // 'Marry'
console.log(userInfo.girlfriend) // 'Marry'

// 此时 Reactive 对象上有了三个 Key
console.log(Object.keys(userInfo)) // ['id', 'name', 'girlfriend']

为什么强调不要在 TypeScript 里使用呢?因为在编译时,无法通过 TypeScript 的类型检查:

bash

❯ npm run build

> hello-vue3@0.0.0 build
> vue-tsc --noEmit && vite build

src/views/home.vue:37:40 - error TS2345: Argument of type '"girlfriend"'
is not assignable to parameter of type 'keyof Member'.

37     const girlfriend = toRef(userInfo, 'girlfriend')
                                          ~~~~~~~~~~~~

src/views/home.vue:39:26 - error TS2339: Property 'girlfriend' does not exist
on type 'Member'.

39     console.log(userInfo.girlfriend) // undefined
                            ~~~~~~~~~~

src/views/home.vue:45:26 - error TS2339: Property 'girlfriend' does not exist
on type 'Member'.

45     console.log(userInfo.girlfriend) // 'Marry'
                            ~~~~~~~~~~


Found 3 errors in the same file, starting at: src/views/home.vue:37

如果不得不使用这种情况,可以考虑使用 any 类型:

ts

// 将该类型直接指定为 `any`
type Member = any
// 当然一般都是 `const userInfo: any`

// 或者保持接口类型的情况下,允许任意键值
interface Member {
  [key: string]: any
}

// 使用 `Record` 也是同理
type Member = Record<string, any>

但笔者还是更推荐保持良好的类型声明习惯,尽量避免这种用法。

使用 toRefs​

在了解了 toRef API 之后,来看看 toRefs 的用法。

API 类型和基本用法​

先看看它的 TS 类型:

ts

function toRefs<T extends object>(
  object: T
): {
  [K in keyof T]: ToRef<T[K]>
}

type ToRef = T extends Ref ? T : Ref<T>

与 toRef 不同, toRefs 只接收了一个参数,是一个 reactive 变量。

ts

interface Member {
  id: number
  name: string
}

// 声明一个 Reactive 变量
const userInfo: Member = reactive({
  id: 1,
  name: 'Petter',
})

// 传给 `toRefs` 作为入参
const userInfoRefs = toRefs(userInfo)

此时这个新的 userInfoRefs 变量,它的 TS 类型就不再是 Member 了,而应该是:

ts

// 导入 `toRefs` API 的类型
import type { ToRefs } from 'vue'

// 上下文代码省略...

// 将原来的类型传给 API 的类型
const userInfoRefs: ToRefs<Member> = toRefs(userInfo)

也可以重新编写一个新的类型来指定它,因为每个字段都是与原来关联的 Ref 变量,所以也可以这样声明:

ts

// 导入 `ref` API 的类型
import type { Ref } from 'vue'

// 上下文代码省略...

// 新声明的类型每个字段都是一个 Ref 变量的类型
interface MemberRefs {
  id: Ref<number>
  name: Ref<string>
}

// 使用新的类型进行声明
const userInfoRefs: MemberRefs = toRefs(userInfo)

当然实际上日常使用时并不需要手动指定其类型, TypeScript 会自动推导,可以节约非常多的开发工作量。

和 toRef API 一样,这个 API 也是可以对数组进行转换:

ts

const words = reactive(['a', 'b', 'c'])
const wordsRefs = toRefs(words)

此时新数组的类型是 Ref<string>[] ,不再是原来的 string[] 类型。

解构与赋值​

转换后的 Reactive 对象或数组支持 ES6 的解构,并且不会失去响应性,因为解构后的每一个变量都具备响应性。

ts

// 为了提高开发效率,可以直接将 Ref 变量直接解构出来使用
const { name } = toRefs(userInfo)
console.log(name.value) // Petter

// 此时对解构出来的变量重新赋值,原来的变量也可以同步更新
name.value = 'Tom'
console.log(name.value) // Tom
console.log(userInfo.name) // Tom

这一点和直接解构 Reactive 变量有非常大的不同,直接解构 Reactive 变量,得到的是一个普通的变量,不再具备响应性。

这个功能在使用 Hooks 函数非常好用(在 Vue 3 里也叫可组合函数, Composable Functions ),还是以一个计算器函数为例,这一次将其修改为内部有一个 Reactive 的数据状态中心,在函数返回时解构为多个 Ref 变量:

ts

import { reactive, toRefs } from 'vue'

// 声明 `useCalculator` 数据状态类型
interface CalculatorState {
  // 这是要用来计算操作的数据
  num: number
  // 这是每次计算时要增加的幅度
  step: number
}

// 声明一个 “使用计算器” 的函数
function useCalculator() {
  // 通过数据状态中心的形式,集中管理内部变量
  const state: CalculatorState = reactive({
    num: 0,
    step: 10,
  })

  // 功能函数也是通过数据中心变量去调用
  function add() {
    state.num += state.step
  }

  return {
    ...toRefs(state),
    add,
  }
}

这样在调用 useCalculator 函数时,可以通过解构直接获取到 Ref 变量,不需要再进行额外的转换工作。

ts

// 解构出来的 `num` 和 `step` 都是 Ref 变量
const { num, step, add } = useCalculator()
console.log(num.value) // 0
console.log(step.value) // 10

// 调用计算器的方法,数据也是会得到响应式更新
add()
console.log(num.value) // 10

为什么要进行转换​

关于为什么要出这么两个 API ,官方文档没有特别说明,不过经过笔者在业务中的一些实际使用感受,以及在写上一节 reactive 的 特别注意,可能知道一些使用理由。

关于 ref 和 reactive 这两个 API 的好处就不重复了,但是在使用的过程中,各自都有不方便的地方:

ref API 虽然在 <template /> 里使用起来方便,但是在 <script /> 里进行读取 / 赋值的时候,要一直记得加上 .value ,否则 BUG 就来了。

reactive API 虽然在使用的时候,因为知道它本身是一个对象,所以不会忘记通过 foo.bar 这样的格式去操作,但是在 <template /> 渲染的时候,又因此不得不每次都使用 foo.bar 的格式去渲染。

那么有没有办法,既可以在编写 <script /> 的时候不容易出错,在写 <template /> 的时候又比较简单呢?

于是, toRef 和 toRefs 因此诞生。

什么场景下比较适合使用它们​

从便利性和可维护性来说,最好只在功能单一、代码量少的组件里使用,比如一个表单组件,通常表单的数据都放在一个对象里。

当然也可以把所有的数据都定义到一个 data 里,再去 data 里面取值,但是没有必要为了转换而转换,否则不如使用 Options API 风格。

在业务中的具体运用​

继续使用上文一直在使用的 userInfo 来当案例,以一个用户信息表的小 demo 做个演示。

在 <script /> 部分:

  1. 先用 reactive 定义一个源数据,所有的数据更新,都是修改这个对象对应的值,按照对象的写法维护数据

  2. 再通过 toRefs 定义一个给 <template /> 使用的对象,这样可以得到一个每个字段都是 Ref 变量的新对象

  3. 在 return 的时候,对步骤 2 里的 toRefs 对象进行解构,这样导出去就是各个字段对应的 Ref 变量,而不是一整个对象

ts

import { defineComponent, reactive, toRefs } from 'vue'

interface Member {
  id: number
  name: string
  age: number
  gender: string
}

export default defineComponent({
  setup() {
    // 定义一个 reactive 对象
    const userInfo = reactive({
      id: 1,
      name: 'Petter',
      age: 18,
      gender: 'male',
    })

    // 定义一个新的对象,它本身不具备响应性,但是它的字段全部是 Ref 变量
    const userInfoRefs = toRefs(userInfo)

    // 在 2s 后更新 `userInfo`
    setTimeout(() => {
      userInfo.id = 2
      userInfo.name = 'Tom'
      userInfo.age = 20
    }, 2000)

    // 在这里解构 `toRefs` 对象才能继续保持响应性
    return {
      ...userInfoRefs,
    }
  },
})

在 <template /> 部分:

由于 return 出来的都是 Ref 变量,所以在模板里可以直接使用 userInfo 各个字段的 key ,不再需要写很长的 userInfo.name 了。

vue

<template>
  <ul class="user-info">
    <li class="item">
      <span class="key">ID:</span>
      <span class="value">{{ id }}</span>
    </li>

    <li class="item">
      <span class="key">name:</span>
      <span class="value">{{ name }}</span>
    </li>

    <li class="item">
      <span class="key">age:</span>
      <span class="value">{{ age }}</span>
    </li>

    <li class="item">
      <span class="key">gender:</span>
      <span class="value">{{ gender }}</span>
    </li>
  </ul>
</template>

需要注意的问题​

请注意是否有相同命名的变量存在,比如上面在 return 给 <template /> 使用时,在解构 userInfoRefs 的时候已经包含了一个 name 字段,此时如果还有一个单独的变量也叫 name ,就会出现渲染上的数据显示问题。

此时它们在 <template /> 里哪个会生效,取决于谁排在后面,因为 return 出去的其实是一个对象,在对象里,如果存在相同的 key ,则后面的会覆盖前面的。

下面这种情况,会以单独的 name 为渲染数据:

ts

return {
  ...userInfoRefs,
  name,
}

而下面这种情况,则是以 userInfoRefs 里的 name 为渲染数据:

ts

return {
  name,
  ...userInfoRefs,
}

所以当决定使用 toRef 和 toRefs API 的时候,请注意这个特殊情况!

函数的声明和使用​

在了解了响应式数据如何使用之后,接下来就要开始了解函数了。

在 Vue 2 ,函数通常是作为当前组件实例上的方法在 methods 里声明,然后再在 mounted 等生命周期里调用,或者是在模板里通过 Click 等行为触发,由于组件内部经常需要使用 this 获取组件实例,因此不能使用箭头函数。

js

export default {
  data: () => {
    return {
      num: 0,
    }
  },
  mounted: function () {
    this.add()
  },
  methods: {
    // 不可以使用 `add: () => this.num++`
    add: function () {
      this.num++
    },
  },
}

在 Vue 3 则灵活了很多,可以使用普通函数、 Class 类、箭头函数、匿名函数等等进行声明,可以将其写在 setup 里直接使用,也可以抽离在独立的 .js / .ts 文件里再导入使用。

需要在组件创建时自动执行的函数,其执行时机需要遵循 Vue 3 的生命周期,需要在模板里通过 @click@change 等行为触发,和变量一样,需要把函数名在 setup 里进行 return 出去。

下面是一个简单的例子,方便开发者更直观地了解:

vue

<template>
  <p>{{ msg }}</p>

  <!-- 在这里点击执行 `return` 出来的方法 -->
  <button @click="updateMsg">修改MSG</button>
</template>

<script lang="ts">
import { defineComponent, onMounted, ref } from 'vue'

export default defineComponent({
  setup() {
    const msg = ref<string>('Hello World!')

    // 这个要暴露给模板使用,必须 `return` 才可以使用
    function updateMsg() {
      msg.value = 'Hi World!'
    }

    // 这个要在页面载入时执行,无需 `return` 出去
    const init = () => {
      console.log('init')
    }

    onMounted(() => {
      init()
    })

    return {
      msg,
      updateMsg,
    }
  },
})
</script>

数据的侦听​

侦听数据变化也是组件里的一项重要工作,比如侦听路由变化、侦听参数变化等等。

Vue 3 在保留原来的 watch 功能之外,还新增了一个 watchEffect 帮助更简单地进行侦听。

watch​

在 Vue 3 ,新版的 watch 和 Vue 2 的旧版写法对比,在使用方式上变化非常大!

回顾 Vue 2​

在 Vue 2 是这样用的,和 data 、 methods 都在同级配置:

ts

export default {
  data() {
    return {
      // ...
    }
  },
  // 注意这里,放在 `data` 、 `methods` 同个级别
  watch: {
    // ...
  },
  methods: {
    // ...
  },
}

并且类型繁多,选项式 API 的类型如下:

ts

watch: { [key: string]: string | Function | Object | Array}

联合类型过多,意味着用法复杂,下面是个很好的例子,虽然出自 官网 的用法介绍,但过于繁多的用法也反映出来对初学者不太友好,初次接触可能会觉得一头雾水:

ts

export default {
  data() {
    return {
      a: 1,
      b: 2,
      c: {
        d: 4,
      },
      e: 5,
      f: 6,
    }
  },
  watch: {
    // 侦听顶级 Property
    a(val, oldVal) {
      console.log(`new: ${val}, old: ${oldVal}`)
    },
    // 字符串方法名
    b: 'someMethod',
    // 该回调会在任何被侦听的对象的 Property 改变时被调用,不论其被嵌套多深
    c: {
      handler(val, oldVal) {
        console.log('c changed')
      },
      deep: true,
    },
    // 侦听单个嵌套 Property
    'c.d': function (val, oldVal) {
      // do something
    },
    // 该回调将会在侦听开始之后被立即调用
    e: {
      handler(val, oldVal) {
        console.log('e changed')
      },
      immediate: true,
    },
    // 可以传入回调数组,它们会被逐一调用
    f: [
      'handle1',
      function handle2(val, oldVal) {
        console.log('handle2 triggered')
      },
      {
        handler: function handle3(val, oldVal) {
          console.log('handle3 triggered')
        },
        /* ... */
      },
    ],
  },
  methods: {
    someMethod() {
      console.log('b changed')
    },
    handle1() {
      console.log('handle 1 triggered')
    },
  },
}

当然肯定也会有开发者会觉得这样选择多是个好事,选择适合自己的就好,但笔者还是认为这种写法对于初学者来说不是那么友好,过于复杂,如果一个用法可以适应各种各样的场景,岂不是更妙?

TIP

另外需要注意的是,不能使用箭头函数来定义 Watcher 函数 (例如 searchQuery: newValue => this.updateAutocomplete(newValue) )。

因为箭头函数绑定了父级作用域的上下文,所以 this 将不会按照期望指向组件实例, this.updateAutocomplete 将是 undefined 。

Vue 2 也可以通过 this.$watch() 这个 API 的用法来实现对某个数据的侦听,它接受三个参数: source 、 callback 和 options 。

ts

export default {
  data() {
    return {
      a: 1,
    }
  },
  // 生命周期钩子
  mounted() {
    this.$watch('a', (newVal, oldVal) => {
      // ...
    })
  },
}

由于 this.$watch 的用法和 Vue 3 比较接近,所以这里不做过多的回顾,请直接看 了解 Vue 3 部分。

了解 Vue 3​

在 Vue 3 的组合式 API 写法, watch 是一个可以接受 3 个参数的函数(保留了 Vue 2 的 this.$watch 这种用法),在使用层面上简单了很多。

ts

import { watch } from 'vue'

// 一个用法走天下
watch(
  source, // 必传,要侦听的数据源
  callback // 必传,侦听到变化后要执行的回调函数
  // options // 可选,一些侦听选项
)

下面的内容都基于 Vue 3 的组合式 API 用法展开讲解。

API 的 TS 类型​

在了解用法之前,先对它的 TS 类型声明做一个简单的了解, watch 作为组合式 API ,根据使用方式有两种类型声明:

  1. 基础用法的 TS 类型,详见 基础用法 部分

ts

// watch 部分的 TS 类型
// ...
export declare function watch<T, Immediate extends Readonly<boolean> = false>(
  source: WatchSource<T>,
  cb: WatchCallback<T, Immediate extends true ? T | undefined : T>,
  options?: WatchOptions<Immediate>
): WatchStopHandle
// ...
  1. 批量侦听的 TS 类型,详见 批量侦听 部分

ts

// watch 部分的 TS 类型
// ...
export declare function watch<
  T extends MultiWatchSources,
  Immediate extends Readonly<boolean> = false
>(
  sources: [...T],
  cb: WatchCallback<MapSources<T, false>, MapSources<T, Immediate>>,
  options?: WatchOptions<Immediate>
): WatchStopHandle

// MultiWatchSources 是一个数组
declare type MultiWatchSources = (WatchSource<unknown> | object)[]
// ...

但是不管是基础用法还是批量侦听,可以看到这个 API 都是接受三个入参:

参数是否可选含义
source必传数据源(详见:要侦听的数据源)
callback必传侦听到变化后要执行的回调函数(详见:侦听后的回调函数)
options可选一些侦听选项(详见:侦听的选项)

并返回一个可以用来停止侦听的函数(详见:停止侦听)。

要侦听的数据源​

在上面 API 的 TS 类型 已经对 watch API 的组成有一定的了解了,这里先对数据源的类型和使用限制做下说明。

TIP

如果不提前了解,在使用的过程中可能会遇到 “侦听了但没有反应” 的情况出现。

另外,这部分内容会先围绕基础用法展开说明,批量侦听会在 批量侦听 部分单独说明。

watch API 的第 1 个参数 source 是要侦听的数据源,它的 TS 类型如下:

ts

// watch 第 1 个入参的 TS 类型
// ...
export declare type WatchSource<T = any> = Ref<T> | ComputedRef<T> | (() => T)
// ...

可以看到能够用于侦听的数据,是通过 响应式 API 定义的变量( Ref<T> ),或者是一个 计算数据 ( ComputedRef<T> ),或者是一个 getter 函数 ( () => T )。

所以要想定义的 watch 能够做出预期的行为,数据源必须具备响应性或者是一个 getter ,如果只是通过 let 定义一个普通变量,然后去改变这个变量的值,这样是无法侦听的。

TIP

如果要侦听响应式对象里面的某个值(这种情况下对象本身是响应式,但它的 property 不是),需要写成 getter 函数,简单的说就是需要写成有返回值的函数,这个函数 return 要侦听的数据, e.g. () => foo.bar ,可以结合下方 基础用法 的例子一起理解。

侦听后的回调函数​

在上面 API 的 TS 类型 介绍了 watch API 的组成,和数据源一样,先了解一下回调函数的定义。

TIP

和数据源部分一样,回调函数的内容也是会先围绕基础用法展开说明,批量侦听会在 批量侦听 部分单独说明。

watch API 的第 2 个参数 callback 是侦听到数据变化时要做出的行为,它的 TS 类型如下:

ts

// watch 第 2 个入参的 TS 类型
// ...
export declare type WatchCallback<V = any, OV = any> = (
  value: V,
  oldValue: OV,
  onCleanup: OnCleanup
) => any
// ...

乍一看它有三个参数,但实际上这些参数不是自己定义的,而是 watch API 传递的,所以不管用或者不用,它们都在那里:

参数作用
value变化后的新值,类型和数据源保持一致
oldValue变化前的旧值,类型和数据源保持一致
onCleanup注册一个清理函数,详见 侦听效果清理 部分

注意:第一个参数是新值,第二个才是原来的旧值!

如同其他 JS 函数,在使用 watch 的回调函数时,可以对这三个参数任意命名,比如把 value 命名为觉得更容易理解的 newValue 。

TIP

如果侦听的数据源是一个 引用类型 时( e.g. Object 、 Array 、 Date … ), value 和 oldValue 是完全相同的,因为指向同一个对象。

另外,默认情况下,watch 是惰性的,也就是只有当被侦听的数据源发生变化时才执行回调。

基础用法​

来到这里,对 2 个必传的参数都有一定的了解了,先看看基础的用法,也就是日常最常编写的方案,只需要先关注前 2 个必传的参数。

ts

// 不要忘了导入要用的 API
import { defineComponent, reactive, watch } from 'vue'

export default defineComponent({
  setup() {
    // 定义一个响应式数据
    const userInfo = reactive({
      name: 'Petter',
      age: 18,
    })

    // 2s后改变数据
    setTimeout(() => {
      userInfo.name = 'Tom'
    }, 2000)

    /**
     * 可以直接侦听这个响应式对象
     * callback 的参数如果不用可以不写
     */
    watch(userInfo, () => {
      console.log('侦听整个 userInfo ', userInfo.name)
    })

    /**
     * 也可以侦听对象里面的某个值
     * 此时数据源需要写成 getter 函数
     */
    watch(
      // 数据源,getter 形式
      () => userInfo.name,
      // 回调函数 callback
      (newValue, oldValue) => {
        console.log('只侦听 name 的变化 ', userInfo.name)
        console.log('打印变化前后的值', { oldValue, newValue })
      }
    )
  },
})

一般的业务场景,基础用法足以面对。

如果有多个数据源要侦听,并且侦听到变化后要执行的行为一样,那么可以使用 批量侦听 。

特殊的情况下,可以搭配 侦听的选项 做一些特殊的用法,详见下面部分的内容。

批量侦听​

如果有多个数据源要侦听,并且侦听到变化后要执行的行为一样,第一反应可能是这样来写:

  1. 抽离相同的处理行为为公共函数
  2. 然后定义多个侦听操作,传入这个公共函数

ts

import { defineComponent, ref, watch } from 'vue'

export default defineComponent({
  setup() {
    const message = ref<string>('')
    const index = ref<number>(0)

    // 2s后改变数据
    setTimeout(() => {
      // 来到这里才会触发 watch 的回调
      message.value = 'Hello World!'
      index.value++
    }, 2000)

    // 抽离相同的处理行为为公共函数
    const handleWatch = (
      newValue: string | number,
      oldValue: string | number
    ): void => {
      console.log({ newValue, oldValue })
    }

    // 然后定义多个侦听操作,传入这个公共函数
    watch(message, handleWatch)
    watch(index, handleWatch)
  },
})

这样写其实没什么问题,不过除了抽离公共代码的写法之外, watch API 还提供了一个批量侦听的用法,和 基础用法 的区别在于,数据源和回调参数都变成了数组的形式。

数据源:以数组的形式传入,里面每一项都是一个响应式数据。

回调参数:原来的 value 和 newValue 也都变成了数组,每个数组里面的顺序和数据源数组排序一致。

可以看下面的这个例子更为直观:

ts

import { defineComponent, ref, watch } from 'vue'

export default defineComponent({
  setup() {
    // 定义多个数据源
    const message = ref<string>('')
    const index = ref<number>(0)

    // 2s后改变数据
    setTimeout(() => {
      message.value = 'Hello World!'
      index.value++
    }, 2000)

    watch(
      // 数据源改成了数组
      [message, index],
      // 回调的入参也变成了数组,每个数组里面的顺序和数据源数组排序一致
      ([newMessage, newIndex], [oldMessage, oldIndex]) => {
        console.log('message 的变化', { newMessage, oldMessage })
        console.log('index 的变化', { newIndex, oldIndex })
      }
    )
  },
})

什么情况下可能会用到批量侦听呢?比如一个子组件有多个 props ,当有任意一个 prop 发生变化时,都需要执行初始化函数重置组件的状态,那么这个时候就可以用上这个功能啦!

TIP

在适当的业务场景,也可以使用 watchEffect 来完成批量侦听,但请留意 功能区别 部分的说明。

侦听的选项​

在 API 的 TS 类型 里提到, watch API 还接受第 3 个参数 options ,可选的一些侦听选项。

它的 TS 类型如下:

ts

// watch 第 3 个入参的 TS 类型
// ...
export declare interface WatchOptions<Immediate = boolean>
  extends WatchOptionsBase {
  immediate?: Immediate
  deep?: boolean
}
// ...

// 继承的 base 类型
export declare interface WatchOptionsBase extends DebuggerOptions {
  flush?: 'pre' | 'post' | 'sync'
}
// ...

// 继承的 debugger 选项类型
export declare interface DebuggerOptions {
  onTrack?: (event: DebuggerEvent) => void
  onTrigger?: (event: DebuggerEvent) => void
}
// ...

options 是一个对象的形式传入,有以下几个选项:

选项类型默认值可选值作用
deepbooleanfalsetrue | false是否进行深度侦听
immediatebooleanfalsetrue | false是否立即执行侦听回调
flushstring'pre''pre' | 'post' | 'sync'控制侦听回调的调用时机
onTrack(e) => void在数据源被追踪时调用
onTrigger(e) => void在侦听回调被触发时调用

其中 onTrack 和 onTrigger 的 e 是 debugger 事件,建议在回调内放置一个 debugger 语句 以调试依赖,这两个选项仅在开发模式下生效。

TIP

deep 默认是 false ,但是在侦听 reactive 对象或数组时,会默认为 true ,详见 侦听选项之 deep。

侦听选项之 deep​

deep 选项接受一个布尔值,可以设置为 true 开启深度侦听,或者是 false 关闭深度侦听,默认情况下这个选项是 false 关闭深度侦听的,但也存在特例。

设置为 false 的情况下,如果直接侦听一个响应式的 引用类型 数据(e.g. Object 、 Array … ),虽然它的属性的值有变化,但对其本身来说是不变的,所以不会触发 watch 的 callback 。

下面是一个关闭了深度侦听的例子:

ts

import { defineComponent, ref, watch } from 'vue'

export default defineComponent({
  setup() {
    // 定义一个响应式数据,注意用的是 ref 来定义
    const nums = ref<number[]>([])

    // 2s后给这个数组添加项目
    setTimeout(() => {
      nums.value.push(1)

      // 可以打印一下,确保数据确实变化了
      console.log('修改后', nums.value)
    }, 2000)

    // 但是这个 watch 不会按预期执行
    watch(
      nums,
      // 这里的 callback 不会被触发
      () => {
        console.log('触发侦听', nums.value)
      },
      // 因为关闭了 deep
      {
        deep: false,
      }
    )
  },
})

类似这种情况,需要把 deep 设置为 true 才可以触发侦听。

可以看到上面的例子特地用了 ref API ,这是因为通过 reactive API 定义的对象无法将 deep 成功设置为 false (这一点在目前的官网文档未找到说明,最终是在 watch API 的源码 上找到了答案)。

ts

// ...
if (isReactive(source)) {
  getter = () => source
  deep = true // 被强制开启了
}
// ...

这个情况就是上面所说的 “特例” ,可以通过 isReactive API 来判断是否需要手动开启深度侦听。

ts

// 导入 isReactive API
import { defineComponent, isReactive, reactive, ref } from 'vue'

export default defineComponent({
  setup() {
    // 侦听这个数据时,会默认开启深度侦听
    const foo = reactive({
      name: 'Petter',
      age: 18,
    })
    console.log(isReactive(foo)) // true

    // 侦听这个数据时,不会默认开启深度侦听
    const bar = ref({
      name: 'Petter',
      age: 18,
    })
    console.log(isReactive(bar)) // false
  },
})
侦听选项之 immediate​

在 侦听后的回调函数 部分有了解过, watch 默认是惰性的,也就是只有当被侦听的数据源发生变化时才执行回调。

这句话是什么意思呢?来看一下这段代码,为了减少 deep 选项的干扰,换一个类型,换成 string 数据来演示,请留意注释:

ts

import { defineComponent, ref, watch } from 'vue'

export default defineComponent({
  setup() {
    // 这个时候不会触发 watch 的回调
    const message = ref<string>('')

    // 2s后改变数据
    setTimeout(() => {
      // 来到这里才会触发 watch 的回调
      message.value = 'Hello World!'
    }, 2000)

    watch(message, () => {
      console.log('触发侦听', message.value)
    })
  },
})

可以看到,数据在初始化的时候并不会触发侦听回调,如果有需要的话,通过 immediate 选项来让它直接触发。

immediate 选项接受一个布尔值,默认是 false ,可以设置为 true 让回调立即执行。

改成这样,请留意高亮的代码部分和新的注释:

ts

import { defineComponent, ref, watch } from 'vue'

export default defineComponent({
  setup() {
    // 这一次在这里可以会触发 watch 的回调了
    const message = ref<string>('')

    // 2s后改变数据
    setTimeout(() => {
      // 这一次,这里是第二次触发 watch 的回调,不再是第一次
      message.value = 'Hello World!'
    }, 2000)

    watch(
      message,
      () => {
        console.log('触发侦听', message.value)
      },
      // 设置 immediate 选项
      {
        immediate: true,
      }
    )
  },
})

注意,在带有 immediate 选项时,不能在第一次回调时取消该数据源的侦听,详见 停止侦听 部分。

侦听选项之 flush​

flush 选项是用来控制 侦听回调 的调用时机,接受指定的字符串,可选值如下,默认是 'pre' 。

可选值回调的调用时机使用场景
'pre'将在渲染前被调用允许回调在模板运行前更新了其他值
'sync'在渲染时被同步调用目前来说没什么好处,可以了解但不建议用…
'post'被推迟到渲染之后调用如果要通过 ref 操作 DOM 元素与子组件 ,需要使用这个值来启用该选项,以达到预期的执行效果

对于 'pre' 和 'post' ,回调使用队列进行缓冲。回调只被添加到队列中一次。

即使观察值变化了多次,值的中间变化将被跳过,不会传递给回调,这样做不仅可以提高性能,还有助于保证数据的一致性。

更多关于 flush 的信息,请参阅 回调的触发时机 。

停止侦听​

如果在 setup 或者 script-setup 里使用 watch 的话, 组件被卸载 的时候也会一起被停止,一般情况下不太需要关心如何停止侦听。

不过有时候可能想要手动取消, Vue 3 也提供了方法。

TIP

随着组件被卸载一起停止的前提是,侦听器必须是 同步语句 创建的,这种情况下侦听器会绑定在当前组件上。

如果放在 setTimeout 等 异步函数 里面创建,则不会绑定到当前组件,因此组件卸载的时候不会一起停止该侦听器,这种时候就需要手动停止侦听。

在 API 的 TS 类型 有提到,当在定义一个 watch 行为的时候,它会返回一个用来停止侦听的函数。

这个函数的 TS 类型如下:

ts

export declare type WatchStopHandle = () => void

用法很简单,做一下简单了解即可:

ts

// 定义一个取消观察的变量,它是一个函数
const unwatch = watch(message, () => {
  // ...
})

// 在合适的时期调用它,可以取消这个侦听
unwatch()

但是也有一点需要注意的是,如果启用了 immediate 选项 ,不能在第一次触发侦听回调时执行它。

ts

// 注意:这是一段错误的代码,运行会报错
const unwatch = watch(
  message,
  // 侦听的回调
  () => {
    // ...
    // 在这里调用会有问题 ❌
    unwatch()
  },
  // 启用 immediate 选项
  {
    immediate: true,
  }
)

会收获一段报错,告诉 unwatch 这个变量在初始化前无法被访问:

bash

Uncaught ReferenceError: Cannot access 'unwatch' before initialization

目前有两种方案可以让实现这个操作:

方案一:使用 var 并判断变量类型,利用 var 的变量提升 来实现目的。

ts

// 这里改成 var ,不要用 const 或 let
var unwatch = watch(
  message,
  // 侦听回调
  () => {
    // 这里加一个判断,是函数才执行它
    if (typeof unwatch === 'function') {
      unwatch()
    }
  },
  // 侦听选项
  {
    immediate: true,
  }
)

不过 var 已经属于过时的语句了,建议用方案二的 let 。

方案二:使用 let 并判断变量类型。

ts

// 如果不想用 any ,可以导入 TS 类型
import type { WatchStopHandle } from 'vue'

// 这里改成 let ,但是要另起一行,先定义,再赋值
let unwatch: WatchStopHandle
unwatch = watch(
  message,
  // 侦听回调
  () => {
    // 这里加一个判断,是函数才执行它
    if (typeof unwatch === 'function') {
      unwatch()
    }
  },
  // 侦听选项
  {
    immediate: true,
  }
)
侦听效果清理​

在 侦听后的回调函数 部分提及到一个参数 onCleanup ,它可以帮注册一个清理函数。

有时 watch 的回调会执行异步操作,当 watch 到数据变更的时候,需要取消这些操作,这个函数的作用就用于此,会在以下情况调用这个清理函数:

  • watcher 即将重新运行的时候
  • watcher 被停止(组件被卸载或者被手动 停止侦听 )

TS 类型:

ts

declare type OnCleanup = (cleanupFn: () => void) => void

用法方面比较简单,传入一个回调函数运行即可,不过需要注意的是,需要在停止侦听之前注册好清理行为,否则不会生效。

在 停止侦听 里的最后一个 immediate 例子的基础上继续添加代码,请注意注册的时机:

ts

let unwatch: WatchStopHandle
unwatch = watch(
  message,
  (newValue, oldValue, onCleanup) => {
    // 需要在停止侦听之前注册好清理行为
    onCleanup(() => {
      console.log('侦听清理ing')
      // 根据实际的业务情况定义一些清理操作 ...
    })
    // 然后再停止侦听
    if (typeof unwatch === 'function') {
      unwatch()
    }
  },
  {
    immediate: true,
  }
)

watchEffect​

如果一个函数里包含了多个需要侦听的数据,一个一个数据去侦听太麻烦了,在 Vue 3 ,可以直接使用 watchEffect API 来简化的操作。

API 的 TS 类型​

这个 API 的类型如下,使用的时候需要传入一个副作用函数(相当于 watch 的 侦听后的回调函数 ),也可以根据的实际情况传入一些可选的 侦听选项 。

和 watch API 一样,它也会返回一个用于 停止侦听 的函数。

ts

// watchEffect 部分的 TS 类型
// ...
export declare type WatchEffect = (onCleanup: OnCleanup) => void

export declare function watchEffect(
  effect: WatchEffect,
  options?: WatchOptionsBase
): WatchStopHandle
// ...

副作用函数也会传入一个清理回调作为参数,和 watch 的 侦听效果清理 一样的用法。

可以理解为它是一个简化版的 watch ,具体简化在哪里呢?请看下面的用法示例。

用法示例​

它立即执行传入的一个函数,同时响应式追踪其依赖,并在其依赖变更时重新运行该函数。

ts

import { defineComponent, ref, watchEffect } from 'vue'

export default defineComponent({
  setup() {
    // 单独定义两个数据,后面用来分开改变数值
    const name = ref<string>('Petter')
    const age = ref<number>(18)

    // 定义一个调用这两个数据的函数
    const getUserInfo = (): void => {
      console.log({
        name: name.value,
        age: age.value,
      })
    }

    // 2s后改变第一个数据
    setTimeout(() => {
      name.value = 'Tom'
    }, 2000)

    // 4s后改变第二个数据
    setTimeout(() => {
      age.value = 20
    }, 4000)

    // 直接侦听调用函数,在每个数据产生变化的时候,它都会自动执行
    watchEffect(getUserInfo)
  },
})
和 watch 的区别​

虽然理论上 watchEffect 是 watch 的一个简化操作,可以用来代替 批量侦听 ,但它们也有一定的区别:

  1. watch 可以访问侦听状态变化前后的值,而 watchEffect 没有。

  2. watch 是在属性改变的时候才执行,而 watchEffect 则默认会执行一次,然后在属性改变的时候也会执行。

第二点的意思,看下面这段代码可以有更直观的理解:

使用 watch :

ts

export default defineComponent({
  setup() {
    const foo = ref<string>('')

    setTimeout(() => {
      foo.value = 'Hello World!'
    }, 2000)

    function bar() {
      console.log(foo.value)
    }

    // 使用 watch 需要先手动执行一次
    bar()

    // 然后当 foo 有变动时,才会通过 watch 来执行 bar()
    watch(foo, bar)
  },
})

使用 watchEffect :

ts

export default defineComponent({
  setup() {
    const foo = ref<string>('')

    setTimeout(() => {
      foo.value = 'Hello World!'
    }, 2000)

    function bar() {
      console.log(foo.value)
    }

    // 可以通过 watchEffect 实现 bar() + watch(foo, bar) 的效果
    watchEffect(bar)
  },
})
可用的侦听选项​

虽然用法和 watch 类似,但也简化了一些选项,它的侦听选项 TS 类型如下:

ts

// 只支持 base 类型
export declare interface WatchOptionsBase extends DebuggerOptions {
  flush?: 'pre' | 'post' | 'sync'
}
// ...

// 继承的 debugger 选项类型
export declare interface DebuggerOptions {
  onTrack?: (event: DebuggerEvent) => void
  onTrigger?: (event: DebuggerEvent) => void
}
// ...

对比 watch API ,它不支持 deep 和 immediate ,请记住这一点,其他的用法是一样的。

flush 选项的使用详见 侦听选项之 flush ,onTrack 和 onTrigger 详见 侦听的选项 部分内容。

watchPostEffect​

watchEffect API 使用 flush: 'post' 选项时的别名,具体区别详见 侦听选项之 flush 部分。

TIP

Vue v3.2.0 及以上版本才支持该 API 。

watchSyncEffect​

watchEffect API 使用 flush: 'sync' 选项时的别名,具体区别详见 侦听选项之 flush 部分。

TIP

Vue v3.2.0 及以上版本才支持该 API 。

数据的计算​

和 Vue 2.0 一样,数据的计算也是使用 computed API ,它可以通过现有的响应式数据,去通过计算得到新的响应式变量,用过 Vue 2.0 的开发者应该不会太陌生,但是在 Vue 3.0 ,在使用方式上也是变化非常大!

TIP

这里的响应式数据,可以简单理解为通过 ref API 、 reactive API 定义出来的数据,当然 Vuex 、Vue Router 等 Vue 数据也都具备响应式,可以在 响应式数据的变化 了解。

用法变化​

先从一个简单的用例来看看在 Vue 新旧版本的用法区别:

假设定义了两个分开的数据 firstName 名字和 lastName 姓氏,但是在 template 展示时,需要展示完整的姓名,那么就可以通过 computed 来计算一个新的数据:

回顾 Vue 2​

在 Vue 2.0 ,computed 和 data 在同级配置,并且不可以和 data 里的数据同名重复定义:

ts

// 在 Vue 2 的写法:
export default {
  data() {
    return {
      firstName: 'Bill',
      lastName: 'Gates',
    }
  },
  // 注意这里定义的变量,都要通过函数的形式来返回它的值
  computed: {
    // 普通函数可以直接通过熟悉的 this 来拿到 data 里的数据
    fullName() {
      return `${this.firstName} ${this.lastName}`
    },
    // 箭头函数则需要通过参数来拿到实例上的数据
    fullName2: (vm) => `${vm.firstName} ${vm.lastName}`,
  },
}

这样在需要用到全名的地方,只需要通过 this.fullName 就可以得到 Bill Gates 。

了解 Vue 3​

在 Vue 3.0 ,跟其他 API 的用法一样,需要先导入 computed 才能使用:

ts

// 在 Vue 3 的写法:
import { defineComponent, ref, computed } from 'vue'

export default defineComponent({
  setup() {
    // 定义基本的数据
    const firstName = ref<string>('Bill')
    const lastName = ref<string>('Gates')

    // 定义需要计算拼接结果的数据
    const fullName = computed(() => `${firstName.value} ${lastName.value}`)

    // 2s 后改变某个数据的值
    setTimeout(() => {
      firstName.value = 'Petter'
    }, 2000)

    // template 那边在 2s 后也会显示为 Petter Gates
    return {
      fullName,
    }
  },
})

可以把这个用法简单的理解为,传入一个回调函数,并 return 一个值,对,它需要有明确的返回值。

TIP

需要注意的是:

  1. 定义出来的 computed 变量,和 Ref 变量的用法一样,也是需要通过 .value 才能拿到它的值

  2. 但是区别在于,默认情况下 computed 的 value 是只读的

原因详见下方的 类型声明 。

类型声明​

之前说过,在 defineComponent 里,会自动帮推导 Vue API 的类型,所以一般情况下,是不需要显式的去定义 computed 出来的变量类型的。

在确实需要手动指定的情况下,也可以导入它的类型然后定义:

ts

import { computed } from 'vue'
import type { ComputedRef } from 'vue'

// 注意这里添加了类型声明
const fullName: ComputedRef<string> = computed(
  () => `${firstName.value} ${lastName.value}`
)

要返回一个字符串,就写 ComputedRef<string> ;返回布尔值,就写 ComputedRef<boolean> ;返回一些复杂对象信息,可以先定义好的类型,再诸如 ComputedRef<UserInfo> 去写。

ts

// 这是 ComputedRef 的类型声明:
export declare interface ComputedRef<T = any> extends WritableComputedRef<T> {
  readonly value: T
  [ComoutedRefSymbol]: true
}

优势对比和注意事项​

在继续往下看之前,先来了解一下这个 API 的一些优势和注意事项(如果在 Vue 2 已经有接触过的话,可以跳过这一段,因为优势和需要注意的东西比较一致)。

优势对比​

看到这里,相信刚接触的开发者可能会有疑问,既然 computed 也是通过一个函数来返回值,那么和普通的 function 有什么区别,或者说优势?

  1. 性能优势

这一点在 官网文档 其实是有提到的:

数据的计算是基于它们的响应依赖关系缓存的,只在相关响应式依赖发生改变时它们才会重新求值。

也就是说,只要原始数据没有发生改变,多次访问 computed ,都是会立即返回之前的计算结果,而不是再次执行函数;而普通的 function 调用多少次就执行多少次,每调用一次就计算一次。

至于为何要如此设计,官网文档也给出了原因:

为什么需要缓存?假设有一个性能开销比较大的计算数据 list,它需要遍历一个巨大的数组并做大量的计算。然后可能有其他的计算数据依赖于 list。如果没有缓存,将不可避免地多次执行 list 的 getter!如果不希望有缓存,请用 function 来替代。

TIP

在这部分内容里,笔者将官方文档的一些用词做了更换,比如把 method 都替换成了 function ,也把 “计算属性” 都换成了 “计算数据”,原因在于官网很多地方是基于 Options API 的写法去描述,而本文档是基于 Composition API 。

点击了解: 如何理解 JavaScript 中方法(method)和函数(function)的区别?

  1. 书写统一

假定 foo1 是 Ref 变量, foo2 是 computed 变量, foo3 是普通函数返回值

看到这里的开发者应该都已经清楚 Vue 3 的 Ref 变量是通过 foo1.value 来拿到值的,而 computed 也是通过 foo2.value ,并且在 template 里都可以省略 .value ,在读取方面,他们是有一致的风格和简洁性。

而 foo3 不管是在 script 还是 template ,都需要通过 foo3() 才能拿到结果,相对来说会有那么一丢丢别扭。

当然,关于这一点,如果涉及到的数据不是响应式数据,那么还是老老实实地用函数返回值吧,原因请见下面的 注意事项 。

注意事项​

有优势当然也就有一定的 “劣势” ,当然这也是 Vue 框架的有意为之,所以在使用上也需要注意一些问题:

  1. 只会更新响应式数据的计算

假设要获取当前的时间信息,因为不是响应式数据,所以这种情况下就需要用普通的函数去获取返回值,才能拿到最新的时间。

ts

const nowTime = computed(() => new Date())
console.log(nowTime.value)
// 输出 Sun Nov 14 2021 21:07:00 GMT+0800 (GMT+08:00)

// 2s 后依然是跟上面一样的结果
setTimeout(() => {
  console.log(nowTime.value)
  // 还是输出 Sun Nov 14 2021 21:07:00 GMT+0800 (GMT+08:00)
}, 2000)
  1. 数据是只读的

通过 computed 定义的数据,它是只读的,这一点在 类型声明 已经有所了解。

如果直接赋值,不仅无法变更数据,而且会收获一个报错。

bash

TS2540: Cannot assign to 'value' because it is a read-only property.

虽然无法直接赋值,但是在必要的情况下,依然可以通过 computed 的 setter 来更新数据。

点击了解:computed 的 setter 用法

setter 的使用​

通过 computed 定义的变量默认都是只读的形式(只有一个 getter ),但是在必要的情况下,也可以使用其 setter 属性来更新数据。

基本格式​

当需要用到 setter 的时候, computed 就不再是一个传入 callback 的形式了,而是传入一个带有 2 个方法的对象。

ts

// 注意这里computed接收的入参已经不再是函数
const foo = computed({
  // 这里需要明确的返回一个值
  get() {
    // ...
  },
  // 这里接收一个参数,代表修改 foo 时,赋值下来的新值
  set(newValue) {
    // ...
  },
})

这里的 get 就是 computed 的 getter ,跟原来传入 callback 的形式一样,用于 foo.value 的读取,所以这里必须有明确的返回值。

这里的 set 就是 computed 的 setter ,它会接收一个参数,代表新的值,当通过 foo.value = xxx 赋值的时候,赋入的这个值,就会通过这个入参传递进来,可以根据的业务需要,把这个值赋给相关的数据源。

TIP

请注意,必须使用 get 和 set 这 2 个方法名,也只接受这 2 个方法。

在了解了基本格式后,可以查看下面的例子来了解具体的用法。

使用示范​

官网的 例子 是一个 Options API 的案例,这里改成 Composition API 的写法来演示:

ts

// 还是这2个数据源
const firstName = ref<string>('Bill')
const lastName = ref<string>('Gates')

// 这里配合setter的需要,改成了另外一种写法
const fullName = computed({
  // getter还是返回一个拼接起来的全名
  get() {
    return `${firstName.value} ${lastName.value}`
  },
  // setter这里改成只更新firstName,注意参数也定义TS类型
  set(newFirstName: string) {
    firstName.value = newFirstName
  },
})
console.log(fullName.value) // 输出 Bill Gates

// 2s后更新一下数据
setTimeout(() => {
  // 对fullName的赋值,其实更新的是firstName
  fullName.value = 'Petter'

  // 此时firstName已经得到了更新
  console.log(firstName.value) // 会输出 Petter

  // 当然,由于firstName变化了,所以fullName的getter也会得到更新
  console.log(fullName.value) // 会输出 Petter Gates
}, 2000)

应用场景​

计算 API 的作用,官网文档只举了一个非常简单的例子,那么在实际项目中,什么情况下用它会让更方便呢?

简单举几个比较常见的例子吧,加深一下对 computed 的理解。

数据的拼接和计算​

如上面的案例,与其每个用到的地方都要用到 firstName + ' ' + lastName 这样的多变量拼接,不如用一个 fullName 来的简单。

当然,不止是字符串拼接,数据的求和等操作更是合适,比如说做一个购物车,购物车里有商品列表,同时还要显示购物车内的商品总金额,这种情况就非常适合用计算数据。

复用组件的动态数据​

在一个项目里,很多时候组件会涉及到复用,比如说:“首页的文章列表 vs 列表页的文章列表 vs 作者详情页的文章列表” ,特别常见于新闻网站等内容资讯站点,这种情况下,往往并不需要每次都重新写 UI 、数据渲染等代码,仅仅是接口 URL 的区别。

这种情况就可以通过路由名称来动态获取要调用哪个列表接口:

ts

const route = useRoute()

// 定义一个根据路由名称来获取接口URL的计算数据
const apiUrl = computed(() => {
  switch (route.name) {
    // 首页
    case 'home':
      return '/api/list1'
    // 列表页
    case 'list':
      return '/api/list2'
    // 作者页
    case 'author':
      return '/api/list3'
    // 默认是随机列表
    default:
      return '/api/random'
  }
})

// 请求列表
const getArticleList = async (): Promise<void> => {
  // ...
  articleList.value = await axios({
    method: 'get',
    url: apiUrl.value,
    // ...
  })
  // ...
}

当然,如果已经学到了 组件通讯 一章的话,这种情况也可以在父组件通过 props 传递接口 URL 。

获取多级对象的值​

经常遇到这样的情况:要在 template 显示一些多级对象的字段,而某些字段不一定有,需要做一些判断。虽然有 v-if ,但是嵌套层级一多,模板代码会难以维护。

如果把这些工作量转移给计算数据,结合 try / catch ,就无需在 template 里处理很多判断了。

ts

// 例子比较极端,但在 Vuex 这种大型数据树上,也不是完全不可能存在
const foo = computed(() => {
  // 正常情况下返回需要的数据
  try {
    return store.state.foo3.foo2.foo1.foo
  } catch (e) {
    // 处理失败则返回一个默认值
    return ''
  }
})

这样在 template 里要拿到 foo 的值,完全不需要关心中间一级又一级的字段是否存在,只需要区分是不是默认值。

不同类型的数据转换​

有时候会遇到一些需求,类似于:让用户在输入框里按一定的格式填写文本,比如用英文逗号 , 隔开每个词,然后保存时用数组的格式提交给接口。

这个时候 computed 的 setter 就发挥妙用了,只需要一个简单的 computed ,就可以代替 input 的 change 事件或者 watch 侦听,可以减少很多业务代码的编写。

vue

<template>
  <input
    type="text"
    v-model="tagsStr"
    placeholder="请输入标签,多个标签用英文逗号隔开"
  />
</template>

<script lang="ts">
import { defineComponent, computed, ref } from 'vue'

export default defineComponent({
  setup() {
    // 这个是最终要用到的数组
    const tags = ref<string[]>([])

    // 因为input必须绑定一个字符串
    const tagsStr = computed({
      // 所以通过getter来转成字符串
      get() {
        return tags.value.join(',')
      },
      // 然后在用户输入的时候,切割字符串转换回数组
      set(newValue: string) {
        tags.value = newValue.split(',')
      },
    })

    return {
      tagsStr,
    }
  },
})
</script>

所以在实际业务开发中,开发者可以多考虑一下是否可以使用 computed 代替 watch ,避免过多的数组侦听带来项目性能的下降。

指令​

指令是 Vue 模板语法里的特殊标记,在使用上和 HTML 的 data-* 属性十分相似,统一以 v- 开头( e.g. v-html )。

它以简单的方式实现了常用的 JavaScript 表达式功能,当表达式的值改变的时候,响应式地作用到 DOM 上。

内置指令​

Vue 提供了一些内置指令可以直接使用,例如:

vue

<template>
  <!-- 渲染一段文本 -->
  <span v-text="msg"></span>

  <!-- 渲染一段 HTML -->
  <div v-html="html"></div>

  <!-- 循环创建一个列表 -->
  <ul v-if="items.length">
    <li v-for="(item, index) in items" :key="index">
      <span>{{ item }}</span>
    </li>
  </ul>

  <!-- 一些事件( `@` 等价于 `v-on` ) -->
  <button @click="hello">Hello</button>
</template>

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

export default defineComponent({
  setup() {
    const msg = ref<string>('Hello World!')
    const html = ref<string>('<p>Hello World!</p>')
    const items = ref<string[]>(['a', 'b', 'c', 'd'])

    function hello() {
      console.log(msg.value)
    }

    return {
      msg,
      html,
      items,
      hello,
    }
  },
})
</script>

内置指令在使用上都非常的简单,可以在官方文档的 内置指令 一章查询完整的指令列表和用法,在模板上使用时,请了解 指令的模板语法 。

TIP

有两个指令可以使用别名:

  • v-on 的别名是 @ ,使用 @click 等价于 v-on:click
  • v-bind 的别名是 : ,使用 :src 等价于 v-bind:src

自定义指令​

如果 Vue 的内置指令不能满足业务需求,还可以开发自定义指令。

相关的 TS 类型​

在开始编写代码之前,先了解一下自定义指令相关的 TypeScript 类型。

自定义指令有两种实现形式,一种是作为一个对象,其中的写法比较接近于 Vue 组件,除了 getSSRProps 和 deep 选项 外,其他的每一个属性都是一个 钩子函数 ,下一小节会介绍钩子函数的内容。

ts

// 对象式写法的 TS 类型
// ...
export declare interface ObjectDirective<T = any, V = any> {
  created?: DirectiveHook<T, null, V>
  beforeMount?: DirectiveHook<T, null, V>
  mounted?: DirectiveHook<T, null, V>
  beforeUpdate?: DirectiveHook<T, VNode<any, T>, V>
  updated?: DirectiveHook<T, VNode<any, T>, V>
  beforeUnmount?: DirectiveHook<T, null, V>
  unmounted?: DirectiveHook<T, null, V>
  getSSRProps?: SSRDirectiveHook
  deep?: boolean
}
// ...

另外一种是函数式写法,只需要定义成一个函数,但这种写法只在 mounted 和 updated 这两个钩子生效,并且触发一样的行为。

ts

// 函数式写法的 TS 类型
// ...
export declare type FunctionDirective<T = any, V = any> = DirectiveHook<
  T,
  any,
  V
>
// ...

这是每个钩子函数对应的类型,它有 4 个入参:

ts

// 钩子函数的 TS 类型
// ...
export declare type DirectiveHook<
  T = any,
  Prev = VNode<any, T> | null,
  V = any
> = (
  el: T,
  binding: DirectiveBinding<V>,
  vnode: VNode<any, T>,
  prevVNode: Prev
) => void
// ...

钩子函数第二个参数的类型:

ts

// 钩子函数第二个参数的 TS 类型
// ...
export declare interface DirectiveBinding<V = any> {
  instance: ComponentPublicInstance | null
  value: V
  oldValue: V | null
  arg?: string
  modifiers: DirectiveModifiers
  dir: ObjectDirective<any, V>
}
// ...

可以看到自定义指令最核心的就是 “钩子函数” 了,接下来来了解这部分的知识点。

钩子函数​

和 组件的生命周期 类似,自定义指令里的逻辑代码也有一些特殊的调用时机,在这里称之为钩子函数:

钩子函数调用时机
created在绑定元素的 attribute 或事件侦听器被应用之前调用
beforeMount当指令第一次绑定到元素并且在挂载父组件之前调用
mounted在绑定元素的父组件被挂载后调用
beforeUpdate在更新包含组件的 VNode 之前调用
updated在包含组件的 VNode 及其子组件的 VNode 更新后调用
beforeUnmount在卸载绑定元素的父组件之前调用
unmounted当指令与元素解除绑定且父组件已卸载时,只调用一次

TIP

因为自定义指令的默认写法是一个对象,所以在代码风格上遵循 Options API 的生命周期命名,而非 Vue 3 的 Composition API 风格。

钩子函数在用法上就是这样子:

ts

const myDirective = {
  created(el, binding, vnode, prevVnode) {
    // ...
  },
  mounted(el, binding, vnode, prevVnode) {
    // ...
  },
  // 其他钩子...
}

在 相关的 TS 类型 已了解,每个钩子函数都有 4 个入参:

参数作用
el指令绑定的 DOM 元素,可以直接操作它
binding一个对象数据,见下方的单独说明
vnodeel 对应在 Vue 里的虚拟节点信息
prevVNodeUpdate 时的上一个虚拟节点信息,仅在 beforeUpdate 和 updated 可用

其中用的最多是 el 和 binding 。

  • el 的值就是通过 document.querySelector 拿到的那个 DOM 元素。

  • binding 是一个对象,里面包含了以下属性:

属性作用
value传递给指令的值,例如 v-foo="bar" 里的 bar ,支持任意有效的 JS 表达式
oldValue指令的上一个值,仅对 beforeUpdate 和 updated 可用
arg传给指令的参数,例如 v-foo:bar 里的 bar
modifiers传给指令的修饰符,例如 v-foo.bar 里的 bar
instance使用指令的组件实例
dir指令定义的对象(就是上面的 const myDirective = { /* ... */ } 这个对象)

在了解了指令的写法和参数作用之后,来看看如何注册一个自定义指令。

局部注册​

自定义指令可以在单个组件内定义并使用,通过和 setup 函数 同级别的 directives 选项进行定义,可以参考下面的例子和注释:

vue

<template>
  <!-- 这个使用默认值 `unset` -->
  <div v-highlight>{{ msg }}</div>

  <!-- 这个使用传进去的黄色 -->
  <div v-highlight="`yellow`">{{ msg }}</div>
</template>

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

export default defineComponent({
  // 自定义指令在这里编写,和 `setup` 同级别
  directives: {
    // `directives` 下的每个字段名就是指令名称
    highlight: {
      // 钩子函数
      mounted(el, binding) {
        el.style.backgroundColor =
          typeof binding.value === 'string' ? binding.value : 'unset'
      },
    },
  },
  setup() {
    const msg = ref<string>('Hello World!')

    return {
      msg,
    }
  },
})
</script>

上面是对象式的写法,也可以写成函数式:

ts

export default defineComponent({
  directives: {
    highlight(el, binding) {
      el.style.backgroundColor =
        typeof binding.value === 'string' ? binding.value : 'unset'
    },
  },
})

TIP

局部注册的自定义指令,默认在子组件内生效,子组件内无需重新注册即可使用父组件的自定义指令。

全局注册​

自定义指令也可以注册成全局,这样就无需在每个组件里定义了,只要在入口文件 main.ts 里启用它,任意组件里都可以使用自定义指令。

请查看 开发本地 Vue 专属插件 一节的内容了解如何注册一个全局的自定义指令插件。

deep 选项​

除了 钩子函数 ,在 相关的 TS 类型 里还可以看到有一个 deep 选项,它是一个布尔值,作用是:

如果自定义指令用于一个有嵌套属性的对象,并且需要在嵌套属性更新的时候触发 beforeUpdate 和 updated 钩子,那么需要将这个选项设置为 true 才能够生效。

vue

<template>
  <div v-foo="foo"></div>
</template>

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

export default defineComponent({
  directives: {
    foo: {
      beforeUpdate(el, binding) {
        console.log('beforeUpdate', binding)
      },
      updated(el, binding) {
        console.log('updated', binding)
      },
      mounted(el, binding) {
        console.log('mounted', binding)
      },
      // 需要设置为 `true` ,如果是 `false` 则不会触发
      deep: true,
    },
  },
  setup() {
    // 定义一个有嵌套属性的对象
    const foo = reactive({
      bar: {
        baz: 1,
      },
    })

    // 2s 后修改其中一个值,会触发 `beforeUpdate` 和 `updated`
    setTimeout(() => {
      foo.bar.baz = 2
      console.log(foo)
    }, 2000)

    return {
      foo,
    }
  },
})
</script>

插槽​

Vue 在使用子组件的时候,子组件在 template 里类似一个 HTML 标签,可以在这个子组件标签里传入任意模板代码以及 HTML 代码,这个功能就叫做 “插槽” 。

默认插槽​

默认情况下,子组件使用 <slot /> 标签即可渲染父组件传下来的插槽内容,例如:

在父组件这边:

vue

<template>
  <Child>
    <!-- 注意这里,子组件标签里面传入了 HTML 代码 -->
    <p>这是插槽内容</p>
  </Child>
</template>

<script lang="ts">
import { defineComponent } from 'vue'
import Child from '@cp/Child.vue'

export default defineComponent({
  components: {
    Child,
  },
})
</script>

在子组件这边:

vue

<template>
  <slot />
</template>

默认插槽非常简单,一个 <slot /> 就可以了。

具名插槽​

有时候可能需要指定多个插槽,例如一个子组件里有 “标题” 、 “作者”、 “内容” 等预留区域可以显示对应的内容,这时候就需要用到具名插槽来指定不同的插槽位。

子组件通过 name 属性来指定插槽名称:

vue

<template>
  <!-- 显示标题的插槽内容 -->
  <div class="title">
    <slot name="title" />
  </div>

  <!-- 显示作者的插槽内容 -->
  <div class="author">
    <slot name="author" />
  </div>

  <!-- 其他插槽内容放到这里 -->
  <div class="content">
    <slot />
  </div>
</template>

父组件通过 template 标签绑定 v-slot:name 格式的属性,来指定传入哪个插槽里:

vue

<template>
  <Child>
    <!-- 传给标题插槽 -->
    <template v-slot:title>
      <h1>这是标题</h1>
    </template>

    <!-- 传给作者插槽 -->
    <template v-slot:author>
      <h1>这是作者信息</h1>
    </template>

    <!-- 传给默认插槽 -->
    <p>这是插槽内容</p>
  </Child>
</template>

v-slot:name 有一个别名 #name 语法,上面父组件的代码也相当于:

vue

<template>
  <Child>
    <!-- 传给标题插槽 -->
    <template #title>
      <h1>这是标题</h1>
    </template>

    <!-- 传给作者插槽 -->
    <template #author>
      <h1>这是作者信息</h1>
    </template>

    <!-- 传给默认插槽 -->
    <p>这是插槽内容</p>
  </Child>
</template>

TIP

在使用具名插槽的时候,子组件如果不指定默认插槽,那么在具名插槽之外的内容将不会被渲染。

默认内容​

可以给 slot 标签添加内容,例如 <slot>默认内容</slot> ,当父组件没有传入插槽内容时,会使用默认内容来显示,默认插槽和具名插槽均支持该功能。

注意事项​

有一条规则需要记住:

  • 父组件里的所有内容都是在父级作用域中编译的
  • 子组件里的所有内容都是在子作用域中编译的

CSS 样式与预处理器​

Vue 组件的 CSS 样式部分,Vue 3 保留着和 Vue 2 完全一样的写法。

编写组件样式表​

最基础的写法,就是在 .vue 文件里添加一个 <style /> 标签,即可在里面写 CSS 代码了。

vue

<template>
  <div>
    <!-- HTML 代码 -->
  </div>
</template>

<script lang="ts">
  // TypeScript 代码
</script>

<style>
/* CSS 代码 */
.msg {
  width: 100%;
}
.msg p {
  color: #333;
  font-size: 14px;
}
</style>

动态绑定 CSS​

动态绑定 CSS ,在 Vue 2 就已经存在了,在此之前常用的是 :class 和 :style ,现在在 Vue 3 ,还可以通过 v-bind 来动态修改了。

使用 :class 动态修改样式名​

它是绑定在 DOM 元素上面的一个属性,跟 class="class-name" 这样的属性同级别,它非常灵活!

TIP

使用 :class 是用来动态修改样式名,也就意味着必须提前把样式名对应的样式表先写好!

假设已经提前定义好了这几个变量:

vue

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

export default defineComponent({
  setup() {
    const activeClass = 'active-class'
    const activeClass1 = 'active-class1'
    const activeClass2 = 'active-class2'
    const isActive = true

    return {
      activeClass,
      activeClass1,
      activeClass2,
      isActive,
    }
  },
})
</script>

如果只想绑定一个单独的动态样式,可以传入一个字符串:

vue

<template>
  <p :class="activeClass">Hello World!</p>
</template>

如果有多个动态样式,也可以传入一个数组:

vue

<template>
  <p :class="[activeClass1, activeClass2]">Hello World!</p>
</template>

还可以对动态样式做一些判断,这个时候传入一个对象:

vue

<template>
  <p :class="{ 'active-class': isActive }">Hello World!</p>
</template>

多个判断的情况下,记得也用数组套起来:

vue

<template>
  <p :class="[{ activeClass1: isActive }, { activeClass2: !isActive }]">
    Hello World!
  </p>
</template>

那么什么情况下会用到 :class 呢?

最常见的场景,应该就是导航、选项卡了,比如要给一个当前选中的选项卡做一个突出高亮的状态,那么就可以使用 :class 来动态绑定一个样式。

vue

<template>
  <ul class="list">
    <li
      class="item"
      :class="{ cur: index === curIndex }"
      v-for="(item, index) in 5"
      :key="index"
      @click="curIndex = index"
    >
      {{ item }}
    </li>
  </ul>
</template>

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

export default defineComponent({
  setup() {
    const curIndex = ref<number>(0)

    return {
      curIndex,
    }
  },
})
</script>

<style scoped>
.cur {
  color: red;
}
</style>

这样就简单实现了一个点击切换选项卡高亮的功能。

使用 :style 动态修改内联样式​

如果觉得使用 :class 需要提前先写样式,再去绑定样式名有点繁琐,有时候只想简简单单的修改几个样式,那么可以通过 :style 来处理。

默认的情况下,都是传入一个对象去绑定:

  • key 是符合 CSS 属性名的 “小驼峰式” 写法,或者套上引号的短横线分隔写法(原写法),例如在 CSS 里,定义字号是 font-size ,那么需要写成 fontSize 或者 'font-size' 作为它的键。

  • value 是 CSS 属性对应的 “合法值”,比如要修改字号大小,可以传入 13px 、0.4rem 这种带合法单位字符串值,但不可以是 13 这样的缺少单位的值,无效的 CSS 值会被过滤不渲染。

vue

<template>
  <p
    :style="{
      fontSize: '13px',
      'line-height': 2,
      color: '#ff0000',
      textAlign: 'center',
    }"
  >
    Hello World!
  </p>
</template>

如果有些特殊场景需要绑定多套 style,需要在 script 先定义好各自的样式变量(也是符合上面说到的那几个要求的对象),然后通过数组来传入:

vue

<template>
  <p :style="[style1, style2]">Hello World!</p>
</template>

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

export default defineComponent({
  setup() {
    const style1 = {
      fontSize: '13px',
      'line-height': 2,
    }
    const style2 = {
      color: '#ff0000',
      textAlign: 'center',
    }

    return {
      style1,
      style2,
    }
  },
})
</script>
使用 v-bind 动态修改 style​

当然,以上两种形式都是关于 <script /> 和 <template /> 部分的操作,如果觉得会给模板带来一定的维护成本的话,不妨考虑这个新方案,将变量绑定到 <style /> 部分去。

TIP

请注意这是一个在 3.2.0 版本之后才被归入正式队列的新功能!如果需要使用它,请确保的 vue 的版本号在 3.2.0 以上,最好是保持最新版本。

先来看看基本的用法:

vue

<template>
  <p class="msg">Hello World!</p>
</template>

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

export default defineComponent({
  setup() {
    const fontColor = ref<string>('#ff0000')

    return {
      fontColor,
    }
  },
})
</script>

<style scoped>
.msg {
  color: v-bind(fontColor);
}
</style>

如上面的代码,将渲染出一句红色文本的 Hello World!

这其实是利用了现代浏览器支持的 CSS 变量来实现的一个功能(所以如果打算用它的话,需要提前注意一下兼容性噢,点击查看:CSS Variables 兼容情况 )。

它渲染到 DOM 上,其实也是通过绑定 style 来实现,可以看到渲染出来的样式是:

html

<p class="msg" data-v-7eb2bc79="" style="--7eb2bc79-fontColor:#ff0000;">
  Hello World!
</p>

对应的 CSS 变成了:

css

.msg[data-v-7eb2bc79] {
  color: var(--7eb2bc79-fontColor);
}

理论上 v-bind 函数可以在 Vue 内部支持任意的 JavaScript 表达式,但由于可能包含在 CSS 标识符中无效的字符,因此官方是建议在大多数情况下,用引号括起来,如:

css

.text {
  font-size: v-bind('theme.font.size');
}

由于 CSS 变量的特性,因此对 CSS 响应式属性的更改不会触发模板的重新渲染(这也是和 :class 与 :style 的最大不同)。

TIP

不管有没有开启 <style scoped> ,使用 v-bind 渲染出来的 CSS 变量,都会带上 scoped 的随机 hash 前缀,避免样式污染(永远不会意外泄漏到子组件中),所以请放心使用!

如果对 CSS 变量的使用还不是很了解的话,可以先阅读一下相关的基础知识点。

相关阅读:使用 CSS 自定义属性(变量) - MDN

样式表的组件作用域​

CSS 不像 JS ,是没有作用域的概念的,一旦写了某个样式,直接就是全局污染。所以 BEM 命名法 等规范才应运而生。

但在 Vue 组件里,有两种方案可以避免出现这种污染问题:一个是 Vue 2 就有的 <style scoped> ,一个是 Vue 3 新推出的 <style module> 。

Style Scoped​

Vue 组件在设计的时候,就想到了一个很优秀的解决方案,通过 scoped 来支持创建一个 CSS 作用域,使这部分代码只运行在这个组件渲染出来的虚拟 DOM 上。

使用方式很简单,只需要在 <style /> 上添加 scoped 属性:

vue

<!-- 注意这里多了一个 `scoped` -->
<style scoped>
.msg {
  width: 100%;
}
.msg p {
  color: #333;
  font-size: 14px;
}
</style>

编译后,虚拟 DOM 都会带有一个 data-v-xxxxx 这样的属性,其中 xxxxx 是一个随机生成的 Hash ,同一个组件的 Hash 是相同并且唯一的:

html

<div class="msg" data-v-7eb2bc79>
  <p data-v-7eb2bc79>Hello World!</p>
</div>

而 CSS 则也会带上与 HTML 相同的属性,从而达到样式作用域的目的。

css

.msg[data-v-7eb2bc79] {
  width: 100%;
}
.msg p[data-v-7eb2bc79] {
  color: #333;
  font-size: 14px;
}

使用 scoped 可以有效的避免全局样式污染,可以在不同的组件里面都使用相同的 className,而不必担心会相互覆盖,不必再定义很长很长的样式名来防止冲突了。

TIP

添加 scoped 生成的样式,只作用于当前组件中的元素,并且权重高于全局 CSS ,可以覆盖全局样式

Style Module​

这是在 Vue 3 才推出的一个新方案,和 <style scoped> 不同,scoped 是通过给 DOM 元素添加自定义属性的方式来避免冲突,而 <style module> 则更为激进,将会编译成 CSS Modules 。

对于 CSS Modules 的处理方式,也可以通过一个小例子来更直观的了解它:

css

/* 案例来自阮一峰老师的博文《CSS Modules 用法教程》 */
/* https://www.ruanyifeng.com/blog/2016/06/css_modules.html */

/* 编译前 */
.title {
  color: red;
}

/* 编译后 */
._3zyde4l1yATCOkgn-DBWEL {
  color: red;
}

可以看出,是通过比较 “暴力” 的方式,把编写的 “好看的” 样式名,直接改写成一个随机 Hash 样式名,来避免样式互相污染。

所以回到 Vue 这边,看看 <style module> 是怎么操作的。

vue

<template>
  <p :class="$style.msg">Hello World!</p>
</template>

<style module>
.msg {
  color: #ff0000;
}
</style>

于是,将渲染出一句红色文本的 Hello World! 。

TIP

  1. 使用这个方案,需要了解如何 使用 :class 动态修改样式名

  2. 如果单纯只使用 <style module> ,那么在绑定样式的时候,是默认使用 $style 对象来操作的

  3. 必须显示的指定绑定到某个样式,比如 $style.msg ,才能生效

  4. 如果单纯的绑定 $style ,并不能得到 “把全部样式名直接绑定” 的期望结果

  5. 如果指定的 className 是短横杆命名,比如 .user-name ,那么需要通过 $style['user-name'] 去绑定

也可以给 module 进行命名,然后就可以通过命名的 “变量名” 来操作:

vue

<template>
  <p :class="classes.msg">Hello World!</p>
</template>

<style module="classes">
.msg {
  color: #ff0000;
}
</style>

TIP

需要注意的一点是,一旦开启 <style module> ,那么在 <style module> 里所编写的样式都必须手动绑定才能生效,没有被绑定的样式虽然也会被编译,但不会主动生效到 DOM 上。

原因是编译出来的样式名已经变化,而原来的 DOM 未指定对应的样式名,或者指定的是编译前的命名,所以并不能匹配到正确的样式。

useCssModule​

这是一个全新的 API ,面向在 script 部分操作 CSS Modules 。

在上面的 CSS Modules 部分可以知道,可以在 style 定义好样式,然后在 template 部分通过变量名来绑定样式。

那么如果有一天有个需求,需要通过 v-html 来渲染 HTML 代码,那这里的样式岂不是凉凉了?当然不会!

Vue 3 提供了一个 Composition API useCssModule 来帮助在 setup 函数里操作的 CSS Modules (对,只能在 setup 或者 script setup 里使用)。

基本用法:

多绑定几个样式,再来操作:

vue

<template>
  <p :class="$style.msg">
    <span :class="$style.text">Hello World!</span>
  </p>
</template>

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

export default defineComponent({
  setup() {
    const style = useCssModule()
    console.log(style)
  },
})
</script>

<style module>
.msg {
  color: #ff0000;
}
.text {
  font-size: 14px;
}
</style>

可以看到打印出来的 style 是一个对象:

  • key 是在 <style modules> 里定义的原始样式名

  • value 则是编译后的新样式名

js

{
  msg: 'home_msg_37Xmr',
  text: 'home_text_2woQJ'
}

所以来配合 模板字符串 的使用,看看刚刚说的,要通过 v-html 渲染出来的内容应该如何绑定样式:

vue

<template>
  <div v-html="content"></div>
</template>

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

export default defineComponent({
  setup() {
    // 获取样式
    const style = useCssModule()

    // 编写模板内容
    const content = `<p class="${style.msg}">
      <span class="${style.text}">Hello World! —— from v-html</span>
    </p>`

    return {
      content,
    }
  },
})
</script>

<style module>
.msg {
  color: #ff0000;
}
.text {
  font-size: 14px;
}
</style>

是不是也非常简单?可能刚开始不太习惯,但写多几次其实这个功能也蛮好玩的!

另外,需要注意的是,如果指定了 modules 的名称,那么必须传入对应的名称作为入参才可以正确拿到这些样式:

比如指定了一个 classes 作为名称:

vue

<style module="classes">
/* ... */
</style>

那么需要通过传入 classes 这个名称才能拿到样式,否则会是一个空对象:

ts

const style = useCssModule('classes')

TIP

在 const style = useCssModule() 的时候,命名是随意的,跟在 <style module="classes"> 这里指定的命名没有关系。

深度操作符​

在 样式表的组件作用域 部分了解到,使用 scoped 后,父组件的样式将不会渗透到子组件中,也不能直接修改子组件的样式。

如果确实需要修改子组件的样式,必须通过 ::v-deep(完整写法) 或者 :deep(快捷写法) 操作符来实现。

TIP

  1. 旧版的深度操作符是 >>> 、 /deep/ 和 ::v-deep,现在 >>> 和 /deep/ 已进入弃用阶段(虽然暂时还没完全移除)

  2. 同时需要注意的是,旧版 ::v-deep 的写法是作为组合器的方式,写在样式或者元素前面,如:::v-deep .class-name { /* ... */ },现在这种写法也废弃了。

现在不论是 ::v-deep 还是 :deep ,使用方法非常统一,来假设 .b 是子组件的样式名:

vue

<style scoped>
.a :deep(.b) {
  /* ... */
}
</style>

编译后:

css

.a[data-v-f3f3eg9] .b {
  /* ... */
}

TIP

可以看到,新的 deep 写法是作为一个类似 JS “函数” 那样去使用,需要深度操作的样式或者元素名,作为 “入参” 去传入。

同理,如果使用 Less 或者 Stylus 这种支持嵌套写法的预处理器,也是可以这样去深度操作的:

less

.a {
  :deep(.b) {
    /* ... */
  }
}

另外,除了操作子组件的样式,那些通过 v-html 创建的 DOM 内容,也不受作用域内的样式影响,也可以通过深度操作符来实现样式修改。

使用 CSS 预处理器​

在工程化的现在,可以说前端都几乎不写 CSS 了,都是通过 sasslessstylus 等 CSS 预处理器来完成样式的编写。

为什么要用 CSS 预处理器?放一篇关于三大预处理器的点评,新开发者可以做个简单了解,具体的用法在对应的官网上有非常详细的说明。

可以查看了解:浅谈 CSS 预处理器,Sass、Less 和 Stylus

在 Vue 组件里使用预处理器非常简单,像 Vite 已内置了对预处理器文件的支持(可处理 .less 、 .scss 之类的预处理器扩展名文件),因此只需要安装对应的依赖到项目里。

这里以 Less 为例,先安装该预处理器:

bash

# 因为是在开发阶段使用,所以添加到 `devDependencies`
npm i -D less

接下来在 Vue 组件里,只需要在 <style /> 标签上,通过 lang="less" 属性指定使用哪个预处理器,即可直接编写对应的代码:

vue

<style lang="less" scoped>
// 定义颜色变量
@color-black: #333;
@color-red: #ff0000;

// 父级标签
.msg {
  width: 100%;
  // 其子标签可以使用嵌套写法
  p {
    color: @color-black;
    font-size: 14px;
    // 支持多级嵌套
    span {
      color: @color-red;
    }
  }
}
</style>

编译后的 css 代码:

css

.msg {
  width: 100%;
}
.msg p {
  color: #333333;
  font-size: 14px;
}
.msg p span {
  color: #ff0000;
}

预处理器也支持 scoped,用法请查阅 样式表的组件作用域 部分。


http://www.kler.cn/a/306371.html

相关文章:

  • 3.5【数据库系统】ER图
  • 初始JavaEE篇 —— 网络编程(2):了解套接字,从0到1实现回显服务器
  • Java复习42(PTA)
  • 使用ookii-dialogs-wpf在WPF选择文件夹时能输入路径
  • 5G 现网信令参数学习(3) - RrcSetup(1)
  • 力扣 LeetCode 27. 移除元素(Day1:数组)
  • Vue:使用v-model绑定的textarea在光标处插入指定文本
  • 爬虫代理API的全面解析:让数据抓取更高效
  • vue part 10
  • ctfshow-web入门-sql注入-web248-UDF 注入
  • Luban策划开源工具
  • 【Vue】- Vue应用
  • RZ7888电机驱动芯片
  • 【2023年】云计算金砖牛刀小试5
  • GitLab权限及设置
  • 【Git】Clone
  • 人工智能(AI)正在以前所未有的速度融入我们生活的方方面面
  • 基于AgentUniverse在金融场景中的多智能体应用探索
  • 学习记录:js算法(三十四):合并 K 个升序链表
  • 计算机网络 ---- 电路交换、报文交换、分组交换
  • 开发后台管理系统-开发环境搭建
  • 【STM32】esp8266通过MQTT连接服务器|订阅发布
  • 10分钟在网站上增加一个AI助手
  • 深入理解 ECMAScript 和 JavaScript
  • 服务器断电重启后报XFS文件系统错误 XFS (dm-0)_ Metadata I_O error
  • Android系列基础知识总结