Vue 源码剖析 —— 对象变化侦测

栏目: JavaScript · 发布时间: 5年前

内容简介:当应用在运行时,内部状态是会不断变化的。而对于 web 应用而言这会直接导致页面不停的重新渲染。那么如何通过状态变化确定具体要重新渲染哪个部分呢?在 MVVM 框架出现之前,大多数时候都需要手动去创建并维护数据与显示层的联系,随着应用的复杂度提高,内部状态和 UI 的联系变得错综复杂,难以维护。前端 MVVM 的框架正是通过编写一个通用的Vue.js 的变化侦测与 React 不同,对 React而言,当状态发生变化时,它并不知道具体哪个状态变化了,只知道状态有可能变了,然后发送信号给框架,框架内部收到信号

当应用在运行时,内部状态是会不断变化的。而对于 web 应用而言这会直接导致页面不停的重新渲染。那么如何通过状态变化确定具体要重新渲染哪个部分呢?在 MVVM 框架出现之前,大多数时候都需要手动去创建并维护数据与显示层的联系,随着应用的复杂度提高,内部状态和 UI 的联系变得错综复杂,难以维护。前端 MVVM 的框架正是通过编写一个通用的 ViewModel 层,负责让 Model 层的变化自动同步到 View 层,还负责让 View 层的修改同步回 Model 。今天我们一起来剖析一下,当应用的内部状态改变时,Vue.js 是怎么做到侦测到变化的。

Vue.js 的变化侦测与 React 不同,对 React而言,当状态发生变化时,它并不知道具体哪个状态变化了,只知道状态有可能变了,然后发送信号给框架,框架内部收到信号后,会进行暴力比对找出来那些DOM节点需要重新渲染。对 Vue.js 而言,当状态发生改变时,它立刻就知道了,而且一定程度上知道具体哪些状态改变了,且如果一个状态上绑定了多个依赖,当状态改变时,会向所有绑定的依赖发送通知。但是,这种粒度更细也是要付出一定代价的,每个状态绑定的依赖越多,依赖跟踪在内存上的消耗就更大,这种情况在 Vue.js 2.0 引入虚拟 DOM 之后改善了很多。

问题一:如何侦听一个对象的变化

在 JS 中,我们侦听对象变化的手段无非两种: Object.defineProperty 和 ES6 的 Proxy 。由于 ES6 的支持情况不理想,Vue.js 2.0 中采用的是第一种方法,但在新版本中应该会放弃 Object.defineProperty 选择 Proxy 。因为 Object.defineProperty 是存在明显缺陷的,后文会提到。首先我们可以采用下面的函数来封装 Object.defineProperty

function defineReactive(data, key, val) {
  Object.defineProperty(data, key, {
    enumerable: true,
    configurable: true,
    get: function() {
      return val
    },
    set: function(newVal) {
      if (val === newVal) return
      val = newVal
    }
  })
}
复制代码

此时,思考一下,要观察数据的真正目的是什么?

目的就是当数据变化时,可以通知那些曾经使用过该数据的地方。所以我们需要先收集依赖,这样当数据变化时在去通知这些依赖。显而易见,可以在 getter 中收集依赖,在 setter 中触发依赖。

问题二:依赖收集在哪里

首先我们可以封装一个通用的依赖类,在 Vue.js 中是 Dep 类:

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

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

  removeSub (sub) {
    remove(this.subs, sub)
  }

  depend () {
    if (somethingToWatch) {
      this.addSub(somethingToWatch)
    }
  }

  notify () {
    const subs = this.subs.slice()
    for (let sub of subs) {
      sub.update()
    }
  }
}

function remove (arr, items) {
  if (arr.length) {
    const index = arr.indexOf(item)
    if (index > -1) {
      return arr.splice(index, 1)
    }
  }
}
复制代码

接着改造一下 defineReactive

function defineReactive(data, key, val) {
  let dep = new Dep()
  Object.defineProperty(data, key, {
    enumerable: true,
    configurable: true,
    get: function() {
      dep.depend()
      return val
    },
    set: function(newVal) {
      if (val === newVal) return
      val = newVal
      dep.notify()
    }
  })
}
复制代码

问题三:依赖是谁

在上面的 Dep 类中出现了 somethingToWatch ,显然它正是我们在数据变化之后需要通知的对象。在 Vue.js 中,我们通知用到数据的地方有很多,比如模板中,或是自定义的一个 watch 。所以此时需要一个抽象的类来覆盖这些情况,Vue.js 中这个类为 Watcher

class Watcher {
  constructor (vm, expOrFn, cb) {
    this.vm = vm
    this.getter = parsePath(expOrFn)
    this.cb = cb
    this.value = this.get()
  }

  get () {
    somethingToWatch = this
    let value = this.getter.call(this.vm, this.vm)
    somethingToWatch = undefined
    return value
  }

  update() {
    const oldValue = this.value
    this.value = this.get()
    this.cb.call(this.vm, this.value, oldValue)
  }
}
复制代码

在这段代码中,当 Watcher 初始化时,会调用 get 方法,而在 get 方法中,我们将 somethingToWatch 指向了当前的 Watcher 实例,当我们在获取 value 值的时候又会触发数据的 getter ,从而自动将 Watcher 实例添加到 Dep 中。当数据变化时, Dep 会触发依赖列表中所有依赖的 update 方法,也就是 Watcher 中的 update 方法, Watcher 中的 update 方法。

可以看一个 vm.$watch('a.b.c', (oldVal, newVal) => {}) 的例子,当 a.b.c 变化时,要调用后面的回调函数。首先,要解析 a.b.c ,在 Vue.js 中用 parsePath 来完成:

const bailRE = /^\w+.$/
function parsePath (path) {
  if (bailRE.test(path)) {
    return
  }
  const segments = path.split('.')
  return function (obj) {
    for (let segment of segments) {
      if (!obj) return
      obj = obj[segment]
    }
  }
  return obj
}
复制代码

至此,我们就拿到了 a.b.c 这个属性,且在 Watcherget 方法中访问了它,触发了它的 getter ,从而将当前 Watcher 实例添加到 a.b.c 的依赖列表里。且当 a.b.c 发生变化时,回调函数将会在 Watcher 中的 update 方法里被调用。

问题四:怎么侦测所有 key

可以看到使用 Object.defineProperty 可以侦测到对象的某个属性值变化,但是我们需要侦听所有属性值(包括子属性)的变化。现在开始封装 Observer 类来实现这一目的:

/**
 * Observer 类会被附加到每一个被侦测的 object上。
 * 一旦加上,会将 object 所有的属性都转化为 getter/setter 的形式
 * 来收集属性依赖,并且在属性变化时通知这些依赖
 */
class Observer {
  constructor(value) {
    this.value = value

    if (!Array.isArray(value)) {
      this.walk(value)
    }
  }

  /**
   * walk 将每一个属性都转为 getter/setter
   */
  walk (obj) {
    const keys = Object.keys(obj)
    for (let key of keys) {
      defineReactive(obj, key, obj[key])
    }
  }
}

function defineReactive(data, key, val) {
  // 新增,用于递归子属性
  if (typeof val == 'object') {
    new Observer(val)
  }
  ...
}
复制代码

通过定义了 Observer 类,我们将一个正常的 object 转换为了被侦测的 object 。然后判断数据类型,只有 Object 类型的数据才会调用 walk 方法将每一个属性都变为 getter/setter 模式。而改造后的 defineReactive 加上了一段新代码用于判断当子属性为 Object 时,对子属性调用 new Observer(val) ,从而形成递归。这样我们就把所有的属性都变为 getter/setter 的形式了。

问题五: Object.defineProperty 带来的隐藏问题

思考一个场景,当我们在一个 Vue 实例中,定义 data: { a: {} } ,又定义了一个方法 action () { this.a.name = 'jay' } ,如果调用了 action 方法,能不能侦听到对象 a 的改变呢? 答案是否定的,由于在初始化过程中, a 并没有 name 这个属性,也就是说在 walk 方法中,我们没有将 name 属性变为 getter/setter 模式,所以无法侦测到这个变化,也不会向依赖发送通知。

再比如,我们在 action 中删除某个已经存在的属性值, Object.defineProperty 只能判断一个数据是否被修改,故同样也是无法侦测到变化的。要解决这两个问题,我们可以调用 vm. delete 这两个API。

Object 的变化侦测过程梳理

Vue 源码剖析 —— 对象变化侦测

Data 通过 Observe 转换为 getter/setter 形式来追踪变化。当外界通过 Watcher 读取数据是,会触发 getter 从而将 Watcher 添加到依赖中。当数据发生变化时,会触发 setter ,从而向 Dep 中的依赖发送通知。 Watcher 收到通知后,会向外界发送通知,外界收到通知后,可能会触发视图更新,也可能触发用户的回调函数。

本系列文章均是深入浅出 Vue.js的学习笔记,有兴趣的小伙伴可以去看书哈。


以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

Linux命令行与shell脚本编程大全 第3版

Linux命令行与shell脚本编程大全 第3版

[美]布鲁姆,布雷斯纳汉 / 门佳、武海峰 / 人民邮电出版社 / 2016-8-1 / CNY 109.00

这是一本关于Linux命令行与shell脚本编程的全方位教程,主要包括四大部分:Linux命令行,shell脚本编程基础,高级shell脚本编程,如何创建实用的shell脚本。本书针对Linux系统的最新特性进行了全面更新,不仅涵盖了详尽的动手教程和现实世界中的实用信息,还提供了与所学内容相关的参考信息和背景资料。通过本书的学习,你将轻松写出自己的shell脚本。一起来看看 《Linux命令行与shell脚本编程大全 第3版》 这本书的介绍吧!

HTML 压缩/解压工具
HTML 压缩/解压工具

在线压缩/解压 HTML 代码

JSON 在线解析
JSON 在线解析

在线 JSON 格式化工具

RGB HSV 转换
RGB HSV 转换

RGB HSV 互转工具