nextTick 作用
官网对 nextTick 的说明是这样的:
等待下一次 DOM 更新刷新的工具方法。当你在 Vue 中更改响应式状态时,最终的 DOM 更新并不是同步生效的,而是由 Vue 将它们缓存在一个队列中,直到下一个"tick"才一起执行。这样是为了确保每个组件无论发生多少状态改变,都仅执行一次更新。
Vue 中响应状态改变不是马上更新而是缓存在一个队列在一定时间后才一起执行,类似函数节流的思路,在这个时间段内只做一次更新,而达到优化性能目的。既然 DOM 更新不是同步的,就需要 DOM 更新后执行一个函数回调,做更新后的一些操作,nextTick 就是这个作用。
那么 DOM 更新的缓存队列是完全像函数节流一样实现的么?nextTick 具体又是怎么实现的呢?
响应系统原理
nextTick 是 DOM 更新后的回调,自然跟 Vue 的响应系统紧密联系,因此先要搞清楚 Vue 响应系统的原理。我们知道Vue 3采用Proxy实现响应式数据,具体如何实现?依赖收集和派发更新又在哪里执行?调度器又如何调度派发的?下面来详细解释说明。
Proxy 对象
简单实现一个响应式数据
data 对象 name 属性值改变时会自动更新页面上的对应元素。
代码中将更新操作放入到set方法中,如果页面其他地方也绑定了 name 属性值那要重新改写 set 方法,不方便维护,因此就有了副作用函数。
副作用函数
副作用函数指的是会产生副作用(也就是产生其他影响)的函数。上面例子中:
就可以作为副作用函数。
在声明一个数组存储要执行的副作用函数,页面其他地方绑定了 name 需要更新,只要往数组中添加对应的副作用函数就可以了。
这里采用了策略模式将副作用操作一个个封装起来,将不变的部分和变化的部分分隔开。
这里我们将代码优化下把 dep 数组换成可以去重的 Set 对象更好些。
依赖收集
上面示例只是对其中一个对象的一个 key 的副作用执行,还有多个对象的多个 key 的多个副作用函数的存储和执行。
这就需要在 get 方法中将对应的副作用函数添加到对应的位置上。这就是依赖收集的部分了。
这部分不涉及到 nextTick 因此不做详细解析。
派发更新
示例中这个这段就是派发更新,当然实际源码中要考虑的细节更多更复杂。
调度器
调度器是目的有两个:一是控制副作用函数的执行顺序;二是控制相同副作用函数的执行次数,也就是在一定时间内我们只关心最终结果更新到视图上,并不关心过程执行了多少次。一般我们实现这种方式是用函数节流。
但这里使用并不合适,一是执行的副作用函数很多的情况下会创建很多定时器影响性能,二是副作用函数的操作主要是操作 DOM,而定时器是宏任务,事件循环的执行流程中微任务更适合操作 DOM。
事件循环:
- 进入到 script 标签,就进入到了第一次事件循环.
- 遇到同步代码,立即执行
- 遇到宏任务,放入到宏任务队列里.
- 遇到微任务,放入到微任务队列里.
- 执行完代码
- 执行微任务代码
- 微任务代码执行完毕,本次队列清空
- 执行浏览器 UI 线程的渲染工作
- 检查是否有 Web Worker 任务,有则执行
- 寻找下一个宏任务,重复步骤2
如果用定时器宏任务操作 DOM 要等到下一次循环才能渲染,在微任务中操作 DOM 当前循环就能渲染。
副作用函数的执行放在 Promise 的微任务中,并且用有去重效果的 Set 对象 queueJob 来存储要执行的副作用函数达到控制执行次数的目的。
示例中 effects 每一项都会创建一个新的微任务,这里其实不用创建,只要把副作用函数放入 queueJob 就可以了,因此添加了 isFlushing 表示是否正在执行队列中的函数。
任务队列执行完后要将其清空。
再进一步优化细节,Promise.resolve()
每次执行都会返回一个新的 Promise,因此这里用一个变量来存储 Promise.resolve()
返回的 Promise。
这里只是简单实现调度器,实际上 Vue 内部的调度器更加完善。
nextTick 实现
理解了响应系统的原理,实现 nextTick 就简单了,在 resolvedPromise 后加 then 回调。
完整代码:
nextTick 注意事项
执行顺序
我们先来看个示例:
示例中count.value++
之前调用 nextTick,打印出来是 0,第一次的 nextTick 的回调是在 DOM 更新之前执行的。第二个打印就是 1。
因为第一次 nextTick 就是将回调加入微任务队列中,之后 count.value++
会触发 set 方法,方法中将 DOM 的更新操作也放入微任务队列中,因此第一次 nextTick 的回调执行时在 DOM 更新操作之前。
再看另一个示例:
这个执行结果是:
这里有个奇怪的点 "first nextTick then" 的执行是在 DOM 更新操作之后,我们将上面的代码转换一下会更好理解。
first nextTick
、DOM 更新操作、second nextTick
是在同一个 Promise 绑定 then 的。因此他们的执行最早按顺序执行的。
first nextTick then
和 second nextTick then
是 resolvedPromise
执行 then 方法返回的新的 Promise 对象绑定的 then。因此他们的要晚放入微任务队列中,有兴趣的同学可以研究下Promise源码实现。
所以 "first nextTick then" 的执行是在 DOM 更新操作之后。
渲染顺序
在看 nextTick 的时候心里有个疑问,事件循环的执行顺序是微任务执行完了之后才会执行浏览器UI渲染页面,也就是微任务执行时候页面还没渲染,为什么 nextTick 的回调微任务能获取渲染之后的 DOM 的内容呢?就是上面的示例能获取 document.getElementById('counter').textContent
值是 1。
一个朋友点醒我:浏览器器的 UI 渲染和 DOM 操作是两回事,在副作用函数执行完之后其实浏览器的 DOM 已经更新,而document.getElementById('counter').textContent
其实也是获取 DOM 的结构状态,而浏览器的UI渲染是要等 DOM 操作任务达到一定数量或间隔时间达到一个事件循环才会执行。