Vue3中组件更新渲染函数和nextTick的执行时机?他们是怎样串联起来的?

287 阅读9分钟

调试代码,让你了解vue3的三个问题:

  1. vue3中怎么给DOM添加事件处理函数?
  2. 为什么响应式变量多次修改,组件只渲染更新一次?
  3. 组件更新渲染函数和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模板中不需?

  • 注意:该源码解读基于Vuev3.5.13版本.

(1)怎么给DOM添加事件处理函数?

  • 初始化的时候,patch函数中根据typeshapeFlag两个参数根据渲染的组件类型分派到不同函数处理。
 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是浏览器自带的组件元素ELEMENTshapeFlag&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函数。 image.png

  • click函数中多次修改响应式变量,给响应式变量分别打点,再一步步执行查看。

countRef.value++等价于countRef.value=countRef.value+1,会先RefImpl响应式变量的getter拦截器,然后this.dep.track收集引用订阅。再赋值后触发setter拦截器, this.dep.trigger触发响应,依赖Depthis.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.thennextTick的回调函数通过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.总结

好啦!看完大概的执行流程,我们可以回答以下三个问题了!

  1. vue3中怎么给DOM添加事件处理函数?

回答

  • (1)在将虚拟DOMpatch到页面的时候,会创建对应的DOM元素,然后通过patchProp给元素设置属性值和注册动作事件。

  • (2)patchProp会根据属性名判断是否为动作事件,然后通过patchEvent注册动作事件处理函数。

  • (3)patchEvent中会创建invoker包裹事件处理函数,并缓存在元素中,通过addEventListener的方式注册动作监听。

image.png

  1. 响应式变量多次修改,组件只渲染更新一次?

回答

  • (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置为不在队列,还原状态。

image.png

  1. 组件更新渲染函数和nextTick的执行时机?他们是怎样串联起来的?

回答

  • (1)响应式变量修改后会将组件渲染更新的任务放入queue任务队列,并创建currentFlushPromise = resolvedPromise.then(flushJobs)异步任务。
  • (2)如果遇到nextTick调用,利用currentFlushPromise.thennextTick的回调函数通过Promise.then链式调用串联起来。
  • (3)待执行栈代码执行完毕,根据EventLoop执行机制,就会轮询异步任务队列,将flushJobs放入执行栈,开始执行queue里面的任务,运行componentUpdateFn更新渲染DOM的任务。
  • (4)待DOM更新完,执行栈再次为空,EventLoop查询异步任务队列,因为nextTick的回调函数是上一个异步任务的Promise.then链式调用,会在上一个异步任务完成后自动加入到异步任务队列。那么此时nextTick的回调函数就会被放入执行栈运行,可以获得DOM更新后的值。

image.png