berwin/Blog

深入浅出 - vue变化侦测原理

berwin opened this issue · 60 comments

深入浅出 - vue变化侦测原理

其实在一年前我已经写过一篇关于 vue响应式原理的文章,但是最近我翻开看看发现讲的内容和我现在心里想的有些不太一样,所以我打算重新写一篇更通俗易懂的文章。

我的目标是能让读者读完我写的文章能学到知识,有一部分文章标题都以深入浅出开头,目的是把一个复杂的东西排除掉干扰学习的因素后剩下的核心原理通过很简单的描述来让读者学习到知识。

关于vue的内部原理其实有很多个重要的部分,变化侦测,模板编译,virtualDOM,整体运行流程等。

今天主要把变化侦测这部分单独拿出来讲一讲。

如何侦测变化?

关于变化侦测首先要问一个问题,在 js 中,如何侦测一个对象的变化,其实这个问题还是比较简单的,学过js的都能知道,js中有两种方法可以侦测到变化,Object.defineProperty 和 ES6 的proxy

到目前为止vue还是用的 Object.defineProperty,所以我们拿 Object.defineProperty来举例子说明这个原理。

这里我想说的是,不管以后vue是否会用 proxy 重写这部分,我讲的是原理,并不是api,所以不论以后vue会怎样改,这个原理是不会变的,哪怕vue用了其他完全不同的原理实现了变化侦测,但是本篇文章讲的原理一样可以实现变化侦测,原理这个东西是不会过时的。

之前我写文章有一个毛病就是喜欢对着源码翻译,结果过了半年一年人家源码改了,我写的文章就一毛钱都不值了,而且对着源码翻译还有一个缺点是对读者的要求有点偏高,读者如果没看过源码或者看的和我不是一个版本,那根本就不知道我在说什么。

好了不说废话了,继续讲刚才的内容。

知道 Object.defineProperty 可以侦测到对象的变化,那么我们瞬间可以写出这样的代码:

function defineReactive (data, key, val) {
    Object.defineProperty(data, key, {
        enumerable: true,
        configurable: true,
        get: function () {
            return val
        },
        set: function (newVal) {
            if(val === newVal){
                return
            }
            val = newVal
        }
    })
}

写一个函数封装一下 Object.defineProperty,毕竟 Object.defineProperty 的用法这么复杂,封装一下我只需要传递一个 data,和 key,val 就行了。

现在封装好了之后每当 datakey 读取数据 get 这个函数可以被触发,设置数据的时候 set 这个函数可以被触发,但是,,,,,,,,,,,,,,,,,,发现好像并没什么鸟用?

怎么观察?

现在我要问第二个问题,“怎么观察?”

思考一下,我们之所以要观察一个数据,目的是为了当数据的属性发生变化时,可以通知那些使用了这个 key 的地方。

举个🌰:

<template>
  <div>{{ key }}</div>
  <p>{{ key }}</p>
</template>

模板中有两处使用了 key,所以当数据发生变化时,要把这两处都通知到。

所以上面的问题,我的回答是,先收集依赖,把这些使用到 key 的地方先收集起来,然后等属性发生变化时,把收集好的依赖循环触发一遍就好了~

总结起来其实就一句话,getter中,收集依赖,setter中,触发依赖

依赖收集在哪?

现在我们已经有了很明确的目标,就是要在getter中收集依赖,那么我们的依赖收集到哪里去呢??

思考一下,首先想到的是每个 key 都有一个数组,用来存储当前 key 的依赖,假设依赖是一个函数存在 window.target 上,先把 defineReactive 稍微改造一下:

function defineReactive (data, key, val) {
    let dep = [] // 新增
    Object.defineProperty(data, key, {
        enumerable: true,
        configurable: true,
        get: function () {
            dep.push(window.target) // 新增
            return val
        },
        set: function (newVal) {
            if(val === newVal){
                return
            }
            
            // 新增
            for (let i = 0; i < dep.length; i++) {
            	 dep[i](newVal, val)
            }
            val = newVal
        }
    })
}

defineReactive 中新增了数组 dep,用来存储被收集的依赖。

然后在触发 set 触发时,循环dep把收集到的依赖触发。

但是这样写有点耦合,我们把依赖收集这部分代码封装起来,写成下面的样子:

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

  constructor () {
    this.id = uid++
    this.subs = []
  }

  addSub (sub: Watcher) {
    this.subs.push(sub)
  }

  removeSub (sub: Watcher) {
    remove(this.subs, sub)
  }

  depend () {
    if (Dep.target) {
      this.addSub(Dep.target)
    }
  }

  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

function defineReactive (data, key, val) {
    let dep = new Dep()        // 修改
    Object.defineProperty(data, key, {
        enumerable: true,
        configurable: true,
        get: function () {
            dep.depend() // 修改
            return val
        },
        set: function (newVal) {
            if(val === newVal){
                return
            }

            dep.notify() // 新增
            val = newVal
        }
    })
}

这一次代码看起来清晰多了,顺便回答一下上面问的问题,依赖收集到哪?收集到Dep中,Dep是专门用来存储依赖的。

收集谁?

上面我们假装 window.target 是需要被收集的依赖,细心的同学可能已经看到,上面的代码 window.target 已经改成了 Dep.target,那 Dep.target是什么?我们究竟要收集谁呢??

黑人问号脸

收集谁,换句话说是当属性发生变化后,通知谁。

我们要通知那个使用到数据的地方,而使用这个数据的地方有很多,而且类型还不一样,有可能是模板,有可能是用户写的一个 watch,所以这个时候我们需要抽象出一个能集中处理这些不同情况的类,然后我们在依赖收集的阶段只收集这个封装好的类的实例进来,通知也只通知它一个,然后它在负责通知其他地方,所以我们要抽象的这个东西需要先起一个好听的名字,嗯,就叫它watcher吧~

所以现在可以回答上面的问题,收集谁??收集 Watcher。

什么是Watcher?

watcher 是一个中介的角色,数据发生变化通知给 watcher,然后watcher在通知给其他地方。

关于watcher我们先看一个经典的使用方式:

// keypath
vm.$watch('a.b.c', function (newVal, oldVal) {
  // do something
})

这段代码表示当 data.a.b.c 这个属性发生变化时,触发第二个参数这个函数。

思考一下怎么实现这个功能呢?

好像只要把这个 watcher 实例添加到 data.a.b.c 这个属性的 Dep 中去就行了,然后 data.a.b.c 触发时,会通知到watcher,然后watcher在执行参数中的这个回调函数。

好,思考完毕,开工,写出如下代码:

class Watch {
    constructor (expOrFn, cb) {
        // 执行 this.getter() 就可以拿到 data.a.b.c
        this.getter = parsePath(expOrFn)
        this.cb = cb
        this.value = this.get()
    }

    get () {
        Dep.target = this
        value = this.getter.call(vm, vm)
        Dep.target = undefined
    }

    update () {
        const oldValue = this.value
        this.value = this.get()
        this.cb.call(this.vm, this.value, oldValue)
    }
}

这段代码可以把自己主动 pushdata.a.b.c 的 Dep 中去。

因为我在 get 这个方法中,先把 Dep.traget 设置成了 this,也就是当前watcher实例,然后在读一下 data.a.b.c 的值。

因为读了 data.a.b.c 的值,所以肯定会触发 getter

触发了 getter 上面我们封装的 defineReactive 函数中有一段逻辑就会从 Dep.target 里读一个依赖 pushDep 中。

所以就导致,我只要先在 Dep.target 赋一个 this,然后我在读一下值,去触发一下 getter,就可以把 this 主动 pushkeypath 的依赖中,有没有很神奇~

依赖注入到 Dep 中去之后,当这个 data.a.b.c 的值发生变化,就把所有的依赖循环触发 update 方法,也就是上面代码中 update 那个方法。

update 方法会触发参数中的回调函数,将value 和 oldValue 传到参数中。

所以其实不管是用户执行的 vm.$watch('a.b.c', (value, oldValue) => {}) 还是模板中用到的data,都是通过 watcher 来通知自己是否需要发生变化的。

递归侦测所有key

现在其实已经可以实现变化侦测的功能了,但是我们之前写的代码只能侦测数据中的一个 key,所以我们要加工一下 defineReactive 这个函数:

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

function defineReactive (data, key, val) {
    walk(val) // 新增
    let dep = new Dep()
    Object.defineProperty(data, key, {
        enumerable: true,
        configurable: true,
        get: function () {
            dep.depend()
            return val
        },
        set: function (newVal) {
            if(val === newVal){
                return
            }

            dep.notify()
            val = newVal
        }
    })
}

这样我们就可以通过执行 walk(data),把 data 中的所有 key 都加工成可以被侦测的,因为是一个递归的过程,所以 key 中的 value 如果是一个对象,那这个对象的所有key也会被侦测。

Array怎么进行变化侦测?

现在又发现了新的问题,data 中不是所有的 value 都是对象和基本类型,如果是一个数组怎么办??数组是没有办法通过 Object.defineProperty 来侦测到行为的。

vue 中对这个数组问题的解决方案非常的简单粗暴,我说说vue是如何实现的,大体上分三步:

第一步:先把原生 Array 的原型方法继承下来。

第二步:对继承后的对象使用 Object.defineProperty 做一些拦截操作。

第三步:把加工后可以被拦截的原型,赋值到需要被拦截的 Array 类型的数据的原型上。

vue的实现

第一步:

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

第二步:

;[
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
]
.forEach(function (method) {
  // cache original method
  const original = arrayProto[method]

  Object.defineProperty(arrayMethods, method, {
    value: function mutator (...args) {
      console.log(method) // 打印数组方法
      return original.apply(this, args)
    },
    enumerable: false,
    writable: true,
    configurable: true
  })
})

现在可以看到,每当被侦测的 array 执行方法操作数组时,我都可以知道他执行的方法是什么,并且打印到 console 中。

现在我要对这个数组方法类型进行判断,如果操作数组的方法是 push unshift splice (这种可以新增数组元素的方法),需要把新增的元素用上面封装的 walk 来进行变化检测。

并且不论操作数组的是什么方法,我都要触发消息,通知依赖列表中的依赖数据发生了变化。

那现在怎么访问依赖列表呢,可能我们需要把上面封装的 walk 加工一下:

// 工具函数
function def (obj: Object, key: string, val: any, enumerable?: boolean) {
  Object.defineProperty(obj, key, {
    value: val,
    enumerable: !!enumerable,
    writable: true,
    configurable: true
  })
}

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

  constructor (value: any) {
    this.value = value
    this.dep = new Dep() // 新增
    this.vmCount = 0
    def(value, '__ob__', this) // 新增

    // 新增
    if (Array.isArray(value)) {
      this.observeArray(value)
    } else {
      this.walk(value)
    }
  }

  /**
   * Walk through each property and convert them into
   * getter/setters. This method should only be called when
   * value type is Object.
   */
  walk (obj: Object) {
    const keys = Object.keys(obj)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(obj, keys[i], obj[keys[i]])
    }
  }

  /**
   * Observe a list of Array items.
   */
  observeArray (items: Array<any>) {
    for (let i = 0, l = items.length; i < l; i++) {
      new Observer(items[i])
    }
  }
}

我们定义了一个 Observer 类,他的职责是将 data 转换成可以被侦测到变化的 data,并且新增了对类型的判断,如果是 value 的类型是 Array 循环 Array将每一个元素丢到 Observer 中。

并且在 value 上做了一个标记 __ob__,这样我们就可以通过 value__ob__ 拿到Observer实例,然后使用 __ob__ 上的 dep.notify() 就可以发送通知啦。

然后我们在改进一下Array原型的拦截器:

;[
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
]
.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
  })
})

可以看到写了一个 switchmethod 进行判断,如果是 pushunshiftsplice 这种可以新增数组元素的方法就使用 ob.observeArray(inserted) 把新增的元素也丢到 Observer 中去转换成可以被侦测到变化的数据。

在最后不论操作数组的方法是什么,都会调用 ob.dep.notify() 去通知 watcher 数据发生了改变。

arrayMethods 是怎么生效的?

现在我们有一个 arrayMenthods 是被加工后的 Array.prototype,那么怎么让这个对象应用到Array 上面呢?

思考一下,我们不能直接修改 Array.prototype因为这样会污染全局的Array,我们希望 arrayMenthods 只对 data中的Array 生效。

所以我们只需要把 arrayMenthods 赋值给 value__proto__ 上就好了。

我们改造一下 Observer

export class Observer {
  constructor (value: any) {
    this.value = value
    this.dep = new Dep()
    this.vmCount = 0
    def(value, '__ob__', this)

    if (Array.isArray(value)) {
      value.__proto__ = arrayMethods // 新增
      this.observeArray(value)
    } else {
      this.walk(value)
    }
  }
}

如果不能使用 __proto__,就直接循环 arrayMethods 把它身上的这些方法直接装到 value 身上好了。

什么情况不能使用 __proto__ 我也不知道,各位大佬谁知道能否给我留个言?跪谢~

所以我们的代码又要改造一下:

// can we use __proto__?
const hasProto = '__proto__' in {} // 新增
export class Observer {
  constructor (value: any) {
    this.value = value
    this.dep = new Dep()
    this.vmCount = 0
    def(value, '__ob__', this)

    if (Array.isArray(value)) {
      // 修改
      const augment = hasProto
        ? protoAugment
        : copyAugment
      augment(value, arrayMethods, arrayKeys)
      this.observeArray(value)
    } else {
      this.walk(value)
    }
  }
}

function protoAugment (target, src: Object, keys: any) {
  target.__proto__ = src
}

function copyAugment (target: Object, src: Object, keys: Array<string>) {
  for (let i = 0, l = keys.length; i < l; i++) {
    const key = keys[i]
    def(target, key, src[key])
  }
}

关于Array的问题

关于vue对Array的拦截实现上面刚说完,正因为这种实现方式,其实有些数组操作vue是拦截不到的,例如:

this.list[0] = 2

修改数组第一个元素的值,无法侦测到数组的变化,所以并不会触发 re-renderwatch 等。

在例如:

this.list.length = 0

清空数组操作,无法侦测到数组的变化,所以也不会触发 re-renderwatch 等。

因为vue的实现方式就决定了无法对上面举得两个例子做拦截,也就没有办法做到响应,ES6是有能力做到的,在ES6之前是无法做到模拟数组的原生行为的,现在 ES6 的 Proxy 可以模拟数组的原生行为,也可以通过 ES6 的继承来继承数组原生行为,从而进行拦截。

总结

响应式结构图

最后掏出vue官网上的一张图,这张图其实非常清晰,就是一个变化侦测的原理图。

getterwatcher 有一条线,上面写着收集依赖,意思是说 getter 里收集 watcher,也就是说当数据发生 get 动作时开始收集 watcher

setterwatcher 有一条线,写着 Notify 意思是说在 setter 中触发消息,也就是当数据发生 set 动作时,通知 watcher

Watcher 到 ComponentRenderFunction 有一条线,写着 Trigger re-render 意思很明显了。

image

思考题

其实文章中为了方便理解,写的都是伪代码,所以很多安全检查和一些细节的处理逻辑就都让我删了

文章的代码中会有这样一个问题,当数据改变触发 Notify 时,watcher 会再次进行 this.get() 操作,那就会将自己又一次添加到 Dep 中,那 Dep 中的依赖就会发生重复,发生了重复后数据又发生改变时,watcher 会被通知两次,这个问题怎么解决??

有兴趣的小伙伴自己想方案或者看vue源码

欢迎把解决办法写在下方留言~

文章写的很好懂!!赞!

ps 其实为什么源码里面defineReactive函数new了一个Dep之后,Observer再new一次Dep ?

@SSShooter 哈哈哈哈哈,这个问题相当有技术含量,不错不错,,,,,😄😄😄

defineReactive 这个函数只有 Object 类型的数据才能进的去,Array是进不去的,所以 Observer 上的 Dep 是给Array类型的数据用的。

而且在 defineReactive 函数中有这样几行代码

let childOb = !shallow && observe(val)
if (childOb) {
  childOb.dep.depend()
  if (Array.isArray(value)) {
    dependArray(value)
  }
}

就是要把 key 上的 depval 上的 dep 和 做一个同步~ 😂😂

做了同步之后呢,就可以直接通过 val 来访问依赖,比如 data.__ob__.dep

有一种场景是{list: [1,2,3,4,5]}这种情况如果watch了listkey,那么如果list中的某个元素变了,watch无法得到通知。为了解决这个问题,需要把当前的watcher也收集到childOb的依赖列表里(就是同时观察obj.list和list。这样的话obj.list = 1可以得到通知,obj.list.push(1)也可以得到通知)。

😂原来是这样 谢谢大佬!!

看递归还是看得有点晕...请问key 上的 dep 和 val 上的 dep有什么不同...:sob:

@SSShooter 哈哈,这是个好问题,可能很多小伙伴都会有这样的问题, key 上的 depval 上的 dep 有什么不同?

用数组举例子,如果是这样的数据 {list: [1,2,3]},对 list 进行了操作比如 this.list.push(4),vue对 Array 类型的拦截操作是从 this 上取 __ob__,然后使用 __ob__上的 dep(就是 val的 dep) 触发消息。

也就是说数组的拦截器是访问不到 key 上的 dep的,但可以访问到 this,而正因为 this上有__ob__ ,所以可以很方便的来访问 Observer,因为 Observer 实例上的依赖和key的依赖是同步的,所以直接访问 this.__ob__.dep 完全可以读取到一个数据被收集的依赖列表。

也就是说正因为每个数据都有 __ob__属性,所以可以做到直接使用数据的 __ob__ 来访问Dep去操作依赖,比如触发依赖或者读取依赖等。

了解!

我有一点疑惑的地方,dep要通过addSub收集watcher,那为什么watcher要通过addDep去收集dep,希望能说明一下,谢谢

@JSupot 原因有很多,watcher 中有一个方法 teardown 用来将 watcher 从曾经观察过的所有 dep 中清除,那如果 watcher 想把自己从这些 dep 中删除掉,首先自己得知道自己都 push 到了哪些 dep 中,对吧?

teardown 中有一部分代码是这样写的:

// this 就是 watcher 实例
// deps 就是你说的watcher收集到的 dep 列表
let i = this.deps.length
  while (i--) {
    this.deps[i].removeSub(this)
}

我知道的另一个原因是和 computed 有关。

为什么会和 computed 有关呢,这个事就比较复杂了。

首先 computed 是绑定在 vm 上的一个 getter,这没问题,但问题是,这个 getter 和 我们在组件中写的 computed 函数中间加了一层 watcher,也就是说当模板中读一个 computed 拿到的值并不是直接执行我们写的 computed 函数中的 return 返回的那个值,而是 watcher.value

看下面的伪代码:

//  getter 就是我们写的 Computed 函数
function computedGetter () {
  const watcher = new Watcher(
    vm,
    getter || noop,
    noop,
    computedWatcherOptions
  )
  if (watcher) {
    if (watcher.dirty) {
      watcher.evaluate()
    }
    if (Dep.target) {
      watcher.depend()
    }
    return watcher.value
  }
}

你肯定会问为什么这么做,为什么要在中间加上 watcher,这个 watcher 是干什么用的?

其实也是一个优化策略,作用是如果Computed中依赖的其他数据没有发生改变的话,那么就不需要执行 Computed 去计算结果,因为如果依赖的数据不发生变化,那么 Computed的结果也不会发生变化。

为了这个优化所以需要加一个 watcher

而加了这个 watcher 可以实现一个很强的功能,就是可以把自己 push 到我们写的 computed 函数中所使用到的其他 datadep 中,这样就可以检测到 Computed中都使用了哪些其他数据。

举个🌰:

我们写了这样一个 Computed:

computed: {
  age () {
    return this.n
  }
}

这个Computed中使用了 this.n,而这个 watcher就可以把自己 pushthis.ndep 中。

如果日后这个 this.n 发生了变化,就会通知到 Computed中的这个 watcher,被通知后 watcher 会把 watcher.dirty 设置为 true,这样当下一次访问数据时会执行 watcher.evaluate() 来重新获取更新一下 watcher.value 保证拿到的数据是更新过的。

这里解释一下为什么 watcher 被通知了只更新 watcher.dirty 而不是触发 watcher.cb,因为 Computed中的这个watcher 是 lazy 的,lazywatcher 被通知后不会立马执行 this.cb() 去做什么事,而仅仅是把 watcher.dirty 设置为 true,然后在 Computed 中每次读数据都会判断这个 watcher.dirty,会判断一下需不需要重新更新一下 watcher.value

其实没有这个优化也不影响功能,但如果Computed中做了大量的计算,并且没有这个优化的话,那么每次读数据都会做大量计算比较消耗性能,如果有很多地方都使用到了这个属性,那么程序会变得非常卡,但有了这个watcher后其实只有在依赖的数据发生了变化后才重新计算,这样就可以降低一些消耗。

所以上面的伪代码中有这样几行代码:

if (watcher.dirty) {
  watcher.evaluate()
}

但是到目前为止都还没有涉及到你问的问题,Computed 为什么和 watcher 中收集的 dep 有关?

我先问个问题,你有没有发现,在 vue 中你写了 Computed,然后Computed依赖了其他数据,你把其他数据改了,DOM却发生了变化,way

比如上面的例子,模板中只用了 age,如果把 this.n 的数据改了,DOM却发生了变化,是不是很神奇?

之所以这么神奇,是因为模板在使用watcher读 Computed这个数据时,Computed从上面我们用来优化的 watcher 中把 deps 掏了出来!!!!

然后把模板的那个 watcher push 到 deps 中的每一项里。

所以虽然模板中使用了一个 computed,但其实他使用的 watcher 已经被注入到了这个 computed 中使用到的所有其他数据的 dep 中,所以才会出现那种明明只使用了 computed,为什么我更新了computed中使用的其他数据 DOM竟然很聪明的知道它需要重新渲染DOM!!!

所以现在回答你的问题,为什么watcher要通过addDep去收集dep?因为 watcher 想记录自己正在监听哪些 dep

image

你好,有个问题想问你一下,使用v-on来监听事件,DOM事件比如click、change和自定义事件有什么区别吗,这个v-on和$on的事件机制有什么联系吗

作者可以讲一下 Observer Watch walk() 的执行方法和顺序吗

@classname 喔?什么顺序?

嗯,目前看懂的是 Observer里面有Dep,dep里收集的watcher,walk方法是递归遍历所有的属性并使用defineProperty方法,这里有个疑问,是每个属性有自己的Observer还是说全局只有一个Observer,walk方法在哪个方法里执行的? 不知道上面说的对不对

@classname 每个属性有自己的 Observer 实例~ walk方法就是在 Observer 中执行的~~

好的,谢啦~

@JSupot 额。。你可以去看看文档。。。。里面会有答案。。

@classname 不客气~ 😁😁

@berwin 今天看了一下,好像vue对v-on的解析是分开处理的,如果是在DOM元素上就是绑定到对应的元素事件上,用在组件上的时候,会注册到事件系统里。

@JSupot 对,表现形式上是这样的~

大佬好~请问一个问题:

if (childOb) {
    childOb.dep.depend();
}

除了需要知道数组的变化外,为何要在子依赖收集中加入父的watcher呢?

试过注释掉代码,只发现了数组不响应的情况,其余的暂时还没发现哦。

@ljf0113 哈哈哈哈,主要就是用来收集数组的依赖的,数组的依赖只有这样收集,才能在数组的拦截器中访问到依赖。

@berwin 如果只为了数组的话,是否有点浪费内存?不能走个if else吗?

@ljf0113 想了半天,,,,没想通在哪里写if else 🤣🤣

{a: []}

如果想监听this.a这个数组,那其实key是a,而value是[]childOb就是[].__ob__,这个时候执行childOb.dep.depend();收集一下当前这个依赖。

我没太想通会有什么浪费,if 和 else又是什么东东在哪里写~~ 😄😄

@berwin 嗯嗯~谢谢大佬解答~~

@ljf0113 不是大佬,哈哈哈,大佬没我这么菜,不用谢啦,可能也是我自己这文章写的不是特别的容易理解给你添麻烦了。

@berwin 整理了一下思路~可能我还是有点疑问,我的意思是这样的:

if (childOb && Array.isArray(val)) { childOb.dep.depend(); }

如果修改成这样呢?

其实不是数组的话,有必要在子依赖中收集父watcher吗?

emmmmm~补充一下,我搜了一下代码,发现__ob__这个东东,在vnode与部分样式相关的代码中都有出现,还没仔细看,应该是有它的用途的。

@ljf0113 一、我感觉你这样写应该也没啥问题,但Vue中为啥不这么写可能也有他自己的原因我们不知道吧。

二、其实这个地方可以换一种理解方式,可以理解为只是在val上收集依赖,这样可能更容易理解一点。

三、__ob__是所有被侦测了变化的数据都会有一个__ob__属性,也就是说,可以通过__ob__来判断一个数据是不是响应式的,也可以直接使用__ob__来操作dep,例如可以直接这样做:this.obj.__ob__.dep.notify() 这样可以手动触发一个通知。

ie9、ie10 和 opera 没有 __proto__ 属性,昨天看书看到的。ie 已测试
qq 20180409012924

@Hugo-seth 噢噢噢噢、多谢多谢。哈哈哈哈,太感谢了

最近正在看 vue 的源码,感谢博主的文章,写的很好啊

@Hugo-seth 😁😁😁 过奖过奖、、

虽然我知道VUE并不是严格的MVVM,但是我个人觉得Watch有点viewmodel的作用。有了getter和setter的data(称之为model)的功能就是value存储值而已,Watch的作用就是用来得知某个data在某处被修改了,然后Watch告诉这个值所有的引用者“我被修改了,改成了XXX”,然后进入异步更新队列去等下次刷DOM。所以我总结Watch就是订阅者模式+告诉DOM你该刷新和刷新的内容。

侦测数组那里console.log(methods) methods多打了个s

@Hazlank 哈哈哈哈,多谢多谢,已经改过来啦~

源码没看,从文档里看出来Vue的处理方法是数据变化后不立即更新,在下一个tick更新,调用这个vm.$nextTick(function () {})😂

@wcc233 哈哈哈,不冲突的、变化侦测和更新视图是两个系统。变化侦测只是侦测数据是否有变动。而具体如何更新DOM是另一个系统在操作~ 文档里写的意思是当变化侦测发现到数据有变化后将通知发出去,但更新系统不会立刻更新,而是在下一个tick再更新~

为什么放到下一个tick更新,可以看下这个文章:#22

@berwin 不好意思,我naive了😂,我再仔细阅读下

在源码$set中, https://github.com/vuejs/vue/blob/dev/dist/vue.js 大概1054行的位置:

var ob = (target).__ob__;
if (!ob) {
    target[key] = val;
    return val
  }

没看到这段代码的意思,若target监听器不存在,则把值给到这个key即可?为什么会有这个 !ob 的判断呢? 谢谢

@wenzi0github 因为并不是所有数据都是响应式数据啊 😂,如果数据不是响应式数据就直接把新增的属性设置到数据上就行了,并且使用return阻止程序继续执行。只有响应式的数据才需要把新增的数据也变成响应式的数据~所以下面才会有这样一段代码

defineReactive(ob.value, key, val);

诶~ 转了一圈回来问大佬。目测你肯定看过 vue 在 github 最新的代码了~dev分支的。有没有发现它默默地改了 computed 的实现。原来的代码(包括现在用的最新版),computed 的实现是 lazy 的,在它依赖的属性值变化的时候标记为 dirty,只在这个 computed 属性被读取的时候才进行运算。

然而 github 的代码实现改为了只要它依赖的属性值产生了变化,那么 computed 函数必然运算一次,其实不是更低效吗?

期待你的解答哦~~

@ljf0113 不不不,其实我就是个小废材。

说正事:

我好像没看见你说的变化?我看到的逻辑还是将dirty设置为true,代码地址:https://github.com/vuejs/vue/blob/dev/src/core/observer/watcher.js#L179

你看到的代码地址能不能发给我看看,我研究一下 🤣🤣

@berwin 你贴的地址已经是新的代码了哦~ 然而官网的 2.5.17 稳定版,还是旧的实现。具体看 watcher 也就是你贴的那部分代码~针对 computed 的实现是不一致的。。。

@ljf0113 看了下,代码虽然不一样,但只是代码位置变了,逻辑是一样的~ 你就当做重构了一下代码,提升了可读性和扩展性。

@berwin 实现完全不同的。。。

const vm = new Vue({
  el: '#app',
  data: {
    a: 1,
    b: 1,
  },
  computed: {
    test() {
      console.log(123)
      return this.a + this.b;
    }
  },
  methods: {
    click() {
      this.a++;
      this.b--;
    }
  }
})

你试下引入不同的 vue,一个是官网的2.5.17版本,一个是 dev 分支 dist 下面的 vue.js,当 click 触发的时候,2.5.17版本只会打印一次,而 dev 分支的版本是两次!

@ljf0113 仔细看了下,你说的对,确实有问题,这个我之前还真没发现。哈哈哈哈

我看了下,发现是这样的:

之前的计算属性的逻辑是,Vue实例会观察computed函数内出现的的所有状态,也就是说只要出现在computed函数里的状态发生变化后,Vue实例都会收到通知并进行重新渲染。

这就导致一个问题,如果计算属性中用到的状态发生了变化,即便这个计算属性的返回值没变,组件也会走一遍渲染的流程,只不过最终由于VirtualDOM的diff发现没有变化所以在视觉上并不会发现有什么问题,但是渲染函数会被执行,看这个issue vuejs/vue#7767 demo地址: https://jsfiddle.net/72gzmayL/

为了解决这个问题,所以把computed的逻辑改成了Vue实例不再观察computed函数中所使用到的状态,那么Vue实例如果不再观察computed函数中所使用到的状态,那它如何知道计算属性依赖的状态发生变化后是否需要重新渲染呢?

这就需要computed的watcher主动去向Vue实例发送通知好让Vue实例知道computed的返回值变了,它应该渲染视图了。所以发送这个通知的前提是computed的返回值有变化。可以看下这行代码:https://github.com/vuejs/vue/blob/dev/src/core/observer/watcher.js#L207

解决这个问题的pull request地址:vuejs/vue#7824

所以为什么会出现你说的 dev 分支的版本会打印出两次呢?因为click函数被执行后修改了两次状态,而这两次修改都会使computed的返回值不一样,所以会向Vue实例发送两次通知,从而Vue实例会读取两次computed的值,所以computed函数会触发两次。(这个应该是一个bug,或许是为了解决一个问题而不小心引发了另一个问题吧。)

@berwin 推测合理,然而它的实现还是有问题~ getAndInvoke 是同步的!它可不会全部依赖变完后再去算一次,现在是只要依赖变了立即算,这样就算最后算出来是一样,渲染函数早就被触发了。。你可以试下改下 dist 下 vue 的源码。。。

this.getAndInvoke(function () {
  this$1.dep.notify();
});

在回调里面 console.log 个啥都行,你会发现也是调用了两次,this$1.dep 里面存的可是渲染函数啊。。

退一步说,按照这个思路,我认为也是有待商榷,现在是靠运行多次 computed 去换取渲染函数不触发,然而 computed 很可能是很复杂的函数,这问题变为了到底牺牲哪个比较好~ 我是感觉如果组件化做得好,反而是渲染函数触发的成本低一点。。。

最优的实现应该是以前 lazy 的基础上,判断是否变化,再执行 this$1.dep.notify();。。。。

@ljf0113 确实是,我也感觉这块实现可能有点问题。和你说的一样,getAndInvoke是同步的,所以并不会等全部依赖变完之后再去算一次。

但这个问题是可以解决的,可以使用异步调度一下,等当前tick把依赖都改完之后在下一个tick里只需要计算一次就可以了。

你可以去vue的issues里把这个问题提一下,他们就会fix掉这个issue了

@berwin 我再仔细看了一下代码,发现应该是死在 event loop 上。。。属性的 setter 触发 dep 的 notify 其实已经放在 micotask 上的,也就意味着 getAndInvoke 也在上面,估计是坑在这了。。

事实说,这是我遇到的第一个关于 event loop 引发的bug~ 虽然面试时候了然于胸,但实际中我真没被坑过,这是第一次。。。。

也许是鄙人愚笨...看完以后似懂非懂...
我理解的,整个流程是封装Object.defineProperty
通过Dep管理依赖,通过watch做通知...
但具体到细节..首先这些代码跑不起来...比如说..我想实现一个..
针对某对象就 obj吧,它的 flag属性做绑定...
然后,flag一旦改变,obj2,obj3上对应的函数就执行一遍....
用..文章中的代码..要改变哪些部分才能实现..?就完全没有概念了....

思考题

其实文章中为了方便理解,写的都是伪代码,所以很多安全检查和一些细节的处理逻辑就都让我删了

文章的代码中会有这样一个问题,当数据改变触发 Notify 时,watcher 会再次进行 this.get() 操作,那就会将自己又一次添加到 Dep 中,那 Dep 中的依赖就会发生重复,发生了重复后数据又发生改变时,watcher 会被通知两次,这个问题怎么解决??

有兴趣的小伙伴自己想方案或者看vue源码

欢迎把解决办法写在下方留言~

先将watcher推到一个队列中,做了去重后在nexttick的时候再更新?
也就是vue文档中的,异步队列更新
不知道对不对,还请楼主解答一下

@weimingxinas 不用先推到队列中,先判断下如果之前推进去过,这次就不推就行了。哈哈哈哈

看着头晕啊,再看几遍 😂

vnues commented

由浅到深 也同时清楚了一些概念

从“依赖收集在哪” 那里开始往下就已经不知所云了,好在这些字我都懂。

受教了。

@berwin 为什么这里的vue变化侦测中有的内容跟你写的深入浅出vue那本书中的有些内容不一样

@berwin能不能解释一下函数中的参数的意义

mark

什么情况不能使用 proto 我也不知道,各位大佬谁知道能否给我留个言?跪谢~

应该是考虑到IE对于__proto__的支持不好,貌似只有IE11支持

把我给讲明白了 写的真好