Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Vue2 变化侦测API实现原理 #80

Open
ChorHing opened this issue Nov 16, 2021 · 0 comments
Open

Vue2 变化侦测API实现原理 #80

ChorHing opened this issue Nov 16, 2021 · 0 comments

Comments

@ChorHing
Copy link

Vue2 变化侦测API实现原理

《Vue2 设计系统之响应式》中,作者介绍到 Vue2 的发布订阅原理,Dep类、Watcher 、vm.$set、vm.$delete 的概念。好学的我,当然要再深究一下 Vue2 中 vm.$watch vm.$set vm.$delete 的实现原理啦!了解 Vue2 watch 的实现原理,对于理解 Vue3 响应式设计逻辑也有一定帮助。

以下内容是本人阅读 Vue.js 官方文档《深入浅出Vue.js》 书籍之后的学习总结,如有错误,欢迎指出~

vm.$watch

介绍

首先,简单回顾一下 Vue2 watch 对象:

{ [key: string]: string | Function | Object | Array }

官方文档介绍:watch 是一个对象,键是需要观察的表达式,值是对应回调函数。值也可以是方法名,或者包含选项的对象。Vue 实例将会在实例化时调用 $watch(),遍历 watch 对象的每一个 property。

接着,再介绍一下 Vue2 $watch 实例方法:

vm.$watch(expOrFn, callback, [options])

  • 参数
    • {string | Function} expOrFn
    • {Function | Object} callback
    • {Object} [options]
      • {boolean} deep
      • {boolean} immediate
  • 返回值{Function} unwatch

官方文档介绍:$watch 用于观察 Vue 实例上的一个表达式或者一个函数计算结果的变化。回调函数得到的参数为新值和旧值。表达式只接受简单的键路径。对于更复杂的表达式,用一个函数取代。

表达式仅支持以点分隔的路径,例如 a.b.c 。如果需要观察多个 data property 计算的总结果,则使用函数返回。举一些简单的例子:

// 键路径
vm.$watch('a.b.c', function (newVal, oldVal) {
  // 做点什么
})

// 函数
vm.$watch(
  function () {
    // 表达式 `this.a + this.b` 每次得出一个不同的结果时,处理函数都会被调用。
    // 这就像监听一个未被定义的计算属性
    return this.a + this.b
  },
  function (newVal, oldVal) {
    // 做点什么
  }
)

// 取消观察函数,用来停止触发回调
var unwatch = vm.$watch('a', cb)

// 使用 deep 和 immediate
vm.$watch('someObject', callback, {
  deep: true, // 发现对象内部值的变化(注:数组无需使用这个属性)
  immediate: true, // 立即以表达式的当前值触发回调
})

实现原理

大概了解 vm.$watch 的使用方法后,再来剥开层层外壳,学习一下内部原理吧~

先看一段简化的代码:

export default class Watcher {
  constructor(vm, expOrFn, cb) {
    this.vm = vm
    // expOrFn 参数支持函数
    if (typeof expOrFn === 'function') {
      this.getter = expOrFn
    } else {
      this.getter = parsePath(expOrFn)
    }
    this.cb = cb
    this.value = this.get()
  }
  // ......
}

Vue.prototype.$watch = function(expOrFn, cb, options) {
  const vm = this
  options = options || {}
  const watcher = new Watcher(vm, expOrFn, cb, options)
  if (options.immediate) {
    cb.call(vm, watcher.value)
  }
  return function unwatchFn() {
    watcher.teardown()
  }
}

在 $watch 中,会执行new Watcher()。进入 Watcher 的构造函数,先判断expOrFn的类型,如果是function,直接将expOrFn赋给getter;否则再调用parsePath()函数读取expOrFn,也就是a.b.c属性路径的值并赋给getter。

我们假设某个 watch 是这样定义的:

// 函数 typeof expOrFn === 'function'
vm.$watch(
  function () {
    return this.a + this.b
  },
  function (newVal, oldVal) {}
)

那么,Watcher 将会同时订阅this.athis.b这两个Vue.js实例的响应式数据。在这之后,如果其中之一的值(假设this.a)有改变,this.a会告知所有订阅了它的 watcher 实例,包括这里举例的 watcher 实例。

执行完new Watcher()后,$watch 会判断是否传入了immediate参数,如果是,则立即使用初始值执行一次cb。

最后,$watch 会返回一个取消订阅的unwatch函数,调用它,实际上是调用 Watcher.teardown() 。那它又是怎么工作的呢?

首先,取消订阅,肯定是要知道自己订阅了谁,也就是 watcher 实例需要通过 list 记录订阅的 Dep 。调用 unwatch (即 teardown) 函数时,循环遍历这个 list ,告诉 Deps :我取消订阅咯,你们可以删掉我啦~

export default class Watcher {
  constructor(vm, expOrFn, cb) {
    // ......
    this.deps = [] // 新增代码
    this.depIds = new Set() // 新增代码
    // ......
  }
  // ......
  addDep(dep) { // 新增代码段
    const id = dep.id
    if (!this.depIds.has(id)) {
      this.depIds.add(id)
      this.deps.push(dep)
      dep.addSub(this)
    }
  }
  teardown() { // 新增代码段
    let i = this.deps.length
    while (i--) {
      this.deps[i].removeSub(this)
    }
  }
}

上面这段代码,展示了 Watcher 类是如何记录自己订阅的 Dep 和 Dep.id。如果是已经订阅过的 Dep ,就不再重复记录。

《Vue2 设计系统之响应式》文章,作者写到了 Dep 类的设计思路。在这个基础上,我们再改改代码,展示一下如何维护 Dep 的订阅者列表:

let uid = 0 // 新增代码

export default class Dep {
  constructor() {
    this.id = uid++ // 新增代码
    this.subscribers = [] // 记录watcher实例
  }
  depend() {
    if (target && !this.subscribers.includes(target)) {
      this.subscribers.push(target)
      target.addDep(this) // 新增代码
    }
  }
  notify() {
    this.subscribers.forEach(sub => sub())
  }
  removeSub(sub) { // 新增代码段
    const index = this.subs.indexOf(sub)
    if (index > -1) {
      return this.subs.splice(index, 1)
    }
  }
}

看完这两段代码,你看得出来 Watcher 和 Dep 是 多对多 关系了吗?如果还是不理解的话,也没关系,再看看下面的图,会更清晰明了~

我们假设watcher1观察的响应式数据是this.a + this.bwatcher2观察的响应式数据是this.a + this.c ,那么 Data 、Watcher 和 Dep 的(简化)关系将会是:

image
pic2

options.deep

再说说 vm.$watch options.deep 参数的原理。

前面提到,当 data 的某个 property 被监听,就会触发property.getter()把当前的订阅者 watcher 收集到 subscribers 。当 property 的值改变,也就是 property.setter()被调用,将会通知subscribers中的所有订阅者更新数据,并触发 re-render 重渲染。

当 watcher 中存在 deep 参数的时候,如果 property 是一个数组,则循环每一个元素并递归调用_traverse()函数;如果 property 是一个对象,则循环每一个 key 的 value 并递归调用_traverse()函数。

具体代码如下:

export default class Watcher {
  constructor(vm, expOrFn, cb) {
    this.vm = vm
    
    // 新增代码段
    if (options) {
      this.deep = !!options.deep
    } else {
      this.deep = false
    }
    
    this.deps = []
    // ......
  }
  // 新增代码段
  get() {
    pushTarget(this) // 将自身watcher观察者实例设置给Dep.target,用以依赖收集
    let value = this.getter.call(vm, vm)
    if (this.deep) {
      traverse(value) // 递归
    }
    popTarget() // 将观察者实例从target栈中取出并设置给Dep.target
    return value
  }
  // ......
}
const seenObjects = newSet()

export function traverse(val) {
  _traverse(val, seenObjects)
  seenObjects.clear()
}

export function _traverse (val, seen) {
  let i, keys
  const isA = Array.isArray(val)
  // 判断val类型
  if ((!isA && !isObject(val)) || Object.isFrozen(val) || val instanceof VNode) {
    return
  }
  // 记录id,避免重复收集依赖
  if (val.__ob__) {
    const depId = val.__ob__.dep.id
    if (seen.has(depId)) {
      return
    }
    seen.add(depId)
  }
  if (isA) {
    i = val.length
    while (i--) _traverse(val[i], seen)
  } else {
    keys = Object.keys(val)
    i = keys.length
    // 递归,val[key[i]] 会触发 getter ,也就是触发收集依赖的操作,实现deep深层监听
    while (i--) _traverse(val[keys[i]], seen)
  }
}

注:可能会有细心的同学注意到,当遍历Array元素时,如果该元素既不是数组,也不是对象,就会return,没有下一步操作。感兴趣的同学可以戳一下:Vue2.0为什么不能检查数组的变化?又该如何解决?

受 JavaScript 的限制 (而且 Object.observe 也已经被废弃),Vue 无法检测到对象属性的添加或删除。如何处理这个问题呢?答案是使用 vm.$set 和 vm.$delete 。

vm.$set

介绍

介绍一下 Vue2 $set 实例方法:

vm.$set(target, propertyName/index, value)

  • 参数

    • {Object | Array} target
    • {string | number} propertyName/index
    • {any} value
  • 返回值:设置的值。

  • 用法

    这是全局 Vue.set别名。可以通过这个方法实现 object 属性被添加后也是响应式的。

实现原理

让我们来看一下,vm.$set 怎么处理数组和对象:

export function set (target, key, val) {
  // target为数组
  if (Array.isArray(target) && isValidArrayIndex(key)) {
    // 修改数组的长度, 避免索引 > 数组长度 导致splcie()执行有误
    target.length = Math.max(target.length, key)
    // 利用数组的splice变异方法触发响应式
    target.splice(key, 1, val)
    return val
  }
  
  // target为对象, key在target或者target.prototype上
  // 同时必须不能在 Object.prototype 上
  // 直接修改即可, 有兴趣可以看issue: https://github.com/vuejs/vue/issues/6845
  if (key in target && !(key in Object.prototype)) {
    target[key] = val
    return val
  }
  
  // 以上都不成立, 即开始给target创建一个新的属性
  // 获取Observer实例
  const ob = target.__ob__
  // 不允许给 Vue.js 实例 或 Vue.js 实例的根数据对象(vm.$data) 添加属性
  if (target._isVue || (ob && ob.vmCount)) {
    process.env.NODE_ENV !== 'production' && warn(
      'Avoid adding reactive properties to a Vue instance or its root $data ' +
      'at runtime - declare it upfront in the data option.'
    )
    return val
  }
  // target本身就不是响应式数据, 直接赋值
  if (!ob) {
    target[key] = val
    return val
  }
  // 进行响应式处理,将新增属性转换成 getter/setter 的形式即可
  defineReactive(ob.value, key, val)
  // 向 target 的依赖触发变化通知
  ob.dep.notify()
  return val
}

vm.$delete

vm.$delete(target, propertyName/index)

介绍

介绍一下 Vue2 $delete 实例方法:

  • 参数

    • {Object | Array} target
    • {string | number} propertyName/index
  • 用法

    这是全局 Vue.delete别名。可以通过这个方法实现 object 属性被删除后,数据侦测可正常运行,并更新视图。

实现原理

让我们来看一下,vm.$set 怎么处理数组和对象。思路和 vm.$set 类似:

export function del (target, key) {
  if (Array.isArray(target) && isValidArrayIndex(key)) {
    // 利用数组的splice变异方法触发响应式
    target.splice(key, 1)
    return
  }
  // 获取Observer实例
  const ob = (target: any).__ob__
  // 不允许删除 Vue.js 实例 或 Vue.js 实例的根数据对象(vm.$data) 的属性
  if (target._isVue || (ob && ob.vmCount)) {
    process.env.NODE_ENV !== 'production' && warn(
      'Avoid deleting properties on a Vue instance or its root $data ' +
      '- just set it to null.'
    )
    return
  }
  // 如果 key 不是 target 自身的属性,直接终止程序的执行
  if (!hasOwn(target, key)) {
    return
  }
  delete target[key]
  
 // 如果ob不存在, 说明target本身不是响应式数据,直接终止程序的执行
  if (!ob) {
    return
  }
  // 触发依赖通知
  ob.dep.notify()
}

Vue3 的响应式设计已从 Object.defineProperty 转为 Proxy ,Vue3 的官方文档仍有 $watch 的介绍,但是与 Vue2 $watch 有一定差异;而 vm.$set 和 vm.$delete 已经不在 Vue3 的文档中。不过现在仍有许多项目使用的是 Vue2,所以了解一下这几个 API 的实现原理也有一定的收获~

阅读完这篇文章,大家应该对 Vue2 的响应式原理有更深的理解啦。对 Vue3 响应式有学习兴趣的同学,可以看一下《Vue3 设计原理之响应式》哦。

以上是个人的学习与总结,感谢阅读。如有错误,欢迎指出~


内容参考:
[1] Vue.js 官方文档
[2]《深入浅出Vue.js》刘博文·著

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant