细说Vue2的响应式原理

1,894 阅读8分钟

学习这篇之前,需要你已掌握 Object.defineProperty 的用法,可以看我的这篇文章,在其中有此函数的介绍。

什么是响应式

在 Vue 开发中,我们修改了数据,所有用到这份数据的视图都会更新。

响应式概括来说就是数据驱动视图的自动更新

举个例子,本文也将以下面这段代码来讲解与实现响应式

HTML

<div id="app">
  {{ obj.message }}
</div>

JS

let data = {
  obj: {
    message: 'Hello Vue!',
  },
}

new Vue({
  el: '#app',
  data,
})

setTimeout(() => {
  data.obj = {
    message: 'Obj have changed!',
  }
}, 1000)
setTimeout(() => {
  data.obj.message = 'Message have changed!'
}, 2000)

Vue 会将 “ Hello Vue! ” 渲染在页面中,在一秒后修改了 data.obj,页面也随之更新为 “ Obj have changed! ”,随后又过一秒 data.obj.message 被修改,页面显示为 “ Message have changed! ”

如何实现响应式

为了实现响应式,要解决2个问题

  1. 数据什么时候变化了 --- 监视数据
  2. 哪些地方用到了数据 --- 解析模板

监视数据

Vue 使用了 3 个类,实现了数据的拦截、更新的订阅与发布

  • Observer,监视者类,监视数据的变化,在数据变化时告诉通知者,在这个类中将数据的所有属性用 Object.defineProperty 重新定义一遍,绑定了存取器(getter/setter)

  • Dep,通知者类,通知订阅者更新视图,因为一个数据可能被多处使用,所以一个通知者会存储多位订阅者

  • Watcher,订阅者类,用于存储数据变化后要执行的更新函数,调用更新函数可以使用新的数据更新视图

下面我们来详述 Vue 是如何操作我们传入的 data 数据的

  1. Vue 拿到了 data 这个对象,创建一个监视者,绑定到对象的 __ob__ 属性上
  2. 创建监视者会将对象身上的所有属性用 Object.defineProperty 重新一遍,在定义的同时,为每一个数据创建它的通知者。通知者会在闭包环境中创建,只有该数据的存取器能够访问到。
  3. 如果对象的值中还有对象,会递归上面的过程
  4. 处理完 data 后,将其所有属性映射到 Vue 实例身上(允许vm.xxx直接访问)

以本文举例,监视数据完成时,创建了两个监视者,分别监视 data 与 obj。创建了四个通知者,分别属于数据 obj 与 message 和两个监视者,为了方便之后的讲解,我们为这些通知者编号

image.png

解析模板

数据监视完毕后,Vue 会解析模板

模板解析的内容较为复杂,这一过程会创建虚拟节点 vnode,匹配到 {{ }} v- 等响应式写法,会根据当前节点的类型、响应式语法等建立该节点的更新函数 patch ,将数据与视图绑定

本文主要是帮助理解响应式更新的逻辑,模板解析语法并不是本文的重点,所以在之后的讲解与实现中使用的还是 DOM 节点

接下来继续以本文的例子来讲解这一流程

  1. Vue 会根据 el: '#app' 配置项获取根 DOM 元素,遍历其所有子节点
  2. 发现一个文本节点的内容是 {{ obj.message }},检测到特定的响应式写法,建立更新函数并提取数据表达式(字符串 'obj.message'
  3. 因为要操作的是只是文本节点的内容,所以更新函数较为简单(下方代码)
    const patch = (value) => {
        node.textContent = value
    }
    
  4. 创建订阅者,保存更新函数与数据表达式,并将此订阅者存入一个全局变量中(表示进入依赖收集阶段),然后执行这个更新函数
  5. 执行函数会时会根据表达式访问数据,触发了监视者的绑定在其身上的 getter,getter 从全局变量获取订阅者,存入其绑定的通知者
  6. 每个数据访问结束时,表示本轮的依赖收集完成,清除全局变量中的订阅者
  7. 然后针对模板中用到的每一个响应式数据,都会重复以上的过程。对于多层嵌套的数据,也是转换成字符串一层层访问,监视者会为一路上所有数据的通知者添加本轮的订阅者

模板解析完成时,只创建了一个订阅者但被添加到了四个通知者中

image.png

如果细想会发现 Dep2 与 Dep3 的内容完全一致,其实 Dep2 主要是给 vue 其他 api 用的

更新数据

以后修改数据就会触发监视者的 setter,setter 就能告诉通知者,通知其内部的订阅者执行更新函数修改视图,实现了数据的响应式

如果修改的数据值是一个对象,会先为其创建监视者,再告诉通知者发布订阅,执行更新函数访问这些数据时,所有子数据的新通知者又存储了之前解析模板时创建的订阅者

还是根据例子来讲解更新流程

  1. 第一次修改的是 data.obj,是一个对象,原对象的监视者、Dep2、Dep4被移除
  2. 触发 obj 的 setter,新值是一个对象,为其创建监视者,同时创建了空的 Dep2、Dep4
  3. 告诉 Dep3,通知其中的订阅者执行更新函数
  4. 更新函数执行访问到了 objobj.message,将此订阅者又添加进 Dep2 和 Dep4 中
  5. 第二次修改的是data.obj.message
  6. 触发 message 的 setter,告诉 Dep4,通知其中的订阅者更新视图

这就是完整的更新流程了

通知者是用集合存储订阅者的,所以多次访问也只会添加一个订阅者

代码实现

Vue 的源码非常复杂,本文只提取了一小部分,以下代码只实现了响应式更新文本节点的功能

// 公开的Vue类
class Vue {
  constructor(options) {
    // 保存数据
    this._data = options.data
    // 创建监视者
    observe(this._data)
    // 将数据都映射到实例上
    this._initData()
    // 模板解析
    compile(options.el, this)
  }

  // 遍历数据,映射到实例上
  _initData() {
    for (const key of Object.keys(this._data)) {
      Object.defineProperty(this, key, {
        get() {
          return this._data[key]
        },
        set(newVal) {
          this._data[key] = newVal
        },
      })
    }
  }
}

// 创建监视者并返回
function observe(obj) {
  // 如果不是对象,不需要创建监视者
  if (typeof obj != 'object') return null
  let ob
  if (typeof obj.__ob__ !== 'undefined') {
    ob = obj.__ob__
  } else {
    // 创建监视着,传入对象
    ob = new Observer(obj)
  }
  return ob
}

// 监视者
class Observer {
  constructor(obj) {
    // 创建通知者
    this.dep = new Dep()
    // 将监视者添加到对象的身上
    Object.defineProperty(obj, '__ob__', {
      value: this,
    })
    // 遍历对象数据,定义存取器
    for (let k of Object.keys(obj)) {
      defineReactive(obj, k)
    }
  }
}

// 通知者
class Dep {
  constructor() {
    // 用集合存储自己的订阅者
    this.subs = new Set()
  }
  // 添加订阅者
  addSub(watcher) {
    // 存储订阅者
    this.subs.add(watcher)
  }
  //发布订阅
  notify() {
    // 依次执行更新函数
    // 浅克隆是为了避免在订阅者中修改同一数据,无限更新
    for (const sub of [...this.subs]) {
      sub.update()
    }
  }
  // 标志,表示是否处于依赖收集阶段,值为 Wather
  static target = null
}

// 定义存取拦截器,创建闭包环境
function defineReactive(data, key) {
  // 为数据创建通知者
  const dep = new Dep()
  // 在闭包环境中用局部变量保存数据
  let val = data[key]
  // 子数据如果是对象,也创建监视者
  let childOb = observe(val)

  // 定义存取器
  Object.defineProperty(data, key, {
    // getter
    get() {
      // 如果处于依赖收集阶段
      if (Dep.target != null) {
        // 添加订阅
        dep.addSub(Dep.target)
        // 监视者的通知者也要添加订阅
        if (childOb != null) {
          childOb.dep.addSub(Dep.target)
        }
      }
      // 从局部变量获取值
      return val
    },
    // setter
    set(newValue) {
      if (val === newValue) {
        return
      }
      // 更新局部变量
      val = newValue
      // 新值也需要尝试创建监视着
      childOb = observe(newValue)
      // 告诉通知者发布订阅
      dep.notify()
    },
  })
}

// 订阅者
class Watcher {
  constructor(vue, expression, callback) {
    this.target = vue
    this.expression = expression
    this.callback = callback
    this.value = this.get()
  }
  update() {
    // 获取新值,如果不相等,则执行更新函数
    const value = this.get()
    if (value !== this.value) {
      this.value = value
      this.callback.call(this.target, value)
    }
  }
  get() {
    // 进入依赖收集阶段,让全局的Dep.target设置成Watcher本身
    Dep.target = this

    // 沿着路径一致寻找
    let val = getObjVal(this.target, this.expression)
    // 依赖收集结束
    Dep.target = null

    return val
  }
}

// 根据字符串表达式获取值
function getObjVal(obj, exp) {
  let val = obj
  exp = exp.split('.')
  exp.forEach((k) => {
    val = val[k]
  })
  return val
}

// 编译模板
function compile(el, vue) {
  // 获取挂载节点
  const $el = document.querySelector(el)

  // 创建片段,存储dom节点
  let fragment = document.createDocumentFragment()

  // 将所有dom节点都放入片段中
  let child
  while ((child = $el.firstChild)) {
    fragment.appendChild(child)
  }

  // 匹配响应式写法
  const reg = /\{\{(.*)\}\}/
  // 遍历子节点
  // 简便起见,直解析文本节点
  for (const node of fragment.childNodes) {
    const text = node.textContent
    if (node.nodeType == 3 && reg.test(text)) {
      // 获取字符串表达式
      let name = text.match(reg)[1].trim()
      // 从vue中获取数据赋值
      node.textContent = getObjVal(vue, name)
      // 创建订阅者,绑定表达式与更新函数
      new Watcher(vue, name, (value) => {
        node.textContent = value
      })
    }
  }

  // 上树
  $el.appendChild(fragment)
}

总结

Vue2 实现响应式的三个类非常绕,希望读者仔细思考,理清其中关系

最后再强调一遍三个类的功能

  • 观察者会在每一个对象身上创建,为其所有属性添加存取器,用于操作通知者发布订阅
  • 每一个数据(包括对象)都会独有一个通知者通知者内使用一个集合存储所有依赖这个数据的订阅者
  • 数据在模板中的每一次使用,都会创建一个订阅者,存储更新函数与数据访问表达式

结语

如果文中有不理解或不严谨的地方,欢迎评论提问。

如果喜欢或有所帮助,希望能点赞关注,鼓励一下作者。