跳至主要內容

Vue

LincZero大约 17 分钟

Vue

目录

响应式基础

声明响应式状态

我们可以使用 reactive()open in new window 函数创建一个响应式对象或数组:

import { reactive } from 'vue'
const state = reactive({ count: 0 })

响应式对象其实是 JavaScript Proxyopen in new window,其行为表现与一般对象相似。 不同之处在于 Vue 能够跟踪对响应式对象属性的访问与更改操作。 如果你对这其中的细节感到好奇,我们在 深入响应式系统open in new window 一章中会进行解释,但我们推荐你先读完这里的主要指南

TypeScript 用户请参阅:为响应式对象标注类型open in new window

setup()

要在组件模板中使用响应式状态,需要在 setup() 函数中定义并返回。

<div>{{ state.count }}</div>
// ...

import { reactive } from 'vue'

export default {
  // `setup` 是一个专门用于组合式 API 的特殊钩子函数
  setup() {
    const state = reactive({ count: 0 })

    // 暴露 state 到模板
    return {
      state
    }
  }
}

自然,我们也可以在同一个作用域下定义更新响应式状态的函数,并将他们作为方法与状态一起暴露出去:

import { reactive } from 'vue'

export default {
  setup() {
    const state = reactive({ count: 0 })

    function increment() {
      state.count++
    }

    // 不要忘记同时暴露 increment 函数
    return {
      state,
      increment
    }
  }
}

暴露的方法通常会被用作事件监听器:

<button @click="increment">
  {{ state.count }}
</button>

<script setup>

setup() 函数中手动暴露大量的状态和方法非常繁琐。 幸运的是,我们可以通过使用构建工具来简化该操作。 当使用单文件组件(SFC)时,我们可以使用 <script setup> 来大幅度地简化代码。

<script setup>
import { reactive } from 'vue'

const state = reactive({ count: 0 })

function increment() {
  state.count++
}
</script>

<template>
  <button @click="increment">
    {{ state.count }}
  </button>
</template>

<script setup> 中的顶层的导入和变量声明可在同一组件的模板中直接使用。 你可以理解为模板中的表达式和 <script setup> 中的代码处在同一个作用域中。

在指南的后续章节中,我们基本上都会在组合式 API 示例中使用单文件组件 + <script setup> 的语法,因为大多数 Vue 开发者都会这样使用。

DOM 更新时机

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

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

import { nextTick } from 'vue'

function increment() {
  state.count++
  nextTick(() => {
    // 访问更新后的 DOM
  })
}

深层响应性

在 Vue 中,状态都是默认深层响应式的。这意味着即使在更改深层次的对象或数组,你的改动也能被检测到。

import { reactive } from 'vue'

const obj = reactive({
  nested: { count: 0 },
  arr: ['foo', 'bar']
})

function mutateDeeply() {
  // 以下都会按照期望工作
  obj.nested.count++
  obj.arr.push('baz')
}

你也可以直接创建一个浅层响应式对象open in new window。它们仅在顶层具有响应性,一般仅在某些特殊场景中需要。

响应式代理 vs. 原始对象

值得注意的是,reactive() 返回的是一个原始对象的 Proxyopen in new window,它和原始对象是不相等的:

const raw = {}
const proxy = reactive(raw)

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

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

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

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

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

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

const proxy = reactive({})

const raw = {}
proxy.nested = raw

console.log(proxy.nested === raw) // false

reactive() 的局限性

reactive() API 有两条限制:

  1. 仅对对象类型有效(对象、数组和 MapSet 这样的集合类型open in new window),而对 stringnumberboolean 这样的 原始类型open in new window 无效。
  2. 因为 Vue 的响应式系统是通过属性访问进行追踪的,因此我们必须始终保持对该响应式对象的相同引用。 这意味着我们不可以随意地“替换”一个响应式对象,因为这将导致对初始引用的响应性连接丢失:
    let state = reactive({ count: 0 })
    
    // 上面的引用 ({ count: 0 }) 将不再被追踪(响应性连接已丢失!)
    state = reactive({ count: 1 })
    
同时这也意味着当我们将响应式对象的属性赋值或解构至本地变量时,或是将该属性传入一个函数时,我们会失去响应性:
 ```cpp
 const state = reactive({ count: 0 })
 
 // n 是一个局部变量,同 state.count
 // 失去响应性连接
 let n = state.count
 // 不影响原始的 state
 n++
 
 // count 也和 state.count 失去了响应性连接
 let { count } = state
 // 不会影响原始的 state
 count++
 
 // 该函数接收一个普通数字,并且
 // 将无法跟踪 state.count 的变化
 callSomeFunction(state.count)
 ```

ref() 定义响应式变量

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

import { ref } from 'vue'
const count = ref(0)

ref() 将传入参数的值包装为一个带 .value 属性的 ref 对象:

const count = ref(0)

console.log(count) // { value: 0 }
console.log(count.value) // 0

count.value++
console.log(count.value) // 1

TypeScript 用户请参阅:为 ref 标注类型open in new window

和响应式对象的属性类似,ref 的 .value 属性也是响应式的。同时,当值为对象类型时,会用 reactive() 自动转换它的 .value

一个包含对象类型值的 ref 可以响应式地替换整个对象:

const objectRef = ref({ count: 0 })

// 这是响应式的替换
objectRef.value = { count: 1 }

ref 被传递给函数或是从一般对象上被解构时,不会丢失响应性:

const obj = {
  foo: ref(1),
  bar: ref(2)
}

// 该函数接收一个 ref
// 需要通过 .value 取值
// 但它会保持响应性
callSomeFunction(obj.foo)

// 仍然是响应式的
const { foo, bar } = obj

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

ref 在模板中的解包

当 ref 在模板中作为顶层属性被访问时,它们会被自动“解包”,所以不需要使用 .value。下面是之前的计数器例子,用 ref() 代替:

<script setup>
import { ref } from 'vue'

const count = ref(0)

function increment() {
  count.value++
}
</script>

<template>
  <button @click="increment">
    {{ count }} <!-- 无需 .value -->
  </button>
</template>

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

所以我们给出以下 object:

const object = { foo: ref(1) }
...
{{ object.foo + 1 }} // 不会像预期的那样工作,渲染的结果会是一个 `[object Object]1`
...
{{ object.foo }} // 需要注意的是,如果一个 ref 是文本插值(即一个 `{{ }}` 符号)计算的最终值,它也将被解包。因此下面的渲染结果将为 `1`
				// 这只是文本插值的一个方便功能,相当于 `{{ object.foo.value }}`

因为 object.foo 是一个 ref 对象。我们可以通过将 foo 改成顶层属性来解决这个问题:

const { foo } = object
...
{{ foo + 1 }}  // 现在渲染结果将是 2

ref 在响应式对象中的解包

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

const count = ref(0)
const state = reactive({
  count
})

console.log(state.count) // 0

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

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

const otherCount = ref(2)

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

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

数组和集合类型的 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)

【补充】JavaScript Proxy

参考:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Proxy

Proxy 对象用于创建一个对象的代理,从而实现基本操作的拦截和自定义(如属性查找、赋值、枚举、函数调用等)。

教程

语法

/**
 * @param handler 一个通常以函数作为属性的对象,各属性中的函数分别定义了在执行各种操作时代理 p 的行为
 *                包含捕捉器(trap)的占位符对象,可译为处理器对象
 * @param target 要使用 Proxy 包装的目标对象(可以是任何类型的对象,包括原生数组,函数,甚至另一个代理)
 *               被 Proxy 代理虚拟化的对象。它常被作为代理的存储后端。根据目标验证关于对象不可扩展性或不可配置属性的不变量(保持不变的语义)
 */
const p = new Proxy(target, handler)

示例

基础示例

const handler = {
    get: function(obj, prop) {
        return prop in obj ? obj[prop] : 37; // 如果没有对应的key就打印37
    }
};

const p = new Proxy({}, handler);
p.a = 1;
p.b = undefined;

console.log(p.a, p.b);      // 1, undefined
console.log('c' in p, p.c); // false, 37

无操作转发代理

在以下例子中,我们使用了一个原生 JavaScript 对象,代理会将所有应用到它的操作转发到这个对象上。

let target = {};
let p = new Proxy(target, {});

p.a = 37;   // 操作转发到目标

console.log(target.a);    // 37. 操作已经被正确地转发

验证

通过代理,你可以轻松地验证向一个对象的传值。下面的代码借此展示了 setopen in new window handler 的作用

let validator = {
  set: function(obj, prop, value) {
    if (prop === 'age') {
      if (!Number.isInteger(value)) {
        throw new TypeError('年龄不是整数');
      }
      if (value > 200) {
        throw new RangeError('年龄似乎无效');
      }
    }

    // 存储值的默认行为
    obj[prop] = value;

    // 表示成功
    return true;
  }
};

let person = new Proxy({}, validator);

person.age = 100;

console.log(person.age);
// 100

person.age = 'young';
// 抛出异常:Uncaught TypeError: 年龄不是整数

person.age = 300;
// 抛出异常:Uncaught RangeError: 年龄似乎无效

扩展构造函数

方法代理可以轻松地通过一个新构造函数来扩展一个已有的构造函数。这个例子使用了constructopen in new windowapplyopen in new window

// 该函数,可在sup类的基础上,扩展base方法。并返回一个新类
function extend(sup, base) {
  var descriptor = Object.getOwnPropertyDescriptor(
    base.prototype, "constructor"
  );
  base.prototype = Object.create(sup.prototype);
  var handler = {
    construct: function(target, args) {
      var obj = Object.create(base.prototype);
      this.apply(target, obj, args);
      return obj;
    },
    apply: function(target, that, args) {
      sup.apply(that, args);
      base.apply(that, args);
    }
  };
  var proxy = new Proxy(base, handler);
  descriptor.value = proxy;
  Object.defineProperty(base.prototype, "constructor", descriptor);
  return proxy;
}

// 原类
var Person = function (name) {
  this.name = name
};

// 扩展后的类。通过extend返回值得到
var Boy = extend(Person, function (name, age) {
  this.age = age;
});

Boy.prototype.sex = "M";

var Peter = new Boy("Peter", 13);
console.log(Peter.sex);  // "M"
console.log(Peter.name); // "Peter"
console.log(Peter.age);  // 13

操作DOM节点

值修正及附加属性

通过属性查找数组中的特定对象

一个完整的 traps 列表示例

方法

【补充】深入响应式系统

Vue 最标志性的功能就是其低侵入性的响应式系统。组件状态都是由响应式的 JavaScript 对象组成的。 当更改它们时,视图会随即自动更新。 这让状态管理更加简单直观,但理解它是如何工作的也是很重要的,这可以帮助我们避免一些常见的陷阱。 在本节中,我们将深入研究 Vue 响应性系统的一些底层细节。

什么是响应性

Q:这个术语在今天的各种编程讨论中经常出现,但人们说它的时候究竟是想表达什么意思呢?

A:本质上,响应性是一种可以使我们声明式地处理变化的编程范式。一个经常被拿来当作典型例子的用例即是 Excel 表格。 例如 A2 = A0+A1,当你试着更改 A0 或 A1,你会注意到 A2 也随即自动更新了。

而 JavaScript 默认并不是这样的。如果我们用 JavaScript 写类似的逻辑:

let A0 = 1
let A1 = 2
let A2 = A0 + A1

console.log(A2) // 3

A0 = 2
console.log(A2) // 仍然是 3

当我们更改 A0 后,A2 不会自动更新。

js如何做到响应式

那么我们如何在 JavaScript 中做到这一点呢?首先,为了能重新运行计算的代码来更新 A2,我们需要将其包装为一个函数:

let A2

function update() {
  A2 = A0 + A1
}

然后,我们需要定义几个术语:

  • 这个 update() 函数会产生一个副作用,或者就简称为作用 (effect),因为它会更改程序里的状态。
  • A0A1 被视为这个作用的依赖 (dependency),因为它们的值被用来执行这个作用。因此这次作用也可以说是一个它依赖的订阅者 (subscriber)。

我们需要一个魔法函数,能够在 A0A1 (这两个依赖) 变化时调用 update() (产生作用)。

whenDepsChange(update)

这个 whenDepsChange() 函数有如下的任务:

  1. 当一个变量被读取时进行追踪。 例如我们执行了表达式 A0 + A1 的计算,则 A0A1 都被读取到了。
  2. 如果一个变量在当前运行的副作用中被读取了,就将该副作用设为此变量的一个订阅者。 例如由于 A0A1update() 执行时被访问到了,则 update() 需要在第一次调用之后成为 A0A1 的订阅者。
  3. 探测一个变量的变化。 例如当我们给 A0 赋了一个新的值后,应该通知其所有订阅了的副作用重新执行。

Vue 中的响应性是如何工作的

我们无法直接追踪对上述示例中局部变量的读写,原生 JavaScript 没有提供任何机制能做到这一点。但是,我们是可以追踪对象属性的读写的。

在 JavaScript 中有两种劫持 property 访问的方式:

下面的伪代码将会说明它们是如何工作的:

function reactive(obj) {			// reactive
  return new Proxy(obj, {				// Proxy的方式进行响应式
    get(target, key) {
      track(target, key)
      return target[key]
    },
    set(target, key, value) {
      target[key] = value
      trigger(target, key)
    }
  })
}

function ref(value) {				// ref
  const refObject = {
    get value() {
      track(refObject, 'value')
      return value
    },
    set value(newValue) {
      value = newValue
      trigger(refObject, 'value')
    }
  }
  return refObject
}

[|TIP] 这里和下面的代码片段皆旨在以最简单的形式解释核心概念,因此省略了许多细节和边界情况。

reactive 局限性

以上代码解释了我们在基础章节部分讨论过的一些 reactive() 的局限性open in new window

  • 当你将一个响应性对象的属性解构为一个局部变量时,响应性就会“断开连接”,因为对局部变量的访问不再触发 get / set 代理捕获。
  • reactive() 返回的代理尽管行为上表现得像原始对象,但我们通过使用 === 运算符还是能够比较出它们的不同。

track() 内部,我们会检查当前是否有正在运行的副作用。如果有,我们会查找到一个存储了所有追踪了该属性的订阅者的 Set,然后将当前这个副作用作为新订阅者添加到该 Set 中。

// 这会在一个副作用就要运行之前被设置
// 我们会在后面处理它
let activeEffect

function track(target, key) {
  if (activeEffect) {
    const effects = getSubscribersForProperty(target, key)
    effects.add(activeEffect)
  }
}

……

……

……

……

……

……

……

……

TypeScript 为相应式标注类型

……

响应式基础(选项式)

声明响应式状态

选用选项式 API 时,会用 data 选项来声明组件的响应式状态。 此选项的值应为返回一个对象的函数。 Vue 将在创建新组件实例的时候调用此函数,并将函数返回的对象用响应式系统进行包装。 此对象的所有顶层属性都会被代理到组件实例 (即方法和生命周期钩子中的 this) 上。

export default {
  data() {
    return {
      count: 1
    }
  },

  // `mounted` 是生命周期钩子,之后我们会讲到
  mounted() {
    // `this` 指向当前组件实例
    console.log(this.count) // => 1

    // 数据属性也可以被更改
    this.count = 2
  }
}

这些实例上的属性仅在实例首次创建时被添加,因此你需要确保它们都出现在 data 函数返回的对象上。若所需的值还未准备好,在必要时也可以使用 nullundefined 或者其他一些值占位。

虽然也可以不在 data 上定义,直接向组件实例添加新属性,但这个属性将无法触发响应式更新。

Vue 在组件实例上暴露的内置 API 使用 $ 作为前缀。它同时也为内部属性保留 _ 前缀。因此,你应该避免在顶层 data 上使用任何以这些字符作前缀的属性。

响应式代理 vs. 原始值

在 Vue 3 中,数据是基于 JavaScript Proxy(代理)open in new window 实现响应式的。使用过 Vue 2 的用户可能需要注意下面这样的边界情况:

export default {
  data() {
    return {
      someObject: {}
    }
  },
  mounted() {
    const newObject = {}
    this.someObject = newObject

    console.log(newObject === this.someObject) // false
  }
}

当你在赋值后再访问 this.someObject,此值已经是原来的 newObject 的一个响应式代理。 与 Vue 2 不同的是,这里原始的 newObject 不会变为响应式:请确保始终通过 this 来访问响应式状态。

声明方法

要为组件添加方法,我们需要用到 methods 选项。它应该是一个包含所有方法的对象:

export default {
  data() {
    return {
      count: 0
    }
  },
  methods: {
    increment() {
      this.count++
    }
  },
  mounted() {
    // 在其他方法或是生命周期中也可以调用方法
    this.increment()
  }
}

Vue 自动为 methods 中的方法绑定了永远指向组件实例的 this。这确保了方法在作为事件监听器或回调函数时始终保持正确的 this。 你不应该在定义 methods 时使用箭头函数,因为箭头函数没有自己的 this 上下文。

export default {
  methods: {
    increment: () => {
      // 反例:无法访问此处的 `this`!
    }
  }
}

和组件实例上的其他属性一样,方法也可以在模板上被访问。在模板中它们常常被用作事件监听器:

<button @click="increment">{{ count }}</button>

在上面的例子中,increment 方法会在 <button> 被点击时调用。

DOM更新时机

基本同组合式

深层相应性

基本同组合式