作用域、执行上下文、闭包

119 阅读14分钟

1、 作用域

作用域是指程序源代码定义变量的区域,规定了如何查找变量和变量的访问权限,javascript 采用词法作用域,即静态作用域

静态作用域
因为 JavaScript 采用的是词法作用域,函数的作用域在函数定义的时候就决定了。

var value = 1;

function foo() {
    console.log(value);
}

function bar() {
    var value = 2;
    foo();
}

bar();

// 结果是 ???

执行 foo 函数,先从 foo 函数内部查找是否有局部变量 value,如果没有,就根据书写的位置,查找上面一层的代码,也就是 value 等于 1,所以结果会打印 1。

2、 执行上下文

2.1 代码执行

理解以下三句话很重要:

  • js 有三种可执行代码会创建一个执行上下文
  • 代码是运行在执行上下文中的
  • 执行上下文是通过执行上下文栈执行代码的
  1. javascript 的可执行代码有三种
  • 全局代码
  • 函数代码
  • eval代码

当遇到这三种代码的时候,会进行一些准备工作,创建一个执行上下文(execution context)。
每个执行上下文,都有三个重要的属性:

  • 变量对象(Variable object,VO)
  • 作用域链(Scope chain
  • this
  1. 代码是运行在执行上下文中的

执行上下文中的代码会分成两个阶段处理:分析和执行

  • 分析执行上下文中的代码 ,包括(生成变量对象建立作用域链确定 this 指向
  • 执行代码,包括(变量赋值、函数引用、执行其他代码)
  1. 执行上下文栈执行代码

JavaScript 引擎创建了执行上下文栈(Execution context stack,ECS)来管理执行上下文。
当执行一个函数的时候,就会创建一个执行上下文,并且压入执行上下文栈,当函数执行完毕的时候,就会将函数的执行上下文从栈中弹出

2.2 变量对象

变量对象是与执行上下文相关的数据作用域,存储了在上下文中定义的变量和函数声明。分为全局上下文的变脸搞对象和函数上下文变量对象

2.2.1 全局上下文

全局上下文中的变量对象就是全局对象

2.2.2 函数上下文

在函数上下文中,我们用活动对象(activation object, AO)来表示变量对象,函数上下文中的变量对象只有在进入这个执行上下文中才会被激活。而只有被激活的变量对象,里面的属性才能被访问。

进入执行上下文
进入执行上下文(分析代码)会先生成变量对象,规则如下:

  1. 函数的所有形参
    • 由名称和对应的值组成的一个变量对象,没有实参
    • 属性值为 undefined
  2. 函数声明
    • 由名称和对应值(函数对象(function-object))组成一个变量对象的属性被创建
    • 如果变量对象已经存在相同名称的属性,则完全替换这个属性
  3. 变量声明
    • 由名称和对应值(undefined)组成一个变量对象的属性被创建
    • 如果变量名称跟已经声明的形式参数或函数相同,则变量声明不会干扰已经存在的这类属性

注意:这里的第二第三点就解释了为什么函数声明和函数表达式在代码执行时表现不一样的原因,函数表达式会被当做变量,在分析变量对象的时候会被赋值undefined

举个例子:

function foo(a) {
  var b = 2;
  function c() {}
  var d = function() {};

  b = 3;

}

foo(1);

在进入执行上下文后,这里分析得到的变量对象 AO 如下

AO = {
    arguments: {
        0: 1,
        length: 1
    },
    a: 1,
    b: undefined,
    c: reference to function c(){},
    d: undefined
}

代码执行
在代码执行阶段,会顺序执行代码,根据代码,修改变量对象的值
还是上面的例子,当代码执行完后,这时候的 AO 是

AO = {
  arguments: {
    0: 1,
    length: 1
  },
  a: 1,
  b: 3,
  c: reference to function c(){},
d: reference to FunctionExpression "d"
}

2.2.3 变量对象总结

  1. 全局上下文的变量对象初始化是全局对象
  2. 函数上下文的变量对象初始化只包括 Arguments 对象
  3. 在进入执行上下文时会给变量对象添加形参、函数声明、变量声明等初始的属性值
  4. 在代码执行阶段,会再次修改变量对象的属性值

2.3 作用域链

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

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

function foo() {
  function bar() {
    ...
  }
}

函数创建时候,各自的 [[scope]]为:

foo.[[scope]] = {
  globalContext.VO
}

bar.[[scope]] = {
  fooContext.AO,
  globalContext.VO
}

函数激活
当函数激活时候,进入执行上下文,创建 VO/AO 后,就会将活动对象添加到作用域链前端,这时候执行上下文的作用域链,我们命名为 Scope

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

2.4 执行上下文具体执行分析

结合执行上下文栈,总结一下执行上下文中变量对象和作用域链的创建过程:

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

这段代码的执行过程如下:

  1. 执行全局代码,创建全局执行上下文,全局上下文被压入执行上下文栈
 ECStack = [
      globalContext
  ];
  1. 全局上下文初始化
  globalContext = {
      VO: [global],
      Scope: [globalContext.VO],
      this: globalContext.VO
  }
  1. 初始化的同时,checkscope 函数被创建,保存作用域链到函数的内部属性[[scope]]
  checkscope.[[scope]] = [
    globalContext.VO
  ];
  1. 执行 checkscope 函数,创建 checkscope 函数执行上下文,checkscope 函数执行上下文被压入执行上下文栈
ECStack = [
    checkscopeContext,
    globalContext
];
  1. checkscope 函数执行上下文初始化:
    1. 复制函数 [[scope]] 属性创建作用域链;
    2. 用 arguments 创建活动对象;
    3. 初始化活动对象,即加入形参、函数声明、变量声明;
    4. 将活动对象压入 checkscope 作用域链顶端
checkscopeContext = {
    AO: {
        arguments: {
            length: 0
        },
        scope: undefined,
        f: reference to function f(){}
    },
    Scope: [AO, globalContext.VO],
    this: undefined
}
  1. checkscope初始化的同时 f 函数被创建,保存作用域链到 f 函数的内部属性[[scope]]
f.[[scope]] = [checkscopeContext.AO, globalContext.VO]
  1. 执行 f 函数,创建 f 函数执行上下文,f 函数执行上下文被压入执行上下文栈
ECStack = [
    fContext
    checkscopeContext,
    globalContext
];
  1. f 函数执行上下文初始化
fContext = {
    AO: {
        arguments: {
            length: 0
        },
    },
    Scope: [AO, checkscopeContext.AO, globalContext.VO],
    this: undefined
}
  1. f 函数执行,沿着作用域链查找 scope 值,返回 scope 值;
  2. f 函数执行完毕,f 函数上下文从执行上下文栈中弹出;
ECStack = [
    checkscopeContext,
    globalContext
];
  1. checkscope 函数执行完毕,checkscope 执行上下文从执行上下文栈中弹出
ECStack = [
    globalContext
];
  1. globalContext执行完毕,globalContext 执行上下文从执行上下文栈弹出

3、 this

this 是 Javascript 的一个关键字。会在执行上下文中绑定一个对象,但是根据什么条件绑定的呢?函数的执行过程中调用位置决定 this 的 绑定对象。不同的执行条件下会绑定不同绑定不同的对象。

3.1 this指向什么

先说一个最简单的,this 在全局作用域指向什么?

  • 在浏览器中指向window,所以,在全局作用域下,我们可以认为 this 指向 window

注意:非严格模式下指向 window ,严格模式下为 undefined

但是,开发中很少直接在全局作用域下使用 this,通常都是在 **函数中使用。 ** 所有的函数在被调用时,都会创建一个执行上下文:

  • 这个上下文记录这函数的调用栈、调用方式、传入的参数信息
  • 也记录这一个 this 属性

查看以下代码,初步感受 this 的指向

// 定义一个函数
function foo () {
    console.log(this);
}

// 1. 直接调用
foo(); // window

// 2. 将foo 放到一个对象中,再调用
var obj = {
    name: "log",
    foo: foo
}
obj.foo() // this指向 obj 对象

// 3. 通过call调用
foo.call("abc") // this 指向 String对象

上面的案例可以得到以下结论

  • 函数在调用时,会默认给 this 绑定一个默认值
  • this 的绑定和定义的位置无关,和调用方式及调用位置有关
  • this 是在运行时绑定的

下面一起学习 this 的绑定规则

3.2 this绑定规则

结论:独立函数调用时会使用默认绑定

3.2.1 默认绑定

结论:独立函数调用时会使用默认绑定

默认绑定一:普通函数调用

  • 函数被直接调用,没有进行任何的对象关联
  • 这种独立的函数调用会只用默认绑定规则,函数中的 this 指向全局对象
// 定义一个函数
function foo () {
  console.log(this);
}
// 1. 直接调用
foo(); // window

默认绑定二: 函数调用链(一个函数又调用另一个函数)

  • 所有的函数调用都没有绑定到某个对象上
function test1() {
    console.log(this); // 指向 window
    test2();
}

function test2() {
    console.log(this); // 指向 window
    test3();
}

function test3() {
    console.log(this); // 指向 window
}

test1();

3.2.2 隐式绑定

另外一种比较常见的调用方式是通过某个对象进行调用的
通过对象调用函数
下面 foo 的调用位置是 obj.foo() 方式进行的,所以 foo 调用时 this 会隐式绑定在 obj 对象上

function foo () {
    console.log(this)
}
var obj = {
    name: "log",
    foo: foo
}

obj.foo(); // 这里this隐式绑定 obj

隐式丢失
下面的结果最终是 window, 因为 foo 最终的调用位置是 bar , 而 bar 调用位置上没有绑定任何对象,所以没有形成默认绑定。 相当于一种默认绑定了

function foo () {
    console.log(this)
}
var obj = {
    name: "log",
    foo: foo
}

var bar = obj.foo(); 
bar(); // 这里this 绑定的是 window

3.2.3 显示绑定

通过 call、apply、bind、显示的绑定对象

  • call(需要绑定的对象,arg1,arg2)
  • call(需要绑定的对象,[arg1,arg2])
  • bind(需要绑定的对象)
var obj1 = {
  name: "obj1",
  sum: function (num1, num2) {
    console.log("obj1", this);
    return num1 + num2;
  },
};
var obj2 = {
  name: "obj2",
  sum: function (num1, num2) {
    console.log("obj2", this);
    return num1 + num2;
  },
};
// call与apply的调用区别
obj1.sum.call(obj2, 10, 20);// 表示调用的是obj2的sum方法,this指向obj2
obj1.sum.apply(obj2, [10, 20]);// 表示调用的是obj2的sum方法,this指向obj2
// bing绑定返回的是一个函数,这时候也是调用obj2的sum方法
obj1.sum.bind(obj2)(10, 2);

3.2.4 new绑定

javascript 中的函数可以当做一个类的构造函数使用, 也就是使用 new 关键字来调用函数,当执行 new 调用时,会执行如下操作:

  • 创建一个全新的对象
  • 这个新对象会被执行 Prototype 连接
  • 这个新对象会绑定到函数调用的 this 上
  • 如果函数没有返回其他对象 表达式会返回新对象
function Person(name) {
    console.log(this) // 指向 Person {}
    this.name = name;
}
var p = new personalbar("log");
console.log(p)

3.2.5 规则优先级

1、 默认绑定的优先级最低
2、显示绑定高于隐式绑定
以下代码最后一行输出 obj2 ,说明显示绑定高于隐式绑定

function foo() {
  console.log(this);
}

var obj1 = {
  name: "obj1",
  foo: foo,
};

var obj2 = {
  name: "obj2",
  foo: foo,
};

// 隐式绑定
obj1.foo(); // obj1
obj2.foo(); // obj2

// 隐式绑定和显示绑定同时存在
obj1.foo.call(obj2); // obj2, 说明显式绑定优先级更高

3、new 绑定高于隐式绑定
以下代码结果是 foo , 说明 new 绑定高于隐式绑定

function foo() {
  console.log(this);
}

var obj = {
  name: "log",
  foo: foo,
};

new obj.foo(); // foo对象, 说明new绑定优先级更高

4、new 绑定高于 bind 绑定
new 绑定不允许和 call、apply、同时使用,但是允许和 bind 同时使用
以下结果显示 foo , 说明 new 绑定高于 bind 绑定

function foo() {
  console.log(this);
}

var obj = {
  name: "obj",
};

var bar = foo.bind(obj);
var foo = new bar(); // 打印foo, 说明使用的是new绑定

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

3.3 this 规则之外

3.3.1 忽略显示绑定

在显示绑定中,如果我们传入的对象是一个 null 或者 undefined, 那么这个显示绑定会被忽略,使用默认绑定

function foo() {
  console.log(this);
}

var obj = {
  name: "log",
};

foo.call(obj); // obj对象
foo.call(null); // window
foo.call(undefined); // window

3.3.2 间接函数引用

  • 赋值 (obj2.foo = obj1.foo)的结果是 foo 函数
  • foo 函数被直接调用,那么就是默认绑定
function foo() {
  console.log(this);
}

var obj1 = {
  name: "obj1",
  foo: foo,
};

var obj2 = {
  name: "obj2",
};

(obj2.foo = obj1.foo)(); // window

3.3.3 ES6箭头函数 箭头函数不使用 this 的四种绑定规则, 而是根据外层作用域来决定 this。上层作用域是什么,则 this 就指向什么

4、 实现 call、apply、bind 函数

4.1 call实现

Function.prototype.mycall = function (thisArg, ...args) {
  // 1.获取需要被执行的函数,调用时foo.mycall()隐式绑定this指向foo,所以fn表示foo,获取被调用的函数
  var fn = this;
  // 2.thisArg表示要绑定的对象,需要处理,防止它传入的是非对象类型
  thisArg = thisArg !== null && thisArg !== undefined ? Object(thisArg) : window;
  // 3.把药调用的函数放入需要绑定的对象中,通过隐式绑定该对象。调用需要被执行的函数
  thisArg.fn = fn;
  var result = thisArg.fn(...args);
  delete thisArg.fn;
  // 4.将最终的结果返回出去
  return result;
};

4.2 apply实现

Function.prototype.myapply = function (thisArg, argArray) {
  // 1.获取到要执行的函数
  var fn = this;
  // 2.处理绑定的thisArg
  thisArg = thisArg !== null && thisArg !== undefined ? Object(thisArg) : window;
  // 3.执行函数
  thisArg.fn = fn;
  argArray = argArray || [];
  var result = thisArg.fn(...argArray);
  delete thisArg.fn;
  // 4.返回结果
  return result;
};

4.3 bind 实现

Function.prototype.mybind = function (thisArg, ...argArray) {
  // 1.获取到真实需要调用的函数
  var fn = this;
  // 2.绑定this
  thisArg = thisArg !== null && thisArg !== undefined ? Object(thisArg) : window;
  function proxyFn(...args) {
    // 3.将函数放到thisArg中进行调用
    thisArg.fn = fn;
    // 特殊: 对两个传入的参数进行合并
    var finalArgs = [...argArray, ...args];
    var result = thisArg.fn(...finalArgs);
    delete thisArg.fn;
    // 4.返回结果
    return result;
  }
  return proxyFn;
};

5、 闭包

理论角度的闭包

MDN 对闭包的定义为:闭包是指那些能够访问自由变量的函数。

那么什么是自由变量

自由变量是指在函数中使用的,但既不是函数参数也不是函数的局部变量的变量。

由此,我们可以看出闭包共有两部分组成:

闭包 = 函数 + 函数能够访问的自由变量

所以在《JavaScript权威指南》中就讲到:从技术的角度讲,所有的JavaScript函数都是闭包。
但是,这是理论上的闭包,其实还有一个实践角度上的闭包。

实践角度的闭包

闭包的定义:指有权访问另一个函数作用域的变量的函数(即使被访问的函数上下文已经被销毁),一般情况下就是在一个函数中包含另一个函数

闭包的作用:访问函数内部变量、保持函数在环境中一直存在,不会被垃圾回收机制处理

分析以下代码执行理解闭包的原理

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

var foo = checkscope();
foo();

这里简要分析执行过程:

  1. 进入全局代码,创建全局执行上下文,全局执行上下文压入执行上下文栈;
 ECStack = [
      globalContext
  ];
  1. 全局执行上下文初始化;
globalContext = {
    VO: [global],
    Scope: [globalContext.VO],
    this: globalContext.VO
  }
  1. 执行 checkscope 函数,创建 checkscope 函数执行上下文,checkscope 执行上下文被压入执行上下栈;
 ECStack = [
      checkscopeContext,
      globalContext
  ];
  1. checkscope 执行上下文初始化,创建变量对象、作用域链、this等;
checkscopeContext = {
    AO: {
        arguments: {
            length: 0
        },
        scope: undefined,
        f: reference to function f(){}
    },
    Scope: [AO, globalContext.VO],
    this: undefined
}
  1. checkscope 函数执行完毕,checkscope 执行上下文从执行上下文栈中弹出;
 ECStack = [
      globalContext
  ];
  1. 执行 f 函数,创建 f 函数执行上下文,f 执行上下文被压入执行上下文栈;
 ECStack = [
      fContext,
      globalContext
  ];
  1. f 执行上下文初始化,创建变量对象、作用域链、this等;

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

了解这个过程后,思考一个问题

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

当我们了解了具体的执行过程后,我们知道 f 执行上下文维护了一个作用域链:

fContext = {
    Scope: [AO, checkscopeContext.AO, globalContext.VO],
}

因为这个作用域链,f 函数依然可以读取到 checkscopeContext.AO 的值,说明当 f 函数引用了checkscopeContext.AO 中的值的时候,即使 checkscopeContext 被销毁了,但是 JavaScript 依然会让 checkscopeContext.AO 活在内存中,f 函数依然可以通过 f 函数的作用域链找到它,正是因为 JavaScript 做到了这一点,从而实现了闭包这个概念。