在说nextTick之前,我们先来看一个常见的错误:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>example</title>
</head>
<body>
<div id="app">
<div v-if="isShow">
<input type="text" ref="userName" />
</div>
<button @click="showInput">点击显示输入框</button>
</div>
</body>
</html>
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<script>
var app = new Vue({
el: '#app',
data: {
isShow: false
},
methods:{
showInput(){
this.isShow = true
this.$refs.userName.focus()
}
}
})
</script>
运行结果是报错,找不到节点。也就是说,当你执行到isShow=true时,此时dom节点尚未更新,只能等待dom更新后,你才能执行下面的focus。
那怎么改呢?很简单,用nextTick:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>example</title>
</head>
<body>
<div id="app">
<div v-if="isShow">
<input type="text" ref="userName" />
</div>
<button @click="showInput">点击显示输入框</button>
</div>
</body>
</html>
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<script>
var app = new Vue({
el: '#app',
data: {
isShow: false
},
methods:{
showInput(){
this.isShow = true
this.$nextTick(()=>{
this.$refs.userName.focus()
})
}
}
})
</script>
那这个nextTick的实现原理是啥呢?我们看一眼源码:
export const nextTick = (function () {
var callbacks = []
var pending = false
var timerFunc
function nextTickHandler () {
pending = false
// 之所以要slice复制一份出来是因为有的cb执行过程中又会往callbacks中加入内容
// 比如$nextTick的回调函数里又有$nextTick
// 这些是应该放入到下一个轮次的nextTick去执行的,
// 所以拷贝一份当前的,遍历执行完当前的即可,避免无休止的执行下去
var copies = callbacks.slice(0)
callbacks = []
for (var i = 0; i < copies.length; i++) {
copies[i]()
}
}
/* istanbul ignore if */
// ios9.3以上的WebView的MutationObserver有bug,
//所以在hasMutationObserverBug中存放了是否是这种情况
if (typeof MutationObserver !== 'undefined' && !hasMutationObserverBug) {
var counter = 1
// 创建一个MutationObserver,observer监听到dom改动之后后执行回调nextTickHandler
var observer = new MutationObserver(nextTickHandler)
var textNode = document.createTextNode(counter)
// 调用MutationObserver的接口,观测文本节点的字符内容
observer.observe(textNode, {
characterData: true
})
// 每次执行timerFunc都会让文本节点的内容在0/1之间切换,
// 不用true/false可能是有的浏览器对于文本节点设置内容为true/false有bug?
// 切换之后将新值赋值到那个我们MutationObserver观测的文本节点上去
timerFunc = function () {
counter = (counter + 1) % 2
textNode.data = counter
}
} else {
// webpack attempts to inject a shim for setImmediate
// if it is used as a global, so we have to work around that to
// avoid bundling unnecessary code.
// webpack默认会在代码中插入setImmediate的垫片
// 没有MutationObserver就优先用setImmediate,不行再用setTimeout
const context = inBrowser
? window
: typeof global !== 'undefined' ? global : {}
timerFunc = context.setImmediate || setTimeout
}
return function (cb, ctx) {
var func = ctx
? function () { cb.call(ctx) }
: cb
callbacks.push(func)
// 如果pending为true, 就其实表明本轮事件循环中已经执行过timerFunc(nextTickHandler, 0)
if (pending) return
pending = true
timerFunc(nextTickHandler, 0)
}
})()
得,有人一见源码就晕,那就不添恶心了,主要是看这里面有个叫MutationObserver的东西,这玩意是干啥的呢?
Mutation是突变的意思,Observer就是观察,MutationObserver就是观察突变。
那观察什么突变呢?总得有个具体的东西吧?嗯,这个东西就是dom了。也就是说,MutationObserver就是观察dom发生变化的,是html5原装api。
我们通过一个例子看一下具体使用方法:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>MutationObserver</title>
</head>
<body>
<div id="content">hi</div>
</body>
</html>
<script>
var callback = function(mutationsList, observer) {
for(var mutation of mutationsList) {
if (mutation.type == 'characterData') {
console.log('监听到文本节点变更为:',mutation.target.data);
}
// if (mutation.type == 'childList') {
// console.log('A child node has been added or removed.');
// }
// else if (mutation.type == 'attributes') {
// console.log('The ' + mutation.attributeName + ' attribute was modified.');
// }
}
};
// 创建一个observer示例与回调函数相关联
var observer = new MutationObserver(callback);
var targetNode = document.getElementById('content')
observer.observe(targetNode.firstChild,//监听的是文本节点
{
characterData: true, //设置true,表示观察目标数据的改变
//attributes: true, //设置true,表示观察目标属性的改变
//childList: true, //设置true,表示观察目标子节点的变化,比如添加或者删除目标子节点,不包括修改子节点以及子节点后代的变化
//subtree: true //设置为true,目标以及目标的后代改变都会观察
})
setTimeout(function(){
targetNode.firstChild.data='hello,world'//变更文本节点信息
},1000)
</script>
这其实就是个观察者模式,只要监听到dom发生了变化,就触发callback回调函数的执行。
回到nextTick源码,其实作者的思路是:
创建一个textNode文本节点并监听,然后让文本值反复在0和1之间切换。只要dom发生任何变化就会触发回调函数执行,这样就可以保证回调函数拿到的dom都是最新的。
我们把上面的代码调整一下:
var callback = function(mutationsList, observer) {
for(var mutation of mutationsList) {
if (mutation.type == 'characterData') {
console.log('监听到文本节点变更为:',mutation.target.data);
}
// if (mutation.type == 'childList') {
// console.log('A child node has been added or removed.');
// }
// else if (mutation.type == 'attributes') {
// console.log('The ' + mutation.attributeName + ' attribute was modified.');
// }
}
};
// 创建一个observer示例与回调函数相关联
var observer = new MutationObserver(callback);
var textNode = document.createTextNode('hi')//新建文本节点
observer.observe(textNode,//监听文本节点
{
characterData: true, //设置true,表示观察目标数据的改变
//attributes: true, //设置true,表示观察目标属性的改变
//childList: true, //设置true,表示观察目标子节点的变化,比如添加或者删除目标子节点,不包括修改子节点以及子节点后代的变化
//subtree: true //设置为true,目标以及目标的后代改变都会观察
})
setTimeout(function(){
textNode.data='hello,world'//变更文本节点信息
},1000)
再回到第一段代码的场景,当isShow变为true之后,dom确实没有立即更新,这涉及到虚拟dom和真实dom的同步问题,这个搞不明白不要紧,反正只要nextTick后面的回调函数,确保只有dom变化了再执行就ok。
我们根据以上思路,自己写一个简易版的nextTick试试:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>example</title>
</head>
<body>
<div id="app">
<div v-if="isShow">
<input type="text" ref="userName" />
</div>
<button @click="showInput">点击显示输入框</button>
</div>
</body>
</html>
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<script>
var app = new Vue({
el: '#app',
data: {
isShow: false
},
methods:{
showInput(){
this.isShow = true
this.mynextTick(()=>{
this.$refs.userName.focus()
})
},
mynextTick(func){
var textNode = document.createTextNode(0)//新建文本节点
var that = this
var callback = function(mutationsList, observer) {
func.call(that)
}
var observer = new MutationObserver(callback);
observer.observe(textNode,{characterData:true })
textNode.data = 1//修改文本信息,触发dom更新
}
}
})
</script>
不过吧,这只是vue初期版本的实现,新版本作者弃用了MutationObserver,改用MessageChannel了。
// Determine (macro) task defer implementation.
// Technically setImmediate should be the ideal choice, but it's only available
// in IE. The only polyfill that consistently queues the callback after all DOM
// events triggered in the same loop is by using MessageChannel.
/* istanbul ignore if */
// 如果浏览器不支持Promise,使用宏任务来执行nextTick回调函数队列
// 能力检测,测试浏览器是否支持原生的setImmediate(setImmediate只在IE中有效)
if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
// 如果支持,宏任务( macro task)使用setImmediate
macroTimerFunc = () => {
setImmediate(flushCallbacks)
}
// 同上
} else if (typeof MessageChannel !== 'undefined' && (
isNative(MessageChannel) ||
// PhantomJS
MessageChannel.toString() === '[object MessageChannelConstructor]'
)) {
const channel = new MessageChannel()
const port = channel.port2
channel.port1.onmessage = flushCallbacks
macroTimerFunc = () => {
port.postMessage(1)
}
} else {
/* istanbul ignore next */
// 都不支持的情况下,使用setTimeout
macroTimerFunc = () => {
setTimeout(flushCallbacks, 0)
}
}
代码没放全哈,咱们主要搞搞清楚MessageChannel是啥玩意?
MessageChannel就是信息通道,它也是html5的api,这玩意干啥用的呢?
你可以把它想象成打电话。打电话必须有两部电话机吧?ok,MessageChannel提供了port1和port2两个属性,分别代表发送端和接收端。然后呢?然后他俩就可以通信了呗:
var channel = new MessageChannel();
var port1 = channel.port1;
var port2 = channel.port2;
port1.onmessage = function (event) {
console.log("port2对port1说:"+event.data);
}
port2.onmessage = function (event) {
console.log("port1对port2说:"+event.data);
}
port1.postMessage("你好port2,吃饭了?");
port2.postMessage("你好port1,没吃呢,你请客?");
nextTick大体实现思路是:
当vue修改了某一个状态时,port1就发出一个信息,然后port2接收到信息,触发回调函数。
const channel = new MessageChannel()
const port = channel.port2
channel.port1.onmessage = flushCallbacks
macroTimerFunc = () => {
port.postMessage(1)
}
因为这个动作是异步的,因此会确保dom更新一轮后,回调函数才会执行,当然这里面又涉及到宏任务和微任务的问题,这块单独写一篇讲解吧。
好,我们根据以上思路,写一个简易版的nextTick试试:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>example</title>
</head>
<body>
<div id="app">
<div v-if="isShow">
<input type="text" ref="userName" />
</div>
<button @click="showInput">点击显示输入框</button>
</div>
</body>
</html>
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<script>
var app = new Vue({
el: '#app',
data: {
isShow: false
},
methods:{
showInput(){
this.isShow = true
this.mynextTick(()=>{
this.$refs.userName.focus()
})
},
mynextTick(func){
var that = this
const ch = new MessageChannel()
const port1 = ch.port1
const port2 = ch.port2
//接受消息
port2.onmessage = function() {
func.call(that)
}
port1.postMessage(1)//随便发送个东西就行
}
}
})
</script>