JavaScript引擎浅析

前言

该文章是为大家整理一个关于js的知识网络,重点是知识的罗列及之间的联系,所以实例可能会有所不足,导致可能没有对应知识的人看不懂,希望大家能够结合其他资料来学习这篇文章,并整理出自己的知识体系。

ok,我们开始。

JavaScript引擎

JavaScript是解释型语言,这就是说它无需编译,直接由JavaScript引擎直接执行。

既然说到了解释型语言,那么我们就来分别以下解释型语言和编译型语言的差别:

  • 编译型语言:程序在执行之前需要一个专门的编译过程,把程序编译成为机器语言的文件(即exe文件),运行时不需要重新编译,直接用编译后的文件(exe文件)就行了。
    • 优点:执行效率高
    • 缺点:跨平台性差
  • 解释型语言:程序不需要编译,程序在运行的过程中才用解释器编译成机器语言,边编译边执行(没有exe文件)。
    • 优点:跨平台性好
    • 缺点:执行效率低

其中程序无需编译,不是说真的不需要编译了,直接执行脚本字符串。而是说不需要在运行之前先编译程序成为exe文件,而是在运行的过程中边运行边执行。

JavaScript解析执行过程

ok,我们回到JavaScript的解析执行过程。

在整体上,JavaScript的解析执行过程分为两个步骤:

  1. 编译
  2. 运行

其中,编译是在解释器中进行,将代码编译成可执行码。运行是在JavaScript引擎中进行,执行可执行码。

过程如下:

编译过程

编译过程不必多说,我们只要清楚这个过程会将字符串代码编译为可执行码。

执行过程

重点是运行过程,运行又由两个过程组成

  1. 预解析
  2. 执行

预解析

预解析的工作是

  1. 收集变量
  2. 分号补全
变量收集

重点注意收集变量这一功能,又名为变量提升,收集的变量有以下三种:

  1. var声明的变量,初始值为undefined
  2. arguments参数,值为传入的实参
  3. function声明定义

若是变量名有重复的话,按照优先级来确定:

function声明定义>函数参数>var声明的变量

tips:

  1. let和const声明的变量不会在预解析阶段变量提升,只有在执行阶段执行到该行时才会声明该变量
  2. 当我们给一个未声明的变量赋值时,JavaScript引擎会认为我们是要声明一个全局变量。但如果我们访问一个为声明的全局变量,会报错
  3. var a = function(){},变量提升时,a是值为undefined的变量而不是函数定义

分号补全

JS执行是需要分号的,但为什么以下语句却可以正常运行呢?

	console.log('a')
	console.log('b')

正是因为预解析阶段会进行分号补全操作。

列举几条自动加分号的规则:

  • 当有换行符(包括含有换行符的多行注释),并且下一个token没法跟前面的语法匹配时,会自动补分号。
  • 当有}时,如果缺少分号,会补分号。
  • 程序源代码结束时,如果缺少分号,会补分号。

不过若是以下的情况,必须得加上';',否则的话,会出现报错。

  • 如果一条语句以"(","{","/","+","-"开始,当前一条语句没有用;结尾的话,就会与前一条语句合在一起解释

还有,其实所有代码都可以写在一行中。只要有';'来分隔开每一句就ok。并且,if及for及while的函数体也可以写在同一行中。

只要做好分隔工作,那么就都可以写在同一行。

变量类型及内存分配

JavaScript有6种数据类型(暂且不论symbol):Number,Boolean,String,Null,Undefined,Object

其中,分为两大类别

  1. 基本数据类型:Number,Boolean,String,Null,Undefined。存储于栈内存中
  2. 引用数据类型:Object(Object数据类型由Function,Object,Array组成)。存储于堆内存中

tips:

  1. 栈内存的数据结构为栈,堆内存的数据结构为树
  2. null为空对象指针,undefined为未初始化

关于不同类型的数据是如何存储在内存的,参考下图:

需要特别注意的是,如下:

	var a = {name:'Bob'}

变量a存储的值不是该对象,而是该对象在堆内存中的地址。

再看下面两道题:

	// demo01.js
	var a = 20;
	var b = a;
	b = 30;

	// 这时a的值是多少?
	// demo02.js
	var m = { a: 10, b: 20 }
	var n = m;
	n.a = 15;

	// 这时m.a的值是多少

在变量对象中的数据发生复制行为时,系统会自动为新的变量分配一个新值。var b = a执行之后,a与b虽然值都等于20,但是他们其实已经是相互独立互不影响的值了。具体如图。所以我们修改了b的值以后,a的值并不会发生变化。

在demo02中,我们通过var n = m执行一次复制引用类型的操作。引用类型的复制同样也会为新的变量自动分配一个新的值保存在变量对象中,但不同的是,这个新的值,仅仅只是引用类型的一个地址指针。当地址指针相同时,尽管他们相互独立,但是在变量对象中访问到的具体对象实际上是同一个。如图所示。

因此当我改变n时,m也发生了变化。这就是引用类型的特性。

内存空间管理及垃圾回收机制

内存是有限的,所以分配的内存必须得在适当的时机回收以供后继使用。

内存的生命周期为:

  1. 为变量分配所需要的内存空间
  2. 使用分配到的内存
  3. 不需要时将其释放回收

这第三步对应的就是垃圾回收。

那么JavaScript引擎是如何判断该内存需不需要释放呢——标记清楚机制。

垃圾回收器每隔一段时间都会检查一次内存,找到其中失去引用的变量,并释放掉。

其中失去引用一般有两种原因

  1. 函数执行完,局部变量没有存在的必要
  2. 没有变量指向堆内存的对象

tips:

  1. 减少使用全局变量,因为全局变量对应着主函数,除非关闭该页面,否则主函数一直不会弹出调用栈,使得全局变量在关闭页面前一直不会被释放

深拷贝和浅拷贝

浅拷贝开辟一个新的内存空间,仅拷贝第一层对象内容,深拷贝也开辟一个新的内存空间,拷贝所有层对象堆内容。

	var arr1 = [2,4]

	let arr2 = arr1

	console.log(arr1==arr2)//true

这就是最常见的,但还不是浅拷贝,那如果我们这样呢?

	var arr1 = [2,4]

	var arr2 = []

	for(let i in arr1){//该复制方式既适用于复制数组又适用于复制对象
		arr2[i]=arr1[i]
	}

	console.log(arr1==arr2)//false

这样输出的是false,也就是说arr1和arr2指向的地址不同。那么这样就是深拷贝了吗?

不是的。如果arr1的成员中有个对象呢?那么对该对象的复制就是浅拷贝。

那么究竟如何才能做到浅拷贝呢?使用递归,每一次递归进行一次如上的拷贝,直到当前层递归数据为非对象。

	var arr1 = [2,4,{name:'bob'},[323,4342]]

	function deepCopy(val){
		var arrSec
		val instanceof Array ?  arrSec =[] :  arrSec ={}
		for(let i in val){
			
			else if(val[i] instanceof Object){

				arrSec[i] = deepCopy(val[i])
			}
			else{
				arrSec[i] = val[i]
			}
		}
		return arrSec
	}

	var arr2 = deepCopy(arr1)

无需底层实现的浅拷贝与深拷贝:

浅拷贝:(以下方法仅适用于数组的浅拷贝)

  1. var arr2 = [...arr1]
  2. var arr2 = arr1.slice()
  3. var arr2 = arr1.concat([])//不常用

深拷贝:(这个方法既适用于对象又适用于数组)

  1. var obj2/arr2 = JSON.parse(JSON.stringify(obj1/arr1))

变量类型比较

使用typeof来检测基本类型,用instanceof来检测对象还是数组

数据类型有:

number,string,boolean,null,undefined,object,function,array

typeof

typeof一般只能返回如下结果:

number,string,boolean,object(null,object,array),function,undefined

tips:

  1. typeof未定义的远算数,会返回undefined

instanceof

由于引用类型的数据用typeof返回的都是object(除function),所以我们用instanceof来判断究竟是什么引用类型(这个说法不是很严谨,大家可以不要记忆这个概念)。

instanceof的使用一般是左值为对象,右值为构造函数。

判断方法如下:

沿着左值对象的__proto__这条线走,并且沿着右值构造函数的prototype这条线走,只要两者能够交叉,即同一个对象,那么就返回true。如果__proto__这条线已经走到头了,还未交叉,则返回false。

所以说,与其说instanceof判断的是什么引用类型,倒不如说是判断是否有继承关系。

执行上下文

大家应该都有接触过函数调用栈吧,执行上下文就是每次压入栈的内容。

执行上下文可以理解为当前代码的执行环境,它会形成一个作用域。

JavaScript的执行环境大致可以分为三种:

  1. 全局执行环境:JavaScript代码运行起来会首先进入该环境
  2. 函数执行环境:当函数被调用时,会进入当前函数的环境
  3. eval(不建议使用,忽略)

所以,JavaScript只有全局作用域及函数作用域。

所以,我们可以这样理解——当开始执行JavaScript代码时,会创建一个全局上下文。每当执行一个函数,就会创建一个函数执行上下文。

JavaScript引擎会以栈的方式处理它们,这个栈我们称为函数调用栈。栈底永远是全局上下文,栈顶就是当前正在执行的执行上下文。

所以,统一一下——当开始执行JavaScript代码时,创建一个全局上下文,压入函数调用栈。每当执行一个函数,就会创建一个函数执行上下文,压入函数调用栈。当函数执行完,该执行上下文弹出栈。直到关闭该页面,才会弹出全局上下文。

tips:

  1. 在此,我可以把JavaScript代码解析执行的过程细化一下
    • 当开始执行JavaScript代码时,会编译全局作用域的代码,然后预解析其可执行码(变量提升,分号补全),然后执行。当执行完后,再次编译函数作用域的代码,然后预解析其可执行码(变量提升,分号补全),然后执行。以此类推,直至执行完JavaScript代码。
    • 这才是JavaScript代码边编译边运行的具体过程,而不是编译一行,执行一行

执行上下文的组成

执行上下文的生命周期可以分为两个阶段:

  1. 创建阶段

    • 在这个阶段中,执行上下文会分别创建变量对象,建立作用域链,以及确定this的指向。
  2. 代码执行阶段

    • 创建完成之后,就会开始执行代码,这个时候,会完成变量赋值,函数引用,以及执行其他代码。

执行上下文由三部分组成:

  1. 变量对象
  2. 作用域链
  3. this

变量对象(VO variable Object)

该对象存储的就是变量提升的arguments参数,var声明的变量,函数声明。

在未进入执行阶段时,变量对象(VO variable Object)中的属性都不能访问。但在进入执行阶段时,变量对象转换为了活动对象(AO active Object),里面的属性都能被访问。

VO和AO其实都是一个对象,只是处于执行上下文的不同生命周期。只有在函数调用栈的顶部执行上下文的变量对象才会变成变量对象。

作用域链

由该环境和所有父环境的变量对象组成的链式结构,保证了当前执行环境对符合访问权限的变量和函数的有序访问

我们通过作用域链,遍历自身的变量对象到全局对象,直到找到对应的变量。

理解作用域链非常关键,这是理解闭包的基础。

执行上下文和作用域是两个完全不同的概念。作用域是在编译阶段就确定下来的,执行上下文是在执行阶段才能够创建的。

不过,切记,当前作用域和上层作用域不是包含关系。

闭包

关于垃圾回收机制,有一个重要的行为,那就是,当一个值,在内存中失去引用时,垃圾回收机制会根据特殊的算法找到它,并将其回收,释放内存。

而我们知道,函数的执行上下文,在执行完毕之后,生命周期结束,那么该函数的执行上下文就会失去引用。其占用的内存空间很快就会被垃圾回收器释放。可是闭包的存在,会阻止这一过程。

实现闭包的操作:

  1. 函数A嵌套在函数B以内
  2. 函数B返回函数A

闭包的核心就是——通过在外部函数(B)的外部(C)保存内部函数(A)的引用,当执行该引用(A)时,由于创建的执行上下文的作用域链中包含有外部函数(B)的引用,从而使外部函数(B)的执行上下文不会被垃圾回收。

  • 函数C中声明函B,函数B中声明函数C

这样就能保存之前执行函数B的操作结果。这样的话,就可以在其他的执行上下文中,操作到函数B的操作结果。

要切记哦:虽然函数A被保存在了函数C中,但函数A的作用域链并没有变化,千万不要把作用域链和函数调用栈混在一起了。在闭包中,能访问到的仍然是作用域链上能查询到的数据。

闭包返回的作用域链中,中间层及之前层的都是不变的内存区域,只有最高层的变量对象是每次调用函数的时候新创建的变量对象。

闭包的好处
  1. 读取函数内部的变量
  2. 这些变量的值保存在内存里,不会在外层函数调用后自动删除
闭包的坏处
  1. 如若闭包为全局变量,会造成内存泄漏

this

关于this的指向一直是大家比较头疼的地方,似乎很难找到一个确切的标准。但this的指向还是有标准的,且往下看。

this的执行是在调用函数,即执行上下文创建时才能确定的,判断标准如下:

  1. new绑定:this指向新创建的对象
  2. call,apply绑定:this指向指定的对象
  3. 显式绑定:this指向调用该函数的对象
  4. 默认绑定:非严格模式下,this指向全局对象,严格模式,this为undefined
    • 全局代码中的this永远指向全局变量
new

new到底做了什么呢?

  1. 创建一个空对象
  2. 将构造函数的this指向该对象
  3. 执行构造函数,从而能够使用构造函数中的this为该新对象添加属性
  4. 将该对象的引用返回
call,apply

这两者的作用都是修改函数中的this指向,功能一直,只是参数的写法有稍许不同。

call传参需要一个一个地传,而apply传参是传一个数组。

而bind和call,apply的区别在于:

call,apply会直接执行。bind是在函数调用之前,改变this的指向,它会返回一个函数。

箭头函数下的this

箭头函数是ES6的新语法,形式如下:

	(参数部分)=>{
		函数体部分
	}

其中,如果参数只有一个,则可以省略括号。如果没有参数或多个参数,括号不能省略。

箭头函数中this指向规则与普通函数的规则不同,他的this指向规则为:

捕获其所在(即定义的位置)上下文的this值, 作为自己的this值,

tips:

  1. 注意,在箭头函数中普通函数的this指向规则都失效了,也就无法使用apply,call,bind来改变函数中的this指向了。
  2. 箭头函数无法访问参数对象arguments
  3. 在对象中的方法千万不要用箭头函数来定义,因为该方法中的this不执行该对象
	function Person() {  
		console.log(this) 
		setTimeout(() => {
			// 回调里面的 `this` 变量就指向了期望的那个对象了
			console.log(this)
		}, 3000);
	}

	var p = new Person();

普通函数的this指向和setTimeout中的箭头函数的指向都是Person对象。

面向对象式编程,继承,原型链

JavaScript是一门面向过程的语言,但随着网页需求功能的复杂化,工程化,要求JavaScript应该也有面向对象编程的能力。

字面量对象

我们可以通过字面量对象来创建一个简单对象

	var obj = {}

当我们想要给我们创建的简单对象添加方法时,可以这样表示

// 可以这样
	var person = {};
	person.name = "TOM";
	person.getName = function() {
		return this.name;
	}

	// 也可以这样
	var person = {
		name: "TOM",
		getName: function() {
			return this.name;
		}
	}

访问属性的时候,可以用一下两种方式

	person.name

	// 或者
	person['name']

当我们想要用一个变量值来作为属性名来访问属性,就用第二种方法。

工厂模式

使用上面的方式创建对象很简单,但是在很多时候并不能满足我们的需求。就以person对象为例。假如我们在实际开发中,不仅仅需要一个名字叫做TOM的person对象,同时还需要另外一个名为Jake的person对象,虽然他们有很多相似之处,但是我们不得不重复写两次。

	var perTom = {
		name: 'TOM',
		age: 20,
		getName: function() {
			return this.name
		}
	};

	var perJake = {
		name: 'Jake',
		age: 22,
		getName: function() {
			return this.name
		}
	}

显然,这样是很不合理的,当有太多的相似对象,编写代码会极为痛苦。

这就引出了工厂模式。

工厂模式就是你给出原料,然后返回给你产品。

看代码:

var createPerson = function (name,age){
	//创建一个中间对象
	var obj = new Object()
	
	obj.name = name
	obj.age = age
	obj.getName = function(){
		return this.name
	}
}

var Tom = createPerson('Tom',18)
var Cherry = createPerson('Tom',40)

不要把工厂模式想的太高大上。显然,工厂模式帮我们解决了重复编码的麻烦,但是他还有一个问题

无法识别工厂模式返回的对象的类型。(其次还有每次返回对象都得为方法分配一个新的内存空间,浪费资源)

如上述代码,Tom和Cherry指向的对象类型都是Object类型。

构造函数

首先构造函数就是个普通的函数,其本身没有什么特别的地方。

但构造函数的特殊之处就在于用new创建一个对象,构造函数对该对象的属性进行添加。

new的具体过程在上文有详细提到,就不赘述了。

就这样,new关键字+构造函数就能够创建出一个有属性的对象,且还能够识别对象类型。

但是又有一个问题来了:

所有用该构造函数创建的对象访问的方法实现是一模一样的,但是每次new的时候都会在内存中分配一片新的空间以保存变量的特性和方法。

显然这是不合理的,既然访问的是同一个方法实现,那么为什么不能每个实例对象都访问同一块内存里的方法呢?

原型对象

我们创建的每一个函数,都有prototype属性指向原型对象,可以选择在原型对象里挂载属性和方法,这样每创建一个对象,都可以通过__proto__访问到原型对象,也就不需要再为这些属性和变量分配空间了。

由于每个函数都可以是构造函数,每个对象都可以是原型对象,因此如果在理解原型之初就想的太多太复杂的话,反而会阻碍你的理解,这里我们要学会先简化它们。就单纯的剖析这三者的关系。

// 声明构造函数
	function Person(name, age) {
		this.name = name;
		this.age = age;
	}

	// 通过prototye属性,将方法挂载到原型对象上
	Person.prototype.getName = function() {
		return this.name;
	}

	var p1 = new Person('tim', 10);
	var p2 = new Person('jak', 22);
	console.log(p1.getName === p2.getName); // true

如图

通过图示我们可以看出,构造函数的prototype与所有实例对象的__proto__都指向原型对象。而原型对象的constructor指向构造函数。

可以这样理解:

构造函数中this添加的属性和方法是私有属性和方法(虽然这个私有属性和方法能够被外界直接取到),原型对象中的属性和方法是共有属性和方法。

当我们访问实例对象中的属性或者方法时,会优先访问实例对象自身的属性和方法,即私有属性和方法。如若找不到,则去原型对象中寻找。

继承与原型链

原型链如图:

每一个对象既可以作为原型对象,又可以作为实例对象,而且有可能既是实例对象又是全局对象,这样的一个对象正是原型链中的一个节点。

继承分为两个步骤:

  1. 在子构造函数中复制父构造函数添加的属性和方法。即让父构造函数中的操作在子构造函数中重现一遍
  2. 子级的原型对象指向父构造函数的实例对象、

具体代码如下:

var Person = function(name){
	this.name = name	
}

Person.prototype.getName = function(){
	return this.name
}

var cPerson = function(name,age){
	Person.call(this,name)
	this.age = age
}

cPerson.prototype = new Person('名称')

//在子级的原型里添加更多的方法
cPerson.prototype.moreFunc = function(){
	console.log('更多的方法')
}

var p = new cPerson('Tom',18)

该继承方案有一个问题,就是

子级的原型对象是父构造函数的实例对象,这样的话,我们就调用了两次父级的构造函数(子级对象中的将子级原型对象中的给屏蔽了)。

子级原型对象为父级实例对象,实际目的仅是父级实例对象中的__proto__,从而形成原型链来寻找属性和方法。

重复调用两次父级的构造函数是没有意义的,所以我们改进一下代码。

var Person = function(name){
	this.name = name	
}

Person.prototype.getName = function(){
	return this.name
}

var cPerson = function(name,age){
	Person.call(this,name)
	this.age = age
}

(function (){
	var Super = function (){}
	
	Super.prototype = Person.prototype
	
	cPerson.prototype = new Super()
	
	//在子级的原型里添加更多的方法
	cPerson.prototype.moreFunc = function(){
		console.log('更多的方法')
	}
})()

var p = new cPerson('Tom',18)

这就是继承的最终解决方案了。

ES6新特性

let和const

前面说过,js只有全局作用域及函数作用域,没有块级作用域。

但是let和const会引入块级作用域。

这两者的特点为:

  1. 不会在预解析阶段变量提升,只有当执行到该行时才会创建let或const变量
  2. 会引入块级作用域
  3. 不可重复命名,否则报错

我们常常使用let来声明一个值会被改变的变量,而使用const来声明一个值不会被改变的变量,也可以称之为常量。

模板字符串

以一个例子比对一下大家就知道了

	// es6
	const a = 20;
	const b = 30;
	const string = `${a}+${b}=${a+b}`;

	// es5
	var a = 20;
	var b = 30;
	var string = a + "+" + b + "=" + (a + b);

使用``将整个字符串包起来,在其中使用${}包裹一个变量或表达式

解析结构

同样,以一个例子来解释

	// 首先有这么一个对象
	const props = {
		className: 'tiger-button',
		loading: false,
		clicked: true,
		disabled: 'disabled'
	}

	// es5
	var loading = props.loading;
	var clicked = props.clicked;

	// es6
	const { loading, clicked } = props;

	// 给一个默认值,当props对象中找不到loading时,loading就等于该默认值
	const { loading = false, clicked } = props;

是不是很简单,就是将访问属性与变量命名在写法上合并为一步。

另外,数组也有属于自己的解析结构

// es6
	const arr = [1, 2, 3];
	const [a, b, c] = arr;

	// es5
	var arr = [1, 2, 3];
	var a = arr[0];
	var b = arr[1];
	var c = arr[2];

数组以序列号一一对应,这是一个有序的对应关系。
而对象根据属性名一一对应,这是一个无序的对应关系。

展开字符串

在ES6中用...来表示展开运算符,它可以将数组或者对象进行展开。

	const arr1 = [1, 2, 3];
	const arr2 = [...arr1, 10, 20, 30];

	// 这样,arr2 就变成了[1, 2, 3, 10, 20, 30];

	const obj1 = {
	  a: 1,
	  b: 2,
	  c: 3
	}

	const obj2 = {
	  ...obj1,
	  d: 4,
	  e: 5,
	  f: 6
	}

	// 结果类似于 const obj2 = Object.assign({}, obj1, {d: 4})

展开字符串还可以运用在参数中

对象字面量和class

对象字面量

ES6对对象字面量做了简化语法的处理。

  1. 当对象字面量的属性和值的变量同名时,可以省略值的声明
  2. 除了属性以外,对象字面量的方法也可以简写
  3. 在对象字面量中可以用[]作为属性名,表示属性名也可以用变量
var name = 'Bob'
var age = 20
var arc = 'sex'
var person = {
	name,
	age,
	[arc]:'male'
	getName(){
		return this.name
	}
}

class声明类

class是ES6的新功能。JavaScript创建类的方式是构造函数,这对于普通的面向对象程序员来说太过别树一帜了。

所以,ES6模拟普通面向对象的类-对象编写代码方式,创造了class。

class实际上就是一个var声明变量,其指向其中的constructor构造函数,只是在原先的构造函数创建类的模式做了形式上的变形,使更符合JAVA的类-对象模式。

知识点:

  1. 其中,constructor函数就是原先的构造函数,当new一个对象的时候就是默认调用这个函数,因为类名就是指向的constructor函数。

  2. 类中声明的方法,都是定义在该构造函数的prototype对象中。相当于说,只要是类中的方法,都是使用构造函数.prototype.方法名=...来声明。

    • 所以我们可以在声明了类之后,通过类名.prototype.方法名=...来在类外动态为类定义方法。
    • 同时,我们还可以用Object.assign(类名.prototype,字面量对象-就是一个对象,用{}来声明)。该字面量对象不会覆盖掉类中的其他方法,所做的工作是添加方法
  3. 如果你没有显式定义一个constructor,那么会隐式生成一个constructor方法,当new的时候调用,该方法返回this。由于new返回对象由构造函数及new执行,所以如果你想要返回的实例对象不是该类的实例,可以在里面return new A()

  4. class中只允许有方法,不允许声明变量,跟JAVA不一样

  5. 只有在类中constructor中用this指定的属性+方法才是实例属性+实例方法,在类中定义的方法是原型方法。然后在类外用那两种方法声明的属性+方法都是原型属性+原型方法

  6. 类的所有实例都共享一个原型对象,而同时类的所有实例又可以通过__proto__来修改原型对象内容,但千万别这样,因为这样会影响到别的该类的实例对象,不符合业务逻辑

  7. class类不会变量提升

注意:

  1. 在类中声明方法时,不要加上function,相当于强制使用对象字面量吧
  2. 在类中声明函数,函数之间不要用,隔离
class类继承

使用extends关键字。

然后在子的constructor函数中使用super()来调用父的构造函数,从而复制父的实例属性。继承过后,通过原型链,可以访问到父级的所有原型链(即可访问到以往所有被用prototype声明的对象)内容。

super在子类定义中有两种使用方法:

  1. 当做构造函数使用。super()等同于A.prototype.constructor.call(this,props)
  2. 当做对象用,则是父的原型对象+类。所以父的实例属性是无法访问到的

注意:

  1. 使用super的时候,必须显式指定是作为函数(super()),还是作为对象(super.某)使用,否则会报错(如console.log(super))
  2. super作为对象使用时,调用父级的方法,super会绑定子类的this
static

静态方法的意义是:在该类所有实例之上,只属于类本身的方法,不是具体某个实例的方法,也不是所有实例都有的方法。

切记:静态方法的意义不是省空间(这是原型方法的意义,而且实例根据原型链访问不到静态方法),而是一个能够统领全局(中枢权)的方法。

由于静态方法中的this指向类本身,所以我们无法使用this来访问到实例属性,方法。但是,我们可以通过创建一个实例对象,从而访问到实例属性,方法。

class Foo{
	prop:2//错
	static prop:2//错
	prop=2//错
	var prop = 2//错
}

关于私有,静态,原型,实例属性和方法,在类中get及set声明函数代码如下:

var prot = 'prot'

class MyClass {
   

   constructor() {
   	this[bar]()


   	//类内  构造函数内  public实例属性,方法
   	this.entity = 0
   	this.entityFunc = ()=>{
   		MyClass.staticFunc2()
   	}

   	this.entityFunc2 = ()=>{
   		console.log('aa')
   	}


   	//类内  构造函数内  私有属性,方法
   	var privat = 8
   	var privateFunc = function(){return 6}
   	this.outFunc = function(){
   		//操作私有变量,使用私有函数
   		privat = 88
   		privateFunc()
   	}


     console.log(this.name); // 42
   }
   // 类内  get,set拦截读写
   get prop(){
   	return 9
   }

   set prop(par){
   	this._prop = 8
   }

   //类中  构造函数外  原型方法
   [prot](){}

   
 }
//类外  静态属性,方法
MyClass.static = 9
MyClass.staticFunc = function(){
	console.log('static')
	MyClass.staticFunc2()
	
}



MyClass.staticFunc2 = function(){
	console.log('static2')
}

//类外  原型属性,方法
MyClass.prototype.prott = ()=>3
MyClass.prototype.prottt = 5

console.log(Object.getOwnPropertyDescriptor(MyClass,'staticFunc2'))

console.log(MyClass.name)

tips:

  1. class声明的类不能够不适用new就直接使用,但构造函数声明类的方式可以不适用new,但这样的话,该构造函数的this就是指向全局对象,从而直接调用构造函数时,往全局对象里增添属性,方法。
    • 我们在构造函数中使用new.target是否为false来判断是否使用new来调用该构造函数,从而规避刚刚的问题
  2. 静态方法中的this指向的是类本身,而不是实例。在实例创建前,静态属性,方法已经存在了。我们无法做到不创建一个对象就使用静态方法访问到实例属性
  3. This会慢慢叠加在一起,静态方法也会慢慢叠加,但原型不会叠加在一起
  4. 静态方法不能被实例对象调用,实例方法不能被类调用
  5. 父类的静态方法,可以被子类继承
  6. class类中不能够声明变量,只能声明方法
  7. 静态字段得等到程序关闭了才能释放。静态的要比非静态的先加载
  8. ES6声明的原型方法不能枚举,但用ES5的构造函数原型方法能够枚举
  9. 类名.name能够得到该类名称
  10. 在ES6中能够使用变量来作为属性名,[变量]:值,当要调用这个属性时,对象[变量]
  11. get,set配对声明,相当于声明一个完整的属性,相当于拦截该属性的存取行为,可用此实现一个私有变量
  12. 也可以在构造函数上声明静态属性和方法
  13. 原型对象是一个空的对象,当声明类的时候就创建出来的,由类来定义该对象内容,并将其prototype指向另一个原型对象(所以原型对象是由该类创建的,而不是由父类创建的)(修改prototype其实是打断原来的原型链,重建一个新的原型链)
对象中声明方法与class中声明方法的差别

class中声明方法:

	class Box{
		constructor(msg){
			this.msg = msg
		}

		save(){
			console.log(this.msg)
		}
	}

对象中声明方法:

var Box = {
	save:function(){
		console.log('aaa')
	}
}

Promise

我们要保证异步的顺序执行,在以往都是通过回调函数来实现。

然而回调函数有两个缺陷:

  1. 回调里面套回调,会导致“回调地狱”
  2. 如果使用回调的话,数据请求与数据处理没有分开来

这两者都会导致阅读源码的困难。

所以,我们需要新的方案能够有以下两个特点:

  1. 用扁平式代码结构代替嵌套式代码结构
  2. 数据请求与数据处理泾渭分明地区分开来

Promise能做到这两点。

Promise对象有三种状态:

  1. pending:等待中,或者进行中,表示没有得到结果
  2. resolved:已经完成,表示得到了我们想要的结果,可以继续往下执行
  3. rejected:也得到结果,但由于结果并非我们所愿,所以拒绝执行

这三种状态不受外界影响,而且状态只能是从pending到resolved或者pending到rejected。我们通过Promise构造函数中的第一个函数参数来处理状态改变。

	new Promise(function(resolve, reject) {
		if(true) { resolve() };
		if(false) { reject() };
	})

tips:

  1. 每当new一个Promise对象时(包括return一个刚new出来的Promise对象),就会立即执行其中的参数函数(不需要异步执行该参数函数)
  2. 上面的resolve和reject都作为一个函数,作用分别是将状态修改为resolved或rejected

then

Promise对象中的then方法,可以接受到Promise对象的状态变化。then方法有两个参数,第一个参数接受resolved状态的执行,第二个参数接受rejected状态与异常状态的执行。

又因为then方法执行完后会返回一个新建的Promise对象,所以可以继续用then来接受Promise对象的状态变化,并执行相应函数。这也是扁平式代码结构的核心。

catch

catch方法是.then(null, rejection)的别名,用于接受rejected状态与异常状态的执行。

Promise对象的rejected状态与异常状态具有冒泡性质,一直向后传递,直到被捕获为止。

then与catch的参数传递

	var fn = function(num) {
		return new Promise(function(resolve, reject) {
			if (typeof num == 'number') {
				resolve(num);
			} else {
				reject('TypeError');
			}
		})
	}

	fn(2).then(function(num) {
		console.log('first: ' + num);
		return num + 1;
	})
	.then(function(num) {
		console.log('second: ' + num);
		return num + 1;
	})
	.then(function(num) {
		console.log('third: ' + num);
		return num + 1;
	});

	// 输出结果
	first: 2
	second: 3
	third: 4

  1. then中第一个参数函数的参数是前面Promise对象resolve()执行中的参数值
  2. then中第二个参数函数的参数和catch的参数函数的参数是前面Promise对象reject()执行中的参数值
  3. then方法执行完会返回一个Promise对象,如若没有异常,该Promise对象会执行resolve(),其中的参数是then方法中return的值。如果有异常,则该Promise对象会执行reject(),其中的参数是then方法中return的值

then与catch的微任务队列异步机制

new一个Promise对象时是立即执行其中的执行函数,但是then方法与catch方法中的参数函数是异步执行的。

即运行代码的过程中遇到then方法和catch方法,需要挂起其对应的参数函数,直到执行栈为空,才执行微任务队列中的任务。(顺带提一句,直至当前的微任务队列清空,下一个宏任务才能进栈)

Promise.all()与Promise.race()

	Promise.all([Promise.resolve(4),5,new Promise((resolve,reject)=>resolve(8))]).then(val=>console.log(val))

切记,参数为一个数组,都由Promise对象组成。且传递给then的参数是所有Promise对象resolve的值组成的数组。如果参数中有一个Promise对象失败的话,则将第一个失败的reject值传递给catch。

	Promise.race([Promise.resolve(4),5,new Promise((resolve,reject)=>resolve(8))]).then(val=>console.log(val))

同样的,参数是一个由Promise对象组成的数组,传递给then的值是第一个完成的Promise中resolve的值。

async,await

但是,Promise这个异步方案还不够完美,我们异步的最终目标就是以同步的书写方式书写异步。

酷吧!就是说我们并不关心他是不是异步,不管是同步还是异步我们都以从上到下的书写方式来书写代码,这才是人类的思维方式啊!

所以也就产生了async这个异步解决方案了。

需要注意的是,async是基于Promise的,即他不是一套独立的解决方案,而是基于Promise的异步机制的一次进步,代替then()的写法。

先声明几个重要的知识点:

  1. 每个async函数执行后返回的都是Promise对象,即每当执行一个async函数都会创建一个Promise对象并返回
  2. await的意义为等待其对应的Promise对象的异步任务执行完,否则将接下来的异步任务挂起,跳出async函数,执行函数外的代码。
  3. 统一用try{},catch(err){}来捕捉异常和reject,替代catch
  4. await关键字只能在async函数中使用
  5. 将async函数中的return值赋值给外部变量,可以做到异步任务之间的通信

异步执行顺序辨析

	async function async1(){
		console.log('async1 start')
		await async2()
		console.log('async1 end')
	}
	async function async2(){
		console.log('async2')
	}
	console.log('script start')
	setTimeout(function(){
		console.log('setTimeout') 
	},0)  
	async1();
	new Promise(function(resolve){
		console.log('promise1')
		resolve();
	}).then(function(){
		console.log('promise2')
	})
	console.log('script end')

执行顺序是这样的:

  1. 进入主代码,输出'async start',并将setTimeout的回调函数放入宏任务队列中
  2. 执行async1函数,调用栈压入async1执行上下文,输出'async1 start'
  3. 遇到await关键字,其后是执行async函数async2,输出'async2',同时返回一个新建的Promise对象,由于没有async2函数执行时没有出现异常,所以这个Promise对象中的执行函数执行resolve(),并将该Promise对象then方法放入微任务栈中
  4. 跳出async1函数。这一步我单独拿出来,是为了说明其特殊性。若是没有await关键字的话,那么不会退出async1函数,而是继续执行函数,输出'async1 end'。await能够保证下一步的代码是在上一步的所有异步任务执行完后才执行的。这也就能够保证每个Promise对象之间又是同步的
  5. 遇到了new一个Promi对象,立即执行其执行函数,输出'promise1',并且将then函数放入微任务队列中
  6. 输出'script end'
  7. 接下来到了重头戏,这个时候执行栈为空了,我们将微任务栈头取出放入执行栈中,即是第3步中的then方法,执行。没有输出,然后将await async2()之后的代码作为一个微任务放入微任务堆栈中。(这点也要特别注意,并不是直接同步执行下去,而是将接下来的代码作为一个微任务放入微任务队列)
  8. 将第5步中放入微任务队列中then放入执行栈执行,输出'promise2'
  9. 将第7步放入微任务队列的任务放入执行栈执行,输出'async1 end'
  10. 微任务队列清空,取出宏任务队列中的任务放入执行栈中执行,输出'setTimeout'
  11. 结束

tips:

  1. 切记,如果await后面跟的是new一个Promise对象的话,Promise对象中的执行函数一定要执行resolve()或reject(),否则直至退出程序都不会执行该async函数后面的代码

(完)

posted @ 2019-03-11 11:43  前端蔡蔡  阅读(2024)  评论(0编辑  收藏  举报