Fork me on GitHub

前端性能优化与原理实践之【异步更新策略】

前端性能优化与原理实践之异步更新策略!

一、Event Loop

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

  • 宏任务: setTimeout、setInterval、 setImmediate、script(整体代码)、 I/O 操作、UI 渲染等。
  • 微任务: process.nextTick、Promise、MutationObserver 等。

1、Event Loop 过程

  • 初始化状态:调用栈为空。微任务队列为空,宏任务队列只有一个 script 脚本。
  • script(全局上下文) 进入调用栈,同步执行代码,在执行代码的过程,会产生新宏任务和微任务,然后分别被推入调用栈中。
  • script 同步代码执行完成之后,就会被推出宏任务栈。这个过程就是宏任务队列中的任务执行和出队的过程。
  • 每当宏任务执行完一个,接着就去微任务队列执行所有的微任务,直到微任务队列为空为止。
  • 然后接着会渲染操作更新界面
  • 检查是否存在 Web worker 任务,如果有,则对其进行处理。

2、渲染过程

假设:打算将更新 DOM 作为一个宏任务去处理。

1
setTimeout(task, 0);

setTimeout 作为一个宏任务被推入宏任务队列中,因为 script 也是一个宏任务队列的任务,所以执行完 script 之后,会执行所有的微任务,所有微任务执行完成之后,直接更新页面,刚刚加入的更新 DOM 的宏任务只能等下一轮执行了。只不过是执行了一遍 script 的无效渲染。

假设:打算将更新 DOM 作为一个微任务去处理。

1
Promise.resolve().then(task)

作为一个微任务,在执行完 script 任务的时候,就会立马得到执行,然后进行渲染。对于用户来说,不用等待下一次的更新。

二、Vue 的异步更新策略

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

1、异步更新的优点

同一时间修改 DOM 多次,此时不会采取同步的策略,因为如果采取同步的策略会操作三次 DOM 。

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

2、Vue 状态更新:nextTick

Vue 每次想要更新一个状态的时候,会先把它这个更新操作给包装成一个异步操作派发出去。等到了一定的时机,然后对操作进行异步的更新。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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(把锁锁上)
pending = true
// 是否要求一定要派发为macro任务
if (useMacroTask) {
macroTimerFunc()
} else {
// 如果不说明一定要macro 你们就全都是micro
microTimerFunc()
}
}
// $flow-disable-line
if (!cb && typeof Promise !== 'undefined') {
return new Promise(resolve => {
_resolve = resolve
})
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// macro首选setImmediate 这个兼容性最差
if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
macroTimerFunc = () => {
setImmediate(flushCallbacks)
}
} else if (typeof MessageChannel !== 'undefined' && (
isNative(MessageChannel) ||
// PhantomJS
MessageChannel.toString() === '[object MessageChannelConstructor]'
)) {
const channel = new MessageChannel()
const port = channel.port2
channel.port1.onmessage = flushCallbacks
macroTimerFunc = () => {
port.postMessage(1)
}
} else {
// 兼容性最好的派发方式是setTimeout
macroTimerFunc = () => {
setTimeout(flushCallbacks, 0)
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 简单粗暴 不是ios全都给我去Promise 如果不兼容promise 那么你只能将就一下变成macro了
if (typeof Promise !== 'undefined' && isNative(Promise)) {
const p = Promise.resolve()
microTimerFunc = () => {
p.then(flushCallbacks)
// in problematic UIWebViews, Promise.then doesn't completely break, but
// it can get stuck in a weird state where callbacks are pushed into the
// microtask queue but the queue isn't being flushed, until the browser
// needs to do some other work, e.g. handle a timer. Therefore we can
// "force" the microtask queue to be flushed by adding an empty timer.
if (isIOS) setTimeout(noop)
}
} else {
// 如果无法派发micro,就退而求其次派发为macro
microTimerFunc = macroTimerFunc
}
1
2
3
4
5
6
7
8
9
10
11
// 派发任务队列中的任务
function flushCallbacks () {
pending = false
// callbacks在nextick中出现过 它是任务数组(队列)
const copies = callbacks.slice(0)
callbacks.length = 0
// 将callbacks中的任务逐个取出执行
for (let i = 0; i < copies.length; i++) {
copies[i]()
}
}

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