nextTick源码分析:MutationObserver和MessageChannel

在说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>
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,948评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,371评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,490评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,521评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,627评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,842评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,997评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,741评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,203评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,534评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,673评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,339评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,955评论 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,770评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,000评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,394评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,562评论 2 349

推荐阅读更多精彩内容