系列文章:
前面我们分析了关于Vue-nextTick方法的两个常见误区,因为文章比较长,分拆成两个部分以方便阅读。接下来我们将尝试自己实现一个vue的事件流程。
下面的内容涉及Vue的响应式原理,对Vue的执行原理有所了解的同学可能看起来会更容易理解。我会尽量用栗子和图片来解释清楚。
"拆解"nextTick和Vue事件流程
回顾一下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
等价,其实主要做了两件事:
- 将template模板替换为渲染函数;
- 使用浏览器版的Vue(vue.global.js)替换构建工具版本;
Step 2: 自行实现响应式更新
接下来我们尝试自行实现更新流程。 首先,复习一下Vue的异步更新设计。
vue的响应式原理简述
Vue的响应式原理,是在getter时收集依赖,在setter时触发数据更新和重新渲染。 简单来说,具体实现类似于常见"发布订阅模式":
- 每个数据项的背后都维护着一个"篮子"(用数组或Set实现),用来保存"突变函数"。
- 在getter时,进行依赖收集
- 在setter时依次触发这个"篮子"内的"突变函数"。
这种"突变函数",在Vue2版本中叫watcher,在Vue3版本中改为effect,即副作用函数。
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更新了。
大概流程如下图:
所以现在我们可以将Step1生成的代码进一步转换为:
// 省略部分代码
<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包裹DOM更新数据, 其中包裹的callback函数包含的setupResult数据更新时, callback函数将重新执行
effect(effectFn)
</script>
☝️上述代码执行效果跟示例SFC
等价。
step3: 加入异步调度机制
经过上一步代码,我们似乎已经实现了响应式,然而,与一般的"发布订阅模式"不同,Vue的突变函数经常会需要触发视图的更新。 假如在一次事件循环中发生多次响应式数据的修改,会触发多个effect
函数的调用,这将导致触发多次DOM的密集更新需求,即使有patch算法进行突变的合并,对于大型应用来说也很可能会产生性能问题。
为了优化该流程,Vue会将一次事件循环中的全部副作用函数压入一个事件栈,然后推迟到微任务阶段统一执行。 回顾上面提到的时间循环流程:
同步任务 > 微任务 > DOM渲染 > 宏任务
因为微任务统一执行后DOM才会进行渲染,所以通过这种方法,Vue能实现在一次事件循环中无论你进行了多少次状态同步更改,每个组件都只更新一次。
那么,这个调度流程是怎么实现的呢? 事实上,Vue的effect
函数支持第二个可选参数options,这是一个对象,包含以下可选属性:
其他属性暂时不考虑,我们只看scheduler这个属性,从类型定义可知,这个是通用的函数类型:
这个函数的作用是用来控制effect
的执行时机,当有传入options.scheduler
时,effect
被触发时并不是执行第一参数的callback函数,而是执行该scheduler
来替代**。 所以我们可以使用该options.scheduler来修改副作用的执行时机。
具体到我们当前的需求,就是用一个队列(Set或数组模拟)来缓存effect函数,每次触发effect
函数时,将该effect
推入该队列,并使用Promise推迟到微任务阶段统一执行。
简化代码如下:
<script type="module">
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
}
})
}
}
</script>
入队流程图大致如下:
到了这里,我们就完成了对示例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(() => {
// nextTick中打印出当前HTML内容
const text = appEl.textContent;
alert('当前Html内容: '+text); // log: '当前Html内容: 333'
})
/*****
此时queue = [
() => {appEl.innerHTML = '222'}
() => {appEl.innerHTML = '333'}
]
*****/
// nextTick后赋值为333
queueJob(() => {
appEl.innerHTML = '333';
});
})
</script>
</body>
</html>
至此,我们便实现了用原生JS实现的Vue事件流程。☝️上述代码基于原生JS, 应该都看得懂了,大家可以复制到浏览器中执行试试,执行结果与原代码一致。
从上面的代码我们可以看出,因为Vue副作用事件放在Promise-then里执行了,为了能获取到更新后的DOM信息,我们需要把相关代码"拖慢一点"执行,所以也放到Promse-then中执行,大家都同属微任务,保证统一步伐,避免执行顺序错乱。 nextTick的作用仅此而已。
大概可以下结论了
经过上述洋洋洒洒几千字的分析,我们现在可以下结论了。
Vue的事件流程并不神秘,只是简单的将一次事件循环中,响应式数据突变引起的副作用函数存储到一个微任务队列中,然后在事件循环的微任务处理阶段依次执行,最后触发DOM渲染。
nextTick并不是用于获取DOM渲染完成后的最终属性的。 因为Vue的响应式更新延迟,造成DOM的更新也是延迟的,当需要在代码中精确获取异步的DOM更新时,需要一个方法,来把执行代码"拖慢"到跟异步响应式更新同一步伐上。 从这个角度来看,nextTick可以认为是为了vue事件延迟更新的一个"补丁",如果没有涉及在同一个事件循环里进行多次数据更新,基本不需要使用nextTick。
nextTick也不是用来把代码推迟到下一次事件循环的,因为基于微任务实现的nextTick根本做不到这一点。 实践中如果确实需要推迟代码到下一个事件循环再执行,可以考虑自己用
setTimeout(fn, 0)
等宏任务方式实现。nextTick只能对发生在它"前面"的数据变化做出响应,而不能对发生在它"后面"的数据变化做出响应,这个是符合预期的,并非bug。