Heim  >  Artikel  >  Web-Frontend  >  Detaillierte Erläuterung der asynchronen Aktualisierungsstrategie von DOM in Vue und des nextTick-Mechanismus

Detaillierte Erläuterung der asynchronen Aktualisierungsstrategie von DOM in Vue und des nextTick-Mechanismus

小云云
小云云Original
2018-02-22 11:15:255066Durchsuche

Dieser Artikel teilt Ihnen hauptsächlich die Analyse der Vue asynchronen Aktualisierungsstrategie und des DOM-Mechanismus in nextTick mit. Leser müssen über gewisse Erfahrung in der Verwendung von Vue verfügen und mit dem JavaScript-Ereignisschleifenmodell vertraut sein. Ich hoffe, es hilft allen.

Einführung: Asynchrone Aktualisierung von DOM

<template>
  <p>
    <p ref="test">{{test}}</p>
    <button @click="handleClick">tet</button>
  </p>
</template>
export default {
    data () {
        return {
            test: 'begin'
        };
    },
    methods () {
        handleClick () {
            this.test = 'end';
            console.log(this.$refs.test.innerText);//打印“begin”
        }
    }
}

Das gedruckte Ergebnis ist begin anstelle des von uns festgelegten end. Dieses Ergebnis reicht aus, um zu zeigen, dass die Aktualisierungen von Vue in DOM nicht synchronisiert sind.

Dies wird in der Vueoffiziellen Dokumentation erklärt:

Vielleicht haben Sie es noch nicht bemerkt, Vueführt DOM Aktualisierungen asynchron aus. Immer wenn eine Datenänderung beobachtet wird, öffnet Vue eine Warteschlange und puffert alle Datenänderungen, die in derselben Ereignisschleife auftreten. Wenn dasselbe watcher mehrmals ausgelöst wird, wird es nur einmal in die Warteschlange verschoben. Diese Deduplizierung während der Pufferung ist sehr wichtig, um unnötige Berechnungen und DOM Operationen zu vermeiden. Dann leert tick in der nächsten Ereignisschleife „Vue“ die Warteschlange und führt die eigentliche (deduplizierte) Arbeit aus.

Kurz gesagt, alle Datenänderungen, die in einer Ereignisschleife auftreten, lösen Ansichtsaktualisierungen im Tick der nächsten Ereignisschleife aus, was ebenfalls ein „Stapelverarbeitung“-Prozess ist. (Beachten Sie, dass das Tick der nächsten Ereignisschleife möglicherweise in der aktuellen Tick-Mikrotask-Ausführungsphase oder im nächsten Tick ausgeführt wird, hauptsächlich abhängig davon, ob die nextTick-Funktion Promise/MutationObserver verwendet oder setTimeout)

Watcher Queue

Im Quellcode von Watcher haben wir festgestellt, dass das watcher von update tatsächlich asynchron ist. (Hinweis: Das Attribut sync ist standardmäßig false, was asynchron ist.) Der Code der Funktion

update () {
    /* istanbul ignore else */
    if (this.lazy) {
        this.dirty = true
    } else if (this.sync) {
        /*同步则执行run直接渲染视图*/
        this.run()
    } else {
        /*异步推送到观察者队列中,下一个tick时调用。*/
        queueWatcher(this)
    }
}

queueWatcher(this) lautet wie folgt:

 /*将一个观察者对象push进观察者队列,在队列中已经存在相同的id则该观察者对象将被跳过,除非它是在队列被刷新时推送*/
export function queueWatcher (watcher: Watcher) {
    /*获取watcher的id*/
    const id = watcher.id
    /*检验id是否存在,已经存在则直接跳过,不存在则标记哈希表has,用于下次检验*/
    if (has[id] == null) {
        has[id] = true
        if (!flushing) {
            /*如果没有flush掉,直接push到队列中即可*/
            queue.push(watcher)
        } else {
        ...
        }
        // queue the flush
        if (!waiting) {
            waiting = true
            nextTick(flushSchedulerQueue)
        }
    }
}

Diese Quelle Code hat mehrere Anforderungen. Dinge, die Sie beachten sollten:

  1. Das erste, was Sie wissen müssen, ist, dass watcher bei der Ausführung von update standardmäßig asynchron sein muss. Es wird Folgendes tun zwei Dinge:

  • Beurteilen Sie, ob dieses has im watcher-Array vorhanden ist.id

  • Wenn ja , es ist nicht erforderlich, watcher Hinzufügen zu queue einzugeben, da sonst keine Verarbeitung erfolgt.

  • Im nextTick(flushSchedulerQueue) besteht die Hauptfunktion der flushScheduleQueue-Funktion darin, den Ansichtsaktualisierungsvorgang durchzuführen. Es werden alle queue in watcher übernommen aus und führen Sie die entsprechenden Ansichtsaktualisierungen durch. Der Kern von

  • ist eigentlich die Funktion nextTick. Schauen wir uns genauer an, wofür nextTick verwendet wird. Die Funktion

  • nextTick

    nextTick führt tatsächlich zwei Dinge aus: Zum einen wird ein timerFunc generiert und als microTask oder macroTask am Rückruf teilgenommen Kommen Sie in die Event-Schleife. Die zweite besteht darin, die Rückruffunktion in eine callbacks-Warteschlange zu stellen und auf die entsprechende Ausführungszeit zu warten. (Dieses Timing hängt mit den verschiedenen Implementierungen von timerFunc zusammen)

    Schauen wir uns zunächst an, wie ein timerFunc generiert und der Rückruf als microTask oder macroTask verwendet wird.

    if (typeof Promise !== 'undefined' && isNative(Promise)) {
        /*使用Promise*/
        var p = Promise.resolve()
        var logError = err => { console.error(err) }
        timerFunc = () => {
            p.then(nextTickHandler).catch(logError)
            // in problematic UIWebViews, Promise.then doesn't completely break, but
            // it can get stuck in a weird state where callbacks are pushed into the
            // microTask queue but the queue isn't being flushed, until the browser
            // needs to do some other work, e.g. handle a timer. Therefore we can
            // "force" the microTask queue to be flushed by adding an empty timer.
            if (isIOS) setTimeout(noop)
        }
    } else if (typeof MutationObserver !== 'undefined' && (
        isNative(MutationObserver) ||
        // PhantomJS and iOS 7.x
        MutationObserver.toString() === '[object MutationObserverConstructor]'
        )) {
        // use MutationObserver where native Promise is not available,
        // e.g. PhantomJS IE11, iOS7, Android 4.4
        /*新建一个textNode的DOM对象,用MutationObserver绑定该DOM并指定回调函数,在DOM变化的时候则会触发回调,该回调会进入主线程(比任务队列优先执行),即textNode.data = String(counter)时便会触发回调*/
        var counter = 1
        var observer = new MutationObserver(nextTickHandler)
        var textNode = document.createTextNode(String(counter))
        observer.observe(textNode, {
            characterData: true
        })
        timerFunc = () => {
            counter = (counter + 1) % 2
            textNode.data = String(counter)
        }
    } else {
        // fallback to setTimeout
        /* istanbul ignore next */
        /*使用setTimeout将回调推入任务队列尾部*/
        timerFunc = () => {
            setTimeout(nextTickHandler, 0)
        }
    }

    Es ist erwähnenswert, dass die Funktion für eingehende Rückrufe entsprechend der Priorität von Promise, MutationObserver und setTimeout aufgerufen wird. Die beiden ersteren generieren eine microTask-Aufgabe, während die letzteren eine macroTask-Aufgabe generieren. (Mikrotasks und Makrotasks)

    Der Grund, warum eine solche Priorität festgelegt wird, besteht hauptsächlich darin, die Kompatibilität zwischen Browsern zu berücksichtigen (IE verfügt nicht über integrierte Promise). Das Festlegen von Promise als höchste Priorität liegt außerdem daran, dass die Promise.resolve().then-Rückruffunktion zu einer -Mikrotask gehört und der Browser nach der Ausführung von Tick alle macroTask löscht a Tick > Führen Sie dann ein microTask-Rendering durch und platzieren Sie den UI-Aktualisierungsvorgang in der Phase, in der DOM Tick ausführt. Im Vergleich zur Verwendung von microTask zum Generieren eines setTimeout gibt es einen Vorgang weniger macroTask Rendern. UI

    Die Funktion

    ist eigentlich die Funktion, die wir wirklich ausführen möchten. nextTickHandler

    function nextTickHandler () {
        pending = false
        /*执行所有callback*/
        const copies = callbacks.slice(0)
        callbacks.length = 0
        for (let i = 0; i < copies.length; i++) {
            copies[i]()
        }
    }
    Die

    Variablen hier beziehen sich auf den callbacks Verbrauch. Die zweite Funktion der Funktion nextTickHandler, die wir zuvor erwähnt haben, „Warten auf die entsprechende Ausführungszeit“, liegt tatsächlich an der unterschiedlichen Implementierung von nextTick 🎜> Rückruf ist ein timerFunc , er wird am Ende des aktuellen PromiseMutationObserver ausgeführt. Wenn es nextTickHandler ist, dann ist der microTask-Rückruf ein Tick, der beim nächsten setTiemout ausgeführt wird. nextTickHandler

    还有就是callbacks中的成员是如何被push进来的?从源码中我们可以知道,nextTick是一个自执行的函数,一旦执行是return了一个queueNextTick,所以我们在调用nextTick其实就是在调用queueNextTick这个函数。它的源代码如下:

    return function queueNextTick (cb?: Function, ctx?: Object) {
        let _resolve
        /*cb存到callbacks中*/
        callbacks.push(() => {
            if (cb) {
                try {
                cb.call(ctx)
                } catch (e) {
                handleError(e, ctx, 'nextTick')
                }
            } else if (_resolve) {
                _resolve(ctx)
            }
        })
        if (!pending) {
            pending = true
            timerFunc()
        }
        if (!cb && typeof Promise !== 'undefined') {
            return new Promise((resolve, reject) => {
                _resolve = resolve
            })
        }
    }

    可以看到,一旦调用nextTick函数时候,传入的function就会被存放到callbacks闭包中,然后这个callbacksnextTickHandler消费,而nextTickHandler的执行时间又是由timerFunc来决定。

    我们再回来看Watcher中的一段代码:

     /*将一个观察者对象push进观察者队列,在队列中已经存在相同的id则该观察者对象将被跳过,除非它是在队列被刷新时推送*/
    export function queueWatcher (watcher: Watcher) {
      /*获取watcher的id*/
      const id = watcher.id
      /*检验id是否存在,已经存在则直接跳过,不存在则标记哈希表has,用于下次检验*/
      if (has[id] == null) {
        has[id] = true
        if (!flushing) {
            /*如果没有flush掉,直接push到队列中即可*/
            queue.push(watcher)
        } else {
          ...
        }
        // queue the flush
        if (!waiting) {
          waiting = true
          nextTick(flushSchedulerQueue)
        }
      }
    }

    这里面的nextTick(flushSchedulerQueue)中的flushSchedulerQueue函数其实就是watcher的视图更新。每次调用的时候会把它pushcallbacks中来异步执行。

    另外,关于waiting变量,这是很重要的一个标志位,它保证flushSchedulerQueue回调只允许被置入callbacks一次。

    所以,也就是说DOM确实是异步更新,但是具体是在下一个Tick更新还是在当前Tick执行microTask的时候更新,具体要看nextTcik的实现方式,也就是具体跑的是Promise/MutationObserver还是setTimeout

    附:nextTick源码带注释),有兴趣可以观摩一下。

    这里面使用PromisesetTimeout来执行异步任务的方式都很好理解,比较巧妙的地方是利用MutationObserver执行异步任务。MutationObserverH5的新特性,它能够监听指定范围内的DOM变化并执行其回调,它的回调会被当作microTask来执行,具体参考MDN,。

    var counter = 1
    var observer = new MutationObserver(nextTickHandler)
    var textNode = document.createTextNode(String(counter))
    observer.observe(textNode, {
        characterData: true
    })
    timerFunc = () => {
        counter = (counter + 1) % 2
        textNode.data = String(counter)
    }

    可以看到,通过借用MutationObserver来创建一个microTasknextTickHandler作为回调传入MutationObserver中。  
    这里面创建了一个textNode作为观测的对象,当timerFunc执行的时候,textNode.data会发生改变,便会触发MutatinObservers的回调函数,而这个函数才是我们真正要执行的任务,它是一个microTask

    注:2.5+版本的VuenextTick进行了修改,具体参考下面“版本升级”一节。

    关于Vue暴露的全局nextTick

    继续来看下面的这段代码:

    <p id="example">
        <p ref="test">{{test}}</p>
        <button @click="handleClick">tet</button>
    </p>
    var vm = new Vue({
        el: '#example',
        data: {
            test: 'begin',
        },
        methods: {
            handleClick() {
                this.test = 'end';
                console.log('1')
                setTimeout(() => { // macroTask
                    console.log('3')
                }, 0);
                Promise.resolve().then(function() { //microTask
                    console.log('promise!')
                })
                this.$nextTick(function () {
                    console.log('2')
                })
            }
        }
    })

    Chrome下,这段代码执行的顺序的1、2、promise、3

    可能有同学会以为这是1、promise、2、3,其实是忽略了一个标志位pending

    我们回到nextTick函数returnqueueNextTick可以发现:

    return function queueNextTick (cb?: Function, ctx?: Object) {
        let _resolve
        /*cb存到callbacks中*/
        callbacks.push(() => {
            if (cb) {
            try {
                cb.call(ctx)
            } catch (e) {
                handleError(e, ctx, 'nextTick')
            }
            } else if (_resolve) {
            _resolve(ctx)
            }
        })
        if (!pending) {
            pending = true
            timerFunc()
        }
        if (!cb && typeof Promise !== 'undefined') {
            return new Promise((resolve, reject) => {
            _resolve = resolve
            })
        }
    }

    这里面通过对pending的判断来检测是否已经有timerFunc这个函数在事件循环的任务队列等待被执行。如果存在的话,那么是不会再重复执行的。

    最后异步执行nextTickHandler时又会把pending置为false

    function nextTickHandler () {
        pending = false
        /*执行所有callback*/
        const copies = callbacks.slice(0)
        callbacks.length = 0
        for (let i = 0; i < copies.length; i++) {
            copies[i]()
        }
    }

    所以回到我们的例子:

    handleClick() {
        this.test = &#39;end&#39;;
        console.log(&#39;1&#39;)
        setTimeout(() => { // macroTask
            console.log('3')
        }, 0);
        Promise.resolve().then(function() { //microTask
            console.log('promise!')
        })
        this.$nextTick(function () {
            console.log('2')
        })
    }

    代码中,this.test = 'end'必然会触发watcher进行视图的重新渲染,而我们在文章的Watcher一节中就已经有提到会调用nextTick函数,一开始pending变量肯定就是false,因此它会被修改为true并且执行timerFunc。之后执行this.$nextTick其实还是调用的nextTick函数,只不过此时的pendingtrue说明timerFunc已经被生成,所以this.$nextTick(fn)只是把传入的fn置入callbacks之中。此时的callbacks有两个function成员,一个是flushSchedulerQueue,另外一个就是this.$nextTick()的回调。

    因此,上面这段代码中,在Chrome下,有一个macroTask和两个microTask。一个macroTask就是setTimeout,两个microTask:分别是VuetimerFunc(其中先后执行flushSchedulerQueuefunction() {console.log('2')})、代码中的Promise.resolve().then()

    版本升级带来的变化

    上面讨论的nextTick实现是2.4版本以下的实现,2.5以上版本对于nextTick的内部实现进行了大量的修改。

    独立文件

    首先是从Vue 2.5+开始,抽出来了一个单独的文件next-tick.js来执行它。

    microTask与macroTask

    在代码中,有这么一段注释:

    Detaillierte Erläuterung der asynchronen Aktualisierungsstrategie von DOM in Vue und des nextTick-Mechanismus

    其大概的意思就是:在Vue 2.4之前的版本中,nextTick几乎都是基于microTask实现的(具体可以看文章nextTick一节),但是由于microTask的执行优先级非常高,在某些场景之下它甚至要比事件冒泡还要快,就会导致一些诡异的问题;但是如果全部都改成macroTask,对一些有重绘和动画的场景也会有性能的影响。所以最终nextTick采取的策略是默认走microTask,对于一些DOM的交互事件,如v-on绑定的事件回调处理函数的处理,会强制走macroTask

    具体做法就是,在Vue执行绑定的DOM事件时,默认会给回调的handler函数调用withMacroTask方法做一层包装,它保证整个回调函数的执行过程中,遇到数据状态的改变,这些改变而导致的视图更新(DOM更新)的任务都会被推到macroTask

    function add$1 (event, handler, once$$1, capture, passive) {
        handler = withMacroTask(handler);
        if (once$$1) { handler = createOnceHandler(handler, event, capture); }
        target$1.addEventListener(
            event,
            handler,
            supportsPassive
            ? { capture: capture, passive: passive }
            : capture
        );
    }
    
    /**
     * Wrap a function so that if any code inside triggers state change,
     * the changes are queued using a Task instead of a MicroTask.
     */
    function withMacroTask (fn) {
        return fn._withTask || (fn._withTask = function () {
            useMacroTask = true;
            var res = fn.apply(null, arguments);
            useMacroTask = false;
            return res
        })
    }

    而对于macroTask的执行,Vue优先检测是否支持原生setImmediate(高版本IE和Edge支持),不支持的话再去检测是否支持原生MessageChannel,如果还不支持的话为setTimeout(fn, 0)

    最后,写一段demo来测试一下:

    <p id="example">
        <span>{{test}}</span>
        <button @click="handleClick">change</button>
    </p>
    var vm = new Vue({
        el: '#example',
        data: {
            test: 'begin',
        },
        methods: {
            handleClick: function() {
                this.test = end;
                console.log('script')
                this.$nextTick(function () { 
                    console.log('nextTick')
                })
                Promise.resolve().then(function () {
                    console.log('promise')
                })
            }
        }
    })

    Vue 2.5+中,这段代码的输出顺序是scriptpromisenextTick,而Vue 2.4输出scriptnextTickpromisenextTick执行顺序的差异正好说明了上面的改变。

    MessageChannel

    Vue 2.4版本以前使用的MutationObserver来模拟异步任务。而Vue 2.5版本以后,由于兼容性弃用了MutationObserver

    Vue 2.5+版本使用了MessageChannel来模拟macroTask。除了IE以外,messageChannel的兼容性还是比较可观的。

    const channel = new MessageChannel()
    const port = channel.port2
    channel.port1.onmessage = flushCallbacks
    macroTimerFunc = () => {
    port.postMessage(1)
    }

    可见,新建一个MessageChannel对象,该对象通过port1来检测信息,port2发送信息。通过port2的主动postMessage来触发port1onmessage事件,进而把回调函数flushCallbacks作为macroTask参与事件循环。

    MessageChannel VS setTimeout

    为什么要优先MessageChannel创建macroTask而不是setTimeout

    HTML5中规定setTimeout的最小时间延迟是4ms,也就是说理想环境下异步回调最快也是4ms才能触发。

    Vue使用这么多函数来模拟异步任务,其目的只有一个,就是让回调异步且尽早调用。而MessageChannel的延迟明显是小于setTimeout的。对比传送门

    为什么要异步更新视图

    看下面的代码:

    <template>
      <p>
        <p>{{test}}</p>
      </p>
    </template>
    export default {
        data () {
            return {
                test: 0
            };
        },
        mounted () {
          for(let i = 0; i < 1000; i++) {
            this.test++;
          }
        }
    }

    现在有这样的一种情况,mounted的时候test的值会被++循环执行1000次。 每次++时,都会根据响应式触发setter->Dep->Watcher->update->run。 如果这时候没有异步更新视图,那么每次<code>++都会直接操作DOM更新视图,这是非常消耗性能的。 所以Vue实现了一个queue队列,在下一个Tick(或者是当前Tick的微任务阶段)的时候会统一执行queueWatcherrun。同时,拥有相同idWatcher不会被重复加入到该queue中去,所以不会执行1000Watcherrun。最终更新视图只会直接将test对应的DOM0变成1000。 保证更新视图操作DOM的动作是在当前栈执行完以后下一个Tick(或者是当前Tick的微任务阶段)的时候调用,大大优化了性能。

    应用场景

    在操作DOM节点无效的时候,就要考虑操作的实际DOM节点是否存在,或者相应的DOM是否被更新完毕。

    比如说,在created钩子中涉及DOM节点的操作肯定是无效的,因为此时还没有完成相关DOM的挂载。解决的方法就是在nextTick函数中去处理DOM,这样才能保证DOM被成功挂载而有效操作。

    还有就是在数据变化之后要执行某个操作,而这个操作需要使用随数据改变而改变的DOM时,这个操作应该放进Vue.nextTick

    之前在做慕课网音乐webApp的时候关于播放器内核的开发就涉及到了这个问题。下面我把问题简化:

    现在我们要实现一个需求是点击按钮变换audio标签的src属性来实现切换歌曲。

    <p id="example">
        <audio ref="audio"
               :src="url"></audio>
        <span ref="url"></span>
        <button @click="changeUrl">click me</button>
    </p>
    const musicList = [
        'http://sc1.111ttt.cn:8282/2017/1/11m/11/304112003137.m4a?tflag=1519095601&pin=6cd414115fdb9a950d827487b16b5f97#.mp3',
        'http://sc1.111ttt.cn:8282/2017/1/11m/11/304112002493.m4a?tflag=1519095601&pin=6cd414115fdb9a950d827487b16b5f97#.mp3',
        'http://sc1.111ttt.cn:8282/2017/1/11m/11/304112004168.m4a?tflag=1519095601&pin=6cd414115fdb9a950d827487b16b5f97#.mp3'
    ];
    var vm = new Vue({
        el: '#example',
        data: {
            index: 0,
            url: ''
        },
        methods: {
            changeUrl() {
                this.index = (this.index + 1) % musicList.length
                this.url = musicList[this.index];
                this.$refs.audio.play();
            }
        }
    });

    毫无悬念,这样肯定是会报错的:

    Uncaught (in promise) DOMException: The element has no supported sources.

    原因就在于audio.play()是同步的,而这个时候DOM更新是异步的,src属性还没有被更新,结果播放的时候src属性为空,就报错了。

    解决办法就是在play的操作加上this.$nextTick()

    this.$nextTick(function() {
        this.$refs.audio.play();
    });

    Das obige ist der detaillierte Inhalt vonDetaillierte Erläuterung der asynchronen Aktualisierungsstrategie von DOM in Vue und des nextTick-Mechanismus. Für weitere Informationen folgen Sie bitte anderen verwandten Artikeln auf der PHP chinesischen Website!

    Stellungnahme:
    Der Inhalt dieses Artikels wird freiwillig von Internetnutzern beigesteuert und das Urheberrecht liegt beim ursprünglichen Autor. Diese Website übernimmt keine entsprechende rechtliche Verantwortung. Wenn Sie Inhalte finden, bei denen der Verdacht eines Plagiats oder einer Rechtsverletzung besteht, wenden Sie sich bitte an admin@php.cn