Vue响应式系统实现基本思路

 

Vue响应式系统实现基本思路

在vue中,我们可以使用watch监听数据的变化,像下面代码一样。

const vm = new Vue({
  data: {
    a: 1
  }
})
vm.$watch('a', () => {
  console.log('a被修改了')
})
vm.a = 2

当执行vm.a = 2的时候,会输出 ‘a被修改了‘, 但这是怎么实现的呢,我将一步一步记录下来,但此篇文章只记录思路,具体vue怎么实现还需要看源码。

思考1. 为什么我们修改vm.a的时候,会触发watch函数, 进一步思考,什么时候我们可以监听对象属性的修改?

首先想到的肯定是对象的访问器属性吧,即getter/setter, 当我们设置对象的某个属性时, 会触发setter访问器,当然, es6的Proxy也是可以的,据说vue3是用Proxy重写的。回到正轨,既然我们能监听对象属性的修改,那我们完全可以在setter中执行watch的回调函数。即

const data = {
  a: 1
}
Object.defineProperty(data, 'a', {
  set() {
    // 执行watch函数的第二个参数, 即回调函数
  },
  get() {
    
  }
})
$watch('a', () => {
  console.log('修改了a')
})

思考2: 如何在setter中执行$watch函数的回调函数(后面称之为 依赖)?

首先,我们要思考如何才能收集到依赖,既然在setter中执行收集到的依赖,现在只剩getter函数了,那我们就在getter函数中收集依赖。

const dep = [] // 收集依赖的数组
const data = {
  a: 1
}
Object.defineProperty(data, 'a', {
  set() {
    // 执行收集到的依赖
    dep.forEach(fn =>  fn())
  },
  get() {
    dep.push(Target)
  }
})

以上代码有两个问题

  1. 当我们执行data.a的时候返回会返回undefined, 为什么呢, 因为我们在写getter函数的时候,没有写return

  2. getter函数中的Target是从哪来的?

根据以上两个问题, 我们完善以上代码

const dep = [] // 收集依赖的数组
const data = {
  a: 1
}
let val = data.a // 缓存字段原有的值
Object.defineProperty(data, 'a', {
  set(newVal) {
    if(newVal === val) return // 如果新设置的值和旧值是一样的,则什么都不做
    val = newVal // 新值覆盖旧值
    // 执行收集到的依赖
    dep.forEach(fn =>  fn())
  },
  get() {
    dep.push(Target)
    return val
  }
})

let Target = null
function $watch(exp, fn) {
  Target = fn // 将fn存储在变量Target中
  data[exp]   // 通过读取data[exp]的值,触发getter, 从而将fn(依赖)收集到dep中
}

对于第一个问题,我们首先缓存一下data.a的值, 然后再在getter函数中返回

对于第二个问题,我们只能对$watch方法下手了,我们首先将fn存储到Target中,然后通过data[exp]触发getter完成依赖收集。

$watch('a', () => {
  console.log('a被修改了')
})
data.a = 2

此时,执行以上代码,就会触发$watch函数, 输出 a被修改了

思考3:以上实现只能监听对象的一个属性,那么如何监听多个属性呢?

对于这个问题,我们完全可以使用for循环来遍历对象,重复执行上述代码

const data = {
  a: 1,
  b: 2
}
for(let key in data) {
  const dep = [] // 收集依赖的数组, 放在for循环里,这个每一个属性都会有自己的依赖收集器
  let val = data[key] // 缓存字段原有的值
  Object.defineProperty(data, key, {
    set(newVal) {
      if(newVal === val) return // 如果新设置的值和旧值是一样的,则什么都不做
      val = newVal // 新值覆盖旧值
      // 执行收集到的依赖
      dep.forEach(fn =>  fn())
    },
    get() {
      dep.push(Target)
      return val
    }
  })
}

let Target = null
function $watch(exp, fn) {
  Target = fn // 将fn存储在变量Target中
  data[exp]   // 通过读取data[exp]的值,触发getter, 从而将fn(依赖)收集到dep中
}

当我们执行以下代码时, 发现data对象的a,b属性都被监听到了。

$watch('a', () => {
  console.log('a被修改了')
})
$watch('b', () => {
  console.log('b被修改了')
})
data.a = 3
data.b = 4

思考4: 目前已经实现了对对象多个属性的监听,那要是遇到嵌套属性的该如何解决呢?

假设对象data是如下形式

const data = {
  a: {
    b: 1
  }
}

此时,我们依然执行上面的代码,发现只有a属性可以被监听到,而b属性没有被监听到,这不是我们想要的,所以继续修改上述代码。

首先,我们应该修改$watch函数的传参,既然它是嵌套的,所以我们的监听b属性是不是应该写成

$watch('a.b', () => {
  console.log('b被修改了')
})
data.a.b = 2

发现还是没有生效😂,仔细观察,这段代码是有问题的

首先, 传递的参数’a.b’是一个字符串,它不可能监听到a.b的变化,所以,我们要解析这种带.的字符串, 修改$watch函数如下

let Target = null
function $watch(exp, fn) {
  Target = fn
  let obj = data
  if(/\./.test(exp)) { // 判断传递的参数中是否包含.
    let pathArr = exp.split('.') // 获取到嵌套属性的数组,以'a.b'为例,得到[a, b]
    pathArr.forEach(key => {
      obj = obj[key] // 循环获取到obj.a.b
    })
    return
  }
  obj[exp]
}

还记得$watch函数的两个作用吗?

  1. 通过Target变量存储依赖

  2. 通过读取属性值触发访问器属性getter,完成依赖收集

所以, 如上代码, 当第一个参数是带有.的字符串,那么遍历,直到读取到最后一个属性,触发getter,完成依赖收集,之后return,不执行后面的代码。

修改$watch函数以后,我们可以再试试,发现还是不可以,那问题出在哪呢?

代码就这么两部分,$watch函数已经修改了,没能产生我们预期的结果,那说明另一部分的代码也需要修改,回顾代码

for(let key in data) {
  const dep = [] // 收集依赖的数组, 放在for循环里,这个每一个属性都会有自己的依赖收集器
  let val = data[key] // 缓存字段原有的值
  Object.defineProperty(data, key, {
    set(newVal) {
      if(newVal === val) return // 如果新设置的值和旧值是一样的,则什么都不做
      val = newVal // 新值覆盖旧值
      // 执行收集到的依赖
      dep.forEach(fn =>  fn())
    },
    get() {
      dep.push(Target)
      return val
    }
  })
}

诶诶诶,我好像发现了什么。我们的for循环只能循环出来第一层对象属性啊,对于嵌套属性没辙啊。所以,怎么办呢?我们可以使用递归啊

function walk(data) {
  for(let key in data) {
    const dep = []
    let val = data[key]
    if(Object.prototype.toString.call(val) === '[object Object]') {
      walk(val)
    }
    Object.defineProperty(data, key, {
      set(newVal) {
        if(newVal === val) return
        val = newVal
        dep.forEach(fn => fn())
      },
      get() {
        dep.push(Target)
        return val
      }
    })
  }
}
walk(data)

我们再次执行如下代码

$watch('a.b', () => {
  console.log('b被修改了')
})
data.a.b = 2

诶!怎么这么神奇呢,它按照我们预期的输出了。

思考5: 我们在写Vue代码的时候,在template模板里读取data里的变量,会不会触发依赖收集呢?

答案是肯定的,那它是如何实现的呢?首先template模板会被处理成render函数,然后执行render函数去完成页面的渲染,具体的实现逻辑还请参照vue源码,因为我目前也不是很清楚(TODO),既然它是个函数,那我们的$watch 函数是不是也应该可以接收函数作为参数

const data = {
	name: 'simple',
	age: 22
}
function render() {
	document.write(`hello, my name is ${data.name}, I am ${data.age} years old this year.`)
}

let Target = null
function $watch(exp, fn) {
	Target = fn
  let obj = data
  if(typeof exp === 'function') {
    exp()
    return
  }
  if(/\./.test(exp)) {
    let pathArr = exp.split('.')
    pathArr.forEach(key => {
      obj = obj[key]
    })
    return
  }
  return obj[key]
}

我们首先判断exp是不是函数,如果是函数,则直接执行函数,完成依赖收集,后面代码不再执行。同时我们要注意传递给$watch函数的第二个参数

$watch(render, render)

第二个参数依然是render,即当依赖发生变化时,会重新执行render函数,我们就实现了数据变化,并将变化应用到DOM上。

总结

以上,大概就是vue响应式系统的基本思路,但是我们实现的都是最简单的,有很多不完善的地方,例如,如何避免重复收集依赖,如何处理数组以及其他边界条件等等。

以下是完整代码

function walk(data) {
  for(let key in data) {
    const dep = []
    let val = data[key]
    if(Object.prototype.toString.call(val) === '[object Object]') {
      walk(val)
    }
    Object.defineProperty(data, key, {
      set(newVal) {
        if(newVal === val) return
        val = newVal
        dep.forEach(fn => fn())
      },
      get() {
        dep.push(Target)
        return val
      }
    })
  }
}
walk(data)

let Target = null
function $watch(exp, fn) {
	Target = fn
  let obj = data
  if(typeof exp === 'function') {
    exp()
    return
  }
  if(/\./.test(exp)) {
    let pathArr = exp.split('.')
    pathArr.forEach(key => {
      obj = obj[key]
    })
    return
  }
  return obj[key]
}

参考: https://github.com/HcySunYang/vue-design/tree/elegant