JS要点解析

前言

对JS学习过程中的一些理解和总结

从我工作到 2017/10 辞职,总共两年时间,期间一直想写个总结JS的文章,却一直没下决心动手,这次,借着辞职学习的时间,把这件事办了。也算是给自己一个交代。

对于前端开发来说,JS的确是一门很有魔力的语言。它不需要静态类型语言对变量类型的严格要求,也不需要编译型语言需要编译才能真正执行(期间,学习了Go语言,真的很喜欢Go语言,也终于让我有理由学习后端开发了。虽然Node也可以,但使用JS写Node服务,真的不太舒服,后端还是需要静态类型语言来做才最合适),这让它在Web前端风生水起(我看过Dart的语法,出自Google,如Go般简洁,前端后端都有支持,很希望Dart能替代JS,但是看现在的情况是不可能了)。

C语言等类C的静态编译型语言里面,程序要想执行,都会有一个入口函数main。但JS在html里面通过script的方式加载,script分割代码的分割问题,以及JS加载都是通过script为单位进行编译(JS的编译好像只有词法分析和语法分析)和执行的问题,致使js在标准上写不了main入口函数,所以JS提供了全局作用域这么个东西。相当于JS线程自己就有个main函数在跑一样。还有一点,每个script标签的代码执行是独立的,前一个报错,不会影响下一个的执行。

说到函数执行,这里就有几点需要提了。

JS函数传参调用的方式:
传名调用 or 传值调用:
JS是传值调用的,对于初学JS的小白来说,这让他们很矛盾。因为他们知道JS有基本类型,有引用类型。引用类型当作参数传入函数调用,如果按传值调用,引用类型传入的又是什么呢?传入的是引用类型的指针,又称内存地址,在JS里统一叫引用,也就是说JS不会把引用类型传过去(真传过去了,就是另一个对象了),而是把地址当作值传过去。

这里要说的一点是,JS函数传参后,arguments 和形参的关系:
如果函数调用时,相应的位置传入了实参,那么 arguments 和形参是互相影响的,也就是说,修改了一个,另一个也会相应的更改,并且是严格相等的。如果相应位置没有传入实参,则他们 arguments 和形参之间是互相独立的。至少在chrome上测试是这样的。

JS在执行之前,也就是词法分析和语法分析之后,还有一个预解析,预解析做的事就是先提升var声明的变量,初始值为undefined,再提升function声明的函数(直接提升整个函数体)。其实可以这么理解,var和function提升的是变量的创建和初始化,只不过var的变量的初始值为undefined。let const class 等提升的是创建,但并没有提升初始化,而变量只有在初始化之后才可以使用,可以解释了let const 形成的暂时性死区,而且const 没有赋值操作。

该轮到函数执行了。在JS主线程的函数执行会形成一个执行栈,函数里定义的变量就都在栈内存里存放着,基本类型保存的就是真实的基本类型的值,引用类型保存的就是引用类型的地址,而引用类型们都在堆内存里存放着。栈内存里变量的存放是有次序和有固定大小的,这也是为什么基本类型string boolean number 的值是不可更改的原因,如果有新值都是另外开辟新的内存来存放新值,至于引擎对基本类型的优化,应该就挺底层的了,我们也不要太过关注。堆内存里对象的存放都是没有结构次序,任意存放的,大小不固定的,所以可以修改对象的属性。

函数的执行链是个后入先出的过程,函数执行完毕后,它的执行栈会被弹出,也就是销毁,栈都没了,里面存放的变量自然就释放了。而堆内存里的引用类型们可不会因为函数执行完毕而销毁,它们会等待GC来回收他们。

GC回收引用类型,我知道的有两种方式,为了少些文字,我把引用类型在之后都叫做对象:

  1. 引用计数:会标记变量或属性对对象的引用个数,如果引用个数为0,就是说它不在被使用,那将会在下一次垃圾回收中回收。但引用计数有循环引用的问题,导致对象无法被回收,A和B互相引用,但它们都没有被其他变量所引用,理应回收他们,计数方式就不能解决这个问题。

代表有,IE8-的浏览器,这个比较特殊,IE8-的JS引擎用的是标记清除,但IE8-浏览器提供的DOM和BOM对象是使用C++以COM对象的形式实现的,而COM对象的垃圾回收机制使用的是引用计数,导致如果循环引用中存在COM对象,就会出现上述问题,IE9把COM对象变成了真正的JS对象,这个问题才得以解决。(出自JavaScript高级程序设计)

  1. 标记清除:现代的浏览器大都使用这种方式回收垃圾。它的策略是这样的:

标记阶段,从全局变量对象(根对象)向下遍历,对能从根对象访问到的对象,都添加标记,这些对象称为可达对象。

清除阶段,遍历堆内存,如果某个对象是可达对象,则清除标记,为下一次标记做准备,不可达对象直接回收内存。具体可以看这篇文章

接下来说下函数执行形成的作用域链:
先说下JS的两个链条:函数的作用域链,用于变量查找;对象的原型链,用于属性查找。
JS会在全局作用域产生一个全局变量对象,用来保存全局作用域内的变量,该对象很显然只会在页面关闭的时候才会销毁,因为这些变量会在之后的程序执行中被用到。

函数的定义:
引擎为函数添加 [[scopechain]] 属性,该属性为它当前所处位置的作用域链。如何获得这个链条,后面会说。

JS函数在执行的时候,会创建一个 {excution context} 执行环境对象,就是该对象决定了函数调用时候的this值(call, apply, bind 会改变this)。在创建该对象的时候,还会做以下几件事:

  1. 为函数添加 [[scope]] 属性,值为 scopechain
  2. 创建执行时的活动对象 {activation object} ,活动对象里有 arguments this 命名参数 变量对象。
  3. 把 {activation object} 添加到 [[scope]] 属性的顶部,也就是第一个。
  4. 当执行完毕,释放[[scope]],回收函数的 {activation object} 以及 {excution context]}

可以看出,函数在定义的时候会有一个作用域链属性(一个包含执行栈内所有执行函数的活动对象的数组),函数在执行的时候也会有自己的作用域属性(一个包含自身活动对象和执行栈内所有执行函数的活动对象的数组)。但函数定义在什么地方是写死的,所以函数的 scopechain 永远是不会变的。这就是为什么叫做词法作用域。

这里做两个作用域链的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// 第一种:
function a(){
function b(){
function c(){
function d(){};
d();
};
c();
};
b();
};
a();

//定义 a.scopeChain = [window]
//执行 a.scope = [a, ...a.scopeChain]
//定义 b.scopeChain = a.scope
//执行 b.scope = [b, ...b.scopeChain]
//定义 c.scopeChain = b.scope
//执行 c.scope = [c, ...c.scopeChain]
//定义 d.scopeChain = c.scope
//执行 d.scope = [d, ...d.scopeChain]

第二种:
a()
function a(){
b()
}
function b(){
c()
}
function c(){}

//定义 a.scopeChain = [window]
//定义 b.scopeChain = [window]
//定义 c.scopeChain = [window]

//执行 a.scope = [a, ...a.scopeChain];
//执行 b.scope = [b, ...b.scopeChain];
//执行 c.scope = [c, ...c.scopeChain];

等号右侧:

  1. window 指代得到是全局变量对象
  2. a, b, c, d 指的是函数各自在执行时生成的活动对象

到了该说闭包的时候了,成气候了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
a();
function a(){
var qq = 11;
function b(){
console.log(qq);
};
b();
}

function a(){
var qq = 11;
return function(){
console.log(qq);
};
}
var b = a();

闭包我这里想说个自己定义的概念,广义闭包,狭义闭包(爱因斯坦会不会打我)。

广义闭包就是函数内部没有使用外部作用域的变量的函数,或者使用了外部变量,但函数只在当前作用域内部执行的函数。之所以称它们为广义 闭包,是因为不管有没有使用外部变量,它们的身上总是引用着执行时刻执行栈内所有执行的函数的活动对象。同狭义闭包不一样的是,由于只在当前环境对函数有引用,所以等它所在的当前环境执行完毕,也就到了销毁它的时候,所以引用不引用所有的活动对象,就变得不重要了。

狭义闭包相反,狭义闭包就是我们常被问到必挂在嘴边的闭包。

第一种闭包是广义闭包,由于还是在父作用域体内,等a函数执行完毕,闭包离开执行环境,就会被回收;

第二种就是比较典型的狭义闭包,a内部的匿名函数形成闭包,并传递给b变量,如果a函数是在全局作用域,在不关闭页面的情况下只有把b赋值为null,解除引用,闭包才会被回收。如果a函数是在另一个函数体内,那么等这个父函数执行完毕,b离开执行环境,标记清除就会回收b引用的闭包。现代浏览器都比较智能,这一点基本可以放心,如果不放心,那就手动赋值为null吧。

上面说了闭包的产生和销毁,现在就来说说,我们整天念叨的闭包到底指的是什么。它又是和什么有关系的。

在之前我们已经说了函数定义和调用阶段所做的事。闭包就是和他们有关系。不管是函数定义上的 scopechain 还是执行时的 scope,它们都引用了祖辈函数执行的活动对象。广义闭包由于定义的函数在其父函数执行完毕后,就销毁了,所以不会一直保留对所有祖辈活动对象的引用。而狭义闭包,按理说函数执行完毕,内部定义的所有变量和函数都是要别回收的,但问题是如果把一个函数当成返回值返回了,返回之后还赋给一个变量,也就是说,该函数还要使用,没有被销毁,自然而然该函数身上的 scopechain 引用的活动对象数组也就不会释放,而这时scopechain里还引用着父函数的活动对象,也就是说本应该被销毁的函数和它的父函数的活动对象都没有被销毁,这就是闭包的根源,在外部引用了内部的函数,导致理应销毁的活动对象和函数没有被销毁。

闭包说完了,该说说我们在前面提到的 {excution context} 执行环境对象,也叫执行上下文对象。这个对象是谁呢?有一个技巧,就是函数是以谁的方法的身份执行的,那么这个对象就是谁,this就是谁。this的确定就这么简单。在严格模式下,由于var function 声明的变量和函数默认不再挂载到window上。所以函数的直接执行,并没有作为任何对象的方法的身份来执行,所以this为undefined也是说的通的。

函数重载的概念其实不是JS的。JS在理论上或者语法上没有这个概念。这个概念一般存在于传统的静态类型语言里。由于静态类型语言在定义函数时,函数的形参的类型都是需要确定的,所以如果同一个函数在调用的时候,如果有传入不同类型或者个数的参数都能处理的需求,那就需要函数重载,重载根据传入的参数的类型和数量的不同实现签名,特定的传参调用会调用特定的签名函数。反观JS,JS的函数也是对象,同一函数名在同一作用域下只能有一个,后面的会覆盖前面的,同一变量不可能指向多个地址。所以重载不可能,但JS也有办法处理这种需求,那就是函数的 arguments 对象 和 最新的 rest 参数。拿arguments来说,它是一个集合,里面包含了所有传入的参数,我们可以通过判断 arguments.length 和 arguments 中的每一项的类型来做不同的操作,同样也实现了这种需求。

上次面试,我才发现我对箭头函数还没有真正了解过。所以这次在我对箭头函数的测试和文档的学习下,来说说箭头函数以及它的this

箭头函数的活动对象里没有 this arguments new.target super 这四个值,所以只能沿着作用域链向上查找,所以箭头函数内部的this等是什么,也就明了了。
箭头函数自身不能用作 generator 函数,也不能用作 constructor 函数,没有 prototype 属性,可以使用ES6的rest剩余参数来替代arguments。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// 测试 super
class A {
constructor(){
this.num = 111;
}
}
class B extends A {
constructor(){
let ts = () => {
console.log(super());
}
ts();
this.name = 'class';
}
}
new B();

// 测试 this arguments new.target
function C(str){
this.num = 222;
let ts = () => {
console.log(this);
console.log(arguments);
console.log(new.target);
};
let tempObj = {
ts: () => {
console.log(this);
console.log(arguments);
console.log(new.target);
}
};
ts('ts');
tempObj.ts('tsObj');
}
new C('zr');

下面来说说对象:

首先要说的就是对象的引用。JS对于引用类型的处理非常高名,而这一处理方式也带来了很多其他的概念名词:浅拷贝,变量是否改变。

刚学JS时我很在意浅拷贝这个概念,但没过多久,我就觉得这对JS来说就是个伪概念,而且现在还非常盛行。js所有的引用类型都是在堆内存里面,他们都是独立的,并不是我们表面上看到的,一个嵌套一个,他们的属性存放的都是值,如果本身的值为引用类型,那么这个值就是引用类型的地址。所以可以认为,在存储机制方面,js对象都是扁平化的。我自己想了这么个词,不同意的可以提issue交流。

对象里最重要的当属原型和原型链了。原型是什么,原型就是个普通对象。原型链是什么,原型链用于属性查找(函数的作用域链用于变量查找),自身没有的属性沿着原型链一直往上找,直至找到。所以,最重要的其实是原型链。最需要了解的就是JS的面向对象编程了,因为它能很好的让我们了解原型及原型链。

JS使用原型链来实现面向对象编程。JS使用构造函数来构造对象,好多人喜欢把构造函数叫做类,构造函数就是个普通的函数。

为了节约书写时间,我这里直接使用类来表示构造函数(大家都懂的,JS没有类)。在讨论类之前,我先说说和函数有关的一个东西,使用函数来说比较好。JS里任何函数的定义,都会创建一个函数的伴生对象(我自己起的名),这个伴生对象就是构造新对象时的原型。这个图能很好的说明它们之间的关系:

JS面向对象

图片说明:图片中new括起来的区域,是我自己想的过程,和真实的new的结果一样,这里只是为了做个说明,但不代表JS真实的new操作。

对象的方法都是函数,由于函数的复用性,所以没有必要都拥有各自的方法,但是属性是用来表示对象自身的状态的,所以各自的属性应该是独立的(不说例外情况)

基于以上,构造对象方法的包括工厂函数模式,构造函数模式,原型模式这里就不说了,大家都知道构造加原型模式,所以这里也不说了。

至于好多人都说对于 prototype constructor proto 等概念混淆了,容易混淆,这里我也说一下,prototype constructor的关系在上面有介绍,读者看仔细就行。proto 是一个非标准的获取对象原型的API,建议尽量用于兼容方案的第二方案。

先把继承讲了:

属性继承:
说白了,就是把父类里的this改变成子类里的this。比如call apply bind,或者给子类this添加属性,值为父类,然后执行,再删除该属性。只要能改变父类this为子类this的方式都行。这样的话。相当于把父类里定义的属性添加到子类里面了。没了!

方法继承:

方法继承说白了,就是个属性查找的事,之前我们说过,属性查找基于原型链。所以我们只要使用原型链把这些对象连接起来,就万事大吉。不过这里只有一两个忠告:1. 就是不要让任何继承环节中的两个我们操作的对象是一个对象。2. 不要使用父类的实例来做中间对象实现继承。原因下面会讲。只要满足上述的要求,想怎么继承就怎么继承。

这里列举几个典型的继承方式,都是平常会接触到的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function Parent(){}
function Child(){}
// 组合继承
Child.prototype = new Parent()
// 道格拉斯继承
function T(){}
T.prototype = Parent.prototype;
Child.prototype = new T();
// 本质和道格拉斯继承一样
Child.protptype = Object.create(Parent.prototype);
// 非标准API实现继承,尽量不要使用
Child.prototype.__proto__ = Parent.prototype;
// 本质和 __proto__ 方式一样
Object.setPrototypeOf(Child.prototype, Parent.prototype)

// 其他自定义
let obj = {};
Object.setPrototypeOf(obj, Parent.prototype);
Child.prototype = obj;
// 还是那句话,只要通过对象的原型属性把原型链连接好,想怎样继承都行。

注意点:

上述所谓的组合继承 是有缺陷的继承,因为如果Parent类需要传入参数,那我们就需要在继承的时候传入相应的参数,如果内部不做兼容处理,就会报错。但问题是,这样的继承就没有普遍性了,我们不可能每次都能传入合适的参数。所以国外的一个小伙,名叫道格拉斯,使用了一个中间类来避开这个问题,能避开的本质是,Parent 类只有在实例化的时候才会有这个问题,但中间类T不需要传参,T的实例化没有问题,也就解决问题了。

这就是为什么有第二条的原因

对于有些人觉的以下方式也没问题:

1
Child.prototype = Parent.prototype;

这种方式和上述的第一条有冲突,不要让任何环节中的两个我们操作的对象是一个对象。因为如果你改Child.prototype上的属性,就相当于修改Parent.prototype上的属性。所以严格禁止。

有人会说了,上面的道格拉斯继承,也是两个对象是同一个对象。说的好,但问题是,道格拉斯继承的中间类T的prototype不是我们要直接操作的对象,所以无所谓。

注:这里的直接操作说的是业务需求上的操作。如果有人作死,偏要拿到这些原型对象做各种修改,那就让他作死吧。

至于ES6的class,这里就不说了,它就是个语法糖,改变了一下语法书写,方法不可枚举等,和ES5一样。

JS里最重要的函数和对象说完了,ES6新的概念比如Promise等建议直接去看阮一峰的ES6教程,其他的都是些API,只要查文档就行。我一直秉承的一个原则就是,能查API文档解决的事情就不是事。了解API体现不了一个人的实力。

JS严格模式是一种向未来兼容的方式。也都是需要记住的一些规则,但都很合理。这里我也不贴出来了,用的多了,自然就有印象了,MDN,百度,网上一大堆。

开发者自己在使用严格模式的时候,不要把 ‘use strict’ 使用在全局,只使用在自己的代码最外层函数里,因为你的代码不应该影响别人的代码,而且如果别人没使用严格模式,全局模式使用就可能会报错。

最后说下 EventLoop:

JS是一门运行于宿主环境的脚本语言,宿主环境很多,浏览器,Node等等。JS执行通过JS引擎编译和执行。JS在执行上是单线程的,但JS有很多操作不是立刻就能有结果的,比如说ajax setTimeout click等事件,所以如果JS所有的操作都在单线程上执行,ajax的操作势必会造成阻塞,所以JS利用事件和宿主环境提供的 任务队列 解决了这个问题。

具体是怎样的呢?页面加载JS,执行JS开始,JS的主线程就开始运行了,当页面所有script标签里的代码都解析和执行完,那么主线程的执行就完了。这时主线程会一直处于等待状态。直到任务队列里有待执行的事件回掉了,就会把任务队列里的函数执行放入主线程执行,执行完毕后,又会查看任务队列里还有没有需要执行的事件回掉,整个过程就这样循环。所以说,JS主线程只会执行同步代码,对于异步代码,其实也是当同步代码对待,执行后就直接跃过去执行下面的代码了,但异步代码会开启相应的浏览器线程来执行相应的任务,待任务执行完毕,就会把回掉函数的执行放入任务队列,并把执行结果传入,但这个回掉函数并不会执行(因为任务队列不属于JS引擎,执行不了JS),只有在主线程空闲下来,才会从任务队列取出函数执行放入主线程执行。

自从ES6引入了 Promise 等新异步对象后,任务队列又添了新成员,这个任务队列称为 microtask(微任务队列),以前哪个叫 macrotask(宏任务队列),主线程执行完毕后,会先从 microtask 获取任务执行,直到队列为空才会获取 macrotask 里的任务执行。

microtask:

Promise, MutationObserver

macrotasks:

setTimeout, setInterval, setImmediate, process.nextTick, event, I/O, UI rendering(不是DOM操作,DOM操作是同步的,渲染是异步的,这是浏览器的优化)

就拿ajax来举例,当新建xhr对象,并注册好事件后,然后send发送。这个过程中,不是只有JS在执行的,还有一个网络线程在做发送数据和获取数据的事,等数据获取了,则该线程触发 onload 事件。然后把 onload(xhr.response) 放入 macrotask 队列,等待JS引擎来执行,主线程空了,并且该任务前面没有其他要执行的任务了,这时候就把该任务弹出任务队列放入JS主线程执行。