绑定完请刷新页面
取消
刷新

分享好友

×
取消 复制
Vue2依赖收集原理
2023-04-04 18:06:59

观察者模式定义了对象间一对多的依赖关系。即被观察者状态发生变动时,所有依赖于它的观察者都会得到通知并自动更新。解决了主体对象和观察者之间功能的耦合。

Vue中基于 Observer、Dep、Watcher 三个类实现了观察者模式

  • Observer类 负责数据劫持,访问数据时,调用dep.depend()进行依赖收集;数据变更时,调用dep.notify() 通知观察者更新视图。我们的数据就是被观察者
  • Dep类 负责收集观察者 watcher,以及通知观察者 watcher 进行 update 更新操作
  • Watcher类 为观察者,负责订阅 dep,并在订阅时让 dep 同步收集当前 watcher。当接收到 dep 的通知时,执行 update 重新渲染视图


dep 和 watcher 是一个多对多的关系。每个组件都对应一个渲染 watcher,每个响应式属性都有一个 dep 收集器。一个组件可以包含多个属性(一个 watcher 对应多个 dep),一个属性可以被多个组件使用(一个 dep 对应多个 watcher)


Dep

我们需要给每个属性都增加一个 dep 收集器,目的就是收集 watcher。当响应式数据发生变化时,更新收集的所有 watcher

  1. 定义 subs 数组,当劫持到数据访问时,执行 dep.depend(),通知 watcher 订阅 dep,然后在 watcher内部执行dep.addSub(),通知 dep 收集 watcher
  2. 当劫持到数据变更时,执行dep.notify() ,通知所有的观察者 watcher 进行 update 更新操作

Dep有一个静态属性 target,全局唯*一,Dep.target 是当前正在执行的 watcher 实例,这是一个非常巧妙的设计!因为在同一时间只能有一个全局的 watcher

注意:
渲染/更新完毕后我们会立即清空 Dep.target,保证了只有在模版渲染/更新阶段的取值操作才会进行依赖收集。之后我们手动进行数据访问时,不会触发依赖收集,因为此时 Dep.target 已经重置为 null

javascript
let id = 

class Dep {
  constructor() {
    this.id = id++
    // 依赖收集,收集当前属性对应的观察者 watcher
    this.subs = []
  }
  // 通知 watcher 收集 dep
  depend() {
    Dep.target.addDep(this)
  }
  // 让当前的 dep收集 watcher
  addSub(watcher) {
    this.subs.push(watcher)
  }
  // 通知subs 中的所有 watcher 去更新
  notify() {
    this.subs.forEach(watcher => watcher.update())
  }
}

// 当前渲染的 watcher,静态变量
Dep.target = null

export default Dep

Watcher

不同组件有不同的 watcher。我们先只需关注渲染watcher。计算属性watcer和监听器watcher后面会单独讲!

watcher 负责订阅 dep ,并在订阅的同时执行dep.addSub(),让 dep 也收集 watcher。当接收到 dep 发布的消息时(通过 dep.notify()),执行 update 重新渲染

当我们初始化组件时,在 mountComponent 方法内会实例化一个渲染 watcher,其回调就是 vm._update(vm._render())

javascript
import Watcher from './observe/watcher'

// 初始化元素
export function mountComponent(vm, el) {
  vm.$el = el

  const updateComponent = () => {
    vm._update(vm._render())
  }

  // true用于标识是一个渲染watcher
  const watcher = new Watcher(vm, updateComponent, true)
}

当我们实例化渲染 watcher 的时候,在构造函数中会把回调赋给this.getter,并调用this.get()方法。
这时!!!我们会把当前的渲染 watcher 放到 Dep.target 上,并在执行完回调渲染视图后,立即清空 Dep.target,保证了只有在模版渲染/更新阶段的取值操作才会进行依赖收集

javascript
import Dep from './dep'

let id = 

class Watcher {
  constructor(vm, fn) {
    this.id = id++
    this.getter = fn
    this.deps = []  // 收集当前 watcher 对应被观察者属性的 dep
    this.depsId = new Set()
    this.get()
  }
  // 收集 dep
  addDep(dep) {
    let id = dep.id
    // 去重,一个组件 可对应 多个属性 重复的属性不用再次记录
    if (!this.depsId.has(id)) {
      this.deps.push(dep)
      this.depsId.add(id)
      dep.addSub(this) // watcher已经收集了去重后的 dep,同时让 dep也收集 watcher
    }
  }
  // 执行 watcher 回调
  get() {
    Dep.target = this // Dep.target 是一个静态属性

    this.getter() // 执行vm._render时,会劫持到数据访问,调用 dep.depend() 进行依赖收集

    Dep.target = null // 渲染完毕置空,保证了只有在模版渲染阶段的取值操作才会进行依赖收集
  }
  // 重新渲染
  update() {
    this.get()
  }
}

我们是如何触发依赖收集的呢?

在执行this.getter()回调时,我们会调用vm._render() ,在_s()方法中会去 vm 上取值,这时我们劫持到数据访问走到 getter,进而执行dep.depend()进行依赖收集

流程:vm._render() ->vm.$options.render.call(vm) -> with(this){ return _c('div',null,_v(_s(name))) } -> 会去作用域链 this 上取 name

在 MDN 中是这样描述 with 的

JavaScript 查找某个未使用命名空间的变量时,会通过作用域链来查找,作用域链是跟执行代码的 context 或者包含这个变量的函数有关。'with'语句将某个对象添加到作用域链的顶部,如果在 statement 中有某个未使用命名空间的变量,跟作用域链中的某个属性同名,则这个变量将指向这个属性值

Observer

我们只会在 Observer 类 和 defineReactive 函数中实例化 dep。在 getter 方法中执行dep.depend()依赖收集,在 setter 方法中执行dep.notity()派发更新通知

依赖收集

依赖收集的入口就是在Object.defineProperty的 getter 中,我们重点关注2个地方,一个是在我们实例化 dep 的时机,另一个是为什么递归依赖收集。我们先来看下代码

javascript
class Observer {
  constructor(data) {
    // 给数组/对象的实例都增加一个 dep
    this.dep = new Dep()

    // data.__ob__ = this 给数据加了一个标识 如果数据上有__ob__ 则说明这个属性被观测过了
    Object.defineProperty(data, '__ob__', {
      value: this,
      enumerable: false, // 将__ob__ 变成不可枚举
    })
    if (Array.isArray(data)) {
      // 重写可以修改数组本身的方法 7个方法
      data.__proto__ = newArrayProto
      this.observeArray(data) 
    } else {
      this.walk(data)
    }
  }

  // 循环对象"重新定义属性",对属性依次劫持,性能差
  walk(data) {
    Object.keys(data).forEach(key => defineReactive(data, key, data[key]))
  }

  // 观测数组
  observeArray(data) {
    data.forEach(item => observe(item))
  }
}

// 深层次嵌套会递归处理,递归多了性能就差 
function dependArray(value) {
  for (let i = ; i < value.length; i++) {
    let current = value[i]
    current.__ob__ && current.__ob__.dep.depend()
    if (Array.isArray(current)) {
      dependArray(current)
    } 
  }
}

export function defineReactive(target, key, value) {
  // 深度属性劫持;给所有的数组/对象的实例都增加一个 dep,childOb.dep 用来收集依赖
  let childOb = observe(value)

  let dep = new Dep() // 每一个属性都有自己的 dep

  Object.defineProperty(target, key, {
    get() {
      // 保证了只有在模版渲染阶段的取值操作才会进行依赖收集
      if (Dep.target) {   
        dep.depend() // 依赖收集
        if (childOb) {
          childOb.dep.depend() // 让数组/对象实例本身也实现依赖收集,$set原理
          if (Array.isArray(value)) { // 数组需要递归处理
            dependArray(value)
          }
        }
      }
      return value
    },
    set(newValue) { ... },
  })
}

实例化 dep 的时机

我们只会在 Observer 类 和 defineReactive 函数中实例化 dep

  1. Observer类:在 Observer 类中实例化 dep,可以给每个数组/对象的实例都增加一个 dep
  2. defineReactive函数:在 defineReactive 方法中实例化 dep,可以让每个被劫持的属性都拥有一个 dep,这个 dep 是被闭包读取的局部变量,会驻留到内存中且不会污染全局

我们为什么要在 Observer 类中实例化 dep?

  • Vue 无法检测通过数组索引改变数组的操作,这不是 Object.defineProperty() api 的原因,而是尤大认为性能消耗与带来的用户体验不成正比。对数组进行响应式检测会带来很大的性能消耗,因为数组项可能会大,比如10000条
  • Object.defineProperty() 无法监听数组的新增

如果想要在通过索引直接改变数组成员或对象新增属性后,也可以派发更新。那我们必须要给数组/对象实例本身增加 dep 收集器,这样就可以通过 xxx.__ob__.dep.notify() 手动触发 watcher 更新了

这其实就是 vm.$set 的内部原理!!!

递归依赖收集

数组中的嵌套数组/对象没办法走到 Object.defineProperty,无法在 getter 方法中执行dep.depend()依赖收集,所以需要递归收集

举个栗子:data: {arr: ['a', 'b', ['c', 'd', 'e', ['f', 'g']], {name: 'libc'}]}

我们可以劫持 data.arr,并触发 arr 实例上的 dep 依赖收集,然后循环触发 arr 成员的 dep依赖收集。对于深层数组嵌套的['f', 'g'],我们则需要递归触发其实例上的 dep 依赖收集

派发更新

对于对象

在 setter 方法中执行dep.notity(),通知所有的订阅者,派发更新通知
注: 这个 dep 是在 defineReactive 函数中实例化的。 它是被闭包读取的局部变量,会驻留到内存中且不会污染全局

javascript
Object.defineProperty(target, key, {
  get() { ... },

  set(newValue) {
    if (newValue === value) return
    // 修改后重新观测。新值为对象的话,可以劫持其数据。并给所有的数组/对象的实例都增加一个 dep
    observe(newValue)
    value = newValue

    // 通知 watcher 更新
    dep.notify()
  },
})

对于数组

在数组的重写方法中执行xxx.__ob__.dep.notify(),通知所有的订阅者,派发更新通知

注: 这个 dep 是在 Observer 类中实例化的,我们给数组/对象的实例都增加一个 dep。可以通过响应式数据的__ob__获取到实例,进而访问实例上的属性和方法

javascript

let oldArrayProto = Array.prototype // 获取数组的原型
// newArrayProto.__proto__  = oldArrayProto
export let newArrayProto = Object.create(oldArrayProto)

// 找到所有的变异方法
let methods = ['push', 'pop', 'shift', 'unshift', 'reverse', 'sort', 'splice'] // concat slice 都不会改变原数组

methods.forEach(method => {
  // 这里重写了数组的方法
  newArrayProto[method] = function (...args) {
    // args reset参数收集,args为真正数组,arguments为伪数组
    const result = oldArrayProto[method].call(this, ...args) // 内部调用原来的方法,函数的劫持,切片编程

    // 我们需要对新增的数据再次进行劫持
    let inserted
    let ob = this.__ob__

    switch (method) {
      case 'push':
      case 'unshift': // arr.unshift(1,2,3)
        inserted = args
        break
      case 'splice': // arr.splice(0,1,{a:1},{a:1})
        inserted = args.slice(2)
      default:
        break
    }

    if (inserted) {
      // 对新增的内容再次进行观测
      ob.observeArray(inserted)
    }
分享好友

分享这个小栈给你的朋友们,一起进步吧。

趣谈前端
创建时间:2020-07-15 17:32:01
一个重度代码洁癖者,有对前端生态的总结,思考和探索。内容涵盖了笔者多年对vue,react,node,webpack以及javascript框架设计的探索和经验。公众号 - 趣谈前端
展开
订阅须知

• 所有用户可根据关注领域订阅专区或所有专区

• 付费订阅:虚拟交易,一经交易不退款;若特殊情况,可3日内客服咨询

• 专区发布评论属默认订阅所评论专区(除付费小栈外)

栈主、嘉宾

查看更多
  • xujiang
    栈主

小栈成员

查看更多
  • ?
  • victoria_ltt
  • asdjlk1
  • LCR_
戳我,来吐槽~