0年前端的Vue响应式原理学习总结1:基本原理

同窗们,你是否想学习Vue的数据响应式原理而无从下手呢?是否有过被复杂的源码教程劝退的经历呢?若是你和我同样,作过一个项目以后想深刻原理的话,恭喜你,你来对地方了。这个系列文章将从纯粹的Vue响应式原理出发,没有其余因素的干扰,带领你们实现一个本身的响应式系统。vue

友情提示:由于咱们的代码会通过多个版本的修改,因此我但愿你们在看文章的时候可以把涉及到的代码手敲一遍,这样可以帮助理解。react

项目地址:giteegit

系列地址:github

2:数组的处理shell

3:渲染watcherexpress

4:最终章数组

0.前言

数据模型仅仅是普通的 JavaScript 对象。而当你修改它们时,视图会进行更新。markdown

使用Vue时,咱们只须要修改数据(state),视图就可以得到相应的更新,这就是响应式系统。要实现一个本身的响应式系统,咱们首先要明白要作什么事情:数据结构

  1. 数据劫持:当数据变化时,咱们能够作一些特定的事情
  2. 依赖收集:咱们要知道那些视图层的内容(DOM)依赖了哪些数据(state)
  3. 派发更新:数据变化后,如何通知依赖这些数据的DOM

接下来,咱们将一步步地实现一个本身的玩具响应式系统闭包

1. 数据劫持

几乎全部的文章和教程,在讲解Vue响应式系统时都会先讲:Vue使用Object.defineProperty来进行数据劫持。那么,咱们也从数据劫持讲起,你们可能会对劫持这个概念有些迷茫,没有关系,看完下面的内容,你必定会明白。

Object.defineProperty的用法在此很少作介绍,不明白的同窗可在MDN上查阅。下面,咱们为obj定义一个a属性

const obj = {}

let val = 1
Object.defineProperty(obj, a, {
  get() { // 下文中该方法统称为getter
    console.log('get property a')
    return val
  },
  set(newVal) { // 下文中该方法统称为setter
    if (val === newVal) return
    console.log(`set property a -> ${newVal}`)
    val = newVal
  }
})
复制代码

这样,当咱们访问obj.a时,打印get property a并返回1,obj.a = 2设置新的值时,打印set property a -> 2。这至关于咱们自定义了obj.a取值和赋值的行为,使用自定义的gettersetter来重写了原有的行为,这也就是数据劫持的含义。

可是上面的代码有一个问题:咱们须要一个全局的变量来保存这个属性的值,所以,咱们能够用下面的写法

// value使用了参数默认值
function defineReactive(data, key, value = data[key]) {
  Object.defineProperty(data, key, {
    get: function reactiveGetter() {
      return value
    },
    set: function reactiveSetter(newValue) {
      if (newValue === value) return
      value = newValue
    }
  })
}

defineReactive(obj, a, 1)
复制代码

若是obj有多个属性呢?咱们能够新建一个类Observer来遍历该对象

class Observer {
  constructor(value) {
    this.value = value
    this.walk()
  }
  walk() {
    Object.keys(this.value).forEach((key) => defineReactive(this.value, key))
  }
}

const obj = { a: 1, b: 2 }
new Observer(obj)
复制代码

若是obj内有嵌套的属性呢?咱们可使用递归来完成嵌套属性的数据劫持

// 入口函数
function observe(data) {
  if (typeof data !== 'object') return
  // 调用Observer
  new Observer(data)
}

class Observer {
  constructor(value) {
    this.value = value
    this.walk()
  }
  walk() {
    // 遍历该对象,并进行数据劫持
    Object.keys(this.value).forEach((key) => defineReactive(this.value, key))
  }
}

function defineReactive(data, key, value = data[key]) {
  // 若是value是对象,递归调用observe来监测该对象
  // 若是value不是对象,observe函数会直接返回
  observe(value)
  Object.defineProperty(data, key, {
    get: function reactiveGetter() {
      return value
    },
    set: function reactiveSetter(newValue) {
      if (newValue === value) return
      value = newValue
      observe(newValue) // 设置的新值也要被监听
    }
  })
}

const obj = {
  a: 1,
  b: {
    c: 2
  }
}

observe(obj)
复制代码

对于这一部分,你们可能有点晕,接下来梳理一下:

执行observe(obj)
├── new Observer(obj),并执行this.walk()遍历obj的属性,执行defineReactive()
    ├── defineReactive(obj, a)
        ├── 执行observe(obj.a) 发现obj.a不是对象,直接返回
        ├── 执行defineReactive(obj, a) 的剩余代码
    ├── defineReactive(obj, b) 
	    ├── 执行observe(obj.b) 发现obj.b是对象
	        ├── 执行 new Observer(obj.b),遍历obj.b的属性,执行defineReactive()
                    ├── 执行defineReactive(obj.b, c)
                        ├── 执行observe(obj.b.c) 发现obj.b.c不是对象,直接返回
                        ├── 执行defineReactive(obj.b, c)的剩余代码
            ├── 执行defineReactive(obj, b)的剩余代码
代码执行结束
复制代码

能够看出,上面三个函数的调用关系以下:

三个函数相互调用从而造成了递归,与普通的递归有所不一样。 有些同窗可能会想,只要在setter中调用一下渲染函数来从新渲染页面,不就能完成在数据变化时更新页面了吗?确实能够,可是这样作的代价就是:任何一个数据的变化,都会致使这个页面的从新渲染,代价未免太大了吧。咱们想作的效果是:数据变化时,只更新与这个数据有关的DOM结构,那就涉及到下文的内容了:依赖

2. 收集依赖与派发更新

依赖

在正式讲解依赖收集以前,咱们先看看什么是依赖。举一个生活中的例子:淘宝购物。如今淘宝某店铺上有一块显卡(空气)处于预售阶段,若是咱们想买的话,咱们能够点击预售提醒,当显卡开始卖的时候,淘宝为咱们推送一条消息,咱们看到消息后,能够开始购买。

将这个例子抽象一下就是发布-订阅模式:买家点击预售提醒,就至关于在淘宝上登记了本身的信息(订阅),淘宝则会将买家的信息保存在一个数据结构中(好比数组)。显卡正式开放购买时,淘宝会通知全部的买家:显卡开卖了(发布),买家会根据这个消息进行一些动做(好比买回来挖矿)。

Vue响应式系统中,显卡对应数据,那么例子中的买家对应什么呢?就是一个抽象的类: Watcher。你们没必要纠结这个名字的含义,只须要知道它作什么事情:每一个Watcher实例订阅一个或者多个数据,这些数据也被称为wacther的依赖(商品就是买家的依赖);当依赖发生变化,Watcher实例会接收到数据发生变化这条消息,以后会执行一个回调函数来实现某些功能,好比更新页面(买家进行一些动做)。

所以Watcher类能够以下实现

class Watcher {
  constructor(data, expression, cb) {
    // data: 数据对象,如obj
    // expression:表达式,如b.c,根据data和expression就能够获取watcher依赖的数据
    // cb:依赖变化时触发的回调
    this.data = data
    this.expression = expression
    this.cb = cb
    // 初始化watcher实例时订阅数据
    this.value = this.get()
  }
  
  get() {
    const value = parsePath(this.data, this.expression)
    return value
  }
  
  // 当收到数据变化的消息时执行该方法,从而调用cb
  update() {
    this.value = parsePath(this.data, this.expression) // 对存储的数据进行更新
    cb()
  }
}

function parsePath(obj, expression) {
  const segments = expression.split('.')
  for (let key of segments) {
    if (!obj) return
    obj = obj[key]
  }
  return obj
}
复制代码

若是你对Watcher这个类何时实例化有疑问的话,不要紧,下面立刻就会讲到

其实前文例子中还有一个点咱们还没有提到:显卡例子中说到,淘宝会将买家信息保存在一个数组中,那么咱们的响应式系统中也应该有一个数组来保存买家信息,也就是watcher

总结一下咱们须要实现的功能:

  1. 有一个数组来存储watcher
  2. watcher实例须要订阅(依赖)数据,也就是获取依赖或者收集依赖
  3. watcher的依赖发生变化时触发watcher的回调函数,也就是派发更新。

每一个数据都应该维护一个属于本身的数组,该数组来存放依赖本身的watcher,咱们能够在defineReactive中定义一个数组dep,这样经过闭包,每一个属性就能拥有一个属于本身的dep

function defineReactive(data, key, value = data[key]) {
  const dep = [] // 增长
  observe(value)
  Object.defineProperty(data, key, {
    get: function reactiveGetter() {
      return value
    },
    set: function reactiveSetter(newValue) {
      if (newValue === value) return
      value = newValue
      observe(newValue)
      dep.notify()
    }
  })
}
复制代码

到这里,咱们实现了第一个功能,接下来实现收集依赖的过程。

依赖收集

如今咱们把目光集中到页面的初次渲染过程当中(暂时忽略渲染函数和虚拟DOM等部分):渲染引擎会解析模板,好比引擎遇到了一个插值表达式,若是咱们此时实例化一个watcher,会发生什么事情呢?从Watcher的代码中能够看到,实例化时会执行get方法,get方法的做用就是获取本身依赖的数据,而咱们重写了数据的访问行为,为每一个数据定义了getter,所以getter函数就会执行,若是咱们在getter中把当前的watcher添加到dep数组中(淘宝低登记买家信息),不就可以完成依赖收集了吗!!

注意:执行到getter时,new Watcher()get方法尚未执行完毕。

new Watcher()时执行constructor,调用了实例的get方法,实例的get方法会读取数据的值,从而触发了数据的gettergetter执行完毕后,实例的get方法执行完毕,并返回值,constructor执行完毕,实例化完毕。

有些同窗可能会有疑惑:明明是watcher收集依赖,应该是watcher收集数据,怎么成了数据的dep收集watcher了呢?有此疑问的同窗能够再看一下前面淘宝的例子(是淘宝记录了用户信息),或者深刻了解一下发布-订阅模式。

经过上面的分析,咱们只须要对getter进行一些修改:

get: function reactiveGetter() {
  dep.push(watcher) // 新增
  return value
}
复制代码

问题又来了,watcher这个变量从哪里来呢?咱们是在模板编译函数中的实例化watcher的,getter中取不到这个实例啊。解决方法也很简单,将watcher实例放到全局不就好了吗,好比放到window.target上。所以,Watcherget方法作以下修改

get() {
  window.target = this // 新增
  const value = parsePath(this.data, this.expression)
  return value
}
复制代码

这样,将get方法中的dep.push(watcher)修改成dep.push(window.target)便可。

注意,不能这样写window.target = new Watcher()。由于执行到getter的时候,实例化watcher尚未完成,因此window.target仍是undefined

依赖收集过程:渲染页面时碰到插值表达式,v-bind等须要数据等地方,会实例化一个watcher,实例化watcher就会对依赖的数据求值,从而触发getter,数据的getter函数就会添加依赖本身的watcher,从而完成依赖收集。咱们能够理解为watcher在收集依赖,而代码的实现方式是在数据中存储依赖本身的watcher

细心的读者可能会发现,利用这种方法,每遇到一个插值表达式就会新建一个watcher,这样每一个节点就会对应一个watcher。实际上这是vue1.x的作法,以节点为单位进行更新,粒度较细。而vue2.x的作法是每一个组件对应一个watcher,实例化watcher时传入的也再也不是一个expression,而是渲染函数,渲染函数由组件的模板转化而来,这样一个组件的watcher就能收集到本身的全部依赖,以组件为单位进行更新,是一种中等粒度的方式。要实现vue2.x的响应式系统涉及到不少其余的东西,好比组件化,虚拟DOM等,而这个系列文章只专一于数据响应式的原理,所以不能实现vue2.x,可是二者关于响应式的方面,原理相同。

派发更新

实现依赖收集后,咱们最后要实现的功能是派发更新,也就是依赖变化时触发watcher的回调。从依赖收集部分咱们知道,获取哪一个数据,也就是说触发哪一个数据的getter,就说明watcher依赖哪一个数据,那数据变化的时候如何通知watcher呢?相信不少同窗都已经猜到了:在setter中派发更新。

set: function reactiveSetter(newValue) {
  if (newValue === value) return
  value = newValue
  observe(newValue)
  dep.forEach(d => d.update()) // 新增 update方法见Watcher类
}
复制代码

3. 优化代码

1. Dep类

咱们能够将dep数组抽象为一个类:

class Dep {
  constructor() {
    this.subs = []
  }

  depend() {
    this.addSub(Dep.target)
  }

  notify() {
    const subs = [...this.subs]
    subs.forEach((s) => s.update())
  }

  addSub(sub) {
    this.subs.push(sub)
  }
}
复制代码

defineReactive函数只需作相应的修改

function defineReactive(data, key, value = data[key]) {
  const dep = new Dep() // 修改
  observe(value)
  Object.defineProperty(data, key, {
    get: function reactiveGetter() {
      dep.depend() // 修改
      return value
    },
    set: function reactiveSetter(newValue) {
      if (newValue === value) return
      value = newValue
      observe(newValue)
      dep.notify() // 修改
    }
  })
}
复制代码

2. window.target

watcherget方法中

get() {
  window.target = this // 设置了window.target
  const value = parsePath(this.data, this.expression)
  return value
}
复制代码

你们可能注意到了,咱们没有重置window.target。有些同窗可能认为这没什么问题,可是考虑以下场景:有一个对象obj: { a: 1, b: 2 }咱们先实例化了一个watcher1watcher1依赖obj.a,那么window.target就是watcher1。以后咱们访问了obj.b,会发生什么呢?访问obj.b会触发obj.bgettergetter会调用dep.depend(),那么obj.bdep就会收集window.target, 也就是watcher1,这就致使watcher1依赖了obj.b,但事实并不是如此。为解决这个问题,咱们作以下修改:

// Watcher的get方法
get() {
  window.target = this
  const value = parsePath(this.data, this.expression)
  window.target = null // 新增,求值完毕后重置window.target
  return value
}

// Dep的depend方法
depend() {
  if (Dep.target) { // 新增
    this.addSub(Dep.target)
  }
}
复制代码

经过上面的分析可以看出,window.target的含义就是当前执行上下文中的watcher实例。因为js单线程的特性,同一时刻只有一个watcher的代码在执行,所以window.target就是当前正在处于实例化过程当中的watcher

3. update方法

咱们以前实现的update方法以下:

update() {
  this.value = parsePath(this.data, this.expression)
  this.cb()
}
复制代码

你们回顾一下vm.$watch方法,咱们能够在定义的回调中访问this,而且该回调能够接收到监听数据的新值和旧值,所以作以下修改

update() {
  const oldValue = this.value
  this.value = parsePath(this.data, this.expression)
  this.cb.call(this.data, this.value, oldValue)
}
复制代码

4. 学习一下Vue源码

Vue源码--56行中,咱们会看到这样一个变量:targetStack,看起来好像和咱们的window.target有点关系,没错,确实有关系。设想一个这样的场景:咱们有两个嵌套的父子组件,渲染父组件时会新建一个父组件的watcher,渲染过程当中发现还有子组件,就会开始渲染子组件,也会新建一个子组件的watcher。在咱们的实现中,新建父组件watcher时,window.target会指向父组件watcher,以后新建子组件watcherwindow.target将被子组件watcher覆盖,子组件渲染完毕,回到父组件watcher时,window.target变成了null,这就会出现问题,所以,咱们用一个栈结构来保存watcher

const targetStack = []

function pushTarget(_target) {
  targetStack.push(window.target)
  window.target = _target
}

function popTarget() {
  window.target = targetStack.pop()
}
复制代码

Watcherget方法作以下修改

get() {
  pushTarget(this) // 修改
  const value = parsePath(this.data, this.expression)
  popTarget() // 修改
  return value
}
复制代码

此外,Vue中使用Dep.target而不是window.target来保存当前的watcher,这一点影响不大,只要能保证有一个全局惟一的变量来保存当前的watcher便可

5.总结代码

现将代码总结以下:

// 调用该方法来检测数据
function observe(data) {
  if (typeof data !== 'object') return
  new Observer(data)
}

class Observer {
  constructor(value) {
    this.value = value
    this.walk()
  }
  walk() {
    Object.keys(this.value).forEach((key) => defineReactive(this.value, key))
  }
}

// 数据拦截
function defineReactive(data, key, value = data[key]) {
  const dep = new Dep()
  observe(value)
  Object.defineProperty(data, key, {
    get: function reactiveGetter() {
      dep.depend()
      return value
    },
    set: function reactiveSetter(newValue) {
      if (newValue === value) return
      value = newValue
      observe(newValue)
      dep.notify()
    }
  })
}

// 依赖
class Dep {
  constructor() {
    this.subs = []
  }

  depend() {
    if (Dep.target) {
      this.addSub(Dep.target)
    }
  }

  notify() {
    const subs = [...this.subs]
    subs.forEach((s) => s.update())
  }

  addSub(sub) {
    this.subs.push(sub)
  }
}

Dep.target = null

const TargetStack = []

function pushTarget(_target) {
  TargetStack.push(Dep.target)
  Dep.target = _target
}

function popTarget() {
  Dep.target = TargetStack.pop()
}

// watcher
class Watcher {
  constructor(data, expression, cb) {
    this.data = data
    this.expression = expression
    this.cb = cb
    this.value = this.get()
  }

  get() {
    pushTarget(this)
    const value = parsePath(this.data, this.expression)
    popTarget()
    return value
  }

  update() {
    const oldValue = this.value
    this.value = parsePath(this.data, this.expression)
    this.cb.call(this.data, this.value, oldValue)
  }
}

// 工具函数
function parsePath(obj, expression) {
  const segments = expression.split('.')
  for (let key of segments) {
    if (!obj) return
    obj = obj[key]
  }
  return obj
}

// for test
let obj = {
  a: 1,
  b: {
    m: {
      n: 4
    }
  }
}

observe(obj)

let w1 = new Watcher(obj, 'a', (val, oldVal) => {
  console.log(`obj.a 从 ${oldVal}(oldVal) 变成了 ${val}(newVal)`)
})

复制代码

4. 注意事项

1. 闭包

Vue可以实现如此强大的功能,离不开闭包的功劳:在defineReactive中就造成了闭包,这样每一个对象的每一个属性就能保存本身的值value和依赖对象dep

2. 只要触发getter就会收集依赖吗

答案是否认的。在Depdepend方法中,咱们看到,只有Dep.target为真时才会添加依赖。好比在派发更新时会触发watcherupdate方法,该方法也会触发parsePath来取值,可是此时的Dep.targetnull,不会添加依赖。仔细观察能够发现,只有watcherget方法中会调用pushTarget(this)来对Dep.target赋值,其余时候Dep.target都是null,而get方法只会在实例化watcher的时候调用,所以,在咱们的实现中,一个watcher的依赖在其实例化时就已经肯定了,以后任何读取值的操做均不会增长依赖。

3. 依赖嵌套的对象属性

咱们结合上面的代码来思考下面这个问题:

let w2 = new Watcher(obj, 'b.m.n', (val, oldVal) => {
  console.log(`obj.b.m.n 从 ${oldVal}(oldVal) 变成了 ${val}(newVal)`)
})
复制代码

咱们知道,w2会依赖obj.b.m.n, 可是w2会依赖obj.b, obj.b.m吗?或者说,obj.b,和obj.b.m,它们闭包中保存的dep中会有w2吗?答案是会。咱们先不从代码角度分析,设想一下,若是咱们让obj.b = null,那么很显然w2的回调函数应该被触发,这就说明w2会依赖中间层级的对象属性。

接下来咱们从代码层面分析一下:new Watcher()时,会调用watcher的get方法,将Dep.target设置为w2get方法会调用parsePath来取值,咱们来看一下取值的具体过程:

function parsePath(obj, expression) {
  const segments = expression.split('.') // 先将表达式分割,segments:['b', 'm', 'n']
  // 循环取值
  for (let key of segments) {
    if (!obj) return
    obj = obj[key]
  }
  return obj
}
复制代码

以上代码流程以下:

  1. 局部变量obj为对象obj,读取obj.b的值,触发getter,触发dep.depend()(该depobj.b的闭包中的dep),Dep.target存在,添加依赖
  2. 局部变量objobj.b,读取obj.b.m的值,触发getter,触发dep.depend()(该depobj.b.m的闭包中的dep),Dep.target存在,添加依赖
  3. 局部变量obj为对象obj.b.m,读取obj.b.m.n的值,触发getter,触发dep.depend()(该depobj.b.m.n的闭包中的dep),Dep.target存在,添加依赖

从上面的代码能够看出,w2会依赖与目标属性相关的每一项,这也是符合逻辑的。

5. 总结

总结一下:

  1. 调用observe(obj),将obj设置为响应式对象,observe函数,Observe, defineReactive函数三者互相调用,从而递归地将obj设置为响应式对象
  2. 渲染页面时实例化watcher,这个过程会读取依赖数据的值,从而完成在getter中获取依赖
  3. 依赖变化时触发setter,从而派发更新,执行回调,完成在setter中派发更新

占个坑

从严格意义来讲,咱们如今完成的响应式系统还不能用于渲染页面,由于真正用于渲染页面的watcher是不须要设置回调函数的,咱们称之为渲染watcher。此外,渲染watcher能够接收一个渲染函数而不是表达式做为参数,当依赖变化时自动从新渲染,而这样又会带来重复依赖的问题。此外,另外一个重要的内容咱们尚未涉及到,就是数组的处理。

如今看不懂前面提到的问题,没有关系,这个系列以后的文章会一步步来解决这些问题,但愿你们可以继续关注。

喜欢的话就请各位看官点个赞吧!!!

相关文章
相关标签/搜索