git地址
副作用函数是指会产生副作用的函数, 如下面的代码所示:
function effect () {
document.body.innerText = 'hello vue3'
}
当effect函数执行时,它会设置body的文本内容,但除了effect函数之外的任何函数都可以读取或者设置body的文本内容.也就是说,effect函数的执行会直接或间接影响其他函数的执行,这是我们说effect函数产生了副作用.副作用很容易产生,例如一个函数修改了全局的变量,这其实也是一个副作用函数,如下代码所示
// 全局变量
let val = 1
function effect () {
val = 2 // 修改全局变量,产生副作用
}
什么是响应式数据,假设在一个副作用函数中读取了某个对象的属性:
const obj = { text: 'hello world' };
function effect () {
//effect函数的执行会读取obj的值
document.body.innerText = obj.text
}
obj.text = 'hello vue3'
最后一行代码修改了obj.text的值,我们希望当值变化时,副作用函数重新执行,那么就是响应式数据.
由上文可以看出当副作用函数执行时会读取数据操作,当修改值时,会触发设置操作.我们能拦截一个对象的读取和设置操作,事情就简单了.当读取obj.text时,我们可以把副作用函数存储到一个“桶”里,当设置obj.text时,再把副作用函数取出执行即可.
在ES2015之前只能通过Object.defubeProperty函数实现,这也是Vue.js2所采用的方式.在ES2015+中,我们可以采用代理对象Proxy来实现,这也是Vue.js3所采用的.
接下来我们就根据如上思路,采用Proxy实现
// 存储副作用函数的桶
const bucket = new Set();
// 原始数据
const data = { text: 'hello world' };
// 对原始数据的代理
const obj = new Proxy(data, {
// 拦截读取操作
get(target, key) {
// 将副作用函数effect添加到存储副作用函数的桶中
bucket.add(effect)
return target[key]
},
// 拦截设置操作
set(target, key, newVal) {
//设置属性值
target[key] = newVal;
//把副作用函数从桶中取出并执行
bucket.forEach(fn => fn())
//返回true代表设置操作成功
return true
}
})
// 使用下面的代码测试就可以看出得到了期望的结果
function effect () {
console.log(obj.text)
}
effect() //hello world
setTimeout(() => {
obj.text = 'hello vue3';
}, 1000) // hello vue3
但是目前的实现还存在很多缺陷,例如我们直接通过名字(effect)来获取副作用函数,这种硬编码的方式很不灵活.副作用函数的名字可以任意取,我们完全可以把副作用函数命名为myEffect,甚至是匿名函数.
为了解决上一节最后提出的问题,我们需要提供一个用来注册副作用函数的机制.如以下代码所示:
// 用一个全局变量存储被注册的副作用函数
let activeEffect;
// effect函数用于注册副作用函数
function effect (fn) {
// 当调用effect注册副作用函数时,将副作用函数fn赋值给activeEffect
activeEffect = fn;
// 执行副作用函数
fn()
}
effect函数接收一个参数fn,即要注册的副作用函数.我们可以按照如下所示的方法使用effect函数:
effect(
// 一个匿名的副作用函数
() => {
console.log(obj.text)
}
)
当effect函数执行时,首先会把匿名的副作用函数fn赋值给全局变量activeEffect.接着执行被注册的匿名副作用函数fn,这将会触发响应式数据obj.text的读取操作.进而触发代理对象Proxy的get拦截函数:
const obj = new Proxy(data, {
// 拦截读取操作
get(target, key) {
// 将activeEffect中存储的副作用函数收集到“桶”中
if (activeEffect) {
bucket.add(activeEffect)
}
return target[key]
},
// 拦截设置操作
set(target, key, newVal) {
//设置属性值
target[key] = newVal;
//把副作用函数从桶中取出并执行
bucket.forEach(fn => fn())
//返回true代表设置操作成功
return true
}
})
但是当我们开启一个定时器,修改不存在的属性时,也会出发副作用函数:
setTimeout(() => {
obj.notExist = 'hello vue3'
})
我们知道,在副作用函数中并没有读取obj.notExist的值,所以理论上并不应该建立响应联系.因此我们需要重新设计“桶”的数据结构.
在上一节的例子中,我们使用了一个Set数据结构作为存储作用函数的“桶”,导致该问题的根本原因是,我们没有在副作用函数与被操作的目标字段之间建立明确的联系.那应该怎样的数据结构呢?
我们需要先仔细观察下面的代码
effect(function effectFn() {
document.body.innerText = obj.text
})
在这段代码中存在三个角色
如果用target来表示一个代理对象所代理的原始对象,用key来表示被操作的字段名,用effectFn来表示被注册的副作用函数,那么可以为这三个角色建立如下关系:
根据上述,我们尝试用代码来实现这个新的“桶”,需要使用WeakMap代替Set作为桶的数据结构
// 存储副作用函数的桶
const bucket = new WeakMap();
然后修改get/set拦截器代码:
const obj = new Proxy(data, {
get(target, key) {
// 没有activeEffect,直接return
if (!activeEffect) return target[key]
// 根据target从“桶”中取得depsMap, 它也是一个Map类型: key --> effects
let depsMap = bucket.get(target)
// 如果不存在depsMap,那么新建一个Map并与target关联
if (!depsMap) {
bucket.set(target, (depsMap = new Map()))
}
// 再根据key从depsMap中取得deps,它是一个Set类型
// 里面存储着所有与当前key相关联的副作用函数: effects
let deps = depsMap.get(key)
// 如果deps不存在,同样新建一个Set并与key关联
if (deps) {
depsMap.set(key, (deps = new Set()))
}
// 最后将当前激活的副作用函数添加到“桶”里
deps.add(activeEffect)
// 返回属性值
return target[key]
},
set(target, key, newVal) {
// 设置属性值
target[key] = newVal;
// 根据target从桶中取得depsMap,它是: key --> effects
const depsMap = bucket.get(target);
if (!depsMap) return
// 根据key取得所有副作用函数effects
const effects = depsMap.get(key);
// 执行副作用函数
effects && effects.forEach(fn => fn());
}
})
从这段代码可以看出构建数据结构的方式,我们分别使用了WeakMap、Map和Set:
其中WeakMap的键是原始对象target,WeakMap的值是一个Map实例,而Map的键是原始对象target的key,Map的值是一个由副作用函数组成的Set.
我们有必要解释一下为什么要使用WeakMap,这其实设计WeakMap和Map的区别,我们用一段代码来解释
const map = new Map();
const weakmap = new WeakMap();
(function(){
const foo = { foo: 1 }
const bar = { bar: 2 }
map.set(foo, 1);
weakmap.set(bar, 2)
}) ()
当该立即执行表达式(IIFE)执行完毕后,对于对象foo来说,它仍然作为map的key被引用着,因此垃圾回收器不会把它从内存中移除,我们仍然可以通过map.keys打印出对象foo.然而对于对象bar来说,由于weakmap的key是弱引用,他不影响垃圾回收器的工作,所以一旦表达式执行完毕,垃圾回收器就会把对象bar从内存中移除,并且我们无法获取weakmap的key值,也就无法通过weakmap取得对象bar.
简单来说,weakmap对key是弱引用的,不影响垃圾回收器的工作.所以WeakMap经常用于存储那些只有当key所引用的对象存在时(没有被回收)才有价值的信息.
最后我们对上文中的代码做一些封装处理.在目前的实现中,当读取属性值时,我们直接在get拦截函数里编写把副作用函数收集到'桶'里的这部分洛基,但更好的做法是将这部分逻辑单独封装到一个track函数中,函数的名字叫track是为了表达追踪的含义.同时我们也可以把触发副作用函数重新执行的逻辑封装到trigger函数中:
const obj = new Proxy(data, {
get(target, key) {
// 将副作用函数activeEffect添加到存储副作用函数的桶中
track(target, key);
// 返回属性值
return target[key]
},
set(target, key, newVal) {
// 设置属性值
target[key] = newVal;
// 把副作用函数从桶里取出来并执行
trigger(target, key)
}
})
function track (target, key) {
// 没有activeEffect,直接return
if (!activeEffect) return
// 根据target从“桶”中取得depsMap, 它也是一个Map类型: key --> effects
let depsMap = bucket.get(target)
// 如果不存在depsMap,那么新建一个Map并与target关联
if (!depsMap) {
bucket.set(target, (depsMap = new Map()))
}
// 再根据key从depsMap中取得deps,它是一个Set类型
// 里面存储着所有与当前key相关联的副作用函数: effects
let deps = depsMap.get(key)
// 如果deps不存在,同样新建一个Set并与key关联
if (deps) {
depsMap.set(key, (deps = new Set()))
}
// 最后将当前激活的副作用函数添加到“桶”里
deps.add(activeEffect)
}
function trigger (target, key) {
// 根据target从桶中取得depsMap,它是: key --> effects
const depsMap = bucket.get(target);
if (!depsMap) return
// 根据key取得所有副作用函数effects
const effects = depsMap.get(key);
// 执行副作用函数
effects && effects.forEach(fn => fn());
}
首先我们要明确分支切换的定义,如下面的代码所示:
const data = {ok: true, text: 'hello world'}
const obj = new Proxy(data, {/*...*/})
effect(function effectFn() {
document.body.innerText = obj.ok ? obj.text : 'not'
})
在effectFn函数内部存在一个三元表达式,根据字段obj.ok的值不同会执行不同的代码分支.当字段obj.ok的值发生变化时,代码执行的分支会跟着变化,这就是所谓的分支切换.
分支切换可能会产生遗留的副作用函数.拿上面这段代码来说,字段obj.ok的初始值为true,这时会读取obj.text的值,所以当effectFn函数执行时会触发字段obj.ok和字段obj.text这两个属性的读取操作,此时副作用函数effectFn与响应式数据之间建立的联系如下:
可以看到,副作用函数effectFn分别被字段data.ok和字段data.text所对应的依赖集合收集.当字段obj.ok的值被修改为false,并触发副作用函数重新执行后,由于此时字段obj.text不会被读取,只会处罚字段obj.ok的读取操作,所以理想情况下副作用函数effectFn不应该被字段obj.text所对应的依赖集合收集.
但按照上一节的实现,我们还做不到这一点.也就是说,当我们把字段obj.ok的值修改为false,并触发副作用函数重新执行之后,整个依赖关系仍然保持上图错误的样子,这时就产生了遗留的副作用函数.
遗留的副作用函数会导致不必要的更新:当修改obj.ok为false时,不会再读取obj.text的值.换句话说无论字段obj.text的值是如何改变,document.body.innerText的值始终都是字符串not,所以最好的结果是,无论obj.text的值怎么变,都不需要重新执行副作用函数,但事实并非如此.
解决这个问题的思路很简单,每次副作用函数执行时,我们可以先把它从所有与之关联的依赖集合中删除.当副作用函数执行完毕后,会重新建立联系.但在新的练习中不会包含遗留的副作用函数.
因此我们需要重新设计effect函数:
let activeEffect
function effect(fn) {
const effectFn = () => {
// 当effectFn执行时,将其设置为当前激活的副作用函数
activeEffect = effect
fn()
}
// activeEffect.deps用来存储所有与该副作用函数相关联的依赖集合
effectFn.deps = []
// 执行副作用函数
effectFn()
}
那么effectFn.deps数组中的依赖集合是如何收集的呢,其实是在track函数中:
function track (target, key) {
// 没有activeEffect,直接return
if (!activeEffect) return
// 根据target从“桶”中取得depsMap, 它也是一个Map类型: key --> effects
let depsMap = bucket.get(target)
// 如果不存在depsMap,那么新建一个Map并与target关联
if (!depsMap) {
bucket.set(target, (depsMap = new Map()))
}
// 再根据key从depsMap中取得deps,它是一个Set类型
// 里面存储着所有与当前key相关联的副作用函数: effects
let deps = depsMap.get(key)
// 如果deps不存在,同样新建一个Set并与key关联
if (deps) {
depsMap.set(key, (deps = new Set()))
}
// 最后将当前激活的副作用函数添加到“桶”里
deps.add(activeEffect)
// deps就是一个与当前副作用函数存在联系的依赖集合
// 将其添加到activeEffect.deps 数组中
activeEffect.deps.push(deps)
}
在track函数中我们将当前执行的副作用函数activeEffect添加到依赖集合deps中,这说明deps就是一个与当前副作用函数存在联系的依赖集合,于是我们也把它添加到activeEffect.deps数组中,这样就完成了对依赖集合的收集.
有了这个联系后,我们就可以在每次副作用函数执行时,根据effectFn.deps获取所有相关联的依赖集合,进而将副作用函数从依赖集合中移除:
let activeEffect
function effect(fn) {
const effectFn = () => {
// 调用cleanup函数完成清除工作
cleanup(effectFn)
// 当effectFn执行时,将其设置为当前激活的副作用函数
activeEffect = effectFn
fn()
}
// activeEffect.deps用来存储所有与该副作用函数相关联的依赖集合
console.log(1111)
effectFn.deps = []
// 执行副作用函数
effectFn()
}
下面是cleanup函数的实现
function cleanup (effectFn) {
//遍历effectFn.deps数组
for (let i = 0; i < effectFn.deps.length; i ++) {
//deps是依赖集合
const deps = effectFn.deps[i]
// 将effectFn从依赖集合中移除
deps.delete(effectFn)
}
// 最后需要重置effectFn.deps数组
effectFn.deps.length = 0
}
cleanup函数接收副作用函数作为参数,遍历副作用函数的effectFn.deps数组,该数组的每一项都是一个依赖集合,然后将该副作用函数从依赖集合中移除,最后重置effectFn.deps数组.
至此我们响应系统已经可以避免副作用函数产生遗留了.但如果你尝试运行代码,会发现目前的实现会导致无限循环.问题出在trigger函数中:
function trigger (target, key) {
// 根据target从桶中取得depsMap,它是: key --> effects
const depsMap = bucket.get(target);
if (!depsMap) return
// 根据key取得所有副作用函数effects
const effects = depsMap.get(key);
// 执行副作用函数
effects && effects.forEach(fn => fn()); // 问题出现在这句代码
}
在trigger函数内部我们遍历effects集合,它是一个Set集合,里面存储着副作用函数.当副作用函数执行时,会调用cleanup进行清除,实际上就是从effects集合中将当前执行的副作用函数剔除,但是副作用函数的执行会导致重新被收集到集合中,而此时对于effects集合的遍历仍在进行.这个行为可以用如下简短的代码来表达:
const map = new Map();
const weakmap = new WeakMap();
(function(){
const foo = { foo: 1 }
const bar = { bar: 2 }
map.set(foo, 1);
weakmap.set(bar, 2)
}) ()
语言规范中对此有明确的说明:在调用forEach遍历Set集合时,如果一个值已经被访问过了,但该值被删除并重新添加到集合,如果此时forEach遍历没有结束,那么改值会重新被访问.因此,上面的代码会无限之行.解决方法很简单,我们可以构造另外一个Set集合并遍历它.
这样就不会无限执行了.回到trigger函数,我们需要同样的手段来避免无限执行:
function trigger (target, key) {
// 根据target从桶中取得depsMap,它是: key --> effects
const depsMap = bucket.get(target);
if (!depsMap) return
// 根据key取得所有副作用函数effects
const effects = depsMap.get(key);
// 执行副作用函数
// effects && effects.forEach(fn => fn()); // 删除
const effectsToRun = new Set(effects) // 新增
effectsToRun.forEach(effectFn => effectFn()) // 新增
}
effect是可以发生嵌套的,例如:
effect(function effectFn1() {
effect(function effectFn2 () {/* */})
/* */
})
在Vue.js中,渲染函数就是在一个effect中执行的,当组建发生了嵌套时,此时就发生了effect嵌套,上节实现的相应系统并不支持effect嵌套,可以用下面代码来测试一下:
let temp1, temp2
effect(function effectFn1 () {
console.log('effectFn1 执行')
effect(function effectFn2 () {
console.log('effectFn2 执行')
temp2 = obj.bar
})
temp1 = obj.foo
})
发现打印结果和预期不同,问题就出在我们实现的effect函数与activeEffect上.观察我们实现的effect函数可以看出,我们用全局变量activeEffect所存储的副作用函数只能有一个.当副作用函数发生嵌套的时候,内层副作用函数的执行会覆盖activeEffect的值,并且永远不会恢复到原来的值.这时候如果再有响应式数据进行依赖收集,即使这个响应式数据是在外层副作用函数中读取的,它们收集到的副作用函数也都会是内层副作用函数.
为了解决这个问题,我们需要一个副作用函数栈effectStack,在副作用函数执行时,将当前副作用函数压入栈中,待副作用函数执行完毕后将其从栈中弹出,并使用让activeEffect指向栈顶的副作用函数,这样就能做到一个响应式数据只会收集直接读取其值的副作用函数.
let activeEffect
// effect栈
const effectStack = []
function effect(fn) {
const effectFn = () => {
// 调用cleanup函数完成清除工作
cleanup(effectFn)
// 当effectFn执行时,将其设置为当前激活的副作用函数
activeEffect = effectFn
// 在调用副作用函数之前将当前副作用函数压入栈中
effectStack.push(effectFn)
fn()
// 在当前副作用函数执行完毕后,将当前副作用函数弹出栈,并把acticeEffect还原为之前的值
effectStack.pop();
activeEffect = effectStack[effectStack.length - 1]
}
// activeEffect.deps用来存储所有与该副作用函数相关联的依赖集合
effectFn.deps = []
// 执行副作用函数
effectFn()
}
完善一个相应系统要考虑诸多细节.而本节要介绍的无限递归循环就是其中之一:
effect(() => {
obj.zoo++
})
可以看到上面的代码会导致栈溢出,实际上我们可以把上面的自增操作拆开来看,它相当于obj.zoo = obj.zoo + 1.
在这个语句中,既会读取obj.zoo的值,又会设置obj.zoo的值,而这就是导致问题的根本原因.
解决问题的方法并不难,通过分析,读取和设置操作是在同一个副作用函数内进行的,此时无论是track收集的副作用函数还是trigger触发执行的副作用函数都是activeEffect.我们可以在trigger动作发生时增加守卫条件: 如果trigger触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行:
function trigger (target, key) {
// 根据target从桶中取得depsMap,它是: key --> effects
const depsMap = bucket.get(target);
if (!depsMap) return
// 根据key取得所有副作用函数effects
const effects = depsMap.get(key);
// 执行副作用函数
// effects && effects.forEach(fn => fn());
const effectsToRun = new Set()
effects && effects.forEach(effectFn => { // 新增
// 如果trigger触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})
effectsToRun.forEach(effectFn => effectFn())
}
所谓可调度,指的是当trigger动作触发副作用函数重新执行时,有能力决定副作用函数执行的时机、次数以及方式.
首先来看一下,如果决定副作用函数的执行方式,以下面的代码为例:
const data = {foo: 1}
const obj = new Proxy(data, { /* */ })
effect(() => {
console.log(obj.foo)
})
obj.foo++
console.log('结束了')
//1
//2
//结束了
现在假设需求有变,输出顺序需要调整为:
//1
//结束了
//2
在不改变代码顺序的情况下,有什么方法可以实现呢,这时就需要响应系统支持调度.
我们可以为effect函数设计一个选项options,允许用户指定调度器:
effect(
() => {
console.log(obj.foo)
},
// options
{
scheduler(fn) {
// ...
}
}
)
如上面的代码所示,用户在调用effect函数注册副作用函数时,可以传递第二个参数options.它是一个对象,其中允许指定scheduler调度函数,同时在effect函数内部我们需要把options选项挂载到对应的副作用函数上:
function effect(fn, optiopns = {}) {
const effectFn = () => {
// 调用cleanup函数完成清除工作
cleanup(effectFn)
// 当effectFn执行时,将其设置为当前激活的副作用函数
activeEffect = effectFn
// 在调用副作用函数之前将当前副作用函数压入栈中
effectStack.push(effectFn)
fn()
// 在当前副作用函数执行完毕后,将当前副作用函数弹出栈,并把acticeEffect还原为之前的值
effectStack.pop();
activeEffect = effectStack[effectStack.length - 1]
}
// 将options挂载到effectFn上
effectFn.optiopns = optiopns // 新增
// activeEffect.deps用来存储所有与该副作用函数相关联的依赖集合
effectFn.deps = []
// 执行副作用函数
effectFn()
}
有了调度函数,我们在trigger函数中触发副作用函数重新执行时,就可以直接调用用户传递的调度器函数,从而把控制权交给用户:
function trigger (target, key) {
// 根据target从桶中取得depsMap,它是: key --> effects
const depsMap = bucket.get(target);
if (!depsMap) return
// 根据key取得所有副作用函数effects
const effects = depsMap.get(key);
// 执行副作用函数
// effects && effects.forEach(fn => fn());
const effectsToRun = new Set()
effects && effects.forEach(effectFn => {
// 如果trigger触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})
effectsToRun.forEach(effectFn => {
// 如果一个副作用函数存在调度器, 则调用该调度器, 并将副作用函数作为参数传递
if (effectFn.optiopns.scheduler) { // 新增
effectFn.optiopns.scheduler(effectFn) // 新增
} else {
// 否则直接执行副作用函数(之前的默认行为)
effectFn()
}
})
}
有了这些基础设施之后,我们使用setTimeout开启一个宏任务来执行副作用函数fn,这样就能实现期望的打印顺序了.
除了控制副作用函数的执行顺序,通过调度器还可以做到控制它的执行次数,这一点尤为重要.我们思考如下例子:
const data = {foo: 1}
const obj = new Proxy(data, { /* */ })
effect(() => {
console.log(obj.foo)
})
obj.foo++
obj.foo++
//1
//2
//3
如果我们只关心最终结果而不关心过程,那么执行三次打印操作是多余的,我们期望的打印结果是1、3,其中不包括过渡状态,基于调度器我们可以很容易的实现此功能.
// 一个标志代表是否在刷新队列
let isFlushing = false
function flushJob() {
// 如果队列正在刷新,则什么都不做
if (isFlushing) return;
// 设置为true
isFlushing = true;
// 在微任务队列中刷新jobQueue队列
p.then(() => {
jobQueue.forEach(job => job())
}).finally(() => {
isFlushing = false
})
}
effect(
() => {
console.log(obj.foo)
},
// options
{
scheduler(fn) {
// ...
// setTimeout(fn)
jobQueue.add(fn)
flushJob()
}
}
)
这个功能有点类似于在Vue.js中连续多次修改响应式数据但只会触发一次更新,实际上Vue.js内部实现了一个更加完善的调度器,思路与上文介绍的相同.
在深入讲解计算属性之前,我们需要先来聊聊关于懒执行的effect,即lazy的effect.现在我们所实现的effect函数会立即执行传递给它的副作用函数.但在有些场景下,我们并不希望它立即执行,而是希望它在需要的时候才执行,例如计算属性.这是我们可以通过options中添加lazy属性来达到目的:
effect(
() => {
console.log(obj.foo)
},
// options
{
lazy: true
}
)
lazy选项和之前介绍的scheduler一样,它通过options选项对象指定.有了它,我们就可以修改effect函数的视线逻辑,当options.lazy为true时,则不立即执行副作用函数:
function effect(fn, optiopns = {}) {
const effectFn = () => {
// 调用cleanup函数完成清除工作
cleanup(effectFn)
// 当effectFn执行时,将其设置为当前激活的副作用函数
activeEffect = effectFn
// 在调用副作用函数之前将当前副作用函数压入栈中
effectStack.push(effectFn)
fn()
// 在当前副作用函数执行完毕后,将当前副作用函数弹出栈,并把acticeEffect还原为之前的值
effectStack.pop();
activeEffect = effectStack[effectStack.length - 1]
}
// 将options挂载到effectFn上
effectFn.optiopns = optiopns
// activeEffect.deps用来存储所有与该副作用函数相关联的依赖集合
effectFn.deps = []
// 只有非lazy的时候才执行
if (!optiopns.lazy) { // 新增
effectFn() // 新增
} //新增
// 将副作用函数作为返回值返回
return effectFn // 新增
}
但问题是,副作用函数应该什么时候执行呢,通过上面的代码可以看出,通过其返回值能够拿到对应的副作用函数,这样我们就可以手动执行该副作用函数了:
const effectFn = effect(
() => {
console.log(obj.foo)
},
// options
{
lazy: true
}
)
effectFn()
如果仅仅能够手动执行副作用函数,其意义并不大.但如果我们把传递给effect的函数看作一个getter,那么这个getter函数可以返回任何值,这样我们在手动执行副作用函数时,就能够拿到其返回值:
const effectFn = effect(
() => {
console.log(obj.foo + obj.bar)
},
// options
{
lazy: true
}
)
const value = effectFn()
为了实现这个目标,我们需要再对effect函数做一些修改:
function effect(fn, optiopns = {}) {
const effectFn = () => {
// 调用cleanup函数完成清除工作
cleanup(effectFn)
// 当effectFn执行时,将其设置为当前激活的副作用函数
activeEffect = effectFn
// 在调用副作用函数之前将当前副作用函数压入栈中
effectStack.push(effectFn)
const res = fn() // 新增
// 在当前副作用函数执行完毕后,将当前副作用函数弹出栈,并把acticeEffect还原为之前的值
effectStack.pop();
activeEffect = effectStack[effectStack.length - 1]
// 将res作为effectFn的返回值
return res // 新增
}
// 将options挂载到effectFn上
effectFn.optiopns = optiopns
// activeEffect.deps用来存储所有与该副作用函数相关联的依赖集合
effectFn.deps = []
// 只有非lazy的时候才执行
if (!optiopns.lazy) {
effectFn()
}
// 执行副作用函数
return effectFn
}
通过新增的代码可以看到,传递给effect函数的参数fn才是真正的副作用函数,而effectFn是我们包装后的副作用函数.为了通过effectFn得到真正的副作用函数fn的执行结果,我们需要将其保存到res变量中,然后将其作为effectFn函数的返回值.
现在我们已经能够实现懒执行的副作用函数,并且能够拿到副作用函数的执行结果了,接下来就可以实现计算属性了,如下所示:
function computed(getter) {
// 把getter函数作为副作用函数,创建一个lazy的effect
const effectFn = effect(getter, {
lazy: true
})
const obj = {
get value() {
return effectFn()
}
}
return obj
}
首先我们定义了一个computetd函数,它接受一个getter函数作为参数,我们把getter函数作为副作用函数,用它创建一个lazy的effect.computed函数的执行会返回一个对象,该对象的value属性是一个访问器属性,只有当读取value的值时,才会执行effectFn并将其结果作为返回值返回.
我们可以使用computed函数来创建一个计算属性:
const data = {foo: 1, bar: 2}
const obj = new Proxy(data, { /* */ })
const sumRes = computed(() => obj.foo + obj.bar)
console.log(sunRes.value) // 3
可以看到它能够正确的工作,不过现在我们实现的计算属性只做到了懒计算,也就是说,只有当你真正读取sumRes.value的值时,它才会进行计算并得到值.但是还做不到对值进行缓存,即假入我们多次访问sumRes.value的值,会导致effectFn进行多次计算,即使obj.foo和obj.bar的值本身并没有变化.为了解决这个问题,就需要我们在实现computed函数时,添加对值进行缓存的功能,如下代码所示:
function computed(getter) {
// value用来缓存上一次计算的值
let value;
// dirty标志用来标识是否需要重新计算值,为true则意味着“脏”,需要计算
let dirty = true;
// 把getter函数作为副作用函数,创建一个lazy的effect
const effectFn = effect(getter, {
lazy: true
})
const obj = {
get value() {
if (dirty) { // 新增
value = effectFn()
// 将dirty设置为false, 下一次返回直接使用缓存到value中的值
dirty = false
}
return value
}
}
return obj
}
相信你已经看到问题所在了,如果此时我们修改了obj.foo或者obj.bar的值,再访问sumRes.value会发现访问的值没有发生变化,这是因为dirty变为false后,即使我们修改了值,但dirty的值为false就不会重新计算.解决方法就需要用到上一节介绍的调度器,如以下代码所示:
function computed(getter) {
// value用来缓存上一次计算的值
let value;
// dirty标志用来标识是否需要重新计算值,为true则意味着“脏”,需要计算
let dirty = true;
// 把getter函数作为副作用函数,创建一个lazy的effect
const effectFn = effect(getter, {
lazy: true,
// 添加调度器,在调度器中将dirty重置为true
scheduler() { // 新增
dirty = true
}
})
const obj = {
get value() {
if (dirty) {
value = effectFn()
// 将dirty设置为false, 下一次返回直接使用缓存到value中的值
dirty = false
}
return value
}
}
return obj
}
它会在getter函数中所依赖的响应式数据变化时执行,这样我们在scheduler函数内将dirty重置为true,在下一次访问sumRes.value时就会重新调用effectFn计算值.
现在我们设计的计算属性已经趋于完美,但还有一个缺陷,它体现在当我们在另外一个effect中读取计算属性的值时:
const sumRes = computed(() => obj.foo + obj.bar)
effect(() => {
console.log(sumRes.value)
})
obj.foo++
如以上代码所示,sumRes是一个计算属性,并且在另一个effect的副作用函数中读取了sumRes.value的值.如果此时修改obj.foo的值,我们期望副作用函数重新执行,就像我们在Vue.js的模版中读取计算属性值的时候,一旦计算属性发生变化就会触发重新渲染一样,但是如果尝试运行上面这段代码,会发现修改obj.foo的值并不会处罚副作用函数的渲染,因此我们说这是一个缺陷.
解决方法:当读取计算属性的值时,我们可以手动调用track函数进行追踪,当计算属性依赖的响应式数据发生变化时,我们可以手动调用trigger函数触发依赖:
function computed(getter) {
// value用来缓存上一次计算的值
let value;
// dirty标志用来标识是否需要重新计算值,为true则意味着“脏”,需要计算
let dirty = true;
// 把getter函数作为副作用函数,创建一个lazy的effect
const effectFn = effect(getter, {
lazy: true,
// 添加调度器,在调度器中将dirty重置为true
scheduler() {
dirty = true;
// 当计算属性依赖的响应式数据变化时, 手动调用trigger函数触发依赖
trigger(obj, 'value')// 新增
}
})
const obj = {
get value() {
if (dirty) {
value = effectFn()
// 将dirty设置为false, 下一次返回直接使用缓存到value中的值
dirty = false
}
// 当读取value时,手动调用track函数进行追踪
track(obj, value)// 新增
return value
}
}
return obj
}
所谓watch,其本质就是观测一个响应式数据,当数据发生变化时通知并执行响应的回调函数.举个例子:
watch(obj, () => {
console.log('数据变了')
})
// 修改响应数据的值,会导致毁掉函数执行
obj.foo++
实际上,watch的实现本质就是利用了effect以及options.scheduler选项,如以下代码所示:
effect(() => {
console.log(obj.foo)
}, {
scheduler() {
// 当obj.foo的值变化时,会执行scheduler调度函数
}
})
在一个副作用函数中访问响应数据obj.foo,通过前面的介绍,我们知道这会在副作用函数与响应式数据之间建立联系,当响应式数据变化时,会触发副作用函数重新执行.但有一个例外,即如果副作用函数存在scheduler选项,当响应式数据变化时,会出发scheduler调度函数执行,而非直接触发副作用函数执行.从这个角度来看,其实scheduler调度函数就相当于一个回调函数,而watch的实现就是利用了这个特点.
//watch函数接收两个参数,source是响应式数据,cb是回调函数
function watch (source, cb) {
effect(
//触发读取的操作,从而建立联系
() => source.foo,
{
scheduler () {
// 当数据变化时,调用回调函数cb
cb()
}
}
)
}
开篇的watch函数可以正常工作,但是我们注意到watch函数的实现中,硬编码了对source.foo的读取操作.换句话说,现在只能观测obj.foo的改变.为了让watch函数具有通用性,我们要一个封装一个通用的读取操作:
function traverse(value, seen = new Set()) {
// 如果要读取的数据是原始值,或者已经被读取过,那么什么都不做
if (typeof value !== 'object' || value === null || seen.has(value)) return
// 将数据添加到seen中,代表遍历地读取过了,避免循环引用引起的死循环
seen.add(value)
// 暂时不考虑数组等其他结构
// 假设value就是一个对象,使用for in 读取对象的每一个值,并递归的调用tarverse进行处理
for (const k in value) {
traverse(value[k], seen)
}
return value
}
function watch(source, cb) {
effect (
// 调用tracerse递归的读取
() => traverse(source),
{
scheduler () {
// 当数据变化时,调用回调函数cb
cb()
}
}
)
}
如上面的代码所示,在watch内部的effect中调用traverse函数进行递归的读取操作,代替硬编码的方式,这样就能读取一个对象上的任意属性,从而当任意属性发生变化时都能够触发回调函数执行.
watch函数除了可以观测响应式数据,还可以接收一个getter函数:
watch(
// getter函数
() => obj.foo,
// 回调函数
() => {
console.log('obj.foo的值变了')
}
)
如以上代码所示,传递给watch函数的第一个参数不再是一个响应式数据,而是一个getter函数.在getter函数内部,用户可以指定该watch依赖哪些响应式数据,只有当这些数据变化时,才会触发回调函数.如下代码实现了这一功能:
function watch(source, cb) {
// 定义getter
let getter
// 如果source是函数,说明用户传递的是getter,所以直接把source赋值给getter
if (typeof source === 'function') {
getter = source
} else {
// 否则按照原来的实现调用traverse递归的读取
getter = () => traverse(source)
}
effect (
// 执行getter
() => getter(),
{
scheduler () {
// 当数据变化时,调用回调函数cb
cb()
}
}
)
}
首先判断source的类型,如果是函数类型,说明用户直接传递了getter函数,这时直接使用用户的getter函数:如果不是函数类型,那么保留之前的做法,即调用traverse函数递归的读取,这样就实现了自定义getter的功能,同时使得watch函数更加强大.
现在的实现还缺少一个非常重要的能力,即在回调函数中拿不到旧值与新值.通常我们在使用Vue.js中的watch函数时,能够在回调函数中得到变化前后的值:
watch(obj, (newValue, oldValue) => {
console.log(newValue, oldValue, '数据变了')
})
那么如何获得新值与旧值呢,这需要充分利用effect函数的lazy选项,如以下代码所示:
function watch(source, cb) {
// 定义getter
let getter
// 如果source是函数,说明用户传递的是getter,所以直接把source赋值给getter
if (typeof source === 'function') {
getter = source
} else {
// 否则按照原来的实现调用traverse递归的读取
getter = () => traverse(source)
}
// 定义旧值与新值
let oldValue, newValue
// 使用effect注册副作用函数时,开启lazy选项,并把返回值存储到effectFn中以便后续手动调用
const effectFn = effect (
// 执行getter
() => getter(),
{
lazy: true,
scheduler () {
// 在scheduler中重新执行副作用函数,得到的是新值
newValue = effectFn()
// 将旧值和新值作为回调函数的参数
cb(newValue, oldValue)
// 更新旧值,不然下一次会得到错误的旧值
oldValue = newValue
}
}
)
// 手动调用副作用函数,拿到的值就是旧值
oldValue = effectFn()
}
在这段代码中,最核心的改动是使用lazy选项创建了一个懒执行的effect.注意上面代码中最下面的部分,我们手动调用effectFn函数会得到的返回值就是旧值,即第一次执行得到的值.当变化发生并触发scheduler调度函数执行时,会重新调用effcetFn函数并得到新值,这样我们就拿到了旧值与新值,接着将他们作为参数传递给回调函数cb就可以了.最后一件非常重要的事情是,不要忘记使用新值更新旧值,否则在下一次变更发生时会得到错误的旧值.
本节我们继续讨论关于watch的两个特性:一个是立即执行的回调函数,另一个是回调函数的执行时机.
首先来看立即执行的回调函数.默认情况下,一个watch的回调只会在响应式数据发生变化时才执行:
// 回调函数只有在响应式数据obj后续发生变化时才执行
watch(obj, () => {
console.log('变化了')
})
在Vue.js中可以通过选项参数immediate来制定回调函数是否需要立即执行:
watch(obj, () => {
console.log('变化了')
}, {
//回调函数会在watch创建时立即执行一次
immediate: true
})
仔细思考就会发现,回调函数的立即执行与后续执行本质上没有任何差别,所以我们可以把scheduler调度函数封装为一个通用函数,分别在初始化和变更时执行它,如以下代码所示:
function watch(source, cb, options = {}) {
// 定义getter
let getter
// 如果source是函数,说明用户传递的是getter,所以直接把source赋值给getter
if (typeof source === 'function') {
getter = source
} else {
// 否则按照原来的实现调用traverse递归的读取
getter = () => traverse(source)
}
// 定义旧值与新值
let oldValue, newValue
// 提取scheduler调度函数为一个独立的job函数
const job = () => {
newValue = effectFn()
cb(newValue, oldValue)
oldValue = newValue
}
// 使用effect注册副作用函数时,开启lazy选项,并把返回值存储到effectFn中以便后续手动调用
const effectFn = effect (
// 执行getter
() => getter(),
{
lazy: true,
scheduler: job
}
)
if (optiopns.immediate) {
// 当immediate为true时立即执行
job()
} else {
// 手动调用副作用函数,拿到的值就是旧值
oldValue = effectFn()
}
}
除了指定回调函数为立即执行之外,还可以通过其他选项参数来指定回调函数的执行时机,例如在Vue.js3中使用flush选项来指定:
watch(obj, () => {
console.log('变化了')
}, {
//回调函数会在watch创建时立即执行一次
flush: 'pre' // 还可以指定为'post' | 'sync'
})
flush本质上是在指定调度函数的执行时机.前文讲解过如何在微任务队列中执行调度函数scheduler,这与flush的更能相同,当flush的值为‘post’时,代表调度函数需要将副作用函数放到一个微任务队列中,并等待DOM更新结束后再执行,我们可以用如下代码进行模拟:
function watch(source, cb, options = {}) {
// 定义getter
let getter
// 如果source是函数,说明用户传递的是getter,所以直接把source赋值给getter
if (typeof source === 'function') {
getter = source
} else {
// 否则按照原来的实现调用traverse递归的读取
getter = () => traverse(source)
}
// 定义旧值与新值
let oldValue, newValue
// 提取scheduler调度函数为一个独立的job函数
const job = () => {
newValue = effectFn()
cb(newValue, oldValue)
oldValue = newValue
}
// 使用effect注册副作用函数时,开启lazy选项,并把返回值存储到effectFn中以便后续手动调用
const effectFn = effect (
// 执行getter
() => getter(),
{
lazy: true,
scheduler: () => {
if (options.flush === 'post') {
const p = Promise.resolve()
p.then(job)
} else {
job()
}
}
}
)
if (options.immediate) {
// 当immediate为true时立即执行
job()
} else {
// 手动调用副作用函数,拿到的值就是旧值
oldValue = effectFn()
}
}
如以上代码所示,我们修改了调度器scheduler的实现方式,在调度器函数内检测options.flush的值是否为post,如果是,则将job函数放到微任务队列中,从而实现异步延迟执行;否则直接执行job函数,这本质上相当于‘sync’的实现机制,即同步执行.对于options.flush的值为‘pre’的情况,我们暂时没有办法模拟,因为这设计组件的更新时机,其中‘pre’和‘post’原本的语义指的就是组建更新前和更新后,不过这并不影响我们理解如何控制回调函数的更新时机.
竞态问题通常在多线程或多进程编程中被提及,举个例子:
let finalData
watch(obj, async () => {
// 发送并等待网络请求
const res = await fetch('/path/to/request')
// 将请求结果赋值给data
finalData = res
})
观察上面的代码,乍一看似乎没有问题.但仔细思考会发现这段代码会发生竞态问题.假如我们第一次修改obj对象的某个字段值,这会导致回调函数执行,同时发送了第一次请求,随着时间的推移,在请求A的结果返回之前,我们对obj对象的某个字段值进行了第二次修改,这会导致发送第二次请求B,此时,请求A和请求B都在进行中,那么哪一个请求会先返回结果呢?我们不确定,如果请求B先于请求A返回结果,就会导致最终finalData中存储的事A请求的结果,如图所示:
但由于请求B是后发送的,因此我们认为请求B返回的数据才是最新的,而请求A应该被视为‘过期’的,所以我们希望变量finalData存储的值应该是由请求B返回的结果,非请求A返回的结果.
归根结底,我们需要的是一个让副作用过期的手段,为了让问题更加清晰,我们先拿Vue.js中的watch函数来复现场景,看看Vue.js是如何帮助开发者解决这个问题的,然后尝试实现这个功能.
在Vue.js中,watch函数的回调函数接收第三个参数onInvalidate,他是一个函数,类似于事件监听器,我们可以使用onInvalidate函数注册一个回调,这个回调函数会在当前副作用函数过期时执行:
let finalData
watch(obj, async (newValue, oldValue, onInvalidate) => {
// 定义一个标志,代表当前副作用函数是否过期,默认为false,代表没有过期
let expired = false
// 调用onInvalidate()函数注册一个过期回调
onInvalidate(() => {
// 当过期时, 将expired设置为true
expired = true
})
// 发送并等待网络请求
const res = await fetch('/path/to/request')
// 只有当该副作用函数的执行没有过期时,才会执行后续操作
if (!expired) {
finalData = res
}
})
那么,onInvalidate的原理是什么呢?在watch内部每次检测到变更后,在副作用函数重新执行之前,会先调用我们通过onInvalidate函数注册的过期回调,仅此而已,如以下代码所示:
function watch(source, cb, options = {}) {
// 定义getter
let getter
// 如果source是函数,说明用户传递的是getter,所以直接把source赋值给getter
if (typeof source === 'function') {
getter = source
} else {
// 否则按照原来的实现调用traverse递归的读取
getter = () => traverse(source)
}
// 定义旧值与新值
let oldValue, newValue
// cleanup用来存储用户注册的过期回调
let cleanup
// 定义onInvalidate函数
function onInvalidate(fn) {
// 将过期回调存储到cleanup中
cleanup = fn
}
// 提取scheduler调度函数为一个独立的job函数
const job = () => {
newValue = effectFn()
// 在调用回调函数cb之前,先调用过期回调
if (cleanup) {
cleanup()
}
// 将onInvalidate作为回调函数的第三个参数,以便用户使用
cb(newValue, oldValue, onInvalidate)
oldValue = newValue
}
// 使用effect注册副作用函数时,开启lazy选项,并把返回值存储到effectFn中以便后续手动调用
const effectFn = effect (
// 执行getter
() => getter(),
{
lazy: true,
scheduler: () => {
if (options.flush === 'post') {
const p = Promise.resolve()
p.then(job)
} else {
job()
}
}
}
)
if (options.immediate) {
// 当immediate为true时立即执行
job()
} else {
// 手动调用副作用函数,拿到的值就是旧值
oldValue = effectFn()
}
}
既然Vue.js3的响应式数据是基于Proxy实现的,那么我们就有必要了解Proxy以及与之相关联的Reflect.什么是Proxy呢?简单的说,使用Proxy可以创建一个代理对象.它能够实现对其他对象的代理,这里的关键词是其他对象,也就是说,Proxy只能代理对象,无法代理非对象值,例如字符串、布尔值等.那么,代理指的是什么呢?所谓代理,指的是对一个对象基本语义的代理.它允许我们拦截并重新定义对一个对象的基本操作.这句话的关键词比较多,我们逐一解释.
什么是基本语义?给出一个对象obj,可以对它进行一些操作,例如读取属性值,设置属性值:
obj.foo // 读取属性foo的值
obj.foo++ // 读取和设置属性foo的值
类似这种读取、设置属性值的操作,就属于基本语义的操作,即基本操作.既然是基本操作那么它就可以使用Proxy拦截:
const p = new Proxy(obj, {
// 拦截读取属性操作
get() { /* ... */ },
// 拦截设置属性操作
set() { /* ... */ }
})
在JavaScript的世界里,万物皆对象.例如一个函数也是一个对象,所以调用函数也是对一个对象的基本操作:
const fn = (name) => {
console.log('我是:', name)
}
// 调用函数是对对象的基本操作
fn()
因此,我们可以用Proxy来拦截函数的调用操作,这里我们使用apply拦截函数的调用:
const p2 = new Proxy(fn, {
// 使用apply拦截函数调用
apply(target, thisArg, argArray) {
target.call(thisArg, ...argArray)
}
})
p2('hcy') // 输出: 我是: hcy
上面两个例子说明了什么是基本操作.Proxy只能拦截对一个对象的基本操作.那么,什么是基非基本操作呢?其实调用对象的方法就是典型的非基本操作,我们可以叫它复合操作:
obj.fn()
实际上,调用一个对象下的方法,是由两个基本语义组成的,第一个基本语义是get,即先通过get操作得到obj,fn属性,第二个基本语义是函数调用,即通过get得到obj,fn的值后再调用它,也就是我们上面说到的apply,理解Proxy只能够代理对象的基本语义很重要,后续我们讲解如何实现对数组或Map,Set等数据类型的代理时,都利用了Proxy的这个特点.
理解了Proxy,我们再来讨论Reflect.Reflect是一个全局对象,其下有许多方法.例如:
Reflect.get()
Reflect.set()
Reflect.apply()
// ...
你可能已经注意到了,Reflect下的方法与Proxy的拦截器方法名字相同,其实这不是偶然,任何在Proxy的拦截器中能够找到的方法,都能够在Reflect中找到同名函数,那么这些函数的作用是什么呢?其实他们的作用一点不神秘.拿Reflect.get函数来说,它的功能就是提供了访问一个对象属性的默认行为,例如下面两个操作是等价的:
const obj = { foo: 1 }
// 直接读取
console.log(obj.foo) // 1
// 使用Reflect.get读取
console.log(Reflect.get(obj, 'foo')) // 1
可能有的读者会产生疑问:既然操作等价,那么它存在的意义是什么呢?实际上Reflect.get函数还能接收第三个参数,即指定接收者receiver,你可以把它理解为函数调用过程中的this,例如:
const obj = {
get foo () {
return this.foo
}
}
console.log(Reflect.get(obj, 'foo', {foo:2})) //输出的是2而不是1
在这段代码中,我们指定第三个参数receiver为一个对象{ foo: 2 },这时读取到的值是receiver对象的foo属性值.实际上,Reflect.*方法还有很多其他方面的意义,但这里我们只关心并讨论这一点,因为它与响应式数据的实现密切相关.为了说明问题,回顾一下在上一节中实现响应式数据的代码,在上一节实现的代码中,在get和set拦截函数中,我们都是直接使用原始对象target来完成对属性的读取和设置操作的.
那么之前的代码有什么问题么?我们借助effect让问题暴露出来.首先,我们修改一下obj对象,为它添加bar属性:
const obj = {
foo: 1,
get bar () {
return this.foo
}
}
可以看到,bar属性是一个访问器属性,它返回了this.foo属性的值.接着我们在effect副作用函数中通过代理对象p访问bar属性
effect(() => {
console.log(p.bar) // 1
})
我们来分析一下这个过程发生了什么,当effect注册的副作用函数执行时,会读取p.bar属性,它发现p.bar是一个访问器属性,因此执行getter函数.由于getter函数中通过this.foo读取了foo的值,因此我们认为副作用函数与属性foo之间也会建立联系.当我们修改p.foo的值时应该能够触发响应,使得副作用函数重新执行才对.然而实际并非如此.当我们尝试修改p.foo的值时,副作用函数并没有重新执行,问题就出在bar属性的访问器函数getter里:
const obj = {
foo: 1,
get foo () {
// 这里的this指向的是谁
return this.foo
}
}
我们回顾一下get拦截函数执行,在get拦截函数内,通过target[key]访问属性值.其中target是原始对象obj,而key就是字符串bar,所以target[key]相当于obj.foo.因此,当我们使用p.bar访问bar属性时,它的getter函数内的this指向的其实是原始对象obj.很显然,在副作用函数内通过原始对象访问它的某个属性是不会建立响应联系的,那么这个问题怎么解决呢,这时Reflect.get函数就派上用场了.先给出解决问题的代码:
const p = new Proxy(obj, {
// 拦截读取操作,接收第三个参数receiver
get(target, key, receiver) {
// 将副作用函数activeEffect添加到存储副作用函数的桶中
track(target, key);
// 使用Reflect.get返回读取到的属性值
return Reflect.get(target, key, receiver)
},
// 省略部分代码
})
当我们使用代理对象p访问bar属性时,那么receiver就是p,你可以把它简单理解为函数调用中的this.接着关键的一部发生了,我们使用Reflect.get(target, key, receiver)代替之前的target[key],这里的关键点就是第三个参数receiver.我们已经知道他就是代理对象,所以访问器属性bar的getter函数内的this指向代理对象p,this由原始对象obj变成了代理对象p.很显然,这会在副作用函数与响应式数据之间建立响应联系,从而达到依赖收集的效果.如果此时再对p.foo进行自增,会发现已经能够触发副作用函数重新执行了.
正是基于上述原因,后文讲解中统一使用Reflect.*方法.
我们经常听到这样的说法:‘JavaScript中一切皆对象’.那么,到底什么是对象呢?实际上,根据ECMAScript规范,在JavaScript中有两种对象,其中一种叫做常规对象,另一种叫做异质对象.
我们知道,在JavaScript中,函数其实也是对象.假设给出一个对象obj,如何区分他是普通对象还是函数呢?实际上,在JavaScript中,对象的实际语义是由对象的内部方法指定的.所谓内部方法,指的是我们对一个对象进行操作时在引擎内部调用的方法,这些方法对于JavaScript使用者来说是不可兼得.举个例子,当我们访问对象属性时:
obj.foo
引擎内部会调用[[Get]]这个内部方法来读取属性值.这里补充一下,在ECMAScript规范使用[[xxx]]来代表内部方法或内部槽.当然,一个对象不仅部署了[[Get]]这个内部方法,下图列出了规范要求的所有必要的内部方法.
内部方法 |
签名 |
描述 |
[[GetPrototypeOf]] |
() -> Object | Null |
查明为该对象提供继承属性的对象,null代表没有继承属性 |
[[SetPrototypeOf]] |
(Object | Null) -> Boolean |
将该对象与提供继承属性的另一个对象相关联.传递null表示没有继承属性,返回true表示操作成功完成,返回false表示操作失败 |
[[IsExtensible]] |
() -> Boolean |
查明是否允许向该对象添加其他属性 |
[[PreventExtensions]] |
() -> Boolean |
控制能否向该对象添加新属性.如果操作成功返回true,否则返回false |
[[GetOwnProperty]] |
(propertyKey) -> Undefined | Property Descriptor |
返回该对象自身属性的描述符,其键为propertykey,如果不存在这样的属性,则返回undefined |
[[DefineOwnProperty]] |
(propertyKey,PropertyDescriptor) -> Boolean |
创建或更改自己的属性,其键为PropertyDescriptor描述的状态.如果该属性已成功创建或更新,则返回true,否则放回false |
[[HasProperty]] |
(propertyKey) -> Boolean |
返回一个布尔值,指示该对象是否已经拥有键为propertyKey的自己的或继承的属性 |
[[Get]] |
(propertyKey, Receiver) -> any |
从该对象返回键为propertyKey的属性的值.如果必须运行ECMAScript代码来检索属性值,则在运行代码时使用Receiver作为this值 |
[[Set]] |
(propertyKey, value, Receiver) -> Boolean |
将键值为propertyKey的属性的值设置为value.如果必须运行ECMAScript代码来设置属性值,则在运行代码时使用Receiver作为this值.如果成功设置了属性值,则返回true;否则返回false |
[[Delete]] |
(propertyKey, Receiver) -> any |
从该对象中删除属于自身的键为propertyKey的属性,如果该属性未被删除并且仍然存在,则返回false,如果该属性已被删除或不存在,则返回true |
[[OwnPropertyKeys]] |
() -> List of propertyKey |
返回一个List,其元素都是对象自身的属性值 |
一个对象必须部署11个必要的内部方法.除了商标的内部方法之外,还有两个额外的必要内部方法:[[Call]]和[[Construct]]
内部方法 |
签名 |
描述 |
[[Call]] |
(any, a List of any) -> any |
将运行的代码与this对象关联.由函数调用出发.该内部方法的参数是一个this值和参数列表 |
[[Construct]] |
(a List of any, Object) -> Object |
创建一个对象.通过new运算符或super调用出发.该内部方法的第一个参数是一个List,该List的元素是构造函数调用或super调用的参数,第二个参数是最初应用new运算符的对象.实现该内部方法的对象为构造函数 |
如果一个对象需要作为函数调用,那么这个对象就必须部署内部方法[[Call]].通过内部方法和内部槽来区分对象,列入函数对象会部署内部方法[[Call]],而普通对象则不会.
满足以下三点就是常规对象:
而所有不符合这三点要求的对象都是异质对象.列入Proxy
从本节开始,我们将着手实现响应式数据.前面我们使用get拦截函数去拦截对属性的读取操作.但在相应系统中,“读取”是一个很宽泛的概念,例如使用in操作符检查对象上是否具有给定的key也属于“读取”操作,如下面的代码所示:
effect(() => {
'foo' in obj
})
这本质上也是在进行“读取”操作.响应系统应该拦截一切读取操作,以便当数据变化能够正确的触发响应.下面列出了对一个普通对象的所有可能的读取操作.
接下来,我们逐步讨论如何拦截这些读取操作.首先是对于属性的读取,例如obj.foo,我们知道这可以通过get拦截函数实现:
const obj = {
foo: 1,
}
const p = new Proxy(obj, {
// 拦截读取操作,接收第三个参数receiver
get(target, key, receiver) {
// 将副作用函数activeEffect添加到存储副作用函数的桶中
track(target, key);
// 使用Reflect.get返回读取到的属性值
return Reflect.get(target, key, receiver)
}
})
对于in操作符,应该如何拦截呢?我们可以先查看上一节的表,尝试寻找与in操作符对应的拦截函数,但其中没有与in操作符相关的内容.这时我们就需要查看关于in操作符的相关规范.在ECMA-262规范的13.10.1节中,明确定义了in操作符的运行时逻辑
上图描述如下:
关键点在第6步,可以发现,in操作符的运算结果是通过调用一个叫做HasProperty的抽象方法得到的.关于HasProperty抽象方法,可以在ECMA-262规范的7.3.11节中找到:
在第3步中,可以看到HasProperty抽象方法的返回值是通过调用对象的内部方法[[HasProperty]]得到的,而[[HasProperty]]内部方法可以在上节的表中找到,它对应的拦截函数名叫has,因此我们可以通过has拦截函数实现对in操作符的代理:
const obj = {
foo: 1,
}
const p = new Proxy(obj, {
has(target, key) {
track(target, key)
return Reflect.has(target, key)
}
})
这样,当我们在副作用函数中通过in操作符操作响应式数据时,就能够建立依赖关系:
effect(() => {
'foo' in p // 将会建立依赖关系
})
再来看看如何拦截for...in循环.在ECMA规范中定义了for...in头部的执行规则,如下图所示:
上图第六步描述的内容如下:
6.如果 iterationKind 是枚举,则
a. 若果exprValue是undefined或null,那么
i. 返回Completion { [[Type]]: break, [[Value]]: empty, [[Target]]: empty }
b. 让obj的值为!ToObject(exprValue)
c. 让iterator的值为?EnumerateObjectProperties(obj)
d. 让nextMethod的值为!GetV(iterator, 'next')
e. 返回Record{ [[Iterator]]: iterator, [[NextMethod]]: nextMethod, [[Done]]: false }
仔细观察c步骤,其中关键点在于EnumerateObjectProperties(obj),这里的EnumerateObjectProperties是一个抽象方法,该方法返回一个迭代器对象,规范的14.7.5.9节给出了满足该抽象方法的示例实现,如下所示:
function* EnumerateObjectProperties(obj) {
const visited = new Set();
for (const key of Reflect.ownKeys(obj)) {
if (typeof key === "symbol") continue;
const desc = Reflect.getOwnPropertyDescriptor(obj, key);
if (desc) {
visited.add(key);
if (desc.enumerable) yield key;
}
}
const proto = Reflect.getPrototypeOf(obj);
if (proto === null) return;
for (const protoKey of EnumerateObjectProperties(proto)) {
if (!visited.has(protoKey)) yield protoKey;
}
}
可以看到,该方法是一个generator函数,接收一个参数obj.实际上,obj就是被for...in循环遍历的对象,其中关键点在于使用Reflect.ownKeys(obj)来获取只属于对象自身拥有的键.有了这个线索,如何拦截for...in循环的答案已经很明显了,我们可以使用ownKeys拦截Reflect.ownKeys操作:
const obj = {
foo: 1,
}
const ITERATE_KEY= Symbol()
const p = new Proxy(obj, {
ownKeys(target) {
// 将副作用函数与ITERATE_KEY关联
track(target, ITERATE_KEY)
return Reflect.ownKeys(target)
}
})
我们在使用track函数进行追踪的时候,将ITERATE_KEY作为追踪的key,为什么呢?这是因为ownKeys拦截函数与get/set拦截函数不同,在set/get中,我们可以得到具体操作的key,但是在ownKeys中,我们只能拿到目标对象target.这也很符合直觉,因为在读写属性值时,总是能够明确的指导当前正在操作哪一个属性,所以只需要在该属性与副作用函数之间建立联系即可.而ownKeys用来获取一个对象的所有属于自己的键值,这个操作明显不与任何具体的键进行绑定,因此我们只能够构造唯一的key作为标识.
既然追踪的是ITERATE_KEY,那么相应地,在触发响应的时候也应该触发它才行:
trigger(target, ITERATE_KEY)
但是在什么情况下,对数据的操作需要出发与ITERATE_KEY相关联的副作用函数重新执行?为了搞清楚这个问题,我们用一段代码来说明.
const obj = { foo: 1 }
const p = new Proxy(obj, {/*... */ })
effect(() => {
// for...in循环
for (const key in p) {
console.log(key)
}
})
副作用函数执行后,会与ITERATE_KEY之间建立响应联系,接下来我们尝试为对象,p添加新的属性bar:
p.bar = 2
由于对象p原本只有foo属性,因此for...in循环只会执行一次.现在为它添加了新的属性bar,所以for...in循环就会由执行一次变成两次.也就是说,当为对象添加新属性时,会对for...in循环产生影响,所以需要触发与ITERATE_KEY相关联的副作用函数重新执行.但目前的实现还做不到这一点.当我们为对象p添加新的属性bar时,并没有触发副作用函数重新执行,这是为什么呢?我们来看一下现在的set拦截函数的实现:
const p = new Proxy(obj, {
set(target, key, newVal, receiver) {
// 设置属性值
const res = Reflect.set(target, key, newVal, receiver)
// 把副作用函数从桶里取出并执行
trigger(target, key)
return res
},
})
当为对象p添加新的bar属性时,会触发set拦截函数执行.此时set拦截函数接收到的key就是字符串‘bar’,因此最终调用trigger函数时也只是出发了与‘bar’相关联的副作用函数重新执行.
当添加属性时,我们将那些与ITERATE_KEY相关联的副作用函数也取出来执行就可以了:
function trigger (target, key) {
// 根据target从桶中取得depsMap,它是: key --> effects
const depsMap = bucket.get(target);
if (!depsMap) return
// 根据key取得所有副作用函数effects
const effects = depsMap.get(key);
// 取得与ITERATE_KEY相关联的副作用函数
const iterateEffects = depsMap.get(ITERATE_KEY)
const effectsToRun = new Set()
// 将与key相关联的副作用函数添加到effectsToRun
effects && effects.forEach(effectFn => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})
// 将与ITERATE_KEY相关联的副作用函数添加到effectsToRun
iterateEffects && iterateEffects.forEach(effectFn => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})
effectsToRun.forEach(effectFn => {
// 如果一个副作用函数存在调度器, 则调用该调度器, 并将副作用函数作为参数传递
if (effectFn.optiopns.scheduler) { // 新增
effectFn.optiopns.scheduler(effectFn) // 新增
} else {
// 否则直接执行副作用函数(之前的默认行为)
effectFn()
}
})
}
对于添加新属性来说,这么做没有什么问题,但如果仅仅修改已有属性的值,而不是添加新属性,那么问题就来了,修改属性不会对for...in循环产生影响.因为无论怎么修改一个属性的值,对于for...in循环来说都只会循环一次.所以在这种情况下,我们不需要触发副作用函数执行,否则会造成不必要的性能开销.然而无论是添加新属性,还是修改已有的属性值,其基本语义都是[[Set]],我们都是通过set拦截函数来实现的.为了解决这个问题,当设置属性操作发生时,就需要我们在set拦截函数内能够区分操作的类型,到底是添加新属性还是设置已有属性:
const p = new Proxy(obj, {
set(target, key, newVal, receiver) {
// 如果属性不存在,则说明是在添加新属性,否则是设置已有属性
const type = Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD'
// 设置属性值
const res = Reflect.set(target, key, newVal, receiver)
// 把副作用函数从桶里取出并执行
trigger(target, key, type)
return res
},
})
在trigger函数内就可以通过类型type来区分当前的操作类型,并且只有当操作类型type 为ADD时,才会触发与ITERATE_KEY相关联的副作用函数重新执行,这样就避免了不必要的性能损耗:
function trigger (target, key, type) {
// 根据target从桶中取得depsMap,它是: key --> effects
const depsMap = bucket.get(target);
if (!depsMap) return
// 根据key取得所有副作用函数effects
const effects = depsMap.get(key);
const effectsToRun = new Set()
// 将与key相关联的副作用函数添加到effectsToRun
effects && effects.forEach(effectFn => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})
if (type === 'ADD') {
// 取得与ITERATE_KEY相关联的副作用函数
const iterateEffects = depsMap.get(ITERATE_KEY)
// 将与ITERATE_KEY相关联的副作用函数添加到effectsToRun
iterateEffects && iterateEffects.forEach(effectFn => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})
}
effectsToRun.forEach(effectFn => {
// 如果一个副作用函数存在调度器, 则调用该调度器, 并将副作用函数作为参数传递
if (effectFn.optiopns.scheduler) { // 新增
effectFn.optiopns.scheduler(effectFn) // 新增
} else {
// 否则直接执行副作用函数(之前的默认行为)
effectFn()
}
})
}
通常我们会将操作类型封装为一个枚举值,例如:
const TriggerType = {
SET: 'SET',
ADD: 'ADD'
}
这样无论是对后期代码的维护,还是对代码的清晰度,都是非常有帮助的.
对于对象的代理,还剩下最后一项,即删除属性的代码:
delete p.foo
如何代理delete操作符呢?还是看规范,规范的13.5.1.2节中明确定义了delete操作符的行为,如下图所示:
上图第5步描述的内容如下:
a. 断言: ! IsPrivateReference(ref)是false
b. 如果IsSuperReference(ref)也是true,则抛出ReferenceError异常
c. 让baseObj的值为?baseObj.[[Delete]](ref.[[ReferencedName]])
d. 让deleteStatus的值为false并且ref.[[Strict]]的值时true, 则抛出TypeError异常
f. 返回deleteStatus
由第五步中的d子步骤可知,delete操作符的行为以来[[Delete]]内部方法.该内部方法可以使用deleteProperty拦截
const p = new Proxy(obj, {
deleteProperty(target, key) {
// 检查被操作的属性是否是对象自己的属性
const hadKey = Object.prototype.hasOwnProperty.call(target, key)
// 使用Reflect.deleteProperty完成属性的删除
const res = Reflect.deleteProperty(target, key)
if (res && hadKey) {
trigger(target, key, 'DELETE')
}
return res
}
})
首先检查被删除的属性是否属于对象自身,然后调用Reflect.deleteProperty函数完成属性的删除工作.只有当这两部的结果都满足条件时,才调用trigger函数触发副作用函数重新执行.需要注意的是,在调用trigger函数时,我们传递了新的操作类型‘DELETE’.由于删除操作会使得对象的键变少,他会影响for...in循环的次数,因此当操作类型为‘DELETE’时,我们也应该出发那些与ITERATE_KEY相关联的副作用函数重新执行:
function trigger (target, key, type) {
// 根据target从桶中取得depsMap,它是: key --> effects
const depsMap = bucket.get(target);
if (!depsMap) return
// 根据key取得所有副作用函数effects
const effects = depsMap.get(key);
const effectsToRun = new Set()
// 将与key相关联的副作用函数添加到effectsToRun
effects && effects.forEach(effectFn => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})
if (type === TriggerType.ADD || type === TriggerType.DELETE) {
// 取得与ITERATE_KEY相关联的副作用函数
const iterateEffects = depsMap.get(ITERATE_KEY)
// 将与ITERATE_KEY相关联的副作用函数添加到effectsToRun
iterateEffects && iterateEffects.forEach(effectFn => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})
}
effectsToRun.forEach(effectFn => {
// 如果一个副作用函数存在调度器, 则调用该调度器, 并将副作用函数作为参数传递
if (effectFn.optiopns.scheduler) { // 新增
effectFn.optiopns.scheduler(effectFn) // 新增
} else {
// 否则直接执行副作用函数(之前的默认行为)
effectFn()
}
})
}