[vue解析]当我们定义data的时候,vue是如何处理的

1,287 阅读7分钟

文章链接

vue解析:computed

vue解析:watch

前言

我们先来实现一个最简单的响应式对象,看看他应该具备什么功能

  1. 存在一个dep用来在触发get的时候收集依赖,在set的时候执行依赖
  2. 存在一个全局的target用来缓存依赖
  3. 通过递归对深层函数进行处理
const data = {
  a: 1,
  b: {
    c: 2
  }
}

function walk (data) {
  for (const key in data) {
    let dep = []
    let val = data[key]
    let nativeString = Object.prototype.toString.call(val)
    if (nativeString === '[object Object]') {
      walk(val)
    }
    Object.defineProperty(data, key, {
      get () {
        dep.push(target)
        return val
      },
      set (newval) {
        if (val === newval) return
        val = newval
        dep.forEach(fn => fn())
      }
    })
  }
}

walk(data)

let target = null

function $watch (exp, fn) {
  target = fn
  let patharr,
    obj = data
  if (typeof exp === 'function') {
    exp()
    return
  }
  if (/\./.test(exp)) {
    patharr = exp.split('.')
    patharr.forEach(p => {
      obj = obj[p]
    })
    return
  }
  data[exp]
}

function render () {
  return document.write(`${data.a}${data.b.c}`)
}

$watch('b.c', () => {
  console.log(`b.c变动了`)
})
$watch(render, render)

既然明白了最简单响应式对象的构成,那么就从一个最简单的对象例子开始

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <script src="../../dist/vue.js"></script>
</head>

<body>
  <div id="app">
    {{a}}
  </div>
  <script>
    let vm = new Vue({
      el: '#app',
      data: {
        a: 1,
      },
    })
  </script>
</body>

</html>

初始化 data

computed一样, data的初始化也在initState。但是不同的是,如果不存在data则会对空对象进行拦截,这是一个友好的优化,如果用户未定义data,又使用了a,就可以报出vue定义好的错误。

export function initState (vm: Component) {
  //这个数组将用来存储所有该组件实例的 watcher 对象
  vm._watchers = []
  const opts = vm.$options
  ...
  if (opts.data) {
    initData(vm)
  } else {
    // 不存在则观测空对象
    observe(vm._data = {}, true /* asRootData */)
  }
  ...
}

接下来直接看数据存在的情况,观察一下代码,在initData中,也主要做了三件事

  1. 通过getData拿到最终对象
  2. 对每一个key进行代理
  3. 调用observe
function initData (vm: Component) {
  let data = vm.$options.data
  data = vm._data = typeof data === 'function'
    ? getData(data, vm)
    : data || {}
  // proxy data on instance
  const keys = Object.keys(data)
  let i = keys.length
  while (i--) {
    const key = keys[i]
    ...// 删除错误提示代码
    proxy(vm, `_data`, key)
  }
  // observe data
  observe(data, true /* asRootData */)
}

这里我们要注意一下vm.$options的返回。它和初始化过程中的合并策略相关,但是现在我们只要断点在这里,然后看vm.$options返回什么就好。它返回一个函数,因此我们要执行以下,拿到对象

data: mergedInstanceDataFn()

但是我们发现,在使用getData进行数据获取的时候,仍旧进行了一次判断,这是因为,我们可能会在beforeCreate中修改vm.$options.data。所以才加了一层判断。

getData很简单,就是运行上面的函数,拿到最终的对象。

第二步主要是给当前实例做了一层代理,可以让我们使用this.xx。比如上面的例子就是当我们使用this.a的时候,就是触发了this._data.a,又因为data_data指向同一个地址,所以就能触发data的拦截器

关于第二个参数,其实和2.6新增的api observable有关,当使用它的时候,不会传入第二个参数

执行 observe

看下面代码,observe其实主要也就两件事情

  1. 判断 value 存不存在__ob__属性,如果存在直接返回
  2. 判断当前value是否符合,然后new Observer
export function observe (value: any, asRootData: ?boolean): Observer | void {
  // 如果观测对象不是 对象或者 vNode 直接return
  if (!isObject(value) || value instanceof VNode) {
    return
  }
  let ob: Observer | void
  // value自身是否有 __ob__ 并且 是 Observer 则已经是响应式数据
  if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
    ob = value.__ob__
  } else if (
    // 判断开关
    shouldObserve &&
    // 是否是服务端判断
    !isServerRendering() &&
    // 对象必须可扩展,一下几个方法会变为不可扩展 Object.freeze()  Object.preventExtensions() Object.seal()
    (Array.isArray(value) || isPlainObject(value)) &&
    Object.isExtensible(value) &&
    // 避免对vue实例对象进行观测
    !value._isVue
  ) {
    ob = new Observer(value)
  }
  if (asRootData && ob) {
    ob.vmCount++
  }
  return ob
}

那为什么要做第一步?这是用来避免重复观测一个数据对象,比如错误的递归调用。

new Observer

该方法,也是主要做了两件事情

  1. value添加了一个不可枚举的__ob__属性,值为Observer本身

根据例子,value变成这样。

{
  a: 1,
  __ob__: Observer {
    dep: Dep {id: 2, subs: []},
    value: {},
    vmCount: 0
  }
}
  1. 判断是数组还是对象,分别做响应式处理
export class Observer {
  constructor (value: any) {
    this.value = value
    // 实例化依赖框, 这个框不属于某个数据
    this.dep = new Dep()
    //依赖计数
    this.vmCount = 0
    // 创建一个不可枚举的 __ob__ 对象,该对象是 Observer本身
    def(value, '__ob__', this)
    // 数组处理方式
    if (Array.isArray(value)) {
      // 判断当前环境是否可以使用 __proto__
      if (hasProto) {
        // 把数组实例与代理原型或与代理原型中定义的函数联系起来,从而拦截数组变异方法
        // 设置value.__proto__ 为 arrayMethods
        protoAugment(value, arrayMethods)
      } else {
        copyAugment(value, arrayMethods, arrayKeys)
      }
      this.observeArray(value)
    } else {
      // 对象处理方式
      this.walk(value)
    }
  }
}

对象的响应式声明

观察以下代码,我们先不看getset的内容,也不去详细了解一些判断,也是主要做了两件事情 walk循环obj中的属性,并调用defineReactivedefineReactive new一个Dep。 然后将 val传给observe去判断,它是不是一个对象或者数组,如果是,继续走一遍以上流程。如果不是,对obj也就是之前的vm._data创建拦截器

walk (obj: Object) {
 const keys = Object.keys(obj)
 for (let i = 0; i < keys.length; i++) {
   defineReactive(obj, keys[i])
 }
}
export function defineReactive (
  obj: Object,
  key: string,
  val: any,
  customSetter?: ?Function,
  shallow?: boolean
) {
  // 依赖框
  const dep = new Dep()
  let childOb = !shallow && observe(val)
  ...// 删除兼容性代码
  Object.defineProperty(obj, key, {
    enumerable: true,
    get: function reactiveGetter () { },
    set: function reactiveSetter (newVal) { }
  })
}

这样整个初始化流程就算走完了。和computed类似。好,接下来就是触发拦截器了。

初始化 Watcher

_init走到最后,我们会执行vm.$mount(vm.$options.el)$mount有两种入口形式

  1. runtime+ compiler 当我们在html直接引入vue就使用的这种,他会将template转换成render, 然后放到$options.render中。
  2. runtime 我们使用脚手架工具开发的项目就是这个版本,但是他也需要转换,不过使用vue-loader工具去完成

因为computed讲过render生成的基本流程,这里不赘述,直接看platforms/web/index.js$mount方法,很简单就是返回了mountComponent。接下来我们看这个方法

export function mountComponent (
  vm: Component,
  el: ?Element,
  hydrating?: boolean
): Component {
    ...
  //把渲染函数生成的虚拟DOM渲染成真正的DOM
   let updateComponent = () => {
      // vm._render() --> vm.$createElement -> createElement ---> vnode | createComponent --> vnode
      vm._update(vm._render(), hydrating)
    }

  // 这就是渲染watcher
  new Watcher(vm, updateComponent, noop, {
    before () {
      if (vm._isMounted && !vm._isDestroyed) {
        callHook(vm, 'beforeUpdate')
      }
    }
  }, true /* isRenderWatcher */)
  ...
  return vm
}

其核心就是 new Watcher和传入了求值函数updateComponent。进入Wacher构造函数。我们可以发现

export default class Watcher {
  constructor (
    vm: Component,
    // 求值表达式
    expOrFn: string | Function,
    // 回调
    cb: Function,
    // 选项
    options?: ?Object,
    // 是否是渲染watcher
    isRenderWatcher?: boolean
  ) {
    // 该观察者属于哪一个组件
    this.vm = vm
    if (isRenderWatcher) {
      // 将当前渲染watcher 复制给 实例的_watcher
      vm._watcher = this
    }
    // 不管是不是 渲染watcher。 当前this都会复制给_watchers
    vm._watchers.push(this)
    if (options) {
      this.before = options.before // 在触发更新之前的 调用回调
    }
    this.cb = cb // 回调
    this.id = ++uid // uid for batching 唯一标识
    this.active = true // 激活对象
    // 实现避免重复依赖
    this.deps = []
    this.newDeps = []
    this.depIds = new Set()
    this.newDepIds = new Set()
    ...// 处理表达式
    // 当时计算属性 构造函数是不求值的
    this.value = this.lazy
      ? undefined
      : this.get()
  }
}

该构造函数主要也是两件事

  1. vm._watcher赋值为当前Watcher,仅在传值为true的时候,注意只有渲染Watcher才会传true
  2. 定义一些属性,可以看上面的注释,关于避免重复依赖这块,后面添加依赖会讲到
  3. 执行类方法get
// observer/dep.js
Dep.target = null
const targetStack = []

export function pushTarget (target: ?Watcher) {
  targetStack.push(target)
  Dep.target = target
}

export function popTarget () {
  targetStack.pop()
  Dep.target = targetStack[targetStack.length - 1]
}

// observer/watcher.js
get () {
 // 给Dep.target 赋值 Watcher
 pushTarget(this)
 let value
 const vm = this.vm
 try {
   value = this.getter.call(vm, vm)
 } catch (e) {
  ...// user watcher 处理
 } finally {
   popTarget()
   // 清空依赖
   this.cleanupDeps()
 }
 return value
}

这里首先看pushTarget方法,它也做了两件事

  1. 将全局Dep.target赋值为当前Watcher实例。
  2. 将当前Watcher实例pushtargetStack中。

然后执行this.getter方法,它就是之前传入的求值函数updateComponent, 也就是执行了vm._update(vm._render(), hydrating)。后面就和computed讲的一样,我们在render.call(vm._renderProxy, vm.$createElement)上加个断点,单步进入就能拿到render生成的匿名执行函数

;(function anonymous () {
  with (this) {
    return _c('div', { attrs: { id: 'app' } }, [_v('\n    ' + _s(a) + '\n  ')])
  }
})

因此当我们执行到this.a的时候 就触发了 定义好的拦截器

添加依赖

在这里单步调试的时候,我们可以很清晰的看到,this.a --> this._data.a-->get。下面我们在回到defineReactive

export function defineReactive (
  obj: Object,
  key: string,
  val: any,
  customSetter?: ?Function,
  shallow?: boolean
) {
  // 依赖框
  const dep = new Dep()
  ...
  Object.defineProperty(obj, key, {
    enumerable: true,
    get: function reactiveGetter () {
      // 如果存在自定义getter 执行自定义的
      const value = getter ? getter.call(obj) : val
      // 要被收集的依赖
      if (Dep.target) {
        // 通过闭包引用了 依赖框
        // 每一个数据字段都通过闭包引用着属于自己的 dep 常量
        dep.depend()
      }
      return value
    },
    set: function reactiveSetter (newVal) { }
  })
}

这里我们可以看到,他也是一个属性一个独立的dep。并且从上面我们也知道Dep.target,就是当前渲染Watcher,那么就会执行dep.depend

export default class Dep {
  static target: ?Watcher;
  id: number;
  subs: Array<Watcher>;

  constructor() {
    // new Dep 时 唯一id会自增
    this.id = uid++
    this.subs = []
  }
  addSub (sub: Watcher) {
    this.subs.push(sub)
  }
  depend () {
    if (Dep.target) {
      Dep.target.addDep(this)
    }
  }
}

export default class Watcher {
    addDep (dep: Dep) {
    const id = dep.id
    // * 在一次求值中 查看这个唯一id 是否在set中已存在,
    if (!this.newDepIds.has(id)) {
      // 不存在就放进 set里面 然后吧 dep也放到 newdeps里
      // 每次重新求值, newDepIds 都会被清空
      this.newDepIds.add(id)
      this.newDeps.push(dep)
      // * 在 多次求值 中避免收集重复依赖的
      if (!this.depIds.has(id)) {
        dep.addSub(this)
      }
    }
  }
}

这里我们连起来看,整个执行流程就是Wacher.addDep -> dep.addSub -> dep.subs.push(Watcher)。我们先不关注newDepIds、newDeps这几个属性。添加完依赖我们并没有执行结束,在Watcher.get方法中,仅仅执行完了value = this.getter.call(vm, vm)。还有两个关键方法要执行

// 清除当前 target
popTarget()
// 清空依赖
this.cleanupDeps()

第一个看上面代码,就很简单清除当前的Dep.targettargetStack。关键我们来看看这个WatchercleanupDeps方法。

cleanupDeps () {
 // 这里就是 移除废弃观察者
 // 将 newDepIds 赋值给 depIds
 // 清空 newdepids
 // 将 newdeps 赋值给 deps
 // 将 newdeps设置为0
 let tmp = this.depIds
 this.depIds = this.newDepIds
 this.newDepIds = tmp
 this.newDepIds.clear()
 tmp = this.deps
 this.deps = this.newDeps
 this.newDeps = tmp
 this.newDeps.length = 0
}

可以看到我们将depIdsdeps赋值为当前的依赖数据,而newDepsnewDepIds做了置空处理

清除了这些,我们直接走set这个流程

export function defineReactive (
  obj: Object,
  key: string,
  val: any,
  customSetter?: ?Function,
  shallow?: boolean
) {
  // 依赖框
  const dep = new Dep()
  Object.defineProperty(obj, key, {
    enumerable: true,
    set: function reactiveSetter (newVal) {
       ... // 求值处理
      dep.notify()
    }
  })
}

我们要给 set方法,做个断点,然后先看a这个dep里面东西

dep: {
  id: 3,
  subs: [
    Watcher // 渲染watcher
  ]
}

删除和例子无关的代码,set很简单,说起来也就两件事,更新值通知dep,通知更新后面走的流程 dep.notify()-->watcher.update()--->queueWatcher(this)进入watcher队列后就会放到nextTick中,等待本次事件循环完成后,就会执行watcher中保存的方法,watcher.run()

这时候我们看看Watcher.run。它重新执行了求值方法,就相当于我们要再走一遍上面流程,触发get进行依赖收集。这样如果不做处理,肯定会出现重复收集依赖的情况。

run () {
 // 观察者是否处于激活状态
 if (this.active) {
   // 重新求值
   const value = this.get()
 }
}

这时候我们再来看addDep这个方法。从上面我们可知,当前newDep已经被清空了,但是deps中保存着之前的依赖,所以这时候走到this.depIds.has(id)就结束了,避免了依赖的重复添加

addDep (dep: Dep) {
 const id = dep.id
 // * 在一次求值中 查看这个唯一id 是否在set中已存在,
 if (!this.newDepIds.has(id)) {
   // 不存在就放进 set里面 然后吧 dep也放到 newdeps里
   // 每次重新求值, newDepIds 都会被清空
   this.newDepIds.add(id)
   this.newDeps.push(dep)
   // * 在 多次求值 中避免收集重复依赖的
   if (!this.depIds.has(id)) {
     dep.addSub(this)
   }
 }
}

然后就这样结束了吗? 当然没有,继续看 执行完get我们还要执行两个方法,我们直接看cleanupDeps

cleanupDeps () {
 // 这里就是 移除废弃观察者

 // 首先获取上次dep的长度
 let i = this.deps.length
 while (i--) {
   // 循环查找dep在newdepids是否不存在
   const dep = this.deps[i]
   if (!this.newDepIds.has(dep.id)) {
     // 将该观察者对象从Dep实例中移除
     dep.removeSub(this)
   }
 }
 ... // 清理依赖
}

看高亮处。这时候我们没进行后面的执行,所以 newDepdep都存在值。所以就会进行 是否还需要对这个观察者进行观察的判断然后才会执行下面清理的流程。这样整个流程才算执行完了。这就是最简单的对象的执行过程。 那么什么时候会不存在?可以这么做一个例子,页面上通过v-if去分别展示两个响应式属性,当不展示另一个的时候,保存的上一个依赖就不需要继续缓存了。这时候就会触发dep.removeSub(this)进行删除。

深层对象和数组的处理

在真正的开发中,一般会出现多种数据结构

  1. 对象
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <script src="../../dist/vue.js"></script>
</head>

<body>
  <div id="app">
    {{a}}
  </div>
  <script>
    let vm = new Vue({
      el: '#app',
      data: {
        a: {
          b: 1,
        },
      },
    })
  </script>
</body>

</html>

观察以上例子,和简易响应式一样,我们需要递归a才行。查看源码, 在defineReactive源码中

export function defineReactive (
  obj: Object,
  key: string,
  val: any,
  customSetter?: ?Function,
  shallow?: boolean
) {
  // 依赖框
  const dep = new Dep()
  let childOb = !shallow && observe(val)
  Object.defineProperty(obj, key, {
    enumerable: true,
    get: function reactiveGetter () {
      // 要被收集的依赖
      if (Dep.target) {
        dep.depend()
        if (childOb) {
          childOb.dep.depend()
        }
      }
      return value
    },
    set: function reactiveSetter (newVal) {
        //可能set的值还是对象或者数组
      childOb = !shallow && observe(newVal)
      dep.notify()
    }
  })
}

初始化的时候,我们观察_data,可以看到这样的数据结构

_data: {
  a: {
    b: 2,
     __ob__: Observer {
       dep: Dep {id: 4, subs: []},
       value: {},
       vmCount: 0
    }
  },
  __ob__: Observer {
    dep: Dep { id: 2, subs: [] }
    value: {},
    vmCount: 1
  }
}

然后再看$options.render这个匿名执行函数

(function anonymous() {
  with (this) {
    return _c('div', {
      attrs: {
        "id": "app"
      }
    }, [_v("\n    " + _s(a.b) + "\n  ")])
  }
}
)

可以看到要执行a.b,但是这时候其实我们还是执行的this.a,触发的也是a的,但是这次我们执行完dep.depend之后,childOb是存在值的,就是上面通过闭包保存的observe({b:2, __ob__:Observe})。 所以这时候我们执行childOb.dep.depend(),也就是在__ob__.dep.subs中添加了该Watcher

注意以下描述 这时候我们this.a执行完成,然后get会返回a的对象b作为值,返回到那个匿名函数,相当于执行了this.a.b,再次进入get,依赖收集。这时候对应依赖不在是,作为b同级的__ob__,而是在defineReactive初始化的dep,在这里会吧Watcher放进去。

这时候我们再去触发set,执行vm.a.b = 6

通过单步调试可以发现,这里触发dep.notify,执行的是defineReactive初始化的dep。之后的流程和之前一样,仅仅更新值,依赖不会更新。

那么什么时候用到__ob__里面的dep

$set

不要忘了我们还有一API专门用来添加属性,vm.$setvue.set指向的是同一个方法,不赘述。

那么而我们就给例子再添加一行代码。

vm.$set(vm.a, 'c', 6)

然后看源码, 因为我们在原有属性上添加值,所以具有__ob__属性,会走正常添加响应式的流程。

export function set (target: Array<any> | Object, key: any, val: any): any {
   ...
  // 如果是新添加属性
  const ob = (target: any).__ob__
  ...
  defineReactive(ob.value, key, val)
  ob.dep.notify()
  return val
}
  1. 调用defineReactive__ob__.value,的新key创建一个拦截器。
  2. 调用ob.dep.notify(),这时候就是上面那个b对象同级__ob__里的dep调用了通知,之后就会走相同的流程

这里就不讲del了,流程是类似的,照着之前的debug走一遍就行。

聊聊数组

修改基础例子

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <script src="../../dist/vue.js"></script>
</head>

<body>
  <div id="app">
    {{a}}
  </div>
  <script>
    let vm = new Vue({
      el: '#app',
      data: {
        a: [1, 2, 4, 5]
      },
    })
  </script>
</body>

</html>

然后看相关源码,(就不截取全部了,只看部分)

export class Observer {
  value: any;
  dep: Dep;
  vmCount: number; // number of vms that have this object as root $data

  constructor (value: any) {
     ...
    // 数组处理方式
    if (Array.isArray(value)) {
      // 判断当前环境是否可以使用 __proto__
      if (hasProto) {
        // 把数组实例与代理原型或与代理原型中定义的函数联系起来,从而拦截数组变异方法
        // 设置value.__proto__ 为 arrayMethods
        protoAugment(value, arrayMethods)
      } else {
        copyAugment(value, arrayMethods, arrayKeys)
      }
      this.observeArray(value)
    }
    ...
  }
}

现代浏览器都会有hasProto,所以看protoAugment方法,它很简单,将value.__proto__ = arrayMethods, 而arrayMethods = Object.create(Array.prototype)。即value.__proto__.__proto__ == Array.prototype

// 缓存数组的原型
const arrayProto = Array.prototype
// value.__proto__.__proto__ == Array.prototype
export const arrayMethods = Object.create(arrayProto)

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

methodsToPatch.forEach(function (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
    }
    // 递归执行observeArray
    if (inserted) ob.observeArray(inserted)
    // 通知依赖更新
    ob.dep.notify()
    return result
  })
})

这种方式有点类似于面向切面编程,我们给a数组的原型链指向了数组的原型。但是我们没有破坏数组本身的方法,并且通过数组本身方法求值后,做了一些响应式的操作,然后返回了值。在看set方法中,相关数组的内容

if (Array.isArray(target) && isValidArrayIndex(key)) {
  target.length = Math.max(target.length, key)
  target.splice(key, 1, val)
  return val
}

也是使用了splice而已。最后注意一下拦截器get中有这么一段代码,是为了解决多维数组的响应式问题

 // 解决 get的时候 value 仍旧是一个数组的时候 去做响应式依赖
if (Array.isArray(value)) {
  dependArray(value)
}
function dependArray (value: Array<any>) {
  for (let e, i = 0, l = value.length; i < l; i++) {
    e = value[i]
    // 如果e还是一个数组,那么就需要再次添加依赖
    e && e.__ob__ && e.__ob__.dep.depend()
    if (Array.isArray(e)) {
      dependArray(e)
    }
  }
}

总结

至此,data的主要功能差不多分析完了,这块的难点主要在

  1. 依赖收集和优化相关的依赖清除
  2. __ob__中的depdefineReactivedep的异同点

通过debug理解了这些,data就没有什么难的地方了。

内容过长,更新删除相关兼容性代码