【源码解析】前端面试必问的响应式的原理,其实也没那么难

847 阅读3分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第13天,点击查看活动详情

前言

大家好,上篇文章watch的原理中我们分享了vue中侦听器的使用方法,使用场景以及实现原理,到这里关于Vue中一些常用的全局API的原理基本就分享完了。今天我们再来学习一下Vue2中数据响应式的实现原理,其实关于数据响应式我们再分享MVVM原理时已经涉及到一部分了,今天我们再来从头到尾详细分析一下。

initState

在我们通过new Vue去创建vue实例时,在Vue的内部首先会调用一个_init函数,而在这个_init函数中会做一堆的init操作,比如initLefecycle、initEvents、initRender、initInjections、initState、initProvide。其中initState就是我们要找的数据响应式的入口,先来看下initState的源码

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);
  }
}

可以看到在该方法中也是一堆init操作,对应的就是vue中的props、methods、data、computed和watch,其中initComputed和initWatch在前两篇分享中已经分析过了,本次我们以data为例分析一下响应式数据的原理。

initData

function initData(vm: Component) {
  let data: any = vm.$options.data;
  data = vm._data = typeof data === "function" ? getData(data, vm) : data || {};
  if (!isPlainObject(data)) {
    data = {};
    ...
  }
  // proxy data on instance
  const keys = Object.keys(data);
  const props = vm.$options.props;
  const methods = vm.$options.methods;
  let i = keys.length;
  while (i--) {
    ...
  }
  // observe data
  observe(data, true /* asRootData */);
}

以上是initData函数的核心代码,其中省去了一些信息提示代码。

  • 首先在vue实例的$options上拿到data
  • 然后检测data是否是一个函数类型(data可以是一个对象也可以是一个函数),如果是函数则调用getData函数让data函数执行并将返回结果(一个对象)重新赋值给data
  • 继续检测data是不是一个纯对象,如果不是纯对象则将data赋为空对象,并给出错误提示信息
  • 接着利用Object.keys获取到data中所有的属性,并在vue实例的$options中拿到props和methods,目的是为了检测data中的属性是否已经在props或methods中被定义
  • 下面就是利用wihile循环遍历data中所有的属性,并检测该属性是否在props或methods中已经存在,如果存在则给出错误提示,因为这三者中的属性名是不能重复的
  • 最后如果检测都没问题则调用observe函数对data进行数据劫持

observe

//源码位置:src/core/observer/index.js  42行
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函数比较简单,其核心目的就是利用data创建一个Oberver的实例。

  • 在该方法中首先要确保data是一个纯对象,并且不是虚拟DOM的实例
  • 然后判断data中是否存在__ob__属性,并且这个属性是Observer的实例,则证明已经为data对象创建过Observer实例了(也就是说已经被劫持过了)则直接将data中的__ob__赋值给ob变量
  • 否则就直接new一个Observer,并且把data作为参数传递进去

Observer

//源码位置:src/core/observer/index.js  135行
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])
    }
  }

在创建Observer(new Observer)实例时会执行到Observer的构造函数,在函数中主要做了两件事:就是分别对数组和对象的属性进行数据劫持

  • 首先这个参数value就是我们外面传进来的data
  • 紧接着创建了一个Dep实例,这个Dep实际时一个发布订阅,在new Dep时会给dep实例添加一个subs数组,该数组用于存放所有属性的watchers
  • 然后判断data是数组还是对象,如果是数组调用observeArray方法对data进行劫持,如果data是一个对象则带调用walk方法
  • 在walk方法中主要就是遍历data中所有的属性,为每个属性调用defineReactive进行数据劫持

defeinReactive

下面我们还是走对象这条线,从walk函数走起分析一下defeinReactive的代码,这个方法也是响应式的核心所在。

//源码位置:src/core/observer/index.js  135行
export function defineReactive(obj: object, key: string,val?: any,customSetter?: Function | null, shallow?: boolean
) {
  const dep = new Dep()
  ...
  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
      ...
      if (setter) {
        setter.call(obj, newVal)
      } else {
        val = newVal
      }
      childOb = !shallow && observe(newVal)
      dep.notify()
    },
  })
}

//源码位置:src/core/observer/dep.js
addSub(sub: Watcher) {
    this.subs.push(sub)
}
depend() {
    if (Dep.target) {
      Dep.target.addDep(this)
    }
}
notify() {
    // stabilize the subscriber list first
    const subs = this.subs.slice()
    ...
    for (let i = 0, l = subs.length; i < l; i++) {
      subs[i].update()
    }
}

上面是defineReactive函数节选的一部分核心代码:

  • 首先在函数体内会创建一个Dep实例,关于Dep我们在前面几篇的分享中也有提到过。在Vue中每个响应式属性都会有一个dep属性,它的主要作用就是利用发布订阅模式来管理每个属性的watcher
  • 接着下面一句Object.defineProperty也就是实现响应式的关键了,这里主要做了3件事:
    • 首先就是把key属性添加到obj对象上
    • 然后对该属性的get和set进行拦截
    • 在get中会调用dep.depend函数进行依赖收集
  • get函数中用到了个Dep.target,实际上这个target是Dep的一个静态属性,其值是一个watcher的实例,会通过两个函数pushTargetpopTarget来设置和移除。也就是说当涉及到该key属性读取时就会触发这了的get函数,在该函数中调用dep.depend()进行依赖收集,依赖收集后又将Dep.target设置为null,避免重复收集。
    • dep.depend()函数中调用了watcher中的addDep函数,而在addDep函数中又通过dep实例调用了dep中的addSub函数,绕来绕去其最终目的就是给dep的subs事件池中添加一个watcher实例。总结来说就是只要有用到key属性的地方,都会相应的添加一个watcher实例(利用Dep来管理),主要目的就是将来当该key值有更新的时候,通过dep来通过各个watchers进行同步更新,从而达到响应式的目的
  • set函数中除了给属性设置新值外,还有一句实现响应式的核心代码,就是dep.notify()。在上面我们刚刚说过当属性key值发生变化时就会通过dep来通过各个watcher进行更新。dep.notify就是这个通知操作,在该函数中主要就是循环dep的subs事件池然后调用update方法更新,其中subs中保存的就是所有的watcher实例,最终调用的其实就是watcher实例上的update方法

总结

本次分享我们主要梳理了数据响应式的原理,简单总结如下:

通过Observe进行数据劫持的时候给每个被劫持的属性都添加了一个dep实例(new Dep),在dep实例中有个数组类型的subs属性,在这个数组中存储的都是使用当前属性时创造的Watcher的实例。

当对应的属性被访问时就会触发对应的get函数,在get函数中通过dep.depend进行依赖收集,同时会创建一个观察者Watcher实例,实例创建完成后就会触发watcher的addDep方法,从而就会调用dep实例的addSub方法,最终将当期watcher实例添加的dep的subs数组中

当被劫持的那个属性数据更新时,就会触发这个属性的set函数,set在执行的时候会触发dep实例的notify方法,notify执行会遍历dep的subs数组,让数组中的每个watcher实例执行update方法,从而更新视图