Vue2 源码系列之响应式原理

282 阅读5分钟

Vue 最独特的特性之一,是其非侵入性的响应式系统。数据模型仅仅是普通的 JavaScript 对象。而当你修改它们时,视图会进行更新。这使得状态管理非常简单直接,不过理解其工作原理同样重要,这样你可以避开一些常见的问题。 ——vue.js

众所周知,vue 2.x是通过Object.defineProperty来实现数据响应式的。那vue中具体是如何实现的呢?数据初始化的流程又是什么样的呢?还有数组又是如何实现响应式的呢?让我们从vue的初始化一步步分析,看看这其中到底经历了什么。

vue的初始化

构造函数

源码路径:\src\core\instance\index.js

function Vue (options) {
  if (process.env.NODE_ENV !== 'production' &&
    !(this instanceof Vue)
  ) {
    warn('Vue is a constructor and should be called with the `new` keyword')
  }
  this._init(options)
}

initMixin(Vue)
stateMixin(Vue)
eventsMixin(Vue)
lifecycleMixin(Vue)
renderMixin(Vue)

export default Vue

上述代码为vue的构造函数,我们可以看到当我们new Vue()的时候会执行_init方法,该方法是通过initMixin方法混入进去的,我们进入到initMixin方法。

initMixin

源码路径:\src\core\instance\init.js

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

    let startTag, endTag
    /* istanbul ignore if */
    if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
      startTag = `vue-perf-start:${vm._uid}`
      endTag = `vue-perf-end:${vm._uid}`
      mark(startTag)
    }

    // a flag to avoid this being observed
    vm._isVue = true
    // merge options
    if (options && options._isComponent) {
      initInternalComponent(vm, options)
    } else {
      vm.$options = mergeOptions(
        resolveConstructorOptions(vm.constructor),
        options || {},
        vm
      )
    }
    /* istanbul ignore else */
    if (process.env.NODE_ENV !== 'production') {
      initProxy(vm)
    } else {
      vm._renderProxy = vm
    }
    // expose real self
    vm._self = vm
    initLifecycle(vm) // 初始化生命周期
    initEvents(vm) // 初始化事件
    initRender(vm) // 插槽相关内容
    callHook(vm, 'beforeCreate') // 调用生命周期beforeCreate钩子
    initInjections(vm) // resolve injections before data/props
    initState(vm) // 初始化状态
    initProvide(vm) // resolve provide after data/props
    callHook(vm, 'created') // 调用生命周期created钩子

    /* istanbul ignore if */
    if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
      vm._name = formatComponentName(vm, false)
      mark(endTag)
      measure(`vue ${vm._name} init`, startTag, endTag)
    }

    if (vm.$options.el) {
      vm.$mount(vm.$options.el)
    }
  }
}

我们可以看到这个方法只在vue构造函数的原型上增加了_init方法,而_init方法中调用了各种初始化方法,并且也能看到在生命周期钩子beforeCreate和created之间初始化provide、inject和初始化状态,我们今天要探讨的是数据响应式,我们只看数据相关的initState()就行了。

initState

源码路径:\src\core\instance\state.js

export function initState (vm: Component) {
  vm._watchers = []
  const opts = vm.$options
  if (opts.props) initProps(vm, opts.props)
  if (opts.methods) initMethods(vm, opts.methods)
  if (opts.data) {
    initData(vm)
  } else {
    observe(vm._data = {}, true /* asRootData */)
  }
  if (opts.computed) initComputed(vm, opts.computed)
  if (opts.watch && opts.watch !== nativeWatch) {
    initWatch(vm, opts.watch)
  }
}

initState方法从实例上获取用户传入的options,然后细分对options中的每一项进行初始化,到这里我们也终于看见了今天的目标initData()。

initData

源码路径:src\core\instance\state.js
避免篇幅过长,这里我就不贴源码了,实际initData中只是判断了用户传入data的类型,如果类型是function则执行该函数得到object类型的data,否则直接返回data,然后调用observe函数传入data。这里就是响应式的部分了,我们接着往下看.

vue数据的响应式

observe

源码路径:src\core\observer\index.js

export function observe (value: any, asRootData: ?boolean): Observer | void {
  if (!isObject(value) || value instanceof VNode) {
    return
  }
  let ob: Observer | void
  if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
    ob = value.__ob__
  } else if (
    shouldObserve &&
    !isServerRendering() &&
    (Array.isArray(value) || isPlainObject(value)) &&
    Object.isExtensible(value) &&
    !value._isVue
  ) {
    ob = new Observer(value)
  }
  if (asRootData && ob) {
    ob.vmCount++
  }
  return ob
}

可以看到observe方法首先判断传入的value是否有__ob__属性(是否是响应式数据),是则取__ob__属性值,否则new Observer()并且传入了用户定义的data。vue中已经是响应式的数据会被添加上__ob__属性,此判断可避免重复监听。我们来看Observer类。

源码路径:src\core\observer\index.js

export class Observer {
  constructor (value: any) {
    this.value = value
    this.dep = new Dep()
    this.vmCount = 0
    def(value, '__ob__', this)
    if (Array.isArray(value)) {
      if (hasProto) {
        protoAugment(value, arrayMethods)
      } else {
        copyAugment(value, arrayMethods, arrayKeys)
      }
      this.observeArray(value)
    } else {
      this.walk(value)
    }
  }

  walk (obj: Object) {
    const keys = Object.keys(obj)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(obj, keys[i])
    }
  }

  observeArray (items: Array<any>) {
    for (let i = 0, l = items.length; i < l; i++) {
      observe(items[i])
    }
  }
}

可以看到Observer类给传入的value定义了一个__ob__的属性,值为当前实例。并且对传入的value做了判断如果是Array调用this.observerArray进行处理,是Object的话则调用this.walk进行处理,我们先来看Object的情况。

Object 的变化侦测

上面的walk方法获取了object的所有key进行遍历,并且调用defineReactive传入objec和key进行变化侦测,而且通过函数名我们也知道这是定义响应式的方法。

源码路径:src\core\observer\index.js

export function defineReactive (
  obj: Object,
  key: string,
  val: any,
  customSetter?: ?Function,
  shallow?: boolean
) {
  const dep = new Dep()

  const property = Object.getOwnPropertyDescriptor(obj, key)
  if (property && property.configurable === false) {
    return
  }

  // cater for pre-defined getter/setters
  const getter = property && property.get
  const setter = property && property.set
  if ((!getter || setter) && arguments.length === 2) {
    val = obj[key]
  }

  let childOb = !shallow && observe(val) // 递归遍历
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter () {
      const value = getter ? getter.call(obj) : val
      // 收集依赖
      if (Dep.target) {
        dep.depend()
        if (childOb) {
          childOb.dep.depend()
          if (Array.isArray(value)) {
            dependArray(value)
          }
        }
      }
      return value
    },
    set: function reactiveSetter (newVal) {
      const value = getter ? getter.call(obj) : val
      /* eslint-disable no-self-compare */
      if (newVal === value || (newVal !== newVal && value !== value)) {
        return
      }
      /* eslint-enable no-self-compare */
      if (process.env.NODE_ENV !== 'production' && customSetter) {
        customSetter()
      }
      // #7981: for accessor properties without setter
      if (getter && !setter) return
      if (setter) {
        setter.call(obj, newVal)
      } else {
        val = newVal
      }
      childOb = !shallow && observe(newVal)
      //触发依赖
      dep.notify()
    }
  })
}

Tips:如果还有同学不知道Object.defineProperty方法怎么用的建议先去这里看一下。

defineReactive方法利用了Object.defineProperty重新定义了get、set方法,并且在get中收集依赖、在set中触发依赖。通过get方法中的收集依赖逻辑可以看出:Dep.target存在则调用dep.depend方法收集依赖。而set方法中则调用dep.notify方法通知依赖更新。这样只能监听数据中的属性并不能监听子属性,因为data某个属性的值也可能是对象或数组,所以需要调用observe方法递归监听。这样我们就实现了对object的监听。我们再回过头来看数组是如何处理的。

Array的变化侦测

vue中数组并不是通过监听索引去实现变化侦测的,因为这样做当我们用list.push(1)向list数组中push一个1时,并不会触发get/set。而我们日常编程中会频繁使用Array原型上的方法去操作数组,所以object那种方法就不灵了。

也正因为如此,vue中重写了数组原型上的方法,当我们使用数组原型方法改变数组时,向依赖发送通知即可。

我们回过头来看Observe类的observerArray方法,只是遍历了传进来的数组,并且递归调用observe监听子项。而再调用observerArray方法之前则将value的原型指向了arrayMethods,我们来看下arrayMethods具体是怎么实现的。
源码路径:src\core\observer\array.js

const arrayProto = Array.prototype
export const arrayMethods = Object.create(arrayProto)

const methodsToPatch = ['push','pop','shift','unshift','splice','sort','reverse']

methodsToPatch.forEach(function (method) {
  // cache original method
  const original = arrayProto[method]
  def(arrayMethods, method, function mutator (...args) {
    const result = original.apply(this, args)
    const ob = this.__ob__
    let inserted
    switch (method) {
      case 'push':
      case 'unshift':
        inserted = args
        break
      case 'splice':
        inserted = args.slice(2)
        break
    }
    if (inserted) ob.observeArray(inserted)
    // notify change
    ob.dep.notify()
    return result
  })
})

首先保存了Array的原型,然后创建了arrayMethods的原型指向Array的原型,因为js中只有push、pop、shift、unshift、splice、sort、reverse这七个方法会改变原数组,所以只需要重写这七个方法就可以了。然后给arrayMethods添加这七个方法。在重写的方法里调用原来的方法对数组进行操作,然后手动通知依赖进行更新即可。因为push、unshift、splice可以给数组新增项,而新增项也可能是对象或数组所以调用observeArray方法对新增项进行变化侦测。

上面我们频繁提到依赖、依赖收集、通知依赖更新,那什么是依赖呢?依赖又收集到哪里去呢?我们接着往下看。

依赖收集到哪里?

vue中封装了一个Dep类,用来管理依赖,使用这个类我们可以添加依赖、删除依赖、向依赖发送通知等,也被称为依赖收集器。
源码路径:src\core\observer\dep.js

export default class Dep {
  constructor () {
    this.id = uid++
    this.subs = []
  }
  addSub (sub: Watcher) {
    this.subs.push(sub)
  }
  removeSub (sub: Watcher) { // 删除依赖
    remove(this.subs, sub)
  }
  depend () { // 添加依赖
    if (Dep.target) {
      Dep.target.addDep(this)
    }
  }
  notify () { // 通知依赖更新
    const subs = this.subs.slice()
    if (process.env.NODE_ENV !== 'production' && !config.async) {
      subs.sort((a, b) => a.id - b.id)
    }
    for (let i = 0, l = subs.length; i < l; i++) {
      subs[i].update()
    }
  }
}

看到这里你也就明白了,其实就是data的每一个key都有一个dep依赖收集器,依赖则收集在该dep的subs数组中。上面提到的收集依赖就是调用depend方法,但是我们通过上面代码发现貌似addSub方法才是将依赖添加到数组中的,这是怎么回事呢?因为dep和依赖是相互订阅的。depend方法中调用依赖的addDep方法将该dep保存到依赖中,而依赖的addDep方法中调用了dep.addSub将依赖保存到dep里。

那什么是依赖呢?这里的Dep.target其实就是依赖也是watcher,watcher分为渲染watcher和用户watcher,vue是组件级别的更新,就是因为一个组件对应一个渲染watcher,它控制着当前组件的更新,但是并不会控制子组件的更新。而用户watcher就是watch选项和$watch、计算属性也是通过watcher实现的,这些我们以后再说,先来看渲染相关的watcher。

依赖Watcher

为了突出重点,这里代码精简过。
源码路径:src\core\observer\watcher.js

export default class Watcher {
  constructor (
    vm: Component,
    expOrFn: string | Function,
    cb: Function,
    options?: ?Object,
    isRenderWatcher?: boolean
  ) {
    this.vm = vm
    if (isRenderWatcher) {
      vm._watcher = this
    }
    vm._watchers.push(this)
    // options
    if (options) {
      this.deep = !!options.deep
      this.user = !!options.user
      this.lazy = !!options.lazy
      this.sync = !!options.sync
      this.before = options.before
    } else {
      this.deep = this.user = this.lazy = this.sync = false
    }
    this.cb = cb
    this.id = ++uid // uid for batching
    this.active = true
    this.dirty = this.lazy // for lazy watchers
    this.deps = []
    this.newDeps = []
    this.depIds = new Set()
    this.newDepIds = new Set()
    this.expression = process.env.NODE_ENV !== 'production'
      ? expOrFn.toString()
      : ''
    // parse expression for getter
    if (typeof expOrFn === 'function') {
      this.getter = expOrFn
    } else {
      this.getter = parsePath(expOrFn)
    }
    this.value = this.lazy
      ? undefined
      : this.get()
  }

  get () {
    pushTarget(this)
    let value
    const vm = this.vm
    try {
      value = this.getter.call(vm, vm)
    } catch (e) {
      if (this.user) {
        handleError(e, vm, `getter for watcher "${this.expression}"`)
      } else {
        throw e
      }
    } finally {
      if (this.deep) {
        traverse(value)
      }
      popTarget()
      this.cleanupDeps()
    }
    return value
  }

  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)
      }
    }
  }
  
  update () {
    /* istanbul ignore else */
    if (this.lazy) {
      this.dirty = true
    } else if (this.sync) {
      this.run()
    } else {
      queueWatcher(this)
    }
  }
}

当组件渲染时会执行new Watcher(),此时会调用Watcher类中的get方法,我们重点关注一下这里,首先调用了pushTarget将this传过去,这个方法就是设置Dep.target = this(watcher实例),然后执行this.getter方法,其实这里就会获取数据,从而触发data的get方法此时Dep.target存在值,依赖就会被收集。然后调用popTarget方法,就是设置Dep.target = undefined。这里还是很有意思的,在组件渲染时初始化一个Watcher实例,也就完成了对应的收集依赖。

当数据改变时会触发set方法,而set方法中会调用该key对应的dep.notify通知watcher进行更新,通过notify方法,我们能看出就是循环subs取出其中的每一个watcher并调用其update方法。update方法将watcher推入一个队列进行异步更新。

写在最后

因为vue实现数据响应式的特点,也导致object、array有些操作是无法侦测到变化的。

object的问题
例如:我们使用obj.name='pdd'或者obj['name'] = 'pdd'向obj中新增一个属性,而vue.js无法侦测到变化,使用delete操作符删除obj一个属性时,vue.js同样无法侦测到变化。

array的问题
例如: 我们使用arr[0] = 2,即使用数组索引去改变数组某一项时,vue.js无法侦测到变化,使用arr.length = 0,去清空数组时也不能侦测到变化。

VUE3采用了ES6 Proxy重写了响应式部分,不存在以上问题。但是VUE2.X为了解决这些问题提供了两个api:Vue.set 、Vue.delete。但是对于数组使用以上两个方法只能通过索引去操作,通过length去清空数组还是无法侦测到变化的。后面会单独写一篇关于常用的全局api的实现原理。

以上就是VUE 2.X数据响应式的内容,希望小伙伴们能从本文中有所收获,三连等于学会(滑稽)。