写在前面:
小作文,本文为霍春阳的《Vue.js设计与实现》一书的第四章:响应系统的作用与实现 的分享文章。是我在美团实习所做的第一次组间技术分享内容。原写于公司内部文档系统,但发现无法转载,故CV到这里。
本文主要内容
基于 Proxy 代理的简单实现响应式,并一步一步地完善这个简单的响应式函数,最终达到与Vue3 源码相近的设计思路与简化实现。同时也包含了 computed 和 watch 的实现(也是一种响应式的完善结果)。
本文会从简单的示例一步一步深入,还是比较好懂的,可慢慢享用。
一种封装的思想
工具模块一:
// utils.js
export default {
foo(fn) {
}
}
使用工具模块一:
import utils from 'utils.js'
utils.foo(()=>{
//...
})
若工具模块一执行出错,需要进行错误处理。
方式一:调用处处理
import utils from 'utils.js'
utils.foo(()=>{
try{
//...
} catch(e){
//...
}
})
方式二:定义处统一处理
// utils.js
export default {
foo(fn) {
try{
fn && fn()
}catch(e){/*...*/}
},
bar(fn) {
try{
fn && fn()
}catch(e){/*...*/}
}
}
方式三:封装错误处理程序函数,假设叫它 callWithErrorHandling
/*
我们提供了 registerErrorHandler 函数,用户可以使用它注册错误处理程序,
然后在 callWithErrorHandling 函数内部捕获错误后,把错误传递给用户注册的错误处理程序
*/
// utils.js
let handleError = null;
export default {
foo(fn) {
callWithErrorHandling(fn)
},
//用户可以调用该函数注册统一的错误处理函数
registerErrorHandler(fn) {
handleError = fn
}
}
function callWithErrorHandling(fn) {
try {
fn && fn()
}catch (e) {
// 将捕获到的错误传递给用户的错误处理程序
handleError(e)
}
}
方式三下调用处代码:
import utils from 'utils.js'
// 注册错误处理程序
utils.registerErrorHandler((e) => {
console.log(e)
})
utils.foo(() => {/*...*/})
utils.bar(() => {/*...*/})
正文开始
概念:副作用函数---顾名思义,是一个会产生副作用的函数
const obj = { text: 'hello world' }
function effect() {
document.body.innerText = boj.text
}
除了effect函数之外的任何函数都可以读取或设置body的文本内容,故effect函数会直接或者间接地影响其他函数的执行。
响应式数据的基本实现--拦截一个对象的读取和设置操作
读取obj.text时,可以把副作用函数 effect 存储到一个‘桶’里。接着,当设置obj.text时,再把副作用函数effect从‘桶’里取出并执行
拦截一个对象属性的读取和设置操作:ES2015之前通过 Object.defineProperty函数实现,ES2015+可以使用代理对象 Proxy 实现
简单实现流程:
读取操作发生时,将副作用函数收集到‘桶’中
当设置操作发生时,从‘桶’中取出副作用函数并执行
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
}
})
完善
上述代码中,我们固定地写了副作用函数effect,为了解绑副作用函数的具名,需要提供一个用来注册副作用函数的机制
// 用一个全局变量 存储 被注册的副作用函数
let activeEffect
// effect 函数用于注册副作用函数
function effect(fn) {
// 当调用 effect 注册副作用函数时,将副作用函数 fn 赋值给 activeEffect
activeEffect = fn
// 执行副作用函数
fn()
}
// 测试
effect(
//一个匿名的副作用函数
() => {
document.body.innerText = obj.text
}
)
//把匿名的副作用函数 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())
return true
}
})
此时,我们在set 时将全部的efect函数执行,但是我们希望响应的只是这一个字段对应的副作用函数 effect来响应。所以我们需要建立副作用函数与被操作函数之间的联系才可以实现对应数据set时的对应数据更新
//使用 WeakMap 代替 Set 作为桶的数据结构
const bucket = new WeakMap()
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 代替 Set 作为桶的数据结构
const bucket = new WeakMap()
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)
}
})
//在 get 拦截函数内调用 track 函数追踪变化
function track(target, key) {
//若没有 activeEffect,直接 return
if(!activeEffect) return
let depsMap = bucket.get(target)
if(!depsMap) {
bucket.set(target, (depsMap = new Map()))
}
let deps = depsMap.get(key)
if(!deps) {
depsMap.set(key, (deps = new Set()))
}
deps.add(activeEffect)
}
// 在 set 拦截函数内调用 trigger 函数触发变化
function trigger(target, key) {
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
effects && effects.forEach(fn => fn())
}
分支切换与 cleanup
document.body.innerText = obj.ok ? obj.text : 'not'
//当 obj.ok 的值发生变化时, 代码执行的分支会跟着变化,这就是所谓的分支切换。
//当 obj.ok 为 false 时,理想情况下obj.text 不会被读取,故 effectFn 不应该被obj.text 所对应的依赖收集
//若要做到无论obj.text如何改变,document.body.innerText都不需要再变化,则需要在每次副作用函数执行时,把它从所有与之关联的依赖集合中删除
1. 重新设计副作用函数:
// 旧副作用函数
function effect(fn) {
// 当调用 effect 注册副作用函数时,将副作用函数 fn 赋值给 activeEffect
activeEffect = fn
// 执行副作用函数
fn()
}
// 用一个全局变量存储被注册的副作用函数
let activeEffect
function effect(fn) {
const effectFn = () => {
// 调用 cleanup 函数完成清除
cleanup(effectFn)
//当effectFn 执行时,将其设置为当前激活的副作用函数
activeEffect = effecctFn
fn()
}
// activeEffect.deps 用来存储所有与该副作用函数相关联的依赖集合
effectFn.deps = []
//执行副作用函数
effectFn()
}
2. 在track函数中收集依赖集合到 effectFn.deps 中,
//在 get 拦截函数内调用 track 函数追踪变化
function track(target, key) {
//若没有 activeEffect,直接 return
if(!activeEffect) return
let depsMap = bucket.get(target)
if(!depsMap) {
bucket.set(target, (depsMap = new Map()))
}
let deps = depsMap.get(key)
if(!deps) {
depsMap.set(key, (deps = new Set()))
}
// 把当前激活的副作用函数添加到依赖集合 deps 中
deps.add(activeEffect)
// deps 就是一个与当前副作用函数存在联系的依赖集合
// 将其添加到 activeEffect.deps 数组中
activeEffect.deps.push(deps) // ---新增---
}
3. 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
}
4. 修改 trigger 函数,防止无限循环
// ---在调用 forEach 遍历 Set 集合 时,如果一个值已经被访问过了,但该值被删除并重新添加到集合,如果此时 forEach 遍历没有结束,那么该值会重新被访问。
// 在 set 拦截函数内调用 trigger 函数触发变化
function trigger(target, key) {
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
//effects && effects.forEach(fn => fn())
const effectsToRun = new Set(effects) // 新增
effectsToRun.forEach(effectFn => effectFn()) // 新增
}
副作用函数依赖集合关系
4. 可嵌套的 effect 与 effect 栈
若组件发生嵌套,例如 Foo 组件渲染了 Bar组件:
// Bar 组件
const Bar = {
render() { /* ... */ }
}
// Foo 组件
const Foo = {
render() {
return <Bar />
}
}
// 此时 effect 就发生了嵌套:
effect(()=>{
Foo.render()
effect(()=>{
Bar.render()
})
})
使用全局变量 activeEffect 存储 effect 函数注册的副作用函数,会导致嵌套副作用函数时,内层副作用函数会覆盖 外层副作用函数
增加栈解决 activeEffect 覆盖
// ---原先的 effect 函数---
// 用一个全局变量存储被注册的副作用函数
let activeEffect
function effect(fn) {
const effectFn = () => {
// 调用 cleanup 函数完成清除
cleanup(effectFn)
//当effectFn 执行时,将其设置为当前激活的副作用函数
activeEffect = effecctFn
fn()
}
// activeEffect.deps 用来存储所有与该副作用函数相关联的依赖集合
effectFn.deps = []
//执行副作用函数
effectFn()
}
//测试嵌套存在的问题
const data = { foo:true, bar: true }
const obj = new Proxy(data, { /* ... */ })
let temp1, temp2
//effectFn1 嵌套了 effectFn2
effect(function effectFn1() {
console.log('effectFn1 执行')
effect(function effectFn2() {
console.log('effectFn2 执行')
temp2 = obj.bar
})
temp1 = obj.foo
})
obj.foo='xxx'
//'effectFn1 执行'
//'effectFn2 执行'
//'effectFn2 执行'
// 我们修改了字段obj.foo 的值,但是effectFn1 并没有重新执行但是effectFn1,反而执行了但是effectFn2
//原先使用activeEffect 来存储通过 effect 函数注册的副作用函数,意味着同一时刻 activeEffect 只能存一个副作用函数,当发生嵌套时,内层会覆盖外层
//---建立一个 effectStack 副作用函数栈来解决,当副作用函数执行时压入栈,执行后弹出栈,并且始终让 activeEffect 指向栈顶
// 用一个全局变量存储被注册的副作用函数
let activeEffect
//effect栈
const effectStack = [] //---新增---
function effect(fn) {
const effectFn = () => {
// 调用 cleanup 函数完成清除
cleanup(effectFn)
//当effectFn 执行时,将其设置为当前激活的副作用函数
activeEffect = effecctFn
//在调用副作用函数前,将副作用函数压入栈
effectStack.push(effectFn) //---新增---
fn()
//在副作用函数执行完毕之后,将当钱副作用函数弹出栈,并把activeEffect 还原为之前的值
effectStack.pop() //---新增---
activeEffect = effectStack[effectStack.kength - 1] //---新增---
}
// activeEffect.deps 用来存储所有与该副作用函数相关联的依赖集合
effectFn.deps = []
//执行副作用函数
effectFn()
}
示例图
5. 避免无限递归循环
递归示例
const data = { foo: 1 }
const obj = new Proxy(data, { /* ... */ })
effect(()=>{ obj.foo++ })
//在执行上述代码时会引起栈溢出。
//首先读取 obj.foo 的值,这会触发 track 操作,将当前副 作用函数收集到“桶”中,接着将其加 1 后再赋值给 obj.foo,此时会 触发 trigger 操作,
//即把“桶”中的副作用函数取出并执行。但问题是 该副作用函数正在执行中,还没有执行完毕,就要开始下一次的执行
//问题:track 收集和 trigger 触发执行的副作用函数都是 activeEffect ,故只需要增加一个 tragger 守卫条件:trigger 触发执行的副作用函数与当前正在执行的副作用函数是否相同
// 在 set 拦截函数内调用 trigger 函数触发变化
function trigger(target, key) {
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
// const effectsToRun = new Set(effects) //***删除***
const effectsToRun = new Set() //---新增---
effects && effects.forEach(effectFn => {
//若 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发
if (effectFn !== activeEffect) { //---新增---
effectsToRun.add(effectFn)
}
})
effectsToRun.forEach(effectFn => effectFn())
//effects && effects.forEach(fn => fn())
}
6. 调度执行
可调度性:当 tragger 动作触发副作用函数重新执行时,有能力决定副作用函数执行的时机、次数以及方式
调度器控制副作用函数的执行顺序
const data = { foo: 1 }
const obj = new Proxy(data, { /***/ })
effect(()=>{
console.log(obj.foo)
})
obj.foo++
console.log('gg')
// 在副作用函数中,我们先打印了obj.foo ,接着 obj.foo 执行自增操作,最后打印 gg
// 1
// 2
// gg
//现在要调整输出顺序为:
// 1
// gg
// 2
// 除了调整代码位置外,可以使响应系统支持调度。 ---可以为effect 函数设计一个选项参数 options,允许用户指定调度器
effect(
()=>{
console.log(obj.foo)
},
//options
{
// 调度器 scheduler 是一个函数
scheduler(fn) {
// ...
}
}
)
// 用户在调用 effect 函数注册副作用函数时,可以传递第二个参数 options 。它是一个对象,允许制定 scheduler 调度函数,
// 同时在effect 函数内部我们需要把 options 选项挂载到对应的副作用函数上:
function effect(fn, options = {}) {
const effectFn = () => {
// 调用 cleanup 函数完成清除
cleanup(effectFn)
//当effectFn 执行时,将其设置为当前激活的副作用函数
activeEffect = effecctFn
//在调用副作用函数前,将副作用函数压入栈
effectStack.push(effectFn)
fn()
//在副作用函数执行完毕之后,将当钱副作用函数弹出栈,并把activeEffect 还原为之前的值
effectStack.pop()
activeEffect = effectStack[effectStack.kength - 1]
}
// 将 options 挂载到effectFn 上
effectFn.options = options //---新增---
// activeEffect.deps 用来存储所有与该副作用函数相关联的依赖集合
effectFn.deps = []
//执行副作用函数
effectFn()
}
// 有了调度函数,我们在 trigger 函数中触发副作用函数重新执行时,就可以直接调用用户传递到调度器函数,从而把控制权交给用户:
function trigger(target, key) {
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
const effectsToRun = new Set()
effects && effects.forEach(effectFn => {
//若 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})
effectsToRun.forEach(effectFn => effectFn{
// 如果一个副作用函数存在调度器, 则调用该调度器, 并将副作用函数作为参数传递
if (effectFn.options.scheduler) { //---新增---
effectFn.options.scheduler(effectFn) //---新增---
}else {
// 否则直接执行副作用函数(原先的默认行为)
effectFn() //---新增---
}
})
}
// 上述代码中。若副作用函数存在调度器,则将副作用函数当做参数传递给用户
// 有了上述基础代码设施,就可以实现修改输出位置了:
const data = { foo: 1 }
const obj = new Proxy(data, { /***/ })
effect(
()=>{
console.log(obj.foo)
},
//options
{
// 调度器 scheduler 是一个函数
scheduler(fn) {
// 将副作用函数放到宏任务队列中执行
setTimeout(fn)
}
}
)
obj.foo++
console.log('gg')
调度器控制副作用函数执行次数
const data = { foo: 1 }
const obj = new Proxy(data, { /***/ })
effect(()=>{
console.log(obj.foo)
})
obj.foo++
obj.foo++
// 在没有指定调度器时,输出为:1、2、3
//如果我们只期望结果:打印为:1、3,不包含过渡状态,可以基于调度器很容易地实现:
// 定义一个任务队列
const jobQueue = new Set()
// 使用 Promise.resolve() 创建一个 promise 实例,我们用它将一个任务添加到微任务队列
const p = Promise.resolve()
// 一个标志代表是否正在刷新队列
let isFlushing = false
function flushJob() {
// 如果队列正在刷新,则什么都不做
if (isFlushing) return
// 设置为 true ,代表正在刷新
isFlushing = true
//在微任务队列中刷新 jobQueue 队列
p.then(()=>{
jobQueue.forEach(job => job())
}).finally(()=>{
//结束后重置 isFlushing
isFlushing = false
})
}
effect(()=>{
console.log(obj.foo)
}, {
scheduler(fn) {
// 每次调度时,将副作用函数添加到 jobQueue 队列中
jobQueue.add(fn)
// 调用 flushJob 刷新队列
flushJob()
}
})
obj.foo++
obj.foo++
// 1. set 一个任务队列 jobQueue 去重
// 2. 每次调度之前将副作用函数添加到 jobQueue 中,再调用 flushJob 刷新队列
// 3. 定义 isFlushing 标志判断函数是否需要执行,一个周期内只会执行一次
// 4. flushJob 将函数通过 p.then 添加到微任务队列
// 5. 输入:1、3
7. 计算属性 computed 与 lazy
懒执行的 effect实现 computed
// 1. 实现 effect 函数的非立即执行,通过在 options 中添加 lazy 属性来达到目的:
effect(
//制定了 lazy 选项,这个函数不会立即执行
() => {
console.log(obj.foo)
},
//options
{
lazy: true
}
)
function effect(fn, options = {}) {
const effectFn = () => {
cleanup(effectFn)
activeEffect = effecctFn
effectStack.push(effectFn)
fn()
effectStack.pop()
activeEffect = effectStack[effectStack.kength - 1]
}
effectFn.options = options
effectFn.deps = []
//执行副作用函数
if( !options.lazy) { //---新增---
effectFn()
}
return effectFn() //---新增---
}
// 我们将副作用函数 effectFn 作为 effect 函数的返回值,当调用 effect 函数时,通过其返回值就可以拿到副作用函数:
const effectFn = effect(()=>{
console.log(obj.foo)
}, { lazy: true })
effectFn()
// 如果我们把传递给 effect 的函数看作一个 getter,那么这个 getter 函数可以返回任何值:
const effectFn = effect(
() => obj.foo + obj.bar,
{ lazy: true }
)
const value = effectFn(). //---value 是 getter 的返回值
//其中还需要对 effect 函数做一些修改:
function effect(fn, options = {}) {
const effectFn = () => {
cleanup(effectFn)
activeEffect = effecctFn
effectStack.push(effectFn)
// 将 fn 的执行结果存储到 res 中
const res = fn() //---新增
//fn()
effectStack.pop()
activeEffect = effectStack[effectStack.kength - 1]
return res //---新增
}
effectFn.options = options
effectFn.deps = []
//执行副作用函数
if( !options.lazy) {
effectFn()
}
return effectFn()
}
// 传递给 effect 函数的参数 fn 才是真 正的副作用函数,而 effectFn 是我们包装后的副作用函数。
//为了通 过 effectFn 得到真正的副作用函数 fn 的执行结果,我们需要将其 保存到 res 变量中,然后将其作为 effectFn 函数的返回值。
// 现在我们已经能够实现懒执行的副作用函数,并且能够拿到副作用函数的执行结果了,接下来就可以实现计算属性:
function computed(getter) {
// value 用来缓存上一次计算的值 ---避免每次调用时都重新计算
let value
// dirty 标志是否需要重新计算值,true则需要
dirty = true
// 把 getter 作为副作用函数,创建一个 lazy 的 effect
const effectFn = effect(getter, {
lazy: true,
// 添加调度器,在调度器中将 dirty 重置为 true
scheduler() {
if(!dirty){
dirty = true
}
}
})
const obj = {
//当读取 value 时才执行 effectFn
get value() {
// 只有“脏”时才计算值,并将得到的值缓存到 value 中
if (dirty) {
value = effectFn()
dirty = false
}
return value
// return effectFn()
}
}
return obj
}
// 1. 定义一个 computed 函数,它接收一个 getter 函数作 为参数,我们把 getter 函数作为副作用函数,用它创建一个 lazy 的 effect。
// 2. 使用dirty、value 判断是否需要更新与存储计算值
// 3. 添加调度器函数,在 getter函数中所依赖的响应式数据变化时执行,重置 dirty,当下一次访问 sumRes.value 就能重新调用 effectFn 计算值
// 4. 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(sumRes.value) //3
//嵌套计算属性还存在缺陷:
const sumRes = computed(() => obj.foo + obj.bar)
effect(() => {
console.log(sumRes.value)
})
obj.foo++
// 计算属性内部的 effect 是懒执行的,对于计算属性的 getter 函数来说,它里面访问的响应式数据只会把 computed 内部的 effect
// 收集为依赖。而当把计算属性用于另一个 effect 时,就会发生 effect 嵌套,外层的 effect 不会被内层 effect 中的响应式数据收集
function computed(getter) {
let value
dirty = true
const effectFn = effect(getter, {
lazy: true,
scheduler() {
if(!dirty){
dirty = true
// 当计算属性依赖的响应式数据变化时,手动调用 trigger 函数触发响应
trriger(obj, 'value') //---新增---
}
}
})
const obj = {
get value() {
if (dirty) {
value = effectFn()
dirty = false
}
// 当读取 value 时,手动调用 track 函数进行追踪
track(obj, 'value') //---新增---
return value
}
}
return obj
}
// 当读取一个计算属性的 value 值时,手动调用 track 函数,把计算属性返回的对象 obj 作为 target,同时作为第一个参数传递给 track 函数
// 当计算属性所依赖的响应式数据变化时会执行调度函数,在调度函数内手动调用 trigger 函数触发响应式即可
8. watch 的实现原理--watch的本质是观测一个响应式数据,并传递一个回调函数
利用 effect 以及 options.scheduler 选项实现
effect(()=>{
console.log(obj.foo)
}, {
scheduler() {
//当 obj.foo 的值变化时,会执行 scheduler 调度函数
}
})
// 简单 watch 函数实现
// watch 函数接收两个参数,source 是响应式数据,cb 是回调函数
function watch(source, cb) {
effect(
// 调用 traverse 递归地读取. 触发读取操作,从而建立联系
() => traverse(source),
{
scheduler() {
//当数据变化时,调用cb
cb()
}
}
)
}
function traverse(value, seen = new Set()){
//如果要读取的数据是原始值,或者已经被读取过了,那么什么都不必做
if(typeof value !== 'object' || value === null || seen.has(value) ) return
// 将数据添加到 seen 中,代表遍历地读取过了,避免循环引用引起的死循环
seen.add(value)
// 暂时不考虑数组等其他结构
// 假设 value 就是一个对象,使用 for...in 读取对象的每一个值,并递归地调用 traverse 进行处理
for (const k in value) {
traverse(value[k], seen)
}
return value
}
// watch 使用
const data = { foo : 1}
const obj = new Proxy(data, { /* ... */})
watch(obj, () => {
console.log('数据变化')
})
obj.foo++
// watch 函数的第一个参数可以不是一个响应式数据,而是一个 getter 函数,在 getter 函数内部,用户可以制定该 watch 依赖哪些响应式数据
function watch(source, cb) {
let getter
// 若用户传递的是函数,直接把 source 赋值给 getter
if (typeof source === 'function') {
getter = source
} else {
getter = () => traverse(source)
}
// 定义旧值与新值
let oldValue, newValue
// 使用 effect 注册副作用函数时,开启 lazy 选项,把返回值存储到 effectFn 中
const effectFn = effect(
// 调用 traverse 递归地读取. 触发读取操作,从而建立联系
() => getter(),
{
lazy: true,
scheduler() {
// 在 schedule 中重新执行副作用函数,得到的是新值
newValue = effectFn()
cb(newValue, oldValue)
// 更新旧值,不然下一次会得到错误的旧值
oldValue = newValue
}
}
)
// 手动调用副作用函数,拿到的值就是旧值
oldValue = effectFn()
}
// 实现立即执行 watch
function watch(source, cb) {
let getter
// 若用户传递的是函数,直接把 source 赋值给 getter
if (typeof source === 'function') {
getter = source
} else {
getter = () => traverse(source)
}
// 定义旧值与新值
let oldValue, newValue
// 提取 scheduler 调度函数为一个独立的 job 函数
const job = ()=> {
newValue = effectFn()
cb(newValue, oldValue)
oldValue = newValue
}
// 使用 effect 注册副作用函数时,开启 lazy 选项,把返回值存储到 effectFn 中
const effectFn = effect(
// 调用 traverse 递归地读取. 触发读取操作,从而建立联系
() => getter(),
{
lazy: true,
scheduler: job
}
)
if(options.immediate) {
job()
} else {
oldValue = effectFn()
}
}
//除了立即执行,还可以通过其他选项参数来指定回调函数的执行时机。如在 Vue.js 3 中使用 flush 选项来指定:
watch(obj, () => {
console.log('change')
}, {
flush : 'pre' // 还可以制定为 ‘post’ | ‘sync’
})
function watch(source, cb) {
let getter
if (typeof source === 'function') {
getter = source
} else {
getter = () => traverse(source)
}
let oldValue, newValue
const job = ()=> {
newValue = effectFn()
cb(newValue, oldValue)
oldValue = newValue
}
const effectFn = effect(
() => getter(),
{
lazy: true,
scheduler: () => {
if (options.flush === 'post') { //---新增----
const p = Promise.resolve()
p.then(job)
} else {
job()
}
}
}
)
if(options.immediate) {
job()
} else {
oldValue = effectFn()
}
}
// 若options.flush 是post,则将 job 函数放入微任务中实现一步延迟执行,故会在 DOM 节点更新结束后再执行
// 否则直接执行 job 函数,本质上相当于 ‘sync’ 的实现机制,即同步执行
// pre 指的是在组件更新前执行,涉及组件更新机制,暂时还没办法模拟
9. 过期的副作用函数--竞态问题
竞态例子:
let finalData
watch(obj,async () => {
const res = await fetch('/path/to/request')
finalData = res
})
// 若在第一次修改 obj 时发送了请求A,在A执行完之前再次修改,发送请求 B,且 B 比 A 先执行完,则此时finalData是A而不是B
function watch(source, cb) {
let getter
if (typeof source === 'function') {
getter = source
} else {
getter = () => traverse(source)
}
let oldValue, newValue
// cleanup 用来存储用户注册过的过期回调
let cleanup
// 定义 onInvalidate 函数
function onInvalidate(fn)
// 将过期回调存储到 cleanup 中
cleanup = fn
}
const job = ()=> {
newValue = effectFn()
// 在调用回调函数 cb 之前,先调用过期回调
if(cleanup){
cleanup()
}
// 将 onInvalidate 作为回调函数的第三个参数,以便用户使用
cb(newValue, oldValue, onInvalidate)
oldValue = newValue
}
const effectFn = effect(
() => getter(),
{
lazy: true,
scheduler: () => {
if (options.flush === 'post') {
const p = Promise.resolve()
p.then(job)
} else {
job()
}
}
}
)
if(options.immediate) {
job()
} else {
oldValue = effectFn()
}
}
//我们首先定义了 cleanup 变量,这个变量用来存 储用户通过 onInvalidate 函数注册的过期回调。可以看到 onInvalidate 函数的实现非常简单,只是把过期回调赋值给了 cleanup 变量。
//这里的关键点在 job 函数内,每次执行回调函数 cb 之前,先检查是否存在过期回调,如果存在,则执行过期回调函数 cleanup。
//最后我们把 onInvalidate 函数作为回调函数的第三个 参数传递给 cb,以便用户使用。
watch(obj, async (newValue, oldValue, onInbalidate) => {
// 定义一个标志,代表当前副作用函数是否过期,默认为 false,代表没有过期
let expired = false
// 调用 onInvalidate() 函数注册一个过期回调
onInvalidate(() => {
// 当过期时,将 expired 设置为 true
expired = true
})
// 发送网络请求
const res = await fetch('/path/to/request')
// 只有当该副作用函数的执行没有过期时,才会执行后续操作
if (!expired) {
finalData = res
}
})
// 第一次修改
obj.foo++
setTimeout(() => {
// 200ms 后做第二次修改
obj.foo++
},200)
文中使用到了Map、WeakMap,这里简单介绍一下Map和WeakMap ---数据来源:JavaScript高级程序设计
Map--基本API
Map 是一种新的集合类型,为这门语言带来了真正的键/值存储机制。
//创建空映射
const m = new Map();
//在创建时初始化实例,可以给 Map 构造函数传入一个可迭代对象,需要包含键/值对数组
//嵌套数组初始化映射
const m1 = new Map([
["key1", "val1"],
["key2", "val2"],
["key3", "val3"],
]);
alert(m1.size); // 3
// 使用自定义迭代器初始化映射
const m2 = new Map({
[Symbol.iterator]: function*() {
yield ["key1", "val1"];
yield ["key2", "val2"];
yield ["key3", "val3"];
}
});
alert(m1.size); // 3
// 初始化之后,可以使用 set() 方法再添加键/值对。使用get()、has()进行查询,delete()、clear()删除值。
// Map 可以使用任何JavaScript数据类型作为键
const functionKey = function() {};
const symbolKey = Symbol();
const objectKey = new Object();
m.set(functionKey, "functionValue")
.set(symbolKey, "symbolValue")
.set(objectKey, "objectValue");
// 在映射中用作键和值的对象及其他“集合”类型,在自己的内容或属性被修改时仍保持不变
const objKey = {},
objVal = {},
arrKey = [],
arrVal = [];
m.set(objKey, objVal);
m.set(arrKey, arrVal);
objKey.foo = "foo";
objVal.bar = "bar";
arrKey.push("foo");
arrVal.push("bar");
console.log(m.get(objKey)); // {bar: "bar"}
console.log(m.get(arrKey)); // ["bar"]
//SameValueZero 比较也可能导致意想不到的冲突
const a = 0/"", // NaN
b = 0/"", // NaN
pz = +0,
nz = -0;
alert(a === b); // false
alert(pz === nz); // true
m.set(a, "foo");
m.set(pz, "bar");
alert(m.get(b)); // foo
alert(m.get(nz)); // bar
Map--顺序与迭代
// 1. 通过 entries() [entries()是默认迭代器] 或 (Symbol.iterator ,它引用 entries()) 取得这个迭代器
const m = new Map([
["key1", "val1"],
["key2", "val2"],
["key3", "val3"]
]);
alert(m.entries === m[Symbol.iterator]); // true
for (let pair of m.entries()) {
alert(pair);
}
for (let pair of m[Symbol.iterator]()) {
alert(pair);
}
// [key1,val1]
// [key2,val2]
// [key3,val3]
// 2. 扩展运算符
console.log([...m]); // [[key1,val1],[key2,val2],[key3,val3]]
// 3. forEach(callback,opt_thisArg)
m.forEach((val, key) => alert(`${key} -> ${val}`));
// key1 -> val1
// key2 -> val2
// key3 -> val3
//键和值在迭代器遍历时是可以修改的,但映射内部的引用则无法修改。
//当然,这并不妨碍修改作为 键或值的对象内部的属性,因为这样并不影响它们在映射实例中的身份
4. m.keys()、m.values()
3. WeakMap--使用弱映射
// WeakMap 是 Map 的“兄弟”类型,其 API 也是 Map 的子集。
// WeakMap 中的“weak”(弱), 表示不会阻止垃圾回收。
const wm = new WeakMap();
wm.set({}, "val");
//set()方法初始化了一个新对象并将它用作一个字符串的键。因为没有指向这个对象的其他引用, 所以当这行代码执行完成后,这个对象键就会被当作垃圾回收。
//然后,这个键/值对就从弱映射中消失 了,使其成为一个空映射
// 为了保证只有通过键对象的引用才能取得值,弱映射中的键只能是 Object 或者继承自 Object 的类型,尝试使用非对象设置键会抛出 TypeError。值的类型没有限制
// 1. 私有属性
const User = (()=>{
const wm = new WeakMap();
class User {
constructor(id) {
this.idProperty = Symbol('id');
this.setId(id);
}
setPrivate(property, value) {
const privateMembers = wm.get(this) || {};
privateMembers[property] = value;
wm.set(this, privateMembers);
}
getPrivate(property) {
return wm.get(this)[property];
}
setId(id) {
this.setPrivate(this.idProperty, id);
}
getId(id) {
return this.getPrivate(this.idProperty);
}
}
return User;
})();
const user = new User(123);
alert(user.getId()); // 123
user.setId(456);
alert(user.getId()); // 456
// 2. DOM节点元数据
因为 WeakMap 实例不会妨碍垃圾回收,所以非常适合保存关联元数据
// 当节点从 DOM 树中被删除后,垃圾回收程序就 可以立即释放其内存(假设没有其他地方引用这个对象):
const wm = new WeakMap();
const loginButton = document.querySelector('#login');
// 给这个节点关联一些元数据
wm.set(loginButton, {disabled: true});
3. 使用 Proxy 构建代理的目的
定义 捕获器(trap)捕获器就是在处理程序对象中定义的“基本操作的 拦截器”。
捕获器不变式
使用捕获器几乎可以改变所有基本方法的行为,但也不是没有限制。根据 ECMAScript 规范,每个捕获的方法都知道目标对象上下文、捕获函数签名,而捕获处理程序的行为必须遵循“捕获器不变式”(trap invariant)。
比如,如果目标对象有一个不可配置且不可写的数据属性,那么在捕获器返回一个与该属性不同的值时,会抛出 TypeError:
示例:
const target = {};
Object.defineProperty(target, 'foo', {
configurable: false,
writable: false,
value: 'bar'
});
const handler = {
get() {
return 'qux';
}
};
const proxy = new Proxy(target, handler);
console.log(proxy.foo);
// TypeErro
代理可以捕获 13 种不同的基本操作。这些操作有各自不同的反射 API 方法、参数、关联 ECMAScript 操作和不变式。---数据来源:JavaScript高级程序设计
const myTarget = {};
get(): 返回值无限制。
const proxy = new Proxy(myTarget, {
get(target, property, receiver) {
console.log('get()');
return Reflect.get(...arguments)
}
});
proxy.foo;
// get()
捕获器处理程序参数
target:目标对象。
property:引用的目标对象上的字符串键属性。1 receiver:代理对象或继承代理对象的对象。
set(): 返回 true 表示成功;返回 false 表示失败,严格模式下会抛出 TypeError。
const proxy = new Proxy(myTarget, {
set(target, property, value, receiver) {
console.log('set()');
return Reflect.set(...arguments)
}
});
proxy.foo = 'bar';
// set()
捕获器处理程序参数
target:目标对象。
property:引用的目标对象上的字符串键属性。 value:要赋给属性的值。
receiver:接收最初赋值的对象。
has(): has()必须返回布尔值,表示属性是否存在。返回非布尔值会被转型为布尔值。
const proxy = new Proxy(myTarget, {
has(target, property) {
console.log('has()');
return Reflect.has(...arguments)
}
});
'foo' in proxy;
// has()
捕获器处理程序参数
target:目标对象。
property:引用的目标对象上的字符串键属性。
defineProperty(): defineProperty()必须返回布尔值,表示属性是否成功定义。返回非布尔值会被转型为布尔值。
const proxy = new Proxy(myTarget, {
defineProperty(target, property, descriptor) {
console.log('defineProperty()');
return Reflect.defineProperty(...arguments)
}
});
Object.defineProperty(proxy, 'foo', { value: 'bar' });
// defineProperty()
捕获器处理程序参数
target:目标对象。
property:引用的目标对象上的字符串键属性。
descriptor:包含可选的 enumerable、configurable、writable、value、get 和 set定义的对象。
getOwnPropertyDescriptor(): getOwnPropertyDescriptor()必须返回对象,或者在属性不存在时返回 undefined。
const proxy = new Proxy(myTarget, {
getOwnPropertyDescriptor(target, property) {
console.log('getOwnPropertyDescriptor()');
return Reflect.getOwnPropertyDescriptor(...arguments)
}
});
Object.getOwnPropertyDescriptor(proxy, 'foo');
// getOwnPropertyDescriptor()
捕获器处理程序参数
target:目标对象。
property:引用的目标对象上的字符串键属性。
deleteProperty(): deleteProperty()必须返回布尔值,表示删除属性是否成功。返回非布尔值会被转型为布尔值。
const proxy = new Proxy(myTarget, {
deleteProperty(target, property) {
console.log('deleteProperty()');
return Reflect.deleteProperty(...arguments)
}
});
delete proxy.foo
// deleteProperty()
捕获器处理程序参数
target:目标对象。
property:引用的目标对象上的字符串键属性。
ownKeys(): ownKeys()必须返回包含字符串或符号的可枚举对象。
const proxy = new Proxy(myTarget, {
ownKeys(target) {
console.log('ownKeys()'); 3
return Reflect.ownKeys(...arguments)
}
});
Object.keys(proxy);
// ownKeys()
捕获器处理程序参数
target:目标对象。
getPrototypeOf(): getPrototypeOf()必须返回对象或 null。
const proxy = new Proxy(myTarget, {
getPrototypeOf(target) {
console.log('getPrototypeOf()');
return Reflect.getPrototypeOf(...arguments)
}
});
Object.getPrototypeOf(proxy);
// getPrototypeOf()
捕获器处理程序参数
target:目标对象。
setPrototypeOf(): setPrototypeOf()必须返回布尔值,表示原型赋值是否成功。返回非布尔值会被转型为布尔值。
const proxy = new Proxy(myTarget, {
setPrototypeOf(target, prototype) {
console.log('setPrototypeOf()');
return Reflect.setPrototypeOf(...arguments)
}
});
Object.setPrototypeOf(proxy, Object);
// setPrototypeOf()
捕获器处理程序参数
target:目标对象。
prototype:target 的替代原型,如果是顶级原型则为 null。
isExtensible(): isExtensible()必须返回布尔值,表示 target 是否可扩展。返回非布尔值会被转型为布尔值。
const proxy = new Proxy(myTarget, {
isExtensible(target) {
console.log('isExtensible()');
return Reflect.isExtensible(...arguments)
}
});
Object.isExtensible(proxy);
// isExtensible()
捕获器处理程序参数
target:目标对象。
preventExtensions():preventExtensions()必须返回布尔值,表示 target 是否已经不可扩展。返回非布尔值会被转 型为布尔值。
const proxy = new Proxy(myTarget, {
preventExtensions(target) {
console.log('preventExtensions()');
return Reflect.preventExtensions(...arguments)
}
});
Object.preventExtensions(proxy);
// preventExtensions()
捕获器处理程序参数
target:目标对象。
apply():返回值无限制。
const myTarget = () => {};
const proxy = new Proxy(myTarget, {
apply(target, thisArg, ...argumentsList) {
console.log('apply()');
return Reflect.apply(...arguments)
}
});
proxy();
// apply()
捕获器处理程序参数
target:目标对象。
thisArg:调用函数时的 this 参数。
argumentsList:调用函数时的参数列表
construct(): construct()必须返回一个对象。
const myTarget = function() {};
const proxy = new Proxy(myTarget, {
construct(target, argumentsList, newTarget) {
console.log('construct()');
return Reflect.construct(...arguments)
}
});
new proxy;
// construct()
捕获器处理程序参数:
target:目标构造函数。
argumentsList:传给目标构造函数的参数列表。
newTarget:最初被调用的构造函数。