vue nextTick 引发的思考
背景 #
最近做项目碰见一个这样的问题,伪代码(vue版本为2.6.x)如下:
<template>
<div class="layout">
<TopBar />
<div class="main" v-if="isRouterAlive">
<slot />
</div>
</div>
</template>
<script>
export default {
async created() {
this.admin = await ajax('...')
if (!admin) {
this.$router.replace('/403')
}
this.$nextTick(() => {
this.isRouterAlive = true
})
}
}
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
没有权限的时候,理想中的情况是这样的:
- 访问首页
- 调用接口获取用户权限,权限为false
- 路由跳转到403
this.isRouterAlive = true
,显示403页面
实际情况是这样的:
- 访问首页
- 调用接口获取用户权限,权限为false
this.isRouterAlive = true
,显示首页- 路由跳转到403,显示403页面
解决方案也很简单,直接await this.$router.replace('/403')
即可,但好学的我打算一探究竟!
分析 #
很显然,解决问题的关键是 this.$router.replace
和 this.$nextTick
,要分析这个问题必然要分析两者的执行逻辑。
此时我们知道 this.$router.replace
返回的是一个 promise
,this.$nextTick
中的timeFunc
实现优先级是 Promise
--> MutationObserver
--> setImmediate
--> setTimeout
,因此在浏览器环境下 this.$nextTick
也是基于 Promise
实现的,我们改造一下代码:
// 案例1
async created() {
this.$router.replace('/403').then(() => {
console.log(1)
})
this.$nextTick(() => {
console.log(2)
this.isRouterAlive = true
})
}
// 2
// 1
2
3
4
5
6
7
8
9
10
11
12
13
控制台先打印 2,再打印 1,很奇怪,为什么同样是 Promise
,nextTick
会优先输出?
是不是因为this.$router.replace
是个多层嵌套的 Promise
,导致后面跟随的输出1的 then
前面还有 隐藏的then
,即可以把上面的代码想象成:
function myReplace () {
return new Promise((resolve) => {
Promise.resolve().then(() => {
resolve()
})
})
}
async created() {
myReplace('/403').then(() => {
console.log(1)
})
Promise.resolve().then(() => {
console.log(2)
})
}
// 2
// 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
真的是类似这样吗?我们再改造一下代码:
// 案例2
async created() {
this.$router.replace('/403').then(() => {
console.log(1)
})
setTimeout(() => {
console.log(3)
})
this.$nextTick(() => {
console.log(2)
this.isRouterAlive = true
})
}
// 2
// 3
// 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
咦,为什么 3 比 1 先输出?this.$router.replace
不是返回 Promise
吗?Promise
不是微任务吗?setTimeout
不是宏任务吗?
this.$router.replace
是返回一个 Promise
没错,但是当只有 Promise
被 resolve
时,才会把输出 1 的 then
加入微任务队列,随后执行,而 3 比 1 先输出说明 this.$router.replace
内部是先执行一个类似 setTimeout
的宏任务,之后 resolve
,即:
function myReplace () {
return new Promise((resolve) => {
setTimeout(() => {
resolve()
}, 10) // 延迟一定时间
})
}
async created() {
myReplace('/403').then(() => {
console.log(1)
})
setTimeout(() => {
console.log(3)
})
Promise.resolve().then(() => {
console.log(2)
})
}
// 2
// 3
// 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
那么问题来了, this.$router.replace
内部执行的宏任务到底是什么?
没办法,只能打断点看源码调试了,具体函数调用栈这里就不赘述了。路由403在项目 router.ts
中的定义为:
{
path: '/403',
name: '403',
component: () => import('./views/403.vue')
}
2
3
4
5
this.$router.replace
函数接受一个 path
参数,在示例中即是 '/403' ,this.$router.replace
内会根据 path
参数找到匹配的 RouteRecord
(路由记录,记录路由的路径、参数、组件等等),之后调用resolveAsyncComponents
方法对匹配的路由记录进行加载处理,因为 403 组件采用异步加载的方式,所以需要先import('./views/403.vue')
,之后再进行路由跳转、视图更新。
动态 import
是也是基于 Promise
的,即:
function myReplace () {
return new Promise((resolve) => {
import('./views/403.vue').then(() => {
resolve()
})
})
}
async created() {
myReplace('/403').then(() => {
console.log(1)
})
setTimeout(() => {
console.log(3)
})
Promise.resolve().then(() => {
console.log(2)
})
}
// 2
// 3
// 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
因此, this.$router.replace
内部执行的宏任务是在 import
里。
那么问题又来了,import
内部执行的宏任务到底是什么?
动态 import
接受模块的 url 作为参数,因此不难猜测, import
内部需要对模块进行请求加载,所以import
内部执行的宏任务就是加载模块的 http 请求,即:
function myReplace () {
return new Promise((resolve) => {
return new Promise((resolve => {
ajax('./views/403.vue').then(() => {
resolve()
})
}))
})
}
async created() {
myReplace('/403').then(() => {
console.log(1)
})
setTimeout(() => {
console.log(3)
})
Promise.resolve().then(() => {
console.log(2)
})
}
// 2
// 3
// 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
分析到这,我们再回过头来看看背景中出现的问题就不难解释了:
async created() {
this.admin = await ajax('...')
if (!admin) {
this.$router.replace('/403')
}
this.$nextTick(() => {
this.isRouterAlive = true
})
}
2
3
4
5
6
7
8
9
- 访问首页
- 调用接口获取用户权限,权限为false
- 执行
this.$router.replace('/403')
,触发一个异步加载403模块的宏任务 - 执行
this.$nextTick
,添加一个微任务到微任务队列 - 执行微任务队列,
this.isRouterAlive = true
,显示首页 - 宏任务异步加载403模块加载完毕,路由跳转到403,显示403页面
到这就结束了吗?远远没有!
我们再改造下代码:
// 案例3
async created() {
this.$router.replace('/403').then(() => {
console.log(1)
})
Promise.resolve().then(() => {
console.log(2)
})
this.$nextTick(() => {
console.log(3)
})
}
2
3
4
5
6
7
8
9
10
11
12
当 403 模块是异步加载时,根据前面的分析,不难得出执行代码会依次输出 2、3、1。
**但是当 403 模块不是异步加载呢?**即路由403在项目 router.ts
中的定义为:
{
path: '/403',
name: '403',
component: Page403 // Page403为 import Page403 from './views/403.vue'
}
2
3
4
5
此时 this.$router.replace
内部并不需要执行 import
,根据前面的分析,代码等价于:
function myReplace () {
return new Promise((resolve) => {
resolve()
})
}
async created() {
myReplace('/403').then(() => {
console.log(1)
})
Promise.resolve().then(() => {
console.log(2)
})
Promise.resolve().then(() => {
console.log(3)
})
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
因此当 403 模块是同步加载时,按照前面的分析,执行代码应该依次输出 1、2、3。
但是实际情况下执行代码,输出顺序为 3、1、2。
咦,为什么会先输出 3 ?为什么先执行了 this.$nextTick
里的回调?
我们分析下 nextTick
的源码,我简化了一下:
const callbacks = []
let pending = false
function flushCallbacks () {
pending = false
const copies = callbacks.slice(0)
callbacks.length = 0
for (let i = 0; i < copies.length; i++) {
copies[i]()
}
}
let timerFunc = Promise.resolve().then(flushCallbacks)
export function nextTick (cb?: Function) {
callbacks.push(cb)
if (!pending) {
pending = true
timerFunc()
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
nextTick
中维护了一个全局的 callbacks
数组,第一次调用 nextTick
:
- 将回调函数放入
callbacks
中 pending
为false
,执行timeFunc
,添加微任务flushCallbacks
到微任务队列中
此后同一 tick
中再次调用nextTick
,只会将回调函数放入callbacks
中,并不会触发新的微任务。因此同一 tick
中多次调用nextTick
的回调函数最终会由第一次调用 nextTick
时添加的微任务 flushCallbacks
统一执行。
分析下如下代码:
// 案例4
async created() {
this.$nextTick(() => {
console.log(1)
})
Promise.resolve().then(() => {
console.log(2)
})
this.$nextTick(() => {
console.log(3)
})
}
// 1
// 3
// 2
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
- 第一次调用
nextTick
,添加微任务flushCallbacks
到微任务队列中,此时callbacks
有一个输出1的回调函数 - 执行
Promise.resolve()
,添加一个输出2的微任务到微任务队列 - 第二次调用
nextTick
,将输出3的回调函数添加到callbacks
数组中 - 执行微任务队列,执行第一个微任务
flushCallbacks
,即依次执行callbacks
数组中的回调函数,依次输出 1,3 - 执行第二个微任务,输出 2
此时回过头再看案例3,当 403 模块是同步加载时,先输出了3,根据案例4,我们可以猜测到 this.$router.replace('/403')
内部调用过一次 nextTick
,将微任务 flushCallbacks
添加到了微任务队列的前面,因此会先输出3,即当 403 模块是同步加载时,案例3代码等价于:
function syncReplace () {
return new Promise((resolve) => {
this.$nextTick(() => {})
resolve()
})
}
async created() {
syncReplace('/403').then(() => {
console.log(1)
})
Promise.resolve().then(() => {
console.log(2)
})
this.$nextTick(() => {
console.log(3)
})
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
那么问题来了,this.$router.replace('/403')
内部为什么会调用 nextTick
?
我们打个断点,看下函数调用栈即可一清二楚:
如上图,当403模块是同步加载时,执行this.$router.replace('/403')
时路由会同步更新,update
过程会调用 queueWatcher
方法, queueWatcher
方法内部调用了 nextTick
(当403模块是同步加载时,执行this.$router.replace('/403')
时实际上会触发多次 update
(watcher
),nextTick
会被多次执行,这里暂不深究)。
至此,我们再分析一下案例3的执行过程(当 403 模块是同步加载时):
// 案例3
async created() {
this.$router.replace('/403').then(() => {
console.log(1)
})
Promise.resolve().then(() => {
console.log(2)
})
this.$nextTick(() => {
console.log(3)
})
}
2
3
4
5
6
7
8
9
10
11
12
- 执行
this.$router.replace('/403')
,路由更新,触发路由相关watcher
,第一次调用nextTick
,添加微任务flushCallbacks
到微任务队列中 - 将输出1的微任务加入微任务队列
- 执行
Promise.resolve()
,添加一个输出2的微任务到微任务队列 - 执行
nextTick
,将输出3的回调函数添加到微任务flushCallbacks
的callbacks
数组中 - 执行微任务队列,执行第一个微任务
flushCallbacks
,即依次执行callbacks
数组中的回调函数,输出 3 - 执行第二个微任务,输出 1
- 执行第二个微任务,输出 2
思考 #
思考以下代码的输出顺序?
思考题1:
<template>
<div class="app">
{{msg}}
</div>
</template>
<script>
export default {
data() {
return {
msg: 'aaa'
}
},
created() {
this.msg = 'bbb'
Promise.resolve().then(() => {
console.log(1)
})
this.$nextTick(() => {
console.log(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
思考题2:
<template>
<div class="app">
{{msg}}
</div>
</template>
<script>
export default {
data() {
return {
msg: 'aaa'
}
},
mounted() {
this.msg = 'bbb'
Promise.resolve().then(() => {
console.log(1)
})
this.$nextTick(() => {
console.log(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