嘘~ 加载慢,正在从阿水的辣鸡服务器里偷取页面 . . .

从编译原理开始讲作用域、闭包、this


从编译原理开始讲作用域、闭包、this

从编译原理开始讲

编程语言

编译型语言:提前将所有源代码一次性转换成机器可识别的二进制指令,也就是形成一个可执行程序(如.exe),如C、C++、Go。

解释型语言:一边执行一边转换,需要哪些源代码就转换哪些源代码,不需要生成可执行程序。如Python、JavaScript、PHP。

编译过程

传统的编译语言在代码执行之前有一个编译的过程,大概分为三部:分词、解析、代码生成。

var a = 1;

以这段代码为例:

分词阶段

这段代码将被分成各个词法单元:vara=1;,至于空格是否会分为词法单元,取决于空格在这门语言中的意义。

解析阶段

将词法单元生成AST,上面代码的AST部分结构(JSON)如下:

  "body": [
    {
      "type": "VariableDeclaration",
      "start": 0,
      "end": 10,
      "declarations": [
        {
          "type": "VariableDeclarator",
          "start": 4,
          "end": 9,
          "id": {
            "type": "Identifier",
            "start": 4,
            "end": 5,
            "name": "a"
          },
          "init": {
            "type": "Literal",
            "start": 8,
            "end": 9,
            "value": 1,
            "raw": "1"
          }
        }
      ],
      "kind": "var"
    }
  ],

代码生成

将AST生成机器可识别代码。

JavaScript的编译

JavaScript虽然是解释型语言,但是它也有一个预编译的过程,这个过程发生在代码执行之前几微秒,预编译也包含以上三步骤,但不完全一样,而且远不止这三步骤,还包含其他许多的优化处理。

下面将JavaScript运行简单模拟成三种角色:

  • 编译器:用于预编译

  • 引擎:用于执行代码

  • 作用域:确定当前执行的代码对一些标志符访问权限

JavaScript中的查询

JavaScript中有两种查询操作:

LHS:当变量出现在赋值操作的左侧时,进行LHS查询。

RHS:当变量出现在赋值操作的右侧时,进行RHS查询。

赋值操作不代表着“=”,JavaScript中存在许多隐式赋值操作,如++,–,函数的形参等。

当引擎遇到var a = 1;这句代码时会分别有LHS和RHS两次查找操作,两次查询操作也分别在不同阶段执行:

编译时(var a):编译器会沿作用域链查找是否之前有声明过a变量,如果没有则会在当前作用域新生成一个变量(非严格模式,严格模式抛出ReferenceError异常),如果有,则会忽略本次声明,这是LHS查询。(let、const声明有所不同,它们在声明位置之前无法使用。)

运行时(a = 1):这部分代码是引擎负责执行,也就是进行RHS查询,引擎首先会沿作用域链查找a变量,如果找到就对它赋值,如果没有,则抛出ReferenceError异常。

词法作用域

通过理解JavaScript代码执行过程可以看到,在编译阶段,根据写代码时变量和块作用域所处的位置,词法作用域就已经确定了。

这和动态作用域不同,动态作用域则是在运行时确定的。

编译时确定和运行时确定的规则是不同的,JavaScript中作用域是编译时确定,而this则是在运行时确定,分清楚这一点很重要。当然,JavaScript中也可以通过一些方法在运行时改变作用域,之后再讨论。

作用域链

“作用域”定义了一套规则,这套规则用来管理引擎如何在当前作用域以及嵌套的子作用域中根据标识符名称进行查找变量。在作用域中查找变量的过程会形成一个链式结构,称为作用域链。

var a = 1;
function foo(x){
  var b = x + 1;
  function bar(y){
    console.log(a, b, y);
  }
}
foo(2);

以上就形成了三个作用域:

查找变量的时候只能从内部往外部查找,而不能反过来,所以上面这种画法并不准确,而应该是下面这种。

变量提升

通过理解上面编译过程还可以知道,在每个作用域中的所有变量在编译阶段就已经被找了出来,并且与该作用域形成了关联。这个过程叫变量提升。

因此,一个变量在某个作用域中如果有声明语句,那么这个变量在该作用域内的任何地方都能被访问到,但变量的值则依赖于赋值语句的位置。

var a = 1;虽然是一条语句,但根据编译原理,它被分为了var aa = 1两个部分,也就是声明和赋值两个部分。

a = 1是在运行时被处理的,因此变量提升指的是变量声明提升,不是变量赋值提升。

因此在js中一下代码会输出undefined而不是报错,也不输出1:

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

函数作用域

每声明一个函数就会形成一个该函数的作用域,属于这个函数的全部变量都可以在整个函数范围内使用和复用。

函数声明与函数表达式

区分函数声明与函数表达式是有必要的,因为函数声明会被提升,而函数表达式不会被提升。

如下:

foo(1, 2); // 3
function foo(a, b){
    console.log(a + b);
}

以上函数能正常调用,但是下面就不行:

foo(1, 2); // Cannot access 'foo' before initialization
let foo = function(a, b){
    console.log(a + b);
}

是因为函数定义被包含在变量的初始化语句中。

此外,我常听见的匿名函数、IIFE(立即执行函数)等也都是函数表达式,而不是函数声明,即使是具名函数的IIFE函数,也是函数表达式。

如下:

(function foo(){
    console.log('111');
    console.log(foo)
})()
foo();

结果如下:

111
[Function: foo]
ReferenceError: foo is not defined:foo is not defined

立即执行函数内部可以访问函数本身,但外部不可以。

函数优先

函数声明和变量声明都会被提升,但是函数优先级更高。

如下,即使是变量后声明,也无法覆盖函数声明:

foo(); // 1 说明声明无法覆盖
function foo() {
    console.log(1);
}
var foo;
foo = function () {
    console.log(2);
}
foo(); // 2 赋值可以覆盖

即使是函数优先,也不建议用相同的名称同时声明函数和变量。

块作用域

在es6之前,只有在with、try/catch中(catch中)存在块作用域。es6之后,let、const可以将变量绑定到任意的块作用域中,通常是某个{}中。

try/catch是es6之前块级作用域的代替方案之一,IIFE也能创建块作用域,但是代码语义就改变了。

块作用域的作用

常见的,块作用域用于for循环中,用let声明循环变量时,每次迭代都是对循环变量的重新绑定,该循环变量只存在本次迭代的作用域中。

此外,显示的声明一个块作用域可以用于优化垃圾回收。

如下:

{
  function process(data){...}
  var someReallyBigData = {...}
  process(someReallyBigData);
  var btn = document.getElementById('btn');
  btn.addEventListener('click', function click(evt){
    //形成了覆盖整个作用域的闭包
  }, false)
 }
 //为了闭包体现更加明显,这里修改了《你不知道的JavaScript》(上卷)代码案例

click的回调不需要someReallyBigData,process()执行后大量的数据可以被垃圾回收机制回收了,但是由于click形成了覆盖整个作用域的闭包,JavaScript可能会保留该作用域所有内容。

但如果我们对占据了大量空间且执行完后不需要的结构显示地声明一个块作用域,引擎就可以对该结构进行垃圾回收。如下:

{
  function process(data){...}
  { // 该块级作用域中的内容执行完可以销毁  
    var someReallyBigData = {...}
      process(someReallyBigData);
  }                      
  var btn = document.getElementById('btn');
  btn.addEventListener('click', function click(evt){
    //形成了覆盖整个作用域的闭包
  }, false)
 }

遮蔽效应

多层的嵌套作用域中可以定义(通过var)同名的标识符(变量),但是作用域会在找到一个匹配的标识符时停止。这就形成了一个遮蔽效应,内部的标识符遮蔽了外部的标识符。

由于通过全局变量会自动成为全局对象的属性,所以被遮蔽的全局变量可以通过全局对象的属性来访问,但被遮蔽的非全局变量就无法被访问了。

运行时修改词法作用域

eval:eval()函数接收一个字符串作为参数,并且以JavaScript代码执行该字符串。

with:用于拓展语句的作用域链。

eval和with会在运行时创建或修改新的作用域,但是由于JavaScript引擎在编译阶段会进行许多性能优化(前面说的静态词法分析就是其中之一),如果引擎发现了eval和with,引擎会认为这些优化是无效的(因为在词法分析阶段无法明确eval和with会对作用域进行什么修改)。编译时无法优化,运行时就要花更大的代价运行代码。

所以无论何时都不建议使用eval和with,即使是eval的代替方案new Function()也不建议使用。

闭包

闭包,简单来说就一句话:引用了另一个作用域中的变量的函数就形成了闭包。

但它的作用却很强大。

当函数执行完毕后,一般来说js引擎的GC(垃圾回收)机制会回收该部分内存空间,但是闭包能让GC在某个函数执行完毕后不回收该部分内存。当然,也正是由于这个特点,会导致部分内存常驻,使用不当也会导致内存泄漏。

下面引用《JavaScript权威指南》(第七版)中对闭包的描述:

与多数现代编程语言一样,JavaScript使用词法作用域。这意味着函数执行时使用的是定义函数时生效的变量作用域,而不是调用函数时生效的变量作用域。为了实现词法作用域,JavaScript函数对象的内部状态不仅要包括函数代码,还要包括对函数定义所在作用域的引用。这种函数对象与作用域(即一组变量绑定)组合起来解析变量的机制,在计算机科学文献中被称作闭包。

严格来讲,所有JavaScript函数都是闭包。但由于多数函数调用与函数定义都在同一作用域内,所以闭包的存在无关紧要。闭包真正值得关注的时候,是定义函数与调用函数的作用域不同的时候。最常见的情形就是一个函数返回了在它内部定义的的嵌套函数。很多强大的编程技术都是建立在这种嵌套函数闭包之上的,因此嵌套函数闭包在JavaScript程序中也变得比较常见。乍一接触闭包难免不好理解,但只有真正理解了,才能用好它们。

可见,不同地方对闭包的定义有所不同,但核心要义都差不多。

this

this是JavaScript中存在于函数内部的关键字,其指向是运行时确定的,需要判断函数中this的绑定,就需要找到这个函数的直接调用位置。

用chrome开发者工具进行断点调试的时候,Call Stack的第二个就是函数的直接调用位置,或者Scope.Local中可直接查看this指向。

找到函数的直接调用位置后根据以下四条规则进行判断,就可得到该函数中this的指向。

JavaScript中this绑定有以下四条规则,优先级从上到下:

  • 函数由new创建,this绑定的是新创建的对象。
  • 函数由call/apply/bind调用,this绑定到指定的对象。
  • 函数由上下文对象调用,this绑定到上下文对象。
  • 默认情况下,this绑定到全局对象,严格模式下为undefined。

可以在控制台调试如下代码进行理解:

function baz(){
  baz.a = 0;
  console.log('baz');
  console.log(this.a);
  bar();
}
function bar(){
  this.a = 1;
  bar.a = 2;
  console.log('bar');
  console.log(this.a);
  console.log(window.a);
  foo.apply(bar);
  foo();
}
function foo(){
  foo.a = 3;
  console.log('foo');
  console.log(this.a);
}

var a = 4
foo()
baz()
//为了深入理解,这里修改了《你不知道的JavaScript》(上卷)代码案例
// foo 4 baz 4 bar 1 1 foo 2 foo 1

之前为了理解作用域、闭包和this,特意看了红宝书,也写了一篇文章:《一文详解js执行上下文、作用域链、闭包、this之间的关系》,但是个人觉得本篇从编译原理讲更易理解。


文章作者: 百念成诗
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 百念成诗 !
评论
  目录