JavaScript - 执行环境、作用域、作用域链、提升、闭包、this、箭头函数

399 阅读1小时+

WechatIMG439.jpeg

函数

函数是由事件驱动的或通过函数可以封装任意多的条语句,且可以在任何地方、任何时候调用执行

函数是 ECMAScript 中最有意思的部分之一,这主要是因为函数实际上是对象。每个函数都是 Function 类型的实例,且与其他引用类型一样也具有相应的属性和方法。因为函数是对象,所以函数名就是指向函数对象的指针,而且不会与某个函数绑定

函数的定义

  • 函数声明 function + 函数名(参数名, 参数名 ...) + 函数体,如:

    function demo () {}
    
  • 函数表达式(匿名函数表达式),如:

    var demo = function () {}; 
    (function foo() {...})()、(function(x, y){alert(x + y); })(2, 3);
    

    区分函数声明和表达式最简单的方法:function 关键字出现的位置(不仅仅是一行代码,而是整个声明中的位置),若 function 是声明中的第一个词则它就是一个函数声明,否则就是一个函数表达式

    函数声明和函数表达式之间的区别是:解析器在向执行环境中加载数据时,对函数声明和函数表达式并非一视同仁,解析器会率先读取函数声明并使其在执行任何代码前可用(可以访问);至于函数表达式则必须等到解析器执行到它所在的代码行才会真正被解释执行(这主要因为提升,详见提升章节)。除了什么时候可通过变量访问函数这点区别外,函数声明与函数表达式的语法其实是等价的

  • 使用 Function 构造函数,Function 构造函数可接收任意数量的参数,最后一个参数始终会被当成函数体,而之前的参数则枚举了新函数的参数。来看下面的例子:

    let sum = new Function("num1", "num2", "return num1 + num2");
    

    不推荐使用这种方法来定义函数,因为这种语法会导致代码被解析两次:

    • 第一次:解析常规 ECMAScript 代码
    • 第二次:解释传入构造函数中的字符串 这显然会影响性能,不过这种语法对于理解 “函数是对象,函数名是指针” 的概念是非常直观的
  • 箭头函数,与函数表达式很像

    let sum = (num1, num2) => {
      return num1 + num2;
    };
    

匿名函数

匿名函数就是没有名字的函数,有时也称为《 拉姆达函数》,匿名函数是通过 函数表达式 而不是 函数声明 语法定义的,可以在任何可以放置表达式的地方利用函数表达式创建一个新函数

创建:

  • 最常用的方式之一:var demo = function () {}
  • 自执行匿名函数:(function(x, y){alert(x + y); })(2, 3),即定义和调用合为一体,创建一个匿名函数并立即执行它,由于外部无法引用它内部的变量,因此在执行完后很快就会被释放,这种机制不会污染全局对象

匿名函数 & 函数声明(具名函数):

  • 当函数表达式被调用时,它创建一个新的函数对象并返回它,这里的赋值跟将任意函数的返回值赋给变量是几乎一样的,唯一特殊的地方是这个值是一个函数对象而不是一些简单的数字或日期等。因为函数在 JS 中是一种特殊的对象,这意味着它们能像其他对象一样被使用,可以被存储在变量中、作为参数传递到其他函数、在函数中被 return 语句返回。函数永远是对象,无论它们是如何被创建出来的

  • 匿名函数在运行时被创建:函数表达式在使用前必须先赋值,函数表达式可以被用在任意一个可以放置表达式的地方,因为函数永远是在运行时被调用的

  • 函数声明则不一样,它们运行在任何其他代码被执行之前,因为 JS 中函数在被调用之前不需要声明,关于函数声明,它最重要的一个特征就是函数声明提升,意思是执行代码之前先读取函数声明,这意味着可以把函数声明放在调用它的语句之后,必须通过显示的调用函数名调用对应函数才能运行其中代码

匿名函数的缺点:

  • 匿名函数在栈追踪中不会显示出有意义的函数名,使得调试困难
  • 当函数需要引用自身时只能使用已过期的 arguments.callee,如在递归中、事件触发后事件监听器需要解绑自身等
  • 匿名函数省略了对于代码的可读性/可理解性很重要的函数名,一个好的描述性的名称可以让代码不言自明

立即执行函数表达式(IIFE)

由于函数被包含在一对 () 括号内部,因此成为一个表达式,通过在末尾加上另外一个 () 可以立即执行这个函数

这种模式很常见,几年前社区给它规定了一个术语:IIFE,表示立即执行函数表达式(Immediately-Invoked Function Expression)

函数名对 IIFE 不是必须的,IIFE 最常见的用法是使用一个匿名函数表达式

一般写法:(function (){..})()(function (){..}())(改进形式)

function test() {} (); // 错误
var test = function () {} (); // 这个可以被执行

立即执行函数不需要被定义,直接执行,执行完毕之后直接释放

经常被用作初始化、需要控制变量作用范围时、需要单独封装某些独立模块时

另一个应用场景:解决 undefined 标识符的默认值被错误覆盖导致的异常(不常见)。将一个参数命名为 undefined,但在对应的位置不传入任何值,即可保证在代码块中的 undefined 标识符的值真的是 undefined

undefined = true; // 给其他代码挖了一个大坑,绝对不要这样做!
(function IIFE(undefined){ 
  var a; 
  if (a === undefined) { console.log(...) } 
})()

IIFE 还有一种变化的用途:倒置代码的运行顺序,将需要运行的函数放在第二位,在 IIFE 执行之后当作参数传递进去。这种模式在 UMD 项目中被广泛使用(尽管该模式略显冗长,但有些人认为它更容易理解)

var a = 2;
(function IIFE(def){
  def(window);
})(function def(global) {
  var a = 3;
  console.log(a); // 3
  console.log(global.a); // 2
})

立即执行函数的作用:

  • 根据 JS 函数作用域的性质,它可分离全局作用域,创建一个独立的作用域,这个作用域内部可以访问外部的变量,而外部环境不能访问其内部的变量,避免立即执行函数内部的变量和外部的变量发生冲突,造成变量全局污染

  • 可用它创建命名空间,只要把自己所有代码都写在这个特殊的函数包装内,则外部就不能访问,除非你允许。如:在实际项目开发中,在一个页面中可能会同时引入多个第三方库,若每个库中的函数都是具名函数,则很可能会产生命名冲突,所以现在很多流行框架都使用自调用匿名函数来解决以上问题

    • 这些库通常会在全局作用域中声明一个名字足够独特的变量,通常是一个对象,该对象被用作库的命名空间。所有需要暴露给外界的功能都会成为该对象的属性,而不是将自己的标识符暴露在顶级的词法作用域中
    • JQuery 就使用这种方法,将 JQuery 代码包裹在 (function(window,undefined){…jquery代码…}(window)) 中,在全局作用域中调用 JQuery 代码时可以达到保护 JQuery 内部变量的作用
    • 另一种规避冲突的办法:模块管理
      // 规避冲突的一个方法,全局命名空间
      var counter = { 
        awesome: "stuff", 
        dosomething: function(){...}, 
        dosomrthingelse: function(){...} 
      }
      

常见的面试题:为什么 alert 出来的总是 6,而不是 0、1、2、3、4、5?

  • 因为 i 贯穿了整个作用域,而不是给每个 li 分配一个单独 i,且不是立即执行的,只是把函数指针赋值给 onclick,具体执行是在 onclick 被触发时,而到那个时候 for 循环早就走完了,i 就是 6
  • 用 var 关键字声明的变量,是全局变量,在 for 循环之后仍然被保存这个作用域里;这可以说明:for() { } 仍然在全局作用域里,并没有产生像函数作用域一样的封闭效果
var liList = ul.getElementsByTagName('li');
for(var i = 0; i < 6; i++){ 
  liList[i].onclick = function(){ 
    alert(i); // 为什么 alert 出来的总是 6,而不是 0、1、2、3、4、5
  } 
}
  
// 解决:用立即执行函数给每个 li 创造一个独立作用域 
// 在立即执行函数执行时,i 的值被立即赋值给 ii(当然还有其他办法)
var liList = ul.getElementsByTagName('li');
for(var i = 0; i < 6; i++){
  (function(ii){ 
    liList[ii].onclick = function(){
      alert(ii); // 0、1、2、3、4、5 
    } 
  })(i) 
}

模块模式:最小化了全局变量的污染也创造了使用变量

var counter = (function(){ 
  var i = 0; 
  return {
    get: function(){ return i; }, 
    set: function(val){ i = val; }, 
    increment: function(){ return ++i; } 
  } 
}()); 
counter.get(); //0 
counter.set(3); 
counter.increment(); //4 
counter.increment(); //5

返回值

ECMAScript 中的函数在定义时不必指定是否有返回值。实际上,任何函数在任何时候都可通过 return + 要返回的值 来实现返回值

每个函数都会有个 return,若不写函数会自动加上(返回 undefined),return 有两个功能:

  • 返回函数的执行结果
  • 终止函数的执行

推荐的做法是要么让函数始终都返回一个值,要么永远都不要返回值。否则,若函数有时返回值有时又不返回值,会给调试代码带来不便

函数参数

严格模式 下对函数有一些限制(发生以下情况就会导致语法错误,代码无法执行):

  • 不能把 函数名 命名为 evalarguments
  • 不能把 参数名 命名为 evalarguments
  • 不能出现两个命名参数同名的情况

ECMAScript 函数的参数与大多数其他语言中的函数参数有所不同,ECMAScript 函数不介意传递进来多少参数也不在乎传进来参数是什么数据类型,如定义的函数只接收两个参数但在调用该函数时未必一定要传递两个参数

ECMAScript 中的参数在内部是用一个数组来表示的,函数接收到的始终都是这个数组而不关心数组中具体包含哪些参数(若有参数的话)。在函数体内可以通过 arguments 对象来访问这个数组从而获取传递给函数的每一个参数

arguments 是一个类数组(类数组并不是 Array 的实例),里面存的就是 实参,可使用 [] 语法访问它的每一个元素,如通过 arguments[0] 就可以查看传递的第一个实参了

1、length 属性,通过 length 属性可确定传递进来多少个参数
2、上面两者数量相等时形参实参列表成映射关系,其中一个变另一个也跟着变,但两者不是同一个;形参实参数量多时,不存在映射关系
3、可获得调用者传入的所有参数,即使函数不定义任何参数还是可以拿到参数的值

没有重载

ECMAScript 函数不能像传统意义上那样实现重载,在其他语言如 Java 中可以为一个函数编写两个定义,只要这两个定义的 签名(接受的参数的类型和数量) 不同即可。因为 ECMAScript 函数 没有签名,其参数是 由包含零或多个值的数组 来表示,而没有函数签名,真正的重载是不可能做到的

将函数名想象为指针也有助于理解为什么 ECMAScript 中没有函数重载的概念。若 ECMAScript 中定义了两个同名的函数,而结果则是后定义的函数会覆盖前面的,调用时是调用后定义的函数

function addNum(num) {
  return num + 100;
}

function addNum(num) {
  return num + 200;
}

var result = addNum(100); // 300

执行环境

EC — 执行环境或执行上下文(Execution Context)

执行上下文 为可执行代码块提供了执行前的必要准备工作,如变量对象的定义、作用域链的扩展、提供调用者的对象引用等信息

JSEC 分为三种:

  • 全局执行上下文默认的最基础的执行上下文,一个程序只会存在一个全局上下文,它在整个 JS 脚本的生命周期中都会存在于执行堆栈的最底部,不会被栈弹出销毁,一旦代码被载入引擎最先进入的就是这个环境。全局上下文会生成一个全局对象(浏览器环境中是 window)且将 this 值绑定到全局对象上

  • 函数执行上下文:每当一个函数被调用时都会创建一个新的函数执行上下文(不管这个函数是不是被重复调用)

  • Eval函数执行上下文:执行在 eval 函数内部的代码也会有它属于自己的执行上下文

ECS — 执行上下文栈(Execution Context Stack)

当 JS 代码执行时,引擎会创建一系列活动的执行上下文,这些执行上文从逻辑上构成了一个执行上下文栈

全局上下文总是在栈底,当前(活动的)执行上下文在栈顶。当在不同的执行上文之间切换(退出而进入新的执行上下文)时,栈会被修改(通过压栈退栈的形式)

  • 压栈:全局EC → 局部EC1 → 局部EC2 → 当前EC
  • 出栈:全局EC ←局部EC1 ←局部EC2 ←当前EC

我们可以用数组的形式来表示环境栈:

ECS = [当前EC, 局部EC1, ... , 全局EC];

当 JS 代码文件被浏览器载入后,默认最先进入的是 全局执行上下文。当在全局上下文中调用执行某个函数时,程序流就进入该被调用函数内,此时引擎就会为该函数创建一个新的执行上下文且将其压入到执行上下文堆栈的顶部

浏览器总是执行当前在堆栈顶部的上下文,一旦执行完毕则该上下文就会从堆栈顶部被弹出,然后进入其下的上下文执行代码...堆栈中的上下文会被依次执行且弹出堆栈,直到回到全局上下文

执行上下文的内容

执行上下文是一个抽象的概念,我们可以将它理解为一个对象,一个执行上下文里包括以下内容:

  • 变量对象(Variable Object)
  • 作用域链(Scope Chain)
  • this

全局执行上下文函数执行上下文 中的变量对象的区别:

  • 全局执行上下文 中的变量对象就是全局对象 Global Object,以浏览器环境来说就是 window 对象

  • 函数上下文中,VO 被表示为活动对象 AO。函数执行上下文中的变量对象内部定义的属性是不会被直接访问的,只有当函数被调用时变量对象 VO 被激活表示为活动对象 AO 时,才能访问到其中的属性和方法

  • 全局执行环境的变量对象始终存在,而函数局部环境的变量对象只会在函数执行的过程中存在

VO - Variable Object(变量对象)

  • 每个执行上下文都与一个变量对象相联系,声明的变量和方法作为属性添加到这个变量对象中,对于函数来说,参数也被添加为这个变量对象的属性

  • VO 对应函数定义阶段,它是一个与执行上下文相关的特殊对象,其中存储了 JS 解析引擎进行预解析时在上下文中定义的变量和函数声明,知道自己的数据存储在哪里且知道如何访问

  • 一般 VO 中会包含以下信息:

    • 变量 (var 变量声明,Variable Declaration)
    • 函数声明 (Function Declaration,缩写为 FD)
    • 函数的形参(Function Arguments)
    function add(a, b) {
      var sum = a + b;
      function say() {
        alert(sum);
      }
      return sum;
    } // sum, say, a, b 组合的对象就是 VO,不过该对象的值基本上都是 undefined
    

    注意:只有函数声明会被加入到变量对象中,而函数表达式会被忽略

    // 函数声明会被加入变量对象
    function a(){}
    // b 是变量声明会被加入变量对象,
    // 但是作为函数表达式的 _b 不会被加入变量对象
    var b = function _b(){}
    
  • 进入执行上下文时,VO 的初始化过程具体如下:

    • 函数的形参(当进入函数执行上下文时)— 变量对象的一个属性,其属性名就是形参的名字,其值就是实参的值;对于没有传递的参数,其值为 undefined
    • 函数声明 — 变量对象的一个属性,其属性名和值都是函数对象创建出来的,若变量对象已经包含了相同名字的属性,则替换它的值
    • 变量声明 — 变量对象的一个属性,其属性名即为变量名,其值为 undefined,若变量名和已经声明的函数名或者函数的参数名相同,并不会影响已经存在的属性

AO - Activation Object(活动对象)

  • 在函数执行上下文中,VO 是不能直接访问的,此时由活动对象(Activation Object, 缩写为 AO) 扮演 VO 的角色,其实 VOAO 是一个东西,只不过处于不同的状态和阶段

    VO(functionContext) === AO;
    
  • AO 对应的是函数执行阶段,当函数被调用执行前会建立一个 执行上下文,它通过 arguments 属性进行初始化,该属性的值为 Arguments 对象,arguments 包括下列属性

    AO = {
      arguments: {
        callee:, // 指向当前函数的引用
        length:, // 真正传递的参数的个数
        properties-indexes: //函数传参参数值(按参数列表从左到右排列)
      }
    };
    
  • AO 包含了函数所需的所有变量,包含了:

    • 函数的所有局部变量
    • 函数的所有命名参数
    • 函数的参数集合
    • 函数的 this 指向
    function add(a,b){
      var sum = a + b;
      function say(){
        alert(sum);
      }
        return sum;
    }
    add(4,5);
    
    // 用 JS 对象来表示 AO
    AO = {
      this : window,
      arguments : [4,5],
      a : 4,
      b : 5,
      say : , // sum : undefined
    }
    

EC 建立分为两个阶段

创建阶段(进入上下文阶段):发生在函数调用时,但在执行具体代码之前

代码执行阶段:变量赋值、函数引用、解释/执行代码

VO 和 AO 的区别就在执行上下文的这两个生命周期里

作用域

概念

作用域 (Scope) 是一套规则,用于确定在何处以及如何查找变量(标识符),确定当前执行代码对变量的访问权限

作用域是可访问变量的集合,在 JS 中作用域为可访问变量、对象、函数的集合,表示变量或函数起作用的区域,指代了它们在什么样的上下文中执行,亦即上下文执行环境

作用域就像是一个独立的地盘,让变量不会外泄、暴露出去,即作用域最大的用处就是隔离变量,不同作用域下同名变量不会有冲突

2 种工作模型

动态作用域在运行时确定的(this 也是),关注函数从何处调用

词法作用域(静态作用域)定义在词法阶段的作用域,即词法作用域是由在写代码时将变量和块作用域写在哪里来决定的(当词法分析器处理代码时会保持作用域不变(大部分情况下是这样),关注函数在何处声明)

JS 采用的作用域模型是词法作用域

注:编译的词法分析阶段基本能够知道全部标识符在哪里以及如何声明,从而能够预测在执行过程中如何对它们进行查找

JS 有两个机制可以“欺骗”词法作用域(**欺骗词法作用域会导致性能下降**)

  • eval(...)

    可接受一个字符串为参数,并将其中的内容视为好像在书写时就存在于程序中这个位置的代码

    eval(...) 中所执行的代码包含一个或多个声明(无论变量还是函数),就会对 eval(...) 所处的词法作用域进行修改

    function foo(str, a) {
      eval(str); 
      console.log(a, b); 
    }
    var b = 2;
    foo("var b = 3", 1) // 1, 3
    

    eval(...) 通常被用来执行动态创建代码,在程序中动态生成代码的场景非常罕见,因为它所带来的好处无法抵消性能上的损失

    严格模式eval(...) 在运行时有其自己的词法作用域,意味着其中的声明无法修改所在的作用域

    function foo(str) { 
      "use strict";
      eval(str);
      console.log(a); // ReferenceError: a is not defined 
    }
    foo("var a = 2");
    

    JS 还有其他一些功能效果和 eval(...) 很相似的:setTimeout(...)setInterval(...) 的第一个参数可以是字符串,该字符串内容可被解释为一段动态生成的函数代码(这些功能已过时并不被提倡,不要使用!

    new Function(...) 函数的行为也很类似,最后一个参数可接受代码字符串并将其转化为动态生成的函数(前面的参数是这个新生成的函数的形参),这种构建函数的语法比 eval(...) 安全点,但也尽量避免使用

    eval 函数带来的问题总结如下:

    • 函数变成了字符串,可读性差,存在安全隐患
    • 函数需要运行编译器,即使只是为了执行一个微不足道的赋值语句,这使得执行速度变慢
    • JSLint 失效,让它检测问题的能力大打折扣
  • with

    通常被当作重复引用同一个对象中的多个属性的快捷方式,可以不需要重复引用对象本身

    它可以将一个没有或有多个属性的对象处理为一个完全隔离的词法作用域,因此这个对象的属性也会被处理为定义在这个作用域中的词法标识符(根据所传的对象凭空创建了一个全新的词法作用域)

    foo.a.b.c = 888;
    foo.a.b.d = 'halfrost';
    
    // 用 with 语句就可以缩短调用
    with (foo.a.b) {
      c = 888;
      d = 'halfrost';
    }
    

    这种特性带来了很多问题,下例可看到输出有问题,with 语句覆盖掉了第一个入参。通过阅读代码,有时是不能分辨出这些问题,它也会随着程序的运行导致发生不多的变化,这种对未来的不确定性就很容易出现 bug

    function myLog( errorMsg , parameters) {
      with (parameters) {
        console.log('errorMsg:' + errorMsg);
      }
    }
    myLog('error', {}); // errorMsg: error
    myLog('error', { errorMsg:'stackoverflow' }); // errorMsg:stackoverflow
    

    with 会导致 3 个问题:

    • 性能问题:变量查找会变慢,因为对象是临时性的插入到作用域链中的
    • 代码不确定性:@Brendan Eich 解释,废弃 with 的根本原因不是因为性能问题,是因为 with 可能会违背当前的代码上下文,使得程序的解析(如安全性)变得困难而繁琐
    • 代码压缩工具不会压缩 with 语句中的变量名

    所以在严格模式下 with 被完全禁止

    Uncaught SyntaxError: Strict mode code may not include a with statement
    

    with 中正常的 var 声明不会被限制在这个块的作用域中,而是被添加到 with 所处的作用域中

    function foo(obj) {
      with(obj) {
        a = 2; 
      } 
    }
    var o1 = { a: 3 };
    var o2 = { b: 3 }; 
    foo(o1); 
    console.log(o1.a) // 2
    
    foo(o2);
    console.log(o2.a); // undefined 
    console.log(a); // 2 -> a 被泄漏到全局作用域上了
    

    若还是想避免使用 with 语句,有两种方法:

    • 用一个临时变量替代传进 with 语句的对象
    • 若不想引入临时变量,可以使用 IIFE
      (function () {
        var a = foo.a.b;
        console.log('Hello' + a.c + a.d);
      }());
      
      // 或
      (function (bar) {
        console.log('Hello' + bar.c + bar.d);
      }(foo.a.b));
      

性能

JS 引擎会在编译阶段进行数项的性能优化,其中某些优化依赖于能够根据代码的词法进行静态分析并预先确定所有变量和函数的定义位置,才能在执行过程快速找到标识符

若发现了 evalwith,引擎无法在编译时对作用域查找进行优化,它只能简单地假设关于标识符位置的判断是无效的,因为无法在词法分析阶段明确知道 eval 会接收什么代码、这些代码会如何对作用域进行修改,也无法知道传递给 with 用来创建新词法作用域的对象的内容到底是什么

例子

假设 JS 采用词法作用域,执行过程:执行 foo 函数,先从 foo 函数内部查找是否有局部变量 value,若没有就根据书写的位置查找上面一层的代码,即 value 等于 1,所以结果会打印 1

假设 JS 采用动态作用域,执行过程:执行 foo 函数,从 foo 函数内部查找是否有局部变量 value,若没有就从用函数的作用域,即 bar 函数内部查找 value 变量,所以结果会打印 2

// JS 采用的是词法作用域,所以这个例子的结果是 1
var value = 1;
function foo() {
  console.log(value);
}
function bar() {
  var value = 2;
  foo();
}
bar(); // 1

// 闭包
var value = 1;
function foo() {
  console.log(value);
}
function bar() {
  var value = 2;
  function foo() {
    console.log(value);
  } 
  return foo();
}
bar(); // 2

全局作用域、局部作用域、函数作用域、块作用域

  • 全局作用域(window/global Scope):在代码中任何地方都能访问到的对象、不在任何函数内定义的变量就拥有全局作用域。实际上 JS 默认有一个全局对象 window,全局作用域中的变量实际上被绑定作为 window 的一个属性

    • 最外层函数和在最外层函数外面定义的变量拥有全局作用域
    • 所有末定义直接赋值的变量自动声明为拥有全局作用域
    • 所有 window 对象的属性拥有全局作用域:如 window.namewindow.locationwindow.top
    • 全局作用域的弊端容易污染全局命名空间引起命名冲突,这就是为何 jQueryZepto 等库的源码,所有的代码都会放在 (function(){....})() 中。因为放在这里面的所有变量都不会被外泄和暴露、不会污染到外面、不会对其他的库或 JS 脚本造成影响
    • JS 实际上只有一个全局作用域,任何变量(函数也视为变量)若没有在当前函数作用域中找到,就会继续往上查找,最后若在全局作用域中也没有找到,则报 ReferenceError 错误
  • 局部作用域:和全局作用域相反,局部作用域一般只在固定的代码片段内可访问到。最常见的是在函数体内定义声明的变量,只能在函数体内使用

  • 函数作用域:是指声明在函数内部的变量,属于该函数内的全部变量都可以在整个函数的范围内使用及复用(嵌套的作用域中也可使用)(外部可通过 return闭包访问函数内部变量)

    • 当函数执行时会创建一个成为执行期上下文的内部对象,一个执行期上下文定义了一个函数执行时的环境,函数每次执行时对应的执行上下文都是独一无二的,所以多次调用一个函数会导致创建多个执行上下文,当函数执行完毕,执行上下文被销毁

    • 函数作用域内,对外是封闭的,从外层的作用域无法直接访问函数内部的作用域

    • 两个不同作用域(除了全局作用域)之间是不能互相访问的

      function demo1() {
        var str = 'abc';
      }
      function demo2() {
        console.log(str);
      }
      demo2(); // 报错 str is not defined
      
    • 在函数体内局部变量的优先级高于同名的全局变量。若在函数内声明的一个局部变量或函数参数中带有的变量和全局变量重名,则全局变量就被局部变量所“遮盖”,而全局变量并不会因此发生值的变化

    • 值得注意的是:块语句(大括号{}中间的语句),如 ifswitch 条件语句或 forwhile 循环语句,不像函数,它们不会创建一个新的作用域,在这些块语句中定义的变量将保留在它们已经存在的作用域中

      if (true) {
        var name = "haha"; // name 依然在全局作用域中
      }
      console.log(name) // haha
      
  • 块级作用域

    • 块级作用域可通过 ES6 新增的命令 letconst 声明,将变量的作用域限制在当前代码块中,所声明的变量在指定块的作用域外无法被访问(这对保证变量不会被混乱地复用及提升代码的可维护性都有很大帮助)

    • 块级作用域可在如下情况被创建:在一个函数内部、在一个代码块(由一对花括号包裹)内部

    • 块级作用域有以下几个特点:

      • 声明变量不会提升到代码块顶部,若有需要则得手动将 let/const 声明放置到顶部,以便让变量在整个代码块内部可用
      • 禁止重复声明:若一个标识符已经在代码块内部被定义,那在此代码块内使用同一个标识符进行 let 声明就会导致抛出错误,但若在嵌套的作用域内使用 let 声明一个同名新变量,则不会抛出错误
        var count = 3;
        let count = 4; // Uncaught SyntaxError: Identifier 'count' has already been declared
        
        var count = 3;
        if(condition) {
          let count = 4; // 不会报错
        }
        
      • 循环中的妙用
        // 这里的 i 是在全局作用域里面的,只存在 1 个值,等到回调函数执行时,用词法作用域捕获的 i 就只能是 5
        for(var i = 0; i < 5; i++) {
          setTimeout(function() {
            console.log(i); // 5 5 5 5 5 
          }, 200); 
        };
        
        // 解法 1: 调用函数,创建独立函数作用域
        for(var i = 0; i < 5; i++) { abc(i); }
        function abc(i) {
          setTimeout(function() {
            console.log(i); // 0 1 2 3 4
          }, 200)
        }
        
        // 解法 2: 采用立即执行函数,创建函数作用域
        for(var i = 0; i < 5; i++) {
          (function(j) {
            setTimeout(function() {
              console.log(j);
            }, 200); 
          })(i);
        };
        
        // 解法 3: let 创建块级作用域
        for(let i = 0; i < 5; i++) { // 在每次执行循环体时都会在循环体上下文中重新初始化一次 
          setTimeout(function() {
            console.log(i);
          }, 200);
        };
        
    • 可以创建块级作用域的还有:
      1、with:用 with 创建出的作用域仅在 with 声明中而非外部作用域有效
      2、try/catchcatch 分句会创建一个块作用域,其中声明的变量仅在 catch 内部有效

作用域 & 执行上下文

JS 属于解释型语言,JS 的执行分为解释执行两个阶段,这两个阶段所做的事并不一样

  • 解释阶段:词法分析 / 语法分析 / 作用域规则确定
  • 执行阶段:创建执行上下文 / 执行函数代码 / 垃圾回收

JS 解释阶段便会确定作用域规则,因此作用域在函数定义时就已经确定,而不是在函数调用时确定。但执行上下文是函数执行之前创建的,执行上下文最明显的就是 this 的指向是执行时确定的,而作用域访问的变量是编写代码的结构确定的

作用域和执行上下文之间最大的区别是执行上下文在运行阶段确定,随时可能改变;作用域在定义时就确定且不会改变

一个作用域下可能包含若干个上下文环境:

  • 有可能从来没有过上下文环境(函数从来就没有被调用过)
  • 有可能有调用过,现在函数被调用完毕后上下文环境被销毁了
  • 有可能同时存在一个或多个(闭包),同一个作用域下不同的调用会产生不同的执行上下文环境,继而产生不同的变量的值

作用域链

定义

作用域链 是由当前执行环境与上层执行环境的一系列作用域共同组成,它保证了当前执行环境对符合访问权限的变量和函数的有序访问

可执行上下文中的词法环境中含有外部词法环境的引用,可通过这个引用获取外部词法环境的变量、声明等,这些引用串联起来一直指向全局的词法环境,因此形成了作用域链

当查找变量时,会先从 当前上下文的变量对象 中查找,若没有找到就会从 父级(词法层面上的父级)执行上下文的变量对象 中查找,一直找到 全局上下文的变量对象全局对象,这样由多个执行上下文的变量对象构成的链表即 作用域链

作用域链

当代码在一个环境中执行时会创建变量对象的一个 作用域链(scope chain) 来保证对执行环境有权访问的变量和函数的有序访问

作用域链的第一个对象始终是当前执行代码所在环境的变量对象(VO

函数的作用域在函数定义时就确定了,这是因为函数有一个内部属性: [[scope]],当函数创建时就会保存所有父变量对象到其中,可理解 [[scope]] 就是所有父变量对象的层级链

注意:[[scope]] 并不代表完整的作用域链

当函数执行时会创建一个执行环境,然后通过复制函数的 [[scope]] 属性中的对象构建起执行环境的作用域链,然后变量对象 VO 被激活生成 AO 并添加到作用域链的前端,则完整的作用域链创建完成

Scope = [AO].concat([[scope]]);

这也可以说明作用域链是在函数创建时就已经有了

注意:ScopeEC 的属性,而 [[scope]] 则是函数的静态属性

若函数是在 全局作用域 中创建的,在函数创建时它的作用域链填入 全局对象(全局对象中有所有全局变量),此时的全局变量是 VO,此时的作用域链就是:

// 此时作用域链(Scope Chain)只有一级,就为 Global Object 
scope(add) -> Global Object(VO)
VO = {
  this: window,
  add: ...,
}

若在函数执行阶段,则将其 Activation Object(AO) 作为作用域链第一个对象,第二个对象是上级函数的执行上下文 AO,下一个对象依次类推

在函数运行过程中标识符的解析是沿着作用域链一级一级搜索的过程,从第一个对象开始逐级向后回溯,直到找到同名标识符为止,找到后不再继续遍历,找不到就报错

add(4, 5);
// 调用 add 后的作用域链(Scope Chain)有两级,第一级为 AO,然后  Global Object(VO)
scope(add) -> AO -> VO

AO = {
  this: window,
  arguments: [4,5],
  a: 4,
  b: 5,
  say: ..., // sum : undefined
}
VO = {
  this: window,
  add: ...,
}
var x = 10;
function foo() {
  var y = 20;
  function bar() {
    var z = 30;
    console.log(x + y + z); // 60 
  }; 
  bar(); 
};
foo();

// 此时作用域链(Scope Chain)有三级,第一级为 bar AO,第二级为 foo AO,然后 Global Object(VO)
// scope -> bar.AO -> foo.AO -> VO
bar.AO = {
  z : 30,
  __parent__ : foo.AO
}
foo.AO = {
  y: 20,
  bar: ..., 
  __parent__: ...,
}
VO = {
  x: 10,
  foo: ...,
  __parent__ : null 
}

示例

结合例子,总结一下函数执行上下文中作用域链和变量对象的创建过程(checkscope 函数创建时,保存的是根据词法所生成的作用域链,checkscope 执行时会复制这个作用域链作为自己作用域链的初始化,然后根据环境生成变量对象并将这个变量对象添加到这个复制的作用域链的前端,这才完整的构建了自己的作用域链)

至于为什么会有两个作用域链,是因为在函数创建时并不能确定最终的作用域链的样子

而为什么会采用复制的方式而不是直接修改呢?我想应该是因为函数可能会被调用很多次吧

var scope = "global scope";
function checkscope(){
  var scope2 = 'local scope';
  return scope2;
}
checkscope();

创建 checkscope 函数时,保存作用域链到内部属性 [[scope]]

checkscope.[[scope]] = [
  globalContext.VO
];

函数 checkscope 执行,创建 checkscope 函数 执行上下文checkscope 函数执行上下文被压入执行上下文栈 Execution context stack, ECS

ECStack = [
  checkscopeContext,
  globalContext
];

checkscope 函数并不立刻执行,开始做准备工作

  • 第一步:复制函数 [[scope]] 属性创建作用域链

    checkscopeContext = {
      Scope: checkscope.[[scope]],
    }
    
  • 第二步:用 arguments 创建初始化活动对象(AO),加入形参、函数声明、变量声明

    checkscopeContext = {
      AO: {
        arguments: { length: 0, ... },
        scope2: undefined
      }, 
      Scope: checkscope.[[scope]],
    }
    
  • 第三步:将活动对象压入 checkscope 作用域链顶端

    checkscopeContext = {
      AO: {
        arguments: { length: 0, ... },
        scope2: undefined
      },
      Scope: [AO, [[Scope]]]
    }
    

准备工作做完,开始执行函数,随着函数的执行,修改 AO 中的属性值

checkscopeContext = {
  AO: {
    arguments: { length: 0, ... },
    scope2: 'local scope'
  },
  Scope: [AO, [[Scope]]]
}

查找到 scope2 的值,返回后函数执行完毕,函数上下文执行上下文栈 中弹出

ECStack = [
  globalContext
];

扩展:LHS、RHS

LHS/RHS 是引擎在执行代码时查询变量的两种方式,其中 L/R 分别意味着 Left/Right

这个“左”和“右”,是相对于赋值操作来说的。当变量出现在赋值操作的左侧时执行的就是 LHS 操作,右侧则执行 RHS 操作:

name = 'donna';

在这个例子里,name 变量出现在赋值操作的左侧,它就属于 LHS

LHS 意味着变量赋值或写入内存,它强调的是一个写入的动作,所以 LHS 查询查的是这个变量的“家”(即,对应的内存空间)在哪

var myName = name
console.log(name)

在这个例子里,第一行有赋值操作,但 name 在操作的右侧,所以是 RHS;第二行没有赋值操作,name 就可以理解为没有出现在赋值操作的左侧,这种情况下也认为 name 的查询是 RHS

RHS 意味着变量查找或从内存中读取,它强调的是这个动作,查询的是变量的内容

提升

概念

JS 和其他语言一样都要经历编译(解释)执行阶段

JS 在编译阶段代码真正执行前会先解析代码搜集所有变量和函数声明且提前声明变量,而不会改变其他语句的顺序,这造成的结果就是所有的声明语句都会被提升到代码的头部,并使其在执行任何代码之前可访问,这个过程称为 提升(hoisting)

提升

注意这里是“声明”会被提前处理,赋值并没有

定义声明是在编译阶段进行的,而赋值则是在执行阶段进行的,即声明本身提升了,赋值或其他运行逻辑还留在原地等待执行

var a = 2;
var a; // 编译阶段
a = 2; // 执行阶段


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

// 等价于
var a ;         
console.log(a); // 由于 a 只声明未赋值,输出 undefined
a = 2;

函数声明会被提升,但函数表达式不会(函数表达式实际上是变量声明的一种)

foo(); // Uncaught TypeError: foo is not a function
var foo = function() {};

// 不是 ReferenceError, 而是 TypeError
// 变量标识符 foo 被提升并分配给所在作用域(这里是全局),因此 foo() 不会导致 ReferenceError
// 但 foo 此时没有赋值(若是函数声明就会直接赋值),这里 foo() 是对 undefined 进行函数调用而导致非法操作,因此抛出 TypeError

即使是具名的函数表达式,名称标识符在赋值之前也无法在所在作用域中使用

foo(); // Uncaught TypeError: foo is not a function
bar(); // Uncaught ReferenceError: bar is not defined
var foo = function bar(){};

函数提升的优先级最高,将首先被提升,即函数声明会被提升到普通变量之前(函数声明高于一切,毕竟函数是 JS 的一等公民),若有多个函数声明则是由最后一个函数声明覆盖之前所有的声明,而函数声明提升不会被变量声明覆盖,但会被变量赋值覆盖

// 例 1
foo(); // 1
var foo;
function foo() {
  console.log(1);
}
foo = function() {
  console.log(2);
};

// 例 2
foo(); // 3
function foo() {
  console.log(1);
}
var foo = function() {
  console.log(2);
}
function foo() {
  console.log(3);
}

let 和 const 声明的变量

console.log(a); // ReferenceError: a is not defined
let a = 3;

得出的基本结论:

  • 作用域是块级的
  • 不能重复声明已存在的变量
  • 暂时性死区,若在代码块中存在 let 或 const 命令,这个区块对这些命令声明的变量,从一开始就形成了封闭作用域,凡是在声明之前就使用这些变量就会报错,因此不发生提升

上面的理解没有问题,但综合各方资料来看不够「全面和深刻」,let 到底有没有提升呢?

首先我们来学习一下变量的生命周期,JS 的变量会经历 创建 create初始化 initialize赋值 assign 三个阶段

  • var 声明的 创建、初始化和赋值 过程

    function fn() {
      console.log('在声明阶段之前', x);
      var x;
      console.log('在未赋值之前', x);
      x = 'assigned';
      console.log('赋值之后', x);
    }
    fn();
         
    // 运行结果:
    // 在声明阶段之前 undefined 
    // 在未赋值之前 undefined 
    // 赋值之后 assigned
    

    在执行 fn 时会有以下过程(不完全):

    • 进入 fn,为 fn 创建一个环境

    • 找到 fn 中所有用 var 声明的变量,在这个环境中 创建 这些变量(即 x

    • 将变量 初始化undefined(在声明和初始化之后但在赋值阶段之前,变量具有 undefined 值且可被使用)

    • 开始执行代码

    • 将 x 变量 赋值assigned

    var 声明会在代码执行前就创建变量并将其初始化为 undefined

  • function 声明的 创建、初始化和赋值 过程

    fn();
    function fn() {
      console.log(2);
    }
    // 2
    

    JS 引擎会有以下过程:

    • 找到所有用 function 声明的变量,在环境中创建这些变量

    • 将这些变量 初始化赋值function(){ console.log(2) }

    • 开始执行代码 fn

    function 声明会在代码执行前就 创建、初始化并赋值

  • let 声明的 创建、初始化和赋值 过程

    let 变量的处理方式不同于 var,主要区别是声明和初始化阶段是分开的

    {
      let x = 1;
      x = 2;
      console.log(x); // 2
    }
    

    只看 {} 里的过程:

    • 找到所有用 let 声明的变量,在环境中 创建 这些变量
    • 开始执行代码(注意此时还没有初始化)
    • 执行 x = 1,将 x 初始化1(这并不是一次赋值,若代码是 let x,就将 x 初始化为 undefined
    • 执行 x = 2,对 x 进行赋值

    这就解释了为什么在 let x 之前使用 x 会报错:

    let x = 'global';
    {
      console.log(x); // Uncaught ReferenceError: Cannot access 'x' before initialization
      let x = 1;
    }
    

    原因有两个:

    • console.log(x) 中的 x 指的是下面的 x,而不是全局的 x,此时 x 还没被初始化
    • 执行 console.logx 还没 初始化,所以不能使用(我认为在块级作用域中,从块级作用域中的第一行开始,到用 let variable 声明变量这一行之前,这一段区域是 let 的暂时性死区

    到这里再来看看 let 到底有没有提升:

    • let创建 过程被提升了,但 初始化 没有提升,在 初始化 之前该变量位于 暂时性死区 中且不可访问

    • var创建初始化 都被提升了

    • function创建初始化赋值 都被提升了

      注意:若 let x 的初始化过程失败了,则 x 变量就将永远处于 created 状态,无法再次对 x 进行初始化(初始化只有一次机会,而那次机会失败了)。由于 x 无法被初始化,所以 x 永远处在暂时死区

    • 最后看 const,其实 constlet 只有一个区别,即 const 只有创建初始化,没有赋值过程

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

      const 无初始化会报错,不会像 let 那样初始化为 undefined

      const x;
      // Uncaught SyntaxError: Missing initializer in const declaration
      
      let x;
      // undefined
      

四种声明总结图

image.png

ES6 中的 class 声明也存在提升

letconst 一样,classJS 中也会被 “提升”,只是 classlet、const 一样被约束和限制了

class 也会在解释之前保持未初始化状态,所以 class 也会受到 “暂时性死区” 的影响,其规定若在声明位置之前引用则是不合法的,会抛出一个异常

let tn = new Person('Peter', 25); // ReferenceError: Person is not defined console.log(peter); 

class Person { 
  constructor(name, age) { 
    this.name = name;
    this.age = age; 
  }
}

因此若要访问 class 必须先声明,如:

class Person {
  constructor(name, age) {
    this.name = name;
    this.age = age;
  }
}

let peter = new Person('Peter', 25); 
console.log(peter); // Person {name: 'Peter', age: 25}

在编译阶段,上面代码的词法环境看起来像这样:

lexicalEnvironment = {
  Person: <uninitialized>
}

当引擎执行到该 class 语句后,它将用值初始化该 class

lexicalEnvironment = {
  Person: <Person object>
}

原因分析

JS 引擎会在正式执行代码前进行一次“预编译”,预编译简单理解就是在内存中开辟一些空间存放一些变量和函数,具体步骤如下:

  • 页面创建 GO 全局对象(Global Object)对象(浏览器中即 window 对象)

  • 加载第一个脚本文件

  • 脚本加载完毕后进行语法分析

  • 开始预编译:

    • 查找函数声明,作为 GO 属性,值赋予函数体

    • 查找变量声明,作为 GO 属性,值赋予 undefined

      GO/window = {
        // 页面加载创建 GO 同时,创建了 document、navigator、screen 等属性
        a: undefined,
        c: undefined,
        b: function(y) {
          var x = 1;
          console.log('so easy');
        }
      }
      
    • 解释执行代码(直到执行函数 b,该部分也被叫做词法分析)

      • 创建 AO 活动对象(Active Object)
      • 查找形参和变量声明,值赋予 undefined
      • 实参值赋给形参
      • 查找函数声明,值赋给函数体
      • 解释执行函数中的代码
        GO/window = {
          // 变量随着执行流得到初始化
          a: 1,
          c: function() {...},
          b: function(y) {
            var x = 1;
            console.log('so easy');
          }
        
    • 第一个脚本文件执行完毕,加载第二个脚本文件

    • 第二个文件加载完毕后,进行语法分析

    • 开始预编译:重复预编译步骤 ....

注:JS 并不存在真正的预编译varfunction 的提升实际是在语法分析阶段就处理好的,且 JS 的预编译是以一个脚本文件为块的,一个脚本文件进行一次预编译,而不是全文编译完成再进行预编译

提升的原因

对于 变量提升 Brendan Eich 给出的解释是:

image.png

大概意思是:由于第一代 JS 虚拟机中的抽象纰漏导致的,编译器将变量放到了栈槽内并编入索引,然后在(当前作用域的)入口处将变量名绑定到了栈槽内的变量(注:这里提到的抽象是计算机术语,是对内部发生的更加复杂的事情的一种简化)

对于 函数提升 Brendan Eich 给出的解释是: image.png

Brendan Eich 很确定的说,函数提升就是为了解决相互递归的问题,大体上可以解决像 ML 语言这样自下而上的顺序问题(就是 A 函数内会调用到 B 函数,而 B 函数也会调用到 A 函数)

若没有函数提升,而是按照自下而上的顺序,当 A 函数被调用时 B 函数还未声明,A 内部无法调用 B 函数。所以 Brendan Eich 设计了函数提升这一形式,将函数提升至当前作用域的顶部

最后 Brendan Eich 还对变量提升函数提升做了总结:

image.png

大概是说,变量提升是人为实现的问题,而函数提升在当初设计时是有目的

最佳实践

理解 变量提升函数提升 可以使我们更了解这门语言、更好地驾驭它,但在开发中不应使用这些技巧,而是要规范代码,做到可读性和可维护性

具体的做法是:

  • 无论变量还是函数,都必须先声明后使用
  • 若对于新的项目可使用 let 替换 var,会变得更可靠,可维护性更高

面试题解析

例 1

console.log(foo);     // undefined
console.log(bar);     // function bar(){...}

var foo = function() {...}; 
function bar() {...} 

// 详解:
var foo ;
function bar() {...} 

console.log(foo);    // undefined 
console.log(bar);    // function bar(){...}

foo = function() {...};

本题主要考的是函数和函数表达式的区别。变量声明和函数先提升到顶部,赋值被留到原地,foo 默认 undefinedbar 输出函数自己

例 2

function foo() {
  a = 5;
  console.log(window.a); // 5
  console.log(a); // 5       
  var a = 10;
  console.log(a); // 10      
}
foo();

这里涉及到全局污染问题,即不使用var其他声明关键字去声明时,在本作用域找不到声明时,默认向上级找,直到最顶层全局 window 上(严格模式 下报 not defined

function foo() {
  a = 1;
  console.log(window.a);    // 1,变量 a 污染到了全局上
}
foo();

下面是本题的解析,考点就是提升和全局污染

function foo() {
  var a ; 
  // 声明 a 变量,a 的声明提前
  // 因为在自己的作用域内有 a 的声明存在,a 并不会污染到全局
  // 而是绑定到本作用域的 a 上,这也是比较忽悠人的地方
  a = 5;
  console.log(window.a);    // undefined,a = 5 没有污染全局,所以 window.a 不存在,故输出 undefined
  console.log(a);           // 5,a 的声明提升,变量 a = 10 是赋值操作,没有提升,a 现在还是 5
  a = 10;
  console.log(a);           // 10
}
foo();

例 3

function foo() {
  var a = 1;        
  function b() {   
    a = 10;
    return '';
    function a() {}
  }
  b();
  console.log(a); // 1 
}
foo();

// 详解:考点 1、污染 2、提升 3、作用域
function foo() {
  var a ;  // a 和 b 一起提升到作用域顶部
  function b() {
    function a() {...} // b 里的函数 a 也提升到 b 的顶部
    a = 10; // 因为上面有变量 a,所以 a 也不会污染到上一层,而是对函数中的 a 进行再次赋值
            // 若函数执行,函数里的 a 的值是 10 且没有污染
    return '';
  }

  a = 1; // 对本作用域的 a 赋值

  b(); // b 函数执行,b 作用域内的 a 被赋值为 10
  console.log(a); // 1
                  // 这个有两点要搞清楚 :
                  // 1、b 中的 a 没有污染到所在作用域
                  // 2、就近原则,本函数的 console.log(a) 找离自己最近的 a 变量
                  // 若 console.log 在函数 b 内,则输出离自己最近的 10
}
foo();

例 4

function foo() {
  var a = 1;        
  function fn() {   
    fb()
    console.log(a); // 1
    a = 10;
    return '';
    function fb() {
      console.log(a); // 1
    };
  }
  fn();
  console.log(a); // 10
}
foo();

闭包

定义

闭包 是基于 词法作用域 书写代码时所产生的自然结果,当函数可以记住并访问所在的词法作用域时就产生了闭包,即使函数是在当前词法作用域之外执行

闭包就是能够读取其他函数内部变量的函数,当在函数内部声明了内部函数并将内部函数作为值返回就会产生闭包,所以在本质上闭包就是将函数内部和函数外部连接起来的一座桥梁

特点

可以读取所在的外层函数内部的变量,让这些变量的值始终保持在内存中

JS 引擎有自动垃圾回收机制,当一个值失去引用时垃圾回收机制会根据特殊的算法找到它并将其回收,释放不再使用的内存空间,闭包可以阻止这个事情的发生

闭包之所以能访问其外层函数作用域中的变量,是因为闭包的作用域链中存在外层函数的变量对象。即使外层函数执行结束,但由于其变量对象仍然被内层函数的作用域引用,因此不会被内存回收,直到闭包执行结束后外层函数的变量对象才会被回收

无论通过任何手段将内部函数传递到所在词法作用域以外,它都会持有对原始定义作用域的引用,无论在何处执行这个函数都会使用闭包

// 例 1
function foo() {
  var a = 2;
  function baz(){
    console.log(a); // 2
  }
  bar(baz);
}

function bar(fn) {
  fn();
}
foo();

// 例 2
var fn;
function foo() {
  var a = 2;
  function baz() {
    console.log(a); // 2
  }
  fn = baz;
}

function bar() {
  fn();
}
foo();
bar(); 

本质上无论何时何地,若将函数(访问它们各自的词法作用域)当作第一级的值类型并到处传递,就会看到闭包在这些函数中的应用

定时器事件监听器Ajax 请求跨窗口通信Web Workers 等其他异步或同步任务中,只要使用了回调函数,实际上就是使用了闭包!

分析

var scope = "global scope"; 
function checkscope(){ 
  var scope = "local scope"; 
  function f() { 
    return scope; 
  } 
  return f; 
} 
var foo = checkscope(); 
foo();

分析一下这段代码中 执行上下文栈执行上下文 的变化情况

  • 进入全局代码,创建 全局执行上下文,全局执行上下文压入执行上下文栈,且全局执行上下文初始化

  • 初始化完成后内存中出现与全局环境相关的四个东西:

    • 全局环境的变量对象(用于存储全局环境的变量)
    • 全局环境的作用域作用域链(作用域链是一个链表,由多个作用域构成,全局环境的作用域链中只有一个全局环境的作用域,它指向全局环境的变量对象)
    • 全局环境的 执行环境执行环境栈(执行环境栈里面存放着一个个执行环境,栈顶的那个表示正在执行的环境,执行环境中有一个指针指向它的作用域链)
    • 函数的作用域链(此时函数的作用域链中只包含全局环境的作用域,并没有函数自己的作用域)

      注意:每个函数自己的作用域是在函数执行时才创建的,而函数的作用域链则是在函数所在的环境被执行时创建的

      当函数被执行时,它自己的作用域才会被添加到已经创建的作用域链的头部

  • 执行 checkscope 函数,创建 checkscope 函数执行上下文,checkscope 执行上下文被压入执行上下文栈

  • checkscope 函数执行上下文初始化,创建变量对象作用域链this

  • checkscope 函数执行完毕,checkscope 执行上下文从执行上下文栈中弹出

  • 执行 f 函数,创建 f 函数执行上下文,f 执行上下文被压入执行上下文栈

  • f 执行上下文初始化,创建变量对象作用域链this

  • 函数执行完毕,f 函数上下文从执行上下文栈中弹出

思考:当 f 函数执行时 checkscope 函数上下文已经被销毁了啊(即从执行上下文栈中被弹出),怎么还会读取到 checkscope 作用域下的 scope 值呢?

  • 原因是 f 执行上下文维护了一个作用域链,因为这个作用域链 f 函数依然可以读取到 checkscopeContext.AO 的值,说明当 f 函数引用了 checkscopeContext.AO 中的值时,即使 checkscopeContext 被销毁了,但 JS 依然会让 checkscopeContext.AO 活在内存中
  • f 函数依然可以通过它自己的函数作用域链找到它,正是因为 JS 做到了这一点,从而实现了闭包这个概念
fContext = { Scope: [fContext.AO, checkscopeContext.AO, globalContext.VO]}

应用

在实践中,闭包的应用场景很多,均是依据闭包能够访问其它函数上下文的变量对象的特性

  • 封装创建私有变量、函数以及方法

    // name 只能通过 getName 来访问
    function Person(name) {
      this.getName = function() {
        return name;
      }
    }
    
  • 存储变量

    闭包的另个特点是可以保存外部函数的变量,原理是基于 JS 中函数作用域链的特点,内部函数保留了对外部函数的活动变量的引用,所以变量不会被释放,举例如下:

    function B() {
      var x = 100;
      return {
        function() {
          return x;
        }
      }
    }
    var m = B(); // 运行 B 函数,生成活动变量 x 被 m 引用
    

    运行 B 函数,返回值就是 B 内部的匿名函数,此时 m 引用了变量 x,所以 B 执行后 x 不会被释放,利用这一点可以把比较重要或计算耗费很大的值存在 x 中,只需第一次计算赋值后就可以通过 m 函数引用 x 的值,不必重复计算同时也不容易被修改

    这种写法的优点是可能会用在把一些不常变动但计算比较复杂的值保存起来,就可以节省每次访问的时间

  • 模拟块级作用域

    for(var i = 0; i < 5; i ++) {
      (function(i) {
        setTimeout(function() {
          console.log(i);
        }, 1000);
      })(i);
    }
    
  • 模拟模块

    模块有 2 个特征:

    • 必须有外部的封闭函数,该函数必须至少被调用一次(每次调用都会创建一个新的模块实例)
    • 封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中形成闭包且可访问或修改私有的状态
    function module() {
      let inner = 1;
      let increaseInner = function() {
        inner ++;
      };
      let decreaseInner = function() {
        inner --;
      };
      let getInner = function() {
        return inner;
      };
      return {
        increaseInner,
        decreaseInner,
        getInner,
      }
    }
    let api = module();
    console.log(api.getInner());
    api.increaseInner();
    console.log(api.getInner());
    api.decreaseInner();
    console.log(api.getInner());
    
  • 柯里化

    柯里化是一种将使用多个参数的一个函数转换成一系列使用一个参数的函数的技术,即 fn(a,b,c) 会变成 fn(a)(b)(c)

    如要计算 x^y 可以用 Math.pow(x, y),不过考虑到经常计算 x^2 或 x^3,可以利用闭包创建新的函数 pow2 和 pow3

    'use strict';
    function make_pow(n) {
      return function (x) {
        return Math.pow(x, n);
      }
    }
    // 创建两个新函数
    var pow2 = make_pow(2);
    var pow3 = make_pow(3);
    console.log(pow2(5)); // 25
    console.log(pow3(7)); // 343
    
  • 在实际情况中常遇到这样一种情况,即有的函数只需要执行一次,其内部变量无需维护,如 UI 的初始化等,则可以使用闭包

    // 将全部 li 字体变为红色
    (function(){
      var els = document.getElementsByTagName('li');
      for(var i = 0,lng = els.length;i < lng;i++){
        els[i].style.color = 'red';
      }
    })();
    // 创建了一个匿名的函数并立即执行它,由于外部无法引用它内部的变量
    // 因此 els, i, lng 这些局部变量在执行完后很快就会被释放,节省内存
    // 关键是这种机制不会污染全局对象
    

注意点

  • 闭包会使得函数中的变量都被保存在内存中,内存消耗很大,所以不能滥用闭包,否则会容易造成网页的性能问题,IE 中可能导致内存泄露,解决方法:在退出函数之前将不使用的局部变量全部删除

  • 闭包可在父函数外部改变父函数内部变量的值,因此若把父函数当作对象使用,把闭包当作它的公用方法,把内部变量当作它的私有属性,这时一定要小心,不要随便改变父函数内部变量的值

  • 通常认为 IIFE 是典型的闭包例子,但严格来说它并不是闭包,因为函数并不是在它本身的词法作用域以外执行,它在定义时所在的作用域中执行。尽管 IIFE 本身并不是观察闭包的恰当例子,但它确实是创建了闭包且也是最常用来创建可以被封闭起来的闭包工具

    var a = 2;
    (function IIFE(){
    console.log(a); // 2 是通过普通的词法作用域查找而非闭包被发现的
    })()
    
  • JSECMAScript、BOM、DOM 组成,在某些浏览器中它们使用不同的语言实现,因此它们具有不同的垃圾回收机制,ECMAScript 对象采用标记清除算法回收内存,而某些浏览器的 DOM 对象采用引用计数算法回收内存,引用计数有个致命的缺点:无法回收循环引用的对象

    举个例子:若一个 DOM 对象 A 中的属性 a 指向另一个 DOM 对象 B,而 B 中有属性 b 指向对象 A,则这两个对象存在循环引用,垃圾回收机制就无法回收它们,这会造成了内存泄漏(大量内存得不到回收),只要循环引用的两个对象中存在一个 DOM 对象,就会导致内存泄漏,请看下面的例子

    // 这段代码获取了一个 DOM 对象且让这个对象的 onclick 属性指向了一个 JS 函数对象 
    // 而这个函数对象又指向了 DOM 对象的 id 属性,从而出现了循环引用 
    // 由于这两个对象中存在一个 DOM 对象,因此就会出现内存泄漏 
    function func () { 
      var dom = document.getElementById("xx"); 
      dom.onclick = function() { 
        alert(dom.id); 
      } 
    }
    

    要解决内存泄漏,只要破坏两个对象的相互引用即可

    上述代码要为 dom 添加一个点击事件,因此 dom.onclick 属性必须要指向一个 JS 函数对象,因此这个引用不能切断。而第二个引用是由 JS 函数对象指向 DOM 对象的,目的是为了获取 domid,可以通过如下代码切断这个引用

    function func () {
      var dom = document.getElementById("xx");
      var id = dom.id;
      dom.onclick = function() {
        alert(id);
      };
      dom = null;
    }
    
    function makeAdd(x) {
      return function(y) {
        return x + y; 
      };
    }
    var add1 = makeAdder(5);
    var add2 = makeAdder(10);
    console.log(add1(4)); // 9
    console.log(add2(3)); // 13
    
    // 通过赋值 null 可释放对闭包的引用
    add1 = null;
    add2 = null;
    
  • 闭包中的 this

    var name = "The Window";
    var obj = {
      name: "My Object",
      getName: function() {
        return function() {
          return this.name;
        };
      }
    };
    console.log(obj.getName()()); // The Window
    // 将这一部分解为 console.log(function(){return this.name;}());
    

必刷题

例题 1

var data = [];
for (var i = 0; i < 3; i++) {
  data[i] = function () {
    console.log(i);
  };
}
data[0](); // 3
data[1](); // 3
data[2](); // 3

当执行到 data[0] 函数前全局上下文的 VO 为:

globalContext = {
  VO: {
    data: [...],
    i: 3
  }
}

当执行 data[0] 函数时 data[0] 函数的作用域链为:data[0]ContextAO 并没有 i 值,所以会从 globalContext.VO 中查找,i3,所以打印的结果就是 3

data[0]Context = {
  Scope: [AO, globalContext.VO]
}

data[1]data[2] 同理

解决:改成闭包形式

var data = [];
for (var i = 0; i < 3; i++) {
  data[i] = (function (i) {
    return function(){
      console.log(i);
    }
  })(i);
}
data[0](); // 0
data[1](); // 1
data[2](); // 2

分析:当执行到 data[0] 函数前全局上下文的 VO

globalContext = {
  VO: {
    data: [...],
    i: 3
  }
}

当执行 data[0] 函数时 data[0] 函数的作用域链发生了改变:

data[0]Context = {
  Scope: [AO, 匿名函数Context.AO, globalContext.VO]
}

匿名函数Context = {
  AO: {
    arguments: { 
      0: 0,
      length: 1
    },
    i: 0
  }
}

data[0]ContextAO 没有 i 值,所以会沿着作用域链从匿名函数 Context.AO 中查找,这时就会找到 i0,找到了就不会继续往 globalContext.VO 中查找了,即使 globalContext.VO 也有 i 的值(值为 3),所以打印的结果就是 0

data[1]data[2] 同理

例题 2

function fun(n, o) {
  console.log(o);
  return {
    fun: function(m){
      return fun(m, n);
    }
  };
} 
var a = fun(0); a.fun(1); a.fun(2); a.fun(3); 
var b = fun(0).fun(1).fun(2).fun(3); 
var c = fun(0).fun(1); c.fun(2); c.fun(3); 
// a, b, c 的输出分别是什么?

分析:

  • 第一行 a

    • fun(0) 调用第一层函数,第一次调用 fun(0) 时,o -> undefined

    • 第二次调用 fun(1),m = 1,此时 fun 闭包了外层函数的 n,即第一次调用的 n = 0,因此在内部调用第一层 fun 函数 fun(1,0)o -> 0

    • 第三次调用 fun(2),m = 2,但依然是调用 a.fun,所以还是闭包了第一次调用的 n,因此在内部调用第一层 fun 函数 fun(2,0),o -> 0

    • a.fun(3) 同理

    • 结果:undefined, 0, 0, 0

  • 第二行 b

    • 第一次调用第一层函数 fun(0) 时,o -> undefined,而其返回值是一个对象,所以第二个 fun(1) 调用的是第二层 fun 函数,后面几个也是调用的第二层 fun 函数

    • 第二次调用 fun(1) 时,m = 1,此时 fun 闭包了外层函数的 n,即第一次调用的 n = 0,因此在内部调用第一层 fun 函数 fun(1,0),o -> 0

    • 第三次调用 fun(2)m = 2,此时当前的 fun 函数是第二次执行的返回对象;第二次第一层 fun 函数时是 (1,0),所以 n = 1,o = 0,返回时闭包了第二次的 n,因此第三次调用第三层 fun 函数时 m = 2,n = 1,即调用第一层 fun 函数 fun(2,1),o -> 1

    • 第四次调用 fun(3)m = 3,闭包了第三次调用的 n,同理最终调用第一层 fun 函数为 fun(3,2),o -> 2

    • 结果:undefined, 0, 1, 2

  • 第三行 c

    • 在第一次调用第一层 fun(0) 时,o -> undefined

    • 第二次调用 fun(1)m = 1,此时 fun 闭包了外层函数的 n,即第一次调用的 n = 0,即 m = 1,n = 0,并在内部调用第一层 fun 函数 fun(1,0),o -> 0

    • 第三次调用 fun(2)m = 2,此时 fun 闭包的是第二次调用的 n = 1,即 m = 2,n = 1,并在内部调用第一层 fun 函数 fun(2,1),o -> 1

    • 第四次 fun(3) 时同理,但依然是调用的第二次的返回值,遂最终调用第一层 fun 函数 fun(3,1),o -> 1

    • 结果:undefined, 0, 1, 1

this

为什么要用 this

下面这段代码可以在不同的上下文对象( meyou )中重复使用函数 identify(),不用针对每个对象编写不同版本的函数

若不使用 this,则需给 identify() 显示传入一个上下文对象,随着使用的模式越来越复杂,显示传递上下文对象会让代码变得越来越混乱

function identify(context) {
  return context.name.toUpperCase();
}
var me = { name: "Kyle" };
identify(me); // KYLE

this 提供了一种更优雅的方式来隐式“传递”一个对象引用(因此可将 API 设计得更加简洁且易于复用)

function identify() {
  return this.name.toUpperCase();
}
var me = { name: "Kyle" };
var you = { name: "Reader" };
identify.call(me); // KYLE
identify.call(you); // READER

对 this 的误解

误解 1:把 this 理解成指向函数自身

一般来说,什么情况下需要从函数内部引用函数自身呢?常见的原因是递归(从函数内部调用函数自身)或可写一个在第一次被调用后自己解除绑定的事件处理器

观察以下代码,this 并不像所想的那样指向函数本身。执行 foo.count = 0 时向函数对象 foo 添加了一个属性 count,但函数内部代码 this.count 中的 this 并不是指向那个函数对象,所以虽然属性名相同,根对象并不相同

function foo(num) {
  console.log("foo:" + num);
  this.count ++; // 记录 foo 被调用
}
foo.count = 0;
var i;
for (i=0; i<10; i ++) {
  if (i > 5) foo(i);
}
// foo: 6
// foo: 7
// foo: 8
// foo: 9
console.log(foo.count); // 0
    
// 解决:可创建另一个带 count 的属性对象,采用词法作用域技术
function foo(num) {
  console.log("foo:" + num);
  data.count ++; // 记录foo被调用
}
var data = { count: 0 };
var i;
for (i=0; i<10; i ++) {
  if(i > 5) foo(i);
}
console.log(data.count); // 4

若要从函数对象内部引用自身,只使用 this 是不够的,一般来说需通过一个指向函数对象的词法标识符(变量)来引用

function foo() {
  foo.count = 4; // foo 指向它自身
}
setTimeout(function(){
  // 匿名函数无法指向自身
},10)

可以用 arguments.callee 来引用当前正在运行的函数对象(注意:arguments.callee 已弃用)

误解 2:this 的作用域指向函数作用域

this 在任何情况下都不指向函数的词法作用域,作用域对象无法通过 JS 代码访问,它存在于 JS 引擎内部

思考以下代码:

  • 通过 this.bar() 来引用 bar 函数是绝对不可能成功的(后续会解释原因)

  • 调用 bar 函数的最自然方法是省略 this,直接使用词法引用标识符

  • 这里试图使用 this 联通 foobar 的词法作用域,从而让 bar 可访问 foo 作用域里的变量 a,这是不可能实现的 -- 不能使用 this 来引用一个词法作用域内部的东西

function foo(){
  var a = 2;
  this.bar();
}
function bar(){
  console.log(this.a);
}
foo(); // undefined

this 是什么

this 是个特别的关键字,被自动定义在所有函数的作用域中,它是函数运行时在函数体内部自动生成的一个对象,只能在函数体内部使用

this 既不是指向函数自身也不指向函数的词法作用域

this 是在运行时进行绑定的并不是在编写时绑定,它的上下文取决于函数调用时的各种条件

this 绑定和函数声明的位置无关,只取决于函数的调用方式,谁调用的就指向谁

当一个函数被调用时会创建一个执行上下文,该上下文会包含函数在哪被调用(调用栈)、函数调用方法、传入的参数等信息,this 就是上下文中的其中一个属性,会在函数执行过程中用到

this 的原理

var obj = {
  foo: function () {}
};
var foo = obj.foo;
obj.foo() // 写法一
foo(); // 写法二

var obj = {
  foo: function () {
    console.log(this.bar);
  },
  bar: 1
};

var foo = obj.foo;
var bar = 2;
obj.foo(); // 1
foo(); // 2

在上面代码中,虽然 obj.foo 和 foo 指向同一个函数,但执行结果可能不一样

这种差异的原因:函数体内部使用了 this 关键字,很多教科书会说 this 指的是函数运行时所在的环境,对于 obj.foo() 来说 foo 运行在 obj 环境,所以 this 指向 obj;对于 foo() 来说 foo 运行在全局环境,所以 this 指向全局环境,两者的运行结果不一样

内存的数据结构

  • JS 语言之所以有 this 的设计跟内存里面的数据结构有关系

  • var obj = { foo: 5 },将一个对象赋给变量 objJS 引擎会先在内存里生成一个对象 { foo: 5 },然后把这个对象的内存地址赋给变量 obj,即变量 obj 是一个地址(reference),若要读取 obj.foo,引擎先从 obj 拿到内存地址,再从该地址读出原始的对象,返回它的 foo 属性

  • 原始的对象以字典结构保存,每一个属性名都对应一个属性描述对象。上面例子的 foo 属性实际上是以下面的形式保存的,注意:foo 属性的值保存在属性描述对象的 value 属性里面

    image1.png

    image2.png

  • 函数

    上面的结构很清晰,问题在于属性值可能是一个函数:var obj = { foo: function() {} };,这时引擎会将函数单独保存在内存中,再将函数的地址赋值给 foo 属性的 value 属性

    image3.png

    image4.png

    由于函数是一个单独的值,因此它可以在不同的环境(上下文)执行

    var f = function() {};
    var obj = { f: f };
        
    // 单独执行
    f();
        
    // obj 环境执行
    obj.f();
    
  • 环境变量

    JS 允许在函数体内部引用当前环境的其他变量,以下代码中函数体里使用了变量 x,该变量由运行环境提供

    var f = function() {
      cobnsole.log(x);
    };
    

    由于函数可以在不同运行环境中执行,因此需要有一种机制能够在函数体内部获得当前的运行环境(context),所以 this 的设计目的就是在函数体内部指代函数当前的运行环境

    以下代码中函数体里面的 this.x 就是指当前运行环境中的 x

    var f = function() {
      console.log(this.x);
    };
    

    看下以下例子

    var f = function() {
      console.log(this.x);
    };
      
    var x = 1;
    var obj = {
      foo: f,
      x: 2,
    };
      
    // 单独执行
    f(); // 1
      
    // obj 环境执行
    obj.f(); // 2
    

    函数 f 在全局环境执行,this.x 指向全局环境的 x

    image5.png

    obj 环境执行,this.x 指向 obj.x

    image6.png

    obj.foo() 是通过 obj 找到 foo,所以就是在 obj 环境执行;一旦 var foo = obj.foo,变量 foo 就直接指向函数本身,所以 foo() 就变成在全局环境执行

调用位置

调用位置是指函数在代码中被调用的位置(而不是声明的位置)

最重要的是分析调用栈(为了到达当前执行位置所调用的所有函数,所关心的调用位置就是在当前正在执行的函数的前一个调用中),通过下面代码看下调用栈和调用位置

function baz() { 
  // 当前调用栈是:baz
  // 因此,当前调用位置是全局作用域
  console.log( "baz" );
  bar(); // <-- bar 的调用位置
}
function bar() {
  // 当前调用栈是 baz -> bar
  // 因此,当前调用位置在 baz 中
  console.log( "bar" );
  foo(); // <-- foo 的调用位置
}
function foo() {
  // 当前调用栈是 baz -> bar -> foo
  // 因此,当前调用位置在 bar 中
  console.log( "foo" );
} 
baz(); // <-- baz 的调用位置

注意:如何(从调用栈中)分析出真正的调用位置,因为它决定了 this 的绑定

可以把调用栈想象成一个函数调用链,就像在前面代码段的注释中所写的一样,但这种方法非常麻烦且容易出错

另一个查看调用栈的方法是使用浏览器的调试工具。绝大多数现代桌面浏览器都内置了开发者工具,其中包含 JS 调试器。就本例来说,可在工具中给 foo 函数的第一行代码设置一个断点或直接在第一行代码前插入 debugger; 语句。运行代码时调试器会在那个位置暂停,同时会展示当前位置的函数调用列表,这就是调用栈。因此若想要分析 this 的绑定,使用开发者工具得到调用栈,然后找到栈中第二个元素,这就是真正的调用位置

几个例子:

  • 若一个函数中有 this,尽管该函数是被最外层的对象所调用,this 指向的也只是它上一级的对象

    var o = {
      user: "追梦子",
      fn: function() {
        console.log(this.user); // 追梦子
      }
    };
    window.o.fn(); // this 指向 o
      
    var o = {
      a: 10,
      b: {
        // a:12,
        fn: function() {
          console.log(this.a); // undefined
        }
      }
    };
    o.b.fn(); // this 指向 b
    
  • 一个比较特殊的情况,this 永远指向的是最后调用它的对象即看它执行时是谁调用的,下例中虽然 fn 是被 b 所引用,但是在将 fn 赋值给 j 时并没有执行所以最终指向的是 window,这和上面例子不一样,上面是直接执行了 fn

    var o = {
      a: 10,
      b: {
        a: 12,
        fn: function() {
         console.log(this.a); // undefined
          console.log(this); // window
        }
      }
    };
    var j = o.b.fn;
    j(); // this 指向 window
    

绑定规则

默认绑定

属于全局性调用,是最常用的函数调用类型,因此 this 指向 全局对象

像这种直接使用而不带任何修饰的函数调用,就默认且只能应用 默认绑定,无法应用其他规则

function foo() {
  console.log( this.a );
}
var a = 2;
foo(); // 2

function f1(){
  return this;
}
f1() === window; // true

若使用严格模式(strict mode),则全局对象将无法使用默认绑定this 会绑定到 undefined,严格模式下与 foo() 的调用位置无关

注意:对于默认绑定来说,决定 this 绑定对象的并不是调用位置是否处于严格模式,而是函数体是否处于严格模式,若函数体处于严格模式则 this 会被绑定到 undefined,否则 this 会被绑定到全局对象

function foo() { "use strict";
  console.log( this.a );
}
var a = 2;
foo(); // TypeError: Cannot read properties of undefined (reading 'a')

function foo() {
  console.log( this.a );
}
var a = 2;
(function(){
  "use strict";
  foo(); // 2
})();

隐式绑定

作为对象方法的调用。函数还可以作为某个对象的方法调用,这时 this 就指向这个上下文对象

无论是直接在 obj 中定义还是先定义再添加为引用属性,该函数严格来说都不属于 obj 对象,然而调用位置会使用 obj 上下文来引用函数

当函数引用有上下文对象时,隐式绑定规则会把函数调用中的 this 绑定到这个上下文对象

function foo() {
  console.log( this.a );
}
var obj = {
  a: 2,
  foo: foo,
};
obj.foo(); // 2,因为调用 foo() 时 this 被绑定到 obj,因此 this.a 和 obj.a 是一样的

function foo() {
  console.log( this.a );
}
var obj2 = {
  a: 42,
  foo: foo
};
var obj1 = {
  a: 2,
  obj2: obj2
};
obj1.obj2.foo(); // 42,对象属性引用链中只有最顶层或说最后一层会影响调用位置

隐式丢失问题:一个最常见的 this 绑定问题就是被隐式绑定的函数会丢失绑定对象,即它会应用 默认绑定,从而把 this 绑定到 全局对象undefined 上(取决于函数体是否是严格模式)

function foo() {
  console.log( this.a );
}
var obj = {
  a: 2,
  foo: foo
};
// 虽然 bar 是 obj.foo 的一个引用,但实际上它引用 foo 函数本身
// 因此 bar 其实是一个不带任何修饰的函数调用,因此应用了默认绑定
var bar = obj.foo;
var a = "oops, global"; // a 是全局对象的属性
bar(); // "oops, global"

function foo() {
  console.log( this.a );
}
function doFoo(fn) {
  // fn 其实引用的是 foo 
  fn(); // <-- 调用位置!
}
var obj = {
  a: 2,
  foo: foo
};
var a = "oops, global"; // a 是全局对象的属性
doFoo( obj.foo ); // "oops, global",参数传递其实就是一种隐式赋值,因此传入函数时也会被隐式赋值

// 若把函数传入语言内置的函数而不是传入自己声明的函数,会发生什么呢?结果是一样的,没有区别
function foo() {
  console.log( this.a );
};
var obj = {
  a: 2,
  foo: foo
};
var a = "oops, global"; // a 是全局对象的属性
setTimeout( obj.foo, 100 ); // "oops, global"

回调函数丢失 this 绑定是非常常见的,除此之外还有一种情况 this 的行为会出乎意料:调用回调函数的函数可能会修改 this

在一些流行的 JS 库中事件处理器常会把回调函数的 this 强制绑定到触发事件的 DOM 元素上。这在一些情况下可能很有用,但有时它可能会让人感到非常郁闷,遗憾的是这些工具通常无法选择是否启用这个行为

无论哪种情况,this 的改变都是意想不到的,实际上是无法控制回调函数的执行方式,因此就没有办法控制会影响绑定的调用位置

之后会介绍如何通过固定 this 来修复(这里是双关,“修复” 和 “固定” 的英语单词都是 fixing)这个问题

显示绑定

在分析隐式绑定时须在一个对象内部包含一个指向函数的属性并通过这个属性间接引用函数,从而把 this 间接(隐式)绑定到这个对象上

若不想在对象内部包含函数引用而想在某个对象上强制调用函数,可使用函数的 callapply 方法,它们的作用都是改变函数的 this 指向

  • 第一参数是一个对象,它们会把这个对象绑定 this,接着在调用函数时指定这个 this

  • 区别

    • call 从第二个参数开始所有的参数都是原函数的参数
    • apply 接受两个参数且第二个参数必须是数组,这个数组代表原函数的参数列表
  • 若传入了一个原始值(字符串、布尔值、数字类型)来当作 this 的绑定对象,这个原始值会被转换成它的对象形式,即 new String(..)new Boolean(..)new Number(..)

    function foo() {
      console.log( this.a );
    }
    var obj = {
      a:2
    };
    foo.call( obj ); // 2:通过 foo.call(..),可以在调用 foo 时强制把它的 this 绑定到 obj 上
    

    显式绑定仍然无法解决上面提出的丢失绑定问题

硬绑定

一种显式的强制绑定,以下代码创建了 bar 函数并在它内部手动调用了 foo.call(obj),因此强制把 foothis 绑定到了 obj,无论之后如何调用函数 bar,它总会手动在 obj 上调用 foo

function foo() {
  console.log( this.a );
}
var obj = {
  a:2
};
var bar = function() {
  foo.call( obj );
};
bar(); // 2
setTimeout( bar, 100 ); // 2
bar.call( window ); // 2

硬绑定的典型应用场景就是创建一个包裹函数,传入所有的参数并返回接收到的所有值

function foo(something) {
  console.log( this.a, something );
  return this.a + something;
}
var obj = { a:2 };
var bar = function() {
  return foo.apply( obj, arguments );
}; 
var b = bar( 3 ); // 2 3 
console.log( b ); // 5

另一种使用方法是创建一个可以重复使用的辅助函数

function foo(something) {
  console.log( this.a, something );
  return this.a + something;
}

// 简单的辅助绑定函数 
function bind(fn, obj) {
  return function() {
    return fn.apply( obj, arguments );
  };
}
var obj = { a:2 }; 
var bar = bind(foo, obj);
var b = bar(3); // 2 3
console.log(b); // 5

Function.prototype.bind(..)

由于硬绑定是一种非常常用的模式,所以在 ES5 中提供了内置的方法 Function.prototype.bind,会返回一个硬编码的新函数,它会把参数设置为 this 的上下文并调用原始函数

Function.prototype.bind(..) 会创建一个新的包装函数,这个函数会忽略它当前的 this 绑定(无论绑定的对象是什么)并把提供的对象绑定到 this 上,它的用法如下:

function foo(something) {
  console.log( this.a, something );
  return this.a + something;
}
var obj = { a:2 };
var bar = foo.bind(obj);
var b = bar(3); // 2 3
console.log(b); // 5

API 调用的“上下文”

第三方库的许多函数及 JS 语言和宿主环境中许多新的内置方法都提供一个可选的参数,通常被称为“上下文” context,其作用和 bind(..) 一样,确保回调函数使用指定的 this,这些函数实际上就是通过 callapply 实现了显式绑定,这样可以少些一些代码

function foo(el) {
  console.log( el, this.id );
}
var obj = { id: "awesome" }; 
// 调用 foo(..) 时把 this 绑定到 obj
[1, 2, 3].forEach( foo, obj ); // 1 awesome 2 awesome 3 awesome

new 绑定

JS 中,构造函数只是一些使用 new 操作符调用的函数

它们并不会属于某个类也不会实例化一个类,实际上它们甚至都不能说是一种特殊的函数类型,它们只是被 new 操作符调用的普通函数而已

使用 new 来调用函数或说发生构造函数调用时,会自动执行下面的操作:

  • 创建或构造一个全新的对象,作为将要返回的对象实例

  • 这个新对象会被执行 [[原型]] 连接,将新的空对象的原型指向了构造函数的 prototype 属性

  • 这个新的空对象会绑定到函数内部的 this

  • 开始执行构造函数内部的代码,若函数没有返回其他对象则 new 表达式中的函数调用会自动返回这个新对象,构造函数内部 this 指向的是这个新生成的空对象,所有针对 this 的操作都会发生在这个空对象上

new 操作符会改变函数 this 的指向问题

// 使用 new 来调用 foo(..) 时,会构造一个新对象并把它绑定到 foo(..) 调用中的 this 上
function foo(a) {
  this.a = a;
}
var bar = new foo(2);
console.log( bar.a ); // 2

// 若原函数返回一个对象类型,则将无法返回新对象,将丢失绑定 this 的新对象
function foo(){
  this.a = 10;
  return new String("捣蛋鬼");
}
var obj = new foo();
console.log(obj.a); // undefined
console.log(obj); // String {'捣蛋鬼'}

优先级

显式绑定隐式绑定 优先级更高,即在判断时应当先考虑是否可以应用 显式绑定

function foo() {
  console.log( this.a );
}
var obj1 = {
  a: 2,
  foo: foo
};
var obj2 = {
  a: 3,
  foo: foo
};

obj1.foo(); // 2
obj2.foo(); // 3
obj1.foo.call( obj2 ); // 3
obj2.foo.call( obj1 ); // 2

new 绑定隐式绑定 优先级高

function foo(something) {
  this.a = something;
}
var obj1 = { foo: foo };
obj1.foo(2);
var bar = new obj1.foo(4);
console.log( obj1.a ); // 2
console.log( bar.a ); // 4

new 绑定显式绑定 谁的优先级更高呢?newcall/apply 无法一起使用,因此无法通过 new foo.call(obj1) 来直接进行测试,但可使用 硬绑定 来测试它俩的优先级

bar硬绑定obj1 上,但 new bar(3) 并没有像预计那样把 obj1.a 修改为 3,相反 new 修改了硬绑定(到 obj1 的)调用 bar 中的 this,因为使用了 new 绑定 得到了一个名字为 baz 的新对象且 baz.a 的值是 3

function foo(something) {
  this.a = something;
}
var obj1 = {};
var bar = foo.bind(obj1); 
bar(2); 
console.log( obj1.a ); // 2

var baz = new bar(3);
console.log( obj1.a ); // 2
console.log( baz.a ); // 3

为什么要在 new 中使用 硬绑定 函数,直接使用普通函数不是更简单吗?

  • 之所以要在 new 中使用 硬绑定 函数,主要目的是预先设置函数的一些参数,这样在使用 new 进行初始化时就可只传入其余的参数

bind 的功能之一就是可以把除了第一个参数(第一个参数用于绑定 this )之外的其他参数都传给下层的函数(这种技术称为“部分应用”,是 “柯里化” 的一种)

function foo(p1,p2) {
  this.val = p1 + p2;
}
// 之所以使用 null 是因为在本例中并不关心硬绑定的 this 是什么
// 反正使用 new 时 this 会被修改
var bar = foo.bind(null, "p1");
var baz = new bar("p2");
baz.val; // p1p2

优先级总结:new 绑定 > 显示绑定 > 隐式绑定 > 默认绑定

判断 this

  • 首先判断函数是否在 new 中调用(new 绑定),若是则 this 绑定的是 新创建的对象

  • 函数是否通过 call、apply(显式绑定)硬绑定 调用,若是则 this 绑定的是 指定的对象

  • 函数是否在某个上下文对象中调用(隐式绑定),若是则 this 绑定的是 那个上下文对象

  • 若都不是上诉的情况则使用 默认绑定,若在 严格模式 下就绑定到 undefined,否则绑定到 全局对象

绑定例外

规则总有例外,这里也一样。在某些场景下 this 的绑定行为会出乎意料,你认为应当应用其他绑定规则时,实际上应用的可能是 默认绑定 规则

被忽略的 this

若把 nullundefined 作为 this 的绑定对象传入 callapplybind 中,这些值在调用时会被忽略,实际应用的是 默认绑定

function foo() {
  console.log( this.a );
}
var a = 2;
foo.call( null ); // 2

什么情况下会传入 null 呢?

  • 一种非常常见的做法是使用 apply 来展开一个数组并当作参数传入一个函数

  • 类似地 bind 可对参数进行 柯里化(预先设置一些参数),这种方法有时非常有用

  • 以上所述两种方法都需传入一个参数当作 this 的绑定对象,若函数并不关心 this 但仍需传入一个占位值,这时 null 可能就是一个不错的选择

ES6 中可用 ... 操作符代替 apply 来展开数组,如 foo(...[1,2])foo(1,2) 是一样的,这样可以避免不必要的 this 绑定,可惜在 ES6 中没有 柯里化 的相关语法,因此还是需要使用 bind

function foo(a,b) {
  console.log("a:" + a + ", b:" + b);
}
// 把数组展开成参数
foo.apply(null, [2, 3]); // a:2, b:3 
// 使用 bind 进行柯里化
var bar = foo.bind( null, 2 ); 
bar(3); // a:2, b:3

然而总是使用 null 来忽略 this 绑定可能产生一些副作用,若某个函数确实使用了 this (如第三方库中的一个函数),则 默认绑定 规则会把 this 绑定到全局对象(在浏器中这个对象是 window),这将导致不可预计的后果,如修改全局对象,这种方式可能会导致许多难以分析和追踪的问题

更安全的 this

一种更安全的做法是 传入一个特殊的对象,把 this 绑定到这个对象不会对程序产生任何副作用,就像网络(以及军队)一样,可以创建 DMZ(demilitarized zone,非军事区)对象 —— 它就是一个空的非委托的对象

若在忽略 this 绑定时总是传入一个 DMZ 对象,则什么都不用担心,因为任何对于 this 的使用都会被限制在这个空对象中,不会对全局对象产生任何影响

在 JS 中创建一个空对象最简单的方法是 Object.create(null)

Object.create(null){} 很像,但并不会创建 Object.prototype,所以它比 {} 更空

function foo(a,b) {
  console.log( "a:" + a + ", b:" + b );
}
// DMZ 空对象
var ø = Object.create( null ); // 把数组展开成参数    
foo.apply( ø, [2, 3] ); // a:2, b:3 
// 使用 bind(..) 进行柯里化
var bar = foo.bind( ø, 2 );
bar( 3 ); // a:2, b:3

间接引用

有可能有意或无意地创建一个函数的间接引用,在这种情况下调用这个函数会应用 默认绑定 规则,间接引用最容易在赋值时发生

// 赋值表达式 p.foo = o.foo 的返回值是目标函数的引用,因此调用位置是 foo() 而不是 p.foo() 或 o.foo()
// 根据之前所诉这里会应用默认绑定
function foo() {
  console.log( this.a );
}
var a = 2;
var o = {
  a: 3,
  foo: foo
};
var p = {
  a: 4
};
o.foo(); // 3 
(p.foo = o.foo)(); // 2

软绑定

硬绑定 这种方式可把 this 强制绑定到指定的对象(除了使用 new 时),防止函数调用应用 默认绑定 规则。但 硬绑定 的问题在于 硬绑定 会大大降低函数的灵活性,使用 硬绑定 后则无法使用 隐式绑定显式绑定 来修改 this

若可以给默认绑定 指定一个 全局对象undefined 以外的值,则可实现和 硬绑定 相同的效果,同时保留 隐式绑定显式绑定 修改 this 的能力,可通过一种被称为 软绑定 的方法来实现想要的效果

除了 软绑定 之外 softBind 的其他原理和 ES5 内置的 bind 类似,它会对指定函数进行封装,先检查调用时的 this,若 this 绑定到 全局对象undefined,则把指定的默认对象 obj 绑定到 this,否则不会修改 this,此外这段代码还支持可选的 柯里化

if (!Function.prototype.softBind) {
  Function.prototype.softBind = function(obj) {
    var fn = this;
    // 捕获所有 curried 参数
    var curried = [].slice.call(arguments, 1);
    var bound = function() {
      return fn.apply((!this || this) === (window || global) ? obj : this, curried.concat.apply( curried, arguments ) );
    };
    bound.prototype = Object.create(fn.prototype); 
    return bound;
  };
}

function foo() {
  console.log("name: " + this.name);
}

var obj = { name: "obj" },
  obj2 = { name: "obj2" },
  obj3 = { name: "obj3" };
var fooOBJ = foo.softBind(obj);

fooOBJ(); // name: obj
obj2.foo = foo.softBind(obj);
obj2.foo(); // name: obj2 
fooOBJ.call(obj3); // name: obj3

当 this 碰到 return 时

若返回值是一个对象,则 this 指向的就是那个返回的对象,若返回值不是一个对象则 this 还是指向函数的实例

function fn() {
  this.user = '追梦子';
  return {};
}
var a = new fn;
console.log(a.user); // undefined

function fn() {
  this.user = '追梦子';
  return function(){};
}
var a = new fn;
console.log(a.user); // undefined

function fn(){
  this.user = '追梦子';
  return 1;
}
var a = new fn;
console.log(a.user); // 追梦子

function fn() {
  this.user = '追梦子';
  return undefined;
}
var a = new fn;
console.log(a.user); // 追梦子

function fn() {
  this.user = '追梦子';
  return null; // 虽然 null 也是对象,但在这里 this 还是指向那个函数的实例,因为 null 比较特殊
}
var a = new fn;
console.log(a.user); //追梦子

示例分析

例 1

// 看 () 的左边是 bar,bar 属于全局对象,所以 `this` 指向全局对象
function bar() {
  alert(this);
}
bar(); // [object Window]

// 先看 () 左边是 baz,baz 属于 foo,所以 baz 里的 this 指向的就是 foo
var foo = {
  baz: function() {
    alert(this);
  }
}
foo.baz(); // [object Object]

// () 左边是 anotherBaz,属于全局对象,this 指向全局对象
var anotherBaz = foo.baz;
anotherBaz(); // [object Window]

// () 左边是 bar,bar 属于 foo.baz,所以 this 指向 foo.baz,this.anum = foo.baz.anum = 20
var anum = 0;
var foo = {
  anum: 10,
  baz: {
    anum: 20,
    bar: function() {
      console.log(this.anum);
    }
  }
};
foo.baz.bar(); // 20

// () 左边是 hello,hello 属于全局对象,所以 this 指向全局对象,this.anum = window.anum = 0
var hello = foo.baz.bar;
hello(); // 0

例 2

const obj = {
  name: 'spike',
  friends: ['deer', 'cat'],
  loop: function() {
    // () 左边是 loop,属于 obj,所以这个 this 指向 obj   
    this.friends.forEach(function (friend) {
      // 在 forEach 中的 this 并不是指向 obj,而是指向全局对象
      // 看 () 左边,在 forEach 中 () 左边是 function 而不是一个引用,所以下面的 this 指向的就是全局对象
      console.log(`${this.name} knows ${friend}`); 
      console.log(this === global); // 在 nodejs 环境下,全局对象为 global
    }) 
  }
}
obj.loop();
// $ node test
// undefined knows dear
// true 
// undefined knows cat
// true

例 3

构造函数里的 this 指向

  • 当使用 new 关键字去执行构造函数时构造函数中的 this 指向的就是新建的那个对象实例

  • 若没有用 new 关键字去执行构造函数,那就要分析函数被调用时所属的作用域了

var savedThis;
function Constr() {
  savedThis = this; // 保存构造函数中的 this 
}
var inst = new Constr(); // 通过 new 关键字执行构造函数
// 构造函数中的 this 指向的就是新创建的对象实例 inst
console.log(savedThis === inst);  // true

function Point(x, y) {
  this.x = x;
  this.y = y;
}
var p = Point(7, 5); // 没有用 new 关键字去执行构造函数!
// 没有用 new,所以构造函数没有返回一个实例对象, 所以 p === undefined
console.log(p === undefined);  // true
// 没有用 new 关键字,Point (7,5) 就只是把函数执行了一遍
// () 左边是 Point,属于全局对象,所以 this 指向全局对象
console.log(x); // 7
console.log(y); // 5

例 4

event handler 事件处理器中 this 的指向,哪个元素触发事件 this 就指向那个元素

<div id="#test">I am an element with id #test</div>
function doAlert() {
  alert(this.innerHTML);
}
doAlert(); // undefined,this 指向全局对象

var myElem = document.getElementById('test');
myElem.onclick = doAlert;
alert(myElem.onclick === doAlert); // true
myElem.onclick(); // I am an element,()左边是 onclick 即 doAlert,属于 myElem,所以 this 指向 myElem

例 5

var length = 10;
function fn() {
  console.log(this.length);
}
const obj = {
  length: 5,
  method: function(fn) {
    // method 这个函数传入了两个参数,一个参数为 fn()
    // fn() 为普通函数,`this` 指向函数的调用者
    // 此时指向全局(也可以看这个函数前面没有点),所以运行结果为 10
    fn(); // 10

    // arguments 表示函数的所有参数,是一个类数组的对象
    // arguments[0] () 可看成是 arguments.0(),调用该函数的是 arguments
    // 此时 this 指向 arguments,this.length 就是 angument.length,即传入的参数总个数 2
    arguments[0](); // 2
  }
};
obj.method(fn, 1);

例 6

window.val = 1;
var obj = {
  val: 2,
  dbl: function() {
    this.val *= 2;
    val *= 2;
    console.log(val);
    console.log(this.val);
  }
};
// obj.dbl() 这行代码执行时 this 指向 obj,所以 this.val === obj.val *= 2,最后结果为 4
// val *= 2 === window.val *= 2,最后结果是 2
obj.dbl(); // 2,4

var func = obj.dbl;
// func() 执行时,func() 没有任何前缀,此时 this 指向 window,所以 this.val === window.val *= 2,此时 window.val 为 4
// val *= 2 === window.val *= 2,最后结果为 8
// 最后 console.log(this.val) 与 console.log(val) 指的都是 window.val,最后结果都是 8
func(); // 8 8

例 7

var x = 10;
var obj = {
  x: 20,
  f: function(){
    console.log(this.x); // 20,this 指向 obj 上下文
    var foo = function(){
      console.log(this.x); // 10,this 指向 window
    };
    foo();
  }
};
obj.f();

例 8

function foo(arg) { 
  this.a = arg; 
  return this; 
}; 
var a = foo(1);
var b = foo(10); 
console.log(a.a); // undefined 
console.log(b.a); // 10

foo(1) 执行时 this 指向 window;函数里等价于 window.a = 1, return window

var a = foo(1) 等价于 window.a = windowvar awindow.a),将刚刚赋值的 1 替换掉了,所以这里的 a 的值是 window,即 window.a = window,window.a.a = window

foo(10) 和第一次一样,都是 默认绑定,此时将 window.a 赋值 10,注意这里是关键,原来 window.a = window,现在被赋值成了 10,变成了值类型,所以 a.a = undefined(验证这一点只需要将 var b = foo(10) 删掉,这里的 a.a 还是 window

  • var b = foo(10) 等价于 window.b = window
  • a = window.a = 10, a.a = undefined,b = window,b.a = window.a = 10

例 9

var x = 10;
var obj = {
  x: 20,
  f: function(){
    console.log(this.x);
  }
};
var bar = obj.f;
var obj2 = {
  x: 30,
  f: obj.f
};
obj.f(); // 20,this 指向 obj,隐性绑定
bar(); // 10,this 指向全局
obj2.f(); // 30,this 指向 obj2

例 10

// 第一个 foo 函数里的 getName 将创建到全局 window 上
function foo() { 
  getName = function() { 
    console.log(1); 
  }; 
  return this; 
}

// 这个 getName 添加到 foo 中
foo.getName = function() { 
  console.log(2); 
};

// 这个 getName 创建到 foo 原型上,在用 new 创建新对象时将直接添加到新对象上
foo.prototype.getName = function() { 
  console.log(3); 
};

// 这个 getName 也是创建到全局 window 上
var getName = function() { 
  console.log(4); 
};

// 这个 getName 同样是创建到 window 上,但其不会被调用
// 因为函数声明的提升优先级最高,所以上面的函数表达式将永远替换这个同名函数
// 除非在函数表达式赋值前去调用 getName(),但在本题中函数调用都在函数表达式之后,所以这个函数可以忽略了
function getName() { 
  console.log(5); 
}

// 隐式绑定
foo.getName(); // 2

// 涉及到函数提升的问题,5 会被 4 覆盖
getName(); // 4

// 这里的 foo 函数执行完成了两件事:
// 1、先执行 foo,将 window.getName 设置为 1
// 2、返回 window
// 故 foo().getName() 等价于 window.getName 输出 1
foo().getName(); // 1

// 刚刚上面的函数刚把 window.getName 设置为 1,故同上输出 1
getName(); // 1

// new 对一个函数(即 foo.getName)进行构造调用,返回一个新对象
new foo.getName(); // 2

// new 是对一个函数进行构造调用,它直接找到了离它最近的函数 foo 并返回了新对象,等价于 var obj = new foo(),obj.getName() 
// 输出的是之前绑定到 prototype 上的那个 getName(因为使用 new 后会将函数的 prototype 连接到新对象)
new foo().getName(); // 3

// 这里等价于 var obj = new foo(),var obj1 = new obj.getName()
// obj 是一个函数名为 foo 的对象,obj1 是一个函数名为 obj.getName 的对象,obj 有 getName 3,即输出 3
new new foo().getName(); // 3

扩展:bind() 连续调用多次,this 的绑定值是什么?

var bar = function() {
  console.log(this.x);
};
var foo = {x: 3};
var sed = {x: 4};
var func = bar.bind(foo).bind(sed);
func();

var fiv = {x: 5};
var func = bar.bind(foo).bind(sed).bind(fiv);
func();

两次调用函数的结果均为 3

原因:在 JS 中调用多次 bind 是无效的,更深层次的原因是 bind 的实现相当于使用函数在内部包了一个 call/apply,第二次 bind 相当于再包住第一次 bind,所以第二次以后的 bind 是无法生效的

箭头函数

箭头函数是 ES6API,箭头函数并不是使用 function 关键字定义的,而是使用被称为 胖箭头 的操作符 => 定义的

箭头函数不使用 this 的四种标准规则,而是根据当前的词法作用域来决定 this,具体来说箭头函数会继承外层函数的 this 绑定(无论 this 绑定到什么)

箭头函数有两个主要的优点:

  • 非常简明的语法
  • 直观的作用域 和 this 的绑定

语法

const add = (a, b) => a + b;  
const getFirst = array => array[0];

高级语法:若想返回一个对象,用括号包起来,不然没有括号会误以为在写一个函数的函数体,如 (name, description) => ({name: name, description: description});

箭头函数最常用于回调函数中,如事件处理器定时器

function foo() {
  setTimeout(() => {
    // 这里的 this 在词法上继承自 foo
    console.log( this.a );
  }, 100);
}
var obj = { a: 2 };
foo.call(obj); // 2

箭头函数没有自己的 this

对于普通函数来说内部的 this 指向函数运行时所在的对象,但这一点对箭头函数不成立

箭头函数并没有自己的执行上下文,因此箭头函数没有自己的 this 对象,内部的this就是定义时上层作用域中的this,即箭头函数内部的 this指向是固定的,相比之下普通函数的this 指向是可变的

function foo() {
  // setTimeout 的参数是个箭头函数,这个箭头函数的定义生效是在`foo`函数生成时,而它的真正执行要等到 100 毫秒后
  // 若是普通函数,执行时 this 应该指向全局对象 window,这时应输出 21
  // 但箭头函数导致 this 总是指向函数定义生效时所在的对象,本例是{id: 42},所以打印出来的是 42
  setTimeout(() => {
    console.log('id:', this.id); // id: 42
  }, 100);
}
var id = 21;
foo.call({ id: 42 }); 

下面例子是回调函数分别为箭头函数和普通函数,对比它们内部的 this 指向

// Timer 函数内部设置了两个定时器,分别使用了箭头函数和普通函数
// 前者的 this 绑定定义时所在的作用域即 Timer 函数,后者的 this 指向运行时所在的作用域即全局对象
// 所以 3100 毫秒之后,timer.s1 被更新了 3 次,而 timer.s2 一次都没更新
function Timer() {
  this.s1 = 0;
  this.s2 = 0;
  // 箭头函数
  setInterval(() => this.s1 ++, 1000);
  // 普通函数
  setInterval(function () {
    this.s2 ++;
  }, 1000);
}
var timer = new Timer();

setTimeout(() => console.log('s1: ', timer.s1), 3100);
setTimeout(() => console.log('s2: ', timer.s2), 3100);

// s1:  3
// s2:  0

下面是 Babel 转换箭头函数产生的 ES5 代码,转换后的 ES5 版本清楚地说明了箭头函数里根本没有自己的 this 而是引用外层的 this

// ES6
function foo() {
  setTimeout(() => {
    console.log('id:', this.id);
  }, 100);
}

// ES5
function foo() {
  var _this = this;

  setTimeout(function () {
    console.log('id:', _this.id);
  }, 100);
}

箭头函数实际上可让 this 指向固定化

箭头函数实际上可让 this 指向固定化,使得它不再可变,这种特性很有利于封装回调函数。下面的例子是 DOM 事件的回调函数封装在一个对象里面

var handler = {
  id: '123',
  // init() 方法中使用了箭头函数,这导致这个箭头函数里面的 this 总是指向 handler
  // 若回调函数是普通函数则运行 this.doSomething() 这一行会报错,因为此时 this 指向 document 对象
  init: function() {
    document.addEventListener('click', event => this.doSomething(event.type), false);
  },
  doSomething: function(type) {
    console.log('Handling ' + type  + ' for ' + this.id);
  }
};

箭头函数不可以当作构造函数使用

箭头函数不可以当作构造函数使用,即不可以对箭头函数使用 new 命令,否则会抛出一个错误。为什么箭头函数不能作为构造函数呢?

构造函数是通过 new 关键字来生成对象实例,生成对象实例的过程也是通过构造函数给实例绑定 this 的过程,而箭头函数没有自己的 this,它内部的 this 是外层代码块的 this

创建对象过程中 new 首先会创建一个空对象并将这个空对象的 __proto__ 指向构造函数的 prototype,从而继承原型上的方法,但是箭头函数没有 prototype,因此不能使用箭头作为构造函数,即不能通过 new 操作符来调用箭头函数

let a = () => {};
console.log(a.prototype); // undefined
// 构造函数生成实例的过程
function Person(name,age) {
  this.name = name;
  this.age = age;
}
var p = new Person('张三', 18);

// new 关键字生成实例过程如下
// 1. 创建空对象 p
var p = {};

// 2. 将空对象 p 的原型链指向构造器 Person 的原型
p.__proto__ = Person.prototype;

// 3. 将 Person() 函数中的 this 指向 p
// 若此处 Person 为箭头函数,而箭头函数没有自己的 this,call 方法无法改变箭头函数的指向,也就无法指向 p
Person.call(p);

除了 this,有三个变量在箭头函数中也不存在

**除了this,以下三个变量在箭头函数中也是不存在的,均指向外层函数的对应变量:

  • arguments(若要获取箭头函数不定量参数用则可以用 ES6rest 参数代替)
  • super
  • new.target
// 箭头函数内部的变量 arguments ,其实是函数 foo 的 arguments 变量
function foo() {
  setTimeout(() => {
    console.log('args:', arguments); // args: Arguments(4) [2, 4, 6, 8, callee: ƒ, Symbol(Symbol.iterator): ƒ]
  }, 100);
}
foo(2, 4, 6, 8);

var fun = () => {
  console.log(arguments);
};
fun(1); // Uncaught ReferenceError:   arguments is not defined

// 解决办法
var fun = (...args) => {
  console.log(args);
};
fun(1); // 输出:[1]

箭头函数的 this 绑定后无法被修改

箭头函数的 this 绑定后无法被修改(new 也不行),由于箭头函数没有自己的 this,因此当然也就不能用 callapplybind 这些方法来改变 this 的指向(callaaplybind 会默认忽略第一个参数,但可以正常传参)

// foo() 内部创建的箭头函数会捕获调用时 foo() 的 this
// 由于 foo() 的 `this` 绑定到 obj1, bar (引用箭头函数) 的 `this` 也会绑定到 obj1
function foo() {
  // 返回一个箭头函数
  return (a) => {
    // this 继承自 foo
    console.log(this.a);
  };
}
var obj1 = { a:2 };
var obj2 = { a:3 };
var bar = foo.call(obj1);
bar.call(obj2); // 2, 不是 3 !

当箭头函数外层没有普通函数,它的 this 会指向哪里?

上文说过普通函数的默认绑定规则是:在非严格模式下默认绑定的 this 指向全局对象,严格模式下 this 指向 undefined

若箭头函数外层没有普通函数继承,它 this 指向的规则:箭头函数在全局作用域下严格模式和非严格模式下它的 this 都会指向window(全局对象)

上文说到,箭头函数的 this 指向外层第一个普通函数时,它的 argumens 继承于该普通函数;若箭头函数的 this 指向全局时,则使用 arguments 会报未声明的错误

let b = () => {
  console.log(arguments);
};
b(1, 2, 3, 4); // Uncaught ReferenceError: arguments is not defined

箭头函数不支持重命名函数参数

箭头函数不支持重命名函数参数,普通函数的函数参数支持重命名

function func1(a, a) {
  console.log(a, arguments); // 2 Arguments(2) [1, 2, callee: ƒ,     Symbol(Symbol.iterator): ƒ]
}
var func2 = (a, a) => {
  console.log(a); // Uncaught SyntaxError: Duplicate parameter name not allowed in this context
}; 
func1(1, 2); 
func2(1, 2);

箭头函数内部还可以再使用箭头函数

// ES5 语法的多重嵌套函数
function insert(value) {
  return {into: function (array) {
    return {after: function (afterValue) {
      array.splice(array.indexOf(afterValue) + 1, 0, value);
      return array;
    }};
  }};
}
insert(2).into([1, 3]).after(1); // [1, 2, 3];

// 上面这个函数可以使用箭头函数改写
let insert = (value) => ({into: (array) => ({after: (afterValue) => {
  array.splice(array.indexOf(afterValue) + 1, 0, value);
  return array;
}})});
insert(2).into([1, 3]).after(1); // [1, 2, 3]

不适用的场景

由于箭头函数使得 this“动态”变成“静态”,下面两个场合不应使用箭头函数

  • 第一个场合:定义对象的方法且该方法内部包括 this

    const cat = {
      lives: 9,
      jumps: () => {
        this.lives --;
      }
    }
    

    上面代码中,调用cat.jumps()时若是普通函数,该方法内部的 this 指向 cat,若写成上面那样的箭头函数,使得 this 指向全局对象,因此不会得到预期结果。这是因为对象不构成单独的作用域,导致 jumps 箭头函数定义时的作用域就是全局作用域

    再看一个例子

    globalThis.s = 21;
    
    const obj = {
      s: 42,
      m: () => console.log(this.s)
    };
    
    obj.m(); // 21
    

    上面例子中,obj.m() 使用箭头函数定,JS 引擎的处理方法是先在全局空间生成这个箭头函数,然后赋值给obj.m,这导致箭头函数内部的 this 指向全局对象,所以 obj.m() 输出的是全局空间的 21,而不是对象内部的 42,上面的代码实际上等同于下面的代码

    globalThis.s = 21;
    globalThis.m = () => console.log(this.s);
    
    const obj = {
      s: 42,
      m: globalThis.m
    };
    
    obj.m() // 21
    

    注意:由于上面这个原因,对象的属性建议使用传统的写法定义,不要用箭头函数定义

  • 第二个场合:需要动态 this 时也不应使用箭头函数

    var button = document.getElementById('press');
    button.addEventListener('click', () => {
      this.classList.toggle('on');
    });
    

    上面代码运行时点击按钮会报错,因为 button 的监听函数是一个箭头函数,导致里面的 this 就是全局对象,若改成普通函数 this 就会动态指向被点击的按钮对象

  • 若函数体很复杂,有许多行或函数内部有大量的读写操作,不单纯是为了计算值,这时也不应该使用箭头函数,而是要使用普通函数,这样可以提高代码可读性

其他

不可以使用 yield 命令,箭头函数不能用作 Generator 函数

箭头函数可以像 bind 一样确保函数的 this 被绑定到指定对象,此外其重要性还体现在它用更常见的词法作用域取代了传统的 this 机制(在 ES6 前就已经使用一种几乎和箭头函数完全一样的模式 self = this

  • 虽然 self = this 和箭头函数看起来均可取代 bind,但从本质上来说它们想替代的是 this 机制

  • 若经常编写 this 风格的代码,但绝大部分时都会使用 self = this箭头函数 来否定 this 机制,那或许应当:

    • 只使用词法作用域并完全抛弃错误 this 风格的代码

    • 完全采用 this 风格,在必要时使用 bind,尽量避免使用 self = this箭头函数

    • 当然,包含这两种代码风格的程序可以正常运行,但在同个函数或同个程序中混合使用这两种风格通常会使代码更难维护且可能也会更难编写

function foo() {
  var self = this; // lexical capture of this
  setTimeout( function(){
    console.log( self.a );
  }, 100);
}
var obj = { a: 2 };
foo.call(obj); // 2