副作用函数的执行会直接或间接影响其他函数的执行。一个副作用函数中读取了某个对象的属性,当该属性的值发生改变后,副作用函数自动重新执行,这个对象就是响应式数据。
1 响应式系统的实现
拦截对象的读取和设置操作。当读取某个属性值时,把副作用函数存储到一个“桶”里,而设置该属性值时,则将这个副作用函数从“桶”中取出病执行。
/**
* 响应式系统基本原理:Proxy 拦截设置及读取操作,读取属性时将副作用
* 函数存于桶,设置属性时将副作用函数从桶中取出并执行
*/
let obj = {name: '',tag: false,count: 0,num1: 0,num2: 0}
let bucket = new Set()
let proxyObj = new Proxy(obj,{
get(target, p, receiver) {
bucket.add(fun)
return target[p]
},
set(target, p, newValue, receiver) {
target[p] = newValue
bucket.forEach(fn => fn())
}
})
function fun() {
console.log(proxyObj.name)
}
fun() // 触发执行,空字符串
proxyObj.name = "hello" // hello
proxyObj.name = "js" // js
1.1 桶的结构
存储副作用函数的“桶”,应该为不同的对象、及其属性存储对应的副函数集。存储的容器为WeakMap。
/**
* 用WeakMap 作为副作用函数的容器,改进响应式系统,支持不同的
* 响应式对象及其属性都能响应式执行
*/
let obj = {name: '',tag: false,count: 0,num1: 0,num2: 0}
let bucketMap = new WeakMap()
let activeFun // 用于指示当前需要注册的副作用函数
let proxyObj = new Proxy(obj,{
get(target, p, receiver) {
track(target,p)
return target[p]
},
set(target, p, newValue, receiver) {
target[p] = newValue
trigger(target,p)
}
})
function track(target,p) { // 跟踪函数
if (activeFun) {
let map = bucketMap[target]
if (!map) map = bucketMap[target] = new Map()
let set = map[p]
if (!set) set = map[p] = new Set()
set.add(activeFun)
}
}
function trigger(target,p) { // 触发函数
let map = bucketMap[target]
if (map) {
let set = map[p]
set && set.forEach(fn => fn())
}
}
function effect(fn) { // 用于注册副作用函数
let tempFun = () => {
activeFun = fn
fn()
activeFun = null
}
tempFun()
}
effect(() => {
console.log(proxyObj.name,proxyObj.tag)
})
effect(() => {
console.log("name2",proxyObj.name)
})
console.log("------------------------------------")
proxyObj.name = "hello"
console.log("------------")
proxyObj.tag = false
console.log("------------")
proxyObj.name = "js";
console.log("------------")
1.2 分支切换
分支切换是指,函数内部存在一个三元表达式,根据某个字段的值会执行不同的代码分支。当该字段的值发生变化时,代码执行的分支会跟着变化。
例如:console.log(proxyObj.tag ? proxyObj.name : "false");
按照上面的代码,当name或tag的值被设置时,都会触发副作用函数。但是,在副作用函数中,当tag为false时,name的值是不会被显示的,这意味着,当tag为false时,无论name被设置多少次,都不希望执行这个副作用函数。
解决方案:当该副作用函数被触发时,删除属性与该函数的关系。在副作用函数执行时再重新创建关系。
function track(target,p) { // 跟踪函数
if (activeFun) {
let map = bucketMap[target]
if (!map) map = bucketMap[target] = new Map()
let set = map[p]
if (!set) set = map[p] = new Set()
set.add(activeFun)
activeFun.funSetList.push(set)
}
}
function effect(fn) { // 用于注册副作用函数
let tempFun = () => {
cleanup(tempFun)
activeFun = tempFun
fn()
activeFun = null
}
tempFun.funSetList = []
tempFun()
}
function cleanup(fn) {
fn.funSetList.forEach(set => {
set.delete(fn)
})
fn.funSetList = []
}
1.3 嵌套的effect
组件在渲染时,会执行effect函数来注册副作用函数,而父组件在渲染时,不仅会执行其本身的effect函数,还会自行其子组件的effect,这是就发生了嵌套的effect的调用。即如下:
effect(() => {
effect(() => {
console.log(proxyObj.count)
})
console.log(proxyObj.tag);
})
当修改tga 属性时,父组件的副作用函数并不会执行。
解决方案:创建一个注册的副作用函数指示栈。副作用函数执行前,将函数压入到栈中,执行完后则弹出该函数。
let activeFunStack = []
let registerFunSet = new Set() // 防止函数多次被注册
function effect(fn) { // 用于注册副作用函数
if (!registerFunSet.has(fn)) {
registerFunSet.add(fn)
let tempFun = () => {
cleanup(tempFun)
activeFun = tempFun
activeFunStack.push(activeFun)
fn()
activeFunStack.pop()
activeFun = activeFunStack.length < 1 ? null : activeFunStack[activeFunStack.length - 1]
}
tempFun.funSetList = []
tempFun()
}
}
effect(() => {
effect(sonFun)
console.log(proxyObj.tag);
})
function sonFun() {
console.log(proxyObj.count)
}
console.log("------------------------------------")
proxyObj.tag = false
proxyObj.count = 1
1.4 避免无限递归
在一个副作用函数设置及读取同一个属性时,上面代码中,会发生无限递归对情况。这是因为,当设置属性值时,会触发副作用函数执行,而副作用函数中又会设置该属性值…
解决方案:在触发时,不执行当前正在被注册的副作用函数。
function trigger(target,p) { // 触发函数
let map = bucketMap[target]
if (map) {
let set = map[p]
if (set) {
let tempSet = new Set(set)
tempSet.forEach(fn => {
if (activeFun !== fn) fn()
})
}
}
}
effect(() => {
console.log(proxyObj.count++);
})
console.log("------------------------------------")
proxyObj.count++;
1.5 调度执行
可调度,是指当动作触发副作用函数重复执行时,有能力决定副作用函数执行的时机、次数以及方式。
1.5.1 微任务
宏任务 | 通常是由宿主环境(浏览器)提供的。包括但不限于:script(整体代码)、setTimeout、setInterval、I/O、UI渲染。 |
微任务 | 由JS引擎(如V8)提供的。它们在当前宏任务之后,下一个宏任务之前执行。常见的微任务:Promose.then() 微任务通常用于执行需要尽快完成的异步操作。 过多使用微任务可能会导致主线程被阻塞,影响页面的响应。 |
表 宏任务和微任务两种类型的队列
执行顺序:
- 宏任务队列:从宏任务队列中取出一个任务执行。
- 执行宏任务:执行宏任务中的所有同步代码。
- 微任务队列:在执行完宏任务中的所有同步代码后,会查看并清空微任务队列中的所有任务。
- 渲染UI:微任务队列清空后,浏览器会进行UI渲染(如果需要)。
- 循环:重复步骤1~4,直到宏任务队列和微任务队列都为空。
function trigger(target,p) { // 触发函数
let map = bucketMap[target]
if (map) {
let set = map[p]
if (set) {
let tempSet = new Set(set)
tempSet.forEach(fn => {
if (activeFun !== fn) {
if (fn.options.scheduler) {
fn.options.scheduler(fn)
} else {
fn()
}
}
})
}
}
}
function effect(fn,options = {}) { // 用于注册副作用函数
if (!registerFunSet.has(fn)) {
registerFunSet.add(fn)
let tempFun = () => {
cleanup(tempFun)
activeFun = tempFun
activeFunStack.push(activeFun)
fn()
activeFunStack.pop()
activeFun = activeFunStack.length < 1 ? null : activeFunStack[activeFunStack.length - 1]
}
tempFun.options = options
tempFun.funSetList = []
tempFun()
}
}
const jobQueue = new Set()
const promise = Promise.resolve()
let isFlushing = false
function flushJob() {
if (!isFlushing) {
isFlushing = true
promise.then(() => {
jobQueue.forEach(fn => fn())
}).finally(() => {
isFlushing = false
})
}
}
effect(() => {
console.log(proxyObj.count);
},{
scheduler(fn) {
jobQueue.add(fn)
flushJob()
}
})
console.log("------------------------------------")
proxyObj.count++;
proxyObj.count++;
1.6 计算属性computed 与 lazy
计算属性,只有当相关依赖发生改变时,计算属性才会重新求值。否则,就是多次访问计算属性,也会立即返回之前的计算结果,不需要再次执行函数。
function effect(fn,options = {}) { // 用于注册副作用函数
if (!registerFunSet.has(fn)) {
registerFunSet.add(fn)
let tempFun = () => {
cleanup(tempFun)
activeFun = tempFun
activeFunStack.push(activeFun)
let res = fn()
activeFunStack.pop()
activeFun = activeFunStack.length < 1 ? null : activeFunStack[activeFunStack.length - 1]
return res
}
tempFun.options = options
tempFun.funSetList = []
if (!options.lazy) {
tempFun()
}
return tempFun
}
}
function computed(fn) {
let value
let dirty = true
let tempFun = () => { trigger(obj,"value") }
let effectFn = effect(fn,{
lazy: true,
scheduler() {
if (!dirty) {
dirty = true
jobQueue.add(tempFun)
flushJob()
}
}
})
let obj = {
get value() {
if (dirty) {
value = effectFn()
dirty = false
}
track(obj,"value")
return value
}
}
return obj
}
let computedRes = computed(() => proxyObj.num1 + proxyObj.num2)
effect(()=> {
console.log("computedRes1",computedRes.value)
})
proxyObj.num1 = 2
proxyObj.num2 = 3
1.7 watch 的实现原理
Vue 的 watch,可以监听对象、对象的某个属性。可以对对象进行深层次监听。当属性值改变时,会触发监听的回调函数。
function watch(source,callBack) {
let newValue,oldValue
let getter
if (typeof source === "function") {
getter = source
} else {
getter = () => traverse(source)
}
const job = () => {
newValue = effectFun()
callBack(newValue,oldValue)
if (typeof newValue === "object") {
oldValue = {...newValue}
} else {
oldValue = newValue
}
}
let effectFun = effect(getter,{
scheduler() {
job()
}
})
let tempRes = effectFun()
if (typeof tempRes === "object") {
oldValue = {...tempRes}
} else {
oldValue = tempRes
}
}
function traverse(value) {
if (typeof value != "object" || value === null) return
for (const k in value) traverse(value[k])
return value
}
watch(proxyObj,(newValue,oldValue) => {
console.log("proxyObj",newValue,oldValue)
})
watch(proxyObj.name,(newValue,oldValue) => {
console.log("name",newValue,oldValue)
})
proxyObj.count = 1
proxyObj.name = "hello"
1.8 过期的副作用
竞态问题指的是两个或多个操作几乎同时发生,并且结果依赖于它们发生的顺序,但顺序又是不确定的。 在单线程JS环境中(浏览器),我们通常不会遇到竞态问题,但是,随着Web API的引入(如异步操作,Promises,async/aswait,Web Workers等),导致JS代码中仍然可以出现竞态问题。
watch(() => proxyObj.count,(newValue,oldValue) => {
Promise.resolve().then(() => {
setTimeout(() => {
console.log(newValue)
},newValue * 1000)
})
})
proxyObj.count = 5
setTimeout(()=> {
proxyObj.count = 2
},500)
解决方案:在第二次触发时,将前一次的触发状态设置为过期,只有状态非过期,产生的结果才有效。
function watch(source,callBack) {
let newValue,oldValue
let getter
if (typeof source === "function") {
getter = source
} else {
getter = () => traverse(source)
}
let cleanup
let cleanFun = (fn) => {
cleanup = fn
}
const job = () => {
if (cleanup) cleanup()
newValue = effectFun()
callBack(newValue,oldValue,cleanFun)
if (typeof newValue === "object") {
oldValue = {...newValue}
} else {
oldValue = newValue
}
}
let effectFun = effect(getter,{
scheduler() {
job()
}
})
let tempRes = effectFun()
if (typeof tempRes === "object") {
oldValue = {...tempRes}
} else {
oldValue = tempRes
}
}
watch(() => proxyObj.count,(newValue,oldValue,cleanFun) => {
let expire = false
cleanFun(() => {
expire = true
})
Promise.resolve().then(() => {
setTimeout(() => {
if (!expire) console.log(newValue)
},newValue * 1000)
})
})