Event Loop 与异步更新策略

Vue 和React都实现了异步更新策略。虽然实现的方式不尽相同, 但都达到了减少DOM操作 避免过度渲染的目的。通过研究框架的运行机制, 其设计思路将深化我们对DOM优化的理解, 拓宽我们对DOM实践的认知。

Event Loop

Micro-Task与Macro-Task

事件循环中的异步队列由两种:macro(宏任务)队列和micro(微任务队列)。

常见的macro-task比如:setTimeout setInterval setImmediate script(整体代码)I/O操作 UI渲染等

常见的micro-task比如: process.nextTick Promise MutationObserver等

Event Loop过程解析

基于对micro和macro的认知,我们来走一遍完整的时间循环过程。

一个完整的Event Loop过程,可以概括为以下阶段:

  • 初始状态,调用栈空。micro队列空, macro队列里有且只有一个script脚本
  • 全局上下文(script 标签)被推入调用栈,同步代码执行。在执行过程中, 通过对一些接口的调用, 可以产生新的macro-task与micro-task,他们会分别被推入任务队列。同步代码执行完了,script脚本会被移出macro队列,这个过程本质上是队列的macro-task的执行和出队的过程。
  • 上一步我们出队的是一个macro-task,这一步我们处理的是micro-task。但需要注意的是:当macro-task出队时,任务是一个一个执行的;而micro-task出队时,任务是一队一队执行的。因此,我们处理micro队列这一步,会逐个执行队列中的任务并把它出队,直到队列被清空。
  • 执行渲染操作,更新界面
  • 检查是否存在web worker任务, 如果由 则对其进行处理
  • 上述过程循环往复, 直到两个队列都清空

总结一下,每一次循环都是一个这样的过程:

渲染的时机

先思考一个这样的问题:加入我想要在异步任务里进行DOM更新, 我该把它包装成micro还是macro呢?

先假设他是一个macro任务,比我我在script脚本中用setTimeout来处理它

setTimeout(task, 0)// task 是一个用于修改DOM的回调

现在task被推入macro队列。 但因为script脚本本身是一个macro任务,所以本次执行完script脚本之后,下一个不走就要去处理micro队列了, 再往下就去执行了一次render。

但本次render我的目标task其实并没有执行,想要修改DOM也没有修改,因此这一次的render其实是一次无效的render。

macro不ok, 我们转向micro试试看。我用Promise来把task包装成是一个micro任务:

Promise.resolve().then(task)

那么我们结束了对script脚本的执行, 是不是紧接着就去处理了micro-task队列了?micro-task处理完,DOM修改好了, 紧接着就可以走render流程了——不需要消耗多余一次渲染,不需要等待一轮事件循环,直接为用户呈现最即时的更新结果。

因此,我们更新DOM的时间点,应该尽可能靠近渲染的时机。当我们需要在异步任务中实现DOM修改时,把它包装成micro任务是相对明智的选择。

异步更新策略(Vue)

什么是异步更新?

当我们使用Vue或React提供的接口去更新数据时, 这个更新并不会立即生效,而是会被推入到一个队列里,待到适当的时机,队列中的更新任务会被批量触发, 这就是异步更新。

异步更新可以帮助我们避免过度渲染,是让JS为DOM分压的典范之一。

异步更新的优越性

异步更新的特性在于它只看结果, 因此渲染引擎不需要问过程买单。

最典型的例子, 比如有时我们会遇到这样的情况:

// 任务一
this.content = '第一次测试'

// 任务二
this.content = '第二次测试'

// 任务三
this.content = '第三次测试'

我们三个更新任务中对同一个状态修改了三次,如果我们采取传统的同步更新策略, 那么就要操作三次DOM。但本质上需要呈现给用户的目标内容其实只是第三次的结果,也就是说只有第三次的操作是有意义的——我们白白浪费了两次计算。

但如果我们把这三个任务塞进异步更新队列里,他们会现在js的层面上被批量执行完毕。当流程走到渲染这一步时,它仅仅需要针对有意义的计算结果操作一次DOM——这就是异步更新的好处

Vue状态更新手法:nextTick

Vue每次想要更新一个状态的时候,他先把这个更新操作给包装成一个异步操作派发出去。这件事情,在源码中是有一个叫做nextTick的函数来完成的:

export function nextTick (cb?:Function,ctx?: Object) {
    let _resolve
    callbacks.push(() => {
        if (cb) {
            try {
                cb.call(ctx)
            } catch (e) { handleError(e, ctx, 'nextTick') }
        } else if (_resolve) {
            _resolve(ctx)
        }
    })
    //  检查上一个异步任务队列(即名为callbacks的任务数组)是否派发和执行完毕了。pending此处相当于一个锁
    if (!pending) {
        pending = true
        // 是否要求一定要派发macro任务
        if (useMacroTask) {
            macroTimerFunc()
        } else { microTimerFunc() }// 如果不是macro那么就全都是micro
    }
    if (!cb && typeof Promise !== 'undefined') {
        return new Promise(resolve => {
            _resolve = resolve
        })
    }

}

我们看到, Vue的异步任务默认情况下都是用Promise来包装的,也就是说他们都是micro-task。

为了熟悉一下常见的macro和micro派发方式 加深对Event Loop的理解, 我们继续细化解析一下macroTimeFunc() 和microTimeFunc()这两个方法

macro Time Func() 是这么实现的:

// macro首选setImmediate这个兼容性最差
if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
    macroTimerFunc = () => {
        setImmediate(flushCallbacks)
    }
} else if(typeof MessageChannel !== 'undefined' && (isNative(MessageChannel) 
    || MessageChannel.toString() === '[object MessageChannelConstructor]')) {
    const channel = new MessageChannel()
    const port = channel.port2
    channel.port1.onmessage = flushCallbacks
    macroTimeFunc = () => {
        port.postMessage(1)
    }
} else {
    //兼容性最好的派发方式是setTimeout
    macroTImerFunc = () => {
        setTimeout(flushCallbacks)
    }
}

microTimeFunc()是这么实现的:

// 简单粗暴 不是ios全都去Promise 如果不兼容promise 那么你只能将就一下变成macro了
if (typeof Promise !== 'undefiend' && isNative(Promise)) {
    const p = Promise.resolve()
    microTimerFunc = () => {
        p.then(flushCallbacks)
        if (isIOS) { setTimeout(noop) }
    }
} else {
    microTimerFunc = macroTimerFunc
}

我们注意到无论是派发macro任务还是micro任务, 派发的任务对象都是一个叫做flushCallbacks的东西,这个东西做了什么

flushCallbacks源码如下:

function flushCallbacks () {
    pending = false
    // callbacks在nexttick中出现过, 他是任务数组
    const copies  callbacks.slice(0)
    callbacks.length = 0
    for (let i = 0; i < copies.length; i++) {
        copies[i]()
    }
}

Vue中每产生一个状态更新任务,它就会被塞进一个叫callbacks的数组(此处是任务队列的实现形式)中, 这个任务队列再被丢进micro或macro队列之前,会先去检查当前是否有异步更新任务正在执行(即检查pending锁)。如果确认pending锁是开着的(false),就把他设置锁上(true),然后对当前callbacks数组的任务进行派发(丢尽micro或macro队列)和执行。设置pending锁的意义在于保证状态更新任务的有序进行,避免发生混乱。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值