赞
踩
在开发中,也会遇到用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里面、一些第三方插件都会遇到同样的情况。
说起原因,还得从头说起。
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; } } }
JS是一门单线程语言,那就意味着一次只能执行一个任务且按顺序执行。如果有耗时任务,也必须等着它执行完了才能执行下一个任务。问题来了,浏览网页的时候,某个高清图片需要加载很久,那网页岂不是卡着等图片加载完才能做别的操作?
显然不是这样的,JS设计者设计了一种执行机制:事件循环机制(Event Loop),以实现单线程非阻塞的方法。
我们先了解下任务。任务可分为同步任务、异步任务,同步任务可以立即执行,一般会直接进入主线程中执行。异步任务指不进入主线程而进入任务队列的任务,一般是比较耗时的,如setTimeout、ajax请求等。异步任务再细分下,可分为宏任务、微任务:
宏任务(macro-task):包括整体代码script、setTimeout、setInterval、ajax、DOM事件
微任务(micro-task):Promise.then、Node 环境下的process.nextTick
然后,事件循环机制是如何执行任务的呢?进入整体代码(宏任务)后,开始第一次循环。接着执行所有的微任务。然后再从宏任务开始,找到其中一个任务队列执行完毕,再执行所有的微任务。
详细内容查看我的另一篇博客:【JS执行机制——事件循环机制】
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
官网源码:官网网址
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。