Vue2源码分析
前言
在开发中,也会遇到用nextTick的情况,面试中也经常考到。因此,总结了下nextTick的使用及实现原理。
我们先来看一个场景:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
</head>
<body>
<div id="app" style="width:120px; height:100px">
<div style="color: red">
{
{
name}}
</div>
<span>{
{
age}}</span>
</div>
<script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
<script>
const vm = new Vue({
data: {
name: 'lisa',
age: 20,
},
el:'#app', // 将数据解析到el元素上
})
vm.$mount('#app')
// nextTick不是创建了一个异步任务,而是将任务维护到任务队列中
vm.name = '赵丽颖'
vm.$nextTick(()=>{
console.log('nextTick中', app.innerHTML)
})
console.log('非nextTick',app.innerHTML)
</script>
</body>
</html>
神奇的事情发生了,明明把name
改为赵丽颖了,但是没有使用nextTick的话,获取的Dom还是以前的,而不是最新的Dom。
此外,created里面、一些第三方插件都会遇到同样的情况。
说起原因,还得从头说起。
一、知识铺垫
1、异步更新
vue的响应式更新,并不是数据变化之后Dom立即变化,而是按照一定的策略更新的。
Vue是异步更新的,因此,要获得更新后的Dom,要使用nextTick来获取。
为什么要这么设计呢?
我们知道,根据数据响应式原理:会给每一个属性配置Object.defineProperty
,并在读取数据时(也就是在get
中)收集依赖,在更新数据时(也就是在set
中)触发依赖。这个依赖指的是watcher
(类),在读取数据时把它存起来,更新数据时通知watcher
更新数据。
试想一下,如果一个属性被修改了多次,就会多次触发watcher:
setTimeout(()=> {
vm.name='shelly'
vm.name = 'lisax'
vm.age = 18
}, 3000)
那岂不是要多次更新Dom,这样就很浪费性能。因此,Vue开启了一个队列,并缓冲在同一事件循环中发生的所有数据变更。通过同一个watcher被多次触发,只会被推入队列一次。这种缓存时去重对于避免不必要的计算和dom操作是非常重要的。实现代码如下:
update() {
// 更新数据时触发
if (this.lazy) {
// 如果是计算属性 依赖的值变化了,就标识计算属性是脏值了
this.dirty = true
} else {
queueWatcher(this); // 把当前的watcher暂存起来
}
}
let queue = [];
let has = {
};
let pending = false; // 防抖
// setTimeout中的回调函数,执行一次刷新操作
function flushSchedulerQueue() {
let flushQueue = queue.slice(0);
queue = []; // 重置
has = {
}; // 重置
pending = false; // 重置
flushQueue.forEach(q => q.run()); // 依次执行队列中的事件
}
// 数据更新时先暂存watcher
function queueWatcher(watcher) {
const id = watcher.id;
if (!has[id]) {
// 如果watcher已经存在,则不需要加入队列
queue.push(watcher); // 更新数据时,不立马更新Dom,使用队列把watcher缓存起来
has[id] = true;
// 不管update执行多少次,最终只执行一轮刷新操作
if (!pending) {
setTimeout(flushSchedulerQueue, 0); // 利用setTimeout进行回调,根据事件循环机制,setTimeout会在同步代码后面执行,详见下文的事件循环
// nextTick(flushSchedulerQueue, 0); 后文实现nextTick
pending = true;
}
}
}
2、事件循环机制
JS是一门单线程语言,那就意味着一次只能执行一个任务且按顺序执行。如果有耗时任务,也必须等着它执行完了才能执行下一个任务。问题来了,浏览网页的时候,某个高清图片需要加载很久,那网页岂不是卡着等图片加载完才能做别的操作?
显然不是这样的,JS设计者设计了一种执行机制:事件循环机制(Event Loop),以实现单线程非阻塞的方法。
我们先了解下任务。任务可分为同步任务、异步任务,同步任务可以立即执行,一般会直接进入主线程中执行。异步任务指不进入主线程而进入任务队列的任务,一般是比较耗时的,如setTimeout、ajax请求等。异步任务再细分下,可分为宏任务、微任务:
-
宏任务(macro-task):包括整体代码script、setTimeout、setInterval、ajax、DOM事件
-
微任务(micro-task):Promise.then、Node 环境下的process.nextTick
然后,事件循环机制是如何执行任务的呢?进入整体代码(宏任务)后,开始第一次循环。接着执行所有的微任务。然后再从宏任务开始,找到其中一个任务队列执行完毕,再执行所有的微任务。
详细内容查看我的另一篇博客:【JS执行机制——事件循环机制】
二、nextTick的实现
1、实现nextTick方法
在初始化的时候先把nextTick绑定在Vue的原型上,这样vm的实例就可以调用了:
import {
nextTick } from './observe/watcher'
export function initStateMixin(Vue) {
Vue.prototype.$nextTick = nextTick
}
// watcher.js
let callbacks = []; // 队列
let waiting = false;// 防抖,标识当前是否有 nextTick 在执行,同一时间只能有一个执行
export function nextTick(cb) {
// cb就是使用时用户传过来的方法
callbacks.push(cb); // 维护nextTick中的callback方法
if (!waiting) {
setTimeout(()=>{
flushCallbacks(); // 最后一起刷新
// timerFunc()
},0)
waiting = true;
}
}
// setTimeout中的回调函数,执行一次刷新操作
function flushCallbacks() {
//debugger
waiting = false;
let cbs = callbacks.slice(0);
callbacks = [];
cbs.forEach(cb => cb()); // 按照顺序依次执行
}
2、使用promise优化
用setTimeout性能耗费比微任务大,且比微任务后面执行。因此,尝试使用promise进行优化:
let timerFunc;
timerFunc = () => {
Promise.resolve().then(flushCallbacks)
}
export function nextTick(cb) {
//debugger
callbacks.push(cb); // 维护nextTick中的callback方法
if (!waiting) {
//setTimeout(()=>{
// flushCallbacks(); // 最后一起刷新
timerFunc() // 这样就是微任务了
//},0)
waiting = true;
}
}
上文异步更新中queueWatcher
方法中的setTimeout,就可以复用nextTick方法了:
function queueWatcher(watcher) {
const id = watcher.id;
if (!has[id]) {
queue.push(watcher);
has[id] = true;
console.log(queue)
// 不管update执行多少次,最终只执行一轮刷新操作
if (!pending) {
// setTimeout(flushSchedulerQueue, 0);
nextTick(flushSchedulerQueue, 0);
pending = true;
}
}
}
3、优雅降级
有些浏览器不兼容promise,比如ie浏览器。所以内部采用了优雅降级的方式:
let timerFunc;
if (Promise) {
timerFunc = () => {
Promise.resolve().then(flushCallbacks)
}
} else if (MutationObserver) {
// 如果promise不支持,就使用MutationObserver
let observe = new MutationObserver(flushCallbacks)
let textnode = document.createTextNode(1);
observe.observe(textnode, {
characterData:true
})
timerFunc = () => {
textnode.textContent = 2;
}
} else if (setImmediate) {
// 再不支持,使用setImmediate
timerFunc = () => {
setImmediate(flushCallbacks)
}
} else {
timerFunc = () => {
// 再不支持,使用setTimeout
setTimeout(flushCallbacks)
}
}
export function nextTick(cb) {
callbacks.push(cb); // 维护nextTick中的callback方法
if (!waiting) {
timerFunc()
waiting = true;
}
}
4、还有一个问题就是,使用nextTick的时候,你得这么使用:
vm.name = 'Lisa'
vm.$nextTick(()=>{
// 要在更新数据的后面使用
console.log(app.innerHTML)
})
因为:更新数据时,先把更新Dom的事件放入队列,然后再把nextTick事件放入队列。队列先进先出,这样才能依次执行,nextTick才能获取更新后的Dom。
总结
1、nextTick是Vue提供的一个全局API,由于Vue的异步更新策略导致我们对数据的修改不会更新,如果此时想要获取更新后的Dom,就需要使用这个方法。
2、nextTick实现原理并不算复杂,即在一次事件循环中,更新了数据,把更新Dom的操作放入队列中,使用了nextTick,则把nextTick里的回调放入队列中,执行完所有的同步代码后,去执行微任务,即依次调用队列里的函数。
3、nextTick不是创建了一个异步任务,而是将任务维护到任务队列中。
源码地址:小Demo手写Vue2
官网源码:官网网址