vue3组合式api

组合式API介绍

什么是组合式 API?

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

  • 响应式 API:例如 ref()reactive(),使我们可以直接创建响应式状态、计算属性和侦听器。
  • 生命周期钩子:例如 onMounted()onUnmounted(),使我们可以在组件各个生命周期阶段添加逻辑。
  • 依赖注入:例如 provide()inject(),使我们可以在使用响应式 API 时,利用 Vue 的依赖注入系统。

组合式 API 是 Vue 3 及 Vue 2.7 的内置功能。对于更老的 Vue 2 版本,可以使用官方维护的插件 @vue/composition-api。在 Vue 3 中,组合式 API 基本上都会配合

虽然这套 API 的风格是基于函数的组合,但组合式 API 并不是函数式编程。组合式 API 是以 Vue 中数据可变的、细粒度的响应性系统为基础的,而函数式编程通常强调数据不可变。

为什么要有组合式 API?

更好的逻辑复用

组合式 API 最基本的优势是它使我们能够通过组合函数来实现更加简洁高效的逻辑复用。在选项式 API 中我们主要的逻辑复用机制是 mixins,而组合式 API 解决了 mixins 的所有缺陷。

组合式 API 提供的逻辑复用能力孵化了一些非常棒的社区项目,比如 VueUse,一个不断成长的工具型组合式函数集合。组合式 API 还为其他第三方状态管理库与 Vue 的响应式系统之间的集成提供了一套简洁清晰的机制,例如 RxJS。

更灵活的代码组织

许多用户喜欢选项式 API 的原因是它在默认情况下就能够让人写出有组织的代码:大部分代码都自然地被放进了对应的选项里。然而,选项式 API 在单个组件的逻辑复杂到一定程度时,会面临一些无法忽视的限制。这些限制主要体现在需要处理多个逻辑关注点的组件中,这是我们在许多 Vue 2 的实际案例中所观察到的。

我们以 Vue CLI GUI 中的文件浏览器组件为例:这个组件承担了以下几个逻辑关注点:

  • 追踪当前文件夹的状态,展示其内容
  • 处理文件夹的相关操作 (打开、关闭和刷新)
  • 支持创建新文件夹
  • 可以切换到只展示收藏的文件夹
  • 可以开启对隐藏文件夹的展示
  • 处理当前工作目录中的变更

Option Api

选项式API(Option Api)需要在特定的区域(data,methods,watch,computed…)编写负责相同功能的代码。如果我们为相同的逻辑关注点标上一种颜色,那将会是这样:

Option Api的缺陷

你可以看到,处理相同逻辑关注点的代码被强制拆分在了不同的选项中,位于文件的不同部分。在一个几百行的大组件中,要读懂代码中的一个逻辑关注点,需要在文件中反复上下滚动,这并不理想。

另外,如果我们想要将一个逻辑关注点抽取重构到一个可复用的工具函数中,需要从文件的多个不同部分找到所需的正确片段。

Composition Api

使用传统的option选项写组件的时候问题,随着业务复杂度越来越高,代码量会不断的加大;由于相关业务的代码需要遵循option的配置写到特定的区域,导致后续维护非常的复杂,同时代码可复用性不高。

这种碎片化使得理解和维护复杂组件变得困难。选项的分离掩盖了潜在的逻辑问题。此外,在处理单个业务逻辑时,我们必须不断地“跳转”相关代码的选项块。

如果能够将同一个业务逻辑相关代码收集在一起会更好。而这正是组合式 API 使我们能够做到的。而组合式API就是为了解决这个问题而生的。

Composition API字面意思是组合式API,它是为了实现基于函数的逻辑复用机制而产生的。主要思想是,我们将它们定义为从新的setup函数返回的JavaScript变量,而不是将组件的功能(例如method、computed等)定义为对象属性。

使用组合式API,我们可以更加优雅的组织我们的代码,函数,让相关功能的代码更加有序的组织在一起。如果用组合式 API(Composition Api) 重构这个组件,将会变成下面右边这样:

现在与同一个逻辑关注点相关的代码被归为了一组:我们无需再为了一个逻辑关注点在不同的选项块间来回滚动切换。此外,我们现在可以很轻松地将这一组代码移动到一个外部文件中,不再需要为了抽象而重新组织代码,大大降低了重构成本,这在长期维护的大型项目中非常关键。

更好的类型推导

近几年来,越来越多的开发者开始使用 TypeScript 书写更健壮可靠的代码,TypeScript 还提供了非常好的 IDE 开发支持。然而选项式 API 是在 2013 年被设计出来的,那时并没有把类型推导考虑进去,因此我们不得不做了一些复杂到夸张的类型体操才实现了对选项式 API 的类型推导。但尽管做了这么多的努力,选项式 API 的类型推导在处理 mixins 和依赖注入类型时依然不甚理想。

因此,很多想要搭配 TS 使用 Vue 的开发者采用了由 vue-class-component 提供的 Class API。然而,基于 Class 的 API 非常依赖 ES 装饰器,在 2019 年我们开始开发 Vue 3 时,它仍是一个仅处于 stage 2 的语言功能。我们认为基于一个不稳定的语言提案去设计框架的核心 API 风险实在太大了,因此没有继续向 Class API 的方向发展。在那之后装饰器提案果然又发生了很大的变动,在 2022 年才终于到达 stage 3。另一个问题是,基于 Class 的 API 和选项式 API 在逻辑复用和代码组织方面存在相同的限制。

相比之下,组合式 API 主要利用基本的变量和函数,它们本身就是类型友好的。用组合式 API 重写的代码可以享受到完整的类型推导,不需要书写太多类型标注。大多数时候,用 TypeScript 书写的组合式 API 代码和用 JavaScript 写都差不太多!这也让许多纯 JavaScript 用户也能从 IDE 中享受到部分类型推导功能。

更小的生产包体积

搭配

reactive 的局限性

reactive() API 有两条限制:

  1. 仅对对象类型有效(对象、数组和 MapSet 这样的集合类型),而对 stringnumberboolean 这样的 原始类型 无效。

  2. 因为 Vue 的响应式系统是通过属性访问进行追踪的,因此我们必须始终保持对该响应式对象的相同引用。这意味着我们不可以随意地“替换”一个响应式对象,因为这将导致对初始引用的响应性连接丢失:

    let state = reactive({ count: 0 })
    
    // 给state重新赋值值一个响应式对象,上面的引用 ({ count: 0 }) 将不再被追踪(响应性连接已丢失!)
    state = reactive({ count: 1 })
    

reactive 响应式丢失的情况

当我们将响应式对象的属性赋值或解构至本地变量时,或是将该属性传入一个函数时,我们会失去响应性:

  • 将响应式对象的属性赋值至本地变量失去响应性

    const state = reactive({ count: 0 })
    
    // n 是一个局部变量,n 和 state.count,失去响应性连接
    let n = state.count
    // 不影响原始的 state,改变的仅仅是局部变量n的值,将无法跟踪 state.count 的变化
    n++;
    console.log('n: ', n); // 1
    console.log('state.count: ', state.count); // 0
    
  • 将响应式对象的属性解构至本地变量失去响应性

    const state = reactive({ count: 0 })
    // count 是一个局部变量,count 也和 state.count 失去了响应性连接
    let { count } = state
    // 不会影响原始的 state,改变的仅仅是局部变量count的值,将无法跟踪 state.count 的变化
    count++
    console.log('count: ', count); // 1
    console.log('state.count: ', state.count); // 0
    
  • 将响应式对象的属性传入一个函数失去响应式

    const state = reactive({ count: 6 });
    
    function doubleTen(data) {
      data *= 10;
      return data * 2;
    }
    
    // doubleTen 函数接收一个普通数字,并且将无法跟踪 state.count 的变化
    console.log(doubleTen(state.count)); // 120
    console.log(state.count); // 6
    

响应式代理 vs. 原始对象

响应式对象其实是 JavaScript Proxy,其行为表现与一般对象相似。不同之处在于 Vue 能够跟踪对响应式对象属性的访问与更改操作。所以reactive() 返回的是一个原始对象的 Proxy,它和原始对象是不相等的:

const obj = {}
const proxyObj = reactive(obj)

// 代理对象和原始对象不是全等的
console.log(proxyObj === obj) // false

只有代理对象是响应式的,更改原始对象不会触发更新。因此,使用 Vue 的响应式系统的最佳实践是 仅使用你声明对象的代理版本

为保证访问代理的一致性,对同一个原始对象调用 reactive() 会总是返回同样的代理对象,而对一个已存在的代理对象调用 reactive() 会返回其本身:

// 在同一个对象上调用 reactive() 会返回相同的代理
console.log(reactive(obj) === proxyObj) // true

// 在一个代理上调用 reactive() 会返回它自己
console.log(reactive(proxyObj) === proxyObj) // true

这个规则对嵌套对象也适用。依靠深层响应性,响应式对象内的嵌套对象依然是代理:

const proxyObj = reactive({})

const obj = {}
proxyObj.nested = obj

console.log(proxyObj.nested === obj) // false

用ref定义响应式变量

ref的概念

reactive() 的种种限制归根结底是因为 JavaScript 没有可以作用于所有值类型的 “引用” 机制。为此,Vue 提供了一个 ref() 方法来允许我们创建可以使用任何值类型的响应式 ref

ref 接受一个任意类型的内部值,返回一个响应式的、可更改的 ref 对象,此对象只有一个指向其内部值的属性 .value

  • ref 对象是可更改的,可以为 .value 赋予新的值。ref 的 .value 属性也是响应式的,即所有对 .value 的操作都将被追踪,并且写操作会触发与之相关的副作用。

  • 如果将一个对象赋值给 ref,那么这个对象将通过 reactive() 转为具有深层次响应式的对象。这也意味着如果对象中包含了嵌套的 ref,它们将被深层地解包。

  • 若要避免这种深层次的转换,请使用 shallowRef() 来替代。

值类型的ref

当值为值类型时,会用Object.defineProperty() 添加 get()set() 实现响应式:




对象类型的ref

当值为对象类型时,会用 reactive() 自动转换它的 .value,实现响应式:




  • ref 被传递给函数,不会丢失响应性:

    const count = ref(1);
    
    // 该函数接收一个 ref,需要通过 .value 取值,但它会保持响应性
    function doubleTen(data) {
    	data.value *= 10;
    	return data.value * 2;
    }
    
    console.log(doubleTen(count)); //  20
    console.log(count.value); //  10
    
  • ref 从一般对象上被解构时,不会丢失响应性:

    const obj = {
      foo: ref(1),
      bar: ref(2),
    };
    
    // 使用解构仍然是响应式的
    const { foo, bar } = obj
    
    // 该函数接收一个 ref,需要通过 .value 取值,但它会保持响应性
    function doubleTen(data) {
    	data.value *= 10;
    	return data.value * 2;
    }
    console.log(doubleTen(foo)); //  20
    console.log(foo.value); //  10
    

简言之,ref() 让我们能创造一种对任意值的 “引用”,并能够在不丢失响应性的前提下传递这些引用。这个功能很重要,因为它经常用于将逻辑提取到 组合函数 中。

ref的解包

ref 在模板中的解包

当 ref 在模板中作为顶层属性被访问时,它们会被自动“解包”,所以不需要使用 .value




请注意,仅当 ref 是模板渲染上下文的顶层属性时才适用自动“解包”。

上面的 count 是顶层属性,但下面例子的 obj.foo 不是顶层属性:

const obj = { foo: ref(1) }

console.log(obj.foo); // RefImpl {__v_isShallow: false, dep: undefined, __v_isRef: true, _rawValue: 1, _value: 1}

// 渲染的结果会是一个 [object Object]1,因为 obj.foo 是一个 ref 对象
console.log(obj.foo + 1); // [object Object]1

// 正确用法应该使用 obj.foo.value
console.log(obj.foo.value + 1); // 2


// 或者我们可以通过将 foo 改成顶层属性来解决这个问题:
const { foo } = obj;
// 现在渲染结果将是 2
console.log(foo + 1); // 2

需要注意的是,如果一个 ref 是文本插值(即一个 {{ }} 符号)计算的最终值,它也将被解包。因此在模板中可以单独使用 obj.foo 下面的渲染结果将为 1

{{ obj.foo }}

这只是文本插值的一个方便功能,相当于 {{ obj.foo.value }}

ref 在响应式对象中的解包

当一个 ref 被嵌套在一个响应式对象中,作为属性被访问或更改时,它会自动解包,因此会表现得和一般的属性一样:

const count = ref(0)
const state = reactive({
  foo: count,
})

console.log(state.foo) // 0

state.foo = 1
console.log(count.value) // 1

如果将一个新的 ref 赋值给一个关联了已有 ref 的属性,那么它会替换掉旧的 ref:

const count = ref(0)
const state = reactive({
  foo: count,
})

console.log(state.foo) // 0

const otherCount = ref(2)
state.foo = otherCount
console.log(state.foo) // 2

// 原始 count 现在已经和 state.foo 失去联系
console.log(count.value) // 1

只有当嵌套在一个深层响应式对象内时,才会发生 ref 解包。当其作为浅层响应式对象的属性被访问时不会解包。

数组和集合类型的 ref 解包

跟响应式对象不同,当 ref 作为响应式数组或像 Map 这种原生集合类型的元素被访问时,不会进行解包。

const books = reactive([ref('Vue 3 Guide')])
// 这里需要 .value
console.log(books[0].value)

const map = reactive(new Map([['count', ref(0)]]))
// 这里需要 .value
console.log(map.get('count').value)

数组声明与赋值的技巧

数组赋值存在的问题:使用reactive声明数组,给空数组赋值不能直接赋值,因为arr = [1, 2, 3]; 让arr失去了响应式。

let arr = reactive([]);
arr = [1, 2, 3]; // arr会失去响应式

解决方法如下:

方法一:使用ref声明数组,在组件模板中直接使用arr,在setup中需要使用arr.value

const arr = ref([]);
arr.value = [1, 2, 3];

方法二:使用reactive声明数组,使用的push方法添加新的元素

const arr = reactive([]);
arr.push(...[1, 2, 3]);

方法三:创建一个响应式对象,对象的属性是数组,给该属性直接赋值

const obj = reactive({
  arr: [],
})
obj.arr = [1, 2, 3];

reactive和ref的对比

ref的研究

  1. ref创建一个响应式数据,一般来说用于创建简单类型的响应式对象,比如String、Number、boolean类型;

  2. 当我们给ref传递一个值之后,如果使用的是基本类型响应式依赖 Object.defineProperty() get()set() ,如果ref使用的是引用类型,ref函数底层会自动将ref转换成reactive; ref(18) 等价于reactive({value:18});

  3. 需要注意的是ref定义的值在组件模板中使用直接使用所定义的字段,但是在setup函数中获取或者修改值需要通过value,当然还有一些自动解包的场景

  4. ref也可以创建引用类型,对于复杂的对象,值是一个被proxy拦截处理过的对象,但是里面的属性不是RefImpl类型的对象,proxy代理的对象同样被挂载到value上,所以可以通过obj.value.key来读取属性,这些属性同样也是响应式的,更改时可以触发视图的更新

reactive研究

  1. reactive里面参数定义必须是对象或者数组(json/arr),本质将传入的数据包装成proxy对象;

  2. 基于Es6的Proxy实现,通过Reflect反射代理操作源对象,相比于reactive定义的浅层次响应式数据对象,reactive定义的是更深层次的响应式数据对象;

总结

  1. 一般来说,ref被用来定义简单的字符串或者数值,而reactive被用来定义对象数组等

  2. 实际上都能用,而且ref也可以去定义简单的对象和数组,也是具有响应式的,不过官方文档中有提到如果将对象分配为ref值,则可以通过reactive方法使该对象具有高度的响应式。

DOM 更新时机

当你更改响应式状态后,DOM 会自动更新。然而,你得注意 DOM 的更新并不是同步的。相反,Vue 将缓冲它们直到更新周期的 “下个时机” 以确保无论你进行了多少次状态更改,每个组件都只更新一次。

若要等待一个状态改变后的 DOM 更新完成,你可以使用 nextTick() 这个全局 API:




响应式工具函数

isRef()

检查某个值是否为 ref。

const count = ref(10);			
const result = isRef(count);
console.log(result); // true

unref()

如果参数是 ref,则返回内部值,否则返回参数本身。这是 val = isRef(val) ? val.value : val 计算的一个语法糖

const count = ref(10);			
const result = unref(count);
console.log(result); // 10

toRef()和toRefs()

toRef()

基于响应式对象上的一个属性,创建一个对应的 ref。这样创建的 ref 与其源属性保持同步:改变源属性的值将更新 ref 的值,反之亦然。

const state = reactive({
  foo: 1,
  bar: 2
})

const fooRef = toRef(state, 'foo')

// 更改该 ref 会更新源属性
fooRef.value++
console.log(state.foo) // 2

// 更改源属性也会更新该 ref
state.foo++
console.log(fooRef.value) // 3

请注意,这不同于:

const fooRef = ref(state.foo)

上面这个 ref 不会state.foo 保持同步,因为这个 ref() 接收到的是一个纯数值。

toRef() 这个函数在你想把一个 prop 的 ref 传递给一个组合式函数时会很有用:


toRef 与组件 props 结合使用时,关于禁止对 props 做出更改的限制依然有效。尝试将新的值传递给 ref 等效于尝试直接更改 props,这是不允许的。在这种场景下,你可能可以考虑使用带有 getsetcomputed 替代。详情请见在组件上使用 v-model 指南。

即使源属性当前不存在,toRef() 也会返回一个可用的 ref。这让它在处理可选 props 的时候格外实用,相比之下 toRefs 就不会为可选 props 创建对应的 refs。

toRefs()

将一个响应式对象转换为一个普通对象,这个普通对象的每个属性都是指向源对象相应属性的 ref。每个单独的 ref 都是使用 toRef() 创建的。

const state = reactive({
  foo: 1,
  bar: 2
})

const stateAsRefs = toRefs(state)

// 这个 ref 和源属性已经“链接上了”
state.foo++
console.log(stateAsRefs.foo.value) // 2

stateAsRefs.foo.value++
console.log(state.foo) // 3

当从组合式函数中返回响应式对象时,toRefs 相当有用。使用它,消费者组件可以解构/展开返回的对象而不会失去响应性:

function useFeatureX() {
  const state = reactive({
    foo: 1,
    bar: 2
  })

  // ...基于状态的操作逻辑

  // 在返回时都转为 ref
  return toRefs(state)
  
  // 等价于下面的写法
  return {
    ...toRefs(state)
  }
}

// 可以解构而不会失去响应性
const { foo, bar } = useFeatureX()

toRefs 在调用时只会为源对象上可以枚举的属性创建 ref。如果要为可能还不存在的属性创建 ref,请改用 toRef

toRef()和toRefs()的对比

  • toRef得到的结果是reactive对象中的某个属性转换为ref变量,并与原属性保持同步。如果该属性在原对象上不存在,会创建出一个新的ref变量
  • toRefs得到的结果是reactive对象中的所有属性转换为ref变量,并与原属性保持同步。只会创建出原对象上存在的属性对应的ref变量,不会创建新的ref变量

有以下数据:

const zhangsan = reactive({
  name: '张三',
  age: 18,
});

使用toRef()的结果

  • 使用toRef(),根据zhagnsan对象的属性创建ref变量,和原属性保持同步
// 语义上等价于 const ageRef = ref(zhangsan.age); 把 zhangsan.age 转换为一个ref变量
// 但是,直接使用 ref 创建 ageRef,ageRef 与 zhangsan.age 的之间的关联将会丢失
const ageRef = toRef(zhangsan, 'age');
console.log('ageRef.value: ', ageRef.value);// ageRef.value:  18

zhangsan.age++;
console.log('zhangsan.age: ', zhangsan.age); // zhangsan.age:  19
console.log('ageRef.value: ', ageRef.value); // ageRef.value:  19

ageRef.value++;
console.log('zhangsan.age: ', zhangsan.age); // zhangsan.age:  20
console.log('ageRef.value: ', ageRef.value); // ageRef.value:  20
  • 使用ref,根据zhagnsan对象的属性创建ref变量,不会和原属性保持同步,相当于创建了一个新的属性
const ageRef = ref(zhangsan.age);
console.log('ageRef.value: ', ageRef.value); // ageRef.value:  18

zhangsan.age++;
console.log('zhangsan.age: ', zhangsan.age); // zhangsan.age:  19
console.log('ageRef.value: ', ageRef.value); // ageRef.value:  18

ageRef.value++;
console.log('zhangsan.age: ', zhangsan.age); // zhangsan.age:  19
console.log('ageRef.value: ', ageRef.value); // ageRef.value:  19
  • 不存在的属性,得到新的ref变量
const weightRef = toRef(zhangsan, 'weight');
console.log('weightRef.value: ', weightRef.value);// weightRef.value:  undefined

使用toRefs()的结果

  • 使用toRefs,根据zhagnsan对象的属性age和name,得到ref属性:zhangsanRef.name 和 zhangsanRef.age
const zhangsanRef = toRefs(zhangsan);
console.log('zhangsanRef: ', zhangsanRef); // {name: ObjectRefImpl, age: ObjectRefImpl}
console.log('zhangsanRef.name.value: ', zhangsanRef.name.value); // zhangsanRef.name.value:  张三
console.log('zhangsanRef.age.value: ', zhangsanRef.age.value); // zhangsanRef.name.value:  18

zhangsan.age++;
console.log('zhangsan.age: ', zhangsan.age); // zhangsan.age:  19
console.log('zhangsanRef.age.value: ', zhangsanRef.age.value); // zhangsanRef.name.value:  19

zhangsanRef.age.value++;
console.log('zhangsan.age: ', zhangsan.age); // zhangsan.age:  20
console.log('zhangsanRef.age.value: ', zhangsanRef.age.value); // zhangsanRef.name.value:  20

torefs的妙用

在setup函数中的使用




在setup语法糖中的使用




isProxy()

检查一个对象是否是由 reactive()readonly()shallowReactive()shallowReadonly() 创建的代理。

const zhangsan = reactive({name: '张三' });
const result = isProxy(zhangsan);
console.log(result); // true

isReactive()

检查一个对象是否是由 reactive()shallowReactive() 创建的代理。

const zhangsan = reactive({name: '张三' });
const result = isReactive(zhangsan);
console.log(result); // true

isReadonly()

  • isReadonly() 检查传入的值是否为只读对象。只读对象的属性可以更改,但他们不能通过传入的对象直接赋值。

  • readonly() 接受一个对象 (不论是响应式还是普通的) 或是一个 ref,返回一个原值的只读代理。

    • 只读代理是深层的:对任何嵌套属性的访问都将是只读的。它的 ref 解包行为与 reactive() 相同,但解包得到的值是只读的。
    • 要避免深层级的转换行为,请使用 shallowReadonly() 作替代。

通过 readonly()shallowReadonly() 创建的代理都是只读的,因为他们是没有 set 函数的 computed() ref。

const result1 = isReadonly(zhangsan);
console.log(result1); // false

const zhangsanReadonly = readonly(zhangsan);
const result2 = isReadonly(zhangsanReadonly);
console.log(result2); // true

计算属性

只读计算属性

计算属性使用 computed() 实现,我们在这里定义了一个计算属性 totalresult




computed() 方法期望接收一个 getter 函数,返回值为一个计算属性 ref。和其他一般的 ref 类似,你可以通过 total.value 访问计算结果。计算属性 ref 也会在模板中自动解包,因此在模板表达式中引用时无需添加 .value

Vue 的计算属性会自动追踪响应式依赖。它会检测到 total 依赖于 chinese、math、english三个属性,所以当 chinese、math、english 任何一个属性改变时,任何依赖于 total 的绑定都会同时更新。

计算属性值会基于其响应式依赖被缓存。一个计算属性仅会在其响应式依赖更新时才重新计算。这意味着只要 chinese、math、english三个属性不改变,无论多少次访问 total 都会立即返回先前的计算结果,而不用重复执行 getter 函数。

可写计算属性

计算属性默认是只读的。当你尝试修改一个计算属性时,你会收到一个运行时警告。只在某些特殊场景中你可能才需要用到“可写”的属性,你可以通过同时提供 getter 和 setter 来创建:




侦听器

计算属性允许我们声明性地计算衍生值。然而在有些情况下,我们需要在状态变化时执行一些“副作用”:例如更改 DOM,或是根据异步操作的结果去修改另一处的状态。

在组合式 API 中,我们可以使用 watch 函数在每次响应式状态发生变化时触发回调函数。

watch

概念

watch()侦听一个或多个响应式数据源,并在数据源变化时调用所给的回调函数。

语法:watch(source, callback, options?)

  • 第一个参数是侦听器的。这个来源可以是以下几种:

    • 一个函数,返回一个值

    • 一个 ref

    • 一个响应式对象 reactive

    • …或是由以上类型的值组成的数组

  • 第二个参数是在发生变化时要调用的回调函数。这个回调函数接受三个参数:

    • 新值

    • 旧值

    • 以及一个用于注册副作用清理的回调函数。

      • 该回调函数会在副作用下一次重新执行前调用,可以用来清除无效的副作用,例如等待中的异步请求。
      • 当侦听多个来源时,回调函数接受两个数组,分别对应来源数组中的新值和旧值。
  • 第三个可选的参数是一个对象,支持以下这些选项:

    • immediate:在侦听器创建时立即触发回调。第一次调用时旧值是 undefined

    • deep:如果源是对象,强制深度遍历,以便在深层级变更时触发回调。

    • flush:调整回调函数的刷新时机。

    • onTrack / onTrigger:调试侦听器的依赖。

基本使用




  • 监听一个 ref
// 当count.value的值发生变化时,触发回调函数
watch(count, (newValue, oldValue) => {
	console.log('count: ', newValue, oldValue);
});

count.value++;
  • 监听一个响应式对象 reactive:当直接侦听一个响应式对象时,侦听器会自动启用深层模式
// zhangsan对象的任何一个属性发生变化时,都会触发回调函数
watch(zhangsan, (newValue, oldValue) => {
  console.log('zhangsan: ', newValue, oldValue);
});

zhangsan.age++;
  • 监听一个 getter 函数:当使用 getter 函数作为源时,回调只在此函数的返回值变化时才会触发。
// 当count.value的值发生变化时,触发回调函数
watch(() => count.value, (newValue, oldValue) => {
	console.log('count.value: ', newValue, oldValue);
});
count.value++;


// 当zhangsan.age的值发生变化时,触发回调函数
watch(() => zhangsan.age, (newValue, oldValue) => {
  console.log('zhangsan.age: ', newValue, oldValue);
});
zhangsan.age++;
  • 或是由以上类型的值组成的数组
// 注意多个同步更改只会触发一次侦听器。
watch([count, () => zhangsan.age], ([newCount, newAge], [oldCount, oldAge]) => {
  console.log('newCount =',newCount,'newZhangsanAge =', newAge, 'oldCount', oldCount, 'oldZhangsanAge =', oldAge);
});

立即监听

watch() 默认是懒侦听的,即仅在侦听源发生变化时才执行回调函数。最初绑定的时候是不会执行的,要等到所监听的值发生改变时才执行监听计算。

添加 { immediate: true } 属性实现立即监听。

watch(
  () => count.value,
  (newVal, oldVal) => {
    // 第一次调用时旧值是 undefined
   	console.log('count.value: ', newValue, oldValue);
  },
  { immediate: true }// 立即执行
)

深层侦听器

监听响应式对象

直接给 watch() 传入一个响应式对象,会隐式地创建一个深层侦听器——该回调函数在所有嵌套的变更时都会被触发:

  • 监听reactive对象,对象中任何一个值发生变化都会触发监听函数
  • 注意:此处无法正确的获取oldValue,因为newValue和oldValue是同一个对象
  • 注意:强制开启了深度监听(deep: false配置无效)
const zhangsan = reactive({
  name: '张三',
  age: 18,
}); 


watch(
  zhangsan, 
  (newValue, oldValue) => {
    console.log(newValue === oldValue); // true
  },
  { deep: false }, // 设置deep无效
);

zhangsan.age++;

监听getter函数

当使用 getter 函数作为源时,回调只在此函数的返回值变化时才会触发,一个返回响应式对象的 getter 函数:

  • 只有在返回不同的对象时,才会触发回调
  • 如果只是改变响应式对象中的某个属性的值,不会触发回调
const zhangsan = reactive({
  name: '张三',
  age: 18,
  friend: {
    age: 20,
  }
}); 

watch(
  () => zhangsan.friend,
  () => {
    // 仅当 zhangsan.friend 被替换时触发
     console.log('zhangsan.friend: ', newValue, oldValue);
  }
);

zhangsan.friend = { age: 21 }; // 会触发watch监听,newValue 此处和 oldValue 是不相等的
zhangsan.friend.age++; // 不会触发watch监听

如果你想让回调在深层级变更时也能触发,你需要使用 { deep: true } 强制侦听器进入深层级模式。

  • 监听深度嵌套对象或数组中的属性变化时,需要 deep 选项设置为 true,强制转成深层侦听器
  • 在深层级模式时,如果回调函数由于深层级的变更而被触发,那么新值和旧值将是同一个对象

给上面这个例子显式地加上 deep 选项:

watch(
  () => zhangsan.friend,
  (newValue, oldValue) => {
    // 注意:newValue 此处和 oldValue 是相等的,除非 zhangsan.friend 被整个替换了
    console.log('zhangsan.friend: ', newValue, oldValue);
  }, 
  { deep: true }, // 此时设置deep有效
);

// 会触发watch监听,此时 newValue 此处和 oldValue 是相等的
zhangsan.friend.age++; 

// 会触发watch监听,此时 newValue 此处和 oldValue 是不相等的
zhangsan.friend = { age: 21 }; 

谨慎使用

深度侦听需要遍历被侦听对象中的所有嵌套的属性,当用于大型数据结构时,开销很大。因此请只在必要时才使用它,并且要留意性能。

watchEffect

概念

watchEffect立即运行一个函数,同时响应式地追踪其依赖,并在依赖更改时重新执行。

  • 第一个参数就是要运行的副作用函数。这个副作用函数的参数也是一个函数,用来注册清理回调。清理回调会在该副作用下一次执行前被调用,可以用来清理无效的副作用,例如等待中的异步请求 。

  • 第二个参数是一个可选的选项,可以用来调整副作用的刷新时机或调试副作用的依赖。

默认情况下,侦听器将在组件渲染之前执行。设置 flush: 'post' 将会使侦听器延迟到组件渲染之后再执行。在某些特殊情况下 (例如要使缓存失效),可能有必要在响应式依赖发生改变时立即触发侦听器。这可以通过设置 flush: 'sync' 来实现。然而,该设置应谨慎使用,因为如果有多个属性同时更新,这将导致一些性能和数据一致性的问题。

返回值是一个用来停止该副作用的函数。

基本使用

watch() 是懒执行的:仅当数据源变化时,才会执行回调。但在某些场景中,我们希望在创建侦听器时,立即执行一遍回调。

举例来说,我们想请求一些初始数据,然后在相关状态更改时重新请求数据。我们可以这样写:

const url = ref('https://...');
const data = ref(null);

async function getData() {
  const response = await axios(url.value);
  data.value = await response.data;
}

// 立即获取
getData()
// ...再侦听 url 变化
watch(url, getData)

我们可以用 watchEffect 函数 来简化上面的代码。watchEffect() 会立即执行一遍回调函数,如果这时函数产生了副作用,Vue 会自动追踪副作用的依赖关系,自动分析出响应源。上面的例子可以重写为:

const url = ref('https://...');
const data = ref(null);

watchEffect(async () => {
   const response = await axios(url.value);
  data.value = await response.datal;
})

这个例子中,回调会立即执行。在执行期间,它会自动追踪 url.value 作为依赖(和计算属性的行为类似)。每当 url.value 变化时,回调会再次执行。

TIP

watchEffect 仅会在其同步执行期间,才追踪依赖。在使用异步回调时,只有在第一个 await 正常工作前访问到的属性才会被追踪。

watch与watchEffect对比

watchwatchEffect 都能响应式地执行有副作用的回调。它们之间的主要区别是追踪响应式依赖的方式:

  • watch 只追踪明确侦听的数据源。它不会追踪任何在回调中访问到的东西。另外,仅在数据源确实改变时才会触发回调。watch 会避免在发生副作用时追踪依赖,因此,我们能更加精确地控制回调函数的触发时机。
  • watchEffect,则会在副作用发生期间追踪依赖。它会在同步执行过程中,自动追踪所有能访问到的响应式属性。这更方便,而且代码往往更简洁,但有时其响应性依赖关系会不那么明确。

watchEffect() 相比,watch() 使我们可以:

  • 懒执行副作用;
  • 更加明确是应该由哪个状态触发侦听器重新执行;
  • 可以访问所侦听状态的前一个值和当前值。
const count = ref(1);
const zhangsan = reactive({
  name: '张三',
  age: 18,
}); 

// 懒执行副作用,初始运行不会执行,当count.value的值发生变到时候才会执行
// 响应性依赖关系十分明确:更加明确是应该由状态count触发侦听器重新执行
// 可以访问所侦听状态的前一个值和当前值 newValue 和 oldValue
watch(count, (newValue, oldValue) => {
  console.log('count: ', newValue, oldValue);
  
  // zhangsan.age 的值发生变化不会执行执行副作用
  let age = zhangsan.age;
  console.log('watch', age);
});

// 立即执行一次副作用,初始运行执行一次
// 响应性依赖关系不那么明确:每当count.value 或者 zhangsan.age 的值发生变化时,执行一次
watchEffect(() => {
  let val = count.value;
  let age = zhangsan.age;
  console.log('watchEffect', val, age);
});

回调的触发时机

当你更改了响应式状态,它可能会同时触发 Vue 组件更新和侦听器回调。

默认情况下,用户创建的侦听器回调,都会在 Vue 组件更新之前被调用。这意味着你在侦听器回调中访问的 DOM 将是被 Vue 更新之前的状态。

如果想在侦听器回调中能访问被 Vue 更新之后的 DOM,你需要指明 flush: 'post' 选项:

watch(
  count, 
  (newValue, oldValue) => {
    // 可以访问更新之后的DOM
    console.log('count: ', newValue, oldValue);
  }, 
 	{flush: 'post'}
);


watchEffect(
  () => {
    // 可以访问更新之后的DOM
		console.log('watchEffect', count.value);
	},
  { flush: 'post' }
);

后置刷新的 watchEffect() 有个更方便的别名 watchPostEffect()

import { watchPostEffect } from 'vue'

// watchPostEffect是 watchEffect 带有 flush: 'post' 选项的别名。
watchPostEffect(() => {
  /* 在 Vue 更新后执行 */
})

停止侦听器

setup()

要手动停止一个侦听器,请调用 watchwatchEffect 返回的函数:

const unwatch = watch(() => {})

// ...当该侦听器不再需要时
unwatch()
const unwatch = watchEffect(() => {})

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

注意,需要异步创建侦听器的情况很少,请尽可能选择同步创建。如果需要等待一些异步数据,你可以使用条件式的侦听逻辑:

// 需要异步请求得到的数据
const data = ref(null)

watchEffect(() => {
  if (data.value) {
    // 数据加载后执行某些操作...
  }
})

监听数组的研究

方法一:使用ref声明数组,在组件模板中直接使用arr,在setup中需要使用arr.value

  • 使用push等改变数组的元素 arr.value.push(6);

    • 监听 ref 数组watch(arr, callback),当使用 push、splice等方法改变数组元素内容,需要加上 { deep: true } 选项,才能触发watch,但此时nowValue和oldVlue输出的值是一样的

    • 使用getter函数监听数组 watch(() => arr.value, callback),需要加上 { deep: true } 选项,才能触发watch,此时nowValue和oldVlue输出的值是一样的

    • 使用getter函数,配合扩展运算符,监听一个新的数组 watch(() => [...arr.value], callback),不需要加上 { deep: true } 选项,也能触发watch,此时nowValue和oldVlue输出的值是不一样的 (推荐)

  • 给数组赋值新的对象 arr.value = [11, 22, 33];

    • 监听 ref 数组,不需要加上 { deep: true } 选项,也能触发watch,此时nowValue和oldVlue输出的值是不一样的
    • 使用getter函数监听数组 watch(() => arr.value, callback),不需要加上 { deep: true } 选项,也能触发watch,此时nowValue和oldVlue输出的值是不一样的
    • 使用getter函数,配合扩展运算符,监听一个新的数组 watch(() => [...arr.value], callback),不需要加上 { deep: true } 选项,也能触发watch,此时nowValue和oldVlue输出的值是不一样的
const arr = ref([1, 2, 3, 4, 5]);
nextTick(() => {
	arr.value.push(6);
	arr.value = [11, 22, 33];
});
watch(
	arr,
	(n, o) => {
		console.log('arr1: ', n === o);
	},
	{ deep: true }
);

watch(
	() => arr.value,
	(n, o) => {
		console.log('arr2: ', n === o); 
	},
	{ deep: true }
);

watch(
	() => [...arr.value],
	(n, o) => {
		console.log('arr3: ', n === o); 
	}
);

方法二:使用reactive声明数组,使用的push方法添加新的元素

  • 使用push等改变数组的元素 arr.push(6);

    • 监听 reactive 数组 watch(arr, callback),当使用 push、splice等方法改变数组元素内容,不需要加上 { deep: true } 选项,也能触发watch,但此时nowValue和oldVlue输出的值是一样的

    • 使用getter函数,配合扩展运算符,监听一个新的数组 watch(() => [...arr], callback),不需要加上 { deep: true } 选项,也能触发watch,此时nowValue和oldVlue输出的值是不一样的 (推荐)

const arr = reactive([]);
nextTick(() => {
	arr.push(6);
});
watch(
	arr,
	(n, o) => {
		console.log('arr1: ', n, o, n === o); // true
	}
);

watch(
	() => [...arr],
	(n, o) => {
		console.log('arr3: ', n, o, n === o); // false
	}
);

方法三:创建一个响应式对象,对象的属性是数组,给该属性直接赋值

  • 使用push等改变数组的元素 obj.arr.push(6);

    • 直接监听 reactive对象 watch(obj, callback),当使用 push、splice等方法改变数组元素内容,不需要加上 { deep: true } 选项,也能触发watch,但此时nowValue和oldVlue输出的值是一样的

    • 推荐使用扩展运算符 [...obj.arr],使用getter函数监听一个新的数组 watch(() => [...obj.arr], callback),不需要加上 { deep: true } 选项,也能触发watch,此时nowValue和oldVlue输出的值是不一样的 (推荐)

  • 给数组赋值新的对象 obj.arr = [11, 22, 33];

    • 直接监听 reactive对象 watch(obj, callback),当使用 push、splice等方法改变数组元素内容,不需要加上 { deep: true } 选项,也能触发watch,但此时nowValue和oldVlue输出的值是不一样的
    • 直接使用getter函数监听数组 watch(() => obj.arr, callback),不需要加上 { deep: true } 选项,也能触发watch,此时nowValue和oldVlue输出的值是不一样的
    • 使用getter函数,配合扩展运算符,监听一个新的数组 watch(() => [...obj.arr], callback),不需要加上 { deep: true } 选项,也能触发watch,此时nowValue和oldVlue输出的值是不一样的
const obj = reactive({
  arr: [1, 2, 3],
});


nextTick(() => {
	// obj.arr.push(6);
	obj.arr = [11, 22, 33];
});

生命周期钩子

每个 Vue 组件实例在创建时都需要经历一系列的初始化步骤,比如设置好数据侦听,编译模板,挂载实例到 DOM,以及在数据改变时更新 DOM。在此过程中,它也会运行被称为生命周期钩子的函数,让开发者有机会在特定阶段运行自己的代码。

注册周期钩子

组合式API通过在生命周期钩子前面加上 “on” 来访问组件的生命周期钩子。

举例来说,onMounted 钩子可以用来在组件完成初始渲染并创建 DOM 节点后运行代码:


还有其他一些钩子,会在实例生命周期的不同阶段被调用,最常用的是 onMountedonUpdatedonUnmounted

当调用 onMounted 时,Vue 会自动将回调函数注册到当前正被初始化的组件实例上。这意味着这些钩子应当在组件初始化时被同步注册。例如,请不要这样做:

setTimeout(() => {
  onMounted(() => {
    // 异步注册时当前组件实例已丢失
    // 这将不会正常工作
  })
}, 100)

注意这并不意味着对 onMounted 的调用必须放在 setup()

onBeforeUpdate

注册一个钩子,在组件即将因为响应式状态变更而更新其 DOM 树之前调用。

  • 这个钩子可以用来在 Vue 更新 DOM 之前访问 DOM 状态。在这个钩子中更改状态也是安全的。

onUpdated

注册一个回调函数,在组件因为响应式状态变更而更新其 DOM 树之后调用。

  • 父组件的更新钩子将在其子组件的更新钩子之后调用。

  • 这个钩子会在组件的任意 DOM 更新后被调用,这些更新可能是由不同的状态变更导致的。如果你需要在某个特定的状态更改后访问更新后的 DOM,请使用 nextTick() 作为替代。

WARNING

不要在 updated 钩子中更改组件的状态,这可能会导致无限的更新循环!

onBeforeUnmount

注册一个钩子,在组件实例被卸载之前调用。

  • 当这个钩子被调用时,组件实例依然还保有全部的功能。

onUnmounted

注册一个回调函数,在组件实例被卸载之后调用。

一个组件在以下情况下被视为已卸载:

  • 其所有子组件都已经被卸载。
  • 所有相关的响应式作用 (渲染作用以及 setup() 时创建的计算属性和侦听器) 都已经停止。

可以在这个钩子中手动清理一些副作用,例如计时器、DOM 事件监听器或者与服务器的连接。


onErrorCaptured

注册一个钩子,在捕获了后代组件传递的错误时调用。

错误可以从以下几个来源中捕获:

  • 组件渲染
  • 事件处理器
  • 生命周期钩子
  • setup() 函数
  • 侦听器
  • 自定义指令钩子
  • 过渡钩子

这个钩子带有三个实参:错误对象、触发该错误的组件实例,以及一个说明错误来源类型的信息字符串。

你可以在 errorCaptured() 中更改组件状态来为用户显示一个错误状态。注意不要让错误状态再次渲染导致本次错误的内容,否则组件会陷入无限循环。

这个钩子可以通过返回 false 来阻止错误继续向上传递。请看下方的传递细节介绍。

错误传递规则

  • 默认情况下,所有的错误都会被发送到应用级的 app.config.errorHandler (前提是这个函数已经定义),这样这些错误都能在一个统一的地方报告给分析服务。
  • 如果组件的继承链或组件链上存在多个 errorCaptured 钩子,对于同一个错误,这些钩子会被按从底至上的顺序一一调用。这个过程被称为“向上传递”,类似于原生 DOM 事件的冒泡机制。
  • 如果 errorCaptured 钩子本身抛出了一个错误,那么这个错误和原来捕获到的错误都将被发送到 app.config.errorHandler
  • errorCaptured 钩子可以通过返回 false 来阻止错误继续向上传递。即表示“这个错误已经被处理了,应当被忽略”,它将阻止其他的 errorCaptured 钩子或 app.config.errorHandler 因这个错误而被调用。

onRenderTracked

注册一个调试钩子,当组件渲染过程中追踪到响应式依赖时调用。

这个钩子仅在开发模式下可用,且在服务器端渲染期间不会被调用。

onRenderTriggered

注册一个调试钩子,当响应式依赖的变更触发了组件渲染时调用。

这个钩子仅在开发模式下可用,且在服务器端渲染期间不会被调用。

onActivated

注册一个回调函数,若组件实例是 缓存树的一部分,当组件被插入到 DOM 中时调用。

onDeactivated

注册一个回调函数,若组件实例是 缓存树的一部分,当组件从 DOM 中被移除时调用。

模板引用

虽然 Vue 的声明性渲染模型为你抽象了大部分对 DOM 的直接操作,但在某些情况下,我们仍然需要直接访问底层 DOM 元素。要实现这一点,我们可以使用特殊的 ref attribute:

<input ref="inputRef">

ref 是一个特殊的 attribute,和 v-for 中提到的 key 类似。它允许我们在一个特定的 DOM 元素或子组件实例被挂载后,获得对它的直接引用。这可能很有用,比如说在组件挂载时将焦点设置到一个 input 元素上,或在一个元素上初始化一个第三方库。

ref 用于注册元素或子组件的引用。

  • 使用选项式 API,引用将被注册在组件的 this.$refs 对象里
  • 使用组合式 API,引用将存储在与名字匹配的 ref
  • 如果用于普通 DOM 元素,引用将是元素本身;如果用于子组件,引用将是子组件的实例。
  • ref 可以接收一个函数值,用于对存储引用位置的完全控制:

关于 ref 注册时机的重要说明:因为 ref 本身是作为渲染函数的结果来创建的,必须等待组件挂载后才能对它进行访问。

this.$refs 也是非响应式的,因此你不应该尝试在模板中使用它来进行数据绑定。

访问一个组件或者元素

  • 为了通过组合式 API 获得该模板引用,我们需要声明一个同名的 ref
  • 你只可以在组件挂载后才能访问模板引用,在 onMounted 或者 nextTick 中都可以
  • 如果你想在模板中的表达式上访问 inputRef,在初次渲染时会是 null。这是因为在初次渲染前这个元素还不存在呢!



如果不使用

应该注意的是,ref 数组并不保证与源数组相同的顺序。

函数模板引用

除了使用字符串值作名字,ref attribute 还可以绑定为一个函数,会在每次组件更新时都被调用。该函数会收到元素引用作为其第一个参数:

<input :ref="(el) => { /* 将 el 赋值给一个数据属性或 ref 变量 */ }">

注意我们这里需要使用动态的 :ref 绑定才能够传入一个函数。当绑定的元素被卸载时,函数也会被调用一次,此时的 el 参数会是 null。你当然也可以绑定一个组件方法而不是内联函数。




组件上的 ref

模板引用也可以被用在一个子组件上。这种情况下引用中获得的值是组件实例:




如果一个子组件使用的是选项式 API 或没有使用

当父组件通过模板引用获取到了该组件的实例时,得到的实例类型为 { a: number, b: number } (ref 都会自动解包,和一般的实例一样)。

侦听模板引用

在声明周期钩子 onMounted 中可以访问模板引用,如果需要侦听一个模板引用 ref 的变化,确保考虑到其值为 null 的情况:

watchEffect(() => {
  if (inputRef.value) {
    inputRef.value.focus()
  } else {
    // 此时还未挂载,或此元素已经被卸载(例如通过 v-if 控制)
  }
})

watchEffect() 在 DOM 挂载或更新之前运行副作用,所以当侦听器运行时,模板引用还未被更新。因此,使用模板引用的侦听器应该用 { flush: 'post' } 选项来定义,这将在 DOM 更新后运行副作用,确保模板引用与 DOM 保持同步,并引用正确的元素。

watchEffect(
	() => {
    inputRef.value.focus();
		console.log('inputRef.value: ', inputRef.value);
	},
	{ flush: 'post' }
);

或者直接使用watchEffect(()=>{ }, { flush: 'post' }) 的别名函数 watchPostEffect():

watchPostEffect(() => {
  inputRef.value.focus();
  console.log('inputRef.value: ', inputRef.value);
});

依赖注入

Prop 逐级透传问题

通常情况下,当我们需要从父组件向子组件传递数据时,会使用 props。想象一下这样的结构:有一些多层级嵌套的组件,形成了一颗巨大的组件树,而某个深层的子组件需要一个较远的祖先组件中的部分数据。在这种情况下,如果仅使用 props 则必须将其沿着组件链逐级传递下去,这会非常麻烦:

vue3组合式api_第2张图片

注意,虽然这里的

组件可能根本不关心这些 props,但为了使 能访问到它们,仍然需要定义并向下传递。如果组件链路非常长,可能会影响到更多这条路上的组件。这一问题被称为“prop 逐级透传”,显然是我们希望尽量避免的情况。

provideinject 可以帮助我们解决这一问题。一个父组件相对于其所有的后代组件,会作为依赖提供者。任何后代的组件树,无论层级有多深,都可以注入由父组件提供给整条链路的依赖。

vue3组合式api_第3张图片

Provide (提供)

概念

provide() 函数提供一个值,可以被后代组件注入。

provide(注入名, 值)接收两个参数:

  • 第一个参数是要注入的 key,被称为注入名

    • 注入名可以是一个字符串或是一个 Symbol

    • 后代组件会用注入名来查找期望注入的值:inject(注入名)

    • 一个组件可以多次调用 provide(),使用不同的注入名,注入不同的依赖值。

  • 第二个参数是提供的值

    • 提供的值可以是任意类型,包括普通变量,响应式的状态ref、reactive,函数等
    • 提供的响应式状态使后代组件可以由此和提供者建立响应式的联系。

与注册生命周期钩子的 API 类似,provide() 必须在组件的 setup() 阶段同步调用。

在组件中使用 Provide

如果不使用

注入数据的注意事项:

  • 如果提供的值是非响应式数据,可以直接使用

  • 如果提供的值是一个 ref,注入进来的会是该 ref 对象,而不会自动解包为其内部的值。这使得注入方组件能够通过 ref 对象保持了和供给方的响应性链接。

  • 如果提供的值是一个reactive,注入进来的也是该reactive对象,注入方组件能够通过 reactive 对象保持了和供给方的响应性链接。如果想解构该对象,需要使用 toRefs

同样的,如果没有使用





最后,如果你想确保提供的数据不能被注入方的组件更改,你可以使用 readonly()来包装提供的值。


使用 Symbol 作注入名

至此,我们已经了解了如何使用字符串作为注入名。但如果你正在构建大型的应用,包含非常多的依赖提供,或者你正在编写提供给其他开发者使用的组件库,建议最好使用 Symbol 来作为注入名以避免潜在的冲突。

我们通常推荐在一个单独的文件中导出这些注入名 Symbol:

// keys.js
export const myInjectionKey = Symbol()
// 在供给方组件中
import { provide } from 'vue'
import { myInjectionKey } from './keys.js'

provide(myInjectionKey, { /*
  要提供的数据
*/ });
// 注入方组件
import { inject } from 'vue'
import { myInjectionKey } from './keys.js'

const injected = inject(myInjectionKey)

插槽

子组件




父组件




setup语法糖

里面的代码会被编译成组件 setup() 函数的内容。这意味着与普通的

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




响应式

响应式状态需要明确使用响应式 API 来创建。和 setup() 函数的返回值一样,ref 在模板中使用的时候会自动解包:




使用组件

这里 MyComponent 应当被理解为像是在引用一个变量。如果你使用过 JSX,此处的心智模型是类似的。其 kebab-case 格式的 同样能在模板中使用——不过,我们强烈建议使用 PascalCase 格式以保持一致性。同时这也有助于区分原生的自定义元素。

动态组件

由于组件是通过变量引用而不是基于字符串组件名注册的,在

请注意组件是如何在三元表达式中被当做变量使用的。

递归组件

一个单文件组件可以通过它的文件名被其自己所引用。例如:名为 FooBar.vue 的组件可以在其模板中用 引用它自己。

请注意这种方式相比于导入的组件优先级更低。如果有具名的导入和组件自身推导的名字冲突了,可以为导入的组件添加别名:

import { FooBar as FooBarChild } from './components'

命名空间组件

可以使用带 . 的组件标签,例如 来引用嵌套在对象属性中的组件。这在需要从单个文件中导入多个组件的时候非常有用:




使用自定义指令

全局注册的自定义指令将正常工作。本地的自定义指令在

如果指令是从别处导入的,可以通过重命名来使其符合命名规范:


defineProps()

子组件:




defineEmits()

子组件




defineExpose()

在标准组件写法里,子组件的数据都是默认隐式暴露给父组件的,但使用

父组件




useSlots() 和 useAttrs()

useSlotsuseAttrs 是真实的运行时函数,它的返回与 setupContext.slotssetupContext.attrs 等价。它们同样也能在普通的组合式 API 中使用。

与普通的

顶层 await

另外,await 的表达式会自动编译成在 await 之后保留当前组件实例上下文的格式。

注意

async setup() 必须与 Suspense 内置组件组合使用,Suspense 目前还是处于实验阶段的特性,会在将来的版本中稳定。

限制

由于模块执行语义的差异,

但是,如果我们想在多个组件中复用这个相同的逻辑呢?我们可以把这个逻辑以一个组合式函数的形式提取到外部文件中:

// mouse.js
import { ref, onMounted, onUnmounted } from 'vue'

// 按照惯例,组合式函数名以“use”开头
export function useMouse() {
  // 被组合式函数封装和管理的状态
  const x = ref(0)
  const y = ref(0)

  // 组合式函数可以随时更改其状态。
  function update(event) {
    x.value = event.pageX
    y.value = event.pageY
  }

  // 一个组合式函数也可以挂靠在所属组件的生命周期上
  // 来启动和卸载副作用
  onMounted(() => window.addEventListener('mousemove', update))
  onUnmounted(() => window.removeEventListener('mousemove', update))

  // 通过返回值暴露所管理的状态
  return { x, y }
}

下面是它在组件中使用的方式:




如你所见,核心逻辑完全一致,我们做的只是把它移到一个外部函数中去,并返回需要暴露的状态。和在组件中一样,你也可以在组合式函数中使用所有的组合式 API。现在,useMouse() 的功能可以在任何组件中轻易复用了。

更酷的是,你还可以嵌套多个组合式函数:一个组合式函数可以调用一个或多个其他的组合式函数。这使得我们可以像使用多个组件组合成整个应用一样,用多个较小且逻辑独立的单元来组合形成复杂的逻辑。实际上,这正是为什么我们决定将实现了这一设计模式的 API 集合命名为组合式 API。

举例来说,我们可以将添加和清除 DOM 事件监听器的逻辑也封装进一个组合式函数中:

// event.js
import { onMounted, onUnmounted } from 'vue'

export function useEventListener(target, event, callback) {
  // 如果你想的话,
  // 也可以用字符串形式的 CSS 选择器来寻找目标 DOM 元素
  onMounted(() => target.addEventListener(event, callback))
  onUnmounted(() => target.removeEventListener(event, callback))
}

有了它,之前的 useMouse() 组合式函数可以被简化为:

// mouse.js
import { ref } from 'vue'
import { useEventListener } from './event'

export function useMouse() {
  const x = ref(0)
  const y = ref(0)

  useEventListener(window, 'mousemove', (event) => {
    x.value = event.pageX
    y.value = event.pageY
  })

  return { x, y }
}

异步状态示例

useMouse() 组合式函数没有接收任何参数,因此让我们再来看一个需要接收一个参数的组合式函数示例。在做异步数据请求时,我们常常需要处理不同的状态:加载中、加载成功和加载失败。




如果在每个需要获取数据的组件中都要重复这种模式,那就太繁琐了。让我们把它抽取成一个组合式函数:

// fetch.js
import { ref } from 'vue'

export function useFetch(url) {
  const data = ref(null)
  const error = ref(null)

  fetch(url)
    .then((res) => res.json())
    .then((json) => (data.value = json))
    .catch((err) => (error.value = err))

  return { data, error }
}

现在我们在组件里只需要:


useFetch() 接收一个静态的 URL 字符串作为输入,所以它只执行一次请求,然后就完成了。但如果我们想让它在每次 URL 变化时都重新请求呢?那我们可以让它同时允许接收 ref 作为参数:

// fetch.js
import { ref, isRef, unref, watchEffect } from 'vue'

export function useFetch(url) {
  const data = ref(null)
  const error = ref(null)

  function doFetch() {
    // 在请求之前重设状态...
    data.value = null
    error.value = null
    // unref() 解包可能为 ref 的值
    fetch(unref(url))
      .then((res) => res.json())
      .then((json) => (data.value = json))
      .catch((err) => (error.value = err))
  }

  if (isRef(url)) {
    // 若输入的 URL 是一个 ref,那么启动一个响应式的请求
    watchEffect(doFetch)
  } else {
    // 否则只请求一次
    // 避免监听器的额外开销
    doFetch()
  }

  return { data, error }
}

这个版本的 useFetch() 现在同时可以接收静态的 URL 字符串和 URL 字符串的 ref。当通过 isRef() 检测到 URL 是一个动态 ref 时,它会使用 watchEffect() 启动一个响应式的 effect。该 effect 会立刻执行一次,并在此过程中将 URL 的 ref 作为依赖进行跟踪。当 URL 的 ref 发生改变时,数据就会被重置,并重新请求。

这里是一个升级版的 useFetch(),出于演示目的,我们人为地设置了延迟和随机报错。

约定和最佳实践

命名

组合式函数约定用驼峰命名法命名,并以“use”作为开头。

输入参数

尽管其响应性不依赖 ref,组合式函数仍可接收 ref 参数。如果编写的组合式函数会被其他开发者使用,你最好在处理输入参数时兼容 ref 而不只是原始的值。unref() 工具函数会对此非常有帮助:

import { unref } from 'vue'

function useFeature(maybeRef) {
  // 若 maybeRef 确实是一个 ref,它的 .value 会被返回
  // 否则,maybeRef 会被原样返回
  const value = unref(maybeRef)
}

如果你的组合式函数在接收 ref 为参数时会产生响应式 effect,请确保使用 watch() 显式地监听此 ref,或者在 watchEffect() 中调用 unref() 来进行正确的追踪。

返回值

你可能已经注意到了,我们一直在组合式函数中使用 ref() 而不是 reactive()。我们推荐的约定是组合式函数始终返回一个包含多个 ref 的普通的非响应式对象,这样该对象在组件中被解构为 ref 之后仍可以保持响应性:

// x 和 y 是两个 ref
const { x, y } = useMouse()

从组合式函数返回一个响应式对象会导致在对象解构过程中丢失与组合式函数内状态的响应性连接。与之相反,ref 则可以维持这一响应性连接。

如果你更希望以对象属性的形式来使用组合式函数中返回的状态,你可以将返回的对象用 reactive() 包装一次,这样其中的 ref 会被自动解包,例如:

const mouse = reactive(useMouse())
// mouse.x 链接到了原来的 x ref
console.log(mouse.x)
Mouse position is at: {{ mouse.x }}, {{ mouse.y }}

副作用

在组合式函数中的确可以执行副作用 (例如:添加 DOM 事件监听器或者请求数据),但请注意以下规则:

  • 如果你的应用用到了服务端渲染 (SSR),请确保在组件挂载后才调用的生命周期钩子中执行 DOM 相关的副作用,例如:onMounted()。这些钩子仅会在浏览器中被调用,因此可以确保能访问到 DOM。
  • 确保在 onUnmounted() 时清理副作用。举例来说,如果一个组合式函数设置了一个事件监听器,它就应该在 onUnmounted() 中被移除 (就像我们在 useMouse() 示例中看到的一样)。当然也可以像之前的 useEventListener() 示例那样,使用一个组合式函数来自动帮你做这些事。

使用限制

组合式函数在

在某种程度上,你可以将这些提取出的组合式函数看作是可以相互通信的组件范围内的服务。

在选项式 API 中使用组合式函数

如果你正在使用选项式 API,组合式函数必须在 setup() 中调用。且其返回的绑定必须在 setup() 中返回,以便暴露给 this 及其模板:

js

import { useMouse } from './mouse.js'
import { useFetch } from './fetch.js'

export default {
  setup() {
    const { x, y } = useMouse()
    const { data, error } = useFetch('...')
    return { x, y, data, error }
  },
  mounted() {
    // setup() 暴露的属性可以在通过 `this` 访问到
    console.log(this.x)
  }
  // ...其他选项
}

与其他模式的比较

和 Mixin 的对比

Vue 2 的用户可能会对 mixins 选项比较熟悉。它也让我们能够把组件逻辑提取到可复用的单元里。然而 mixins 有三个主要的短板:

  1. 不清晰的数据来源:当使用了多个 mixin 时,实例上的数据属性来自哪个 mixin 变得不清晰,这使追溯实现和理解组件行为变得困难。这也是我们推荐在组合式函数中使用 ref + 解构模式的理由:让属性的来源在消费组件时一目了然。
  2. 命名空间冲突:多个来自不同作者的 mixin 可能会注册相同的属性名,造成命名冲突。若使用组合式函数,你可以通过在解构变量时对变量进行重命名来避免相同的键名。
  3. 隐式的跨 mixin 交流:多个 mixin 需要依赖共享的属性名来进行相互作用,这使得它们隐性地耦合在一起。而一个组合式函数的返回值可以作为另一个组合式函数的参数被传入,像普通函数那样。

基于上述理由,我们不再推荐在 Vue 3 中继续使用 mixin。保留该功能只是为了项目迁移的需求和照顾熟悉它的用户。

和无渲染组件的对比

在组件插槽一章中,我们讨论过了基于作用域插槽的无渲染组件。我们甚至用它实现了一样的鼠标追踪器示例。

组合式函数相对于无渲染组件的主要优势是:组合式函数不会产生额外的组件实例开销。当在整个应用中使用时,由无渲染组件产生的额外组件实例会带来无法忽视的性能开销。

我们推荐在纯逻辑复用时使用组合式函数,在需要同时复用逻辑和视图布局时使用无渲染组件。

和 React Hooks 的对比

如果你有 React 的开发经验,你可能注意到组合式函数和自定义 React hooks 非常相似。组合式 API 的一部分灵感正来自于 React hooks,Vue 的组合式函数也的确在逻辑组合能力上与 React hooks 相近。然而,Vue 的组合式函数是基于 Vue 细粒度的响应性系统,这和 React hooks 的执行模型有本质上的不同。

更多hook的理解

  • Vue3中的Hook函数(对比mixin)
  • Vue3必学技巧-自定义Hooks-让写Vue3更畅快
  • 浅谈:为啥vue和react都选择了Hooks?
  • vueuse:我不许身为vuer的前端,你的工具集只有lodash!

你可能感兴趣的:(vue,vue.js,javascript,前端)