高階前端開發者必會的34道Vue面試題解析(三)

全棧者發表於2020-03-30

高階前端開發者必會的34道Vue面試題解析(三)

高階前端開發者必會的34道Vue面試題解析(三)

前言

通過前面的文章,我們認識了頁面的響應是由Vue例項裡的data函式所返回的資料變化而驅動,也重點學習了頁面的響應與資料變化之間是是如何來聯絡起來的,並且分別在Vue2.x與3.x中,從零到一實現了兩個版本下的資料變化驅動頁面響應原理。

接下來在本文裡一起看看當資料變化時,從原始碼層面逐步分析一下觸發頁面的響應動作之後,如何做渲染到頁面上,展示到使用者層面的。

同時也會了解在Vue中的非同步方法NextTick的原始碼實現,看一看NextTick方法與瀏覽器的非同步API有何聯絡。

注意,本文涉及的Vue原始碼版本為2.6.11。

什麼是非同步渲染?

這個問題應該先要做一個前提補充,當資料在同步變化的時候,頁面訂閱的響應操作為什麼不會與資料變化完全對應,而是在所有的資料變化操作做完之後,頁面才會得到響應,完成頁面渲染。

從一個例子體驗一下非同步渲染機制。

import Vue from 'Vue'
new Vue({
  el: '#app',
  template: '<div>{{val}}</div>',
  data () {
    return {
      val: 'init'
    }
  },
  mounted () {
    this.val = '我是第一次頁面渲染'
    // debugger 
    this.val = '我是第二次頁面渲染'
    const st = Date.now()
    while(Date.now() - st < 3000) {}
  }
})複製程式碼

上面這一段程式碼中,在mounted裡給val屬性進行了兩次賦值,如果頁面渲染與資料的變化完全同步的話,頁面應該是在mounted裡有兩次渲染。

而由於Vue內部的渲染機制,實際上頁面只會渲染一次,把第一次的賦值所帶來的的響應與第二次的賦值所帶來的的響應進行一次合併,將最終的val只做一次頁面渲染。

而且頁面是在執行所有的同步程式碼執行完後才能得到渲染,在上述例子裡的while阻塞程式碼之後,頁面才會得到渲染,就像在熟悉的setTimeout裡的回撥函式的執行一樣,這就是的非同步渲染。

熟悉React的同學,應該很快能想到多次執行setState函式時,頁面render的渲染觸發,實際上與上面所說的Vue的非同步渲染有異曲同工之妙。

Vue為什麼要非同步渲染?

我們可以從使用者和效能兩個角度來探討這個問題。

從使用者體驗角度,從上面例子裡便也可以看出,實際上我們的頁面只需要展示第二次的值變化,第一次只是一箇中間值,如果渲染後給使用者展示,頁面會有閃爍效果,反而會造成不好的使用者體驗。

從效能角度,例子裡最終的需要展示的資料其實就是第二次給val賦的值,如果第一次賦值也需要頁面渲染則意味著在第二次最終的結果渲染之前頁面還需要渲染一次無用的渲染,無疑增加了效能的消耗。

對於瀏覽器來說,在資料變化下,無論是引起的重繪渲染還是重排渲染,都有可能會在效能消耗之下造成低效的頁面效能,甚至造成載入卡頓問題。

非同步渲染和熟悉的節流函式最終目的是一致的,將多次資料變化所引起的響應變化收集後合併成一次頁面渲染,從而更合理的利用機器資源,提升效能與使用者體驗。

Vue中如何實現非同步渲染?

先總結一下原理,在Vue中非同步渲染實際在資料每次變化時,將其所要引起頁面變化的部分都放到一個非同步API的回撥函式裡,直到同步程式碼執行完之後,非同步回撥開始執行,最終將同步程式碼裡所有的需要渲染變化的部分合並起來,最終執行一次渲染操作。

拿上面例子來說,當val第一次賦值時,頁面會渲染出對應的文字,但是實際這個渲染變化會暫存,val第二次賦值時,再次暫存將要引起的變化,這些變化操作會被丟到非同步API,Promise.then的回撥函式中,等到所有同步程式碼執行完後,then函式的回撥函式得到執行,然後將遍歷儲存著資料變化的全域性陣列,將所有陣列裡資料確定先後優先順序,最終合併成一套需要展示到頁面上的資料,執行頁面渲染操作操作。

非同步佇列執行後,儲存頁面變化的全域性陣列得到遍歷執行,執行的時候會進行一些篩查操作,將重複操作過的資料進行處理,實際就是先賦值的丟棄不渲染,最終按照優先順序最終組合成一套資料渲染。

這裡觸發渲染的非同步API優先考慮Promise,其次MutationObserver,如果沒有MutationObserver的話,會考慮setImmediate,沒有setImmediate的話最後考慮是setTimeout。

接下來在原始碼層面梳理一下的Vue的非同步渲染過程。

高階前端開發者必會的34道Vue面試題解析(三)

接下來從原始碼角度一步一分析一下。

1、當我們使用this.val='343'賦值的時候,val屬性所繫結的Object.defineProperty的setter函式觸發,setter函式將所訂閱的notify函式觸發執行。

defineReactive() {
  ...
  set: function reactiveSetter (newVal) {
    ...
    dep.notify();
    ...
  }
  ...
}複製程式碼

2、notify函式中,將所有的訂閱元件watcher中的update方法執行一遍。

Dep.prototype.notify = function notify () {
  // 拷貝所有元件的watcher
  var subs = this.subs.slice();
  ...
  for (var i = 0, l = subs.length; i < l; i++) {
    subs[i].update();
  }
};複製程式碼
高階前端開發者必會的34道Vue面試題解析(三)

3、update函式得到執行後,預設情況下lazy是false,sync也是false,直接進入把所有響應變化儲存進全域性陣列queueWatcher函式下。

Watcher.prototype.update = function update () {
  if (this.lazy) {
    this.dirty = true;
  } else if (this.sync) {
    this.run();
  } else {
    queueWatcher(this);
  }
};複製程式碼
高階前端開發者必會的34道Vue面試題解析(三)

4、queueWatcher函式裡,會先將元件的watcher存進全域性陣列變數queue裡。預設情況下config.async是true,直接進入nextTick的函式執行,nextTick是一個瀏覽器非同步API實現的方法,它的回撥函式是flushSchedulerQueue函式。

function queueWatcher (watcher) {
  ...
  // 在全域性佇列裡儲存將要響應的變化update函式
  queue.push(watcher);
  ...
  // 當async配置是false的時候,頁面更新是同步的
  if (!config.async) {
    flushSchedulerQueue();
    return
  }
  // 將頁面更新函式放進非同步API裡執行,同步程式碼執行完開始執行更新頁面函式
  nextTick(flushSchedulerQueue);
}複製程式碼
高階前端開發者必會的34道Vue面試題解析(三)

5、nextTick函式的執行後,傳入的flushSchedulerQueue函式又一次push進callbacks全域性陣列裡,pending在初始情況下是false,這時候將觸發timerFunc。

function nextTick (cb, ctx) {
  var _resolve;
  callbacks.push(function () {
    if (cb) {
      try {
        cb.call(ctx);
      } catch (e) {
        handleError(e, ctx, 'nextTick');
      }
    } else if (_resolve) {
      _resolve(ctx);
    }
  });
  if (!pending) {
    pending = true;
    timerFunc();
  }
  // $flow-disable-line
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(function (resolve) {
      _resolve = resolve;
    })
  }
}複製程式碼

6、timerFunc函式是由瀏覽器的Promise、MutationObserver、setImmediate、setTimeout這些非同步API實現的,非同步API的回撥函式是flushCallbacks函式。

var timerFunc;
// 這裡Vue內部對於非同步API的選用,由Promise、MutationObserver、setImmediate、setTimeout裡取一個// 取用的規則是 Promise存在取由Promise,不存在取MutationObserver,MutationObserver不存在setImmediate,// setImmediate不存在setTimeout。
if (typeof Promise !== 'undefined' && isNative(Promise)) {
  var p = Promise.resolve();
  timerFunc = function () {
    p.then(flushCallbacks);
    if (isIOS) {
      setTimeout(noop);
    }
  };
  isUsingMicroTask = true;
} else if (!isIE && typeof MutationObserver !== 'undefined' && (
   isNative(MutationObserver) ||  
    // PhantomJS and iOS 7.x  
   MutationObserver.toString() === '[object MutationObserverConstructor]')) {
   var counter = 1;
   var observer = new MutationObserver(flushCallbacks);
   var textNode = document.createTextNode(String(counter));
  observer.observe(textNode, {
    characterData: true
  });
  timerFunc = function () {
    counter = (counter + 1) % 2;
    textNode.data = String(counter);
  };
  isUsingMicroTask = true;
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  timerFunc = function () {
    setImmediate(flushCallbacks);
  };} else {
  timerFunc = function () {
    setTimeout(flushCallbacks, 0);
  };
}複製程式碼

7、flushCallbacks函式中將遍歷執行nextTick裡push的callback全域性陣列,全域性callback陣列中實際是第5步的push的flushSchedulerQueue的執行函式。

// 將nextTick裡push進去的flushSchedulerQueue函式進行for迴圈依次呼叫
function flushCallbacks () {
  pending = false;
  var copies = callbacks.slice(0);
  callbacks.length = 0;
  for (var i = 0; i < copies.length; i++) {
    copies[i]();
  }
}複製程式碼

8、callback遍歷執行的flushSchedulerQueue函式中,flushSchedulerQueue裡先按照id進行了優先順序排序,接下來將第4步中的儲存watcher物件全域性queue遍歷執行,觸發渲染函式watcher.run。

function flushSchedulerQueue () {
var watcher, id;
// 安裝id從小到大開始排序,越小的越前觸發的update
queue.sort(function (a, b) { return a.id - b.id; });
// queue是全域性陣列,它在queueWatcher函式裡,每次update觸發的時候將當時的watcher,push進去
  for (index = 0; index < queue.length; index++) {
    ...
    watcher.run(); // 渲染
    ...
  }
}複製程式碼

9、watcher.run的實現在建構函式Watcher原型鏈上,初始狀態下active屬性為true,直接執行Watcher原型鏈的set方法。

Watcher.prototype.run = function run () {
  if (this.active) {
    var value = this.get();
    ...
  }
};複製程式碼

10、get函式中,將例項watcher物件push到全域性陣列中,開始呼叫例項的getter方法,執行完畢後,將watcher物件從全域性陣列彈出,並且清除已經渲染過的依賴例項。

Watcher.prototype.get = function get () {
  pushTarget(this);
  // 將例項push到全域性陣列targetStack
  var vm = this.vm;
  value = this.getter.call(vm, vm);
  ...
}複製程式碼

11、例項的getter方法實際是在例項化的時候傳入的函式,也就是下面vm的真正更新函式_update。

function () {
  vm._update(vm._render(), hydrating);
};複製程式碼

12、例項的_update函式執行後,將會把兩次的虛擬節點傳入傳入vm的patch方法執行渲染操作。

Vue.prototype._update = function (vnode, hydrating) {
  var vm = this;
  ...
  var prevVnode = vm._vnode;
  vm._vnode = vnode;
  if (!prevVnode) {
    // initial render
    vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */);
  } else {
    // updates
    vm.$el = vm.__patch__(prevVnode, vnode);
  }
  ...
};複製程式碼

nextTick的實現原理

首先nextTick並不是瀏覽器本身提供的一個非同步API,而是Vue中,用過由瀏覽器本身提供的原生非同步API封裝而成的一個非同步封裝方法,上面第5第6段是它的實現原始碼。

它對於瀏覽器非同步API的選用規則如下,Promise存在取由Promise.then,不存在Promise則取MutationObserver,MutationObserver不存在setImmediate,setImmediate不存在最後取setTimeout來實現。

從上面的取用規則也可以看出來,nextTick即有可能是微任務,也有可能是巨集任務,從優先去Promise和MutationObserver可以看出nextTick優先微任務,其次是setImmediate和setTimeout巨集任務。

對於微任務與巨集任務的區別這裡不深入,只要記得同步程式碼執行完畢之後,優先執行微任務,其次才會執行巨集任務。

Vue能不能同步渲染?

1、 Vue.config.async = false

當然是可以的,在第四段原始碼裡,我們能看到如下一段,當config裡的async的值為為false的情況下,並沒有將flushSchedulerQueue加到nextTick裡,而是直接執行了flushSchedulerQueue,就相當於把本次data裡的值變化時,頁面做了同步渲染。

function queueWatcher (watcher) {
  ...
  // 在全域性佇列裡儲存將要響應的變化update函式
  queue.push(watcher);
  ...
  // 當async配置是false的時候,頁面更新是同步的
  if (!config.async) {
    flushSchedulerQueue();
    return
  }
  // 將頁面更新函式放進非同步API裡執行,同步程式碼執行完開始執行更新頁面函式
  nextTick(flushSchedulerQueue);
}複製程式碼

在我們的開發程式碼裡,只需要加入下一句即可讓你的頁面渲染同步進行。

import Vue from 'Vue'
Vue.config.async = false複製程式碼

2、this._watcher.sync = true

在Watch的update方法執行原始碼裡,可以看到當this.sync為true時,這時候的渲染也是同步的。

Watcher.prototype.update = function update () {
  if (this.lazy) {
    this.dirty = true;
  } else if (this.sync) {
    this.run();
  } else {
    queueWatcher(this);
  }
};複製程式碼

在開發程式碼中,需要將本次watcher的sync屬性修改為true,對於watcher的sync屬性變化只需要在需要同步渲染的資料變化操作前執行this._watcher.sync=true,這時候則會同步執行頁面渲染動作。

像下面的寫法中,頁面會渲染出val為1,而不會渲染出2,最終渲染的結果是3,但是官網未推薦該用法,請慎用。

new Vue({
  el: '#app',
  sync: true,
  template: '<div>{{val}}</div>',
  data () {
    return { val: 0 }
  },
  mounted () {
    this._watcher.sync = true
    this.val = 1
    debugger
    this._watcher.sync = false
    this.val = 2
    this.val = 3
  }
})複製程式碼

總結

本文中介紹了Vue中為什麼採用非同步渲染頁面的原因,並且從原始碼的角度深入剖析了整個渲染前的操作鏈路,同時剖析出Vue中的非同步方法nextTick的實現與原生的非同步API直接的聯絡。最後也從原始碼角度下了解到,Vue並非不能同步渲染,當我們的頁面中需要同步渲染時,做適當的配置即可滿足。

References

[1] https://github.com/vuejs/vue

[2] https://cn.vuejs.org/

後記

如果你喜歡探討技術,或者對本文有任何的意見或建議,你可以掃描下方二維碼,關注微信公眾號“ 全棧者 ”,也歡迎加作者微信,與作者隨時互動。歡迎!衷心希望可以遇見你。

高階前端開發者必會的34道Vue面試題解析(三)

歡迎小夥伴加群,反饋或者提問。

高階前端開發者必會的34道Vue面試題解析(三)



相關文章