一個簡單案例的Vue2.0原始碼

風吹草發表於2023-11-18

本文學習vue2.0原始碼,主要從new Vue()時發生了什麼和頁面的響應式更新2個維度瞭解Vue.js的原理。以一個簡單的vue程式碼為例,介紹了這個程式碼編譯執行的流程,在流程中原始DOM的資訊會被解析轉換,存在不同的物件中。其中關鍵的物件有el、template、ast、code、render、render function和vnode等。本文對vue原始碼每一個關鍵細節的位置都進行了記錄。

vue原始碼的理解需要一些js基礎,先介紹js的相關基礎。

1.基礎知識

1.1 Chrome瀏覽器架構

1.1.1 Chrome架構簡介

Javascript是一種無型別的語言,所以很靈活,但編譯執行比較耗時。現在主流的Javascript引擎有V8、JavaScriptCore等。Javascript引擎只是瀏覽器中的一個小部分。比如,chromium瀏覽器的架構如圖1[1],它包含渲染引擎Blink(Webkit)和V8引擎,Blink(Webkit)引擎與javaScript引擎相互提供了介面(如圖2[2]),由它們協作完成HTML的渲染。Chomium瀏覽器中和Blink並列的模組還有GPU/CommandBuffer(硬體加速架構)、沙箱模型、CC(Chromium Compositor)、IPC、UI等。在這些模組之上是”Content模組“和”Content API“,它們將下面的渲染機制、安全機制和外掛機制等隱藏起來,提供一個介面層。該介面會被上層模組”Chromium瀏覽器“、”Content Shell“等使用;它可以被其它專案比如CEF(Chromium Embedded Framework)、Opera瀏覽器等使用。

”Chromium瀏覽器“、”Content Shell“是構建在”Content API“之上的兩個”瀏覽器“,Chromium具有瀏覽器完整的功能,也就是我們編譯出來能看到的瀏覽器式樣。”Content Shell“是使用Content API來包裝的一層簡單的”殼“,但是它也是一個簡單的”瀏覽器“,使用者可以使用Content模組來渲染和顯示網頁內容。Content Shell的作用很明顯,其一可以用來測試Content模組很多功能的正確性,例如渲染、硬體加速等;其二是一個參考,可以被很多外部的專案參考來開發基於”Content API“的瀏覽器或各種型別的專案。上面還有一個部分是”Androdi WebView“,他是為了滿足Android系統上的WebView而設計的,其思想是利用Chromium的實現來替換原來Android系統預設的WebView。

圖1 chromium模組結構圖

圖2 渲染引擎和JavaScript引擎的關係

1.1.2 Chrome中HTML渲染流程

HTML渲染指將 HTML,CSS 和 JavaScript 轉換為螢幕上的畫素的過程,它由渲染引擎和JavaScript引擎協作完成。HTML渲染流程如圖3[3]所示,可以將這個過程分為5個步驟[4]。HTML直譯器、CSS直譯器和佈局都是渲染引擎中的模組。

  • HTML直譯器處理 HTML 標記並構造 DOM 樹;
  • CSS直譯器處理 CSS 並構建 CSSOM 樹;同時JavaScript引擎編譯執行JavaScript指令碼;
  • 將 DOM 和 CSSOM 組合成一個 Render 樹。在DOM建立的時候,渲染引擎接受來自CSS直譯器的樣式資訊,構建一個新的內部繪圖模型,建立RenderObject樹;
  • 在Render樹上執行佈局(渲染引擎中的模組)以計算每個節點的幾何體。
  • 將各個節點繪製到螢幕上。

圖3 渲染引擎的一般渲染過程及各階段依賴的其他模組

1.1.3 V8中JavaScript的編譯流程

Netscape於1995年開發了Javascript,主要目的是處理一些輸入框校驗,這些檢驗在之前都是由後端語言(比如Perl)來實現的。在客戶端處理基本的校驗是非常令人興奮的;當時電話調變解調器盛行,訪問伺服器的速度很慢,訪問時需要很大的耐心。從那以後,JavaScript逐漸成長為市場上每個瀏覽器的重要特性。JavaScript不再僅僅實現簡單的資料校驗,如今與瀏覽器視窗和內容的方方面面都有互動。JavaScript被認為是全能的語言,可是實現複雜的運算和互動,包含閉包、lambda表示式,甚至是超程式設計(metaprogramming)等特性。[5]JavaScript設計的時候,並不適用來開發大工程和效能要求非常的場景。但隨著javascript使用越來越廣泛,功能越來越豐富,對javascript的效能的要求也越來越高。

Javascript是一種無型別或動態型別的語言,在編譯的時候不知道變數的型別。相比較而言,C++或Java等語言都是靜態型別語言,它們在編譯的時候知道變數的型別。早期javascript的編譯流程如圖4所示;先將原始碼編譯成抽象語法樹,然後抽象語法樹上解釋執行。早期的javascipt編譯器執行如Demo1的程式碼時,獲取物件obj中的屬性b,是透過在obj物件中搜尋變數識別符號”b“實現的。而在Demo2和Demo3中,java執行位元組碼(或原生程式碼)時,獲取包含同樣資料的物件obj的屬性b,只需要將物件obj所在的地址向右偏移4個位元組(int的大小)即可。對物件屬性的訪問時非常頻繁的,相比於java中透過偏移量來訪問值,使用2個彙編指令就能完成;在javascript中透過屬性名匹配訪問資料的值,效能會差很多。[6]

image-1389306-20231118102818984-1885396207

圖4 早期Javascript的編譯流程

var obj ={a:1,b:2}
console.log(obj.b) //2

Demo1 JavaScript獲取物件obj的屬性b的值

public class Obj {
    public int a;
    public int b;

    public Obj(int a, int b) {
        this.a = a;
        this.b = b;
    }
}

Demo2 Java中定義類Obj

public class test {
    public static void main(String[] args) {
        Obj obj = new Obj(1, 2);
        System.out.println(obj.b); //2
    }
}

Demo3 Java中訪問物件obj的屬性b

為了提高javascript的編譯效能,眾多工程師借鑑了java和C++編譯器的思想,嘗試對javascript進行改進。隨著java虛擬機器的JIT技術引入,現在的做法是將抽象語法樹轉成中間表示(位元組碼),然後透過JIT技術轉成原生程式碼(彙編程式碼),這能夠大大提高執行效率,如圖5。當然也有些直接從抽象語法樹生成原生程式碼的JIT技術,例如V8。V8中使用了特殊的方式來表示資料型別。[106]

image-1389306-20231118102840063-754011083

圖5 JavaScript編譯流程改進

原始碼編譯時,先生成抽象語法樹(ast)是編譯器的通常做法,java編譯器、C++編譯器中都包含這個步驟。在3.1節中,vue框架在將template解析生成Vnode時,也先將template解析抽象語法樹。Demo4所示的javascript程式碼編譯生成的ast樹如圖6所示[7]

if(typeof a == "undefined" ) { 
    a = 0;
} else { 
    a = a;
}
 
alert(a);

Demo4 一段簡單的js程式碼

圖6 根據js程式碼生成的抽象語法樹(ast)

1.2 不同瀏覽器的差異

1.2.1 渲染引擎和JavaScript引擎

不同的瀏覽器使用的渲染引擎和JavaScript引擎有所不同。目前主流的瀏覽器有Chomium、FireFox、Edge、Safri等,它們使用的渲染引擎和Javascript引擎如圖7[8][9]。Chromium剛開始使用Webkit引擎,後來從Webkit中建立了Blink分支,這主要有2方面原因:1)Chromium使用了不同於其它基於Webkit的瀏覽器的多程式架構,支援多程式架構增加了Webkit和Chromium社群的複雜性,阻礙了集體前進的步伐;2)這使得有機會進行其它效能提升方案的開放式調查,Chromium的使用者和開發者希望Chromium儘可能地快。比如,他們希望可以有儘可能多的瀏覽器任務併發執行,以使主執行緒有空閒執行應用程式碼。他們已經取得了巨大的進展,比如減少了JavaScript和layout對頁面滾動的影響,並使得越來越多的CSS動畫以60fps的速度執行,即使JavaScript正在做其它繁重的工作[10]。JavaScirpt引擎V8相比於JavaScriptCore,效率大大提升,後來Node.js也是基於V8引擎的。

瀏覽器 渲染引擎 Javascript引擎
Chromium 早期:Webkit,後來:Blink V8
Safari Webkit JavascriptCore
Edge 早期:EdgeHTML,後來:Blink Chakra
IE Trident Chakra
FireFox Gecko Spider Monkey

圖7 不同瀏覽器的渲染引擎和JavaScript引擎

1.2.2 JavaScript規範的統一

Netscape公司於1994年首次釋出Netscape Navigator,並於1995年開發了JavaScript。1995年,微軟首次釋出了IE(Internet Explorer),這導致了和NetScape的瀏覽器大戰。微軟對Navigator的直譯器進行逆向工程(Reverse engineering),建立了自己的指令碼語言JScipt。2000年,IE的市場份額達到了95%。2004年,Netscape的繼承者Mozilla釋出了FireFox瀏覽器。在2005年,Mozilla加入了ECMA國際組織。2008年,Google首次釋出了Chrome瀏覽器,使用了JavaScript引擎V8,比其他JavaScript引擎都要快。2008年,這些不同的瀏覽器組織在Oslo的會議上相聚並達成最終的協議[11],同年五大主要瀏覽器(IE、FireFox、Safari、Chrome和Opera)全部開始遵守ECMAScript3規範[12]

1.3 JavaScript的一些特性

JavaScript的語法從C和其它類似C的語言(比如Java和Perl)中借鑑了很多。熟悉這些語言的開發者可以輕鬆掌握JavaScript的寬鬆語法[11]。筆者也沒有專門學過js這門語言,因為它看起來和java很像。雖然JavaScript與java在名字、語法和標準庫上都很相似,但其實兩者是不同的語言,在設計上也有巨大的差異[14]。在閱讀Vue2.0原始碼時,發現JavaScript有一些特性是java中沒有的,比如原型、閉包等,在這先簡單介紹一下這些特性。

1.3.1 原型(prototype)

1.3.1.1 原型

prototype是JavaScript語言的一個重要特性。Demo5是一個使用prototype的簡單案例,定義函式A並設定了函式原型的屬性值name後,函式A的原型例項a1和a2會共享這個屬性值。當一個函式(構造器)建立的時候,它的prototype屬性也會被建立。預設所有的prototype都會有一個constructor屬性,它指向prototype所屬的函式。當函式(構造器)建立新例項時,例項中會有一個內在指標指向函式(構造器)原型。在ECMA-262中,這個指標被稱為[[prototype]]。在script中沒有標準方式獲取[[prototype]]的值,但是Firefox、Safari、Chrome和Edge等在每個物件上都加上了__proto__屬性,透過__proto__獲取[[prototype]]的值。[15]圖8中展示了函式、函式原型和原型例項的關係。依據圖8的關係,Demo6中的結果是顯然的。

function A(){}
A.prototype.name = 'jack'
a1 = new A()
a2 = new A()
console.log(a1.name) //jack
console.log(a2.name) //jack

Demo5一個使用prototype的簡單案例

圖8 函式、函式原型和原型例項的關係

console.log(A.prototype.constructor === A);//true 
console.log(a1.__proto__ === A.prototype);//true    

Demo6 函式、函式原型和原型例項的關係

也可以在Chrome瀏覽器的控制檯檢視物件a1的屬性,在控制檯中a1的屬性如圖9。圖中第一個[[Prototype]]表示物件a1指向的原型A Prototype,它的constructor為f A()。第二個[[Prototype]]指向原型A Prototype的原型Object Prototype,該原型的constructor為f Object()。Object Prototype不是其它原型的例項,所以它下面沒有[[Prototype]]。多個[[Prototype]]構成原型鏈[47],原型鏈中的原型的關係如圖10所示。根據圖10中關係,Demo7的結果是顯而易見的。原型例項會共享原型鏈中的所有屬性。

圖9 Chrome控制檯中物件a1的屬性

圖10 函式A的一個簡單的原型鏈

console.log(a1.__proto__ === A.prototype) //true
console.log(A.prototype.__proto__ === Object.prototype) //true
console.log(Object.prototype.__proto__ === null) //true

Demo7 原型鏈中各物件屬性之間的關係

在javascirpt中的原型概念可以與java做類比。如圖11,js中原型例項看為java中的物件;js中函式(構造器)看為是java類中的構造方法;js的函式原型看成java類中的靜態成員變數和方法;js的原型鏈看為java類的繼承。java類中的靜態成員變數和方法在第一次使用該類時載入到方法區,函式原型在函式(構造器)建立的同時被建立。java中使用建構函式建立物件時,物件中有內在指標指向方法區的類;函式(構造器)建立新例項時,例項中有一個內在指標指向函式(構造器)原型。js中如果函式原型是其它原型的例項,該函式原型會共享其它原型中的屬性,構成原型鏈;java中子類會繼承父類的屬性和方法。

js java
原型例項 物件
函式(構造器) 類中的構造方法
函式原型
1)當一個函式(構造器)建立的時候,它的函式原型也會被建立
2)當函式(構造器)建立新例項時,例項中會有一個內在指標指向函式(構造器)原型
類中的靜態成員變數和方法
1)類中的靜態成員變數和方法在第一次使用該類時載入到方法區
2)使用建構函式建立物件時,物件中有內在指標指向方法區的類
原型鏈 類的繼承

圖11 將js中的原型與java的語法做類比

1.3.1.2 函式和函式原型繼承

在上一節中,其實基於prototype構成的原型鏈實現了函式原型的繼承,它非常類似於java中類的繼承。除了基於原型鏈的繼承,還有很多其它方式能實現繼承[16]。下面以Demo8所示的父函式Animal和子函式Cat為例,介紹實現函式和函式原型繼承的幾種方式。

function Animal(){
    this.species = "動物";
}
Animal.prototype.food = "肉類"

function Cat(name,color){
    this.name = name;
	this.color = color;
}

Demo8 父函式Animal和子函式Cat

1)基於prototype的繼承

Demo9展示了透過prototype實現建構函式和函式原型繼承,案例中子函式的原型和父函式的原型構成原型鏈。

function extend(child,parent){
    child.prototype = new parent();
    child.prototype.constructor = child;
    return child;
}

extend(Cat,Animal);
var cat1 = new Cat("大毛","黃色");
alert(cat1.species); // 動物
alert(cat1.food); //肉類

Demo9 透過原型鏈實現建構函式和函式原型繼承

2)建構函式繫結

也可以使用建構函式繫結實現建構函式的繼承。如Demo10,使用call或apply方法,將父物件的建構函式繫結在子物件上,即可實現建構函式的繼承。

function Cat(name,color){
	Animal.apply(this, arguments);
	this.name = name;
	this.color = color;
}

var cat1 = new Cat("大毛","黃色");
alert(cat1.species); // 動物

Demo10 透過建構函式繫結實現建構函式繼承

3)複製繼承

如Demo11,將父函式Parent的原型中的屬性,逐一複製給子函式Child的原型中的屬性,可以實現函式原型的繼承。

function extend(Child, Parent) {
    var p = Parent.prototype;
    var c = Child.prototype;
    for (var i in p) {
      c[i] = p[i];
    }
}

extend(Cat,Animal);
var cat1 = new Cat("大毛","黃色");
alert(cat1.food); //肉類

Demo11 透過函式原型複製實現函式原型繼承

1.3.1.3 疑問

在上面1.3.1.1節中在Chrome控制檯中檢視了原型例項的屬性,對__proto__欄位還有疑問。如圖12,第二個[[Prototype]]表示的是Object Prototype,它的__proto__欄位應為null;圖中的__proto__表示的顯然不是Object Prototype的原型,它表示的是物件a1的原型。既然__proto__表示的是物件a1的原型,那應該和第一個[[Prototype]]處於同一層級,為何放在第二個[[Prototype]]的下一層級呢?

圖12 原型例項a1(1.3.1.1節)的屬性

1.3.2 閉包(closure)

1.3.2.1 執行上下文作用域

執行上下文(execution context)是JavaScript中非常重要的概念,也可簡稱為上下文。變數或函式的執行上下文定義了可以訪問哪些資料。每個執行上下文都關聯一個variable object,它包含上下文中的變數和函式[17]

全域性上下文位於最外層。在web瀏覽器中,全域性上下文指window物件。當執行上下文執行完後,執行上下文連帶包含在其中的函式和變數一起銷燬。window物件在關閉應用的時候會銷燬,比如關閉web頁面或關閉瀏覽器。每個函式呼叫都有自己的執行上下文。當函式執行完畢後,函式的執行上下文會銷燬[17]

當函式定義時,它的作用域鏈被建立,並預載入 global variable object,並將作用域鏈儲存到[[scope]]中。當函式執行時,函式的執行上下文被建立,並基於函式的[[scope]]建立上下文的作用域鏈。之後,函式的activation object被建立並新增到上下文的作用域鏈中。如果函式定義在其它函式中,在函式定義時,函式的作用域鏈也會預載入其它函式的activation object。函式執行上下文中的作用域鏈包含global variable object,本函式的activation object,還可能包含其它函式的activation object。[118]以Demo12所示的簡單案例進行說明。當程式碼執行到Afunc中swapFunc函式時,如圖13的執行上下文swapFunc execution context和作用域鏈Scope Chain被建立。Scope Chain中包含Global variable object、AFunc activation object和swapFunc activation object。最靠前的是當前程式碼執行的上下文中的swapFunc activation object。然後包含當前執行上下文的上一層執行上下文中的Afunc activation object。然後是上上一層執行上下文的activation object,直到global context中的Global variable object。函式和變數的識別符號取值是透過在scope chain中搜尋識別符號名稱,搜尋是從scope chain的最前面開始的[17]

var a = 1;
function AFunc() {
    let b = 2;
    function swapFunc() {
        let temp = b;
        b = a;
        a = temp;
        // a,b,temp都可以獲取到
	}
	// a,b可以獲取到
	swapFunc();
}
// 只能獲取到變數a
AFunc();

Demo12 函式swapFunc可以訪問在swapFunc之外宣告的變數a和變數b

圖13 函式swapFunc執行上下文中的作用域鏈

1.3.2.2閉包

將上一節Demo12的程式碼稍作調整,在AFunc函式中最後一行程式碼前加個return,如Demo13。執行AFunc()會返回swapFunc函式,用變數BFunc接收。當swapFunc函式從AFunc()返回的時候,swapFunc函式的作用域鏈被初始化為包含函式AFunc的variable object和Global variable object,如圖13所示。執行函式BFunc()時,它可以訪問AFunc activation object中的變數b和Global variable object中的變數a。也就是說,當函式AFunc執行完後,AFunc的activation object不能被銷燬,因為函式swapFunc的作用域鏈對其有引用。當函式AFunc執行完後,他的作用域鏈被銷燬;但是activation object仍然在記憶體中,直到函式swapFunc銷燬時才會被銷燬,swapFunc的銷燬可以透過將swapFunc的值設定null,等待垃圾回收任務回收它。[18]當一個函式可以訪問當前函式的activation object之外的變數時,這個函式被成為閉包(closure)[19]。Demo13中的函式swapFunc和AFunc都是閉包,swapFunc可以訪問AFunc variable object和Global variable object中的變數;AFunc可以訪問Global activation object中的變數。由於閉包中包含其它函式的activation object,可能會比非閉包的函式佔用更多的記憶體,比如Demo13中的BFunc函式。所以在實際使用時,應儘量在必要的時候才使用閉包。

var a = 1;
function AFunc() {
    let b = 2;
    function swapFunc() {
        let temp = b;
        b = a;
        a = temp;
        // a,b,temp都可以獲取到,swapFunc是一個閉包
	}
	// a,b可以獲取到,AFunc是一個閉包
	return swapFunc();
}
// 只能獲取到變數a
BFunc = AFunc();
BFunc();//BFunc執行時可以訪問函式之外變數a和b,BFunc是一個閉包函式
BFunc = null;//將BFunc設定為null,等待垃圾回收任務回收

Demo13 函式BFunc是一個閉包

1.3.2.2 一些閉包的案例[20]

1)案例1:斐波那契數列

如Demo14,在函式makeFab中定義了函式inner,inner訪問了makeFab activation object中的變數last和current。執行makeFab()會返回inner函式,用變數fab接收。當inner函式從makeFab()返回的時候,inner函式的作用域鏈(scope chain)被初始化為包含函式makeFab 的activation object。由於函式inner的scope chain對函式makeFab的activation object有引用,在makeFab執行完後,makeFab的activation object不會被銷燬。每次執行fab函式後,變數last和current不會被銷燬,下一次執行fab函式時,會在上次的執行結果的基礎上進行計算。

function makeFab () { 
    let last = 1, current = 1 
    return function inner() {  
        [current, last] = [current + last, current] 
        return last 
    }
}

let fab = makeFab()
console.log(fab()) // 1
console.log(fab()) // 2
console.log(fab()) // 3
console.log(fab()) // 5

Demo14 斐波那契數列

2)案例2:防抖節流函式

在web頁面的上下文中執行如Demo2的案例,如果你在輸入框輸入一個字母或數字,0.5s後控制檯將輸出這個字元。如果你在輸入框連續輸入,且輸入的間隔小於0.5s,那麼在停止輸入的0.5s後,控制檯輸出輸入框中的資訊。這可以實現輸入框的字元聯想或自動搜尋功能,並避免過於頻繁的後端請求。

Demo15中使用了clearTimeout方法。clearTimeout是一個全域性方法,它的引數是setTimeout()返回的timeoutId,呼叫clearTimeout會取消setTimeout建立的timeout任務[21]

<body>
    <input type="text">
</body>
<script>
    function debounce (func, time) {
        let timer = 0
        return function (...args) {
            timer && clearTimeout(timer)
            timer = setTimeout(() => {
                    timer = 0
                    func.apply(this, args) },
                time)
        }
    }
    input = document.getElementsByTagName("input")[0];
    input.onkeypress = debounce(
        function () {
            console.log(input.value) //事件處理邏輯
        },
        500)
</script>

Demo15 防抖節流函式

3)案例3:優雅解決按鈕多次連續點選

當使用者點選按鈕向後端傳送請求時,使用者可能會多次連續點選。如果每次點選都觸發一次請求,可能會出現上一次請求還未返回,又觸發下一次請求的情況。多次請求一方面會消耗服務端資源;另一方面可能會導致資料意外錯誤,比如重複建立表單記錄。Demo16中透過使用lock標記欄位解決了這個問題,每次傳送請求前將lock置為true,請求返回將lock置為false,如果點選按鈕時上一次請求尚未返回,此時lock為true,函式直接返回,不會傳送新的請求。其中匿名函式function(postParams)就是一個閉包。

let clickButton = (
        function () {
            let lock = false
            return function (postParams) {
                if (lock) return lock = true // 使用axios傳送請求 
                lock = true
                axios.post('urlxxx', postParams).then(
                    // 表單提交成功 
                ).catch(error => {
                    // 表單提交出錯 
                    console.log(error)
                }).finally(() => {
                    // 不管成功失敗 都解鎖 
                    lock = false
                })
            }
        })()
button.addEventListener('click', clickButton)

Demo16 優雅解決按鈕多次連續點選

為了避免每個點選函式都使用lock標記欄位,可以使用裝飾器。如Demo17,使用裝飾器函式singleClick,當manuDone為true時,可以手動設定函式done的觸發時間。當呼叫test()函式時,會每隔1s觸發呼叫一次print函式,第一次呼叫print函式時將lock置為true,同時呼叫singleClick函式的引數func函式,函式中進行了控制檯輸出,並設定了2s後觸發done函式,done函式將lock置為false。從第一次test函式中的timeout任務執行,呼叫print函式,print函式中執行singleClick函式的引數func函式,在呼叫func函式時將lock置為true的總耗時大於2s。所以第2次test函式中的timeout任務執行(1s後),和第3次test函式中的timeout任務執行(2s後)時lock仍為false,呼叫print函式時,函式直接返回,不會進行singleClick函式的引數func函式的呼叫。第4次(3s)後lock已置為true,此時函式singleClick中func函式可以正常呼叫,並在控制檯輸出相應的數字。所以控制檯輸出數字的時間間隔為3s,輸出數字的間隔為3。

function singleClick(func, manuDone = false) {
    let lock = false
    return function (...args) {
        if (lock) return lock = true
        lock = true
        let done = () => lock = false
        if (manuDone)
            return func.call(this, ...args, done)
        let promise = func.call(this, ...args)
        promise ? promise.finally(done) : done()
        return promise
    }
}

let print = singleClick(
    function (i, done) {
        console.log('print is called', i)
        setTimeout(done, 2000)
    }, true)

function test() {
    for (let i = 0; i < 10; i++) {
        setTimeout(() => {
            print(i)
        }, i * 1000)
    }
}

test();
//print is called 0
//print is called 3
//print is called 6
//print is called 9

Demo17 裝飾器函式singleClick

使用如Demo17的裝飾器函式singleClick對Demo16進行改造,得到Demo18。只需在裝飾器函式singleClick中使用lock欄位,不用每個點選事件函式clickButton中使用lock欄位。當點選按鈕時,如果上一次請求尚未返回,不會傳送新的請求。其中singleClick函式的返回值以及done函式都是閉包。

let clickButton = singleClick(function (postParams) {
    if (!checkForm()) return
    return axios.post('urlxxx', postParams).then(
        // 表單提交成功 
    ).catch(error => {
        // 表單提交出錯 
        console.log(error)
    })
})
button.addEventListener('click', clickButton)

Demo18 使用裝飾器函式singeClick解決按鈕多次連續點選

4)案例4:使用閉包模擬“封裝”特性

“封裝”是物件導向的特性之一,所謂“封裝”,即一個物件對外隱藏了其內部的一些屬性或者方法的實現細節,外界僅能透過暴露的介面操作該物件。js是比較“自由”的語言,所以並沒有類似Java語言那樣提供私有變數或私有方法的定義方式,不過利用閉包,卻可以很好地模擬這個特性。比如遊戲開發中,玩家物件身上通常會有一個經驗屬性,假設為exp,"打怪"、“做任務”、“使用經驗書”等都會增加exp這個值,而在升級的時候又會減掉exp的值,把exp直接暴露給各處業務來操作顯然是很糟糕的。Demo19中使用閉包將exp隱藏起來,只能透過getExp()和changeExp()函式操作。

function makePlayer() {
    let exp = 0
    // 經驗值 
    return {
        getExp() {
            return exp
        }, changeExp(delta, sReason = '') { // log(xxx),記錄變動日誌 
            exp += delta
        }
    }
}
let p = makePlayer()
console.log(p.getExp())// 0
p.changeExp(2000)
console.log(p.getExp()) // 2000

Demo19 使用閉包模擬“封裝”特性

1.3.3 其它特性

1.3.3.1 Object.defineProperty[22]

ECMA-262透過屬性的內部屬性描述了屬性的特徵。規範中的這些內部屬性用於JavaScript引擎的實現,無法直接透過JavaScript訪問到。屬性名使用兩對中括號括起來以表示其是內部屬性,比如[[Enumerable]]。屬性分為資料屬性(data properties)和訪問屬性(access properties)2種,它們具有不同的內部屬性。

1)資料屬性

資料屬性包含資料值的地址([[Value]])。可以從該地址中讀取和寫入value值。資料屬性包含4個屬性:

[[Configurable]]—表明屬效能否透過delete刪除,該屬性的內部屬效能否被修改,或該資料屬效能否被修改為訪問屬性。預設值為true。

[[Enumerable]]—表明屬效能否在 for…in 迴圈和 Object.keys() 中被列舉。預設值為true。

[[Writable]]—表明屬性的value能否被修改。預設值為true。

[[Value]]—屬性的value。這是一個屬性的value被讀取和寫入的地址。預設值為undefined。

當一個屬性被新增到物件中時,屬性的[[Configurable]]、[[Enumerable]]和[[Writable]]等內部屬性被設定為true,同時[[Value]]屬性被設定為賦予的值。比如Demo20中,person的name屬性被建立並賦值”jack“。這表示[[Value]]被設定為”jack“,對屬性值的修改也會存在[[Value]]中。你可以使用Object.defineProperty()來修改預設的屬性值。這個方法由3個引數,擬修改或新增的屬性所屬的物件,屬性名以及descriptor物件。descriptor物件有configurable、enumerable、 writable和value等4個屬性。你可以修改這些屬性值。對descriptor中的屬性值的修改會影響後續對屬性或descriptor中屬性的操作。如Demo21,當configurable設定為false,表明屬性不能透過delete刪除,該屬性的除writable之外的屬性不能被修改,或該資料屬性不能被修改為訪問屬性。

let person = {
  name: "jack"
};

Demo20 定義person物件

let person = {};
Object.defineProperty(person, "name", {
    configurable: false,//表明屬性不能透過delete刪除,該屬性的除writable之外的內部屬性不能被修改,或該資料屬性不能被修改為訪問屬性
    Enumerable: false, //表明屬性不能在 for…in 迴圈和 Object.keys() 中被列舉
	writable: false, //表明屬性的value不能被修改
	value: "jack"
});

/*驗證configurable: false的作用*/
delete person.name; //false
console.log(person.name);//"jack"

//throw an error,"Uncaught TypeError: Cannot redefine property: name"
Object.defineProperty(person, "name", {
    configurable: true,//由fase為true
    Enumerable: false,
	writable: false, 
	value: "jack"
});

//throw an error,"Uncaught TypeError: Cannot redefine property: name"
Object.defineProperty(person, "name", {
    configurable: true,
    Enumerable: false,
	get() { //由資料屬性修改為訪問屬性
    	return this.name;
	}
});

/*驗證Enumerable: false的作用*/
console.log(Object.keys(person));//[]

/*驗證writable: false的作用*/
console.log(person.name); // "jack"
person.name = "rose";
console.log(person.name); // "jack"

Demo21 透過defineProperty修改屬性的資料屬性

2)訪問屬性

訪問屬性不包含資料值的地址[[value]]。它包含getter和setter函式。當一個訪問屬性被讀取,getter函式被呼叫;當被寫入,setter函式被呼叫,setter函式的入參是新寫入的value值。訪問屬性包含4個屬性:

[[Configurable]]—表明屬效能否透過delete刪除,該屬性的屬效能否被修改,或該訪問屬效能否被修改為訪資料屬性。預設值為true。

[[Enumerable]]—表明屬效能否在 for…in 迴圈和 Object.keys() 中被列舉。預設值為true。

[[Get]]—屬性被讀取時呼叫該函式。預設值為undefined。

[[Set]]—屬性被寫入時呼叫該函式。預設值為undefined。

你可以使用Object.defineProperty()來修改預設的屬性值。如Demo22,當我們寫入name屬性時,函式set被呼叫。

let person = {_name:"jack",cnt:0};
Object.defineProperty(person, "name", {
  configurable:true,
  enumerable:true,
  get() {
    return this._name;
  },
  set(newValue) {
    if(newValue){
        this._name = newValue
        this.cnt ++;
    }
  }
});
//屬性寫入時呼叫set()函式
person.name = "rose";   
//屬性寫入時呼叫get()函式
console.log(person._name); // rose
console.log(person.cnt);//1

Demo22 透過defineProperty修改屬性的訪問屬性

3)定義多屬性

如果你想定義一個物件中的多個屬性,ECMAScript提供了Object.defineProperties()方法。如Demo23所示,透過Object.defineProperties()定義個物件person中的_name、cnt和name等多個屬性。

Object.defineProperties(person, {
  _name:{
    value:"jack"
  },
  cnt:{
    value:0
  },
  name:{
    configurable:true,
    enumerable:true,
    get() {
      return this._name;
    },
    set(newValue) {
      if(newValue){
        this._name = "rose"
        this.cnt ++;
      }
    }
  }
});

Demo23 透過Object.defineProperties定義物件的多個屬性

4)讀取descriptor的屬性值

透過difineProperty定義的屬性,屬性的descriptor可以透過Object.getOwnPropertyDescriptor獲取。也可以透過Object.getOwnPropertyDescriptors一次性獲取所有屬性的descriptor。如Demo24,基於Demo23中的定義的物件person,先使用Object.getOwnPropertyDescriptor()獲取了屬性name的descriptor;然後使用Object.getOwnPropertyDescriptors獲取了person物件的所有屬性的descriptor。

let descriptor = Object.getOwnPropertyDescriptor(person,"name");
console.log(descriptor.configurable);//true
console.log(descriptor.enumerable);//true
console.log(typeof descriptor.get);//"function"
console.log(typeof descriptor.set);//"function"

console.log(Object.getOwnPropertyDescriptors(person));
//{
//  cnt: {
//    configurable: true
//    enumerable: true
//    value: 0
//    writable: true
//  },
//  name: {
//    configurable: true
//    enumerable: true
//    get: ƒ get()
//    set: ƒ set(newValue)
//  },
//  _name: { 
//    configurable: true
//    enumerable: true
//    value: "jack"
//     writable: true   
//  }
//}

Demo24 透過Object的getOwnPropertyDescriptor和getOwnPropertyDescriptors方法獲取屬性的descriptor

1.3.3.2 Object.create(o)[23]

Object.create(o)返回一個以o為原型的物件。

function a(){}
var b = Object.create(a.prototype);
console.log( b.__proto__ === a.prototype ); //true

Demo25 透過Object.create()建立物件

1.3.3.3 字串方法[24]

Demo26列舉了字串的部分方法。

//slice(start,end):擷取字串起始索引與結束索引之間的部分,作為新字串返回
var str = "Apple, Banana, Mango";
var res = str.slice(7,13);
console.log(res); //Banana

Demo26 字串的部分方法

1.3.3.4 陣列方法[25]

Demo27 列舉了陣列的部分方法。

//拼接陣列:splice() 方法可用於向陣列新增新項:
var fruits = ["Banana", "Orange", "Apple", "Mango"];
fruits.splice(2, 0, "Lemon", "Kiwi");
console.log(fruits); //Banana,Orange,Lemon,Kiwi,Apple,Mango

//位移元素 unshift() 方法(在開頭)向陣列新增新元素,並“反向位移”舊元素:
var fruits = ["Banana", "Orange", "Apple", "Mango"];
fruits.unshift("Lemon");    // 向 fruits 新增新元素 "Lemon"
console.log(fruits); //Lemon,Banana,Orange,Apple,Mango

Demo27 陣列的部分方法

1.3.3.5 with(this)

動態建立的函式中,可以使用with(ObjName),比如with(this)或with(document)(document是DOM中的物件)。with(ObjName)後程式碼塊的作用域鏈得到增強,物件ObjName(比如this或document)中的函式或變數可以像本地變數一樣被訪問[26]。如Demo28中,vm是一個vue例項,使用with(vm)後的程式碼塊的作用域得到增強,可以在程式碼塊中直接訪問vm例項中的函式_c。

在vue原始碼中render函式中使用了with(this),this指vue例項代理;Demo29是一個簡化的示例。如Demo29,在vue示例代理的handler中定義了方法has(),handler中的方法has()像一個攔截器一樣;在呼叫代理的方法時,會優先呼叫handler中定義的,如果handler中未定義再呼叫例項中的。Proxy的使用詳見1.3.4.1節。在with(vueProxy)程式碼塊中呼叫方法時,js引擎內部會先呼叫has(vueProxy,方法名)。比如呼叫方法_d時,js引擎內部會先呼叫方法has(vueProxy,"_d"),has函式在handler中定義了,直接呼叫handler中的has函式,控制檯會輸出該方法在例項中未定義,並丟擲錯誤。

function Vue(){
}
vm = new Vue();
vm._c = function createElement(){}

with(vm){
  _c();
}

Demo28 使用with(objName)增強程式碼塊的作用域

function Vue(){
}
vue = new Vue();
vue._c = function createElement(){}
//為vue例項建立代理vueProxy
const handler = {
  has(target, key){
	  const has = key in target
      if (!has){
          console.log("Property or method "+ key +" is not defined on the instance")
      }
      return has;
  }
}
vueProxy = new Proxy(vue,handler)

with(vueProxy){
  _c();//方法呼叫時,js引擎內部會呼叫has(vueProxy,"_c")函式
  _d();//方法呼叫時,js引擎內部會呼叫has(vueProxy,"_d")函式
}

//瀏覽器控制檯輸出:
//Property or method _d is not defined on the instance
//error: Uncaught ReferenceError: _d is not defined at <anonymous>

Demo29 vue原始碼的render函式中使用with(this)的一個簡化案例

1.3.3.6 MessageChannel

MessageChannel例項有2個埠port1和port2,代表2個通訊終端。它可以透過將port以引數的形式傳遞到worker中,使父頁面和worker透過channel進行通訊[27]

可以使用postMessage在主頁面環境和worker環境進行往返通訊。瀏覽器可以透過worker在主頁面環境之外分配一個獨立的子環境,worker和執行緒有很多相同的特徵,worker環境可以和主環境平行地執行程式碼[28]。如Demo31,檔案main.js中建立了factorialWorker.js的worker,使用postMessage與worker環境進行通訊,worker環境接受到資訊後又透過postMessage通訊回來。

如果要透過channel進行通訊,可以透過MessageChannel實現。如Demo33,檔案main.js中建立了worker.js的worker,使用postMessage將MessagePort(值為[channel.port1])傳送worker;Demo32中的woker接收到資訊中的MessagePort,併為MessagePort設定message Handler。Demo33中使用終端port2透過channel傳送資訊,Demo32中的終端port1接受到資訊後,再透過channel發出新的資訊;Demo33中的終端port2接收到資訊,並輸出到控制檯。

MessageChannel也可以在同一個js檔案中使用。如Demo34,終端port1傳送資訊,終端port2接受到資訊並輸出到控制檯。但終端port1接收到資訊是非同步執行的,非同步執行的原理與setTimeout相似,如Demo35。

function factorial(n) {
let result = 1;
while(n) { result *= n­­; }
return result;
}

self.onmessage = ({data}) => {
self.postMessage(`${data}! = ${factorial(data)}`);
};

Demo30 work環境中的factorialworker.js,使用postMessage通訊

const factorialWorker = new Worker('./factorialWorker.js');
factorialWorker.onmessage = ({data}) => console.log(data);
factorialWorker.postMessage(5);
factorialWorker.postMessage(7);
factorialWorker.postMessage(10);

// 控制檯輸出:
// 5! = 120
// 7! = 5040
// 10! = 3628800

Demo31 主環境中的main.js,使用postMessage通訊

let messagePort = null;
function factorial(n) {
  let result = 1;
  while(n) { result *= n--; }
  return result;
}
// Set message handler on global object
self.onmessage = ({ports}) => {
  if (!messagePort) {
    messagePort = ports[0];
    self.onmessage = null;
    // Set message handler on global object
    messagePort.onmessage = ({data}) => {
      // Subsequent messages send data
      messagePort.postMessage(`${data}! = ${factorial(data)}`);
    };
  }
}

Demo32 worker環境中的worker.js,基於MessageChannel通訊

const channel = new MessageChannel();
const factorialWorker = new Worker('./worker.js');
// Send the MessagePort object to the worker.
factorialWorker.postMessage(null, [channel.port1]);
channel.port2.onmessage = ({data}) => console.log(data);
channel.port2.postMessage(5);

//控制檯輸出:
// 5! = 120

Demo33 主環境中的main.js,基於MessageChannel通訊

const channel = new MessageChannel();
channel.port2.onmessage = ({data}) => console.log(data);
channel.port1.postMessage(1);

//控制檯輸出:
//1

Demo34 在同一個js檔案中基於MessageChannel通訊

setTimeout(() => {
  console.log("setTimeout_1");
}, 0);

//使用MessageChannel
const channel = new MessageChannel()
channel.port2.onmessage = ()=>{console.log("onmessage")}
channel.port1.postMessage(1)

setTimeout(() => {
  console.log("setTimeout_2");
}, 0);

console.log("After setTimeout");

//控制檯輸出:
//After setTimeout
//setTimeout_1
//onmessage
//setTimeout_2

Demo35 MessageChannel接收到資訊後是非同步執行的

1.3.3.7 call和apply

call和apply是函式的兩個額外方法,可以透過call和apply方法進行函式呼叫。call和apply方法可以接收一個特殊的引數,這個引數在函式內部可以透過this引用。apply和call方法的功能相同,只是接收的引數不同[29]。apply方法有2個引數,第一個參數列示函式內部透過this可引用的物件,第二個引數是一個陣列或arguments物件,如Demo36。你可以在函式內部使用arguments獲取函式所有的引數,也可以根據索引獲取指定的第幾個引數,比如使用argumnets[0]獲取第一個引數。arguments在引擎內部是一個陣列,但它不是一個Array例項[30]。call方法的引數個數不確定,第一個參數列示函式內部透過this可引用的物件,第二個及後面的引數是直接傳遞給函式的引數,如Demo37。

function sum(num1, num2) {
return num1 + num2;
}
function callSum1(num1, num2) {
return sum.apply(this, arguments); // passing in arguments object
}
function callSum2(num1, num2) {
return sum.apply(this, [num1, num2]); // passing in array
}
console.log(callSum1(10, 10)); // 20
console.log(callSum2(10, 10)); // 20

Demo36 apply方法有2個引數

let o = {
num: 10
};

function sum(num1, num2) {
return this.num + num1 + num2;
}

num = sum.apply(o, num1,num2) //passing  arguments directly
console.log(num); // 30

Demo37 call方法的引數個數不確定

1.3.4 es6的一些特性

1.3.4.1 Proxy[51]

proxy透過Proxy構造器建立。Proxy構造器有2個引數,target物件和handler物件。如Demo38,透過Proxy構造器建立了proxy物件,構造器的第一個引數是被代理的物件target,第二引數handler為空,所有對proxy的操作都會到達target物件。handler的主要目的是允許自定義trap,它像”基本操作攔截器“一樣。當這些基本操作被proxy呼叫時,會直接呼叫proxy中的trap函式,也就是說你可以對基本操作進行攔截和修改。如Demo38,在使用proxy[property], proxy.property, 或Object.create(proxy)[property]來訪問屬性時,會進行基本操作get(),這些基本操作會呼叫proxy中定義的trap函式get(),而不會呼叫javascript引擎中的基本操作get()。基本操作get()不是ECMAScript物件可以直接呼叫的方法。

const target = {
  id: 'target',
};
//handler為空物件
let handler = {
};
let proxy = new Proxy(target, handler);

console.log(proxy.id); // target

//handler中新增方法屬性
handler = {
  get() {
    return 'target override';
  },
};
proxy = new Proxy(target, handler);
console.log(proxy.id); // target override

Demo38 為物件建立代理,可以在handler中自定義方法

1.3.4.2 let

1)let和var的主要差別

let的使用和var很像,最主要的差別是let是塊作用域的,而var是函式作用域的[31]

變數的作用域是指變數定義的程式碼所在的區域。全域性變數是全域性作用域,它可以定義在JavaScript程式碼的任何地方;函式變數是函式作用域,它只能定義在函式體中。函式的引數被認為是本地變數,它只能定義在函式體中。在函式體中,本地變數的優先順序高於同名的全域性變數[48],如Demo39。

var scope = "global"; 
function checkscope() {
  var scope = "local"; 
  return scope; 
}
checkscope() //local

Demo39 本地變數的優先順序高於全域性變數

在一些類似C語言的程式語言中,每個使用{}括起來的程式碼塊都有自己的作用域,變數不能在作用域外被訪問。ECMAScript6以前的javascript是沒有塊作用域的,使用的是函式作用域。函式中定義的變數是函式作用域,變數可以在整個函式以及函式的子函式中訪問,如Demo40。

function checkscope() {
  var scope = "local scope"; 
  return function nested() {
    return scope; 
  }
}
checkscope() //local scope

Demo40 var變數可以在整個函式和整個函式的子函式中訪問

ECMAScript6中新增了let關鍵字,它是塊作用域的。如Demo41,程式碼塊{}中使用let宣告瞭變數a,在程式碼塊外訪問變數a,控制檯會報變數a未定義的錯誤。這與var關鍵字不同,var是函式作用域的。塊作用域是函式作用域的子作用域。在for迴圈的迭代變數的宣告中,使用var會有迭代變數指向同一個變數的問題[41]。如Demo42,setTimeout是非同步執行的,在for迴圈執行完畢後,開始執行setTimeout的任務。由於var是函式作用域的,所以5個setTimeout任務中引用的變數i是同一函式作用域下的變數,此時變數i的值為5,所以控制檯的輸出結果為5,5,5,5,5。使用let宣告可以解決這個問題,如Demo43,在for迴圈執行完畢後,開始執行setTimeout任務。由於let是塊作用域的,所以5個setTimeout任務中引用的變數i是不同塊作用域下的,它們的值是不同的,所以控制檯輸出結果為0,1,2,3,4。

{
  let a = 1;
}
console.log(a) //Uncaught ReferenceError: a is not defined

Demo41 let是塊作用域的

for (var i = 0; i < 5; ++i) {
  setTimeout(() => console.log(i), 0)
}
//你期望的控制檯可能是0, 1, 2, 3, 4
//控制檯輸出:
//5, 5, 5, 5, 5

Demo42 在for迴圈使用var宣告迭代變數

for (let i = 0; i < 5; ++i) {
  setTimeout(() => console.log(i), 0)
}
//控制檯輸出:
//0, 1, 2, 3, 4

Demo43 在for迴圈使用let宣告迭代變數

2)let和var的其它差別

使用var變數時會有變數提升的現象[42]。如Demo44,函式f()中使用var宣告瞭區域性變數scope,它的作用域是函式f()。在區域性變數宣告之前,就可以對區域性變數進行訪問,但此時區域性變數尚未賦值,所以第一個輸出結果為”undefined“。在變數宣告之前就可以對變數進行訪問,被成為變數提升現象。

var scope = "global";
function f() {
  console.log(scope); // "undefined", 而不是 "global"
  var scope = "local"; 
  console.log(scope); // "local"
}
f();

Demo44 var變數的變數提升

let與var不同,但也有類似於變數提升的現象[45]。如Demo45,在塊變數scope宣告之前,對scope的訪問會丟擲錯誤,而不是”outerBlock“。在塊變數scope宣告之前,對變數scope的訪問就會受影響,是類似於變數提升的現象。var出現變數提升現象,let出現類似於變數提升的現象,原因是var變數在編譯後是undefined的狀態,而let變數在編譯後是uninitialized的狀態[13]。如Demo44,當函式f()編譯完成開始執行後,第一行輸出變數scope時,函式變數scope的值為undefined,所以控制檯輸出undefined。如Demo45,當程式碼塊編譯完成開始執行後,第一行輸出變數scope時,塊變數scope的值處於initialized的狀態,此時的變數無法被訪問,所以控制檯輸出錯誤資訊。

let scope = "outerBlock";
{
  console.log(scope); //Uncaught ReferenceError: Cannot access 'scope' before initialization, 
                      //而不是 "outerBlock"
  let scope = "block"; 
  console.log(scope); // "block"
}

Demo45 let變數類似於變數提升的現象

由於上述原因,Demo46在執行時也會丟擲錯誤。這是由於在編譯完成開始執行程式碼塊時,塊變數x處於uninitialized的狀態,此時訪問塊變數會報錯。在for迴圈的迴圈語句中使用let時沒有這個問題。在for迴圈中使用let宣告迭代變數的初始值時,初始值表示式在當前變數作用域外進行計算。如Demo47,for迴圈可以正常輸出塊變數x的值;迭代變數的初始值表示式是在塊作用域外計算的。使用let塊語句時也沒有這個問題。如Demo48,let塊語句由包含變數宣告和初始值表示式的塊和程式碼塊組成;變數和初始值被放在()中,緊接著是由{}括起來的程式碼塊。let塊語句中的初始值表示式並不是程式碼塊的一部分,初始值表示式在程式碼塊作用域外執行。[49]

let x = 1;
{ 
  let x = x + 1; //Uncaught ReferenceError: Cannot access 'x' before initialization
  console.log(x); 
}

Demo46 let變數存在類似於變數提升的現象

let x = 1;
for(let x = x + 1; x < 5; x ++){
    console.log(x); //2,3,4
}

Demo47 for迴圈迭代變數的初始值表示式是在塊作用域之外計算的

let x = 1;
let (x = x + 1){
    console.log(x + 1);//3
}
console.log(x + 1);//2

Demo48 let塊語句中的初始值表示式是在塊作用域之外計算的

不可以在同一個塊作用域下使用let重複宣告同一個變數,當使用let和var混合宣告同名變數也是不允許的[41]。如Demo49,當重複宣告同名變數時,會報出SyntaxError的錯誤。

var name;
var name;
let age;
let age; // SyntaxError; identifier 'age' has already been declared

//使用let和var混合宣告變數
var name2;
let name2; // SyntaxError
let age2;
var age2; // SyntaxError

Demo49 不能使用let重複宣告同一個變數

1.3.4.3 const[31]

const具有let的一些特點,但是用const宣告變數時必須有一個初始值,且變數的值不能更改。嘗試修改變數的值將會丟擲執行時錯誤,如Demo50。除了這點不同之外,const基本具有let的其它特點。比如const也是塊作用域的,const變數不可重複宣告等,如Demo51。

const a;//Uncaught SyntaxError: Missing initializer in const declaration

const a = 1;
a = 2; //Uncaught TypeError: Assignment to constant variable

Demo50 const變數的值不能修改

// 不允許重複宣告變數
const name = 'Matt';
const name = 'Nicholas'; // SyntaxError

// const時塊作用域的
const name = 'Matt';
if (true) {
const name = 'Nicholas';
}
console.log(name); // Matt

Demo51 const的一些特性

1.3.4.4 Class

Class是ECMSScript6中引入的新語法結構,所以你對它可能不熟悉。在ECMAScript6之前使用prototype和constructor也能模仿類的行為,但語法顯得冗長和混亂。儘管ECMAScript6中的Class具有典型的物件導向程式設計的特徵,但底層仍然使用prototype和constructor的概念。[32]如Demo52,相比於使用構造器和原型模仿類的行為,使用ES6的Class定義類的語法更簡潔清晰。

//使用構造器和原型模仿類的行為
function Point(x, y) {
  this.x = x;
  this.y = y;
}
Point.prototype.toString = function () {
  return '(' + this.x + ', ' + this.y + ')';
};
 
// 使用ES6的Class定義類
class Point {
  constructor(x, y) {
    this.x = x;
    this.y = y;
  }
  toString() { 
    return '(' + this.x + ', ' + this.y + ')';
  }
}

Demo52 使用Class定義類

與function型別類似,定義class主要有2種方式,class宣告和class表示式,如Demo53。

class Point {
  constructor(x, y) { 
    this.x = x;
    this.y = y;
  }
}
const point = class{
  constructor(x, y) {  
    this.x = x;
    this.y = y;
  }  
}

Demo53 定義Class的2種方式

class中可以包含構造器方法、例項方法、getter、setter和靜態類方法等,如Demo54。class包含的這些部分不是必須的,一個空class也是合法的。

class Point {
  constructor(x, y) { //構造器方法
    this.x = x;
    this.y = y;
  }
  toString() { //例項方法
    return '(' + this.x + ', ' + this.y + ')';
  }
  set coordinate(value){ //setter
    [this.x, this.y] = value.split(",");
  }
  get coordinate(){ //getter
    return this.x + "," + this.y;
  }
  static locate() { //靜態方法
    console.log('class', this);
  }
  locate(){ 
    console.log('instance',this);
  }
}

let point = new Point(10,20);
console.log(point.toString());//(10,20)

point.coordinate = "20,30";
console.log(point.coordinate);//20,30

point.locate();//instance, Point{x:'20',y:'30'}
Point.locate();//class,class Point {}

Demo54 一個包含構造器方法、例項方法、getter、setter和靜態類方法的class類定義

1.4 vue原始碼簡介

1.4.1 flow和typescript

javascript是動態型別的語言,javascript程式碼在執行時,變數被賦予不同的值可能會改變變數的型別。因為變數的型別沒有限制,開發時可能會有很多型別錯誤,這些錯誤在執行時才會發現。對於大型專案的開發來說,會降低開發效率。為了避免JavaScript中動態型別的問題,我們需要透過其它語言來寫我們的專案,然後將其編譯成JavaScript[33]。我們需要一種作為JavaScript擴充套件的語言,來限制變數的型別。臉書的flow和微軟的typescript都提供了JavaScript的靜態型別擴充套件。

vue2.0中使用的是臉書的flow[34],在除錯的時候需要下載flow外掛。typescirpt在vue3.0中全面使用。typescript和flow具有很多的語言特性[35],筆者也只是瞭解,不過並不影響原始碼的閱讀。

1.4.2 vue原始碼構建

在github上下載vue2.5.16的原始碼[36]。原始碼構建使用npm run build命令(Node.js版本為v14.15.1),在構建前需要先使用npm install安裝模組。如圖14,npm run build實際上執行的是node scripts/build.js命令。檢視build.js,其中獲取了./config檔案的所有build配置,如Demo55。./config中部分構建配置如Demo56,其中包含名稱為“web-full-dev”和”weex-factory“的構建配置,後者的屬性weex為true,構建的目標檔案中包含“weex”。使用npm run build命令構建時,會過濾掉目標檔案中包含“weex”的構建配置,如Demo55,這些構建配置不會進行構建。其它正常構建的配置在構建時,比如“web-full-dev”配置構建時,原始碼中包含的if(__WEEX__)判斷,該判斷為false,所以生成的目的碼中直接不再包含if(__WEEX__)相關的程式碼,如Demo57和Demo58。Weex 是阿里巴巴發起的跨平臺使用者介面開發框架,同時也正在 Apache 基金會進行專案孵化,Weex 允許你使用 Vue 語法開發不僅僅可以執行在瀏覽器端,還能被用於開發 iOS 和 Android 上的原生應用的元件[46]。npm run build構建完成後,會生成多個檔案,本文的案例中使用的是vue.js。

圖14 vue原始碼的構建命令npm run build

//檔案目錄:\vue-2.5.16\scripts\build.js

//引入config.js中的所有構建配置
let builds = require('./config').getAllBuilds()

// filter builds via command line arg
if (process.argv[2]) {
  const filters = process.argv[2].split(',')
  builds = builds.filter(b => {
    return filters.some(f => b.output.file.indexOf(f) > -1 || b._name.indexOf(f) > -1)
  })
} else {
  //過濾掉構建的目標檔案中包含"weex"的構建配置
  // filter out weex builds by default
  builds = builds.filter(b => {
    return b.output.file.indexOf('weex') === -1
  })
}

build(builds)

Demo55 vue原始碼構建的build.js檔案

//檔案目錄:\vue-2.5.16\scripts\config.js

const builds = {
    // Runtime+compiler development build (Browser)
  'web-full-dev': {
    entry: resolve('web/entry-runtime-with-compiler.js'),
    dest: resolve('dist/vue.js'),
    format: 'umd',
    env: 'development',
    alias: { he: './entity-decoder' },
    banner
  },
  // Weex runtime factory
  'weex-factory': {
    weex: true,
    entry: resolve('weex/entry-runtime-factory.js'),
    //構建的目標檔案中包含weex
    dest: resolve('packages/weex-vue-framework/factory.js'),
    format: 'cjs',
    plugins: [weexFactoryPlugin]
  }
}

function genConfig (name) {
  const opts = builds[name]
  const config = {
    //此處省略部分程式碼
    output: {
      file: opts.dest,
      format: opts.format,
      banner: opts.banner,
      name: opts.moduleName || 'Vue'
    }
  }
  //此處省略部分程式碼
  return config
}

if (process.env.TARGET) {
  module.exports = genConfig(process.env.TARGET)
} else {
  exports.getBuild = genConfig
  exports.getAllBuilds = () => Object.keys(builds).map(genConfig)
}

Demo56 vue原始碼構建的config.js檔案

export function createPatchFunction (backend) {
  function createElm (
    vnode,
    insertedVnodeQueue,
    parentElm,
    refElm,
    nested,
    ownerArray,
    index
  ) {
    const tag = vnode.tag
    if (isDef(tag)) {
      //vue原始碼中包含if(__WEEX__)判斷
      if (__WEEX__) {
        // in Weex, the default insertion order is parent-first.
        // List items can be optimized to use children-first insertion
        // with append="tree".
        const appendAsTree = isDef(data) && isTrue(data.appendAsTree)
        if (!appendAsTree) {
          if (isDef(data)) {
            invokeCreateHooks(vnode, insertedVnodeQueue)
          }
          insert(parentElm, vnode.elm, refElm)
        }
        createChildren(vnode, children, insertedVnodeQueue)
        if (appendAsTree) {
          if (isDef(data)) {
            invokeCreateHooks(vnode, insertedVnodeQueue)
          }
          insert(parentElm, vnode.elm, refElm)
        }
      } else {
        createChildren(vnode, children, insertedVnodeQueue)
        if (isDef(data)) {
          invokeCreateHooks(vnode, insertedVnodeQueue)
        }
        insert(parentElm, vnode.elm, refElm)
      }        
    }
  }
}

Demo57 vue原始碼中的if(__WEEX__)判斷

function createPatchFunction (backend) {
  function createElm (
    vnode,
    insertedVnodeQueue,
    parentElm,
    refElm,
    nested,
    ownerArray,
    index
  ) {
    var tag = vnode.tag;
    if (isDef(tag)) {
      /* istanbul ignore if */
      {
        createChildren(vnode, children, insertedVnodeQueue);
        if (isDef(data)) {
          invokeCreateHooks(vnode, insertedVnodeQueue);
        }
        insert(parentElm, vnode.elm, refElm);
      }
    }
  }
}

Demo58 生成的vue.js中不包含if(__WEEX__)判斷

1.4.3 vue.js的除錯

vue原始碼有很多細節,對細節不理解時,可以先debug除錯看下。vue.js除錯只需要以下2個簡單的步驟:

  • 在github上下載vue2.5.16的原始碼[36];將原始碼中dist資料夾下的vue.js放在前端專案的lib資料夾下;透過如圖15的方式在html檔案中引入vue.js。

圖15在html檔案中引入vue.js

  • 在瀏覽器中訪問html頁面。開啟F12開發者工具,如圖16在需要除錯的程式碼位置上打上斷點。重新整理頁面,即可進入斷點開始除錯。除錯時,可在控制檯輸出相應變數的值,如圖17。

圖16 在vue.js原始碼打斷點進行除錯

圖17 vue.js除錯時,可在控制檯輸出變數的值

2.流程圖繪製

檢視vue.js原始碼,裡面有很多功能。本文只考慮如Demo58的簡單案例,在chrome瀏覽器中執行時所經歷的流程。重點描述從new Vue到生成操作DOM的原生js的完整流程,對vue.js有個大概的認識。在流程中會有一些物件,這些物件記錄了從el(new Vue時引數中的屬性)到vnode的完整流程,它們是el、template、element,ast,函式render,vnode等。vnode是指虛擬DOM,他包含真實DOM的資訊,但這些資訊尚未更改到真實DOM上。將vnode和舊的vnode(與真實DOM資訊相同)進行對比,只將有差異的節點(元素)更新到真實DOM上。

vue.js是一個js框架,它會將new Vue編譯成操作DOM的原生js程式碼,以實現頁面的變更。同時,new Vue中的data(model)更新後,其掛載的DOM(view)是實時更新的,即Vue的檢視更新是響應式的。這依賴於vue藉助原生的js函式defineProperty,將data中的屬性定義為訪問屬性,在讀取屬性時呼叫get函式,在寫入屬性時呼叫set()函式。在獲取屬性值時將當前vue例項的Wather例項新增到Dep例項的subs中;在修改屬性值時,獲取subs中的Watcher例項,並觸發Watcher的update方法,重新執行函式render生成vnode,並將vnode和舊的vnode進行對比,將有差異的節點更新到真實DOM上。每個vue例項都對應一個 Watcher 例項[37],如果有多個vue例項,只會觸發data所屬的vue例項的Watcher的update方法,對DOM的更新只涉vue例項掛載的DOM區域。

Demo58中案例的程式碼執行,這其中有一些過程,下面從vue.js原始碼層面進行詳細介紹。如果你對程式碼邏輯分支不清楚,或變數的資料格式不清楚,可透過程式碼除錯的方式,先大概熟悉程式碼邏輯和變數的資料格式。

<body>
<div id = "app" >
    <div >{{ username[0] }}</div>
</div>

<script src="./lib/vue.js"></script>

<script>
    new Vue({
        //指定掛載的 DOM 區域
        el: '#app',
        //指定 model 資料來源
        data: {
            username: ['張三']
        }
    });
</script>
</body>

Demo58 一個使用vue.js的簡單案例

2.1 從new Vue到DOM操作

如Demo58的簡單案例在編譯執行時,從new Vue到生成操作DOM的原生js會經過一系列步驟。流程圖1展示了原始碼中先後執行了哪些函式或方法。在流程圖的左側展示的函式引數的傳遞以及返回值的返回。流程圖的右側展示了函式的定義,包含定義全域性的函式常量或在函式原型中新增函式。函式引數的傳遞及返回值返回的流程中,經歷從el到vnode的轉變,主要經過了如圖中①-⑤的步驟,涉及的變數依次為el、template、ast、code、render、render function和vnode。在3.1節中將依次介紹這些變數的含義和在實際執行中的資料格式。流程圖中每個流程節點最上方是程式碼的所屬檔案,檔案地址是指在未構建的vue2.5.16原始碼的相對地址。

流程從new Vue到生成vnode,然後會執行patch方法。new Vue流程中舊vnode是由未解析的原始DOM生成的,它的nodeType值為1。在執行到如流程圖2所示的步驟2中的patch方法時,不會呼叫patchVnode方法由根節點開始從上至下進行patch,而是直接呼叫createElm方法。在createElm時分為4個步驟,建立元素、建立子元素、呼叫鉤子函式修改元素屬性和將元素新增到父元素下;這些步驟會生成操作DOM的原生js程式碼,以對DOM進行操作。建立子元素是遞迴的,當vnode沒有子節點時,遞迴終止。createElm方法呼叫後,會移除舊Vnode對應的DOM元素在父元素中移除。

流程圖1 從new Vue到生成操作DOM的原生js

流程圖2 new Vue進行patch操作時直接呼叫createElm函式

2.2 響應式更新

對vue例項中data資料的更新是響應式的,當data更新後,vue例項掛載的DOM是實時更新的,即頁面檢視是實時更新的。響應式更新主要是使用Object.defineProperty(詳見1.3.3.1)將data中的屬性定義為訪問屬性,在讀取屬性時呼叫get函式,在寫入屬性時呼叫set函式來實現的。如流程圖3的“呼叫defineReactive函式”步驟,在get函式中將當前vue例項的Watcher例項新增到dep.subs中(官方表述為Watcher進行依賴收集[37]),在set函式中獲取subs中的Watcher例項,並通知Wacher例項呼叫update方法。後文中Watcher例項用watcher表示,Dep例項用dep表示,Observer例項用observer表示。

流程圖3中,在new Vue後會呼叫vue的初始化方法,初始化方法中呼叫initState函式,經過幾個步驟後,到達“呼叫observe函式”步驟。在“呼叫observe函式”步驟,會建立Observer例項,到達“執行Observer構造器”步驟。在“執行Observer構造器”步驟中,一方面建立Dep例項,並將this(Observer例項)、dep和value關聯起來,可以透過value.__ob__.dep找到value的observer的dep;另一方面會判斷value是否是陣列,如果是陣列,則重寫”push“、”pop“等方法(詳見3.2節),並逐個以陣列元素為引數呼叫observe()函式。如果不是陣列,則呼叫walk方法,在walk方法中,以obj的逐個鍵為引數呼叫defineReactive函式。在defineReactive函式中,一方面建立了被閉包(set和get函式)引用的dep,將被閉包引用的dep簡稱為閉包dep;另一方面以屬性值為引數呼叫observe函式,並返回childOb。在get函式中,呼叫閉包dep的depend方法將當前vue例項的watcher新增到閉包dep.subs中;並且如果childOb存在,則向childOb的dep.subs中新增當前vue例項的watcher,並且如果屬性值是一個陣列,則向每一個陣列元素的observer的dep.subs中新增當前vue例項的watcher。在流程圖4的set函式中,以newVal為引數呼叫observe函式,返回值用childOb接收;並且呼叫dep.notify方法,通知到observer的dep.subs中的每一個watcher。

在4種情況下會呼叫observe函式,首次到達“呼叫observe函式”步驟時以data為引數呼叫;在步驟”執行Observer構造器“中,判斷value不是陣列時以value為引數呼叫;判斷value是陣列時依次以每個陣列元素為引數呼叫;修改屬性值呼叫set函式時以屬性值為引數呼叫。呼叫observe函式,重新定義個每個物件屬性的set和get函式。在get函式中,將watcher新增到閉包dep.subs中;在set函式中,通知閉包dep.subs中的所有watcher呼叫update方法。透過obj['property'] = newValobj.property = newVal, 或Object.create(obj).property = newVal的方式修改屬性 ,每個屬性的修改都會呼叫該屬性的set函式,通知所有watcher呼叫update方法,進行DOM更新。

但如果屬性值是陣列,呼叫obj[property].push(newVal)或obj[property][0] = newVal是不會呼叫屬性property的set函式,這在如Demo59的程式碼中也有說明,程式碼中dependArray函式是在步驟”呼叫defineReactive函式“中呼叫的。在步驟”呼叫defineReactive函式“的get函式中,會判斷childOb是否存在,如果存在則向childOb的dep.subs中新增當前vue例項的watcher,這裡dep不是閉包dep,是childOb(屬性值的observer)的dep。如果value是陣列,如Demo59,會為每個陣列元素的observer的dep.subs中新增當前Vue例項的watcher,這裡dep也不是閉包dep,是observer的dep。observer的dep是在步驟”執行Observer構造器“中建立的,當value是陣列時,執行陣列的”push“、”pop“等函式時會通知陣列的observer的dep.subs中的watcher(詳見3.2節),如果value陣列的元素還是陣列,執行子陣列的”push“、”pop“等函式也會通知子陣列的observer的dep.subs中的watcher。但透過obj[property][0] = newVal不會通知watcher呼叫update方法,即此時DOM不會實時更新,這是vue2.0的一個Bug。vue3.0沒有這個問題。

//所屬檔案:\core\observer\index.js

/**
 * Collect dependencies on array elements when the array is touched, since
 * we cannot intercept array element access like property getters.
 */
function dependArray (value: Array<any>) {
  for (let e, i = 0, l = value.length; i < l; i++) {
    e = value[i]
    e && e.__ob__ && e.__ob_class Observer_.dep.depend()
    if (Array.isArray(e)) {
      dependArray(e)
    }
  }
}

Demo59 dependArray函式

對如Demo58所示的案例進行修改,新增了“點選”按鈕。如Demo60,當點選”點選“按鈕時,會透過3種方式修改data中username的值。第一種方式this.username = ['李四']會呼叫屬性username的set函式,通知閉包dep.subs中的watcher呼叫update方法。第二種方式this.username.unshift('李四')會通知陣列['張三']的observer的dep.subs中的watcher呼叫update方法。第三種方式this.username.unshift('李四')不會通知watcher,這是vue2.0的bug,vue3.0沒有這個問題。如果username的值為陣列套陣列,比如[['張三']],那麼this.username[0].unshift('李四')也會通知陣列['張三']的observer的dep.subs中的watcher。

如流程圖4,透過第一種方式this.username = ['李四']修改username的值之後,會呼叫屬性的set函式。在set函式中,會通知閉包dep中的watcher呼叫update方法。在update方法中,判斷this.sync是否為true,為true表示同步更新,否則時非同步更新(詳見3.3節)。流程圖4中假定this.sync為true,會呼叫this.run()方法。後續步驟包括呼叫render函式生成新的Vnode等,最後呼叫vm.__patch__方法。

<body>
<div id = "app" >
    <div >{{ username[0] }}</div>
    <button v-on:click="handleClick">點選</button>
</div>

<script src="./lib/vue.js"></script>

<script>
    new Vue({
        //指定掛載的 DOM 區域
        el: '#app',
        //指定 model 資料來源
        data: {
            username: ['張三']
        },
        methods: {
            handleClick() {
                this.username = ['李四'] //會實時更新
               // this.username.unshift('李四') //會實時更新
               // this.username[0] = ['李四'] //不會實時更新
            }
        }
    });
</script>
</body>

Demo60 點選按鈕時修改this.username的值時,檢視是實時更新的

流程圖3 new Vue時將當前vue例項的watcher新增到閉包dep.subs中

流程圖4 從資料物件修改通知watcher到呼叫watcher.update方法到呼叫vm.__patch__方法

vm.__patch__方法的呼叫如流程圖5。經過一些步驟後,開始patchVnode函式。在patchVnode函式中,先對Vnode和舊Vnode進行patch,然後對它們的子節點進行patch。如果Vnode和舊Vnode完全相同,則不需修改,否則將Vnode的資訊更新到DOM上。Vnode的子節點和舊Vnode的子節點同時存在時,會呼叫updateChildren函式(詳見3.4節);如果不同時存在,則直接將增加或移除的子節點更新到DOM上。updateChildren函式中,會判斷vnode和舊vnode是否存在同型別的子節點(sameVnode()函式判斷),如果存在,則以同型別的子節點為引數遞迴呼叫patchVnode函式;否則將增加或移除的節點更新到DOM上。增加節點時,會呼叫createElm()函式,createElm()函式的定義詳見2.1節的流程圖2,他會透過遞迴建立子節點,實現節點和其所有子節點相應DOM元素的建立。

流程圖5 資料修改時呼叫patch函式時,會呼叫patchVnode函式逐個節點進行patch

3.重要細節

3.1 templat解析生成render[50]

2.1節介紹了new Vue到DOM操作的完整流程。流程中涉及的物件依次為el、template、ast、code、render、render function和vnode。在2.1節的流程圖1中,el在new Vue()時作為構造器引數中的屬性,它的值為”#app“。透過呼叫getOutHTML(el)獲取template,ast、render function和vnode等也在後續的流程生成。從el到生成vnode的流程圖可表示為圖18。其中序號對應2.1節的流程圖1中的步驟。

圖18 從el到vnode的流程

3.1.1 template

透過debug如第2節Demo58中的案例,template的值如圖19所示。

圖19 簡單案例Demo58在debug時template的值

從el到template的步驟,如2.1節圖1的步驟①。步驟中如Demo61的函式Vue.prototype.$mount執行,先獲取id為el的值”#app“的元素,然後獲取元素的outerHTML屬性,outerHTML即是template的值。

//所屬檔案:\platforms\web\entry-runtime-with-compiler.js

Vue.prototype.$mount = function (el,hydrating){
el = el && query(el) //根據id獲取元素
const options = this.$options
template = getOuterHTML(el)//獲取元素的outerHTML屬性
const { render, staticRenderFns }= compileToFunctions(template, {
        shouldDecodeNewlines,
        shouldDecodeNewlinesForHref,
        delimiters,comments
      }, this);
      options.render = render
      options.staticRenderFns = staticRenderFns
 return mount.call(this, el, hydrating);
}

Demo61 流程圖1中的步驟①

3.1.2 ast

透過debug,ast的結構如圖20所示。

圖20 簡單案例Demo58在debug時ast的值

ast是抽象語法樹[45],在1.1.3節也有介紹過。將生成ast物件轉換為抽象語法樹如圖21所示`[38][39]`

圖21 ast物件轉換成的抽象語法樹

將template解析成ast的步驟,如2.1節流程圖1的步驟②。在parse時會使用正規表示式按順序從頭到尾匹配template字串中HTML的開始標籤和結束標籤,解析流程如圖22。先匹配到開始標籤<div id = "app">,解析該標籤並存入棧中。然後匹配到開始標籤<div>,解析該標籤並存入棧中,入棧時判斷棧不為空,將當前標籤和棧頭部的標籤建立父子關係。然後解析到第一個文字,文字元素不用入棧,建立文字元素和棧頭部標籤的父子關係。然後解析到第一個結束標籤,將棧頭部的標籤彈出,此時棧中只剩開始標籤<div id = "app">。然後解析到第二個結束標籤,將棧頭部的標籤彈出。此時,template解析完畢,第一個開始標籤作為root節點返回。總的來說,在parse時會使用正規表示式按順序從頭到尾匹配template字串中HTML的開始標籤、結束標籤和文字,開始標籤和文字會被解析為元素物件(如Demo62);在解析的過程中會建立元素間的父子關係,解析後的元素構成ast樹,第一個開始標籤作為root節點返回(如Demo63)。其實template解析時遇到結束標籤,從棧中彈出一個開始標籤,是為了方便建立標籤間的父子關係的。這個例子沒有說明這一點,以Demo64的例子進行說明。

圖22 將template解析成ast的流程示意圖(入棧的標籤實際已解析為元素物件)

//所屬檔案:\compiler\parser\html-parser.js

export function parseHTML (html, options) {
  while (html) {//按順序從頭到尾匹配template字串中HTML的開始標籤、結束標籤和文字
    if (!lastTag || !isPlainTextElement(lastTag)) {
      let textEnd = html.indexOf('<')
      if (textEnd === 0) {
        // End tag:
        const endTagMatch = html.match(endTag)//匹配結束標籤
        if (endTagMatch) {
          const curIndex = index
          advance(endTagMatch[0].length)//html中已匹配的部分擷取掉
          parseEndTag(endTagMatch[1], curIndex, index)//解析結束標籤
          continue
        }

        // Start tag:
        const startTagMatch = parseStartTag()//匹配開始標籤,html中已匹配的部分擷取掉
        if (startTagMatch) {
          handleStartTag(startTagMatch)//解析開始標籤為element
          if (shouldIgnoreFirstNewline(lastTag, html)) {
            advance(1)
          }
          continue
        }
      }
      let text, rest, next
      if (textEnd >= 0) {//匹配文字
        rest = html.slice(textEnd)
        while (
          !endTag.test(rest) &&
          !startTagOpen.test(rest) &&
          !comment.test(rest) &&
          !conditionalComment.test(rest)
        ) {
          // < in plain text, be forgiving and treat it as text
          next = rest.indexOf('<', 1)
          if (next < 0) break
          textEnd += next
          rest = html.slice(textEnd) 
        }
        text = html.substring(0, textEnd)  //獲取文字
        advance(textEnd)//html中已匹配的部分擷取掉
      }
      if (options.chars && text) {
        options.chars(text)
      }
    }
  }
}

Demo62 template解析之parseHTML函式

//所屬檔案:\compiler\parser\index.js

export function parse (
  template: string,
  options: CompilerOptions
): ASTElement | void {
  parseHTML(template, {
    //解析到開始標籤時會執行
    start (tag, attrs, unary) {
      if (!root) {
        root = element //將第一個開始標籤設定為ast的根節點
        checkRootConstraints(root)
      }
      if (currentParent && !element.forbidden) {
        if (element.elseif || element.else) {
          processIfConditions(element, currentParent)
        } else if (element.slotScope) { // scoped slot
          currentParent.plain = false
          const name = element.slotTarget || '"default"'
          ;(currentParent.scopedSlots || (currentParent.scopedSlots = {}))[name] = element
        } else {
          currentParent.children.push(element) //在currentParent的子標籤中新增剛入棧的標籤
          element.parent = currentParent //將剛入棧的標籤的父標籤設定為currentParent
        }
      }
      if (!unary) {
        currentParent = element//設定currentParent為剛入棧標籤
        stack.push(element)
      } else {
        closeElement(element)
      }
    }
    //解析到結束標籤時執行
    end () {
      // remove trailing whitespace
      const element = stack[stack.length - 1]
      const lastNode = element.children[element.children.length - 1]
      if (lastNode && lastNode.type === 3 && lastNode.text === ' ' && !inPre) {
        element.children.pop()
      }
      // pop stack
      stack.length -= 1 //彈出棧首標籤
      currentParent = stack[stack.length - 1]//設定currentParent為彈棧後的棧首標籤
      closeElement(element)
    },
  }
    //解析到文字時會執行
    chars (text: string) {
      const children = currentParent.children
      text = inPre || text.trim()
        ? isTextTag(currentParent) ? text : decodeHTMLCached(text)
        // only preserve whitespace if its not right after a starting tag
        : preserveWhitespace && children.length ? ' ' : ''
      if (text) {
        let res
        if (!inVPre && text !== ' ' && (res = parseText(text, delimiters))) {
          children.push({
            type: 2,
            expression: res.expression,
            tokens: res.tokens,
            text
          })
        } else if (text !== ' ' || !children.length || children[children.length - 1].text !== ' ') {
          children.push({ //將文字元素新增為棧頂標籤的子元素
            type: 3, 
            text
          })
        }
      }          
    }
  return root;//返回根節點
}

Demo63 template解析之parse函式

在解析如Demo64的template時,在parse時會使用正規表示式按順序從頭到尾匹配template字串中HTML的開始標籤和結束標籤,解析流程如圖23。先匹配到開始標籤<div id = "parent">,解析該標籤並存入棧中。然後匹配到開始標籤 <div id = "child1">,解析該標籤並存入棧中,入棧時判斷棧不為空,將當前標籤和棧頭部的標籤建立父子關係,如demo63。然後匹配到結束標籤</div>,將棧頭部的標籤彈出,此時棧中只剩開始標籤<div id = "parent">。然後匹配到開始標籤<div id = "child2">,解析該標籤並存入棧中,入棧時判斷棧不為空,將當前標籤和棧頭部的標籤建立父子關係。然後匹配到第二個結束標籤</div>,將棧頭部的標籤彈出,此時棧中只剩開始標籤<div id = "parent">。然後匹配最後一個</div>,匹配完成後,將棧頭部的標籤彈出。此時,template解析完畢,第一個開始標籤作為root節點返回。可以看出,在template解析時遇到結束標籤,從棧中彈出相應的開始標籤,是為了方便建立標籤間的父子關係的。

<div id = "parent">
  <div id = "child1"></div>
  <div id = "child2"></div>
</div>    

Demo64 說明解析template時使用棧結構的原因的案例

圖23 如Demo64的template解析為ast的流程示意圖(入棧的標籤實際已解析為元素物件)

3.1.3 render function

透過debug,2.1節圖1中的步驟③中code的值如圖24所示,步驟④中render的值如圖25所示。

圖24 簡單案例Demo58在debug時code的值

圖25 簡單案例Demo58在debug時res.render的值

由ast樹解析為render function經歷瞭如第2.1節圖1中的步驟③和④。步驟③中呼叫函式generate,根據ast生成code,render是code的屬性。函式generate的程式碼如Demo65所示,它呼叫瞭如Demo66的函式genElement;genElement中呼叫瞭如Demo67的函式genChildren,genChildern中會呼叫函式gen;函式gen實際值為如Demo68的函式genNode,genNode會判斷節點型別,如果是開始標籤節點,則呼叫genElement,形成遞迴,如果是文字節點,則呼叫如Demo69的函式genText。當節點沒有子節點或節點為文字節點時,遞迴會終止。過程中主要的函式呼叫可以表示為圖26。generate函式生成的render字串,在2.1節圖1步驟④中,被轉換為render function。

圖26 由ast生成render時genElement函式的遞迴呼叫示意圖

//所屬檔案:\compiler\codegen\index.js

export function generate (
  ast: ASTElement | void,
  options: CompilerOptions
): CodegenResult {
  const state = new CodegenState(options)
  const code = ast ? genElement(ast, state) : '_c("div")' 
  return {
    render: `with(this){return ${code}}`,
    staticRenderFns: state.staticRenderFns
  }
}

Demo65 generate函式

//所屬檔案:\compiler\codegen\index.js

export function genElement (el: ASTElement, state: CodegenState): string {
  if (el.staticRoot && !el.staticProcessed) {
    return genStatic(el, state)
  } else if (el.once && !el.onceProcessed) {
    return genOnce(el, state)
  } else if (el.for && !el.forProcessed) {
    return genFor(el, state)
  } else if (el.if && !el.ifProcessed) {
    return genIf(el, state)
  } else if (el.tag === 'template' && !el.slotTarget) {
    return genChildren(el, state) || 'void 0'
  } else if (el.tag === 'slot') {
    return genSlot(el, state)
  } else {
    // component or element
    let code
    if (el.component) {
      code = genComponent(el.component, el, state)
    } else {
      const data = el.plain ? undefined : genData(el, state)
      const children = el.inlineTemplate ? null : genChildren(el, state, true)//呼叫genChildren
      code = `_c('${el.tag}'${
        data ? `,${data}` : '' // data
      }${
        children ? `,${children}` : '' // children
      })`
    }
    // module transforms
    for (let i = 0; i < state.transforms.length; i++) {
      code = state.transforms[i](el, code)
    }
    return code
  }
}

Demo66 genElement函式

//所屬檔案:\compiler\codegen\index.js

export function genChildren (
  el: ASTElement,
  state: CodegenState,
  checkSkip?: boolean,
  altGenElement?: Function,
  altGenNode?: Function
): string | void {
  const children = el.children
  if (children.length) {  //沒有children時遞迴終止
    const el: any = children[0]
    // optimize single v-for
    if (children.length === 1 &&
      el.for &&
      el.tag !== 'template' &&
      el.tag !== 'slot'
    ) {
      return (altGenElement || genElement)(el, state)
    }
    const normalizationType = checkSkip
      ? getNormalizationType(children, state.maybeComponent)
      : 0
    const gen = altGenNode || genNode //引數altGenNode為null,取genNode
    return `[${children.map(c => gen(c, state)).join(',')}]${  //呼叫gen(取genNode)函式
      normalizationType ? `,${normalizationType}` : ''
    }`
  }
}

Demo67 genChildren函式

//所屬檔案:\compiler\codegen\index.js

function genNode (node: ASTNode, state: CodegenState): string {
  if (node.type === 1) {//型別為開始標籤節點
    return genElement(node, state) //呼叫genElemnt,形成遞迴呼叫
  } if (node.type === 3 && node.isComment) {
    return genComment(node)
  } else {//型別為文字節點
    return genText(node) //如果是文字節點,遞迴終止
  }
}

Demo68 genNode函式

//所屬檔案:\compiler\codegen\index.js

export function genText (text: ASTText | ASTExpression): string {
  return `_v(${text.type === 2
    ? text.expression // no need for () because already wrapped in _s()
    : transformSpecialNewlines(JSON.stringify(text.text))
  })`
}

Demo69 genText函式

3.1.4 vnode

透過debug,vnode的結構如圖27所示。vnode的結構很長,圖中未全部顯示。vnode的結構可簡化為Demo70。

圖27 簡單案例Demo58在debug時vnode的值

{
    children:[{
        tag:"div"
        children:[{
			text:"張三"
        }]
    }]
    data:{
        attrs:{id: 'app'}
    }
    tag:"div"
}

Demo70 對vnode簡化後的物件

由render function生成vnode如2.1節流程圖1中的步驟⑤,相關程式碼如Demo71所示。Demo71中的render是一個匿名函式(3.1.3節圖5),render函式的函式體是with(this){return _c('div',{attrs:{"id":"app"}},[_c('div',[_v(_s(username[0]))])])},該函式體會在執行render.call()時執行。其中_c指createElement函式(如Demo73),_v指createTextVNode函式(如Demo72),_s指是toString函式(如Demo72),對函式體替換後得到with(this){return createElement('div',{attrs:{"id":"app"}}, [createElement('div',[createTextVNode(toString(username[0]))])])}

//所屬檔案:\core\instance\render.js

Vue.prototype._render = function (): VNode {
    vnode = render.call(vm._renderProxy, vm.$createElement)
}

Demo71 流程圖1中的步驟⑤

//所屬檔案:\core\instance\render-helpers\index.js

export function installRenderHelpers (target: any) {
  target._o = markOnce
  target._n = toNumber
  target._s = toString
  target._l = renderList
  target._t = renderSlot
  target._q = looseEqual
  target._i = looseIndexOf
  target._m = renderStatic
  target._f = resolveFilter
  target._k = checkKeyCodes
  target._b = bindObjectProps
  target._v = createTextVNode
  target._e = createEmptyVNode
  target._u = resolveScopedSlots
  target._g = bindObjectListeners
}

Demo72 _v_s等函式的定義

//所屬檔案:\core\instance\render.js

vm._c = (a, b, c, d) => createElement(vm, a, b, c, d, false)

Demo73 函式_c的定義

函式體with(this){return createElement(vm,'div',{attrs:{"id":"app"}},[createElement(vm,'div', [createTextVNode(toString(username[0]))])])}開始執行。先執行函式createTextVNode(toString(username[0])),函式createTextVNode的定義如Demo74。由於函式是在with(this)的程式碼塊中,執行時作用域得到加強,username[0]指this.username[0];this指呼叫render.call方法時的第一個引數vm._renderProxyvm._renderProxy是vue例項vm的代理,故username[0]的值為‘張三’。函式執行後建立Vnode例項vnode,vnode作為返回值返回,它的節點資訊如Demo75。

//所屬檔案:\core\vdom\vnode.js

export function createTextVNode (val: string | number) {
  return new VNode(undefined, undefined, undefined, String(val))
}

Demo74 createTextVNode函式

{
	text:"張三"
}

Demo75 對生成的vnode簡化後的物件構成

然後執行表示式createElement(vm,'div',[createTextVNode(toString(username[0]))])createTextVNode(toString(username[0]))剛剛已執行,它的返回值是vnode。替換表示式中已執行部分,得到createElement(vm,'div',[vnode]),其中vnode的資訊如Demo75。表示式執行時,呼叫如Demo76的createElement方法,createElement方法中呼叫如Demo77的_createElement方法,_createElement中建立了新的Vnode例項vnode,它的children是引數中的[vnode]。新的vnode作為返回值返回,它的節點資訊如Demo78。

然後執行表示式createElement(vm,'div',{attrs:{"id":"app"}},[createElement(vm,'div',[createTextVNode(toString(username[0]))])])createElement(vm,'div',[createTextVNode(toString(username[0]))])剛剛已執行,它的返回值是vnode。替換表示式中已執行部分,得到createElement(vm,'div',{attrs:{"id":"app"}},[vnode]),其中vnode的資訊如Demo10。表示式執行時,呼叫Demo9的createElement方法,建立了新的Vnode例項vnode,它的children是引數中中的[vnode],它的data屬性值是引數中的{attrs:{"id":"app"}}。新的vnode作為返回值返回,它的節點資訊如Demo70。

//所屬檔案:\core\instance\render.js

export function createElement (
  context: Component,
  tag: any,
  data: any,
  children: any,
  normalizationType: any,
  alwaysNormalize: boolean
): VNode | Array<VNode> {
  if (Array.isArray(data) || isPrimitive(data)) {
    normalizationType = children
    children = data
    data = undefined
  }
  if (isTrue(alwaysNormalize)) {
    normalizationType = ALWAYS_NORMALIZE
  }
  return _createElement(context, tag, data, children, normalizationType)
}

Demo76 createElement函式

//所屬檔案:\core\vdom\create-element.js

export function _createElement (
  context: Component,
  tag?: string | Class<Component> | Function | Object,  
  data?: VNodeData,
  children?: any,                                      
  normalizationType?: number
): VNode | Array<VNode> {
  if (isDef(data) && isDef((data: any).__ob__)) {
    process.env.NODE_ENV !== 'production' && warn(
      'Avoid using observed data object as vnode data: ${JSON.stringify(data)}\n' +
      'Always create fresh vnode data objects in each render!',
      context
    )
    return createEmptyVNode()
  }
  // object syntax in v-bind
  if (isDef(data) && isDef(data.is)) {
    tag = data.is
  }
  if (!tag) {
    // in case of component :is set to falsy value
    return createEmptyVNode()
  }
  // warn against non-primitive key
  if (process.env.NODE_ENV !== 'production' &&
    isDef(data) && isDef(data.key) && !isPrimitive(data.key)
  ) {
    if (!__WEEX__ || !('@binding' in data.key)) {
      warn(
        'Avoid using non-primitive value as key, ' +
        'use string/number value instead.',
        context
      )
    }
  }
  // support single function children as default scoped slot
  if (Array.isArray(children) &&
    typeof children[0] === 'function'
  ) {
    data = data || {}
    data.scopedSlots = { default: children[0] }
    children.length = 0
  }
  if (normalizationType === ALWAYS_NORMALIZE) {
    children = normalizeChildren(children)
  } else if (normalizationType === SIMPLE_NORMALIZE) {
    children = simpleNormalizeChildren(children)
  }
  let vnode, ns
  if (typeof tag === 'string') {
    let Ctor
    ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag)
    if (config.isReservedTag(tag)) {
      // platform built-in elements
      vnode = new VNode(    //1.建立Vnode節點
        config.parsePlatformTagName(tag), data, children,
        undefined, undefined, context
      )
    } else if (isDef(Ctor = resolveAsset(context.$options, 'components', tag))) {
      // component
      vnode = createComponent(Ctor, data, context, children, tag)
    } else {
      // unknown or unlisted namespaced elements
      // check at runtime because it may get assigned a namespace when its
      // parent normalizes children
      vnode = new VNode(     
        tag, data, children,
        undefined, undefined, context
      )
    }
  } else {
    // direct component options / constructor
    vnode = createComponent(tag, data, context, children)
  }
  if (Array.isArray(vnode)) {
    return vnode
  } else if (isDef(vnode)) {
    if (isDef(ns)) applyNS(vnode, ns)
    if (isDef(data)) registerDeepBindings(data)
    return vnode //2.返回vnode節點
  } else {
    return createEmptyVNode()
  }
}

Demo77 _createElement函式

{
        tag:"div"
        children:[{
			text:"張三"
        }]
    }

Demo78 對生成的vnode簡化後的物件構成

剛剛執行的render.call()方法有2個引數(如Demo79),render.call()執行時會呼叫函式render(如圖28)。render.call()的第一個參數列示函式render執行時的this;第二個引數作為render函式的引數,但render函式是一個匿名無參函式,所以第二個引數沒有用到。render.call()的第一個引數為vm._renderProxyvm._renderProxy的值是在_init方法執行時賦予的,如Demo80。_init方法中呼叫瞭如Demo81的initProxy方法,它以hasHandler為引數建立vue例項的代理vm._renderProxy,hasHandler中定義了has方法。render函式的函式體是with(this)的程式碼塊,程式碼塊執行時,會呼叫has方法判斷_c_v_s等函式在this中是否存在(參見1.3.3.5節),this指vm._renderProxy。由於vm._renderProxy的hasHander中定義了has方法,直接呼叫該has方法,不會呼叫javascript引擎內部的has方法。hasHandler中的has呼叫時,如果_c_v_s等函式在vm._renderProxy中不存在,則會給出錯誤警告。

//所屬檔案:\core\instance\render.js

Vue.prototype._render = function (): VNode {
    vnode = render.call(vm._renderProxy, vm.$createElement)
}

Demo79 流程圖1中的步驟⑤

圖28 簡單案例Demo1在debug時res.render的值

//所屬檔案:\core\instance\init.js

export function initMixin (Vue: Class<Component>) {
Vue.prototype._init = function (options?: Object) {
     if (process.env.NODE_ENV !== 'production') {
      initProxy(vm)
    } else {
      vm._renderProxy = vm
    }
}
}

Demo80 vue原型中的_init方法定義

//所屬檔案: \core\instance\proxy.js

if (process.env.NODE_ENV !== 'production') {
initProxy = function initProxy (vm) {
    if (hasProxy) {
      // determine which proxy handler to use
      const options = vm.$options
      const handlers = options.render && options.render._withStripped
        ? getHandler
        : hasHandler     //此時,render尚未初始化,option.render為undefined,取值hasHandler
      vm._renderProxy = new Proxy(vm, handlers)
    } else {
      vm._renderProxy = vm
    }

    const hasHandler = {
    has (target, key) {
      const has = key in target
      const isAllowed = allowedGlobals(key) || key.charAt(0) === '_'
      if (!has && !isAllowed) {
        //如果屬性在物件中不存在,給出錯誤警告
        warnNonPresent(target, key)
      }
      return has || !isAllowed
    }
  }

  const getHandler = {
    get (target, key) {
      if (typeof key === 'string' && !(key in target)) {
        warnNonPresent(target, key)
      }
      return target[key]
    }
  }
  }

Demo81 initProxy函式

3.2 響應式更新之陣列方法重寫[40]

如2.2節的流程圖3,步驟“執行Observer構造器”的程式碼如Demo82所示。當value是陣列時,會呼叫protoAugment方法或copyAugment方法。這兩個方法的作用是什麼呢?以protoAugment為例進行說明。在protoAugment函式中,修改value.__proto__為arrayMethods。arrayMethods的相關定義如Demo83。在Demo83中arrayMethods函式原型繼承了Array的函式原型,並重寫了”push“、”pop“等7個函式,重寫後的函式除了執行陣列的原功能外,還會呼叫 ob.observeArray方法和ob.dep.notify方法。ob(this.__ob__)是在Demo82定義的。當呼叫value的這7個函式時,呼叫的其實是重寫後的函式,比如push方法新增一個元素,則以新增元素(push方法入參時轉為陣列格式)為引數呼叫observeArray函式;並通知陣列的observer的dep.subs中的所有的watcher呼叫update方法,dep.subs中的watcher是在步驟”呼叫defineReactive函式“的get函式中新增的(詳見2.2節第4段)。

//所屬檔案:\core\observer\index.js

class Observer {
constructor (value: any) {
    //observer的dep
    this.dep = new Dep()
    def(value, '__ob__', this)
    if (Array.isArray(value)) {
      const augment = hasProto//判斷瀏覽器是否支援__proto__屬性
        ? protoAugment//修改陣列物件的__proto__屬性,覆蓋原有方法
        : copyAugment//透過Object.defineProperty,覆蓋原有方法
      augment(value, arrayMethods, arrayKeys)
      this.observeArray(value)
    } else {
      this.walk(value)
    }
}
    
function protoAugment (target, src, keys) {
  target.__proto__ = src;
}    

Demo82 執行Observer構造器,如果value是陣列,重寫陣列部分方法

//所屬檔案:\core\observer\array.js

const arrayProto = Array.prototype
//arrayMethods繼承自arrayProto
export const arrayMethods = Object.create(arrayProto)

const methodsToPatch = [
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
]

/**
 * Intercept mutating methods and emit events
 */
methodsToPatch.forEach(function (method) {
  // cache original method
  const original = arrayProto[method]
  //重寫arrayMethods的push、pop等方法
  def(arrayMethods, method, function mutator (...args) {
    const result = original.apply(this, args)
    const ob = this.__ob__
    let inserted
    switch (method) {
      case 'push':
      case 'unshift':
        inserted = args
        break
      case 'splice':
        inserted = args.slice(2)
        break
    }
    // 在陣列中新增的元素的observer的dep.subs中新增當前屬性所屬物件的Watcher
    if (inserted) ob.observeArray(inserted)
    // 對dep.subs中的Watcher進行通知
    ob.dep.notify()
    return result
  })
})

Demo83 建立繼承於Array的函式arrayMethods,並重寫部分方法

3.3 響應式更新之非同步更新[41]

如2.2節所述,data中屬性值的更新是響應式的。當data中的屬性值更新,會通知相應的watcher呼叫update方法。watcher.update方法中對檢視的更新預設是非同步的。如2.2節流程圖4,watcher的update方法中,this.sync預設為false,會呼叫queueWatcher(this)方法。如Demo84,函式queueWather中watcher被新增到queue中,然後呼叫nextTick函式。如Demo85,函式nextTick中新增回撥函式flushSchedulerQueue到callback陣列中,然後呼叫macroTimerFunc函式。如Demo86,函式macroTimerFunc在載入vue.js時建立,函式中非同步呼叫flushCallbacks函式。如Demo87,flushCallbacks函式中逐個呼叫callback陣列中的函式,flushSchedulerQueue被呼叫。如Demo88,flushSchedulerQueue函式中遍歷queue中watcher,呼叫watcher的run方法,進行DOM更新。由於flushCallbacks函式是非同步呼叫的,所以DOM更新也是非同步的。

//所屬檔案: \core\observer\scheduler.js

export function queueWatcher (watcher: Watcher) {
  const id = watcher.id
  if (has[id] == null) {
    has[id] = true
    if (!flushing) {
      queue.push(watcher) //watcher被新增到queue中
    } else {
      // if already flushing, splice the watcher based on its id
      // if already past its id, it will be run next immediately.
      let i = queue.length - 1
      while (i > index && queue[i].id > watcher.id) {
        i--
      }
      queue.splice(i + 1, 0, watcher)
    }
    // queue the flush
    if (!waiting) {
      waiting = true
      nextTick(flushSchedulerQueue)
    }
  }
}

Demo84 queueWatcher函式

//所屬檔案:\core\util\next-tick.js

export function nextTick (cb?: Function, ctx?: Object) {
  let _resolve
  callbacks.push(() => { //回撥函式flushSchedulerQueue新增到callback陣列中
    if (cb) {
      try {
        cb.call(ctx)
      } catch (e) {
        handleError(e, ctx, 'nextTick')
      }
    } else if (_resolve) {
      _resolve(ctx)
    }
  })
  if (!pending) {
    pending = true
    if (useMacroTask) {
      macroTimerFunc() //呼叫macroTimerFunc函式
    } else {
      microTimerFunc()
    }
  }
  // $flow-disable-line
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}

Demo85 nextTick函式

//所屬檔案:\core\util\next-tick.js

// 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 */
if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  macroTimerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else if (typeof MessageChannel !== 'undefined' && (
  isNative(MessageChannel) ||
  // PhantomJS
  MessageChannel.toString() === '[object MessageChannelConstructor]'
)) {
  //非同步執行flushCallbacks函式
  const channel = new MessageChannel()
  const port = channel.port2
  channel.port1.onmessage = flushCallbacks  
  macroTimerFunc = () => {   
    port.postMessage(1)
  }
} else {
  /* istanbul ignore next */
  macroTimerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}

Demo86 macroTimerFunc函式在載入vue.js時定義

//所屬檔案:\core\util\next-tick.js

function flushCallbacks () {
  pending = false
  const copies = callbacks.slice(0)
  callbacks.length = 0
  for (let i = 0; i < copies.length; i++) {
    copies[i]()
  }
}

Demo87 flushCallbacks函式

//所屬檔案: \core\observer\scheduler.js

function flushSchedulerQueue () {
  flushing = true
  let watcher, id

  // Sort queue before flush.
  // This ensures that:
  // 1. Components are updated from parent to child. (because parent is always
  //    created before the child)
  // 2. A component's user watchers are run before its render watcher (because
  //    user watchers are created before the render watcher)
  // 3. If a component is destroyed during a parent component's watcher run,
  //    its watchers can be skipped.
  queue.sort((a, b) => a.id - b.id)

  // do not cache length because more watchers might be pushed
  // as we run existing watchers
  for (index = 0; index < queue.length; index++) {
    watcher = queue[index]
    id = watcher.id
    has[id] = null
    watcher.run()  //呼叫watcher.run()方法,進行DOM更新
    // in dev build, check and stop circular updates.
    if (process.env.NODE_ENV !== 'production' && has[id] != null) {
      circular[id] = (circular[id] || 0) + 1
      if (circular[id] > MAX_UPDATE_COUNT) {
        warn(
          'You may have an infinite update loop ' + (
            watcher.user
              ? `in watcher with expression "${watcher.expression}"`
              : `in a component render function.`
          ),
          watcher.vm
        )
        break
      }
    }
  }

  // keep copies of post queues before resetting state
  const activatedQueue = activatedChildren.slice()
  const updatedQueue = queue.slice()

  resetSchedulerState()

  // call component updated and activated hooks
  callActivatedHooks(activatedQueue)
  callUpdatedHooks(updatedQueue)

  // devtool hook
  /* istanbul ignore if */
  if (devtools && config.devtools) {
    devtools.emit('flush')
  }
}

Demo88 flushSchedulerQueue函式

非同步執行是指當前執行緒行完成後,才會執行非同步的函式。實現非同步執行有多種方式。Demo86中函式macroTimerFunc非同步呼叫flushCallbacks函式使用了3種方式。以setTimeout為例進行說明。如Demo89,setTimeout中的任務不會立馬執行,任務會被新增到task任務中[42],直到呼叫setTimeout的執行緒終止後才會執行[43],所以“After setTimeout”會在“setTimeout”之前輸出。MessageChannel中onmessage的非同步執行與setTimeout的原理相似,詳見1.3.3.6節。

如Demo90中所示html頁面,當點選“點選”按鈕後,在修改this.username的值時,只會將vue例項的watcher新增到queue中,不會立馬執行DOM更新,所以consle.log輸出的值時修改前的”張三“。當第二次修改this.username之時,watcher已被新增到queue中,不會重複新增。在handclick函式執行完畢後,當前執行緒終止;開始執行flushCallbacks函式,函式只會執行一次,然後遍歷queue中的watcher,呼叫watcher.run()方法。由於queue已去重,watcher.run方法只會被呼叫一次,而如果是同步執行watcher.run會被呼叫2次,比較耗時。非同步呼叫是從效能上考慮的。

setTimeout(()=>{console.log("setTimeout")}, 0);
console.log("After setTimeout");

//控制檯輸出:
//After setTimeout
//setTimeout

Demo89 setTimeout的非同步執行

<body>
<div id = "app" >
    <div ref="test">{{ username[0] }}</div>
    <button v-on:click="handleClick">點選</button>
</div>

<script src="./lib/vue.js"></script>

<script>
    new Vue({
        //指定掛載的 DOM 區域
        el: '#app',
        //指定 model 資料來源
        data: {
            username: ['張三']
        },
        methods: {
            handleClick() {
                this.username = ['李四']  //對DOM的更新是非同步的,將watcher新增到queue中
                console.log(this.$refs.test.innerText); //張三
                this.username = ['王五'] //watcher已新增到queue中,不會重複新增
            }
        }
    });
</script>
</body>

Demo90 一個非同步更新DOM的簡單頁面

3.4 updateChildren()方法[44]

2.2節的流程圖5中,在patchVNode()時,如果oldCh和newCh都存在,則會呼叫updateChildren函式。updateChilren的原始碼如Demo91。程式碼實現可概括為逐個判斷新子節點陣列中的子節點是否存在同型別(sameVnode()函式判斷)的舊子節點。如果存在,則以新子節點和舊子節點為引數呼叫patchVNode。patchVNode後,如果舊子節點在舊子節點陣列中的次序與新子節點在新子節點陣列中的次序不同,則對相應的DOM元素的次序進行調整。比如,舊子節點陣列的非首個節點與新節點陣列的首個節點是同型別的,除了呼叫patchVNode之外,還要透過nodeOps.insertBefore()函式調整DOM中相應元素的次序。在逐個判斷新子節點陣列的子節點是否存在同型別的舊子節點時,先對新子節點陣列和舊子節點陣列的首尾節點進行判斷,這在某些場景下可以提高執行效率。

//所屬檔案: \core\vdom\patch.js

function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
    let oldStartIdx = 0
    let newStartIdx = 0
    let oldEndIdx = oldCh.length - 1
    let oldStartVnode = oldCh[0]
    let oldEndVnode = oldCh[oldEndIdx]
    let newEndIdx = newCh.length - 1
    let newStartVnode = newCh[0]
    let newEndVnode = newCh[newEndIdx]
    let oldKeyToIdx, idxInOld, vnodeToMove, refElm

    // removeOnly is a special flag used only by <transition-group>
    // to ensure removed elements stay in correct relative positions
    // during leaving transitions
    const canMove = !removeOnly

    if (process.env.NODE_ENV !== 'production') {
      checkDuplicateKeys(newCh)
    }

    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
      if (isUndef(oldStartVnode)) {
        oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
      } else if (isUndef(oldEndVnode)) {
        oldEndVnode = oldCh[--oldEndIdx]
      //先將舊子節點陣列和新子節點陣列按首首、尾首、首尾、尾尾的方式進行同型別子節點匹配
      } else if (sameVnode(oldStartVnode, newStartVnode)) {
        patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)
        oldStartVnode = oldCh[++oldStartIdx]
        newStartVnode = newCh[++newStartIdx]
      } else if (sameVnode(oldEndVnode, newEndVnode)) {
        patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue)
        oldEndVnode = oldCh[--oldEndIdx]
        newEndVnode = newCh[--newEndIdx]
      } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
        patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue)
        canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
        oldStartVnode = oldCh[++oldStartIdx]
        newEndVnode = newCh[--newEndIdx]
      } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
        patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue)
        canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
        oldEndVnode = oldCh[--oldEndIdx]
        newStartVnode = newCh[++newStartIdx]
      //然後判斷新子節點陣列的開始節點在舊子節點陣列中是否存在同鍵節點
      } else {
        if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
        idxInOld = isDef(newStartVnode.key)
          ? oldKeyToIdx[newStartVnode.key]
          : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
        if (isUndef(idxInOld)) { // New element
           //如果不存在同鍵節點,呼叫createElm函式
          createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
        } else {
          vnodeToMove = oldCh[idxInOld]
          if (sameVnode(vnodeToMove, newStartVnode)) {
            patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue)
            oldCh[idxInOld] = undefined
            canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
          } else {
            // 如果是不同型別的節點,呼叫createElm函式
            createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
          }
        }
        newStartVnode = newCh[++newStartIdx]
      }
    }
    if (oldStartIdx > oldEndIdx) {
      refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
      //新增節點
      addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
    } else if (newStartIdx > newEndIdx) {
      //移除父元素parentElm中的子元素oldCh.elm
      removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
    }
  }

Demo91 函式updateChildren

參考文章:

[1] 朱永盛.WebKit技術內幕[M].北京:電子工業出版社,2014:46.

[2] 朱永盛.WebKit技術內幕[M].北京:電子工業出版社,2014:241.

[3] 朱永盛.WebKit技術內幕[M].北京:電子工業出版社,2014:14.

[4] 渲染頁面:瀏覽器的工作原理.developer.mozilla.org,檢索於2023-10-23.

[5] Matt Frisbie.Professional JavaScript for Web Developers[M].Indianapolis, Indiana.John Wiley & Sons, Inc,2020: 1.

[6] 朱永盛.WebKit技術內幕[M].北京:電子工業出版社,2014:238-240.

[7] Jackie Yin.深度剖析Javascript 引擎執行原理分析.知乎,2017,檢索於2023-11-17.

[8] Browser engine.WIKIPEDIA,檢索於2023-10-23.

[9] Introduction to Javascript Engines.www.geeksforgeeks.org,檢索於2023-11-17.

[10] Developer FAQ - Why Blink?.www.chromium.org,檢索於2023-11-17.

[11] JavaScript.WIKIPEDIA,檢索於2023-11-17.

[12] Matt Frisbie.Professional JavaScript for Web Developers[M].Indianapolis, Indiana.John Wiley & Sons, Inc,2020: 6.

[13] haoduoyu2099.從底層和記憶體角度透析Javascript 的執行過程.CSDN.2023,檢索於2023-11-18

[14] Matt Frisbie.Professional JavaScript for Web Developers[M].Indianapolis, Indiana.John Wiley & Sons, Inc,2020: 25.

[15] Matt Frisbie.Professional JavaScript for Web Developers[M].Indianapolis, Indiana.John Wiley & Sons, Inc,2020: 274.

[16] 阮一峰.Javascript物件導向程式設計(二):建構函式的繼承.www.ruanyifeng.com,檢索於2023-11-17.

[17] Matt Frisbie.Professional JavaScript for Web Developers[M].Indianapolis, Indiana.John Wiley & Sons, Inc,2020: 106-107.

[18] Matt Frisbie.Professional JavaScript for Web Developers[M].Indianapolis, Indiana.John Wiley & Sons, Inc,2020: 379-380.

[19] Matt Frisbie.Professional JavaScript for Web Developers[M].Indianapolis, Indiana.John Wiley & Sons, Inc,2020: 376.

[20] 前端Q群282549184.JavaScript閉包應用介紹.簡書,檢索於2023-11-17.

[21] clearTimeout() global function.developer.mozilla.org,檢索於2023-11-17.

[22] Matt Frisbie.Professional JavaScript for Web Developers[M].Indianapolis, Indiana.John Wiley & Sons, Inc,2020: 252.

[23] JavaScript 物件定義.W3school,檢索於2023-11-17.

[24] JavaScript 字串方法.W3school,檢索於2023-11-17.

[25] JavaScript 陣列方法.W3school,檢索於2023-11-17.

[26] Matt Frisbie.Professional JavaScript for Web Developers[M].Indianapolis, Indiana.John Wiley & Sons, Inc,2020: 605.

[27] Matt Frisbie.Professional JavaScript for Web Developers[M].Indianapolis, Indiana.John Wiley & Sons, Inc,2020: 984.

[28] Matt Frisbie.Professional JavaScript for Web Developers[M].Indianapolis, Indiana.John Wiley & Sons, Inc,2020: 970.

[29] Matt Frisbie.Professional JavaScript for Web Developers[M].Indianapolis, Indiana.John Wiley & Sons, Inc,2020: 368.

[30] Matt Frisbie.Professional JavaScript for Web Developers[M].Indianapolis, Indiana.John Wiley & Sons, Inc,2020: 353.

[31] Matt Frisbie.Professional JavaScript for Web Developers[M].Indianapolis, Indiana.John Wiley & Sons, Inc,2020: 31-34.

[32] Matt Frisbie.Professional JavaScript for Web Developers[M].Indianapolis, Indiana.John Wiley & Sons, Inc,2020: 302.

[33] Comparing statically typed JavaScript implementations.blog.logrocket.com,檢索於2023-11-17.

[34] Getting Started.flow.org,檢索於2023-11-17.

[35] TypeScript for JavaScript Programmers.www.typescriptlang.org,檢索於2023-11-17.

[36] vuejs.v2.5.17-beta.0.Github,檢索於2023-11-17.

[37] 深入響應式原理.v2.cn.vuejs.org,檢索於2023-11-17.

[38] 郭方超.瀏覽器載入、解析、渲染的流程?.知乎,檢索於2023-11-17.

[39] Jackie Yin.HTML程式碼是如何被解析成瀏覽器中的DOM物件的.知乎,檢索於2023-11-17.

[40] answershuto.資料繫結原理.Github,檢索於2023-11-17.

[41] answershuto.Vue.js非同步更新DOM策略及nextTick.Github,檢索於2023-11-18.

[42] Using promises.developer.mozilla.org,檢索於2023-11-18.

[43] setTimeout() global function.developer.mozilla.org,檢索於2023-11-18.

[44] answershutoVirtualDOM與diff(Vue實現).Github,檢索於2023-11-18.

[45] Abstract syntax tree.WIKIPEDIA,檢索於2023-11-18.

[46] 原生渲染.v2.cn.vuejs.org,檢索於2023-11-17.

[47] Matt Frisbie.Professional JavaScript for Web Developers[M].Indianapolis, Indiana.John Wiley & Sons, Inc,2020: 289.

[48] David Flanagan.JavaScript: The Definitive Guide[M].sebastopol.O’Reilly Media, Inc,2011: 53-54

[49] David Flanagan.JavaScript: The Definitive Guide[M].sebastopol.O’Reilly Media, Inc,2011: 270-271

[50] answershuto.聊聊Vue的template編譯,檢索於2023-11-17.

[51] Matt Frisbie.Professional JavaScript for Web Developers[M].Indianapolis, Indiana.John Wiley & Sons, Inc,2020: 325.

相關文章