Vue3侦听器和异步任务调度, 其中有个神秘角色

侦听器的实现逻辑

我们先来看看一个最简单的使用方式(watch的使用方式非常灵活,我们通过简单的使用方式来了解流程):

let disabled = ref(false);

let unwatch = watch(disabled, (value, oldValue, oninvalidate) => {
  console.log(oldValue);
  console.log(value);
  nextTick(() => {
      console.log("hoho");
  });
})

先思考问题:

  1. 参数value是新值,oldValue旧值, 如何实现对disabled进行求值的封装,以及旧值oldValue是如何保存的?
  2. 侦听器也是响应式的API,那disabled的依赖收集和依赖分发是如何实现的(即值的变化是怎么被监听到的)?
  3. unwatch是一个取消监听的函数,内部的实现逻辑是什么?
对监听数据求值的实现

逻辑在doWatch方法中:

所有支持的的watch数据都被封装成了对应的求值函数。

<!-- doWatch -->
function doWatch(
  source: WatchSource | WatchSource[] | WatchEffect | object,
  cb: WatchCallback | null,
  { immediate, deep, flush, onTrack, onTrigger }: WatchOptions = EMPTY_OBJ
): WatchStopHandle {
  // 1. 求值函数的封装
  let getter: () => any
  if (isRef(source)) {
    getter = () => source.value
  } else if (isReactive(source)) {
    getter = () => source
  } else if (isArray(source)) {
    // 省略...
  } else if (isFunction(source)) {
    // 省略......
  } else {
    getter = NOOP
  }
}

函数中有一个oldValue变量,每次求新值后都会保存在它上面,作为下一次求值的旧值。
第一次监听的时候会调用求新值的函数,这样数据变化后就知道了最开始的值

<!-- doWatch -->
let oldValue = isMultiSource ? [] : {}

// 计算新值
const newValue = effect.run() // 等同于调用getter函数
oldValue = newValue
数据响应式的实现

目前只需要看下面的第二段代码,第一段代码后面会介绍

<!-- doWatch -->
let scheduler: EffectScheduler
if (flush === 'sync') {
  scheduler = job as any
} else if (flush === 'post') {
  scheduler = () => queuePostRenderEffect(job, instance && instance.suspense)
} else {
  // default: 'pre'
  scheduler = () => {
    if (!instance || instance.isMounted) {
      queuePreFlushCb(job)
    } else {
      job()
    }
  }
}

// 2.
const effect = new ReactiveEffect(getter, scheduler)
effect.run()

和组件的副作用渲染函数一样,侦听器也是基于ReactiveEffect

  1. ReactiveEffectrun方法执行会调用getter函数,我们的例子中会调用disabledvalue方法从而触发依赖收集,我们的例子中收集的就是effect对象;
  2. 当数据disabled变化后会触发依赖分发,会找到effect对象,执行它里面的scheduler方法,scheduler方法又会调用getter方法计算新值然后返回。这些操作同时进行了又一次收集依赖,等待下一次的数据变化。

如果对响应式的 依赖收集依赖分发 有疑问的同学可以参考一下其他的文章。

问题:如果一个数据即被 监听器监听,也被使用在了组件模板中,那 组件的副作用渲染函数监听器函数 哪个会被先执行?

答案是 监听器函数 ,因为监听器是在setup函数中调用的,所以是先收集的 监听器函数

取消监听的实现
<!-- doWatch -->
return () => {
  effect.stop()
  if (instance && instance.scope) {
    remove(instance.scope.effects!, effect)
  }
}
  1. effect.stop()的主要作用是将effect对象 从 监听数据的 依赖列表中移除,这样监听数据变化后就不会再触发 getter函数了;此外将effect对象置为 未激活,未激活的effect对象也是不能触发getter函数的,所以是双保险;
  2. effect对象从组件作用域中移除;

任务调度的实现

在理解任务调度之前我们先来了解一些重要的概念:

  • JS是单线程的,所有的JS代码执行在JS引擎线程中;
  • 浏览器是多线程的,除了JS引擎线程还有UI渲染线程,网络IO线程等;
  • 由于JS执行在一个线程中,所以一次只能执行一个任务,如果有多个任务,就必须排队,前面一个任务完成,再执行后面一个任务;
  • 同步任务 是指主进程中一个个按顺序执行的任务,如果某个任务执行时间久,那后面的任务就等待执行;
  • 异步任务 先不进入主线程,而先进入任务队列,只有主线程空闲了,且异步任务可以执行了,这些任务才会进入主线程,也是按照先后顺序执行;
  • JS操作DOM是同步任务,浏览器渲染DOM是异步任务(因为js引擎线程GUI渲染线程线程间是互斥的);
  • 异步任务分为 微任务宏任务, 微任务优先执行,所有的微任务执行完成后再执行宏任务
  • 微任务promise等,宏任务setTimeout等;
触发组件渲染的入口逻辑
const effect = new ReactiveEffect(
  componentUpdateFn,
  () => queueJob(instance.update),
  instance.scope
)

const update = (instance.update = effect.run.bind(effect) as SchedulerJob)

前面提到过 组件的副作用渲染函数 是基于ReactiveEffect
组件模板的数据变化后,会触发() => queueJob(instance.update)函数(本质就是ReactiveEffectrun方法),然后最终会调用componentUpdateFn函数执行组件的挂载或者更新,从而更新DOM。

  • queueJob 的逻辑
<!-- scheduler.ts -->
// 组件渲染任务数组
const queue: SchedulerJob[] = []

// 组件渲染任务队列执行函数
export function queueJob(job: SchedulerJob) {
  // 省略其他
  if (job.id == null) {
    queue.push(job)
  } else {
    queue.splice(findInsertionIndex(job.id), 0, job)
  }
  queueFlush()
}

Vue 维护了一个queue队列,用于保存需要执行的 副作用渲染函数ReactiveEffectrun方法。
queueJob 就是将 副作用渲染函数 添加到队列中合适的位置,然后执行 queueFlush方法。

queueJob

queueFlush方法我们先忽略,我们回到侦听器的相关逻辑中。

侦听器的侦测的数据变化后的的逻辑

我们继续看上面提到的一段代码。

let scheduler: EffectScheduler
if (flush === 'sync') {
  scheduler = job as any // the scheduler function gets called directly
} else if (flush === 'post') {
  scheduler = () => queuePostRenderEffect(job, instance && instance.suspense)
} else {
  // default: 'pre'
  scheduler = () => {
    if (!instance || instance.isMounted) {
      queuePreFlushCb(job)
    } else {
      // with 'pre' option, the first call must happen before
      // the component is mounted so it is called synchronously.
      job()
    }
  }
}

我们可以通过flush参数来指定侦听器的执行顺序,有sync,postpre(默认) 这三种方式。同步很好理解我们不讨论,我们主要来研究queuePostRenderEffectqueuePreFlushCb这两个方法。

  • queuePostRenderEffect在大多数情况下等同于queuePostFlushCb函数:
<!-- render.ts -->
export const queuePostRenderEffect = __FEATURE_SUSPENSE__
  ? queueEffectWithSuspense
  : queuePostFlushCb

所以我们来看看queuePostFlushCbqueuePreFlushCb的逻辑:

<!-- scheduler.ts -->
// 更新DOM前的两个callback队列
const pendingPreFlushCbs: SchedulerJob[] = []
let activePreFlushCbs: SchedulerJob[] | null = null

// 更新DOM前的两个callback队列
const pendingPostFlushCbs: SchedulerJob[] = []
let activePostFlushCbs: SchedulerJob[] | null = null

export function queuePreFlushCb(cb: SchedulerJob) {
  queueCb(cb, activePreFlushCbs, pendingPreFlushCbs, preFlushIndex)
}

export function queuePostFlushCb(cb: SchedulerJobs) {
  queueCb(cb, activePostFlushCbs, pendingPostFlushCbs, postFlushIndex)
}


function queueCb(
  cb: SchedulerJobs,
  activeQueue: SchedulerJob[] | null,
  pendingQueue: SchedulerJob[],
  index: number
) {
  // 省略其他
  pendingQueue.push(cb)
  queueFlush()
}
  1. Vue 维护了DOM更新前需要执行的回调函数执行队列pendingPreFlushCbsactivePreFlushCbs;
  2. Vue 维护了DOM更新后需要执行的回调函数执行队列pendingPostFlushCbsactivePostFlushCbs;
  3. queuePostFlushCbqueuePreFlushCb分别是吧对应的回调方法加到pendingPostFlushCbspendingPreFlushCbs队列中;
  4. 然后执行queueFlush函数。
`queuePostFlushCb`和`queuePreFlushCb`
queueFlush执行异步调用

不管是DOM更新还是监听器的监听到数据后的回调都是进入了queueFlush,我们来看看它的实现逻辑。

const resolvedPromise: Promise<any> = Promise.resolve()

function queueFlush() {
  if (!isFlushing && !isFlushPending) {
    isFlushPending = true
    currentFlushPromise = resolvedPromise.then(flushJobs)
  }
}

queueFlush 使用了微任务的Promise执行异步执行flushJobs。且用isFlushPending控制flushJobs的执行时机。

flushJobs清空所有任务
function flushJobs(seen?: CountMap) {

  isFlushPending = false
  isFlushing = true
    
  // 1. 依次执行所有的所有的回调函数
  flushPreFlushCbs(seen)

  // 2. 对副作用渲染函数排序,然后依次执行所有的副作用渲染函数
  queue.sort((a, b) => getId(a) - getId(b))

  try {
    for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
      const job = queue[flushIndex]
      callWithErrorHandling(job, null, ErrorCodes.SCHEDULER)
    }
  } finally {
    // 3. 依次执行所有的所有的回调函数
    flushPostFlushCbs(seen)

    isFlushing = false
    currentFlushPromise = null
    
    // 4. 如果有新的回调函数添加进来,继续一个 1,2,3 的执行流程
    if (
      queue.length ||
      pendingPreFlushCbs.length ||
      pendingPostFlushCbs.length
    ) {
      flushJobs(seen)
    }
  }
}

flushJobs 的作用是清空所有任务:

  1. flushPreFlushCbs依次清空所有DOM更新前的回调函数:1). 先将pendingPreFlushCbs中的所有数据拷贝到activePreFlushCbs中,pendingPreFlushCbs置空等待新的回调函数加入;2). 依次执行activePreFlushCbs中的回调函数;
  2. callWithErrorHandling(job, null, ErrorCodes.SCHEDULER)依次清空所有更新DOM的副作用渲染函数;
  3. flushPostFlushCbs依次清空所有DOM更新后的回调函数:1). 先将pendingPostFlushCbs中的所有数据拷贝到activePostFlushCbs中,pendingPostFlushCbs置空等待新的回调函数加入;2).依次执行activePostFlushCbs中的回调函数;
flushJobs

神秘的nextTick

nextTick异常神秘,遇到DOM的操作问题可能就想到它了。其实非常简单

export function nextTick<T = void>(
  this: T,
  fn?: (this: T) => void
): Promise<void> {
  const p = currentFlushPromise || resolvedPromise
  return fn ? p.then(this ? fn.bind(this) : fn) : p
}

异常简单,其实就是对Promise调用then方法。

Promise.resolve().then(() => {
  // 清空任务(包括更新DOM)
}).then(() => {
  // 是不是可以获取到更新后的DOM了??
})

不那么神秘的forceUpdate

forceUpdate: i => () => queueJob(i.update)

现在也不需要我解释这个方法的作用了。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,013评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,205评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,370评论 0 342
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,168评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,153评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,954评论 1 283
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,271评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,916评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,382评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,877评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,989评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,624评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,209评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,199评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,418评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,401评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,700评论 2 345