likes
comments
collection
share

对Vue的nextTick和事件流程的“庖丁解牛”(下)

作者站长头像
站长
· 阅读数 13

系列文章:

前面分析了关于Vue-nextTick方法的两个常见误区,因为文章比较长,分拆成两个部分以方便阅读。接下来我们将尝试自己实现一个vue的事件流程。

下面的内容涉及Vue的响应式原理,对Vue的执行原理有所了解的同学可能看起来会更容易理解。我尽量用栗子和图片来解释清楚。


"拆解"nextTick和Vue事件流程

回顾一下Vue官方文档中对于nextTick的解释

对Vue的nextTick和事件流程的“庖丁解牛”(下)

看上面的解释,大概能猜到,nextTick应该是跟Vue的事件流程处理相关的。

搞清楚问题最好的方式是自己“写一遍”。为了能从根源上搞清楚Vue的更新原理,我们尝试用自己代码来实现一个最小化最简单的响应式更新过程。

上文验证误区二时, 我们使用了一个简单的例子:

// 示例SFC代码
<template>
  <div @click="modify">{{name}}</div>
</template>
<script setup>
import {ref, nextTick} from 'vue';
const name = ref("111");
const modify = () => {
  name.value = "222";
  nextTick(() => {
    const text = document.querySelector("div").innerText;
    alert(text);
  });
  name.value = "333";
};
</script>

这个例子简单且直接,适合作为验证示例。接下来我们将一步步对其进行改写,解除vue事件流程的封装,也许最后您会跟我一样,发现原来就是个简单的东西!

这个实例参考自这篇文章

Step 1: 将vue-sfc改写成浏览器版本

我们都知道,vue的sfc文件是vue自己提供的语法糖,需要预编译后才能在浏览器中运行,通常借助webpack+vue-loder实现。 为了能摆脱对构建工具的依赖,第一步我们先将上述示例SFC转成等价的html代码。 参考霍大的《Vue.js设计与实现》,等价代码如下:

<!DOCTYPE html>
<html lang="en">
<body>
  <div id="app"></div>
  <!-- vue.global.js是vue打包的能直接在现代浏览器中允许的版本 -->
  <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
  <script type="module">
    // Vue的浏览器版本会将常用API暴露在全局变量Vue中
    const { createApp, ref,h, nextTick  } = Vue
    const VueComp = {
      setup() {
        const name = ref('111');
        const handleClick = () => {
          name.value = '222';
          nextTick(() => {
            const text = document.querySelector("#app").textContent;
            alert('当前Html内容: '+text);
          });
          name.value = '333';
        }
        return {
          name,
        }
      },
      render(){
        return h('div', {onClick: () => handleClick() }, [name.value])
      }
    };
    const app = createApp(VueComp).mount('#app');
  </script>
</body>
</html>

以上☝️代码与示例SFC等价,其实主要做了两件事:

  1. 将template模板替换为渲染函数;
  2. 使用浏览器版的Vue(vue.global.js)替换构建工具版本;

Step 2: 自行实现响应式更新

接下来我们尝试自行实现更新流程。 首先,复习一下Vue的异步更新设计。

vue的响应式原理简述

Vue的响应式原理,是在getter时收集依赖,在setter时触发数据更新和重新渲染。 简单来说,具体实现类似于常见"发布订阅模式":

  • 每个数据项的背后都维护着一个"篮子"(用数组或Set实现),用来保存"突变函数"。
  • 在getter时,进行依赖收集
  • 在setter时依次触发这个"篮子"内的"突变函数"。

这种"突变函数",在Vue2版本中叫watcher,在Vue3版本中改为effect,即副作用函数

对Vue的nextTick和事件流程的“庖丁解牛”(下)

Vue3.0中的effect函数的作用,可以简单理解为:当effect函数"包裹"的callback内的"响应式数据"发生setter操作时,该callback将会重新执行:

const reactiveVal = ref('a'); // reactiveVal是个响应式数据
effect(function callback(){
    // callback中的响应式数据reactiveVal发生变化时, callback函数将重新执行。
    reactiveVal.value = 'b'; 
})

vue的组件更新机制

而Vue对组件的处理,实际上是使用了effect包裹了组件的render函数,所以当render函数中的响应式数据更新时,将触发重新执行render

我们都知道,render函数是用来返回vnode(虚拟DOM)树的,重新执行render就会生产新的vnode树,这样我们就拿到了新旧两棵vnode树了,然后就可以进行patch算法,进而触发dom更新了。

大概流程如下图: 对Vue的nextTick和事件流程的“庖丁解牛”(下)

所以现在我们可以将Step1生成的代码进一步转换为:

// 省略部分代码
const { createApp, ref,h, nextTick, render, effect  } = Vue;
const renderDOM = render; // 起个别名以方便跟render函数区分
const VueComp = {
  setup() {
    const name = ref('111');
    const handleClick = () => {
      name.value = '222';
      nextTick(() => {
        const text = document.querySelector("#app").textContent;
        alert('当前Html内容: '+text);
      });
      name.value = '333';
    }
    return {
      name,
      handleClick,
    }
  },
  render(){
    return h('div', {onClick: () => this.handleClick() }, [this.name.value])
  }
};
// 执行一次setup, 获得setup返回的响应式数据. 整个生命周期setup只会执行一次
const setupResult = VueComp.setup();

const effectFn = () => {
   // 将setupResult返回的数据bind到render函数中, 
   // 使render函数中可以使用this.xxx引用setup返回的数据
   const subTree = VueComp.render.call(setupResult);
   renderDOM(subTree, document.querySelector('#app'));
}
// effect包裹effectFn,这样render函数包含的响应式数据更新时,effectFn函数将重新执行
effect(effectFn)

☝️上述代码执行效果跟示例SFC等价。

step3: 加入异步调度机制

经过上一步代码,我们似乎已经实现了响应式,然而,与一般的"发布订阅模式"不同,Vue的突变函数经常会需要触发视图的更新。 假如在一次事件循环中发生多次响应式数据的修改,会触发多个effect函数的调用,这将导致触发多次DOM的密集更新需求,即使有patch算法进行突变的合并,对于大型应用来说也很可能会产生性能问题。

为了优化该流程,Vue会将一次事件循环中的全部副作用函数压入一个事件栈,然后推迟到微任务阶段统一执行。 回顾上面提到的时间循环流程:

同步任务 > 微任务 > DOM渲染 > 宏任务

因为微任务统一执行后DOM才会进行渲染,所以通过这种方法,无论你在一次事件循环中进行了多少次状态同步更改,每个Vue组件都只更新一次。

那么,这个调度流程是怎么实现的呢? 事实上,Vue的effect函数支持第二个可选参数options,这是一个对象,包含以下可选属性:

对Vue的nextTick和事件流程的“庖丁解牛”(下)

其他属性暂时不考虑,我们只看scheduler这个属性,从类型定义可知,这个是通用的函数类型:

对Vue的nextTick和事件流程的“庖丁解牛”(下)

这个函数的作用是用来调度effect的,当有传入options.scheduler时,effect被触发时并不是执行第一参数的callback函数,而是执行该scheduler来替代。我们可以使用该options.scheduler来修改effect的执行时机。

具体到我们当前的需求,就是用一个队列(Set或数组模拟)来缓存effect函数,每次触发effect函数时,将该effect推入该队列,并使用Promise推迟到微任务阶段统一执行

简化代码如下:

const { createApp, ref, h, nextTick, render, effect } = Vue;
const renderDOM = render; // 起个别名以方便跟render函数区分
const VueComp = {
  // 省略部分代码
  // ...
};
// 执行一次setup, 获得setup返回的响应式数据. 整个生命周期setup只会执行一次
const setupResult = VueComp.setup();

const effectFn = () => {
  // 将setupResult返回的数据bind到render函数中, 
  // 使render函数中可以使用this.xxx引用setup返回的数据
  const subTree = VueComp.render.call(setupResult);
  renderDOM(subTree, document.querySelector('#app'));
}

// 调用副作用函数. effect第二个参数为options, options.scheduler为effect的调度函数, 
// 将副作用相关函数effectFn传入queueJob函数来实现延迟执行
effect(effectFn, {
  scheduler: () => queueJob(effectFn),
});

// 创建一个缓存队列
const queue = new Set();
// 是否触发清理的flag, 避免重复执行;
let isFlushing = false;
// 延迟执行的入队函数
function queueJob(job) {
  queue.add(job);
  if (!isFlushing) {
    isFlushing = true;
    // 通过promise将队列任务的执行放到微任务队列
    Promise.resolve().then(() => {
      try {
        // 取出微任务队列, 逐个执行
        queue.forEach(job => job())
      } finally {
        // 微任务处理完成后重置flag
        isFlushing = false
      }
    })
  }
}

入队流程图大致如下: 对Vue的nextTick和事件流程的“庖丁解牛”(下)

到了这里,我们就完成了对示例SFC的事件流程的手动实现。 在浏览器中直接运行☝️,执行结果都与原代码一致。

完整代码为:

<!DOCTYPE html>
<html lang="en">
<body>
  <div id="app"></div>
  <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
  <script type="module">
    const { createApp, ref, h, nextTick, render, effect } = Vue;
    const renderDOM = render; // 起个别名以方便跟render函数区分
    const VueComp = {
      setup() {
        const name = ref('111');
        const handleClick = () => {
          name.value = '222';
          nextTick(() => {
            const text = document.querySelector("#app").textContent;
            alert('当前Html内容: '+text);
          });
          name.value = '333';
        }
        return {
          name,
          handleClick,
        }
      },
      render() {
        return h('div', { onClick: () => this.handleClick() }, [this.name.value])
      }
    };
    // 执行一次setup, 获得setup返回的响应式数据. 整个生命周期setup只会执行一次
    const setupResult = VueComp.setup();

    const effectFn = () => {
      // 将setupResult返回的数据bind到render函数中, 
      // 使render函数中可以使用this.xxx引用setup返回的数据
      const subTree = VueComp.render.call(setupResult);
      renderDOM(subTree, document.querySelector('#app'));
    }

    // 调用副作用函数. effect第二个参数为options, options.scheduler为effect的调度函数, 
    // 将副作用相关函数effectFn传入queueJob函数来实现延迟执行
    effect(effectFn, {
      scheduler: () => queueJob(effectFn),
    });

    // 创建一个缓存队列
    const queue = new Set();
    // 是否触发清理的flag, 避免重复执行;
    let isFlushing = false;
    // 延迟执行的入队函数
    function queueJob(job) {
      queue.add(job);
      if (!isFlushing) {
        isFlushing = true;
        // 通过promise将队列任务的执行放到微任务队列
        Promise.resolve().then(() => {
          try {
            // 取出微任务队列, 逐个执行
            queue.forEach(job => job())
          } finally {
            // 微任务处理完成后重置flag
            isFlushing = false
          }
        })
      }
    }
  </script>
</body>
</html>

Step 4: 原生JS实现的粗糙版本

最后,考虑到有些同学可能对Vue的effect机制不太了解,我们还可以进一步简化掉所有Vue相关的内容,以及vnode等其他我们这个示例不关注的内容,用原生JS实现一个粗糙的版本(对Step3代码能完全看懂的同学可以跳过该Step):

<!DOCTYPE html>
<html lang="en">
<body> 
  <div id="app"></div>
  <script>
    const appEl = document.querySelector('#app');
    appEl.innerHTML = '111'; // 初始值111
    
    appEl.addEventListener('click', () => {
      const queue = new Set()
      let isFlushing = false
      function queueJob(job) {
        queue.add(job)
        if (!isFlushing) {
          isFlushing = true
          Promise.resolve().then(() => {
            try {
              queue.forEach(job => job())
            } finally {
              isFlushing = false
            }
          })
        }
      }
     
      /*
      nextTick前赋值为222, 此时queue = []
      */ 
      queueJob(() => {
        appEl.innerHTML = '222';
      });
      /*****
      此时queue = [
          () => {appEl.innerHTML = '222'}
      ]
      *****/ 
      // nextTick使用Promse.resove().then()实现
      Promise.resolve().then(() => {
        const text = appEl.textContent;
        alert('当前Html内容: '+text);  // log: '当前Html内容: 333'
      })
       
      // nextTick后赋值为333
      queueJob(() => {
        appEl.innerHTML = '333';
      });
      /*****
      此时queue = [
          () => {appEl.innerHTML = '222'}
          () => {appEl.innerHTML = '333'}
      ]
      *****/
    })
  </script>
</body>
</html>

至此,我们便实现了用原生JS实现的一个“粗糙”的Vue事件流程。☝️上述代码基于原生JS, 应该都看得懂了,大家可以复制到浏览器中执行试试,执行结果与原代码一致。

从上面的代码我们可以看出,因为Vue副作用事件放在Promise-then里执行了,为了能获取到更新后的DOM信息,我们需要把相关代码"拖慢一点"执行,所以也放到Promse-then中执行,大家都同属微任务,保证统一步伐,避免执行顺序错乱。 nextTick的作用仅此而已。

对Vue的nextTick和事件流程的“庖丁解牛”(下)

大概可以下结论了

经过上述洋洋洒洒几千字的分析,现在差不多可以下结论了:

  1. Vue的事件流程并不神秘,只是简单的将一次事件循环中,响应式数据突变引起的副作用函数存储到一个微任务队列中,然后在事件循环的微任务处理阶段依次执行,最后触发DOM渲染。

  2. nextTick并不是用于获取DOM渲染完成后的最终属性的。 因为Vue的响应式更新延迟,造成DOM的更新也是延迟的,当需要在代码中精确获取异步的DOM更新时,需要一个方法,来把执行代码"拖慢"到跟异步响应式更新同一步伐上。 从这个角度来看,nextTick可以认为是为了vue事件延迟更新的一个"补丁",如果没有涉及在同一个事件循环里进行多次数据更新,基本不需要使用nextTick。

  3. nextTick也不是用来把代码推迟到下一次事件循环的,因为基于微任务实现的nextTick根本做不到这一点。 实践中如果确实需要推迟代码到下一个事件循环再执行,可以考虑自己用setTimeout(fn, 0)等宏任务方式实现。

  4. nextTick只能对发生在它"前面"的数据变化做出响应,而不能对发生在它"后面"的数据变化做出响应,这个是符合预期的,并非bug。


参考: