您说什么、希望什么、期待什么、想什么都不重要,只有你做了什么才算数
------博恩.崔西
前言
这是一个Vue源码系列文章,建议从第一篇文章 Vue源码系列(一):Vue源码解读的正确姿势 开始阅读。 文章是我个人学习源码的一个历程,这边分享出来希望对大家有所帮助。
本文要讲解的是Vue异步更新机制和nextTick原理。
大家都知道,异步更新和响应式原理一样,都是Vue的核心之一。在我们使用Vue的过程中,基本大部分的 watcher 更新都需要经过异步更新的处理。而 nextTick 则是异步更新的核心。
接下来我将从源码入手,然后再以例子讲解,让小伙伴们都能弄懂这Vue的一个核心原理。
源码解析
源码解析我们需要找一个入口,那入口又是什么呢??
在上上篇文章 Vue源码系列(三):数据响应式原理🔥🔥 中咱们说了vue的数据响应式原理,其中在 dep.js(也就是“代码块8”)中,我们说过一个方法:notify(),当时我对他的解析是:通知更新。它就是本篇文章的入口,不记得的可以回顾一下上上篇文章。
接下来从入口 notify() 方法,开始本章的源码解读。
dep.js
代码块 1
// src/core/observer/dep.js
...
// 遍历dep的所有watcher 然后执行他们的update
notify () {
// 获取dep所收集的所有watcher
const subs = this.subs.slice()
// 在非生产环境并且同步执行的时候 做排序处理
if (process.env.NODE_ENV !== 'production' && !config.async) {
subs.sort((a, b) => a.id - b.id)
}
// 遍历所有watcher,并执行他们各自的update()方法
for (let i = 0, l = subs.length; i < l; i++) {
subs[i].update()
}
}
...
watcher.js
接下来看看 watcher 调用的 update() 方法具体做了什么。
代码块 2
// src/core/observer/watcher.js
...
// 更新
update () {
if (this.lazy) {
// 懒执行走这里,比如:computed()
this.dirty = true
} else if (this.sync) {
// 同步执行 更新视图 执行下面的run函数
this.run()
} else {
// 异步推送 进入更新队列 将watcher放到观察者队列中 具体实现查看【代码块 3】
queueWatcher(this)
}
}
// 更新视图
run () {
if (this.active) {
// 调用get方法
const value = this.get()
if (
value !== this.value ||
isObject(value) ||
this.deep
) {
// 更新旧值为新值
const oldValue = this.value
this.value = value
if (this.user) {
const info = `callback for watcher "${this.expression}"`
invokeWithErrorHandling(this.cb, this.vm, [value, oldValue], this.vm, info)
} else {
// 渲染watcher
this.cb.call(this.vm, value, oldValue)
}
}
}
}
...
scheduler.js
继续看看将watcher放到观察者队列中的 queueWatcher 方法
【代码块 3】
// /src/core/observer/scheduler.js
export function queueWatcher (watcher: Watcher) {
const id = watcher.id
/**
* 当我们调用某个watcher的callback之前会先将它在has中的标记置为null
*
* 注意 这里是==而不是===
* 如果has[id]不存在,则has[id]为undefined,undefined==null结果为true
* 如果has[id]存在且为null,则为true
* 如果has[id]存在且为true,则为false
* 这个if表示,如果这个watcher尚未被 flush 则 return
*/
// 做一个判重 如果 watcher 已经存在,则不会重复进入队列
if (has[id] == null) {
// 再次把watcher置为true 说明已经在队列中了 保证只有一个watcher,避免重复
has[id] = true
if (!flushing) {
// 如果没有在刷新队列中,则将watcher push入队列中
queue.push(watcher)
} else {
// 如果在刷新队列中 则根据当前 watcher.id 遍历
// 这个循环其实是在处理边界情况。 即:在watcher队列更新过程中,用户再次更新了队列中的某个watcher
let i = queue.length - 1
while (i > index && queue[i].id > watcher.id) {
i--
}
// 倒序查找,找到小于自己的 则将自己插入下一个位置
queue.splice(i + 1, 0, watcher)
}
if (!waiting) {
// 通过waiting 保证nextTick只执行一次
waiting = true
// 在非生产环境并且同步执行的时候 刷新调度队列
if (process.env.NODE_ENV !== 'production' && !config.async) {
flushSchedulerQueue()
return
}
// 最终会把所有更新动作(flushSchedulerQueue)放入nextTick中,推入到异步队列中执行
nextTick(flushSchedulerQueue)
}
}
}
next-tick.js
最后就是看看nextTick具体做了什么。
【代码块 4】
// src/core/util/next-tick.js
// 引入一些需要的方法
import { noop } from 'shared/util'
import { handleError } from './error'
import { isIE, isIOS, isNative } from './env'
// 是否使用微任务
export let isUsingMicroTask = false
// 其实就是需要处理的事件队列
const callbacks = []
// 如果已经有timerFunc推送到任务队列中 则不再推送
let pending = false
// 最终执行nextTick方法传进来的回调函数(执行事件队列中的事件)
function flushCallbacks () {
pending = false
const copies = callbacks.slice(0)
callbacks.length = 0
// 遍历callbacks,执行存储在其中的flushSchedulerQueue函数
for (let i = 0; i < copies.length; i++) {
copies[i]()
}
}
/**
* 这里先翻译一下源码中注释的大概意思, 以下是对源码注释的一个翻译:
*
* Here we have async deferring wrappers using microtasks.
* 这里我们使用微任务的异步延迟包装器
* In 2.5 we used (macro) tasks (in combination with microtasks).
* 在 2.5 版本我们使用宏任务和微任务相结合
* However, it has subtle problems when state is changed right before repaint
* 然而,当状态在重新绘制之前更改时,有一些微妙的问题
* Also, using (macro) tasks in event handler would cause some weird behaviors
* 此外,在事件处理程序中使用宏任务会导致一些无法回避的奇怪行为
* So we now use microtasks everywhere, again.
* 所以我们现在重新使用微任务(microtasks)
* A major drawback of this tradeoff is that there are some scenarios where microtasks have too high a priority and fire in between supposedly sequential events or even between bubbling of the same event
* 这种权衡的一个主要缺点是,在某些情况下,微任务的优先级太高,在假定的连续事件之间,甚至在同一事件的冒泡之间触发
*
*/
// 设置一个函数指针,将该指针添加到任务队列,待主线程任务执行完毕后, 再将任务队列中的 timerFunc 函数添加到执行栈中执行
let timerFunc
/**
* 这边其实是一个“优雅降级”处理
* 执行的优先顺序为 promise => MutationObserver => setImmediate => setTimeout
*/
// 首先 判断是否原生支持Promise
if (typeof Promise !== 'undefined' && isNative(Promise)) {
const p = Promise.resolve()
timerFunc = () => {
p.then(flushCallbacks)
if (isIOS) setTimeout(noop)
}
isUsingMicroTask = true
// 其次 判断是否原生支持MutationObserver
// MutationObserver: 该方法提供了监视对DOM树所做更改的能力
} else if (!isIE && typeof MutationObserver !== 'undefined' && (
isNative(MutationObserver) || MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
let counter = 1
// 如果支持MutationObserver 用MutationObserver执行flushCallbacks
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
// 再次 判断是否原生支持setImmediate
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
timerFunc = () => {
// 如果原生支持setImmediate 用setImmediate执行flushCallbacks
// 该方法用来把一些需要长时间运行的操作放在一个回调函数里,在浏览器完成后面的其他语句后,就立刻执行这个回调函数。
setImmediate(flushCallbacks)
}
// 最后 都不支持的情况下 则使用setTimeout来兜底
} else {
timerFunc = () => {
setTimeout(flushCallbacks, 0)
}
}
// 将回调函数cb包装成一个箭头函数push到事件队列callbacks中
export function nextTick (cb?: Function, ctx?: Object) {
let _resolve
// 向事件队列中添加箭头函数作为参数,并且使用callbacks存储包装好的cb函数
callbacks.push(() => {
if (cb) {
// try catch 包装回调函数,是为了错误捕获
try {
cb.call(ctx)
} catch (e) {
handleError(e, ctx, 'nextTick')
}
} else if (_resolve) {
_resolve(ctx)
}
})
// 在事件队列执行(flushCallbacks调用)的时候pending才设为false
// pending 为 flushCallbacks 被添加到队列里到尚未执行这段时间
if (!pending) {
pending = true
timerFunc()
}
if (!cb && typeof Promise !== 'undefined') {
return new Promise(resolve => {
_resolve = resolve
})
}
}
源码解析小结
到此就是vue异步更新源码和nextTick的源码解析。咱们稍作总结一下:
- 异步更新机制是vue的核心原理之一,主要运用的是浏览器的异步任务队列。(这里之后会出一个Event Loop的文章,之后文章OK以后再把链接贴上来)
- 数据更新以后调用 dep.js 中的 notify() 遍历 dep 的所有 watcher,然后执行他们的 update()
- 然后 watcher.js 中的 update() 将 watcher 放到观察者队列中,
- 最后 执行 scheduler.js 中的 queueWatcher() 最终会把所有更新动作(flushSchedulerQueue)放入 nextTick中,并推入到异步队列中执行。 在回调中,对 queueWatcher() 中的 watcher 进行排序,然后执行对应的DOM更新
Vue的数据更新为什么要使用异步呢?
以下面的代码为例,展开说明
<template>
<div>
<h1 ref="h1" @click="handleMessage">{{ message }}</h1>
</div>
</template>
<script>
export default {
data() {
return {
message: '异步更新'
}
},
methods: {
handleMessage() {
this.message = 'nextTick'
const _data = this.$refs['h1'].innerHTML
console.log(_data)
}
},
}
</script>
<style lang="scss" scoped></style>
上面的代码本意是想在点击 h1 标签的时候更新 h1 的内容为 nextTick。 但是在实际过程中我们会发现,这么简单的逻辑既然实现不了,打印出来以后既然不是我们想象的结果。如图↓
从上图可以看出,在第一次点击的时候,我们打印出来的还是原来的值 异步更新 而不是 nextTick。在第二次点击的时候才会变成 nextTick。那么如果我们想在第一次点击的时候就更新做一些别的事,就会无法实现。
那么为什么会出现这种情况呢?
这就是因为vue对dom的更新时异步的。当vue观察到data中的数据变化时,就会缓存在一个事件循环中,只有等js的引擎清空了这个事件队列中的缓存以后,在下一个事件的时候才会去渲染。
所以咱们打印的时候是打印不到想要的结果的。那么想要打印到我们想要的结果怎么办呢?其实也很简单,加一个 nextTick 就OK。上代码↓
<template>
<div>
<h1 ref="h1" @click="handleMessage">{{ message }}</h1>
</div>
</template>
<script>
export default {
data() {
return {
message: '异步更新'
}
},
methods: {
handleMessage() {
this.message = 'nextTick'
this.$nextTick(()=>{
const _data = this.$refs['h1'].innerHTML
console.log(_data)
})
}
},
}
</script>
<style lang="scss" scoped></style>
效果如下↓
那么问题来了,Vue的数据更新为什么要使用异步呢?看着没啥用的感觉。接下来再看一个例子 🌰
<template>
<div>
<h1 ref="h1" @click="handleMessage">{{ value }}</h1>
</div>
</template>
<script>
export default {
data() {
return {
value: 0
}
},
methods: {
handleMessage() {
for(var i=0; i<=10; i++ ) {
this.value = i
console.log(this.value)
}
}
},
}
</script>
<style lang="scss" scoped></style>
一样的当我们点击 h1 的时候看一下效果↓
如图,在我们正常的想法中 this.value 从0依次变到10,视图也会从0依次变到10,结果视图只是从0直接变为10,中间并没有过渡。这就是Vue的数据更新使用异步的原因:
Vue每一次的更新都会渲染整个组件,如果是同步的话,一旦修改了data属性,便会触发对应的watcher,然后调用对应watcher下的update方法更新视图,这样就会造成渲染太过频繁。而异步更新则Vue会在本轮数据更新后,再去异步更新视图,这样就能大大的优化性能。
本文到此也就结束了,希望对大家有所帮助。同时看到这里了也希望XDM献上免费小爱心,不胜感激🙏 🙏 🙏
Vue2.x系列的源码文章也就到这篇文章为止,基本常规的vue源码面试题也都差不多了,之后会继续Vue3.x的系列文章,希望大家继续多多支持🙏 🙏 🙏 。