【系列 3】vue依赖收集原理与nextTick实现

1,036 阅读7分钟

一、vue响应式数据依赖收集原理

vue收集依赖的步骤:

  1. Watcher监听: 一个组件一个Watcher,每次执行 updateComponent 更新当前组件时创建一个 Watcher(监听者) => Dep.target用来监听该组件执行 render 访问了多少个 data 内的响应式数据,触发了多少 get
  2. 响应式get订阅: 每个响应式数据触发 defineProperty 前创建一个 Dep(发布者)。 用来当 1 过程触发了一个 get 时就拿到 1 中的 Dep.target 在其内部记录这个 Dep, 并在 Dep 内也记录这个 Dep.target
    (说人话就是:Watcher知道这个组件被多少个响应式数据影响到,Dep知道我这个响应式数据影响了多少组件)
  3. 响应式set发布: 当某个响应式数据改变 触发set,通过Dep它知道要更新多少个组件,执行多少下 updateComponent (Watcher怎么拿到updateComponent方法:创建Watcher时就存储了该组件的 updateComponent方法)

【系列 2】手写vue模板编译 我们知道生成后的 render 函数如下:

render = new Function('with(this){return _c("div", {id:"app"},_v(_s(name)))}')   

render 最终将交给 mountComponent 内部调用 -> 生成vnode -> diff对比 vnode 生成 dom 渲染页面

function mountComponent(vm) {
  // 实现页面的挂载流程
  const updateComponent = () => {
    // 调用render函数生成 vnode  -> 交给update diff对比 生成真实的dom
    vm.update(vm.render());
  }
  updateComponent(); // 响应式数据变化 也调用这个函数重新执行 
}

发现了吗? updateComponent 方法内当我们执行 render 时里面获取的 name 就是 this.name, 也就触发了响应式数据的 get 方法
这样为了 获取到该组件被多少个响应式数据影响到 时,我们就需要在 updateComponent 上下功夫了
如: vue-resolve/blob/vue-03/src/lifecycle.js 43行

// 通过 Watcher 监听 updateComponent
new Watcher(vm, updateComponent, ()=>{
  console.log('页面重新渲染 updated')
}, true)

Watcher 做了什么呢?

1. Watcher监听

let wid = 0
class Watcher {
  constructor(vm, updateComponent, cb, options) {
    this.vm = vm
    this.fn = updateComponent
    this.cb = cb
    this.options = options
    this.deps = []
    this.depsId = new Set()
    this.id = wid++
    this.get() // 实现页面的渲染
  }
  // 在执行 updateComponent 方法前设定了一个 Dep.target 执行完成后清空 Dep.target
  get() {
    Dep.target = this
    this.fn()
    Dep.target = null
  }
  addDep(dep) {
    let id = dep.id
    if (!this.depsId.has(id)) {
      this.deps.push(dep)
      this.depsId.add(id)
      dep.addWatcher(this)
    }
  }
  update() {
      this.fn() // 执行 updateComponent 方法 更新组件
  }
}

主要是设定一个 Dep.target2.响应式get订阅 时能知道 Watcher 是谁, Watcher里面存有 updateComponent 方法,侧面就知道了该响应式数据对应更新的组件

2. 响应式get订阅

上面执行的 updateComponent 内部执行了 render 方法, 自然触发了响应式数据的 get 方法

Object.defineProperty(data, key, {
    get() {
      if (Dep.target) {
        dep.depend(); // 依赖收集 要将属性收集对应的 watcher
        if (childOb) {
          childOb.dep.depend(); // 让数组和对象也记录一下渲染 watcher
          if (Array.isArray(value)) {
            dependArray(value);
          }
        }
      }
      return value;
    }
    ...
}
class Dep {
  constructor() {
    this.id = did++
    this.watchers = []
  }
  depend() {
    // 此时 Dep.target 就是上面的 Watcher
    Dep.target.addDep(this) // 1. 让 watcher 去记录 dep
  }
  addWatcher (watcher) {    // 2. 让 dep 去记录 watcher
    this.watchers.push(watcher)
  }
  notify () {
    this.watchers.forEach(watcher => watcher.update()) // 批量执行 updateComponent 方法 更新组件 (可以看看上面 watcher 中的 update 方法)
  }
}

主要目的是让 Watcher知道这个组件被多少个响应式数据影响到,Dep知道我这个响应式数据影响了多少组件

3. 响应式set发布

Object.defineProperty(data, key, {
    get() {
      ...
    }
    set(newValue) {
      if (newValue === value) return;
      childOb = observe(newValue);
      value = newValue;
      dep.notify(); // 执行 updateComponent 方法 更新组件 (可以看看上面 Dep 中的 notify 方法)
    }
}

dep.notify() 批量执行 updateComponent 方法 更新组件

由上可知:每次改变一个响应式数据就会调用影响到的所有组件的 updateComponent 方法更新组件
那么我如下操作:

<template>
    <div>{{ name }}</div>
</template>

export default {
    data: () => ({ name: '小米' })
    mounted() {
       this.name = 1  // 触发本组件的 updateComponent 方法
       this.name = 2  // 触发本组件的 updateComponent 方法
    }
}

是不是我改多少次响应式数据就更新多少次组件了, 尤大心想: “这我肯定要想办法解决这个问题, 我的vue跟react的区别就在于这,react是手动挡它改完数据需要手动执行 setState() 方法才更新组件,这太low了,我要让数据与视图自动化响应式。” 也就是所谓的 MVVM

那怎么解决多次修改响应式数据频繁更新组件呢 ?
⊙o⊙ 办法有了,就用 js任务队列 来解决它吧!

js任务队列运行机制解决组件频繁更新

在事件循环中,每进行一次循环操作称为 tick,每一次 tick 的任务 处理模型 是比较复杂的,但关键步骤如下:

  • 执行一个宏任务(栈中没有就从事件队列中获取)
  • 执行过程中如果遇到微任务,就将它添加到微任务的任务队列中
  • 宏任务执行完毕后,立即执行当前微任务队列中的所有微任务(依次执行)
  • 当前宏任务执行完毕,开始检查渲染,然后GUI线程接管渲染
  • 渲染完毕后,JS线程继续接管,开始下一个宏任务(从事件队列中获取)

具体实现原理:

<template>
    <div>{{ name }}</div>
</template>

export default {
    data: () => ({ name: '小米' })
    mounted() {
        this.name = 1  // 触发本组件的 updateComponent 方法
        
        // 此时我们将 updateComponent 放入一个队列里,等待宏任务执行完成后遍历执行队列里的 updateComponent 了
        let queue = []
        queue.push(updateComponent)
        // 1. 通过 Promise 来实现异步执行 (等所有响应式数据都修改后统一更新组件)
        let p =  Promise.resolve()
        p.then(() => {
            // 此时 queue 内就是所有同步代码执行完成后共同push的 updateComponent
            queue.forEach(cb => cb())
            queue = []
        })
        // 2. 如果浏览器不支持 Promise 还可以用 setTimeout (其实vue里面共兼容了 Promise, MutationObserver, setImmediate, setTimeout 这 4 种异步实现)
        // setTimeout(() => {
        //    queue.forEach(cb => cb())
        //    queue = []
        // }, 0)

        this.name = 2  // 触发本组件的 updateComponent 方法
        queue.push(updateComponent)
    }
}

很简单就是用一个异步事件,让正常代码都执行完成后才更新受影响的组件
当然,当 queue 内已经存在相同 updateComponent 时就不需要再次 push 进去。这样就避免了多个响应式数据对应一个组件的情况

那我们把它包装成一个公共方法,方法名就叫 nextTick

二、nextTick实现

vue 源码中的 next-tick.js ,其实 nextTick 这个公共方法的原理就是开启一个异步微任务,把回调方法 cb 放微任务里面, 等待js宏任务代码都执行完成后才执行 cb 回调, 这样就有效避免了频繁更新组件

// nextTick.js 基本实现
let callbacks = []
let waiting = false
function flushCallbacks() {
  callbacks.forEach(cb => cb())
  callbacks = []
  waiting = false
}

let timeFunc
// 这里的 if 就是实现兼容性, 通过每个浏览器对js的支持程度,选择不同的微任务实现
if (typeof Promise !== 'undefined') {
  let p = Promise.resolve()
  timeFunc = () => {
    p.then(flushCallbacks)
  }
} else if (typeof MutationObserver !== 'undefined') {
  let observer = new MutationObserver(flushCallbacks) // mutationObserver放的回调是异步执行的
  let textNode = document.createTextNode(1) // 文本节点内容先是 1
  observer.observe(textNode,{ characterData: true })
  timeFunc = () => {
    textNode.textContent = 2 // 改成了2  就会触发更新了
  }
} else if (typeof setImmediate !== 'undefined') {
  timeFunc = () => {
    setImmediate(flushCallbacks)
  }
} else {
  timeFunc = () => {
    setTimeout(flushCallbacks, 0) // 所有浏览器都支持 setTimeout,所以最终都没有就使用 setTimeout
  }
}

export function nextTick(cb) {
  callbacks.push(cb)
  if (!waiting) {
    waiting = true
    timeFunc()
  }
}

那 vue 是怎么用 nextTick 的呢?

1. 响应式数据修改 触发set 执行 dep.notify()

Object.defineProperty(data, key, {
    get() {
      ...
    },
    set(newValue) {
      if (newValue === value) return;
      childOb = observe(newValue);
      value = newValue;
      dep.notify(); // 通知依赖的watcher去重新渲染
    },
  });

2. notify 遍历执行所有 watcher.update() (本质就是将当前 Watcher 送入 queue 队列)

class Dep {
  constructor() {
    this.id = did++
    this.watchers = []
  }
  ...
  notify () {
    this.watchers.forEach(watcher => watcher.update()) // 遍历所有 watcher
  }
}

为什么把 watcher 送入队列, 因为 watcher 内存储了 updateComponent 方法, 等待js宏任务都执行完成后就依次执行 watcher 内的 updateComponent 方法, 组件就一并更新了

class Watcher {
  constructor(vm, updateComponent, cb, options) {
    this.fn = updateComponent
    ...
  }
  get() {
    Dep.target = this
    this.fn() // fn 就是 updateComponent
    Dep.target = null // 只有在渲染的时候才有Dep.target属性
  }
  update() {
    queueWatcher(this) // 就是开启一个异步方法将当前 Watcher 送入 queue 队列
  }
  run() {
    this.get() // 重新执行 updateComponent
  }
}  

3. 使用 queueWatcher 将组件 Watcher 存入 queue 队列,并开启一个 nextTick,等待宏任务执行完成后就遍历 queue 执行 Watcher内的 updateComponent 更新组件

queueWatcher 具体实现代码

import { nextTick } from "../utils/nextTick"
let queue = []
let has = {}
let pending = false

function flushSchedularQueue() {
  queue.forEach(Watcher => Watcher.run()) //  组件一并更新
  queue = []
  has = {}
  pending = false
}

// 重点在这里
export function queueWatcher(watcher) {
  let id = watcher.id
  if (has[id] == null) {
    queue.push(watcher)
    has[id] = true
    // 宏任务内第一次更改响应式数据时进来创建一个 nextTick,后续更改直接 push 到创建好的 queue 即可
    if (!pending) {
      nextTick(() => { // 万一一个属性 对应多个更新,那么可能会开启多个定时器
        flushSchedularQueue() // 批处理操作 , 防抖
      })
      pending = true
    }
  }
}
手写 vue 代码仓库链接
GitHubgithub.com/shunyue1320…
Giteegitee.com/shunyue/vue…

【系列 2】手写vue模板编译 —— 上一页