Vue3

Vue2与Vue3有什么不同

响应式实现方法不同

Object.defineProperty

在vue2中的响应式基于Object.defineProperty,能监听属性的gettersetter,然后在getter中触发依赖收集,在setter中触发依赖更新。

dep和watcher

在vue2中,每个响应式属性都有一个dep实例负责管理它的依赖:触发getter时调用dep.depend方法收集依赖,触发setter时调用dep.notify通知依赖更新。然后在vue2中,依赖被抽象为一个Watcher实例,通常情况下new Watcher实例就会开启依赖收集。

proxy + Object.defineProperty

在vue3中的响应式基于Proxy,解决了vue2中修改数组下标无法被监听到的问题,解决了vue2中无法监听到对象属性的新增和删除的问题(解决了什么问题)。同时vue3中也不是完全抛弃了Object.defineProperty,vue3中的ref就使用了Object.defineProperty来接听value属性的gettersetter

副作用函数effect

在vue3中,不存在watcher和dep,取而代之的是副作用函数effect,使用effect函数注册副作用函数,就相当于在vue2中创建watcher实例,副作用函数的依赖改变,副作用函数就会重新执行。

API风格不同

在vue2使用的是选项式api,在vue3中推荐使用组合式api。组合式api的好处是,可以将逻辑紧密相关的代码写在一起,而且在组合式api的写法下,源码改为了函数式编程(vue2是类式编程),更方便按需引入,因为tree-shaking必须配合按需引入的写法来实现,所以vue3中的tree-shaking的效果更好,打包后的文件更小。

生命周期钩子函数不同

在vue3中

  • setup函数替代了beforeCreatecreated这2个生命周期函数
  • beforeDestorydestoryed钩子被替换为beforeUnmountunmounted
  • 并且所有生命周期函数在原来的基础上添加了前缀on

组件混入mixin

在vue3中不推荐使用组件混入mixin来复用代码,因为组件混入mixin只适合选项式api的写法,不适合用在组合式api中,而且mixin也容易和组件本身的配置项产生冲突。在vue3中推荐将代码封装到hooks中,需要的时候再引入。

fragment

在vue2中的组件模板内,只能使用一个根标签,而在vue3中,组件模板内可以使用多个根标签,会自动被fragment标签包裹。

v-model

在vue2,vue3中,v-model在自定义组件上的用法不同。在vue2中,在自定义组件上使用v-model相当于动态绑定了value属性,然后监听组件的input事件,在vue3中,在自定义组件上使用v-model,相当于动态绑定了modelValue属性,然后监听的是组件的update:modelValue事件。

v-if和v-for的优先级

在vue3中v-if的优先级高于v-for,而在vue2中则反之。这样当将v-ifv-for写在一起的时候,通常会就会报错,从而避免开发者将这两个指令写在一起。

新增组件

在vue3中新增了SuspenseTeleport组件。

keep-alive

vue2,vue3中keep-alive的写法不同。在vue2中,只需使用keep-alive标签包裹router-view,就能实现组件的缓存;然后在keep-alive标签上添加一些属性比如include,max,就能实现更为精细的缓存控制。

在vue3中,由于组件不需要命名,也不需要注册,所以只能通过路由对象并结合v-if来控制路由组件的缓存。在模板中的写法也有所不同,结构更为复杂。

自定义指令的钩子也不同

在vue2中的自定义指令钩子包括bind(指令首次绑定到元素上时触发),inserted(元素插入到父节点中时触发),update(组件更新时触发),unbind(指令解绑时触发)。

而在vue3中,自定义指令的钩子则和vue3中的生命周期函数类似,比如mounted(元素插入到父元素时触发),updated(组件更新完成后触发),unmounted(指令解绑,元素卸载的时候触发)。

Vue3.0里为什么要用 Proxy API 替代 defineProperty API ?

使用proxy来替代Obejct.defineProperty ,是因为基于Obejct.defineProperty 实现的响应式存在诸多问题:

  • Object.defineProperty 只能监听一个属性gettersetter,如果想要给一个对象添加响应式,需要遍历它的所有属性并依次调用Object.defineProperty 方法,性能较差
  • Object.defineProperty 无法监听到数组方法对数组元素的修改,无法监听到通过下标对数组的修改, 无法监听到对象属性的增加和删除。

Proxy监听的是整个对象,完全没有Object.defineProperty在实现响应式上存在的问题,而且不需要遍历对象每个属性逐个添加响应式,性能更好。

Composition Api 与 Options Api 有什么不同?

代码组织方式

选项式api按照代码的类型来组织代码;而组合式api按照代码的逻辑来组织代码,逻辑紧密关联的代码会被放到一起。

代码复用方式

在选项式api这,我们使用mixin来实现代码复用,使用单个mixin似乎问题不大,但是当我们一个组件混入大量不同的 mixins 的时候,就存在两个非常明显的问题:命名冲突和数据来源不清晰

而在组合式api中,我们可以将一些可复用的代码抽离出来作为一个函数并导出,在需要在使用的地方导入后直接调用即可。这个种模块化的方式,既解决了命名冲突的问题

vue3中的响应式是如何实现的?

基本实现

vue3中的响应式是通过proxy+副作用函数实现的。

副作用函数,指的是会产生副作用的函数,也就是说函数的执行会影响其他函数的执行。在vue3中,函数内部访问了响应式数据的函数,就可以被视为副作用函数,也叫做effect。

1
2
3
4
const obj = { text: "hello world" } 
function effect(){
document.body.innerHTML = obj.text
}

我们希望当obj.text被修改的时候,上述副作用函数effect会重新执行,但是目前来看,这个功能无法实现,因为obj不是响应式数据,它被修改无法被检测到。因此我们需要把这个数据变成响应式的,然后当触发这个数据的getter的时候,收集这个副作用函数,当触发这个数据的setter的时候,执行这个函数。在vue3中,我们使用proxy来劫持对象的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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
const bucket = new WeakMap() //存储了所有原始数据的depsMap
let activeEffect
function effect(fn) {
activeEffect = fn
fn()//触发依赖收集
}
// 进行依赖收集
function track(target, key) {
if (!activeEffect) { //如果没有活跃的副作用函数,不进行副作用收集
return
}
let depsMap = bucket.get(target)
if (!depsMap) {
depsMap = new Map()
bucket.set(target, depsMap)
}
let deps = depsMap.get(key)
if (!deps) {
deps = new Set()
depsMap.set(key, deps)
}
deps.add(activeEffect) //收集副作用
}
function trigger(target, key) {
const depsMap = bucket.get(target)
if (!depsMap) {
return
}
const deps = depsMap.get(key)
if (!deps) {
return
}
deps.forEach(fn => fn())
}
const data = {
name: 'sanye',
age: 29,
bool: true
}
const obj = new Proxy(data, {
get(target, key) {
track(target, key)
return target[key]
},
set(target, key, value) {
target[key] = value
trigger(target, key)
}
})
effect(() => {
console.log(obj.name)
})
setTimeout(() => {
// obj.children = ""
obj.name = '三叶'
}, 1000)

在上述代码中:

  • 使用proxy获取代理对象,给对象添加响应式
  • 定义了effect函数,用来注册副作用函数,被注册的副作用函数会被设置为activeEffect,这是一个全局变量,然后被调用,访问响应式数据。
  • 获取对象的属性,会触发getter,然后调用track方法进行副作用收集,每个属性都有自己的deps集合
  • 修改对象的属性,会触发setter,然后调用trigger方法,调用这个属性收集到的所有副作用函数

更新依赖

但是上述代码还有缺陷,当effect函数是下面这种情况时:

1
2
3
effect(()=>{
console.log(obj.bool? obj.name : obj.age)
})

初次执行时,由于obj.bool为true,所以注册副作用函数的时候,只有obj.bool的deps集合和obj.name的deps集合会收集这个副作用函数(箭头函数)为依赖。当我们修改obj.bool为false的时候,三元表达式的值就和obj.name没有任何关系了,但是当我们修改obj.name的时候,副作用函数还是会被执行。因为obj.name的deps集合中还存在这个副作用函数。

解决办法就是,后续每次触发副作用函数的时候,都先从存储有这个副作用函数的deps集合中,删除这个副作用函数,然后再执行一次依赖收集。因此我们还需要知道这个副作用函数,被哪些deps集合收集了

修改effect函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function effect(fn) {
const effectFn = () => {
cleanup(effectFn) //清除旧的依赖
//重新进行依赖收集
activeEffect = effectFn
fn()
}
effectFn.deps = [] //在effectFn上挂载deps,用于记录哪些deps集合中存储了这个副作用函数
effectFn()// 立即调用一次,触发依赖收集
}
function cleanup(effectFn) {
effectFn.deps.forEach(dep => {
dep.delete(effectFn) //从集合中删除这个副作用函数
})
effectFn.deps.length = 0
}

effectFn是包装后的副作用函数,不仅会执行传入的副作用函数,还会进行依赖清除。

修改的track函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function track(target, key) {
if (!activeEffect) { //如果没有活跃的副作用函数吗,不进行副作用收集
return
}
let depsMap = bucket.get(target)
if (!depsMap) {
depsMap = new Map()
bucket.set(target, depsMap)
}
let deps = depsMap.get(key)
if (!deps) {
deps = new Set()
depsMap.set(key, deps)
}
// 双向依赖收集
deps.add(activeEffect) //deps集合收集副作用函数
activeEffect.deps.push(deps) //副作用函数收集deps集合
}

无限循环问题

上述修改后的代码仍然存在问题,当我们修改数据触发trigger的时候,会陷入无限循环。问题就处在trigger函数中。

1
2
3
4
5
6
7
8
9
10
11
function trigger(target, key) {
const depsMap = bucket.get(target)
if (!depsMap) {
return
}
const deps = depsMap.get(key)
if (!deps) {
return
}
deps.forEach(fn => fn()) //注意这行代码
}

当我们修改一个响应式数据的属性,会先找到这个属性的deps集合,然后遍历执行其中的副作用函数,对于每个副作用函数,执行时都会先将自己从这个dep集合中移除,然后再次触发依赖收集:

1
2
3
4
5
6
const effectFn = () => {
cleanup(effectFn) //清除旧的依赖
// 重新进行依赖收集
activeEffect = effectFn
fn()
}

于是这个副作用函数,又被放入了这个正在遍历的deps集合中。语言规范中有对此明确说明,在调用forEach遍历set集合时,如果一个值已经被访问过了,但该值被删除并重新添加到集合,如果此时forEach遍历没有结束,这个值会被重新访问。因此上面的代码会无限执行。

解决办法也很简单,只需要在遍历执行副作用函数的时候,将deps集合拷贝一次然后遍历这个集合就行

1
2
const copy_deps = new Set(deps) //正确的做法是先把这个集合拷贝
copy_deps.forEach(fn => fn())

嵌套的effect

1
2
3
4
5
6
7
8
effect(function fn1(){
console.log('fn1执行')
effect(function fn2(){
console.log('fn2执行')
temp2 = obj.bar
})
temp1 = obj.foo
})

在这种情况下,我们希望当修改obj.foo时,会触发fn1的执行,由于fn2嵌套在fn1中,所以也会触发fn2的执行。而当修改obj.bar的时候,只会触发fn2的执行。但实际情况并非如此,当我能修改obj.foo的时候,反而输出”fn2执行了”。我们来分析一下:

当执行第一个effect函数的时候,包装后的fn1会被立即执行,activeEffect会被设置为包装后的fn1,然后执行fn1:输出”fn1执行了”,然后执行第二个effect函数,同理,包装后的fn2会被立即执行,activeEffect会被设置为包装后的fn2,然后执行fn2:输出”fn2执行了”,然后访问obj.bar,于是包装后的fn2就会被obj.bar的deps集合收集,然后继续执行fn1,执行temp1 = obj.foo访问obj.foo,但此时activeEffect还是包装后的fn2,于是包装后的fn2就会被obj.foo的deps集合收集。

所以说是我们的activeEffect设计的有问题。 我们需要再加一个副作用函数栈effectStack:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
let activeEffect
const effectStack = []
function effect(fn) {
// 将副作用函数进行包装,后续存储在deps集合中的也是effectFn不是fn
const effectFn = () => {
cleanup(effectFn) // 先清除依赖
//再进行依赖收集
activeEffect = effectFn //将这个副作用函数设置为activeEffect(活跃的副作用函数)
effectStack.push(activeEffect)
fn() // 触发依赖收集
effectStack.pop()//收集完依赖后弹出
activeEffect = effectStack[effectStack.length - 1] // 重新设置activeEffect
}
effectFn.deps = [] //记录了在哪些deps集合中存储了这个副作用函数
effectFn()// 立即调用一次,触发副作用收集
}

这样我们解决了嵌套effect情况下的依赖收集错误的问题了。

无限递归问题

再看下面这种情况:

1
2
3
effect(()=>{
obj.foo++
})

再这个传入的副作用函数中,不但访问了obj.foo还修改了obj.foo,当注册这个副作用函数的时候,会立即执行包装后的副作用函数

  • 先清除依赖
  • 设置activeEffect为这个包装了的副作用函数
  • 执行obj.foo++,这个操作可以分为2部分,先读取,再修改obj.foo的值。
  • 在读取obj.foo的值的时候,这个被包装了的副作用函数,就会被收集到obj.foo的deps集合中,然后又修改了obj.foo,导致这个被包装了的副作用函数又被执行,可以先前这个包装了的副作用函数并没有执行完毕,所以是函数内部调用了函数,所以导致了无限递归问题

解决这个问题的方法也很简单,只需修改trigger中的代码即可,如果trigger触发的副作用函数等于activeEffect,也就是正在执行的副作用函数,则直接返回

1
2
3
4
5
6
7
copy_deps.forEach(fn => {
//不加入这行代码就会触发无限递归
if (fn == activeEffect) {
return
}
fn()
})

添加调度

目前为止,只要我们修改了响应式数据,就会触发对应的副作用函数立即执行。我们能不能控制副作用函数的执行时机和次数呢?也就是说能不能添加调度呢?

我们可以为effect函数设计一个选项参数options,它允许用户指定调度器。

1
2
3
4
5
effect(()=>{console.log(obj.foo)},{
scheduler(fn){
setTimeOut(fn,0)
}
})

同时我们将options挂载在包装后的副作用函数上,修改effect函数的代码

1
2
effectFn.deps = [] //记录了在哪些deps集合中存储了这个副作用函数
effectFn.options = options

然后在trigger函数中,执行回调函数的时候,如果发现回调函数中存在调度器,则执行调度器。

1
2
3
4
5
6
7
8
9
10
11
12
13
const copy_deps = new Set(deps) //正确的做法是先把这个集合拷贝
copy_deps.forEach(fn => {
// 如果trigger触发的副作用函数等于activeEffect,也就是正在执行的副作用函数,则直接返回
// 不加入这行代码就会触发无限递归
if (fn == activeEffect) {
return
}
if (fn.options.scheduler) {//如果有调度器则使用调度器
fn.options.scheduler(fn)
} else {
fn()
}
}

到此为止我们还只能修改副作用函数执行的时机:我们可以将副作用函数放入宏任务队列,让其在下一个宏任务阶段执行,还不能控制回调函数的执行次数,修改了几次响应式数据,就会触发几次副作用函数的执行。有时候我们只关系结果而不关心中间的过程,所以多次执行副作用函数是没必要的。我们可以借鉴vue2中的nextTick来实现控制副作用函数的执行次数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const jobQueue = new Set() //任务队列,具有去重机制
let isFlushing = false //标志是否在更新队列
const p = Promise.resolve()
const flushJob = () => {
if (isFlushing) {
return
}
isFlushing = true //上锁
p.then(() => {
jobQueue.forEach(fn => fn()) //将清空任务队列的任务放入微任务队列
}).finally(() => [
//只有在微任务阶段,清空微任务队列后,isFlushing才会被修改为false
//这就确保了在一次事件循环,一个副作用函数只会被执行一次(由于去重),清空任务队列的任务也只会放入微任务队列一次
isFlushing = false
])
}

然后我们就可以在调度器中这么写:

1
2
3
4
5
6
effect(() => { console.log(obj.age) }, {
scheduler: (fn) => {
jobQueue.add(fn)
flushJob()
}
})

vue3中的计算属性是如何实现的

懒执行的effect

在深入讲解计算属性之前,我们需要先来聊聊如何实现懒执行的effect,在我们上面的中,使用effect注册的副作用函数会立即执行,比如:

1
2
3
effect(()=>{
console.log(obj.foo) //这段代码会立即执行
})

但在有些场景下,我们不希望它立即执行,而是在需要它的时候再执行,比如计算属性,这时我们可以通过在options中添加lazy属性来达到目的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function effect(fn, options) {
// 将副作用函数进行包装,后续存储在deps集合中的也是effectFn不是fn
const effectFn = () => {
cleanup(effectFn) // 先将这个副作用函数从deps集合中移除
activeEffect = effectFn //将这个副作用函数设置为activeEffect(活跃的副作用函数)
effectStack.push(activeEffect)
fn() // 触发依赖收集
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1] // 重新设置activeEffect
}
effectFn.deps = [] //记录了在哪些deps集合中存储了这个副作用函数
effectFn.options = options
if (!options || !options.lazy) {
effectFn()// 立即调用一次,触发副作用收集
}
return effectFn
}

这样我们调用 effect就能拿到返回的effectFn函数然后手动调用。但是只是实现了手动调用effectFn函数其实意义不大,最好还能返回传入的副作用函数的返回值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function effect(fn, options) {
// 将副作用函数进行包装,后续存储在deps集合中的也是effectFn不是fn
const effectFn = () => {
// console.log('effectFn被执行了')
cleanup(effectFn) // 先将这个副作用函数从deps集合中移除
activeEffect = effectFn //将这个副作用函数设置为activeEffect(活跃的副作用函数)
effectStack.push(activeEffect)
const res = fn() // 触发依赖收集
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1] // 重新设置activeEffect
return res //返回值
}
effectFn.deps = [] //记录了在哪些deps集合中存储了这个副作用函数
effectFn.options = options
if (!options || !options.lazy) {
effectFn()// 立即调用一次,触发副作用收集
}
return effectFn
}

然后我们就可以开始实现计算属性了。对应到vue2中,fn就是getter,effectFn就是get方法。

基本实现

1
2
3
4
5
6
7
8
9
10
11
function computed(getter) {
const effectFn = effect(getter, {
lazy: true,//配置为true,调用effect的时候,effectFn只会被返回不会调用
})
const obj = {
get value() {
return effectFn()
}
}
return obj
}

首先我们定义一个computed函数,这个函数接收一个getter函数作为参数,我们把getter函数作为副作用函数,用它创建一个lazyeffectcomputed函数的执行会返回一个对象,它的value属性是一个访问器属性,只有读取value的值的时候,才会执行effectFn并将其返回值返回。

1
2
3
4
const data = {foo:1, bar:2}
const obj = new Proxy(data, {/*..*/})
const sumRes = computed( () => obj.foo + obj.bar )
console.log(sumRes.value) // 3

添加缓存

我们的代码现在还不算完美,因为计算属性的依赖(getter中访问的响应式数据)被修改了,计算属性的effectFn也会重新执行,这算不上懒计算,而且我们多次访问sumRes.value的值,会导致effectFn进行多次计算,即使obj.fooobj.bar本身没有发生变化。为了解决这个问题,我们需要添加值缓存的功能,具体的来说,需要添加调度器scheduler,需要修改返回的对象的get value()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function computed(getter) {
let dirty = true // 标记计算属性是否为脏
let value // 存储缓存的值
// 得到"get"
const effectFn = effect(getter, {
lazy: true,//配置为true,调用effect的时候,effectFn只会被返回不会调用
scheduler: () => {//当计算属性的依赖改变就会触发setter,然后调用trigger,然后调用scheduler
// 但是计算属性的依赖更新,并不会调用effectFn,不会重新计算,只是将这个计算属性标记为脏
dirty = true
}
})
const obj = {
get value() {
if (dirty) {
value = effectFn() //只有计算属性为脏的时候才重新计算值
dirty = false
}
return value
}
}
return obj
}

添加依赖管理

现在我们设计的计算属性已经趋于完美了,但是它还有一个缺陷,体现在我们在一个effect中读取计算属性的值的时候:

1
2
3
4
5
const sumRes = computed( () => obj.foo + obj.bar )
effect(()=>{
console.log(sumRes.value)
})
obj.foo++

我们期望修改了obj.foo,副作用函数会重新执行,就像在模板中使用了计算属性,然后计算属性的依赖被修改了,模板会更新一样,但是事实并非如此。其实这是一个典型的effect嵌套问题:执行effect函数,会立即执行包装后的副作用函数effectFn,将activeEffect修改为effectFn,然后访问sumRes的value属性,触发计算属性的getter,然后执行计算属性的effectFn,然后计算属性的effectFnobj.fooobj.bardeps集合收集,然后activeEffect又被切换,然后传入的副作用函数(箭头函数)执行完毕,然后发现它没有被任何数据收集,计算属性的依赖只会收集计算属性的副作用函数

解决办法就是,当访问计算属性的value属性的时,调用track方法收集副作用,然后计算属性的依赖被修改后,调用trigger通知副作用函数执行。

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
function computed(getter) {
let dirty = true
let value
const effectFn = effect(getter, {
lazy: true,//配置为true,调用effect的时候,effectFn只会被返回不会调用
scheduler: () => {//当计算属性的依赖改变就会触发setter,然后调用trigger,然后调用scheduler
//但是计算属性的依赖更新,并不会调用计算属性的get函数,也就是不会调用effectFn,只是将这个计算属性标记为脏
dirty = true
trigger(obj, 'value')// 计算属性的依赖改变了,就通知依赖计算属性的副作用函数更新
}
})
const obj = {
get value() {
if (dirty) {
value = effectFn()
dirty = false
}
// track一般情况只能在代理对象中的get中触发,这里我们手动触发
// 目的是给计算属性的value也添加依赖管理
track(obj, 'value')
return value
}
}
return obj
}

vue3中的watch是如何实现的?

定义getter

所谓watch就是观测一个响应式的数据,当数据发生变化时通知并执行相应的回调函数。实际上,watch的实现本质上就是利用了effectoptions.scheduler。下面我们实现一个最简单的watch函数:

1
2
3
4
5
6
7
8
//source是响应式数据(proxy对象),cb是回调函数
watch(source, cb){
effect(()=>{source.foo},{
scheduler(){
cb()
}
})
}

上述代码中,当source.foo被修改,就会执行回调函数,而不是执行注册的副作用函数。

从这里也可以看出,effect主要负责包装好的副作用函数设置到activeEffect上,而track也是从activeEffect上收集副作用函数,存储在bucket中,二者配合工作才实现了vue3的响应式系统。

在上述代码中,我们硬编码了对foo的读取操作,为了让watch函数更具有通用行,我们需要封装一个通用的读取操作

1
2
3
4
5
6
7
8
9
10
11
function traverse(obj) {
// 如果obj不是对象直接返回
if (typeof obj !== 'object' || obj == null) {
return
}
// 否则递归读取子属性
for (let key in obj) {
traverse(obj[key])
}
return obj //返回传入的对象
}

递归的读取一个响应式对象上的所有子属性。当任意一个属性发生变化,都能够触发回调函数的执行。

watch函数除了可以观测响应式数据(proxy对象),还可以接收一个getter函数

1
2
3
watch(()=>{ obj.foo },()=>{
console.log('obj.foo的值改变了')
})

在getter函数内部,用户可以指定watch依赖(监听)哪些响应式数据,只有当这些数据变化才会触发回调函数的执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function watch(source, cb, options) {
// 处理getter
let getter // getter是需要有返回值的,也要访问响应式数据触发依赖收集
if (typeof source == 'function') {
getter = source // 如果source是函数的话就,这个函数就被当作getter
} else {
// 不是函数就当理成对象处理,这不就意味默认深度监听吗
// 这解释了为什么在vue3中监听proxy对象变化的时候默认是深度监听。
getter = () => traverse(source) //如果source是对象的话,watch回调中的value也是这个对象
}
effect(() => getter(),{
scheduler(){
cb()
}
})
}

拿到新值与旧值

现在实现的watch还缺少一个非常重要的功能,我们调用wath的回调cb中,通常能获取到新值和旧值。首先我们需要在watch中定义新值与旧值,然后需要获取到这个值,这就必须拿到调用effect返回的包装后的副作用函数effectFn。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function watch(source, cb) {
// 处理getter
let getter //getter是需要有返回值的,也要访问响应式数据触发依赖收集
if (typeof source == 'function') {
getter = source //如果source是函数的话就,这个函数的返回值就是watch中的value
} else {
// 不是函数就当理成对象处理,这不就意味默认深度监听吗
// 这解释了为什么在vue3中监听proxy对象变化的时候默认是深度监听。
getter = () => traverse(source) //如果source是对象的话,watch回调中的value也是这个对象
}
let newValue, oldValue
const effectFn = effect(() => getter(), {
lazy: true, //则在effect函数内部不会调用effectFn
// 当getter中的依赖改变,effectFn会被调用,但是如果effectFn有scheduler,则调用scheduler
scheduler(){
newValue = effectFn() // 重新调用effectFn()拿到新的值
cb(newValue, oldValue) // 调用回调函数拿到新值和旧值
oldValue = newValue // 将新值设置为旧值
}
})
oldValue = effectFn() //watch默认设置lazy为true然后手动调用,就是为了拿出value初始化oldValue吗
}

对应到vue2中,此处的scheduler像极了watcher的run方法,不但负责调用get(对应effectFn),还负责调用cb。

立即执行的watch

默认情况下,一个watch中的回调,只会在监听的响应式数据发生变化后才变化,在vue中,可以通过指定immediate:true让回调函数立即执行。仔细思考后可以发现,立即执行回调函数和后续数据变化执行回调函数的逻辑相同,所以我们可以把调度函数scheduler封装为一个通用的函数。修改后的代码如下:

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
function watch(source, cb, options) {
// 处理getter
let getter //getter是需要有返回值的,也要访问响应式数据触发依赖收集
if (typeof source == 'function') {
getter = source //如果source是函数的话就,这个函数的返回值就是watch中的value
} else {
// 不是函数就当理成对象处理,这不就意味默认深度监听吗
// 这解释了为什么在vue3中监听proxy对象变化的时候默认是深度监听。
getter = () => traverse(source) //如果source是对象的话,watch回调中的value也是这个对象
}
let newValue, oldValue
const job = () => {
newValue = effectFn() //获取新的值并且还会重新收集依赖,调用effectFn这一部即便不是在watch中,也是effect要做的
//同时传入的cb也应该被调用
cb(newValue, oldValue)
oldValue = newValue
}
const effectFn = effect(() => getter(), {
lazy: true, //则在effectFn函数内部不会调用effectFn
// 当getter中的依赖改变,effectFn会被调用,但是如果effectFn有scheduler,则调用scheduler
scheduler: job
}
//这二者是有取舍的,二者都会调用一次effectFn触发依赖收集
if (options && options.immediate) {
job()
} else {
//也就是说,即便immediate不为true,也会调用一次effectFn进行依赖收集。
oldValue = effectFn() //设置lazy为true然后手动调用就是为了拿出value吗
}
}

回调函数的执行时机

除了指定回调函数为立即执行外,还可以通过其他选项参数,来指定回调函数的执行时机。例如在vue3中通过flush选项来指定,当flush的值post,代表调度函数需要将副作用函数放到微任务队列中,等待dom更新后再执行。

1
2
3
4
5
6
7
8
9
10
11
const effectFn = effect(() => getter(), {
lazy: true, //则在effectFn函数内部不会调用effectFn
// 当getter中的依赖改变,effectFn会被调用,但是如果effectFn有scheduler,则调用scheduler
scheduler(){
if(options.flush == 'post'){
Promise.resolve().then(job)
}else{
job()
}
}
}

过期的副作用

竞态问题通常在多进程或者多线程中被提及,前端工程师可能很少讨论它,但是日常工作中,你可能早就遇到过与竞态问题相似的场景。

1
2
3
4
5
let finalData
watch(obj, async ()=>{
const res = await fetch('/path/to/request')
finalData = res
})

假设我们第一次修改obj对象的某个字段值,这会导致回调函数执行,同时发送了第一次请求A,随着时间的推移,在请求A的结果返回前,我们对obj对象的某个字段做了第二次修改,这会导致发送第二次请求B,此时请求A和请求B都在进行中,那么哪个请求会先到达?我们不确定,如果请求B先于请求A到达,这就会导致最终finalData存储的是A请求返回的结果,但由于B请求是后发送的,因此我们认为请求B返回的数据才是最新的,所以我们希望变量finalData存储的是B请求返回的结果,而非A请求返回的结果。

实际上,我们可以将这个问题做一个总结,请求A是副作用函数第一次执行时所产生的副作用,请求B是副作用函数第二次执行时所产生的副作用。由于请求B后发生,所以请求A的结果应该被视为最新的,而请求A已经过期了,其产生的结果应该被视为无效。

归根结底,我们需要一个让副作用过期的手段。我们来看看vue是怎么做的。

1
2
3
4
5
6
7
8
9
10
11
12
13
let finalData
watch(obj, async(newValue, oldValue, onInvalidate)=>{
let expired = false //默认没有过期
onInvalidate(()=>{
//当再次调用回调函数时候,这个传入的回调函数就会被执行,然后这个副作用函数就过期了
expired = true
})
//只有当该副作用函数的执行没有过期,才会执行后续操作
const res = await fetch('/path/to/request')
if(!expired){
finalData = res
}
})
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
function watch(source, cb, options) {
// 处理getter
let getter //getter是需要有返回值的,也要访问响应式数据触发依赖收集
if (typeof source == 'function') {
getter = source //如果source是函数的话就,这个函数的返回值就是watch中的value
} else {
// 不是函数就当理成对象处理,这不就意味默认深度监听吗
// 这解释了为什么在vue3中监听proxy对象变化的时候默认是深度监听。
getter = () => traverse(source) //如果source是对象的话,watch回调中的value也是这个对象
}
let newValue, oldValue
let cleanup
const onInvalidate = (fn) => cleanup = fn
const job = () => {
newValue = effectFn() //获取新的值并且还会重新收集依赖,调用effectFn这一部即便不是在watch中,也是effect要做的
if(cleanup) cleanup()
//同时传入的cb也应该被调用
cb(newValue, oldValue, onInvalidate)
oldValue = newValue
}
const effectFn = effect(() => getter(), {
lazy: true, //则在effectFn函数内部不会调用effectFn
// 当getter中的依赖改变,effectFn会被调用,但是如果effectFn有scheduler,则调用scheduler
scheduler: job
}
//这二者是有取舍的,二者都会调用一次effectFn触发依赖收集
if (options && options.immediate) {
job()
} else {
oldValue = effectFn() //设置lazy为true然后手动调用就是为了拿出value吗
}
}

注册一次监听watch函数只会执行一次,而回调函数可以执行多次,每次执行的回调函数都占用了不同的内存空间。

监听ref

目前我们实现的watch函数只能监听reactive包装的数据,也旧是proxy对象,或者proxy对象上的属性,如何监听ref包裹的对象,也就是refImpl对象的变化呢?

当传入watch的是一个refImpl对象,我们watch内部会将其识别出来,然后将() => source.value作为getter

1
2
3
4
5
6
if (isRef(source)) {
// 如果 source 是 ref
getter = () => source.value
} else if (cb) {
// 其他情况...
}

访问ref对象的value属性,同样能触发副作用收集,也是从activeEffect中获取副作用函数,不过不是存储在bucket中,而是ref对象自己的dep属性中(值是一个集合)。

watch能正常监听refImpl对象的改变,比如ref(0);但是监听不到ref({a:1})对象的改变。因为默认情况,watch只会监听refImpl对象的value属性的变化,如果value属性未变,就不会触发回调函数,即便value是个对象,且它的属性改变了,但是只要它的引用没变,回调函数也不会执行。

这个问题可以通过开启深度监听解决(在第三个参数传入{ deep: true }),或者监听ref.value(返回一个reactive包装的对象),因为watch监听reactive类型的数据默认是深度监听(递归访问子属性)。

你了解过watchEffect吗

watch 的套路是:既要指明监视的属性,也要指明事件的回调。watchEffect 的套路是:不用指明监视哪个属性,回调中用到哪个属性,那就监视哪个属性。

1
2
3
4
5
6
// watchEffect 所指定的回调中用到的数据只要发生变化,则直接重新执行回调。
watchEffect(() => {
const x1 = sum.value
const x2 = person.age
console.log('watchEffect配置的回调执行了')
})

简要源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function watchEffect(fn, options = {}) {
let cleanup
const onInvalidate = (fn) => { cleanup = fn }

const effectFn = effect(() => {
if (cleanup) cleanup() // 清理上一次的副作用
fn(onInvalidate) // 执行用户传入的函数
}, {
scheduler: () => {
// 可配置 flush 时机
queueJob(effectFn)
}
})
// 返回 stop 函数
return () => stop(effectFn)
}
1
2
3
4
5
6
7
8
9
10
watchEffect((onInvalidate) => {
const controller = new AbortController()
fetch('/api', { signal: controller.signal })
.then(res => res.json())
.then(data => console.log(data))

onInvalidate(() => {
controller.abort() // 传入的这个回调函数,会在下一次cb中被执行,作用是取消本次cb中的请求
})
})

也就是说,传入watchEffect的副作用函数,还支持配置形参onInvalidate。onInvalidate允许用户自定义cleanup函数,在本次回调中指明如何清理本次回调的副作用,在传入watchEffect的副作用函数每次重新执行前,都会先进行副作用清理,清理上次回调的副作用。

watchEffect还会返回一个stop函数,支持清理包装后的副作用函数,原理就是从deps中删除对应的副作用函数(清理依赖)

总结

watchEffect可以看成简化版的watch,省略了cb,或者说将cb和副作用函数合并到一起了。传入WatchEffect的函数会立即执行一次, 触发依赖收集(收集副作用函数),这个函数中使用了哪些响应式数据,就监听哪些响应式数据,当这些响应式数据改变,就重新执行回调函数。

ref和reactive对象依赖收集的方式不同吗?

reactive对象,指的其实是reactive包装的对象,本质是proxy对象。proxy对象触发getter的时候,通过trackactiveEffect上收集依赖,存储到bucket中(具体存储在某个属性的deps集合中),然后触发setter的时候,通过trigger触发依赖更新

查看ref函数源码,可以观察到ref包装的对象,也就是refImpl对象,触发getter的时候,执行的是trackRefValue,然后触发setter的时候,执行的是triggerRefValue 。观察refImpl对象,可以发现依赖它的依赖,或者说收集的副作用函数存储在refImpl.dep上。

观察trackRefValue的源码可知,trackRefValue也是从activeEffect上收集依赖(激活的副作用函数),也就是说它们收集的东西都是一样的,都是副作用函数,而这个副作用函数由effect函数来提供,只不过依赖存储的位置不同

1
2
3
4
5
6
7
8
9
export function trackRefValue(ref) {
if (shouldTrack && activeEffect) {
// ref.deps 存储所有依赖它的 effect
ref.dep = getDepFromReactiveOrRef(ref)
//双向依赖收集
ref.dep.add(activeEffect)
activeEffect.deps.push(ref.dep)
}
}

toRef()和toRefs()

响应式丢失

1
2
3
const obj = reactive({ foo:1, bar:2 })
const newObject = {...obj}
console.log(newObject)

在上述例子中,我们打印newObject,输出一个普通的对象{ foo:1, bar:2 },这个对象并不具备响应式,当然也可以自己测试一下。

使用扩展运算符进行对象的浅拷贝,其实也只是将一个个属性拷贝然后读取对应的值。

toRef()

如何来解决这个问题呢?我们可以借鉴ref给基本数据类型添加响应式的方案。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const obj = reactive({ foo:1, bar:2 })
function toRef(target, key) {
const wrapper = {
get value() {
return target[key]
},
set value(value) {
target[key] = value
}
}
//添加__v_isRef属性
Object.defineProperty(wrapper, '__v_isRef', {
value: true
})
return wrapper
}
const newObj = toRef(obj, 'foo')

可以看到toRef返回了一个新的对象newObj,这个对象有一个访问器属性value,当访问newObj的value属性其实是在访问obj的foo属性,由于obj是响应式数据,所以newObj.value会触发依赖收集(副作用收集)。当修改newObj.value其实是在修改obj的foo属性,所以能触发依赖更新。

toRefs()

使用toRefs可以将一个对象上的第一层属性都转化为ref类型

1
2
3
4
5
6
7
8
9
10
const obj = reactive({ foo:1, bar:2 })
function toRefs(target) {
const obj = {}
for (key in target) {
obj[key] = toRef(target, key)
}
return obj
}
const refs = toRefs(obj) //toRefs会返回一个新的对象,不会修改原对象,这个对象即便使用对象解构,响应式也不会丢失
const newObj = { ...refs }//newObj中的每个属性都具有响应式

自动脱ref

toRefs函数的确解决了响应式丢失的问题,但是同时也带来了新的问题。由于toRefs会把响应式数据的第一层属性值转换为ref,因此必须通过value属性访问值。

1
2
3
4
5
6
const obj = reactive({foo:1, bar:2})
obj.foo //1
obj.bar //2
const newObj = {...toRefs(obj)}
newObj.foo.value //1
newObj.foo.value //2

这其实增加了用户的心智负担,因为用户通常在模板中使用数据,我们也不希望编写这样的代码:

1
<p>{{ foo.value / bar.value}}</p>

因此我们需要自动脱ref的能力,所谓自动脱ref,指的是属性的访问行为,即如果读取的属性的值是一个ref,则直接将该ref对应的value属性值返回。例如:

1
newObj.foo //1

要实现此功能,需要使用Proxy为newObj创建一个代理对象,通过代理来实现最终的目标。

1
2
3
4
5
6
7
8
function proxyRefs(newObj) {
return new Proxy(newObj, {
get(target, key) {
const value = target[key]
return value.__v_isRef ? value.value : value
}
})
}

可以观察到创建的这个代理对象,触发get的时候,并没有进行依赖收集,只是单纯的返回值。

实际上我们在编写 Vue.js 组件时,组件中的 setup 函数返回的数据,会自动传递给 proxyRefs 函数进行处理,这也是为什么我们可以在模板中直接访问一个 ref 的值而无需通过.value属性来访问。

既然读取属性的值有自动脱ref的能力,那么设置属性的值也应该有自动为ref设置值的能力,实现功能很简单,只需要添加对应的set拦截函数即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function proxyRefs(newObj) {
return new Proxy(newObj, {
get(target, key) {
const value = target[key]
return value.__v_isRef ? value.value : value
},
set(target, key, value) {
const oldValue = target[key]
if (oldValue.__v_isRef) {
oldValue.value = value
} else {
target[key] = value
}
}
})
}

实际上,自动脱ref不仅存在于上述场景中,reactive函数也有自动脱ref的功能。

1
2
3
const count = ref(0)
const obj = reactive({count})
obj.count

ref类型的数据如何收集模板为依赖

计算属性和ref类型的数据,在模板中使用的话,并没有访问value属性,那是如何触发依赖收集的?怎么会把更新模板的副作用函数收集为依赖?其实这得益于vue中自动脱ref的机制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function proxyRefs(newObj) {
return new Proxy(newObj, {
get(target, key) {
const value = target[key]
return value.__v_isRef ? value.value : value
},
set(target, key, value) {
const oldValue = target[key]
if (oldValue.__v_isRef) {
oldValue.value = value
} else {
target[key] = value
}
}
})
}

可以观察到,创建的这个代理对象,触发get的时候,并没有触发依赖收集,只是单纯的返回值。当访问ref对象的value属性时,就触发了trackRefValue(count),相当于是在get中又触发了一个get

我们来看一个完整例子:

1
2
3
4
5
6
7
8
<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>

<template>
<div>{{ count }}</div>
</template>

执行流程:

setup 执行,返回 { count }

1
setup() → { count: RefImpl }

Vue 调用 proxyRefs 包装返回值

1
ctx = proxyRefs({ count })

模板编译器生成渲染函数

1
2
3
function render() {
return createVNode('div', null, ctx.count)
}

渲染时执行 render()

1
ctx.count // 触发 proxyRefs 的 get

isRef(count) → 返回 count.value,触发 trackRefValue(count),依赖收集完成

同样的,computed 本质是一个 ComputedRefImpl,它也有 .value。然后依赖收集也是通过调用trackRefValue(refImpl),将activeEffect收集到自己的dep集合中。

上述分析的意义在于,当进行依赖分析的时候,在getter函数中如果访问了值为ref类型的属性,可以认为同时也访问了value属性,value属性也参与依赖的收集。