Vue响应式之异步更新与nextTick
# 前言
我们在前面文章讲过,当数据更新时,正常情况下 Watcher 不是马上执行更新回调,而是通过 queueWatcher 来进行调度
下来我们先来看看 queueWatcher 的源码
# queueWatcher
// src\core\observer\scheduler.js
/**
* Push a watcher into the watcher queue.
* Jobs with duplicate IDs will be skipped unless it's
* pushed when the queue is being flushed.
*/
export function queueWatcher (watcher: Watcher) {
const id = watcher.id
if (has[id] == null) {
has[id] = true
if (!flushing) {
queue.push(watcher)
} else {
// if already flushing, splice the watcher based on its id
// if already past its id, it will be run next immediately.
let i = queue.length - 1
while (i > index && queue[i].id > watcher.id) {
i--
}
queue.splice(i + 1, 0, watcher)
}
// queue the flush
if (!waiting) {
waiting = true
if (process.env.NODE_ENV !== 'production' && !config.async) {
flushSchedulerQueue()
return
}
nextTick(flushSchedulerQueue)
}
}
}
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
33
34
has 用来存放 watcher 的 id, 作用是防止队列重复添加相同 watcher
这里的队列不包括已执行的 watcher ,即队列中 watcher 已执行则 has[id] 会置 null
flushing 用来判断是否开始执行 watcher (flushSchedulerQueue),作用是为了调整新插入 watcher 在队列中的顺序
若开始 flushing ,由于当前 queue 已经按
watcher.id
排序了,所以新的 watcher 需要插入在未执行队列中比新 watcher 的 id 大的元素之前
waiting 用来将 flushSchedulerQueue 操作放入 microTask 队列
flushSchedulerQueue 整个过程是一个 microTask
watchers 按同步代码执行顺序被添加到队列后( macroTask )才开始执行 flushSchedulerQueue 这个 microTask
而 watcher 执行过程中,可能触发新的 watcher 执行,所以需要 flushing 去控制新的 watcher 插入队列的位置
!!同步代码中执行
$nextTick(()=>{})
,里面的回调 cb 和 flushSchedulerQueue 同属一个 microTask ,且 cb 在 flushSchedulerQueue 之后执行。稍后分析 nextTick 时再细讲
# 实例分析
<script type="text/x-template" id="demo-template">
<div>
{{a}}{{b}}
</div>
</script>
<script>
var vm = new Vue({
el: '#el',
template: '#demo-template',
data: {
a: 0,
b: 0
},
// user watcher
watch: {
a(){
this.b = 1
}
},
mounted() {
this.a = 1
this.b = 2
}
})
</script>
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
最终页面上显示的是什么?是 11 还是 12 ?
结果
11
- a 发生变更,其依赖对应的 watcher (有 renderWatcher 和 user watcher )执行 queueWatcher,放入 queue 中
- b 发生变更,其依赖对应的 watcher (renderWatcher) 预放入 queue ,不过此时已经有 renderWatcher ,故不再放入
- 同步代码执行结束,开始执行 flushSchedulerQueue ,先执行 user watcher ,此时 b = 1 ,其依赖对应的 watcher (renderWatcher) 已在 queue 中,故不再放入。
- 执行 renderWatcher 将 a=1 和 b=1 的值渲染到页面上
# flushSchedulerQueue
flushSchedulerQueue 代码如下
// src\core\observer\scheduler.js
/**
* Flush both queues and run the watchers.
*/
function flushSchedulerQueue () {
currentFlushTimestamp = getNow()
flushing = true
let watcher, id
queue.sort((a, b) => a.id - b.id)
// queue 会动态增加,所以 queue.length 不能 cache
for (index = 0; index < queue.length; index++) {
watcher = queue[index]
// before 在 watcher 实例化的 options.before 中指定
if (watcher.before) {
watcher.before()
}
id = watcher.id
has[id] = null
watcher.run()
// dev 环境做了个死循环判断
if (process.env.NODE_ENV !== 'production' && has[id] != null) {
circular[id] = (circular[id] || 0) + 1
if (circular[id] > MAX_UPDATE_COUNT) {
warn(
'You may have an infinite update loop ' + (
watcher.user
? `in watcher with expression "${watcher.expression}"`
: `in a component render function.`
),
watcher.vm
)
break
}
}
}
// 重置状态前的进行备份,后面有用到
const activatedQueue = activatedChildren.slice()
const updatedQueue = queue.slice()
// 重置 index queue activatedChildren has circular waiting flushing
resetSchedulerState()
// call component updated and activated hooks
callActivatedHooks(activatedQueue)
callUpdatedHooks(updatedQueue)
// devtool hook
/* istanbul ignore if */
if (devtools && config.devtools) {
devtools.emit('flush')
}
}
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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
有几个地方需要注意的:
# 为什么要重排序
watcher 的 id 是根据创建顺序递增的
在执行 watcher 队列前,对队列进行了排序,主要是以下几点原因:
- 由于父组件总是在子组件前创建,所以组件更新应该由父到子
- 由于用户的 Watcher (通过 watch 选项创建的) 在 render watcher 前创建,因此用户的 Watcher 应该先运行
- 如果一个组件在父组件的 watcher 运行中 destroyed ,该组件的 watchers 应该被跳过
也就是将 watcher 队列按 watcher 的创建顺序进行排序
因此 queueWatcher 时还使用了 flushing 变量去控制 watcher 的顺序
# 死循环判断
has[id] = null
watcher.run()
// dev 环境做了个死循环判断
if (process.env.NODE_ENV !== 'production' && has[id] != null) {
circular[id] = (circular[id] || 0) + 1
if (circular[id] > MAX_UPDATE_COUNT) {
warn(
'You may have an infinite update loop ' + (
watcher.user
? `in watcher with expression "${watcher.expression}"`
: `in a component render function.`
),
watcher.vm
)
break
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
当某个 watcher 连续执行了 100 次以上,说明存在死循环
// user watcher
watch:{
a(){
this.a = this.a + 1
}
}
2
3
4
5
6
- a 发生改变时,其依赖对应的 user watcher 被放入 queue
- 同步代码执行结束后,开始执行 flushSchedulerQueue
- user watcher 的 has 值置空,并执行 user watcher ,此时 a 发生改变,user watcher 再次放入 queue
- user watcher 一直执行,直到达到 MAX_UPDATE_COUNT 上限,出现 warn 警告
注意: vue 中没有处理相互调用的情况
watch:{
a:function(){
this.b = this.b + 1
},
b:function(){
this.a = this.a + 1
}
}
2
3
4
5
6
7
8
这种情况下,当 a 或 b 改动时,会不断的执行 watcher 的回调,vue 中没有做检测,导致最后死循环、页面卡住
这种还算比较容易看出来的,要是 watch 的项变多了,相互修改,一不小心就会出现循环,到时候排查还很难
watch 造成死循环的都是用户自定义的 watch 有问题
# activatedQueue 队列 与 activated 钩子
activatedQueue 为 activatedChildren 的备份
// src\core\observer\scheduler.js
/**
* Queue a kept-alive component that was activated during patch.
* The queue will be processed after the entire tree has been patched.
*/
export function queueActivatedComponent (vm: Component) {
// setting _inactive to false here so that a render function can
// rely on checking whether it's in an inactive tree (e.g. router-view)
vm._inactive = false
activatedChildren.push(vm)
}
function callActivatedHooks (queue) {
for (let i = 0; i < queue.length; i++) {
queue[i]._inactive = true
activateChildComponent(queue[i], true /* true */)
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// src\core\instance\lifecycle.js
export function activateChildComponent (vm: Component, direct?: boolean) {
if (direct) {
vm._directInactive = false
if (isInInactiveTree(vm)) {
return
}
} else if (vm._directInactive) {
return
}
if (vm._inactive || vm._inactive === null) {
vm._inactive = false
for (let i = 0; i < vm.$children.length; i++) {
activateChildComponent(vm.$children[i])
}
callHook(vm, 'activated')
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<keep-alive>
用于包裹组件,缓存不活动的组件实例
callUpdatedHooks 执行前, activatedChildren 会添加活动的组件实例
而执行 callUpdatedHooks(updatedQueue)
时, activatedChildren 中所添加的活动的组件实例会调用 activated 钩子,表明这些被 <keep-alive>
包裹的组件当前处于活动状态
其他的细节我们在讲到 <keep-alive>
时细讲
# updatedQueue 队列与 updated 钩子
updatedQueue 为 queue 的备份,按照由后往前的顺序,执行 watcher 所对应的 vm 的 updated 钩子
其中要求 watcher 为 renderWatcher 且 vm._isMounted && !vm._isDestroyed
故效果为按子组件到父组件的顺序执行 updated 钩子
# nextTick
最后再来说说 nextTick
# 事件循环
在讲解 nextTick 之前,我们需要先简要介绍一下浏览器中的事件循环
浏览器中的任务分为 macrotask 和 microtask
macrotask(宏任务): 同步代码, setTimeout 等
microtask(微任务): 原生 Promise, MutationObserver 等
- 从 macrotask 队列中取一个宏任务执行,执行完后, 执行所有的 microtask.
- 重复回合
当某个宏任务执行完后,会查看是否有微任务队列。如果有,先执行微任务队列中的所有任务,如果没有,会读取宏任务队列中排在最前的任务,执行宏任务的过程中,遇到微任务,依次加入微任务队列。当前宏任务执行栈空后,再次读取微任务队列里的任务,依次类推。
# nextTick 源码
nextTick 可以是 queueWatcher 时内部执行,也可以是用户 vm.$nextTick
或 Vue.nextTick
手动执行,都是调用的同一个方法
// src\core\util\next-tick.js
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)
}
})
// 用来判断是否应该创建新的 microTask
if (!pending) {
pending = true
timerFunc()
}
// $flow-disable-line
if (!cb && typeof Promise !== 'undefined') {
return new Promise(resolve => {
_resolve = resolve
})
}
}
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
该方法会将回调 cb 放入 callbacks 队列中,这个 callbacks 队列会在一个 microTask 中被执行。
microTask 由 timerFunc 创建,如果当前 microTask 已经开始执行, nextTick 传入的回调会放在下一个 microTask 中执行
例:
<script type="text/x-template" id="demo-template">
<div>
{{a}}
</div>
</script>
<script>
var vm = new Vue({
el: '#el',
template: '#demo-template',
data: {
a: 0,
},
watch: {
a(val){
console.log(`a=${val}`)
},
},
mounted() {
this.a = 1
Promise.resolve().then(() => {
console.log("promise")
this.a = 3
})
this.$nextTick(()=>{
console.log("nextTick")
this.a = 2
})
}
})
</script>
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
页面上显示什么,输出又是什么?
点击查看
页面上显示 3 ,输出结果为 :
a=1
nextTick
promise
a=3
2
3
4
执行顺序:
- a 发生变更,其依赖对应的 watcher (有 renderWatcher 和 user watcher )执行 queueWatcher,放入 queue 中
- 此时调用 nextTick ,将 flushSchedulerQueue 回调放入 callbacks 中,同时将 flushCallbacks 放入 microTask 队列
- 通过
Promise.resolve().then
新建一个 microTask (promiseMicroTask) 并放入 microTask 队列 - 执行 $nextTick ,将回调 cb 放入 callbacks 中
- 同步代码执行结束,开始执行 microTask 队列
- 先执行 flushCallbacks 这个 microTask ,此时 callbacks 中有两个回调,分别是 flushSchedulerQueue 和 cb
- 先执行 flushSchedulerQueue: flushSchedulerQueue 中会先执行 user watcher ,此时输出
a=1
,其后执行 renderWatcher ,此时通知页面更新为 1,通过打断点可以看到页面更新了 ,flushSchedulerQueue 执行完毕进行重置; - 随后执行 cb 回调,输出
promise
, a 发生变更,其依赖对应的 watcher 放入 queue 中,并且重复过程 2
- 先执行 flushSchedulerQueue: flushSchedulerQueue 中会先执行 user watcher ,此时输出
- 执行 promiseMicroTask ,a 发生变更,其依赖对应的 watcher 已在 watcher 中,因此不再添加
- 执行 flushCallbacks 这个 microTask ,此时 callbacks 只有 flushSchedulerQueue 这个回调,执行过程同 6.1 , 输出
a=3
,同时页面更新为 3
这个也顺便解释了在同步代码中执行 Promise.resolve().then
和 $nextTick
效果是不一样的,后者的回调和 watcher 的执行在同一个 microTask ,会在 Promise.resolve().then
的回调之前执行
同时回答了这个问题:为什么 nextTick 中每个回调不是一个 microTask ?
这是为了保证 nextTick 的回调能在 flushSchedulerQueue 后马上执行
至于为什么要求这样做,源码中给了大量解释,可能是有些平台会存在问题,感兴趣的可以看源码和相关 issue
回到源码,还有一个细节,当 nextTick 传入的 cb 为空时,会返回一个 Promise
if (!cb && typeof Promise !== 'undefined') {
return new Promise(resolve => {
_resolve = resolve
})
}
2
3
4
5
所以可以这样使用:
// then 中的回调也会放在 callbacks 中,和 flushSchedulerQueue 同一个 microTask
this.$nextTick().then(()=>{})
2
# microTask 的模拟实现
上文说到 timerFunc 会创建一个 microTask ,怎么做的呢?
let timerFunc
if (typeof Promise !== 'undefined' && isNative(Promise)) {
const p = Promise.resolve()
timerFunc = () => {
p.then(flushCallbacks)
if (isIOS) setTimeout(noop)
}
isUsingMicroTask = true
} else if (!isIE && typeof MutationObserver !== 'undefined' && (
isNative(MutationObserver) ||
MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
let counter = 1
const observer = new MutationObserver(flushCallbacks)
const textNode = document.createTextNode(String(counter))
observer.observe(textNode, {
characterData: true
})
timerFunc = () => {
counter = (counter + 1) % 2
textNode.data = String(counter)
}
isUsingMicroTask = true
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
timerFunc = () => {
setImmediate(flushCallbacks)
}
} else {
timerFunc = () => {
setTimeout(flushCallbacks, 0)
}
}
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
33
34
35
36
37
按照 Promise => MutationObserver => setImmediate => setTimeout 的顺序匹配
# nextTick 中能拿到最新的 dom 值么?
还是举个例子
<script type="text/x-template" id="demo-template">
<div ref='a'>{{ a }}</div>
</script>
<script>
var vm = new Vue({
el: '#el',
template: '#demo-template',
data: {
a: 0,
},
mounted() {
const refA = this.$refs.a
this.a = 1
console.log(refA.innerHTML)
this.$nextTick(() => {
console.log(refA.innerHTML)
})
}
})
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
输出结果是什么?
结果
0
1
2
根据上文的描述,$nextTick
中的回调会在 flushSchedulerQueue
的 renderWatcher
后执行
而 renderWatcher 时,会对 dom 进行更新,所以 $nextTick
执行回调时拿到的就是更新后的 dom 节点
说点题外话,为什么 renderWatcher 要在 microTask 中执行而不是在 macroTask ,因为浏览器的执行顺序为:macroTask -> microTask queue -> UI Render ,如果 renderWatcher 放在 macroTask 中,由于 renderWatcher 可能有多次,所以 UI Render 也会进行多次
可能你又有疑问了,刚刚不是说 $nextTick
可以拿到更新后的 dom 节点,这里又说 UI Render
要在 microTask queue 执行之后,是不是矛盾了?
解析
不矛盾
UI Render
包括样式计算(Recalculate Style),生成布局树(Layout),更新图层树(Update Layer Tree),绘制(Paint),图层合成(Composite Layers),通知渲染进程渲染等过程
根据宏任务时进行的操作决定后面会执行哪几个过程,如果还 Schedule Style Recalculation ,则会进行
而在宏任务中操作 dom,设置样式等,只可能进行解析(Parse HTML),计划计算样式(Schedule Style Recalculation)等,还不会开始计算样式,进行布局和绘制等
但是,如果在宏任务中去获取具体布局相关属性,比如宽高位置,会进行布局和样式计算(同步操作),然后将值返回
同样,如果在宏任务中去获取具体非布局相关属性,比如颜色等,会进行样式计算(同步操作),然后将值返回
当然,这个的前提是触发了计划计算样式(Schedule Style Recalculation),如果这个没触发,直接去获取属性并不会造成回流重绘
并且,如果进行了重排重绘,在 UI Render 阶段不会再次进行样式计算(Recalculate Style)和生成布局树(Layout)了
注意一点,设置了某个节点的样式,然后去获取另一个节点的布局,会先进行样式计算,但不一定是会进行 Layout 的
渲染引擎很智能,会根据样式计算结果得知需不需要进行 Layout
比如前面节点设置的样式并没有涉及布局相关属性,那么后者获取布局属性并不需要先进行 Layout ,
原本以为处于绝对定位然后设置了布局相关属性,影响不到我们要获取的节点,不会进行 Layout,但事实会 Layout 说明渲染引擎没有那么智能
后面我会单独写一篇文章讲这个
试试如下例子:
<div id="a">a</div>
<script>
function test() {
var a = document.querySelector("#a")
Promise.resolve().then(()=>{
a.innerHTML = "test-1"
})
Promise.resolve().then(()=>{
a.innerHTML = "test-2"
console.log(a.innerHTML)
alert(1)
})
}
function test2(){
var a = document.querySelector("#a")
Promise.resolve().then(()=>{
a.innerHTML = "test2-1"
})
setTimeout(() => {
a.innerHTML = "test2-2"
console.log(a.innerHTML)
alert(1)
}, 0);
}
</script>
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
注意:测试的时候不能用 debugger ,应该用 alert,见 浏览器debugger为什么没有阻止浏览器渲染界面而alert可以? (opens new window)
执行 test 时,在 microTask queue 中对 a.innerHTML
进行两次修改,由于在 UI Render
前用 alert 阻塞了渲染线程,故页面不会变化,console.log(a.innerHTML)
输出 test-2
执行 test2 时,在 microTask queue 中对 a.innerHTML
进行了修改并执行了 UI Render
,所以页面上会显示 test2-1
,下一次事件循环,从 setTimeout 宏任务开始,由于被 alert 堵塞,故页面不会变化,console.log(a.innerHTML)
输出 test2-2
总的来说:nextTick 中能拿到当前最新的 dom 值,但此时页面还未进行重渲染
# 总结
Vue 的异步更新,借用了 microTask 的概念,在 nextTick 执行所有 watcher 的 run ,以保证 watcher 能够按照正常顺序执行。同时,由于对 dom 的更新都是处于 microTask 中,可以避免页面渲染多次