Vue3 学习 01——响应式状态,监听器

继续做点笔记,因为笔记是边学边写的,所以前面的内容和后面的内容可能会有不一致,这时候以后面的内容为准。这与其说是笔记,不如说是学习记录。

JS 代理机制

JS 的代理 Proxy 机制能够对对象的所有操作进行代理,包括:

  1. get:get 操作
  2. set,set 操作
  3. apply:函数调用,仅在对象是函数对象时才有意义;调用对象的函数的时候,实际上是先 get 到函数实例再进行调用,要拦截这个得拦截 get
  4. construct:new 操作
  5. has:in 操作
  6. deleteProperty:delete 操作
  7. ……其他的一些,不表

JS 的代理机制看上去比 python 的描述符协议更为强大,但它们实际上是不同的用途——JS 的代理机制创建新的对象用于替代原对象,而描述符协议是属于对象自身的。

Vue 使用代理机制去拦截对引用变量的 get,set 操作,实现 js 变量和 DOM 的双向绑定。

下面是一个使用代理的示例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/** @type {ProxyHandler<object>} */
const printer = {
get(target, prop) {
console.log('get', prop)
return target[prop]
},
set(target, prop, newValue) {
console.log('set', prop)
target[prop] = newValue
return true
},
}
const obj = { a: 1, b: 2 }

/** @type {typeof obj} */
const proxiedObject = new Proxy(obj, printer)

console.log(proxiedObject.a) // get a
proxiedObject.a = 100 // set a

代理机制和描述符协议有着同样的缺陷——只代理对对象的操作(即操作对象“内部”),但对代理对象变量的 get、set 是无法拦截的(这时候操作的是对象本身,get 和 set 是更换了这个对象而非操作原对象)。这就限制了 Vue 的组合式 API 中的代理对象的格式——必须操作代理变量的 value 字段,而非操作代理对象本身。还好这个 typescript 能正确处理。

Hello, World

后面再提关于项目结构之类的玩意儿,先过概念。首先来一个经典的双向绑定案例。它引入了:

  1. 文本插值语法——在 HTML 文本中插入 js 表达式,这个表达式以当前组件实例为作用域去计算(实际上…是个沙箱环境,它只能访问部分全局变量),我们使用单文件结构,使用组合式 API,因此这里的作用域就是 script 标签。
  2. v-on语法,简写为@,允许从当前组件作用域中获取函数去绑定 DOM 事件,而且它能够后缀修饰符表示

注意这里 v-on 绑定的是事件,比如这里我们把 click 事件绑定到inc上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<script setup lang="ts">
import { ref } from 'vue';
const count = ref(0)
function inc() {
count.value++
}
</script>

<template>
<button v-on:click="inc">inc</button>
<button v-on:click.prevent="inc">inc</button>
<button @click="inc">inc</button>
<button @click="inc()">inc</button>
<p>counter: {{ count }}</p>
</template>

注意——DOM 更新和count.value++执行不是同步的——Vue 会缓冲所有状态的修改,在 next tick 后一并更新,这保证多次修改只会执行一次。nextTick 能够 await 到:

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

async function inc() {
count.value++
await nextTick()
// 现在 DOM 已经更新了
}

v-开头的属性称为指令,指令可以有参数,比如上面的v-on:click.prevent中的 prevent;Vue 提供了许多内置指令,它就像模板引擎中提供的那些玩意。这设计思路就和 React 完全不同——React 全部都以 js 表达式来做,使用三目去实现 if,使用 map 去实现 for。不过 Vue 同样支持 JSX,因此支持 React 那种风格。

v-on有一个及其操蛋的地方——传inc和传inc()效果是一样的,后者显然是不应该的,这证明它不是把内容当作一个纯粹的 js 表达式去看待,我认为这样很 tm 离谱。

实践证明,对v-on(以及其它的所有指令),它接受的表达式,并非是被当作普通的 JS 表达式去看待,同时它不是只执行一次,而是每次遇到相应事件时都进行执行。因为 Vue 对此做过特殊处理,所以x.inc能行,而incN(5)不能行。

下面给出一个例子,以及 v-on 的心智模型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<script setup lang="ts">
import { ref } from 'vue';
const count = ref(0)
const incN = (n: number) => {
console.log('incN invoked') // logs every time
const res = () => {
count.value += n
}
res.self = res
return res
}
</script>

<template>
<button @click="incN(5)">doesn't work</button>
<button @click="count+=1">works</button>
<button @click="incN(5).self">works</button>
<p>counter: {{ count }}</p>
</template>

建立v-on关于表达式的心智模型:

  1. 表达式每次都会被执行,而非只执行一次(在 React 或原始 HTML 的编程模型中,都是只执行一次的)
  2. 表达式并非普通的 js 表达式——setup 中定义的 Ref,在表达式的顶层中会像普通变量那样操作,不需要获取 value 字段(而且 ts 能提供正确补全),比如表达式中可以出现count++count+=1
  3. 表达式执行时,Vue 会将$event添加到作用域中,它代表相应的 DOM 事件
  4. 表达式的最后一个操作是函数调用的时候(这里在运行时检查了表达式的语法树! JS 本身是做不到的!),直接执行表达式
  5. 否则认为表达式会返回一个函数,以$event为参数执行该函数

对于其它的内置指令,也要抱有警惕心,认识到它的表达式可能不是(必然不是)直接被传递给 js 的,Vue 在其中可以做任何幺蛾子。

不得不说,Vue 的这个设计会减少一些代码量,避免每次都在顶层写一个箭头函数去传递 event,但是会为闭包函数带来麻烦,而且会带来一定的心智负担,见仁见智吧。这里或许也体现了 Vue 的设计哲学——要实用,不要纯粹。v-on 的这个设计实际上对 v-for 也是方便的,后面会看到。

v-on用来绑定函数我们已经清楚了,那关于绑定属性呢?绑定属性使用v-bind,这是一种单向绑定,就像 react 中我们做的那样;v-model则是双向绑定——它只支持部分元素如 input,textarea 等,但支持直接把变量和组件绑定,不需要手动处理 getter,setter。

v-bind简写为:,因为它太过常用。下面展示了v-bindv-model的简单用法。

1
2
3
4
5
6
7
8
9
10
11
<script setup lang="ts">
import { ref } from 'vue';
const text = ref({a: 1})
const enabled = ref(true)
</script>

<template>
<button @click="enabled=!enabled">toggle</button>
<input type="text" :disabled="!enabled" v-model="text" />
<p>text: {{ text }}</p>
</template>

显然——v-bind接受的是普通 js 表达式,而v-model接受的必须是 LHS——左值,可以赋值的值。下面专门去学习一下。

ref,reactive,计算属性

对于响应式的变量,Vue 提供了两个实现——ref 和 reactive,它们各有其特性和用途,Vue 推荐优先使用 ref。计算属性的行为和 Ref 基本一致,这里也一并学习。

首先要知道,setup 只执行一次,不像 React 的函数式组件那样每次渲染都执行,这是起点。

ref

ref 函数接受一个初始化变量,返回Ref,Ref 是一个对象,其中把初始化的变量塞到 value 属性中(注意这里没有使用 Proxy,只使用了 getter、setter 去实现同样效果,但如果变量不是基本类型,那它仍旧会被转换成响应式对象),允许去监听 js 代码中对 value 的修改并做出相应的操作。

Vue 对 ref 有一个独特的处理——对指令表达式中的顶层出现的 ref 变量,Vue 会自动给它解包。顶层是指语法树的顶层,如a,而a.b,其中 b 为 ref 变量时,这就无效了。下面的代码测试该行为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<script setup lang="ts">
import { ref } from 'vue';

const a = {
b: ref('')
}
</script>

<template>
<button @click="a.b.value += '!'">emphsis</button>
<input type="text" v-model="a.b.value" />
<input type="text" v-model="a.b" disabled/> <!-- always [object Object] -->
<p>模板变量则不一样——它会自动添加 value: {{ a.b }}</p>
<p>text: {{ a.b.value }}</p>
</template>

这证明,对于指令表达式的执行,可以认为,Vue 会自动地使用同名的解包后的变量(即 value 字段的值)去遮蔽原来的 ref(同时记录 value 到 ref 的映射供后面操作),这使得顶层的 ref 不需要使用.value就能使用,而非顶层的 ref 必须显式给出.value

文本插值的逻辑亦有差别——注意到,指令表达式中a.b是不支持的,但模板变量中a.b则支持,但仔细测试又会发现,a.b + '!'这样又是不支持的,需要显式给定.value。这证明,模板变量对 Ref 的处理实际上适应我之前的认知——模板变量能够同时处理 Ref 和基础变量,Ref 会自动解包

上面说的遮蔽,看起来是无法通过单纯的 js 代码完成的,但其实仍旧是可以的——setup 函数可以认为是 return 了作用域下的所有变量,这里是记录了变量名的。这再次体现了 Vue 和 React 的不同之处——React 基于纯 js,即使是 jsx,实际上也只是 js 的语法糖,有等价的 js 存在;而 Vue 则是不追求这一点,做了更多封装,甚至不吝啬在运行时去解析和执行表达式,从而让语义能够超出 js 本身的限制,这对开发者来说更为便捷,但是会带来额外的心智负担,无法直接沿用 js 的心智模型,还是那句话,见仁见智吧。

这里会有一个疑惑——为什么要做这种“遮蔽”的魔法,而不是直接允许指令表达式能够接受 ref?这是因为为了方便在表达式中使用 js 表达式。比如下面的例子,如果不使用“遮蔽”的话,就会出现 ref 变量(对象)和基础变量互操作的应当避免的状况。

1
2
3
4
5
6
7
8
<script setup lang="ts">
import { ref } from 'vue';
const text = ref('hello!')
</script>

<template>
<input type="text" :value="text + '!'" />
</template>

总结:

  1. 执行指令表达式时,Vue 会使用同名解包后的变量遮蔽原 Ref 变量,非顶层的 Ref 变量享受不到这种遮蔽,只能手动解包;指令表达式不能返回 Ref 变量
  2. 执行文本插值时,Vue 同样会进行该遮蔽操作,但也支持插值表达式返回 Ref 变量(指令表达式则不支持),对于 Ref 变量,Vue 会自动将其解包

ref 具有深层响应性——ref 即使在持有数组、嵌套对象时,其元素的变更仍然是能够被检测到的。这种深层的响应性来自于 ref 会将复杂对象递归地转换为响应式代理(基于 reactive 函数)。

ref 的缺点就在于它的深层响应性——对大型数据的响应性会影响性能,以及使用外部库的时候,也难以使用 ref 去集成。Vue 提供了 shallowRef,只追踪对.value的访问以进行优化

reactive

ref 接受一个值,然后把它包装到一个对象中,使用 value 属性去访问该值,监听变更的方式是 getter、setter。reactive 则是直接把对象本身递归地转换成代理,监听变更的方式是 Proxy。

实际上,这是说:

  1. 当 x 是基本类型时,ref(x) == shallowRef(x)
  2. 当 x 是嵌套类型时,ref(x) == shallowRef(reactive(x))

reactive 也可以单独拿出来用,这时候就不需要使用.value去访问了,而是直接去访问相应字段。reactive 函数支持对象、数组、Map,Set 等

reactive 的一个缺点是,如果解构出来基本类型的值,那它的响应性就丢失了,这是显然的——基本类型直接被拷贝了。但对于嵌套的对象,它仍旧是具有响应性的,只不过不能更换对象自身,只能对自身进行修改罢了。

reactive 对象中可以包含 ref 对象并且当作普通变量一样使用,只有在 ref 对象是原 reactive 对象的嵌套字段的时候,该 ref 对象会自动解包,当 ref 对象是数组、Map、Set 的元素时,不会自动解包。这个性质很奇怪,但可以根据 reactive 对象的类型签名做判断——reactive 函数的的类型演算和这里的逻辑应该是一致的

1
2
3
4
const testA: {a: number} = reactive({ a: ref(1) })
const testB: {a: {b: number}} = reactive({a: {b: ref(2)}})
const testC: {a: {b: Ref<number>[]}} = reactive({a: {b: [ref(2)]}})
const testD: {a: {b: Map<string, Ref<number>>}} = reactive({a: {b: new Map([['hello', ref(1)]])}})

Vue 同样提供了 shallowReactive,仅追踪浅层对象的变更。

计算属性 computed

这个我们熟悉,类似 React 的 useMemo,但要再度重申——setup 函数只执行一次。

计算属性使用computed函数定义,该函数返回一个 Ref(实际上是 ComputedRef)。Vue 对计算属性的执行是显然的——执行计算属性函数,缓存返回值,检查该函数所依赖的所有变量并记录;如果这些变量有改变,则重新执行计算属性函数,缓存返回值,同时再度检查该函数依赖的所有变量并记录(依赖的变量可能会有变动)。

但和 React 不同的地方是,计算属性可以是可写的。计算属性传一个函数时认为是 getter,但也可以传一个包含getset的方法代表 getter,setter。下面展示了计算属性的使用,一个是演示只读的计算属性,一个是演示可读写的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
<script setup lang="ts">
import { computed, ref, type ComputedRef, type Ref, type WritableComputedRef } from 'vue';

const counter = ref(0)
const firstName = ref('')
const lastName = ref('')

const isOdd: ComputedRef<boolean> = computed(() => {
return counter.value % 2 == 1
})

const fullName: WritableComputedRef<string, string> = computed({
get() {
if (!firstName.value && !lastName.value) { return '' }
if (!lastName.value) {return firstName.value}
return `${firstName.value} ${lastName.value}`
},
set(value: string) {
if (!value) {
firstName.value = lastName.value = ''
return
}
const [fstName, ...last] = value.split(' ')
firstName.value = fstName
if (last.length !== 0) {
lastName.value = last.join(' ')
}
}
})

</script>

<template>
<button @click="counter+=1">inc</button>
<input v-model="fullName" placeholder="name"/>
<p>
counter: {{ counter }}<br/>
isOdd: {{ isOdd }}<br/>
firstName: {{ firstName }}<br/>
lastName: {{ lastName }}<br/>
</p>
</template>

最新的 Vue 版本支持在计算属性的 getter 中接受参数得到上一个值,在某些场景可能有用。

最佳实践是:计算属性的 getter 不要有副作用,同时不要修改计算属性的返回值。

侦听器 watch

上面一直在处理状态,该关注副作用了,侦听器就是 Vue 中的“useEffect”,而侦听器的定义确实和 React 那边类似。侦听器接受一个 Ref(包括 ComputedRef),Reactive,或者一个 getter 函数,或者前述内容组成的数组,作为自己的依赖,然后是回调。

显然能够预测到侦听器的行为——

  1. 如果传给它的是 Ref,则将该 Ref 加入依赖中,Ref 变更时执行回调
  2. 如果传给它的是 Reactive,同上
  3. 如果传给它的是 getter,通过执行它去得到所有的依赖,并在它们变更时执行回调,就像临时地定义了一个计算属性一样
  4. 如果传给它的是数组,对数组内的元素均执行上述操作

注意,这里侦听器传变量的话得传 Ref,Reactive 变量,如果非 Ref(比如 Reactive 的某个基础类型的字段,比如 props 的某个字段),得用 getter。为什么呢?我们知道,Vue是通过代理或getter、setter去监听的,但从Vue编译器看来,组件的所有代码是“原子”的——Vue无法直接确定watch的参数究竟是来自哪个响应式变量——它只能看到这个变量当前的值,而传递getter就允许让Vue独立去调用它,并检查究竟对应的是哪个变量。

侦听器比 useEffect 更为强大——它会直接把当前的所有依赖值和变更前的依赖值都作为参数传过来供使用,无论有多少个依赖,注意到下面的例子中,newValue的类型即为[obj]的类型。

然而,上面说的“当前的所有依赖值和变更前的依赖值”,并不准确——只要我传给它的不是基础类型,这时候 newValue 是等于 oldValue 的,下面的例子中也有体现。这是因为,变更的是它的深层,它自己没有改变。

1
2
3
4
5
6
7
8
9
10
11
const obj = reactive({
a: {
b: {
c: 1
}
}
})
watch([obj], (newValue, oldValue, onCleanup) => {
console.log(Object.is(newValue[0], oldValue[0])) // true
console.log('obj changed', newValue[0].a.b.c)
})

何时 oldValue 才有意义呢?使用基础类型的 Ref 的时候,以及使用 getter 函数的时候。

1
2
3
4
5
6
7
8
9
const a = ref(0)
const b = reactive({
value: 0
})

watch([a, () => b.value], (newValue, oldValue, onCleanup) => {
console.log(newValue[0] !== oldValue[0] || newValue[1] !== oldValue[1])
console.log(newValue, oldValue)
})

同时也注意到,监听器的回调的第三个参数onCleanup,这个参数用于这一趟的清理,它的行为等价于 React 的 useEffect return 的函数。

需要注意的是,对于 Ref 和 Reactive,watch 的监听是深层的,而对于 getter,对它的监听是浅层的,这就是说,getter 只有在返回不同对象的时候才认为需要执行回调。但这里可以配置是否使用深层监听,如果使用,则 getter 也会进行深层监听,这里不表。

监听器有诸多配置项,如配置只执行一次,如配置在定义时即刻执行,比如请求初始数据。

此外还有 watchEffect,它认为回调中操作到的依赖就是它的依赖,因此不需要传递依赖。watchEffect 必须是定义时即刻执行的,同时在回调是异步函数时,只有同步时访问的依赖会被监听到,这就是说,只有第一次 await 前访问的变量会被作为依赖。

不要认为监听器的依赖和 React Hook 的依赖是同一个东西,处理依赖的时候,总是扪心自问,我究竟什么时候才要重新执行我这个操作?

最后,监听器有和 useEffect 一样的问题——如果回调是 async 的,可能这一趟还没执行完的时候下一趟就开始执行了。考虑一个情景——我们在订单号变更的时候要修改展示的订单,这是一个异步调用,现在订单号变成 a,我们开始拉取数据,在数据到来之前,订单号又变成 b,开始拉取 b,这时候,如果 b 先拉取到,a 后拉取到,最后展示的就会是 a。

对于这个情况,Vue 和 React 的处理方式一致——用一个布尔变量表示是否忽略这一趟的结果,默认值为 false,在清理函数中把它设为 true,执行操作前检查它:

1
2
3
4
5
6
7
8
9
10
11
watchEffect((onCleanup) => {
let ignore = false
(async () => {
const res = getUser(id)
if (ignore) return
// ...
})()
onCleanup(() => {
ignore = true
})
})

下面给一个实例——按下按钮后 3 秒钟后,将计数器加 1,其中多次按下时重置计时。这个场景其实不适合使用监听器,只是作为演示(直接做节流不就行了),但其实 React 里也经常这么干(用一个没有意义的 state 去专门用来触发 Effect)。注意到 Vue 的代码比 React 的更为舒服——支持整个回调为 async 函数,这归功于 Vue 通过参数而非是通过返回值去得到清理函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<script setup lang="ts">
import { ref, watch } from 'vue';

const counter = ref(0)

async function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms))
}

const refresher = ref(0) // 没有意义,只是用于通知 watch
watch(refresher, async (n,o,onCleanUp) => {
let ignore = false
onCleanUp(() => ignore = true) // 如果函数是 async 的,onCleanUp 必须在 await 之前调用!
await sleep(3000)
if (ignore) return
counter.value++
})
</script>

<template>
<button @click="refresher++">incDelay</button>
{{ counter }}
</template>