Vue原理解析(六):理解响应式原理-对象

上一篇 Vue 原理解析(五): 虚拟Dom到真实Dom的生成过程

vue 之所以能数据驱动视图发生变更的关键就是:依赖它的响应式系统了。 响应式系统如果根据数据类型区分: 对象和数组两者的实现会有所不同。 解释响应式原理,需要从整体流程出发, 不在vue 组件化的整体流程中找到响应式原理的位置,对深刻理解响应式原理不太好。 接下来我们从整体流程出发, 试着站在巨人的肩膀上分别说明对象和数组的实现原理。

对象的响应式原理

对象响应式数据的创建

  • 在数组的初始化阶段, 将对传入的状态进行初始化, 以下以data为例, 会将传入数据包装为响应式的数据。
对象示例:

main.js
new Vue({  // 根组件
  render: h => h(App)
})

---------------------------------------------------

app.vue
<template>
  <div>{{info.name}}</div>  // 只用了info.name属性
</template>
export default {  // app组件
  data() {
    return {
      info: {
        name: 'cc',
        sex: 'man'  //  **即使是响应式数据,没被使用就不会进行依赖收集**
      }
    }
  }
}

接下来的分析将以上面代码为例, 这种结构其实是一个嵌套组件,只不过根组件一般定义的参数比较少而已,理解这个很重要的。

在组件=new Vue() 后执行vm._init() 初始化过程中, 当执行到initState(vm)时就会对内部使用到的一些状态, 如: props, data, computed, watch, methods 分别进行初始化, 再对data 进行初始化的最后有这么一句:

function initData(vm) {  //初始化data
  ...
  observe(data) //  info:{name:'cc',sex:'man'}
}

这个observer 就是将用户定义的data变成响应式的数据, 接下来看看它的创建过程:

export function observe(value) {
  if(!isObject(value)) {  // 不是数组或对象,再见
    return
  }
  return new Observer(value)
}

简单理解这个observer 方法就是Observer 这个类的工厂方法, 所以还是要看下Observer 这个类的定义:

export class Observer {
  constructor(value) {
    this.value = value
    this.walk(value)  // 遍历value
  }
  
  walk(obj) {
    const keys = Object.keys(obj)
    for(let i = 0; i < keys.length; i++) {
      defineReactive(obj, keys[i])  // 只传入了两个参数
    }
  }
}

当执行new Observer 时, 首先将传入的对象挂载到当前this 下, 然后遍历当前对象的每一项, 执行defineReactive 这个方法, 看看它的定义:

export function defineReactive(obj, key, val) {

  const dep = new Dep()  // 依赖管理器
  
  val = obj[key]  // 计算出对应key的值
  observe(val)  // 递归包装对象的嵌套属性
  
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get() {
      ... 收集依赖
    },
    set(newVal) {
      ... 派发更新
    }
  })
}

这个方法的作用就是使用Object.defineProperty创建响应式数据。 首先根据传入的objkey 计算出 val 具体的值; 如果val 还是对象, 那就使用observe 方法进行递归创建, 在递归的过程中使用Object.defindeProperty 将对象的每一个属性都变成响应式数据:

...
data() {
  return {
    info: {
      name: 'cc',
      sex: 'man'
    } 
  }
}
这段代码就会有三个响应式数据:
  info, info.name, info.sex

知识点: Object.defineProperty内的get 方法, 它的作用就是谁访问到当前key 的值就用 defineReactive 内的dep 将它收集起来, 也就是依赖收集的意思。 set 方法的作用就是当前key 的值被赋值了, 就通知dep内收集到的依赖项, key的值发生了变更, 视图请变更吧。

这个时候getset 只是定义了, 并不会触发。 什么是依赖,我们接下来说明,首先看看下图帮大家理清响应式数据的创建过程(这里先摘用网上的一张图):

在这里插入图片描述

依赖收集

什么是依赖? 看下之前mountComponent的定义:

function mountComponent(vm, el) {
  ...
  const updateComponent = function() {
    vm._update(vm._render())
  }
  
  new Watcher(vm, updateComponent, noop, {  // 渲染watcher
    ...
  }, true)  // true为标志,表示是否是渲染watcher
  ...
}

首先我们说明下这个Watcher 类, 它类似与之前的VNode 类, 根据传入的参数不同, 可以分别实例化出三种不同的Watcher 实例, 它们分别是用户watcher, 计算watcher 以及渲染watcher:

用户 (user) watcher

  • 也就是用户自己定义的, 如:
new Vue({
  data {
    msg: 'hello Vue!'
  }
  created() {
    this.$watch('msg', cb())  // 定义用户watcher
  },
  watch: {
    msg() {...}  // 定义用户watcher
  }
})

这里的两种方式内部都是使用Watcher 这个类实例化的, 只是参数不同, 具体实现我们之后章节说明, 这里大家只是知道这个是用户watcher即可。

计算 (computed) watcher

  • 顾名思义, 这个是当定义计算属性实例化出来的一种:
new Vue({
  data: {
    msg: 'hello'  
  },
  computed() {
    sayHi() {  // 计算watcher
      return this.msg + 'vue!'
    }
  }
})

渲染属性 (render) watcher

  • 只是用做视图渲染而定义的Watcher 实例, 再组件实行vm.$mount 的最后会实例化Watcher 类, 这个时候就是以渲染watcher 的格式定义的, 收集的就是当前渲染watcher 的实例, 我们来看下它内部如何定义的:
class Watcher {
  constructor(vm, expOrFn, cb, options, isRenderWatcher) {
    this.vm = vm
    if(isRenderWatcher) {  // 是否是渲染watcher
      vm._watcher = this  // 当前组件下挂载vm._watcher属性
    }
    vm._watchers.push(this)  //vm._watchers是之前初始化initState时定义的[]
    this.before = options.before  // 渲染watcher特有属性
    this.getter = expOrFn  // 第二个参数
    this.get()  // 实例化就会执行this.get()方法
  }
  
  get() {
    pushTarget(this)  // 添加
    ...
    this.getter.call(this.vm, this.vm)  // 执行vm._update(vm._render())
    ...
    popTarget()  // 移除
  }
  
  addDep(dep) {
    ...
    dep.addSub(this)  // 将当前watcher收集到dep实例中
  }
}

当执行new Watcher 的时候内部会挂载一些属性, 然后执行this.get()这个方法, 首先会执行一个全局的方法pushTarget(this) , 传入当前watcher 的实例, 我们看下这个方法定义的地方:

Dep.target = null
const targetStack = []  // 组件从父到子对应的watcher实例集合

export function pushTarget (_target) {  // 添加
  if (Dep.target) {
    targetStack.push(Dep.target)  // 添加到集合内
  }
  Dep.target = _target  // 当前的watcher实例
}

export function popTarget() {  // 移除
  targetStack.pop()  // 移除数组最后一项
  Dep.target = targetStack[targetStack.length - 1]  // 赋值为数组最后一项
}

首先会定义一个Dep 类的静态属性Dep.targetnull, 这是一个全局会用到的属性, 保存的是当前组件对应渲染watcher 的实例; targetStack 内存储的是再执行组件化的过程中每个组件对应的渲染watcher实例集合, 使用的是一个先进后出的形式来管理数组的数据, 这里可能有点不太好懂, 稍等再看到最后的流程图后自然就明白了;然后传入的watcher实例赋值给全局属性Dep.target , 再之后的依赖收集过程中就是收集的它。

watcherget 这个方法然后会执行getter 这个方法, 它是new Watcher 时传入的第二个参数, 这个参数就是之前的updateComponent 变量:

function mountComponent(vm, el) {
  ...
  const updateComponent = function() {  //第二个参数
    vm._update(vm._render())
  }
  ...
}

只要一执行就会执行当前组件实例上的vm._update(vm.render())render 函数转为VNode, 这个时候如果render 函数内有使用到data 中已经转为了响应式的数据,就会触发get方法进行依赖收集, 补全之前依赖收集的逻辑:

export function defineReactive(obj, key, val) {
  const dep = new Dep()  // 依赖管理器
  
  val = obj[key]  // 计算出对应key的值
  observe(val)  // 递归的转化对象的嵌套属性
  
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get() {  // 触发依赖收集
      if(Dep.target) {  // 之前赋值的当前watcher实例
        dep.depend()  // 收集起来,放入到上面的dep依赖管理器内
        ...
      }
      return val
    },
    set(newVal) {
      ... 派发更新
    }
  })
}

这个时候我们知道watcher 是个什么东西了, 简单理解就是数据和组件之间一个通信工具的封装, 当某个数据被组件读取时, 就将依赖数据的组件使用Dep 这个类给收集起来。

当前例子data 内的属性是只有一个渲染watcher 的, 因为没有被其它组件所使用。 但如果该属性被其它组件使用到,又会将使用它的组件收集起来。 例如作为了props传递给子组件, 再dep的数组内就会存在多个渲染watcher。 我们来看下Dep类这个依赖管理器的定义:

let uid = 0
export default class Dep {
  constructor() {
    this.id = uid++
    this.subs = []  // 对象某个key的依赖集合
  }
  
  addSub(sub) {  // 添加watcher实例到数组内
    this.subs.push(sub)
  }
  
  depend() {
    if(Dep.target) {  // 已经被赋值为了watcher的实例
      Dep.target.addDep(this)  // 执行watcher的addDep方法
    }
  }
}

----------------------------------------------------------
class Watcher{
  ...
  addDep(dep) {  // 将当前watcher实例添加到dep内
    ...
    dep.addSub(this)  // 执行dep的addSub方法
  }
}

这个Dep 类的作用就是管理属性对应watcher, 如添加/删除/通知。 至此, 依赖收集的过程就算是完成了, 还是以一张图片加深对过程的理解:
在这里插入图片描述

派发更新

如果只是收集依赖, 那其实是没有任何意义的, 将收集到的依赖在数据发生变化时通知并引起视图变化, 这样才有意义。 现在我们对数据重新赋值:

app.vue
export default {  // app组件
  ...
  methods: {
    changeInfo() {
      this.info.name = 'ww';
    }
  }
}

这个时候就会触发创建响应式数据时的set方法了, 我们再补全那里的逻辑:

export function defineReactive(obj, key, val) {
  const dep = new Dep()  // 依赖管理器
  
  val = obj[key]  // 计算出对应key的值
  observe(val)  // 递归转化对象的嵌套属性
  
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get() {
      ... 依赖收集
    },
    set(newVal) {  // 派发更新
      if(newVal === val) {  // 相同
        return
      }
      val = newVal  // 赋值
      observer(newVal)  // 如果新值是对象也递归包装
      dep.notify()  // 通知更新
    }
  })
}

当赋值触发set 时, 首先会检测新值和旧值, 不能相同; 然后将新值赋值给旧值; 如果新值是对象则将它变成响应式的; 最后让对应属性的依赖管理器使用dep.notify发出更新视图的通知。来看下它的实现:

let uid = 0
class Dep{
  constructor() {
    this.id = uid++
    this.subs = []
  }
  
  notify() {  // 通知
    const subs = this.subs.slice()
    for(let i = 0, i < subs.length; i++) {
      subs[i].update()  // 挨个触发watcher的update方法
    }
  }
}

这里做的事情只有一件, 将收集起来的watcher 挨个遍历触发update方法:

class Watcher{
  ...
  update() {
    queueWatcher(this)
  }
}

---------------------------------------------------------
const queue = []
let has = {}

function queueWatcher(watcher) {
  const id = watcher.id
  if(has[id] == null) {  // 如果某个watcher没有被推入队列
    ...
    has[id] = true  // 已经推入
    queue.push(watcher)  // 推入到队列
  }
  ...
  nextTick(flushSchedulerQueue)  // 下一个tick更新
}

执行update 方法时将当前watcher 实例传入到定义的queueWatcher 方法内, 这个方法的作用是把将要执行更新的watcher收集到一个队列queue之内,保证如果同一个watcher 内触发了多次更新, 只会更新一次对应的watcher ,我们举两个小实例:

export default {
  data() {
    return {  // 都被模板引用了
      num: 0,
      name: 'cc',
      sex: 'man'
    }
  },
  methods: {
    changeNum() {  // 赋值100次
      for(let i = 0; i < 100; i++) {
        this.num++
      }
    },
    changeInfo() {  // 一次赋值多个属性的值
      this.name = 'ww'
      this.sex = 'woman'
    }
  }
}

这里的三个响应式属性他们收集都是同一个渲染watcher。 所以当赋值100次的情况出现时, 再将当前的渲染watcher 推入到的队列之后, 之后赋值触发的set队列内并不会添加任何渲染watcher; 当同时赋值多个属性时也是, 因为他们收集的都是同一个渲染watcher , 所以推入到队列一次之后就不会添加了。

知识点: vue 还是很聪明的, 通过上面实例大家应该看出来, 派发更新通知的粒度是组件级别, 至于组件内是哪个属性赋值了,派发更新并不关心, 而且怎么高效更新这个视图, 那是之后diff对比做的事情。

队列有了, 执行nextTick(flushSchedulerQueue) 再下一次tick时更新它, 这里的nextTick 就是我们经常使用的 this.$nextTick 方法的原始方法, 它们作用一致,实现原理之后章节说明。 看下参数flushSchedulerQueue是啥?

let index = 0

function flushSchedulerQueue() {
  let watcher, id
  queue.sort((a, b) => a.id - b.id)  // watcher 排序
  
  for(index = 0; index < queue.length; index++) {  // 遍历队列
    watcher = queue[index]  
    if(watcher.before) {  // 渲染watcher独有属性
      watcher.before()  // 触发 beforeUpdate 钩子
    }
    id = watcher.id
    has[id] = null
    watcher.run()  // 真正的更新方法
    ...
  }
}

原来是个函数, 再nextTick方法的内部会执行第一个参数。 首先会将queue这个队列进行一次排序,依次是每次new Watcher 生成的 id, 以从小到大的顺序。 当前示例只是做渲染, 而且队列内只存在了一个渲染watcher, 所以是不存在顺序的。 但是如果有定义user watchercomputed watcher 加上 render watcher 后 , 它们之间就会存在一个执行顺序的问题了。

知识点: watcher 的执行顺序是先父后子, 然后是从computed watcheruser watcher 最后 render watcher , 这从它们的初始化顺序就能看出来。

然后就是遍历这个队列, 因为是渲染watcher, 所有是有before 属性的, 执行传入的before方法触发beforeUpdate 钩子。 最后执行watcher.run()方法, 执行真正的派发更新方法。 我们看下run干了啥:

class Watcher {
  ...
  run () {  
    if (this.active) {
      this.getAndInvoke(this.cb) // 有一种要抓狂的感觉
    }
  }
  
  getAndInvoke(cb) {  // 渲染watcher的cb为noop空函数
    const value = this.get()
    
    ... 后面是用户watcher逻辑
  }
}

执行run 就是执行getAndInvoke方法, 因为是渲染watcher, 参数cbnoop空函数。 看了这么多, 其实… 就是重新执行一次 this.get()方法, 让 vm._update(vm._render())在走一遍而已。 然后生成新旧VNode , 最后进行diff比对以更新视图。

最后说下vue 基于Object.defineProperty响应式系统的一些不足。 比如:只能监听到数据的变化, 所以有时data中要是定义一堆的初始值, 因为加入了响应式系统后才能被感知到; 还有就是常规JavaScript操作对象的方式, 并不能监听到增加以及删除。如:

export default {
  data() {
    return {
      info: {
        name: 'cc'
      }
    }
  },
  methods: {
    addInfo() {  // 增加属性
      this.info.sex = 'man'
    },
    delInfo() {  // 删除属性
      delete info.name
    }
  }
}

数据是被赋值了, 但是视图并不会发生变更。 vue为了解决这个问题,提供了两个API: $set 和 $delete, 它们又是怎么办到的? 原理我们之后章节分享。

最后我们以一个问题结束本章内容:

  • 当前组件模板中用到的变量一定要定义在data里么?

解答:

  • data 中的变量都会被代理到this下, 所以我们也可以在this下挂载属性, 只要不重名即可。 而且定义在data中的变量在vue的内部会将它包装成响应式的数据, 让它拥有变更即可驱动视图变化的能力。 但是如果这个数据不需要驱动视图, 定义在created 或者 mounted 钩子内也是可以的, 因为不会执行响应式的包装方法,对性能也是一种提升。

下一篇: Vue原理解析(七): 理解响应式原理(下)-数组

  • 3
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值