调试代码,让你了解vue3的三个问题:
- vue3中怎么给DOM添加事件处理函数?
- 为什么响应式变量多次修改,组件只渲染更新一次?
- 组件更新渲染函数和nextTick的执行时机?他们是怎样串联起来的?
1.弄个简单vue3示例
NextTick.vue
文件
<template>
<h1 id="AAA" @click="click">Hello World {{ countRef }}!</h1>
</template>
<script setup lang="ts">
import { nextTick, ref } from 'vue';
const countRef = ref<number>(0);
const click = () => {
countRef.value++;
countRef.value++;
countRef.value++;
const dom = document.getElementById('AAA')!;
console.log(dom.innerText);
nextTick(() => {
console.log(dom.innerText);
});
};
</script>
main.ts
文件
import App from './NextTick.vue';
import { createApp } from 'vue';
createApp(App).mount('#app');
2.编译后的代码
NextTick.vue
编译后代码,setup
函数运行返回所有变量和函数
const _sfc_main = /* @__PURE__ */ _defineComponent({
__name: "NextTick",
setup(__props, { expose: __expose }) {
__expose();
const countRef = ref(0);
const click = () => {
countRef.value++;
countRef.value++;
countRef.value++;
const dom = document.getElementById("AAA");
console.log(dom.innerText);
nextTick(() => {
console.log(dom.innerText);
});
};
const __returned__ = { countRef, click };
Object.defineProperty(__returned__, "__isScriptSetup", { enumerable: false, value: true });
return __returned__;
}
});
template模板编译成的render
函数中, @click
转化成onClick
,将setup
函数运行返回的结果中的$setup.click
函数作为事件处理函数。
function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock(
"h1",
{
id: "AAA",
onClick: $setup.click
},
"Hello World " + _toDisplayString($setup.countRef) + "!",
1
/* TEXT */
);
}
3.调试代码,弄清执行流程
怎么调试可以看我之前的文:vue3中ref为什么script中要用.value,而template模板中不需?
- 注意:该源码解读基于
Vue
的v3.5.13
版本.
(1)怎么给DOM添加事件处理函数?
- 初始化的时候,
patch
函数中根据type
和shapeFlag
两个参数根据渲染的组件类型分派到不同函数处理。
const patch = (n1, n2, container, anchor = null, parentComponent = null, parentSuspense = null, namespace = void 0, slotScopeIds = null, optimized = isHmrUpdating ? false : !!n2.dynamicChildren) => {
//...
const { type, ref: ref2, shapeFlag } = n2;
switch (type) {
case Text:
processText(n1, n2, container, anchor);
break;
case Comment:
processCommentNode(n1, n2, container, anchor);
break;
case Static:
//...
break;
case Fragment:
processFragment(/*...*/);
break;
default:
if (shapeFlag & 1) {//ELEMENT
processElement(/*...*/);
} else if (shapeFlag & 6) {//COMPONENT
processComponent(/*...*/);
} else if (shapeFlag & 64) {//TELEPORT
type.process(/*...*/);
} else if (shapeFlag & 128) {//SUSPENSE
type.process(/*...*/);
}
//...
}
//...
}
h1
是浏览器自带的组件元素ELEMENT
,shapeFlag&1
为true,走processElement
处理。- 因为是初始化渲染真实DOM,没有旧虚拟DOM,走
mountElement
const processElement = (n1, n2, container, anchor, parentComponent, parentSuspense, namespace, slotScopeIds, optimized) => {
if (n1 == null) {
mountElement(/*...*/);
} else {
patchElement(/*...*/);
}
}
mountElement
中利用hostCreateElement
创建真实的DOM元素,hostPatchProp
设置属性值和事件处理函数,并使用hostInsert
挂载到父级DOM
const mountElement = (vnode, container, anchor, parentComponent, parentSuspense, namespace, slotScopeIds, optimized) => {
//...
const { props, shapeFlag, transition, dirs } = vnode;
el = vnode.el = hostCreateElement(/*...*/);
}
if (shapeFlag & 8) {//TEXT_CHILDREN文本内容子级
hostSetElementText(el, vnode.children);
} else if (shapeFlag & 16) {//ARRAY_CHILDREN数组子级组件
mountChildren(/*...*/);
}
//...
if (props) {
for (const key in props) {
//非保留关键词的属性名,赋值到真实DOM
if (key !== "value" && !isReservedProp(key)) {
hostPatchProp(el, key, null, props[key], namespace, parentComponent);
}
}
//...
}
//...
hostInsert(el, container, anchor);
//...
}
patchProp
中根据属性名分别处理,判断否为是动作事件,如果是则通过patchEvent
添加事件处理函数。
const isOn = (key) => key.charCodeAt(0) === 111 && key.charCodeAt(1) === 110 && // uppercase letter
(key.charCodeAt(2) > 122 || key.charCodeAt(2) < 97);
const isModelListener = (key) => key.startsWith("onUpdate:");
const patchProp = (el, key, prevValue, nextValue, namespace, parentComponent) => {
const isSVG = namespace === "svg";
if (key === "class") {
patchClass(el, nextValue, isSVG);
} else if (key === "style") {
patchStyle(el, prevValue, nextValue);
} else if (isOn(key)) {//事件名
if (!isModelListener(key)) {//非v-model的事件名
patchEvent(el, key, prevValue, nextValue, parentComponent);
}
}
//...
};
patchEvent
中会注册动作事件处理函数并缓存在元素上
function addEventListener(el, event, handler, options) {
el.addEventListener(event, handler, options);
}
function removeEventListener(el, event, handler, options) {
el.removeEventListener(event, handler, options);
}
const veiKey = Symbol("_vei");
//nextValue事件处理函数
function patchEvent(el, rawName, prevValue, nextValue, instance = null) {
const invokers = el[veiKey] || (el[veiKey] = {});
const existingInvoker = invokers[rawName];
if (nextValue && existingInvoker) {//更新事件处理函数
existingInvoker.value = !!(process.env.NODE_ENV !== "production") ? sanitizeEventValue(nextValue, rawName) : nextValue;
} else {
const [name, options] = parseName(rawName);
if (nextValue) {//注册事件处理函数并缓存在元素上
const invoker = invokers[rawName] = createInvoker(
!!(process.env.NODE_ENV !== "production") ? sanitizeEventValue(nextValue, rawName) : nextValue,
instance
);
addEventListener(el, name, invoker, options);
} else if (existingInvoker) {//注销事件处理函数
removeEventListener(el, name, existingInvoker, options);
invokers[rawName] = void 0;
}
}
}
createInvoker
创建invoker
调用者,利用callWithAsyncErrorHandling
执行异步函数,监测报错和处理。即触发事件后,会在invoker
调用者中执行事件处理函数。
//initialValue执行函数
function createInvoker(initialValue, instance) {
const invoker = (e) => {
if (!e._vts) {
e._vts = Date.now();
} else if (e._vts <= invoker.attached) {//避免过期无效的函数执行
return;
}
callWithAsyncErrorHandling(
patchStopImmediatePropagation(e, invoker.value),
instance,
5,
[e]
);
};
invoker.value = initialValue;
invoker.attached = getNow();
return invoker;
}
(2)为什么响应式变量多次修改,组件只渲染更新一次?
更新时会调用componentUpdateFn
函数,使用patch
函数渲染真实DOM,我们在patch
函数这里打点,然后查看点击后callstack
执行栈调用的函数。
-
可以看到点击后,会触发注册事件处理函数的
invoker
函数,然后callWithAsyncErrorHandling>callWithErrorHandling
执行click
函数。 -
click
函数中多次修改响应式变量,给响应式变量分别打点,再一步步执行查看。
countRef.value++
等价于countRef.value=countRef.value+1
,会先RefImpl
响应式变量的getter
拦截器,然后this.dep.track
收集引用订阅。再赋值后触发setter
拦截器, this.dep.trigger
触发响应,依赖Dep
用this.notify
通知引用订阅更新。
class Dep {
notify(debugInfo?: DebuggerEventExtraInfo): void {
//...
for (let link = this.subs; link; link = link.prevSub) {
if (link.sub.notify()) {
;(link.sub as ComputedRefImpl).dep.notify()
}
}
//...
endBatch()
}
- 而初始化的时候,组件添加了渲染更新的响应式副作用,一旦组件引用的响应式变量更改就会触发
ReactiveEffect
,执行更新任务job
,job
任务会运行componentUpdateFn
渲染更新DOM。
const setupRenderEffect = (instance, initialVNode, container, anchor, parentSuspense, namespace, optimized) => {
const effect = instance.effect = new ReactiveEffect(componentUpdateFn);
//...
const job = instance.job = effect.runIfDirty.bind(effect);
job.i = instance;
job.id = instance.uid;
//将任务队列放入调度器
effect.scheduler = () => queueJob(job);
}
ReactiveEffect的notify
中,!(this.flags & EffectFlags.NOTIFIED)
判断是否已经通知需要组件渲染更新,没有则置为已通知,并添加到订阅链表。
export function batch(sub: Subscriber, isComputed = false): void {
sub.flags |= EffectFlags.NOTIFIED
if (isComputed) {
sub.next = batchedComputed
batchedComputed = sub
return
}
sub.next = batchedSub
batchedSub = sub
}
class ReactiveEffect {
notify(): void {
if (
this.flags & EffectFlags.RUNNING &&
!(this.flags & EffectFlags.ALLOW_RECURSE)
) {
return
}
if (!(this.flags & EffectFlags.NOTIFIED)) {
batch(this)
}
}
}
Dep
将批量执行订阅链表的trigger
触发器
export function endBatch(): void {
//...
let error: unknown
while (batchedSub) {
let e: Subscriber | undefined = batchedSub
batchedSub = undefined
while (e) {
const next: Subscriber | undefined = e.next
e.next = undefined
e.flags &= ~EffectFlags.NOTIFIED
if (e.flags & EffectFlags.ACTIVE) {
try {
// ACTIVE flag is effect-only
;(e as ReactiveEffect).trigger()
} catch (err) {
if (!error) error = err
}
}
e = next
}
}
//...
}
ReactiveEffect
执行调度器的任务。即effect.scheduler = () => queueJob(job)
class ReactiveEffect {
trigger(): void {
if (this.flags & EffectFlags.PAUSED) {
pausedQueueEffects.add(this)
} else if (this.scheduler) {
this.scheduler()
} else {
this.runIfDirty()
}
}
}
queueJob
中判断任务是否在队列,如果不在则添加的任务队列queue
中,将job
置为已在队列,并创建异步任务,在异步函数回调后执行任务队列flushJobs
。
export function queueJob(job: SchedulerJob): void {
if (!(job.flags! & SchedulerJobFlags.QUEUED)) {
const jobId = getId(job)
const lastJob = queue[queue.length - 1]
if (!lastJob ||
//作业id大于尾部时的快速路径
(!(job.flags! & SchedulerJobFlags.PRE) && jobId >= getId(lastJob))) {
queue.push(job)
} else {
queue.splice(findInsertionIndex(jobId), 0, job)
}
//置为已在队列
job.flags! |= SchedulerJobFlags.QUEUED
queueFlush()
}
}
const resolvedPromise = /*@__PURE__*/ Promise.resolve() as Promise<any>
function queueFlush() {
if (!currentFlushPromise) {
currentFlushPromise = resolvedPromise.then(flushJobs)
}
}
- 同理后续组件引用的响应式变量多次修改赋值后,都会重复响应式通知
ReactiveEffect
的过程,但因为job
是同一个任务,flags
已经置为已添加到队列,会在queueJob
拦截,避免重复添加到queue
任务执行队列。
(3)组件更新渲染函数和nextTick的执行时机?他们是怎样串联起来的?
- 从上面
queueFlush
中,创建异步函数,让异步回调后flushJobs
中执行任务队列会在,并用currentFlushPromise
记录。 - 而
nextTick
只是简单的一个promise.then
回调执行fn
函数. - 但这才是巧妙之处,利用
Promise
链式调用的特点,直接使用currentFlushPromise.then
来串联之前执行任务队列的异步任务,让组件渲染更新函数排在nextTick
回调函数之前,使得nextTick
的回调函数可以获取渲染更新后真实DOM的最新值。
function nextTick(fn) {
const p2 = currentFlushPromise || resolvedPromise;
return fn ? p2.then(this ? fn.bind(this) : fn) : p2;
}
-
click
事件处理函数invoker
调用者callWithAsyncErrorHandling
执行完毕。callstack
为空,进入EventLoop
执行流程,查看异步任务队列。而之前currentFlushPromise = resolvedPromise.then(flushJobs)
异步任务已经触发并记录在异步任务队列,立即放入执行栈运行,开始调用flushJobs
-
flushJobs
中,判断任务队列中的任务是否已执行,若未执行则运行任务,而上面响应式变量修改添加了ReactiveEffect
的组件更新渲染任务,执行完毕后将任务标记不在任务队列,还原状态,方便下次更新的时候添加到任务队列。
function flushJobs(seen?: CountMap) {
if (__DEV__) {
seen = seen || new Map()
}
const check = __DEV__? (job: SchedulerJob) => checkRecursiveUpdates(seen!, job): NOOP
try {
for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
const job = queue[flushIndex]
//判断任务执行了没有
if (job && !(job.flags! & SchedulerJobFlags.DISPOSED)) {
if (__DEV__ && check(job)) {
continue
}
//非递归任务,将任务置为不在队列,还原状态
if (job.flags! & SchedulerJobFlags.ALLOW_RECURSE) {
job.flags! &= ~SchedulerJobFlags.QUEUED
}
//执行任务
callWithErrorHandling(job,job.i,job.i ? ErrorCodes.COMPONENT_UPDATE : ErrorCodes.SCHEDULER, )
if (!(job.flags! & SchedulerJobFlags.ALLOW_RECURSE)) {
job.flags! &= ~SchedulerJobFlags.QUEUED
}
}
}
} finally {
//...
}
}
ReactiveEffect.runIfDirty
中判断依赖是否修改过,如果修改了则执行组件更新渲染函数componentUpdateFn
。
runIfDirty(): void {
if (isDirty(this)) {
this.run()
}
}
//判断依赖的变量是否修改过
function isDirty(sub: Subscriber): boolean {
for (let link = sub.deps; link; link = link.nextDep) {
if (
link.dep.version !== link.version ||
(link.dep.computed &&
(refreshComputed(link.dep.computed) ||
link.dep.version !== link.version))
) {
return true
}
}
//...
return false;
}
-
当
componentUpdateFn
执行完毕,响应式变量修改后更新渲染的真实DOM已经挂载到页面,那么此时的执行栈为空,而nextTick
调用的时候,利用currentFlushPromise.then
将nextTick
的回调函数通过Promise
链式调用串联起来,EvenLoop
会再次查看异步任务队列,将nextTick
的回调函数放入执行栈运行。 -
同理
await nextTick
的情况,await
后面的代码相当于Promise.then
的回调。
const countRef = ref<number>(0);
const click = async () => {
countRef.value++;
countRef.value++;
countRef.value++;
const dom = document.getElementById('AAA')!;
console.log(dom.innerText);
await nextTick();
console.log(dom.innerText);
};
4.总结
好啦!看完大概的执行流程,我们可以回答以下三个问题了!
- vue3中怎么给DOM添加事件处理函数?
回答
-
(1)在将虚拟DOM
patch
到页面的时候,会创建对应的DOM
元素,然后通过patchProp
给元素设置属性值和注册动作事件。 -
(2)
patchProp
会根据属性名判断是否为动作事件,然后通过patchEvent
注册动作事件处理函数。 -
(3)
patchEvent
中会创建invoker
包裹事件处理函数,并缓存在元素中,通过addEventListener
的方式注册动作监听。
- 响应式变量多次修改,组件只渲染更新一次?
回答
- (1)响应式变量修改后会触发依赖订阅通知,通知该组件的响应式副作用
ReactiveEffect
,执行scheduler
调度者,而调度者在初始化的时候挂载了queueJob(job)
函数(将组件渲染更新的任务job
添加任务队列)。 - (2)
queueJob
中会判断job.flags
任务标记是否在任务队列,如果不在则放入到queue
任务队列,job.flags
置为已在任务队列,并创建currentFlushPromise = resolvedPromise.then(flushJobs)
异步任务,将执行队列任务的flushJobs
放入异步回调中。 - (3)后续的响应式变量多次修改因为是同一个组件,那么组件渲染更新的任务
job
也是同一个,job.flags
已经标记为已在任务队列,那么就会跳过放入任务队列的代码。 - (4)
flushJobs
执行queue
任务队列的任务,组件渲染更新任务完成后,会将job
置为不在队列,还原状态。
- 组件更新渲染函数和nextTick的执行时机?他们是怎样串联起来的?
回答
- (1)响应式变量修改后会将组件渲染更新的任务放入
queue
任务队列,并创建currentFlushPromise = resolvedPromise.then(flushJobs)
异步任务。 - (2)如果遇到
nextTick
调用,利用currentFlushPromise.then
将nextTick
的回调函数通过Promise.then
链式调用串联起来。 - (3)待执行栈代码执行完毕,根据
EventLoop
执行机制,就会轮询异步任务队列,将flushJobs
放入执行栈,开始执行queue
里面的任务,运行componentUpdateFn
更新渲染DOM的任务。 - (4)待DOM更新完,执行栈再次为空,
EventLoop
查询异步任务队列,因为nextTick
的回调函数是上一个异步任务的Promise.then
链式调用,会在上一个异步任务完成后自动加入到异步任务队列。那么此时nextTick
的回调函数就会被放入执行栈运行,可以获得DOM更新后的值。