深入理解Vue(三)

183 阅读17分钟

四、响应系统的作用与实现

4.1响应式数据和副作用函数

在介绍响应式系统之前,我们要先明确一个概念,副作用函数,什么是副作用函数?,用以下代码为例:

function effect () {
  document.body.innerText = 'hello'
}

我们修改了body的值为hello,但是body我们不仅在effect函数中可以获取到,在任何地方都可以获取到,所以我们说effect函数是一个副作用函数,总结:副作用函数就是在函数内修改了非仅函数内部可访问的变量的值,对外部数据产生了影响

那这个和响应式有什么关系呢,我们改造一下上面的代码

const obj = {text: 'hello'}
function effect () {
  document.body.innerText = obj.text
}
obj.text = 'change'

代码中我们让body的值等于了obj对象的text属性,之后修改了obj的text属性的值,修改后body的值没有跟随着改变,我们期望的是obj的text的值发生了变化,effect函数要重新执行,body的值也跟随着变化,这就是响应式

4.2响应式数据的基本实现

我们期望数据变化,使用数据了的副作用函数就重新执行,这涉及到了两个问题,就是我们怎么知道哪些副作用绑定了该数据,以及我们如何通过副作用函数重新执行,es2015中的proxy可以帮我们解决对应的问题,proxy允许我们在数据读取和修改时创建钩子函数,这样我们就可以在数据读取也就是应用时,将副作用函数收集起来,等到数据修改时,再将副作用函数提取出来进行执行

按照该思路我们进行实现

// 储存副作用函数的桶
const bucket = new Set()
const obj = new Proxy({text: 'hello'},{
  get(target, key) {
    // 将副作用函数收集起来
    bucket.add(effect)
    return target[key]
  },
  set(target, key, newValue) {
    const setRes = Reflect.set(...arguments)
    // 将副作用函数从桶中拿出来执行
    bucket.forEach(fn=>fn())
    return setRes
  }
})
const effect = ()=>{
  document.body.innerText = obj.text
}
effect()
setTimeout(()=>obj.text = 'change',1000)

至此我们就实现了一个基础版的响应式,但是其中还有很多的问题,例如我们在收集副作用的时候明确了副作用函数是effect,我们在后面会解决这些问题

4.3设计一个完善的响应式系统

我们先解决副作用函数的命名问题,我们期望可以明确一个变量指向副作用函数,同时期望使用的人可以随意给自己的副作用函数命名,我们可以提供一个函数来进行对应的转换,代码如下

let activeEffect = undefined
function effect (fn) {
  activeEffect = fn
  fn()
}

至此我们已经解决了副作用函数的命名问题,但随之而来的问题是我们的副作用收集并不够明确,obj是一个对象,对象会有很多的属性,我们在收集副作用时,将所有的副作用都与对象绑定了,然而这个并不够准确,例如下面的例子

effect(()=>{
  console.log('执行了副作用函数')
  document.body.innerText = obj.text
})
obj.noProps = true

我们会发现,在修改obj的noProps属性时,副作用函数也执行了,然而我们期望的是仅修改副作用函数中用到的变量的对应的属性的时候才会执行对应的副作用函数,因此我们需要修改一下我们的绑定关系,从最开始的对象变为对象的对应属性,我们从新设计桶的数据结构

image.png

const bucket = new WeakMap()

调整我们的副作用收集和副作用执行的位置,也就是proxy监听的get和set函数,我们将收集和执行行为单独写成两个函数track和trigger

// 副作用收集函数
function track(target, key) {
  // 没有副作用函数返回
  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)
}
​
// 副作用执行函数
function trigger(target, key) {
  const depsMap = bucket.get(target)
  if(!depsMap) return
  const deps = depsMap.get(key)
  if(!deps) return
  deps.forEach(fn=>fn())
}

修改代理proxy的set和get函数

const obj = new Proxy({text: 'hello'},{
  get(target, key) {
    // 将副作用函数收集起来
    track(target, key)
    return target[key]
  },
  set(target, key, newValue) {
    const setRes = Reflect.set(...arguments)
    // 将副作用函数从桶中拿出来执行
    trigger(target, key)
    return setRes
  }
})

至此我们的整个数据结构如下

image.png

4.4分支切换与cleanup

我们已经实现了一个相对完善的响应式系统了,但是还有一些特殊的情况需要去处理,例如三元表达式的分支切换,以下面的代码为例

effect(()=>{
  document.body.innerText = obj.ok ? obj.text : 'none'
})

在这个副作用函数中,我们期待的是obj的ok为true时,obj的text属性改变副作用函数重新执行一遍,当obj.ok为false的时候,obj.text改变,副作用函数不重新执行

我们目前的响应式系统并不能实现这一需求,当obj.ok为true时,obj对象的ok和text属性都会绑定该副作用,obj.ok的值改为false后,重新执行副作用函数,obj的ok属性会绑定该副作用函数,问题出现了,这个时候并没有断开obj的text属性和副作用函数的绑定

解决这一问题的方法很简单,我们只需要在副作用函数执行前,先断开之前的所有响应绑定,然后副作用函数执行,会重新绑定最新准确的依赖关系,以上面的例子为例,就是obj.ok更改为false,之后副作用函数清空响应绑定,也就是断开和obj.ok和obj.text的依赖关系,这样不管两个值接下来怎么改变都不会触发副作用函数的执行了,之后副作用函数执行,因为本次obj.ok的值为false,所以只会触发obj.ok的get,重新建立依赖

好的解决逻辑已经有了,但是新的问题又出现了,就是我们怎么通过副作用函数知道它都和谁有依赖,我们之前的响应式系统都是单向的,也就是我们可以通过对象和对象的属性定位到这个属性的副作用函数,没有办法反向定位,所以我们要重新设计一些我们的副作用函数effect,处理方法如下

// 原
let activeEffect = undefined
function effect (fn) {
  activeEffect = fn
  fn()
}
// 新
let activeEffect = undefined
function effect (fn) {
  const effectFn = ()=> {
    activeEffect = effectFn
    fn()
  }
  effectFn.deps = []
  effectFn()
}

可以看到我们新写了一个effectFn函数,并将activeEffect指向了这个函数,可能有的人会疑惑,为什么要多次一举让activeEffect指向这个函数,直接等于fn不可以么,这是因为函数保存的也是引用地址,如果我们让activeEffect直接等于fn,后面挂载属性deps时会导致传入的函数fn上也同步修改了deps属性为空对象,破坏了传入的参数。

现在我们的effect函数有了反向收集的地方了,我们只需要改写一下track函数,在进行对象的属性和副作用函数绑定的阶段进行双向绑定

function track(target, key) {
  // 没有副作用函数返回
  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)
  // 将该属性的副作用桶绑定给该副作用
  activeEffect.deps.push(deps)
}

现在我们已经可以通过副作用函数去找到对应都哪些桶中有它了,只要在执行前将它从这些桶中删除,副作用的执行发生在trigger,可以发现最后执行的是activeEffect,目前我们的activeEffect是一个函数,我们新写一个用于清除掉方法,cleanup,放在副作用函数执行前执行

let activeEffect = undefined
function effect (fn) {
  const effectFn = ()=> {
    cleanup(effectFn)
    activeEffect = effectFn
    fn()
  }
  effectFn.deps = []
  effectFn()
}
function cleanup (effectFn) {
  for (let i=0;i<effectFn.deps.length;i++) {
    const deps = effectFn.deps[i]
    deps.delete(effectFn)
  }
  effectFn.deps.length = 0
}

我们允许后发现页面卡死了,这是因为我们在执行阶段,也就是trigger使用的是deps.forEach的方法,在循环便利时,我们不断的对deps做delete和add,导致这个forEach一直运行,无限循环了,解决方法也很简单,我们在执行时将deps拷贝一份后再去执行就好了

function trigger(target, key) {
  const depsMap = bucket.get(target)
  if(!depsMap) return
  const deps = depsMap.get(key)
  // 拷贝一份deps
  const effectsToRun = new Set(deps)
  effectsToRun.forEach(fn=>fn())
}

4.5嵌套的effect与effect栈

现在我们需要处理的问题是effect的嵌套问题,也就是在effect函数中又调用了effect函数,这是很有可能的事情,我们目前的系统副作用收集流程是这样的

其中修改全局activeeffect是一个很重要的环节,这关系着我们在响应式get环节绑定的副作用函数是否准确,当我们的effect函数发生潜逃关系的时候会导致我们的activeEffect指向不符合预期,以下面的代码为例

let temp1 = undefined
let temp2 = undefined
effect(function effectFn1() {
  console.log('执行了effectFn1')
  effect(function effectFn2() {console.log('执行了effectFn2'); temp2 = obj.text})
  temp1 = obj.ok
})

我们修改obj.ok的值,控制台可以看到打印了执行了effectFn2

// effect运行的打印
执行了effectFn1
执行了effectFn2
// obj.ok = false
执行了effectFn2
// obj.text = 'hh'
执行了effectFn2

可以看到不论是obj.ok还是obj.text绑定的副作用函数都是effectFn2,然而我们期待的是obj.ok绑定的副作用函数是effectFn1,obj.text绑定的副作用函数是effectFn2,造成这个的原因是什么呢,我们分析一下上面的effect执行过程中activeEffect的指向

effect(function effectFn1() {
  // effectFn1开始执行了,这时候activeEffect指向effectFn1
  console.log('执行了effectFn1')
  effect(function effectFn2() {
    // effectFn2开始执行了,这时候activeEffect指向effectFn2
        console.log('执行了effectFn2')
    // 这个时候触发了obj.text属性的读取触发响应式的get,依赖绑定obj.text和activeEffect绑定即effectFn2绑定
        temp2 = obj.text
    }
  )
  // effectFn2函数执行完毕,触发obj.ok属性的读取,触发响应式的get,进行依赖绑定obj.ok和activeEffect绑定即effectFn2绑定
  temp1 = obj.ok
})

通过上面的分析我们发现了问题所在,就是effectFn2函数在执行完毕后,activeEffect函数并没有重新指向回effectFn1,还停留在effectFn2,下面我们进行解决,

js的函数任务执行是在一个栈结构中运行的,有着先入后出的特点,我们为effect也同样建立一个栈,并且让activeEffect始终指向这个effect栈的最上层,这样就可以保证我们的activeEffect是准确的了

let activeEffect = undefined
// effect栈
const effectStack = []
function effect (fn) {
  const effectFn = ()=> {
    cleanup(effectFn)
    activeEffect = effectFn
    // 将副作用推入到栈中
    effectStack.push(effectFn)
    fn()
    // 函数执行完毕将副作用函数推出栈
    effectStack.pop()
    // 让activeEffect执行effect栈的最上层
    activeEffect = effectStack[effectStack.length - 1]
  }
  effectFn.deps = []
  effectFn()
}

4.6避免无限递归循环

当副作用函数中即会触发响应式数据的读取和赋值操作的时候就会造成无限递归循环,在读取的时候进行了依赖绑定,将副作用和响应式数据进行绑定,之后进行赋值操作,会触发副作用函数的执行,副作用函数执行又触发了响应式数据的读取和赋值,形成循环,造成无限递归循环,以下面的代码为例

const data = {foo: 1}
const obj = new Proxy(data,options)
effect(()=>obj.foo++)

通过执行会发现我们的操作导致栈溢出了,造成这一问题的主要原因出现在数据的赋值操作上,因为赋值的时候才会又造成行为的循环,所以我们只需要判断一下,赋值时触发的副作用函数和当前正在执行的副作用函数是否是同一个,如果是同一个,那么就不用再执行了,我们修改一下trigger函数

function trigger(target, key) {
    const depsMap = bucket.get(target)
    if(!depsMap) return
    const deps = depsMap.get(key)
    const effectsToRun = new Set()
    deps && deps.forEach(effectFn=>{
        if(effectFn!==activeEffect) {
            effectsToRun.add(effectFn)
        }
    })
    effectsToRun.forEach(fn=>fn())
}

4.7调度执行

什么叫调度执行,即控制响应式触发副作用的时机,以下面的代码为例

const data = {foo: 1}
const obj = new Proxy(data, options)
effect(()=>console.log(obj.foo))
obj.foo++
console.log('结束了')

执行结果

1
2
'结束了'

如果我们的响应式系统可以进行调度执行,那么我们可以实现让执行结果变为1,'结束了',2,我们需要修改副作用函数和副作用函数的执行阶段trigger函数

function effect (fn, options = {}) {
  const effectFn = ()=> {
    cleanup(effectFn)
    activeEffect = effectFn
    // 将副作用推入到栈中
    effectStack.push(effectFn)
    fn()
    // 函数执行完毕将副作用函数推出栈
    effectStack.pop()
    // 让activeEffect执行effect栈的最上层
    activeEffect = effectStack[effectStack.length - 1]
  }
  effectFn.options = options
  effectFn.deps = []
  effectFn()
}
function trigger(target, key) {
    const depsMap = bucket.get(target)
    if(!depsMap) return
    const deps = depsMap.get(key)
    const effectsToRun = new Set()
    deps && deps.forEach(effectFn=>{
        if(effectFn!==activeEffect) {
            effectsToRun.add(effectFn)
        }
    })
    effectsToRun.forEach(fn=>{
        if(fn.options.scheduler) {
            fn.options.scheduler(fn)
        }else {
            fn()
        }
    })
}

我们修改了effect函数,允许effect函数传递options,options中的scheduler负责进行调度执行,允许用户自己操控副作用函数的执行时机

4.8计算属性computed和lazy

我们来实现vue的computed,在vue中我们使用的时候会使用computed传入一个函数,从而使得我们在使用computed函数获得的值被读取时永远是最新的值,这个函数就是我们前面实现的副作用函数,并且computed得到的对象是一个有value属性的只读对象

function computed(getter) {
    const effectFn = effect(getter)
    const obj = {
        get value() {
            return effectFn
        }
    }
    return obj
}

我们实现了一个初步的computed函数,获取到了一个带有value只读属性的对象,但是effect副作用函数并没有返回值,我们需要effect函数有返回值,并且副作用getter只有在读取的时候才执行,我们需要改写一下computed函数,和effect函数,并在options中新加一个属性lazy,来控制副作用是否要执行

function computed(getter) {
    const effectFn = effect(getter,{
        lazy: true
    })
    const obj = {
        get value() {
            return effectFn()
        }
    }
    return obj
}
function effect (fn, options) {
  const effectFn = ()=> {
    cleanup(effectFn)
    activeEffect = effectFn
    // 将副作用推入到栈中
    effectStack.push(effectFn)
    // 新增返回函数的执行结果
    const res = fn()
    // 函数执行完毕将副作用函数推出栈
    effectStack.pop()
    // 让activeEffect执行effect栈的最上层
    activeEffect = effectStack[effectStack.length - 1]
    return res
  }
  effectFn.options = options
  effectFn.deps = []
  // lazy为true则不执行
  if(!options.lazy) effectFn()
  return effectFn
}

computed还有缓存的属性在,也就是如果响应式依赖的数据没有变化,则computed不会去重复触发副作用函数,我们修改一下computed函数

function computed(getter) {
    let value
    let dirty = true
    const effectFn = effect(getter,{
        lazy: true,
        scheduler() {
            dirty = true
        }
    })
    const obj = {
        get value() {
            if (dirty) {
                value = effectFn()
                dirty = false
            }
            return value
        }
    }
    return obj
}

我们新增了调度器,当相适应依赖数据发生变化时会触发调度器,这样我们就可以通过调度器来控制dirty,用dirty做开关,控制是否要重新执行effectFn。现在的computed已经趋于完美了,但是还有一个缺陷,就是computed不能用于effect函数中,不会形成依赖绑定,以下面的代码为例

const com = computed(()=>{
    console.log('执行了computed')
    return obj.text + obj.ok
})
effect(()=>console.log(com.value))
setTimeout(()=>obj.text = 'change')

我们打开控制台可以发现执行结果如下

执行了computed
hellotrue

可以看到只打印了一次'执行了computed',这证明computed中的副作用函数只执行了一次,这和我们期待的是不相符的,我们期待的是在effect函数执行时会读取一次com.value的值,当我们修改obj.text时,com的value值也应该发生了改变,并且触发effect函数,之所以造成我们预料之外的情况是因为computed得到的并不是一个传统的响应式对象,他的value值没有在get的时候去收集依赖,value的依赖性改变的时候没有重新执行依赖,我们需要改造一下computed函数

function computed(getter) {
    let value
    let dirty = true
    const effectFn = effect(getter,{
        lazy: true,
        scheduler() {
            if (!dirty){
                dirty = true
                trigger(obj, 'value')
            }
        }
    })
    const obj = {
        get value() {
            if (dirty) {
                value = effectFn()
                dirty = false
                track(obj, 'value')
            }
            return value
        }
    }
    return obj
}

我们修改了computed函数,让obj的value值get的时候进行副作用绑定,在依赖数据变化触发的scheduler调度中进行trigger执行副作用函数,至此一个完成的computed函数就完成了

4.9watch的实现原理

先对vue中的watch功能进行拆解,首先是第一个参数可以是一个响应式的对象或者是一个伴有返回值的函数,第二个参数是回调函数,有两个参数,旧的值和新的值

通过拆分我们发现我们需要实现的事情在上面都有实现过了,首先是想执行回调函数,我们可以利用effect的调度器来进行,第二个就是拿到对应的值,我们可以使用lazy参数来进行实现,

已经明确了写法,我们来进行代码实现

function watch(source, cb) {
    let getter
    if (typeof source === 'function') getter = source
    else getter = () => traverse(source)
    
}
// 我们需要读取响应式对象的每个属性,这样才能将每个属性都绑定依赖关系
function traverse(value, seen = new Set()) {
    if(typeof value !== 'object' || value === null || seen.has(value)) {
        return
    }
    // 主要防止对象的循环引用
    seen.add(value)
    for (let k in value) {
        traverse(value[k], seen)
    }
    return value
}

我们实现了watch的第一个参数,既可以传递响应式数据,也可以传递一个函数,当传递响应式的数据的时候,我们需要遍历的读取响应式数据的所有值,这样才能对响应式数据的每个属性都形成绑定关系

我们来实现后续的内容

function watch(source, cb) {
    let getter
    if (typeof source === 'function') getter = source
    else getter = () => traverse(source)
    effect(getter,{
        scheduler() {
            cb()
        }
    })
}

我们通过effect将响应式对象进行了绑定,并通过scheduler调度器,在响应式对象变化的时候可以触发调度器执行回调

function watch(source, cb) {
    let getter
    let oldValue, newValue
    if (typeof source === 'function') getter = source
    else getter = () => traverse(source)
    const effectFn = effect(getter,{
        scheduler() {
            newValue = effectFn()
            if(oldValue === newValue) return
            cb(oldValue, newValue)
            oldValue = newValue
        },
        lazy: true
    })
    oldValue = effectFn()
}

4.10过期的副作用

在watch中使用数据监听去发送网络请求可能会出现竞态问题,以下面这段代码为例

let finalData
watch(obj, async () => {
// 发送并等待网络请求
const res = await fetch('/path/to/request')
// 将请求结果赋值给 data
finalData = res
})

我们用watch去监听了obj,希望obj的值发生改变的时候,去发送网络请求去把结果赋值给finalData,这里会出现finalData的值不是我们预期值的问题,我们短时间内连续修改了两次obj,会触发两次wathc的回调操作,但是因为回调中是有网络请求的,所以我们不能保证后一次的回调操作完成是在前一次之后的,也就是可能第二次回调先执行完毕,之后第一次回调才执行完毕,导致finalData的值其实是第一次回调后的结果,不是第二次的

image.png 我们需要增强我们的watch函数以实现可以控制这一行为,使用阶段我们为了避免这种行为的发生,我们会用一个开关控制,例如下面

watch(obj, async () => {
let expired = false
// 发送并等待网络请求
const res = await fetch('/path/to/request')
// 将请求结果赋值给 data
if (!expired) {
    finalData = res
}
})

我们定义了一个变量expired,只有expired为false的时候才会进行赋值操作,现在我们需要一个时机来修改expired的值,我们可以利用闭包,在watch中定义一个变量,让第一次回调的时候去修改这个变量的值,然后在触发第二次回调的时候前,先去判断这个变量有没有值,有的话去执行,这样我们就可以在第一次回调的时候给这个值一个方法,来修改回调中的expired值来控制是否使用该次回调的结果了

watch(obj, async (newValue, oldValue, onInvalidate) => {
let expired = false
// onInvalidate用于给watch中的变量赋值
onInvalidate(()=>expired=true)
// 发送并等待网络请求
const res = await fetch('/path/to/request')
// 将请求结果赋值给 data
if (!expired) {
    finalData = res
}
})
function watch(source, cb) {
    let getter
    let oldValue, newValue
    if (typeof source === 'function') getter = source
    else getter = () => traverse(source)
    
    let cleanup
    function onInvalidate (fn) {
        cleanup = fn
    }
    const effectFn = effect(getter,{
        scheduler() {
            newValue = effectFn()
            if (oldValue === newValue) return
            if (cleanup) cleanup()
            cb(oldValue, newValue, onInvalidate)
            oldValue = newValue
        },
        lazy: true
    })
    oldValue = effectFn()
}

这样我们就可以处理过期的副作用了,整个流程大致如下

image.png