手写简化版Vue(六) 更新队列

144 阅读6分钟

本系列文章会以实现Vue的各个核心功能为标杆(初始化、相应式、编译、虚拟dom、更新、组件原理等等), 不会去纠结于非重点或者非本次学习目标的细节, 从头开始实现简化版Vue, 但是, 即使是简化, 也需要投入一定的时间和精力去学习, 而不可能毫不费力地学习到相对复杂的知识; 所有简化代码都会附上原版源码的路径, 简化版仅仅实现了基本功能, 如需了解更多细节, 可以去根据源码路径去阅读对应的原版源码;

同步渲染问题

前面, 我们完成了视图更新的最后一部分, 知道了其更新渲染流程为: 数据变更-> setter-> dep.notify-> watcher.update-> watcher.run-> render-> update-> 页面渲染并呈现; 我们来做一次更新:

import Vue from "/lib/Vue/platforms/web/runtime-with-compiler.js";
const vm = new Vue({
  el: '#app',
  template: '<div>{{name}}</div>',
  data () {
    return {
      name: 'jack'
    }
  }
})
vm.name = 1

再把关键节点都给打印出来看看

不错一切都和我们的猜想一样, 那如果我们连续两次给name赋值会怎样?

const vm = new Vue({
  el: '#app',
  template: '<div>{{name}}</div>',
  data () {
    return {
      name: 'jack'
    }
  }
})
vm.name = 1
vm.name = 2

我们会发现, 最后的render、update竟然每次都走了, name = 1 其实没必要展示在页面上的, 为了一个不需要展示的数据, 更新整个页面, 这似乎有些不妥, 根据前面对update的学习, 我们知道, 虽然diff算法做了很多优化, 但它终究还是存在一定的性能消耗的, 那么vue又是如何优化的呢? 大家在写vue的时候一定见过这种现象:

<template>
  <div ref="name">{{name}}</div>
</template>
<script>
  export default {
    data () {
      return {
        name: '小明'
      }
    },
    methods: {
      getData () {
        this.name = '大明'
        console.log(this.$refs.name) // 小明
      }
    }
  }
<script>

没错, 我们明明已经更新name为'大明'了, 可节点还是'小明', 这是因为我们的代码是同步执行的, 而Vue的更新是异步的! 而这个异步, 正是Vue解决反复渲染的方案! 来看看vue是如何实现的吧、

异步渲染

我们刚才的渲染顺序中, watcher.update后面就直接watcher.run了, 这其实是之前为了更好理解响应式而做的简化,现在这里应该是这样的

// 源码路径: /src/core/observer/watcher.ts
import { isFunction } from '../../shared/util'
import { pushTarget } from './dep'
import { queueWatcher } from './scheduler' // 增加

let uid = 0
export default class Watcher {
  constructor(vm, expOrFn) {
    this.vm = vm
    this.depIds = new Set()
    this.id = ++uid
    if (isFunction(expOrFn)) {
      this.getter = expOrFn
    }
    this.get()
  }
  get () {
    const vm = this.vm
    pushTarget(this)
    let value = ''
    try {
      value = this.getter.call(vm, vm)
    } catch (e) {
      console.log(e.message)
    }
    return value
  }
  addDep (dep) {
    const id = dep.id
    if (!this.depIds.has(id)) {
      this.depIds.add(id)
      dep.addSub(this)
    }
  }
  update () {
    console.log('watcher.update')
    // this.run()
    queueWatcher(this) // 增加
  }
  run () {
    console.log('watcher.run')
    this.get()
  }
}

queueWatcher

我们将udpate中直接run, 改为了调用queueWatcher, 即watcher队列方法

// 源码路径: /src/core/observer/scheduler.ts
import { nextTick } from "../util/next-tick"
const queue = []
let has = {}
//
let waitting = false
// 是否正在更新中
let flushing = false
// 当前更新到的watcher的下标
let index = 0
// 更新队列
function flushSchedulerQueue () {
  flushing = true
  let watcher, id
  for (index = 0; index < queue.length; ++index) {
    watcher = queue[index]
    id = watcher.id
    // 置为null, 说明这个watcher的依赖如果再有变化,则可以更新
    has[id] = null
    // 最终执行更新
    watcher.run()
  }
  resetSchedulerState()
}

// 重置队列
function resetSchedulerState () {
  queue.length = 0
  waitting = flushing = false
}

// 添加watcher
export function queueWatcher (watcher) {
  const id = watcher.id
  // 如果已经记录了这个watcher, 则不再重复记录
  if (has[id]) {
    return
  }
  has[id] = true
  // 如果不是正在执行队列中的方法
  if (!flushing) {
    // 添加到watcher的队列中
    queue.push(watcher)
  // 如果正在执行队列中的watcher
  } else {
    const i = queue.length - 1
    // 后续的逻辑可以理解为:
    // 如果i已经是正在执行的watcher的下标了
    // 或者下标为i的watcher的id小于或等于当前传入的watcher.id
    // 则执行splice替换
    while (i > index && queue[i].id > watcher.id) {
      i--
    }
    queue.splice(i + 1, 0, watcher)
  }
  // 这里的waitting其实就是为了防止nextTick被重复执行
  // flushSchedulerQueue被执行后, 才会被重新置为false
  // 此处才能重新再执行一次nextTick
  if (!waitting) {
    waitting = true
    nextTick(flushSchedulerQueue)
  }
}

代码小节:

  1. 首先判断传入的这个watcher.id是否存在于has, 如果已存在, 则说明该watcher已经进入待执行队列, 无需再重新去执行了; 这里我们要清楚, 一个watcher 其实就是一段更新逻辑, 比如, 我们的视图部分的更新, 那么最后负责这项工作的其实就是一个watcher, 所以无论你的视图上有多少响应式变量, 它们对应的watcher, 都是同一个; 所以这里要防止在同一批次的更新中, watcher被重复加入;
  2. 注意flushing和waitting这两个变量, flushing=true表示flushSchedulerQueue表示是否正在执行; 而waitting=true表示nextTick在本轮更新中是否有被调用且未结束,它们是很完美的配合:
    1. 首次进入, 执行一次nextTick, waitting=true, 那么在本轮后续在更新完成前, 不得再执行nextTick
    2. 后续进入, 如果, 如果flushing为false, 则直接queque.push(watcher); 如果flushing为true, 则说明队列已经开始执行, 则通过queue.splice的方式, 将当前watcher插入对列;
    3. 更新完成, 执行resetSchedulerState, 将flushing和waitting都设置为false, 状态重置;

nextTick

接着来看下nextTick方法:

import { isIE, isIOS, isNative } from "./env"
import { noop } from "../../shared/util"
const callbacks = []
let pending = false
let timerFunc

// 初始化定义异步方法
// 注意, 这里的逻辑总体是微任务优先
if (typeof Promise !== 'undefined' && isNative(Promise)) {
  const p = Promise.resolve()
  timerFunc = () => {
    p.then(flushCallbacks)
    // 在ios环境下, Promise.then的回调方法被推入微任务队列后, 微任务队列会出现不刷新的问题
    // 所以, 强制执行一个setTimeout, 其回调为一个空的方法
    if (isIOS) setTimeout(noop)
  }
// 如果Promise不存在, 则退而求其次使用MutationObserver
// IE11不支持MutationObserver
} else if (!isIE
  && typeof MutationObserver !== 'undefined' &&
  (isNative(MutationObserver) ||
  // PhantomJS 和 IOS7.x环境下
  MutationObserver.toString() === '[object MutationObserverConstructor]')) {
    let counter = 1
    const observer = new MutationObserver(flushCallbacks)
    const textNode = document.createTextNode(String(counter))
    observer.observe(textNode, {
      characterData: true
    })
    timerFunc = () => {
      counter = (counter + 1) % 2
      textNode.data = String(counter)
    }
// 如果mutationObserver在当前环境也不支持, 则使用setImmediate
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  timerFunc = () => {
    setImmediate(flushCallbacks)
  }
// 如果以上都不存在, 则使用setTimeout这个宏任务
} else {
  timerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}

// 执行callbacks中的所有方法
function flushCallbacks () {
  pending = false
  // 对数组进行浅拷贝, 这样, 即使callbacks被指为空, copies中的函数引用也仍然存在
  const copies = callbacks.slice(0)
  // 置空callbacks
  callbacks.length = 0
  for (let i = 0; i < copies.length; ++i) {
    copies[i]()
  }
}

export function nextTick (cb, ctx) {
  let _resolve
  // 将所有传入的方法放入callbacks队列中
  callbacks.push(() => {
    if (cb) {
      try {
        cb.call(ctx)
      } catch (e) {
        console.log(e.message)
      }
    } else if (_resolve) {
      _resolve(ctx)
    }
  })
  // 如果此时已经不是pending状态了, 则可继续
  if (!pending) {
    pending = true
    // 执行异步方法
    timerFunc()
  }
  // 如果没有传入第一个参数, 则返回一个promise
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}

从上面的timerFunc可以看出, Vue的更新队列, 是以micro task(微任务)为主; 而为何要以微任务为主呢? 其实在Vue2.5之前, 使用的是微任务, 但是, 由于微任务优先级过高, 很容易导致其在两个冒泡事件之间触发, 详情见issue #6866, 但是如果采用采用全是macro task宏任务, 则会导致动画过程中出现异常, 详情见 issue #6813, 另外还有7109, #7153, #7546, #7834, #8109等问题; 说白了, 宏任务/微任务虽然都存在缺陷, 但现实中, 宏任务出现问题的频率还是更高; 所以在Vue2.5之后, 虽然仍旧保持微任务优先,但是对事件部分做了一些处理, 保证了了事件不受更新; 具体做了哪些操作, 后续事件章节中会进行介绍;

往期回顾

手写简化版Vue(一) 初始化

手写简化版Vue(二) 响应式原理

手写简化版Vue(三) 编译器原理

手写简化版Vue(四) render的实现

手写简化版Vue(五) _update的实现解析