一个简单案例的Vue2.0源码

本文学习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]^

图4 早期Javascript的编译流程

js 复制代码
var obj ={a:1,b:2}
console.log(obj.b) //2

Demo1 JavaScript获取对象obj的属性b的值

java 复制代码
public class Obj {
    public int a;
    public int b;

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

Demo2 Java中定义类Obj

java 复制代码
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]

图5 JavaScript编译流程改进

源代码编译时,先生成抽象语法树(ast)是编译器的通常做法,java编译器、C++编译器中都包含这个步骤。在3.1节中,vue框架在将template解析生成Vnode时,也先将template解析抽象语法树。Demo4所示的javascript代码编译生成的ast树如图6所示^[7]^。

js 复制代码
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中的结果是显然的。

js 复制代码
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 函数、函数原型和原型实例的关系

js 复制代码
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的一个简单的原型链

js 复制代码
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为例,介绍实现函数和函数原型继承的几种方式。

js 复制代码
function Animal(){
    this.species = "动物";
}
Animal.prototype.food = "肉类"

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

Demo8 父函数Animal和子函数Cat

1)基于prototype的继承

Demo9展示了通过prototype实现构造函数和函数原型继承,案例中子函数的原型和父函数的原型构成原型链。

js 复制代码
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方法,将父对象的构造函数绑定在子对象上,即可实现构造函数的继承。

js 复制代码
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的原型中的属性,可以实现函数原型的继承。

js 复制代码
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]^。

js 复制代码
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函数。所以在实际使用时,应尽量在必要的时候才使用闭包。

js 复制代码
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函数时,会在上次的执行结果的基础上进行计算。

js 复制代码
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]^。

html 复制代码
<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)就是一个闭包。

js 复制代码
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。

js 复制代码
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函数都是闭包。

js 复制代码
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()函数操作。

js 复制代码
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之外的属性不能被修改,或该数据属性不能被修改为访问属性。

js 复制代码
let person = {
  name: "jack"
};

Demo20 定义person对象

js 复制代码
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被调用。

js 复制代码
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等多个属性。

js 复制代码
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。

js 复制代码
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为原型的对象。

js 复制代码
function a(){}
var b = Object.create(a.prototype);
console.log( b.__proto__ === a.prototype ); //true

Demo25 通过Object.create()创建对象

1.3.3.3 字符串方法^[24]^

Demo26列举了字符串的部分方法。

js 复制代码
//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 列举了数组的部分方法。

js 复制代码
//拼接数组: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函数,控制台会输出该方法在实例中未定义,并抛出错误。

js 复制代码
function Vue(){
}
vm = new Vue();
vm._c = function createElement(){}

with(vm){
  _c();
}

Demo28 使用with(objName)增强代码块的作用域

js 复制代码
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。

js 复制代码
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通信

js 复制代码
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通信

js 复制代码
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通信

js 复制代码
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通信

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

//控制台输出:
//1

Demo34 在同一个js文件中基于MessageChannel通信

js 复制代码
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。

js 复制代码
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个参数

js 复制代码
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对象可以直接调用的方法。

js 复制代码
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。

js 复制代码
var scope = "global"; 
function checkscope() {
  var scope = "local"; 
  return scope; 
}
checkscope() //local

Demo39 本地变量的优先级高于全局变量

在一些类似C语言的编程语言中,每个使用{}括起来的代码块都有自己的作用域,变量不能在作用域外被访问。ECMAScript6以前的javascript是没有块作用域的,使用的是函数作用域。函数中定义的变量是函数作用域,变量可以在整个函数以及函数的子函数中访问,如Demo40。

js 复制代码
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。

js 复制代码
{
  let a = 1;
}
console.log(a) //Uncaught ReferenceError: a is not defined

Demo41 let是块作用域的

js 复制代码
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声明迭代变量

js 复制代码
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"。在变量声明之前就可以对变量进行访问,被成为变量提升现象。

js 复制代码
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的状态,此时的变量无法被访问,所以控制台输出错误信息。

js 复制代码
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]^

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

Demo46 let变量存在类似于变量提升的现象

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

Demo47 for循环迭代变量的初始值表达式是在块作用域之外计算的

js 复制代码
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的错误。

js 复制代码
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。

js 复制代码
const a;//Uncaught SyntaxError: Missing initializer in const declaration

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

Demo50 const变量的值不能修改

js 复制代码
// 不允许重复声明变量
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定义类的语法更简洁清晰。

js 复制代码
//使用构造器和原型模仿类的行为
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。

js 复制代码
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也是合法的。

js 复制代码
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

js 复制代码
//文件目录:\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文件

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文件

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__)判断

js 复制代码
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源码层面进行详细介绍。如果你对代码逻辑分支不清楚,或变量的数据格式不清楚,可通过代码调试的方式,先大概熟悉代码逻辑和变量的数据格式。

html 复制代码
<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没有这个问题。

js 复制代码
//所属文件:\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__方法。

html 复制代码
<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的值。

js 复制代码
//所属文件:\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的流程示意图(入栈的标签实际已解析为元素对象)

js 复制代码
//所属文件:\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函数

js 复制代码
//所属文件:\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解析时遇到结束标签,从栈中弹出相应的开始标签,是为了方便建立标签间的父子关系的。

html 复制代码
<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函数的递归调用示意图

js 复制代码
//所属文件:\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函数

js 复制代码
//所属文件:\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函数

js 复制代码
//所属文件:\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函数

js 复制代码
//所属文件:\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函数

js 复制代码
//所属文件:\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的值

json 复制代码
{
    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]))])])}

js 复制代码
//所属文件:\core\instance\render.js

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

Demo71 流程图1中的步骤⑤

js 复制代码
//所属文件:\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等函数的定义

js 复制代码
//所属文件:\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。

js 复制代码
//所属文件:\core\vdom\vnode.js

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

Demo74 createTextVNode函数

json 复制代码
{
	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。

js 复制代码
//所属文件:\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函数

js 复制代码
//所属文件:\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函数

json 复制代码
{
        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中不存在,则会给出错误警告。

js 复制代码
//所属文件:\core\instance\render.js

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

Demo79 流程图1中的步骤⑤

图28 简单案例Demo1在debug时res.render的值

js 复制代码
//所属文件:\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方法定义

js 复制代码
//所属文件: \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段)。

js 复制代码
//所属文件:\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是数组,重写数组部分方法

js 复制代码
//所属文件:\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更新也是异步的。

js 复制代码
//所属文件: \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函数

js 复制代码
//所属文件:\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函数

js 复制代码
//所属文件:\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时定义

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函数

js 复制代码
//所属文件: \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次,比较耗时。异步调用是从性能上考虑的。

js 复制代码
setTimeout(()=>{console.log("setTimeout")}, 0);
console.log("After setTimeout");

//控制台输出:
//After setTimeout
//setTimeout

Demo89 setTimeout的异步执行

html 复制代码
<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中相应元素的次序。在逐个判断新子节点数组的子节点是否存在同类型的旧子节点时,先对新子节点数组和旧子节点数组的首尾节点进行判断,这在某些场景下可以提高运行效率。

js 复制代码
//所属文件: \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.

相关推荐
Backstroke fish9 小时前
Token刷新机制
前端·javascript·vue.js·typescript·vue
小曲程序9 小时前
vue3 封装request请求
java·前端·typescript·vue
Lysun00110 小时前
[less] Operation on an invalid type
前端·vue·less·sass·scss
程序视点17 小时前
【Vue3新工具】Pinia.js:提升开发效率,更轻量、更高效的状态管理方案!
前端·javascript·vue.js·typescript·vue·ecmascript
刚刚好ā18 小时前
js作用域超全介绍--全局作用域、局部作用、块级作用域
前端·javascript·vue.js·vue
ZwaterZ21 小时前
vue el-table表格点击某行触发事件&&操作栏点击和row-click冲突问题
前端·vue.js·elementui·c#·vue
ZwaterZ1 天前
el-table-column自动生成序号&&在序号前插入图标
前端·javascript·c#·vue
木子七1 天前
vue2-vuex
前端·vue
小小黑0071 天前
uniapp+vue3+ts H5端使用Quill富文本插件以及解决上传图片反显的问题
uni-app·vue
Ztiddler1 天前
【npm设置代理-解决npm网络连接error network失败问题】
前端·后端·npm·node.js·vue