Vue3

Vue2与Vue3有什么不同

响应式实现方法不同

API风格不同

在vue2使用的是选项式api,在vue3中既可以使用组合式api,又可以使用选项式api。

其他

  • 组件注册方式不同:Vue3导入组件后,也不需要在components里注册

  • 模板语法不同:Vue2中模板里只能有一个根标签,而在Vue3里可以有多个,因为这些根标签都会被fragment标签包裹

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

defineProperty

Object.defineProperty() 方法会直接在一个对象上定义一个新属性,或者修改对象的现有属性,并返回此对象。

1
2
3
4
5
6
7
8
9
10
//传入一个对象,将这个对象转变成响应式对象
function observe(obj) {
if (typeof obj !== 'object' || obj == null) {
return
}
//使用Object.keys可比使用for in 然后再使用hasOwnProperty判断方便多了
Object.keys(obj).forEach(key => {
defineReactive(obj, key, obj[key])
})
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function defineReactive(obj, key, val) {
//如果存在嵌套对象的情况,则递归添加响应式。
if( typeof val == 'object'){
observe(val)
}
Object.defineProperty(obj, key, {
get() {
console.log(`get ${key}:${val}`);
return val
},
set(newVal) {
if (newVal !== val) {
//当给key赋值为对象的时候,还需要在set方法中给这个对象也添加响应式。
if(typeof newVal == 'object'){
observe(newVal)
}
val = newVal
//调用update方法,做一些更新视图的工作,依赖这个属性的视图,计算属性,watch都会更新或执行一些逻辑
update()
}
}
})
}
1
2
3
4
5
const arrData = [1,2,3,4,5];
observe(arrData)
arrData.push() //无响应
arrData.pop() //无响应
arrDate[0] = 99 //ok,有响应

缺点小结

  • Object.defineProperty无法监听到数组方法对数组元素的修改
  • 需要遍历对象每个属性逐个添加监听,而且无法监听到对象属性添加删除,如果属性值是嵌套对象,还深层监听,造成性能问题。

Proxy

Proxy的监听是整个对象,那么对这个对象的所有操作会进入监听操作,这就完全可以代理所有属性了

定义一个响应式方法reactive,这个reactive方法,就是vue3中的reactive方法的简化版

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function reactive(obj) {
if (typeof obj !== 'object' || obj == null) {
return obj
}
// Proxy相当于在对象外层加拦截
const observed = new Proxy(obj, {
get(target, key, receiver) {
const res = Reflect.get(target, key, receiver)
console.log(`获取${key}:${res}`)
return res
},
set(target, key, value, receiver) {
const res = Reflect.set(target, key, value, receiver)
console.log(`设置${key}:${value}`)
return res
},
deleteProperty(target, key) {
const res = Reflect.deleteProperty(target, key)
console.log(`删除${key}:${res}`)
return res
}
})
return observed
}

测试一下简单数据的操作,发现都能劫持

1
2
3
4
5
6
7
8
9
10
11
const state = reactive({
foo: 'foo'
})
// 1.获取
state.foo //输出:"获取foo:foo"
// 2.设置已存在属性
state.foo = 'fooooooo' //输出:"设置foo:fooooooo"
// 3.设置不存在属性
state.dong = 'dong' // 输出:"设置dong:dong"
// 4.删除属性
delete state.dong // 输出:"删除dong:true"

再测试嵌套对象情况,这时候发现就不那么 OK 了

1
2
3
4
5
6
7
8
9
const state = reactive({
bar: { a: 1 }
})

//最终结果- 输出:"获取bar:[object Object]"
//因为 state.bar 就是在访问bar属性,触发getter,所以输出:"获取bar:[object Object]",并且返回{a:1}
//然后访问{a:1}的a属性,由于{a:1}是个普通对象,所以不会触发对应的getter
//简单的来说,这个操作进行了两次属性访问,但是只触发了一次getter
state.bar.a = 10

如果要解决,需要在get之上再进行一层代理

1
2
3
4
5
6
7
8
const observed = new Proxy(obj, {
get(target, key, receiver) {
const res = Reflect.get(target, key, receiver)
console.log(`获取${key}:${res}`)
//如果返回的对象是一个object,则给这个对象添加响应式
return typeof res==='object' ? reactive(res) : res
}
})

修改后输出的结果:

1
2
获取bar:[object Object]
设置a:10

由此可以看出,Proxy是按需添加响应式的,只有当我们取出的值是一个对象的时候,Proxy才会递归给这个对象添加响应式。而在Object.defineProperty中是直接递归添加响应式的。

总结

Object.defineProperty这个方法存在许多缺点,比如必须遍历对象的所有属性逐个添加监听,而且无法监听对象属性的增加与删除,如果属性的值是引用类型还需要深度监听,造成性能问题

对于数组,Object.defineProperty方法无法监听到数组方法,对数组元素的修改,需要重写数组方法。

而Proxy能监听整个对象的变化,也能监听到数组方法对数组元素的修改。

说说Vue 3.0中Treeshaking特性?

是什么

Tree shaking 是一种通过清除多余js代码方式,来优化项目打包体积的技术。Vue3源码实现了高度的ESM模块化,更好的支持Tree-shaking。

如何做

Tree shaking是基于ES6模块语法(importexports),主要是借助ES6模块的静态编译思想,编译时就能确定模块的依赖关系,以及输入和输出的变量。

Tree shaking无非就是做了两件事:

  • 编译阶段利用ES6 Module判断哪些模块已经加载
  • 判断那些函数和变量未被使用或者引用,进而删除对应代码

那么为什么使用 CommonJs、AMD 等模块化方案无法支持 Tree Shaking 呢?

因为在 CommonJs、AMD、CMD 等旧版本的 js 模块化方案中,导入导出行为是高度动态,难以预测的,只能在代码运行的时候确定所有模块的依赖关系,例如:

1
2
3
4
if(process.env.NODE_ENV === 'development'){
require('./bar');
exports.foo = 'foo';
}

而 ESM 方案则从规范层面规避这一行为,它要求所有的导入导出语句,只能出现在模块顶层,可以理解为全局作用域;且导入导出的模块名必须为字符串常量,这意味着下述代码在 ESM 方案下是非法的:

1
2
3
4
if(process.env.NODE_ENV === 'development'){
import bar from 'bar';
export const foo = 'foo';
}

所以,ESM 模块之间的依赖关系是高度确定的,与运行状态无关,编译工具只需要对 ESM 模块做静态语法分析,就可以从代码字面量中,推断出哪些模块值未曾被其它模块使用,这是实现 Tree Shaking 技术的必要条件。

关于tree-shaking更多内容参考:前端面试—webpack | 三叶的博客

关于cjs和esm的更多内容参考:nodejs | 三叶的博客

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 data = {
name: 'sanye',
age: 29,
bool: true
}
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 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)

在上述代码中,当执行effect函数,会将传入的fn标记为activeEffect(激活状态的副作用函数),然后调用fn,访问响应式数据,触发依赖收集,调用track方法,将activeEffect收集到对应key的deps集合中去。后续当修改obj.name,触发trigger,调用name属性的deps集合中的所有副作用函数。

更新依赖

但是上述代码还有缺陷,当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()// 立即调用一次,触发依赖收集
}
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
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) //收集副作用函数
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
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
18
function effect(fn, options) {
// 将副作用函数进行包装,后续存储在deps集合中的也是effectFn不是fn
const effectFn = () => {
// console.log('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
}

然后我们就可以开始实现计算属性了。

基本实现

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

添加缓存

我们的代码现在还不算完美,因为计算属性的依赖被修改了,计算属性的effectFn也会重新执行,这算不上懒计算,而且我们多次访问sumRes.value的值,会导致effectFn进行多次计算,即使obj.fooobj.bar本身没有发生变化。为了解决这个问题,我们需要添加值缓存的功能。

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又被切换,然后传入的副作用函数(箭头函数)执行完毕,然后发现它没有被任何数据收集。所以即便修改了obj.foo,注册的副作用函数也不会重新执行。

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的实现本质上就是利用了watch和options.scheduler。下面我们实现一个最简单的watch函数:

1
2
3
4
5
6
7
8
9
10
//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
function traverse(obj) {
if (typeof obj !== 'object' || obj == null) {
return
}
for (let key in obj) {
traverse(obj[key])
}
return obj
}

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

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

1
2
3
4
5
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是函数的话就,这个函数的返回值就是watch中的value
} 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, //则在effectFn函数内部不会调用effectFn
// 当getter中的依赖改变,effectFn会被调用,但是如果effectFn有scheduler,则调用scheduler
scheduler(){
newValue = effectFn() //重新计算拿到新的值
cb(newValue, oldValue) //调用回调函数拿到新值和旧值
oldValue = newValue //修改旧值
}
})
oldValue = effectFn() //设置lazy为true然后手动调用就是为了拿出value吗
}

立即执行的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
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 {
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
33
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类型的数据默认是深度监听(递归访问子属性)。

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
export function trackRefValue(ref) {
if (shouldTrack && activeEffect) {
// ref.deps 存储所有依赖它的 effect
ref.dep = getDepFromReactiveOrRef(ref)
ref.dep.add(activeEffect)
activeEffect.deps.push(ref.dep)
}
}

自动脱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属性也参与依赖的收集。

你了解过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
17
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,传入的副作用函数,在watchEffect内部被调用的时候,会传入onInvalidate函数,这个函数的作用其实就设置cleanup方法,简单的来说,onInvalidate允许用户自定义cleanup函数。在传入watchEffect的副作用函数每次重新执行前,都会先进行副作用清理

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

总结

  • watchEffect是基于effect实现的,基于effect包装而来的
  • 支持清理过期的副作用
  • 会返回一个stop函数,该函数的作用是从所有依赖集合中移除传入的副作用函数