【手写 Vue2.x 源码】第二十四篇 - 异步更新流程

文章介绍了Vue的异步更新机制,包括为什么需要异步更新、更新的实现思路、数据变更缓存的位置、如何缓存watcher更新逻辑以及$nextTick的使用。通过队列和防抖策略,确保多次数据变更只执行一次视图更新。最后讨论了优化异步更新的方法,减少不必要的Promise创建。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

一,前言

上篇,介绍了 Vue依赖收集的视图更新部分,主要涉及以下几点:

视图初始化时:

  • render方法中会进行取值操作,进入 Object.defineProperty 的 get 方法
  • get 方法中为数据添加 dep,并记录当前的渲染 watcher
  • 记录方式:watcher查重并记住 dep,dep 再记住 watcher

数据更新时:

  • 当数据发生改,会进入 Object.defineProperty 的 set 方法
  • 在 set 方法中,使 dep 中收集的全部 watcher 执行视图渲染操作 watcher.get()
  • 在视图渲染前(this.getter方法执行前),通过 dep.target 记录当前的渲染 watcher
  • 重复视图初始化流程

本篇,介绍 Vue 的异步更新流程


二,异步更新的实现

1,为什么要做异步更新

上文末尾提到了一个问题:

当前代码版本,在视图渲染阶段会进行依赖收集,数据改变将通知所有被收集的渲染 watcher更新视图:

// dist/index.html

let vm = new Vue({
  el: '#app',
  data() {
    return { name: "Brave" , age: 123}
  }
}); 

vm.name = "Brave Wang";
vm.name = "Brave";
vm.name = "Brave Wang";
vm.name = "Brave";
vm.name = "Brave Wang";
vm.name = "Brave";

在这种情况下,频繁地更新同一数据,就会多次触发视图渲染dep.notify ---> watcher.update

image.png

虽然name的值总共变化了6次,但实际上,只要最后一次更新视图就可以;

由于当前采用同步调用watcher.update进行更新,即数据变化一次就会触发一次视图更新操作;

想要做到仅在最后执行一次视图更新操作,这就需要将视图的更新,改造为“异步更新”机制;

2,异步更新的实现思路

当数据发生变化时,先将数据变更的逻辑缓存起来,不直接处理;如果有相同数据更新就进行合并,在最后仅执行一次视图更新操作;

Vue中,vue.nextTick方法就可以实现异步更新;

  • 同步更新,每次数据更新都会同步调用update方法;
  • 异步更新,先将更新逻辑Watcher缓存起来,合并后一起处理;

3,数据变更缓存的位置

数据变更就会进入setter方法,但并不能在setter中进行做缓存:

  • 因为,数组的变化是不会进入setter的;
  • 但是,不论何种数据变化,最终视图渲染都将汇集到watcher.update方法

所以,在watcher.update方法中进行watcher的缓存是最合适的;

4,缓存 watcher 更新逻辑

缓存的思路:

watcher集中缓存到一个队列中,在缓存过程中进行合并,最后一次性执行;

由于此时为异步代码,当逻辑全部执行完成后,才会将队列中的watcherrun执行;

vue中,存在一个任务调度方法:src/observe/schedule.js:

queueWatcher方法:缓存队列,用于watcher的去重和缓存(唯一标识 id);

// src/observe/schedule.js

let queue = [];           // 用于缓存渲染 watcher
let has = {};             // 存放 watcher 唯一 id,用于 watcher 的查重
let pending = false;      // 等待状态标记,用于控制 setTimeout 只走一次

/**
 * 对 watcher 进行查重并缓存,最后统一执行更新
 * @param {*} watcher 需更新的 watcher
 */
export function queueWatcher(watcher) {
  let id = watcher.id;
  if (has[id] == null) {  // has 对象中没有当前 watcher
    has[id] = true;       // 缓存标记
    queue.push(watcher);  // 缓存 watcher 但不调用,后续统一处理
    if (!pending) {       // 等效于防抖,内部逻辑只执行依次
      setTimeout(() => {  // 执行 watcher
        queue.forEach(watcher => watcher.run()) // 依次触发视图更新
        queue = [];       // reset...
        has = {};         // reset...
        pending = false;  // reset....
      }, 0);
      pending = true;     // 首次进入被置为 true,使微任务执行完成后宏任务才执行
    }
  }
}

pending标记的解释:

  • queueWatcher方法中,同一 watcher 只会保存一次,不同 watcher 就会多次加入到queue队列中,pending标记用于控制setTimeout中的watcher批量执行逻辑仅执行一次,相当于一个防抖操作(多次执行只走一次);
  • setTimeout是宏任务,相当于异步代码,当全部watcher存入队列后,就会执行内部的批量执行逻辑;

Watcher类的update方法中使用queueWatcher缓存队列,为Watcher类添加run方法执行视图更新操作,从而实现异步更新:

// src/observe/watcher.js

import Dep from "./dep";
import { queueWatcher } from "./scheduler";

let id = 0;
class Watcher {
  constructor(vm, fn, cb, options){
    this.vm = vm;
    this.fn = fn;
    this.cb = cb;
    this.options = options;
    this.id = id++;
    this.depsId = new Set();
    this.deps = [];
    this.getter = fn;
    this.get();
  }
  addDep(dep){
    let did = dep.id;
    if(!this.depsId.has(did)){
      this.depsId.add(did);
      this.deps.push(dep);
      dep.addSub(this); 
    }
  }
  get(){
    Dep.target = this; 
    this.getter();
    Dep.target = null; 
  }
  update(){
    console.log("watcher-update", "查重并缓存需要更新的 watcher")
    queueWatcher(this);
  }
  run(){
    console.log("watcher-run", "真正执行视图更新")
    this.get();
  }
}

export default Watcher;

测试异步更新:

image.png

观察控制台输出:6次同步更新操作被查重并缓存,仅在最后执行了一次异步更新操作;

5,代码重构

  • 1,优化nextTick异步方案:改用promise包装的方案来实现异步操作;
// src/utils.js

/**
 * 将方法异步化
 * @param {*} fn 需要异步化的方法
 * @returns 
 */
export function nextTick(fn) {
  return Promise.resolve().then(fn);
}
  • Vue3 的nextTick就是采用 Promise 实现的;
  • Vue2 中做了一些兼容性处理;这里的源码实现暂不考虑兼容问题;
  • 2,将刷新队列逻辑,抽取为独立方法flushschedulerQueue

setTimeiout内部逻辑用于刷新队列:执行所有watcher.run并清空队列;

/**
 * 刷新队列:执行所有 watcher.run 并将队列清空;
 */
function flushschedulerQueue() {
  queue.forEach(watcher => watcher.run()) // 依次触发视图更新
  queue = [];       // reset
  has = {};         // reset
  pending = false;  // reset
}

  • 重构完成后的代码
**
 * 将 watcher 进行查重并缓存,最后统一执行更新
 * @param {*} watcher 需更新的 watcher
 */
export function queueWatcher(watcher) {
  let id = watcher.id;
  if (has[id] == null) {
    has[id] = true;
    queue.push(watcher);
    if (!pending) {
      nextTick(flushschedulerQueue); // 改造后,使用 nextTick
      pending = true;
    }
  }
}

6,测试异步更新

<div id="app">
  <li>{{name}}</li>
  <li>{{name}}</li>
  <li>{{name}}</li>
  <li>{{age}}</li>
</div>

let vm = new Vue({
  el: '#app',
  data() {
    return { name:  "Brave"}
  }
}); 

vm.name = "Brave Wang";
console.log("数据更新后立即获取 dom", vm.$el.innerHTML);

控制台输出结果:

image.png

此时,观察控制台输出,打印的dom元素仍为旧值,这时由于vm.name已使用异步更新;

那么,如何获取更新后的dom元素呢?

7,获取更新后的 dom

Vue中,可以使用vm.$nextTick方法,获取到异步更新后的值;

所以,在Vue初始化阶段initMixin方法中,添加原型方法$nextTick:

// src/init.js

import { nextTick } from "./utils";

export function initMixin(Vue) {·
  Vue.prototype._init = function (options) {...}
  Vue.prototype.$mount = function (el) {...}
  
  // 为 Vue 扩展原型方法 $nextTick
  Vue.prototype.$nextTick = nextTick;
}

功能测试:

let vm = new Vue({
  el: '#app',
  data() {
    return { name:  "Brave"}
  }
}); 

vm.name = "Brave Wang";
console.log("数据更新后立即获取 dom", vm.$el.innerHTML);

vm.$nextTick(()=>{
  console.log("$nextTick获取更新后的 dom", vm.$el.innerHTML);
})

image.png

这样,就实现了$nextTick原型方法,并通过此方法获取到异步更新后的dom元素值;


三,异步更新实现的优化

在上边的实现中,共创造了两个promise

  • 第一次,在更新数据时,创造了一个promise
  • 第二次,在nextTick中,又创造了一个promise
  • 第一个promise先执行,第二个promise再执行;

所以,第二个拿到的,其实是第一个成功后的结果;

这里可以进行优化,合并成为一个promise,与watcher执行异步更新的原理相似:

  • 当更新数据时,先将更新逻辑缓存起来;
  • 当用户通过nextTick取值时,再将取值逻辑也缓存起来;
    将两个逻辑缓存到一个数组,在同一个微任务中全部执行即可;

这样一来,整个过程中就只创建了一个promise:

// src/utils.js

let callbacks = [];   // 缓存异步更新的 nextTick
let waiting = false;  // 是否等待清空中

/**
 * 将方法异步化(与 watcher 的异步更新实现相似)
 * @param {*} fn 需要异步化的方法
 * @returns 
 */
export function nextTick(fn) {
  // return Promise.resolve().then(fn);
  callbacks.push(fn); // 缓存异步更新的 nextTick,后续统一处理
  if(!waiting){       // 如果未处于等待状态,就触发一次执行,并置位
    Promise.resolve().then(flushsCallbacks);
    waiting = true;   // 首次进入被置为 true,控制逻辑只走一次
  }
}

/**
 * 依次执行缓存的 nextTick,并清空队列
 */
function flushsCallbacks() {
  callbacks.forEach(fn => fn())
  callbacks = [];   // reset
  waiting = false;  // reset
}

callbacks中:

  • 第一次传入的fn,一定是来自内部的;
  • 第二次传入的fn,才是用户自己写的;

先将两个fn的逻辑缓存起来,之后再一次性执行更新;

这样,就实现了将用户的nextTick和内部更新的nextTick合并在一起;

Promise.then是一个微任务;

测试效果:

let vm = new Vue({
  el: '#app',
  data() {
    return { name:  "Brave"}
  }
}); 

vm.name = "Brave Wang";
console.log("数据更新后立即获取 dom", vm.$el.innerHTML);

vm.$nextTick(()=>{
  console.log("$nextTick获取更新后的 dom", vm.$el.innerHTML);
})
vm.$nextTick(()=>{
  console.log("$nextTick获取更新后的 dom", vm.$el.innerHTML);
})
vm.$nextTick(()=>{
  console.log("$nextTick获取更新后的 dom", vm.$el.innerHTML);
})

在以上代码示例中,共包含了4nextTick

  • 1 次更新数据的 nextTick
  • 3 次用户实现的 nextTick

4nextTick,但只创建了一个Promise,在同一个微任务中全部执行;

注意:这里多个nextTick只执行了一次then,而非多次;

测试结果:

image.png


四,结尾

本篇,主要介绍了 Vue 的异步更新流程,主要涉及以下几点:

  • 为什么要做异步更新
  • 异步更新的实现思路
  • 数据变更缓存的位置
  • 缓存 watcher 更新逻辑
  • vm.$nextTick 获取更新后的 dom
  • 测试异步更新

下一篇,数组的依赖收集


维护记录

  • 20230207:优化异步更新内容描述和排版、添加了必要的逻辑说明;添加了代码注释,添加内容中的代码高亮;添加同步更新逻辑、异步更新逻辑的相关测试截图;更新文章摘要;
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

BraveWangDev

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值