Vue 深入浅出 - 响应式原理

955 阅读4分钟

思考

带着问题看源码:

  1. vue 是如何实现响应式的?
  2. vue 是在哪个环节进行依赖收集的?
  3. vue 是在哪个环节完成 vnode => dom 的?
  4. data 改变之后是如何更新试图的?

核心概念

掏一张官方图片:

每个组件实例都对应一个 watcher 实例,它会在组件渲染的过程中把“接触”过的数据 property 记录为依赖。之后当依赖项的 setter 触发时,会通知 watcher,从而使它关联的组件重新渲染。

image 这里主要搞清几个概念:

  • observe:通过 new Observe(obj) 把一个对象变成响应式(主要使用defineReactive方法处理对象的属性)
  • Dep:每个响应式数据都会对应一个 dep(Dep实例),用来存放依赖该值的订阅者(Watcher)
  • Watcher:通过调用 Watcher.update 方法触发更新

这里有个关于 Dep.target 的点弄了好久才明白,其实 target 是一个 Dep 类的静态变量,每一个 dep 实例都能访问到,代表的是当前进行依赖收集的 Watcher

简单梳理一下整个过程:

  1. new Vue 之后拿到 option 里面的 data,执行 initData 方法
  2. 使用 observe data 变成响应式,通过 walk 方法对 data 的每一个属性使用 defineReactive 进行处理
  3. defineReactive 的过程中,对每一个属性闭包生成一个 Dep 实例,使用 defineProperty 劫持 getset
    • get 的时候加入依赖收集操作 dep.depend()
    • set 的时候执行依赖分发操作 dep.notify()
  4. 执行vm.$mount(核心是执行mountComponent)方法对组件进行挂载
  5. 生成一个 renderWatcher 实例
  6. 执行 vm._render() 方法得到一个Vnode,在这一步由于触发了响应式数据的 get,因此会进行依赖收集
  7. 将得到的Vnode作为参数,调用Vm._update 方法
  8. 进行patch操作,渲染页面
  9. 页面对响应式数据做出修改,触发 set 内部 dep.notify 进行更新

相关源码

跟着源码来理解上面的每个过程大致实现,只保留核心代码
首先看一下 _init 的整个流程

  Vue.prototype._init = function (options?: Object) {
    const vm: Component = this
    // a uid
    vm._uid = uid++

    /*一个防止vm实例自身被观察的标志位*/
    vm._isVue = true
    // merge options
    if (options && options._isComponent) {
    // ...
    } else {
    	/* 合并 options */
      vm.$options = mergeOptions(
        resolveConstructorOptions(vm.constructor),
        options || {},
        vm
      )
    }
    
    vm._self = vm
    /*初始化生命周期*/
    initLifecycle(vm)
    /*初始化事件*/
    initEvents(vm)
    /*初始化render*/
    initRender(vm)
    /*触发beforeCreate钩子事件*/
    callHook(vm, 'beforeCreate')
    initInjections(vm)
    /*初始化props、methods、data、computed与watch*/
    initState(vm)
    initProvide(vm)
    /*触发created钩子事件*/
    callHook(vm, 'created')
		/* 初始化数据完成之后挂载 vm */
    if (vm.$options.el) {
      /*挂载组件*/
      vm.$mount(vm.$options.el)
    }
  }

主要是对数据进行一系列初始化处理, 同时触发 vue 生命周期内的钩子函数

由于 beforeCreate 之前还没有对 data 进行初始化, 这就是为什么 beforeCreate 函数内无法访问 data 的原因

initData (对数据进行响应式处理)

执行initData的时机: new Vue => _init => initState => initData => walk => defineReactive(核心)

core/instance/state.js
function initData (vm: Component) {
 /*得到data数据, 如果是一个函数,就执行获得返回值*/
 let data = vm.$options.data
 data = vm._data = typeof data === 'function'
  ? getData(data, vm)
  : data || {}
 /* 核心就是执行observe方法*/
 observe(data, true /* asRootData */)
}

observe方法通过new Observedata变成响应式,核心是使用defineReactive,直接看Observe源码:

export class Observer {
  value: any
  dep: Dep
  vmCount: number

  constructor(value: any) {
    this.value = value
    this.dep = new Dep()
    this.vmCount = 0
    /* 将Ob server实例绑定到data的__ob__属性上面去,observe的时候会先检测是否已经有__ob__对象存放Observer实例 */
    def(value, "__ob__", this)
    if (Array.isArray(value)) {
      /*
          如果是数组,将修改后可以截获响应的数组方法替换掉该数组的原型中的原生方法,达到监听数组数据变化响应的效果。
          如果浏览器支持__proto__属性,则直接覆盖当前数组对象原型上的原生数组方法,否则直接覆盖数组对象的原型。
      */
      const augment = hasProto
        ? protoAugment /*直接覆盖原型的方法来修改目标对象*/
        : copyAugment /*定义(覆盖)目标对象或数组的某一个方法*/
      augment(value, arrayMethods, arrayKeys)

      /*遍历数组的每一个成员进行observe*/
      this.observeArray(value)
    } else {
      /*如果是对象则直接walk进行绑定*/
      this.walk(value)
    }
  }

  /* 遍历每对象并且在它们上面绑定getter与setter。这个方法只有在value的类型是对象的时候才能被调用 */
  walk(obj: Object) {
    const keys = Object.keys(obj)
    /*walk方法会遍历对象的每一个属性进行defineReactive绑定*/
    for (let i = 0; i < keys.length; i++) {
      defineReactive(obj, keys[i], obj[keys[i]])
    }
  }

  /*对数组的每一个成员进行observe*/
  observeArray(items: Array<any>) {
    for (let i = 0, l = items.length; i < l; i++) {
      /*数组需要遍历每一个成员进行observe*/
      observe(items[i])
    }
  }
}

defineReactive方法中,通过闭包生成一个Dep实例,在defineProperty重写的getset方法中使用,进行依赖收集以及分发

export function defineReactive (
 obj: Object,
 key: string,
 val: any,
 customSetter?: Function
) {
 /*在闭包中定义一个dep对象*/
 const dep = new Dep()

 /*如果之前该对象已经预设了getter以及setter将其执行,保证不会覆盖之前已经定义的getter/setter。*/
 const getter = property && property.get
 const setter = property && property.set
 
 /*对象的子对象递归进行observe并返回子节点的Observer对象*/
 let childOb = observe(val)
 Object.defineProperty(obj, key, {
  enumerable: true,
  configurable: true,
  get: function reactiveGetter () {
   /*如果原本对象拥有getter方法则执行*/
   const value = getter ? getter.call(obj) : val
/* Dep.target 就是当前进行依赖收集的Watcher */
   if (Dep.target) {
    /*进行依赖收集*/
    dep.depend()
    if (childOb) {
     /*子对象进行依赖收集,其实是同一个watcher*/
     childOb.dep.depend()
    }
    if (Array.isArray(value)) {
     /*是数组则需要对每一个成员都进行依赖收集,如果数组的成员还是数组,则递归。*/
     dependArray(value)
    }
   }
   return value
  },
  set: function reactiveSetter (newVal) {
   /* 如果值没有发生变化不进行操作*/
   const value = getter ? getter.call(obj) : val
   if (newVal === value || (newVal !== newVal && value !== value)) {
    return
   }
   if (setter) {
    /*如果原本对象拥有setter方法则执行setter*/
    setter.call(obj, newVal)
   } else {
    val = newVal
   }
   /*新的值需要重新进行observe,保证数据响应式*/
   childOb = observe(newVal)
   /*dep对象通知所有的观察者*/
   dep.notify()
  }
 })
}

到这一步完成了数据的响应式, 也得到了我们第一个问题的答案 核心就是使用 defineProperty 劫持 data 每个属性的 get 和 set, 在 get 中进行依赖收集, 在 set 中进行依赖分发

Vm.$mount

可以看到 _init 方法内部,当对数据进行初始化完毕之后, 会调用vm.$mount(vm.$option.el) 因为 Vue 可以跨平台使用, $mount 会根据不同的平台进行不一样的处理, 这里只考虑 web 平台下

/platforms/web/runtime/index.js
Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  /*获取DOM实例对象*/
  el = el && inBrowser ? query(el) : undefined
  /*挂载组件*/
  return mountComponent(this, el, hydrating)
}

核心是使用 mountComponent 方法
这一步主要做了两件事, 收集依赖以及 渲染真实 dom

  1. 触发beforeMount钩子
  2. vm._render() 获取 vnode, 同时会执行一个匿名函数, 在这一步会触发响应式数据的 get, 完成依赖收集
  3. vm._update将获取的 vnode 进行 __patch__, 渲染真实 dom
  4. 触发mounted钩子
export function mountComponent (
  vm: Component,
  el: ?Element,
  hydrating?: boolean
): Component {
  vm.$el = el
  if (!vm.$options.render) {
    /*render函数不存在的时候创建一个空的VNode节点*/
    vm.$options.render = createEmptyVNode
    if (process.env.NODE_ENV !== 'production') {
      // ...
    }
  }
  /*触发beforeMount钩子*/
  callHook(vm, 'beforeMount')

  /*updateComponent作为Watcher对象的getter函数,用来依赖收集*/
  let updateComponent
  if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
    // ...
  } else {
    /* 更新以及初次渲染都会通过该方法 */
    updateComponent = () => {
      // 更新view视图, 内部会调用__patch__ 进行 diff 算法,将 vnode 渲染为真实 DOM
      vm._update(vm._render(), hydrating)
    }
  }
  /*
    这里对该vm注册一个rednerWatcher,Watcher 用 expOrFn 接受传递过来的函数 updateComponent, Watcher 内部会将 expOrFn 解析成 getter, 因此在 get 的时候会触发用于触发传递过来的函数 updateComponent
    updateComponent 会触发 vm._render(),这一步会对模板进行解析,并执行一个匿名函数
  */
  vm._watcher = new Watcher(vm, updateComponent, noop, null, true) 
  hydrating = false

  if (vm.$vnode == null) {
    /*标志位,代表该组件已经挂载*/
    vm._isMounted = true
    /*触发mounted钩子*/
    callHook(vm, 'mounted')
  }
  return vm
}

Watcher 内部核心代码:

  1. WatcherexpOrFn 接受传递过来的函数 updateComponent, 并将 expOrFn 解析成 getter
  2. Watcher 被 get 的时候会执行 updateComponent
 /*把表达式expOrFn解析成getter*/
if (typeof expOrFn === 'function') {
  this.getter = expOrFn
}
get () {
    /*将自身watcher观察者实例设置给Dep.target,用以依赖收集。*/
    pushTarget(this)
    let value
    const vm = this.vm

    if (this.user) {
     // ...
    } else {
    	/* 主要是这一步, getter 被 */
      value = this.getter.call(vm, vm)
    }

    /*将观察者实例从target栈中取出并设置给Dep.target*/
    popTarget()
    this.cleanupDeps()
    return value
  }

updateComponent 是依赖收集以及刷新页面关键的一步:

  1. 给当前vm注册一个 renderWatcher, 并传入 updateComponent函数
  2. updateComponent 会在 Watcherget 中调用, 即该 watcher 被访问的时候
  3. vm._render() 内部会实现模板的解析, 执行一个匿名函数, 该方法返回一个 vnode. 在这一步因为对 data 内部属性进行了访问, 因此会触发之前定义在definePropertyget 的方法: dep.depend, 进行依赖收集
    vm._render 的匿名函数
(function anonymous() {
    with (this) {
        return _c('div', {
            attrs: {
                "id": "app"
            }
        }, [_v("\n\t\t\t我是一个" + _s(data.msg) + "\n\t\t\t"), _c('button', {
            on: {
                "click": handleChange
            }
        }, [_v("click")])])
    }
})

其中 _ccreateElement 的缩写, 用来创建vnode, _s 用来将数据转为字符串

依赖收集逻辑如下:
1. new Watcher 的同时, 触发内部 get 方法, 调用 pushTarget 将自身设置给 Dep 内静态变量 target, 用于依赖收集
2. 模板解析过程中, 会访问 data 内部的数据, 触发 get 内部 dep.depend 方法, 进行依赖收集, 此时 Dep.target 为当前 Watcher 实例 3. vm._update 用来进行视图更新, 内部会调用__patch__ 方法将 vnode 转换为真实 dom

执行到这里已经完成了数据的响应式处理, 依赖收集, 以及页面渲染, 生命周期也到了 mount 这一步, 接下来是数据修改之后如何更新页面的逻辑

Watcher.update(更新视图)

当对响应式数据进行修改之后, 会触发 set 内部的依赖分发功能 dep.notify, 该方法内部其实就是遍历整个依赖列表subs, 里面保存的是依赖当前数据的Watcher, 触发他们的 update 方法

  /*通知所有订阅者*/
  notify () {
    const subs = this.subs.slice()
    for (let i = 0, l = subs.length; i < l; i++) {
      subs[i].update()
    }
  }

Watcher.updateWatch内部定义的方法, 这里就长话短说(不考虑异步更新优化策略), 其实就是调用 Watcher.run()方法, 然后触发 Watchergetter, 上面说过 Watcherget 的时候会触发传递过来的函数 updateComponent, 因此又回到了之前的逻辑

  run () {
    if (this.active) {
      /* get操作在获取value本身也会执行getter从而调用update更新视图 */
      const value = this.get()
  }

dep.notify => Watcher.update => Watcher.run => watcher.getter => updateComponent => vm._render(得到vnode并拿到修改后的数据) => vm._update进行 patch更新视图
由于对已经进行过收集的依赖会做缓存, 因此触发响应式数据的 get 的时候并不会重新对依赖进行收集

  addDep (dep: Dep) {
    const id = dep.id
    if (!this.newDepIds.has(id)) {
      this.newDepIds.add(id)
      this.newDeps.push(dep)
      if (!this.depIds.has(id)) {
        dep.addSub(this)
      }
    }
  }

总结

至此已经得到了所有问题的答案

  1. vue 是如何实现响应式的
    • 核心是通过 defineProperty 劫持对象的 get 以及 set
    • get 中收集依赖, 在 set中分发依赖
  2. vue 是在哪个环节进行依赖收集的
    • 执行 vm.$mount的时候, 会执行 updateComponent 方法
    • 创建一个 renderWatcher, 并将 this.getter 设为 updateComponent
    • vm._render 实现模板的解析, 并得到 vnode, 在这一步触发响应式数据的 get, 触发依赖收集
  3. vue 是在哪个环节完成 vnode => dom 的
    • vm.update 方法会进行 patch, 将vnode渲染为真实 dom
  4. data 改变之后是如何更新试图的
    • 当触发 set 之后, 执行dep.notify,接着会触发watcher.run
    • watcher.run会触发watcher内部的 get 方法从而执行 this.getter.call(vm, vm)
    • this.getter 其实就是创建实例时传递过来的updateComponent方法, 因此会重新触发之前的渲染逻辑,从而更新试图