1

一篇巩固基础的文章,也可能是一系列的文章,梳理知识的遗漏点,同时也探究很多理所当然的事情背后的原理。

为什么探究基础?因为你不去面试你就不知道基础有多重要,或者是说当你的工作经历没有亮点的时候,基础就是检验你好坏的一项指标。

JS基础都会有哪些考点:闭包,继承,深拷贝,异步编程等一些常见考点,为什么无论是当我还是个学生的时候被面试还是到现在当面试官去考别人,都还是问这些?项目从jQuery都过渡到React全家桶了,js还是考这些?

因为这些知识点很典型,一个知识点弄懂需要先把很多前置的其他的知识点弄懂。比如闭包,闭包背后就有作用域,变量提升,函数提升,垃圾收集机制等知识点。所以这些知识点往往能以点概面,考察很多基础的东西。

先来看看闭包(Closure)。

文章里提到了一些知识点:

  1. JS编译运行过程
  2. 词法作用域与动态作用域
  3. 作用域链顺序
  4. 变量与函数提升
  5. 闭包的应用

JS编译原理

基本概念

与JAVA,C++,C等静态语言不同,JavaScript是不需要编译的。在JAVA中,程序员写的JAVA代码要被编译器编译成机器语言,然后执行。

编译

一般程序中的一段源代码在执行之前会经历三个步骤,统称为“编译”:

  1. 分词/词法分析(Tokenizing/Lexing)
    这个过程会将由字符组成的字符串分解成(对编程语言来说)有意义的代码块,这些代码块被称为词法单元(token)。例如,考虑程序 var a = 2;。这段程序通常会被分解成为下面这些词法单元:var、a、=、2 、;。空格是否会被当作词法单元,取决于空格在这门语言中是否具有意义。
  2. 解析/语法分析(Parsing)
    这个过程是将词法单元流(数组)转换成一个由元素逐级嵌套所组成的代表了程序语法结构的树。这个树被称为“抽象语法树”(Abstract Syntax Tree,AST)。var a = 2; 的抽象语法树中可能会有一个叫作 VariableDeclaration 的顶级节点,接下来是一个叫作 Identifier(它的值是 a)的子节点,以及一个叫作 AssignmentExpression的子节点。AssignmentExpression 节点有一个叫作 NumericLiteral(它的值是 2)的子节点。
  3. 代码生成

    将 AST 转换为可执行代码的过程称被称为代码生成。这个过程与语言、目标平台等息息相关。抛开具体细节,简单来说就是有某种方法可以将 var a = 2; 的 AST 转化为一组机器指令,用来创建一个叫作 a 的变量(包括分配内存等),并将一个值储存在 a 中。

解释器

JavaScript则不同,JavaScript中对应编译的部分叫做解释器(Interpreter)。这两者的区别用一句话来概括就是:编译器是将源代码编译为另外一种代码(比如机器码,或者字节码),而解释器是直接解析并将代码运行结果输出。

编译运行过程

JavaScript编译运行过程中有三个重要的角色:引擎,编译器,作用域。三者互相配合这样工作:

  1. 源代码被编译器处理,进行词法和语法分析,将编译出来的变量、方法、数据等存储到作用域,然后将编译出来的机器代码交给引擎处理。
  2. 作用域负责收集并维护由所有声明的标识符(变量)组成的一系列查询,并实施一套非常严格的规则,确定当前执行的代码对这些标识符的访问权限。
  3. 引擎运行来处理这些机器代码,遇到变量、方法、数据等去作用域中寻找,并执行。

举个例子:

var a = 1;

这段代码交给解释器之后:

  1. 编译器运行源代码,识别出声明变量var a,编译器询问作用域是否已经有一个该名称的变量存在于同一个作用域的集合中。如果是,编译器会忽略该声明,继续进行编译;否则它会要求作用域在当前作用域的集合中分配内存声明一个新的变量,并命名为 a。
  2. 编译器将上述代码编译成机器代码并交给引擎执行。
  3. 引擎运行时从作用域获取a,如果没有a则抛出异常,有的话则将a赋值1。

上述代码在执行过程中开起来就好像:

var a;
a = 1;
这不就是很熟悉的变量提升吗,但是为什么会有变量提升呢,可以理解为代码的声明和赋值是分别在编译和运行时执行,两者之间的数据衔接全靠作用域(事实上并不是这样,后面会提到)。

异常

这里我们很熟悉,有两种异常:编译异常,运行异常。

编译异常

编译器在编译的时候发生错误,编译停止比如:
clipboard.png
很明显编译器无法知道将1赋值给谁,没法写出对应的机器语言,编译停止。

运行异常

引擎在运行时候发生错误,例如:
clipboard.png
引擎向作用域获取a,但是编译器未在作用域中声明a,运行报错。

声明了a,并将a赋值为1,但是a无法运行,运行报错。

LHS查询 RHS查询

RHS 查询与简单地查找某个变量的值别无二致,而 LHS 查询则是试图找到变量的容器本身,从而可以对其赋值。

ES5 中引入了“严格模式”。同正常模式,或者说宽松 / 懒惰模式相比,严格模式在行为上
有很多不同。其中一个不同的行为是严格模式禁止自动或隐式地创建全局变量。因此,在
严格模式中 LHS 查询失败时,并不会创建并返回一个全局变量,引擎会抛出同 RHS 查询
失败时类似的 ReferenceError 异常。

接下来,如果 RHS 查询找到了一个变量,但是你尝试对这个变量的值进行不合理的操作,
比如试图对一个非函数类型的值进行函数调用,或着引用 null 或 undefined 类型的值中的
属性,那么引擎会抛出另外一种类型的异常,叫作 TypeError。

垃圾收集

和C#、Java一样JavaScript有自动垃圾回收机制,也就是说执行环境会负责管理代码执行过程中使用的内存,在开发过程中就无需考虑内存分配及无用内存的回收问题了。

JavaScript垃圾回收的机制很简单:找出不再使用的变量,然后释放掉其占用的内存,但是这个过程不是时时的,因为其开销比较大,所以垃圾回收器会按照固定的时间间隔周期性的执行。

变量生命周期

什么叫不再使用的变量?不再使用的变量也就是生命周期结束的变量,当然只可能是局部变量,全局变量的生命周期直至浏览器卸载页面才会结束。局部变量只在函数的执行过程中存在,而在这个过程中会为局部变量在栈或堆上分配相应的空间,以存储它们的值,然后再函数中使用这些变量,直至函数结束(闭包特殊)。

一旦函数结束,局部变量就没有存在必要了,可以释放它们占用的内存。貌似很简单的工作,为什么会有很大开销呢?这仅仅是垃圾回收的冰山一角,就像刚刚提到的闭包,貌似函数结束了,其实还没有,垃圾回收器必须知道哪个变量有用,哪个变量没用,对于不再有用的变量打上标记,以备将来回收。用于标记无用的策略有很多,常见的有两种方式:标记清除和 引用计数,这里介绍一下标记清除:

标记清除(mark and sweep)

这是JavaScript最常见的垃圾回收方式,当变量进入执行环境的时候,比如函数中声明一个变量,垃圾回收器将其标记为“进入环境”,当变量离开环境的时候(函数执行结束)将其标记为“离开环境”。至于怎么标记有很多种方式,比如特殊位的反转、维护一个列表等,这些并不重要,重要的是使用什么策略,原则上讲不能够释放进入环境的变量所占的内存,它们随时可能会被调用的到。

垃圾回收器会在运行的时候给存储在内存中的所有变量加上标记,然后去掉环境中的变量以及被环境中变量所引用的变量(闭包),在这些完成之后仍存在标记的就是要删除的变量了,因为环境中的变量已经无法访问到这些变量了,然后垃圾回收器相会这些带有标记的变量机器所占空间。

大部分浏览器都是使用这种方式进行垃圾回收,只是垃圾收集的时间间隔不同。

作用域(scope)

作用域负责收集并维护由所有声明的标识符(变量)组成的一系列查询,并实施一套非常严格的规则,确定当前执行的代码对这些标识符的访问权限。

作用域分类

作用域共有两种主要的工作模型。第一种是最为普遍的,被大多数编程语言所采用的词法作用域,我们会对这种作用域进行深入讨论。另外一种叫作动态作用域,仍有一些编程语言在使用(比如 Bash 脚本、Perl 中的一些模式等)。

词法作用域

词法作用域是由你在写代码时将变量和块作用域写在哪里来决定的,因此当词法分析器处理代码时会保持作用域不变(大部分情况下是这样的)。

动态作用域

动态作用域并不关心函数和作用域是如何声明以及在何处声明的,只关心它们从何处调用。换句话说,作用域链是基于调用栈的,而不是代码中的作用域嵌套。

JavaScript中大部分场景都是词法作用域,函数中的this则是动态作用域,我们先仔细讨论词法作用域。

词法作用域

词法作用域中,又可分为全局作用域函数作用域块级作用域

全局作用域

默认进入的就是全局作用域,在浏览器上全局作用域通常都被挂载到windows上。

函数作用域

函数作用域的含义是指,属于这个函数的全部变量都可以在整个函数的范围内使用及复用(事实上在嵌套的作用域中也可以使用)。

var a = 1;

function fn () { // 函数作用域起点
    var a = 2;
    
    console.log(a);
} // 函数作用域终点

fn(); // 函数作用域这行业是,因为涉及到参数传值

console.log(a);

块级作用域

很常见,简单来说用{}来包裹起来的,通常可以复用的代码就是,比如for循环,switch case,while等等。

for(var i=0; i<10; i++){ // 块作用域
    console.log(i);
} // 块作用域

var a = 1;
switch (a) { // 块作用域
    case 1: { // 块作用域
        // ....
    }
    case 2: { // 块作用域
        // ....
    }
    default: { // 块作用域
        // ....
    }
}

while (a) { // 块作用域
  // ....     
}

{ // 硬写了一个块作用域
    let a = 2;
    console.log(a);
}

看一个例子:

function func (a) {
    var b = a * 2;
    
    function foo (c) {
        console.log(a, b, c);   
    }
    
    foo(b*3)
    
    {
        let a = 2;
        console.log(a); // 2
    }
}

func(1); // 1,2,3

通过上面这个例子我们来分析:

  1. func被定义在了默认的全局作用域,全局作用域只有 func;
  2. func函数创造了一个函数作用域,在函数体内定义的变量被定义在了函数作用域内:a,b,foo;
  3. foo函数又创造了一个函数作用域,里面有:c
  4. {}创造了一个块级作用域,里面使用let定义了一个a,这里的变量有:a

动态作用域

在词法作用域中,函数运行时遇到变量,回去在其词法作用域中寻找对应变量,而在动态作用域中,则是根据当前运行情况来确定,最常见的就是this关键字。

var b = 1;
var c = 123;

function fn (a) {
    console.log(a);
    console.log(b);
    console.log(this.c);
}

fn('hello');

var obj = {
    b: 2,
    c: 12,
    fn: fn
}

var o = {
    obj: obj
}

obj.fn('world');

o.obj.fn('!');

fn分别在全局作用域中执行,和obj的属性执行。

变量a是fn的函数作用域中定义的,属于词法作用域范畴;

变量b没有在函数作用域中定义,向上寻找,在全局作用域中找到,也是词法作用域范畴;

this.c属于动态作用域,函数执行的时候顺着调用栈动态寻找,this总是指向调用函数者。

作用域链(scope chain)

不同作用域之间是如何协作的,这就涉及到了作用域链。

作用域查找会在找到第一个匹配的标识符时停止。在多层的嵌套作用域中可以定义同名的标识符,这叫作“遮蔽效应”(内部的标识符“遮蔽”了外部的标识符)。抛开遮蔽效应,作用域查找始终从运行时所处的最内部作用域开始,逐级向外或者说向上进行,直到遇见第一个匹配的标识符为止。

不同作用域之间是可以嵌套的,所有的局部作用域都在全局作用域这个大容器之中,作用域之间的嵌套关系就好比堆栈和出栈。

还是上面的例子:

  1. func函数被定义在了全局作用域上,所以func函数内的变量作用域链为:[func函数作用域,全局作用域];
  2. foo函数被定义在了foo函数内,两个作用域互相嵌套,foo函数的作用域就是:[foo函数作用域,func函数作用域,全局作用域];
  3. {}在func函数内定义了一个块级作用域:[块级作用域,func函数作用域,全局作用域]。

在每个作用域内查找变量,如果对于的作用域内无法找到变量,则去其作用域链的上一级查找,直到找到第一个结果返回,否则返回undefined。

如果多个作用域内有相同名称的变量,则会找到距离当前作用域最近的变量。

提升(hoisting)

一开始编译运行过程的时候我们就知道了JS中存在变量提升,实际上分成两种情况:变量声明提升和函数声明提升。

变量声明提升

通常JS引擎会在正式执行之前先进行一次预编译,在这个过程中,首先将变量声明及函数声明提升至当前作用域的顶端,然后进行接下来的处理。

这个我们应该很熟悉了,举个例子:

console.log(a); // undefined
var a = 1;
console.log(a); // 1

按照阅读逻辑,在a声明之前调用a,会发生RHS异常,从而触发ReferenceError。

但是实际运行的时候,并没有报错,因为上面的代码看起来被编译成了:

var a;
console.log(a);
a = 1;
console.log(a);

这样理解看起来是不是就很合理了。

但是值得注意的是,变量提升只会提升至本作用域最顶端,而不会夸作用域:

var foo = 3;

function func () {

    var foo = foo || 5;

    console.log(foo); // 5
}

func();

在func里面的是函数作用域,全局作用域的一个子集,所以在函数作用域中调用变量foo应该就近寻找当前作用域内有无变量,找到一个即停止寻找。上述代码看起来:

var foo = 3;

function func () {
    var foo;
    
    foo = foo || 5;

    console.log(foo); // 5
}

func();

函数声明提升

与变量声明类似的,函数在声明的时候也会发生提升的情况:

func(); // 'hello world'

function func () {
    console.log('hello world');
}

相似的,如果在同一个作用域中存在多个同名函数声明,后面出现的将会覆盖前面的函数声明;

对于函数,除了使用上面的函数声明,更多时候,我们会使用函数表达式,下面是函数声明和函数表达式的对比:

console.log(foo1);
//函数声明
function foo1() {
    console.log('function declaration');
}

console.log(foo2);
//匿名函数表达式
var foo2 = function() {
    console.log('anonymous function expression');
};

console.log(bar);
console.log(foo3);
//具名函数表达式
var foo3 = function bar() {
    console.log('named function expression');
};
console.log(bar);

JavaScript中的函数是一等公民,函数声明的优先级最高,会被提升至当前作用域最顶端。上述的例子可以发现:只有函数声明的时候,才会发生变量提升,函数无论是匿名函数/具名函数表达式,均不会发生函数声明提升。

两者优先级

两者同时存在提升,那个优先级更高:

console.log(a);
var a = 1;
function a () {
    console.log('hello');
}

console.log(b);
function b () {
    console.log('hello');
}
var b = 1;

上面例子可以看到,当变量和函数同名的时候,无论谁声明在后,都是函数的优先级最高,变量为函数让路。

为什么提升

至于变量提升的原因:Note 4. Two words about “hoisting”

闭包

经过前面知识点铺垫之后,终于来到了闭包。

function closure () {
    var a = 1;
    
    function result () {
        return a;
    }
    
    return result;
}

closure()();

上面这个例子是个很常见的闭包,变量a在函数closure内,不应该在其作用域外被访问,但是通过返回result函数实现了在外部访问到了a,这就是一个简单的闭包。

事实上闭包的定义:(wiki pedia)

闭包,又称词法闭包(Lexical Closure)或函数闭包(function closures),是引用了自由变量的函数。这个被引用的自由变量将和这个函数一同存在,即使已经离开了创造它的环境也不例外。

简单说就是,函数内定义了一个引用了其作用域内变量的函数,然后将该函数当做一个值传递到其他地方,该函数在运行的时候,虽然运行环境已经不是其词法作用域,但是还可以访问到其词法作用域中的变量。

或者说我们可以这样理解:

本质上无论何时何地,如果将函数(访问它们各自的词法作用域)当作第一级的值类型并到处传递,你就会看到闭包在这些函数中的应用。在定时器、事件监听器、Ajax 请求、跨窗口通信、Web Workers 或者任何其他的异步(或者同步)任务中,只要使用了回调函数,实际上就是在使用闭包!

这里关键点:函数,函数引用了其作用域内的变量,在其词法作用域外被调用。来看一些常见的例子:

function closure () {
    var a = 1;
    
    function result () {
        return a;
    }
    
    window.result = result;
}

closure();
result();

上面例子的变形,closure不在return result,而是挂载到window对象上。很显然result的词法作用域在不是全局作用域,满足闭包的条件,也是一个闭包。

function wait(message) {
    setTimeout( function timer() {
        console.log( message );
    }, 1000 );
}
wait( "Hello, closure!" );

这个有点意思,延迟很常见。timer的词法作用域是[wait函数作用域,全局作用域],wait里面起了一个延迟队列任务,timer被当做参数传递到了延迟里,而timer里面还调用了message。这样的话,wait执行结束之后并不会被内存回收,1s之后,timer执行,其词法作用域都还在,满足闭包条件,是一个闭包。

练习

一道经典题目:输出结果

for(var i = 0; i<5; i++){
    setTimeout(function(){
        console.log(i);
    }, 100);
}

代码运行之后,打印5个5。这里setTimeout定义之后不会被立即执行,而是加入到队列中延迟执行,执行的时候运行匿名函数,匿名函数打印i,i不在匿名函数作用域中,顺着作用域链向上寻找,在全局作用域中找到i,这时候的i已经是5了,所以均打印5。

这里变形一下:还保留for循环,以及setTimeout形式,要求结果输出0,1,2,3,4,怎么改?

很多种方法,我们分成不同方向去考虑:

1. 使用块级作用域

变量i实际上是个全局作用域变量,for循环,每次都重复声明i,可以使用块级作用域,声明不同的块级作用域中的变量:

for(let i = 0; i<5; i++){
    setTimeout(function(){
        console.log(i);
    }, 100);
}

或者,赋值转换:

for(var i = 0; i<5; i++){
    let a = i;
    setTimeout(function(){
        console.log(a);
    }, 100);
}

这样的话,匿名函数执行的时候,函数作用域内没有i,去块级作用域寻找i,找到并返回结果,并不会直接寻找到全局作用域。

2. 闭包

闭包应该是最容易想到的,因为他的场景满足在其词法作用域外被调用,怎么使用闭包:立即执行函数(IIFE)

for(var i = 0; i<5; i++){
    (function(i){
        setTimeout(function(){
            console.log(i);
        }, 100);
    })(i);
}

立即执行函数创造了一个新的匿名函数作用域,这个作用域内的i是定义的时候传进来的,settimeout函数执行时候线上寻找到该作用域,并打印变量。

3.bind函数

或者使用bind函数可以直接更改匿名函数的作用域:

for(var i = 0; i<5; i++){
    setTimeout(function(i){
        console.log(i);
    }.bind(this, i), 100);
}

4.奇技淫巧

只针对这个题目,可以使用进栈出栈保持顺序:

var arr = [];

for(var i = 0; i<5; i++){
    arr.unshift(i);
    
    setTimeout(function(){
        console.log(arr.pop());
    }, 100);
}

参考

  1. 《你所不知道的JavaScript》
  2. 《JavaScript高级程序设计》
  3. JavaScript系列文章:变量提升和函数提升
  4. JavaScript深入之闭包
  5. 闭包

Aus0049
2.4k 声望231 粉丝

console.log(([][[]]+[])[+!![]]+([]+{})[!+[]+!![]])