【JS深入】作用域, 闭包与执行上下文

作用域的含义

作用域, 指的是函数或者变量可访问的范围。

在代码编译(编译器中)的过程中有三个重要的步骤:

  1. 词法分析:将一连串的字符打断成为有意义的片段, 称之为 token。
  2. 语法分析:将一个token的流转换成一个一个嵌套元素的树(抽象语法树, AST, Abstract Syntax Tree)。
  3. 代码生成:将抽象语法树转换为可执行的代码

其中分词阶段有两个重要的查找:LHS 以及 RHS

  • LHS:意味着“赋予。。。值”, 或者表示“赋值的目标”,例如a=3. 未被满足的赋值则可能会抛出一个TypeError表示试图对结果进行了一个非法/不可能的动作.
  • RHS:意味着“获取。。。的值”, 或者表示“赋值的源”,例如console.log(a). 未被满足的RHS会导致ReferenceError错误被抛出.

词法作用域

js 中没有动态作用域,只有词法作用域。 词法作用域是基于在编写代码时变量和作用域的块所在的位置确定的。在进行匹配时,只匹配查找到的第一个变量。

js中可以通过一些方法来欺骗词法作用域,但欺骗词法作用域会导致性能降低. 因为JS引擎对这些语法无法进行静态分析, 从而优化执行效率. 不到万不得已, 请不要使用它们.

eval

eval()函数接受一个字符串作为参数值, 并将其作为动态运行的代码。

function foo(str, a) {
    eval(str);
    console.log(a, b);
}

var b = 2;
foo('var b=3', 1); //1 3
复制代码

with

注意: with存在变量泄露的副作用:with 在运行时将一个对象和它的属性转换为一个带有“标识符”的“作用域”, 当赋值给对象中不存在的变量时,该变量就会泄漏到全局。不过可以看看写法:

var obj = {
    a: 1,
    b: 2,
    c: 3
};

// 重复“obj”显得更“繁冗”
obj.a = 2;
obj.b = 3;
obj.c = 4;
// “更简单”的缩写
with (obj) {
    a = 3;
    b = 4;
    c = 5;
}
复制代码

函数作用域,作用域链,块作用域

一直以来, JavaScipt 只有全局作用域和函数作用域. 直到ES6开始, 才开始加入了块级作用域.

下面我们分别来看看这些不同的作用域:

1. 函数作用域

函数作用域的意义:

  1. 遵循最低权限原则(least privilege)
  2. 避免冲突
  3. 创建全局“命名空间”
  4. 进行模块管理

匿名与命名

setTimeout( function(){
	console.log("I waited 1 second!");
}, 1000 );
复制代码

匿名函数表达式可以快速的键入, 但是有以下的缺点:

  • 在栈轨迹上匿名函数没有名称, 会增加调试的困难度
  • 没有名称的情况下, 当函数需要进行递归的使用, 只能通过arguments.callee这个被废弃的API
  • 匿名函数表达式无助于代码的可读性.

最佳的实践是总是命名你的函数表达式.

IIFE

函数作用域有一种常见的立即执行函数表达式,即 IIFE(Immediately Invoked Function Expression)。

常见的IIFE写法根据执行的一对()不同, 有两种写法:

写法一:

(function foo(){
  var a = 10;
  console.log(a);
})();
复制代码

写法二:

(function foo(){
  var a = 10;
  console.log(a);
}());
复制代码

这两种的写法效果是一样的, 使用哪种写法取决于你的风格, 一般来说, 第一种写法比较常见.

IIFE 的应用有且不仅有这么几种:

//用法1: 传入window, 用于隔绝一些全局变量, 或者抹平全局对象的差异等等
(function IIFE(global) {
    var a = 3;
    console.log(a); // 3
    console.log(global.a); // 2
})(window);

//用法2:保证在一个代码块中undefined确实是一个undefined
undefined = true; // 给其他的代码埋地雷!别这么干!
(function IIFE(undefined) {
    var a;
    if (a === undefined) {
        console.log('Undefined is safe here!');
    }
})();

//用法3: 反向使用IIFE用于UMD(Universal Module Definition–统一模块定义)
var a = 2;
(function IIFE(def) {
    def(window);
})(function def(global) {
    var a = 3;
    console.log(a); // 3
    console.log(global.a); // 2
});
复制代码

函数声明和函数表达式

这是函数声明:

// 函数声明
function funDeclaration(type){
    return type==="Declaration";
}
复制代码

这是函数表达式:

var funExpression = function(type){
    return type==="Expression";
}
复制代码
  1. 函数表达式与函数声明不同,函数名只在该函数内部有效,并且此绑定是常量绑定
  2. 对于一个常量进行赋值,在 strict 模式下会报错,非 strict 模式下静默失败。

看下面一段代码:

var b = 10;
(function b() {
    b = 20; // b 在这里是函数声明, 常量绑定无法被赋值
    console.log(b);
})();
//输出: function b(){...}
复制代码

但是如果在函数内部重新赋值:

(function b() {
    let b = 20; // b 在这里被重新声明了, 覆盖了函数声明
    console.log(b);
})(); // 输出: 20
复制代码
  1. IIFE 中的函数是函数表达式,而不是函数声明.

注意: 用函数声明创建的函数可以在函数定义之前进行调用, 但是用函数表达式创建的函数就不能. 这是因为两者提升模式不同.

2. 作用域链

JavaScript 代码的整个执行过程分为两个阶段,代码编译阶段和代码执行阶段。编译阶段由编译器完成,将代码翻译成可执行代码,这个阶段作用域被确定下来, 执行阶段由引擎完成, 这个阶段会创建执行上下文。

在执行上下文生成的过程中,变量对象,作用域链以及 this 的值会分别被确定。

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

我们使用一个例子来解释作用域链:

var a = 20;

function test() {
    var b = a + 10;

    function innerTest() {
        var c = 10;
        return b + c;
    }

    return innerTest();
}

test();
复制代码

在这个例子中,全局,函数 test,函数innerTest的执行上下文先后创建,设定其变量对象分别为VO(global),VO(test),VO(innerTest)。而innerTest的作用域链则同时包含了这三个变量对象:

innerTestEC = {
    VO: {},
    [[SCOPE]]: [VO(innerTest), VO(test), VO(global)]
};
复制代码

这里的[[SCOPE]]就是作用域链。一般第一项是当前作用域,最后一项是全局变量对象。

当前作用域与上层作用实际上是一种链式关系,而不是包含关系。它是一个单向链表,并借此我们才能访问到上一层作用域中的变量。

3. 块作用域

ES6 以前,JS 是没有真正的简单的块作用域的。但是我们可以通过try...catch来创建出块作用域:

try {
    undefined(); //用非法的操作强制产生一个异常!
} catch (err) {
    console.log(err); // 好用!
}

console.log(err); // ReferenceError: `err` not found
复制代码

ES6 以后,引入了 let、const 来创建块作用域。 其主要特性有:

  1. 创建了块作用域
  2. 禁止声明提升, 并且创建了TDZ(temporal dead zone, 声明死区)
  3. 不允许重复声明

闭包

闭包是 JS 的核心概念, 简单的定义是:函数能够记住并访问它的词法作用域,即使这个函数在它的词法作用域之外执行的时候

闭包有一些非常重要的应用场景,这里随意的列举一些:

1. 简单的创建模块

function CoolModule() {
    var something = 'cool';
    var another = [1, 2, 3];

    function doSomething() {
        console.log(something);
    }

    function doAnother() {
        console.log(another.join(' ! '));
    }
}

var foo = CoolModule();

foo.doSomething(); // cool
foo.doAnother(); // 1 ! 2 ! 3
复制代码

2. 在循环中使用闭包

for (var i = 1; i <= 5; i++) {
    setTimeout(function timer() {
        console.log(i);
    }, i * 1000);
} //'6'被打印5次,每秒一次
复制代码

3. 在柯里化中使用闭包

// 简单实现,参数只能从右到左传递
function createCurry(func, args) {
    var arity = func.length;
    var args = args || [];

    return function() {
        var _args = [].slice.call(arguments);
        [].push.apply(_args, args);

        // 如果参数个数小于最初的func.length,则递归调用,继续收集参数
        if (_args.length < arity) {
            return createCurry.call(this, func, _args);
        }

        // 参数收集完毕,则执行func
        return func.apply(this, _args);
    };
}
复制代码

使用方法:

function check(targetString, reg) {
    return reg.test(targetString);
}

var _check = createCurry(check);

var checkPhone = _check(/^1[34578]\d{9}$/);
var checkEmail = _check(/^(\w)+(\.\w+)*@(\w)+((\.\w+)+)$/);
复制代码

动态作用域

由于 JS 中并没有真正的动态作用域,关于动态作用域的概念, 我们整理两点:

  1. 动态作用域是运行时确定的, 而不是在编写时静态的确定。动态作用域不关心函数的作用域在哪里和如何被声明,而是关心它们从何处被调用, 换句话说,它的作用域是基于调用栈的,而不是代码中作用域的嵌套。
  2. JS 没有实际上的动态作用域,但是 this 的绑定机制有些类似于动态作用域, 因为this是关心函数是如何被调用的。

this

这部分介绍下this的相关语法和知识点。

首先, this并不是指向函数自己,也不会以任何方式指向函数的词法作用域this在运行时进行绑定,依赖于函数调用的上下文。this绑定与函数声明的位置没有任何关系而是与函数被调用的方式紧密相连。

从上面的介绍中,我们知道一个函数被调用的时候会建立起一个执行环境,其中包含了函数的调用栈,调用方式,被传递的参数。这个记录的属性之一就是函数执行期间的this指向。

this 是一个完全根据调用点而为每次函数调用建立的绑定

所谓的调用点(call-site)指的是执行函数的语句所在的位置。

this 有几种常见的的绑定机制:

1. 默认绑定(Default Binding)

独立函数调用

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

var a = 2;

foo(); // 2
复制代码

如果在strict模式下,a不会指向全局对象,则会抛出一个undefined异常:TypeError: this is undefined

这里有一个微妙的细节: 即便所有的this绑定规则都是完全基于调用点的, 但如果foo()内容没有在strict mode下执行, 对于默认绑定来说全局对象是唯一合法的; foo()的调用点的strict mode状态于此无关:

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

var a = 2;

(function(){
	"use strict";

	foo(); // 2
})();
复制代码

当然这种局部混用严格模式是非常不好的实践. 你的代码应该在整体上采用严格模式或者非严格模式.

2. 隐式绑定(Implicit Binding)

这种规则下我们考虑的是调用是否有一个环境对象(context object), 也称为拥有者(owning)或者容器(containing),例如下面的代码:

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

var obj = {
    a: 2,
    foo: foo
};

obj.foo(); // 2
复制代码

隐式丢失(Implicitly Lost):当一个隐式绑定丢失了绑定, 通常会退回到默认绑定,根据strict模式的区别,其结果不是 global 就是 undefined

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

var obj = {
    a: 2,
    foo: foo
};

var bar = obj.foo; // 函数引用!

var a = 'oops, global'; // `a` 也是一个全局对象的属性

bar(); // "oops, global"
复制代码

函数 foo 并不属于对象 obj,obj 只是拥有 foo 的引用,所以 bar 与 obj 并没有关系。

3. 显式绑定(Explicit Binding)

如果你想要强制一个函数调用是的某个特定对象作为this绑定, 而不再这个对象上防止一个函数引用属性. 就可以用显式绑定. 具体的说, 函数拥有一些callapply方法. 这两个参数接受的第一个参数用于this的对象, 之后只用这个指定的this来调用函数. 因为你已经直接指明你想要this是什么. 这种绑定方式就是显式绑定.

两个参数的区别在, 除了第一个参数外, call 可以接受一个参数列表, apply 只接受一个参数数组.

let a = {
    value: 1
};
function getValue(name, age) {
    console.log(name);
    console.log(age);
    console.log(this.value);
}

getValue.call(a, 'cxk', '24');
getValue.apply(a, ['cxk', '24']);
复制代码

call 的性能比 apply 的性能要更好, 尤其是在 es6 引入了Spread operator(延展操作符)后, 即使参数是数组,可以使用 call.

let params = [1, 2, 3, 4];
xx.call(obj, ...params);
复制代码

但是显式绑定不能解决的问题在于, 它仍然不能为解决这个问题: 函数丢失了自己原本的this绑定, 或者被第三方框架覆盖了.

手写一个call/apply

call:

Function.prototype.myCall = (content, ...args){
    let context = content || window;
    context.fn = this
    let res = context.fn(...args)
    delete context.fn;
    return res
}
复制代码

apply 类似:

Function.prototype.myApply = (content, args) {
    let context = content || window;
    context.fn = this
    let res = context.fn(...args)
    delete context.fn;
    return res
}
复制代码

4. 硬绑定(Hard Binding)

不过, 有一个显示绑定的变种可以实现这个技巧. 考虑这段代码:

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

var obj = {
	a: 2
};

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

bar(); // 2
setTimeout( bar, 100 ); // 2

// `bar` 将 `foo` 的 `this` 硬绑定到 `obj`
// 所以它不可以被覆盖
bar.call( window ); // 2
复制代码

这段代码中, 我们创建了一个函数bar(), 在它的内部手动调用foo.cal(), 由此强制this绑定到obj并调用foo. 无论之后如何调用函数bar, 它总是手动使用obj调用foo. 这种绑定坚定并且明确, 所以叫做硬绑定

用硬绑定将一个函数包装起来的最典型方法, 是为所有传入的参数和传出的参数返回值创建一个通道.

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;
}

// 简单的 `bind` 帮助函数
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
复制代码

这种方式是如此的常见, 以至于作为内建函数内置于ES5中.

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
复制代码

bind(...)返回一个硬编码的新函数, 它使用你指定的this环境来调用原本的函数.

注意, 在ES6中, bind生成的硬绑定函数有一个名为.name的属性, 源自原始的目标函数.

bind的实现原理

Function.prototype.myBind = function(context) {
    // 只能接受function作为this
    if (typeof this !== 'function') {
        throw new TypeError('Error');
    }
    var _this = this; // 目标函数
    // 获取传递的所有参数
    var args = [...arguments].slice(1);
    //返回一个函数
    return function F() {
        // 因为返回了一个函数, 我们可以 new F()
        // 应该要new的是原本的函数, 而不是我们的F, 
        // 因此这里要返回 new _this
        if (this instanceof F) {
            return new _this(...args, ...arguments);
        }
        return _this.apply(context, args.concat(...arguments));
    };
};
复制代码

5. new 绑定(new Binding)

首先,在 js 中的 new 和传统语言中的 new 并不相同, new 的动作如下:

  • 创建一个新的对象
  • 将这个对象接入原型链
  • 将这个对象设置为函数调用的 this 绑定
  • 除非函数指定返回对象,否则默认返回这个新构建的对象
function _new(fn, ...arg) {
    if(typeof fn !== 'function') throw `${fn} is not a constructor`
    // 创建一个原型链为fn.prototype的对象
    const obj = Object.create(fn.prototype);
    // 在这个对象上执行fn, 获取结果
    const ret = fn.apply(obj, arg);
    // 如果返回的结果是个对象, 那么返回这个对象, 否则说明函数执行返回的不是对象, 则直接返回obj
    return ret instanceof Object ? ret : obj;
}
复制代码

四种 this 绑定规则的优先级为:new=>call/apply=>隐式绑定=>默认绑定

此外,如果在 call/apply 中传递 null 或者 undefined 作为 this 的绑定参数,这些值会被忽略掉从而执行默认绑定。

new的实现原理

new被调用之后实际上做了三件事:

  1. 让实例对象可以访问到私有属性
  2. 让实例对象可以访问构造函数原型所在原型链上的属性
  3. 考虑构造函数有返回值的情况
function _new(fn, ...arg) {
    if(typeof fn !== 'function') throw `${fn} is not a constructor`
    // 创建一个原型链为fn.prototype的对象
    const obj = Object.create(fn.prototype);
    // 在这个对象上执行fn, 获取结果
    const ret = fn.apply(obj, arg);
    // 如果返回的结果是个对象, 那么返回这个对象, 否则说明函数执行返回的不是对象, 则直接返回obj
    return ret instanceof Object ? ret : obj;
}
复制代码

6. 两种特殊绑定

两种比较特殊的绑定情况,其一是间接引用

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所在的作用域是全局作用域
复制代码

其二是软绑定:

//这是一个软绑定工具
if (!Function.prototype.softBind) {
    Function.prototype.softBind = function(obj) {
        var fn = this,
            curried = [].slice.call(arguments, 1),
            bound = function bound() {
                return fn.apply(
                    !this ||
                        (typeof window !== 'undefined' && this === window) ||
                        (typeof global !== 'undefined' && this === global)
                        ? obj
                        : this,
                    curried.concat.apply(curried, arguments)
                );
            };
        bound.prototype = Object.create(fn.prototype);
        return bound;
    };
}
复制代码

软绑定是一种可退化绑定.

关于this绑定还有一些其他的情况

7. 箭头函数

箭头函数是 ES6 的新语法,它有特殊的this绑定方式, 主要有几个特点:

  1. 函数体内的 this 对象,就是定义时所在的对象,而不是使用时所在的对象。
  2. 不能够当做构造函数,不能使用 new 命令。因为
    1. 没有自己的 this,无法调用 call,apply。
    2. 没有 prototype 属性 ,而 new 命令在执行时需要将构造函数的 prototype 赋值给新的对象的 __proto__
  3. 不能使用 arguments 对象。
  4. 不能使用 yield 对象。

小结

  1. 以函数的形式调用的时候, this指向window
  2. 以方法的形式调用的时候, this指向调用方法的对象
  3. 以构造函数的形式调用的时候, this指向新创建的对象
  4. callapply调用的时候, this指向指定的对象
  5. arguments为调用的时候, 指向当前的函数

执行上下文

执行上下文, 就是当前js代码被解析和执行时所在环境的抽象概念. JS中运行任何的代码都是在执行上下文中运行的.

执行上下文的类型

执行上下文总共有三种类型:

1. 全局执行上下文:

默认的, 基础的执行上下文. 不在任何函数中的代码都位于全局执行上下文中. 它做了两件事:

  1. 创建一个全局对象, 在浏览器中这个全局对象就是window对象
  2. 将this指针指向这个全局对象. 一个程序中只能存在一个全局的执行上下文

2. 函数执行上下文:

每次调用函数的时候, 都会为函数创建一个新的执行上下文, 每个函数都拥有自己的执行上下文, 但是只有函数在调用的时候才会被创建.

一个程序中可以存在任意数量的函数执行上下文. 每当一个新的执行上下文被创建, 它都会按照特定的顺序执行一些列的步骤.

活动对象(AO,Activation Object) 就是在函数创建的时候被创建的一个特殊对象.

在函数的执行上下文中, 是把活动对象当做变量对象的活动(变量时作为局部执行上下文的变量对象来使用), 活动对象包含形参和arguments对象.

实际上, 变量对象和活动对象的作用是一样的, 都是为了记录保存我们的变量的.

3. Eval函数执行上下文

运行在eval函数中的代码有自己的执行上下文. 会有性能损失和安全问题.

执行上下文的生命周期

执行环境(EC)建立大致如下步骤:

创建阶段解释器, 扫描传递给函数的参数或者arguments, 本地函数声明和本地变量声明, 并创建EC对象.

执行上下文的生命周期包括三个阶段: 创建阶段, 执行阶段, 回收阶段.

1. 创建阶段

当函数被调用, 但没有执行任何其内部代码之前, 会做几件事:

  • 创建变量对象(VO, Variable Object): 首次初始化函数的参数arguments, 提升函数声明和变量声明.
    • 这里的变量声明提升是使用var创建变量的时候发生的, 如果你使用let/const是没有声明提升机制的, 但是同样会有一个预解析过程, 会将声明的变量放入到变量对象中, 只不过和var声明的变量存储位置不同.
  • 创建作用域链(Scope Chain): 在执行期上下文的创建阶段, 作用域链式在变量对象之后创建的. 作用域链本身包含变量对象. 作用域链用于解析变量. 当被要求解析变量的时候, js始终从代码嵌套的最内层开始, 如果最内层没有找到变量, 就会跳转到上一层父作用域中查找, 直到找到该变量.
  • 确定this执行的上下文的值

2. 执行阶段

负责执行变量赋值, 代码的执行

3. 回收阶段

执行上下文出栈等待虚拟机回收执行上下文

this指向的解释

正如上文所述, this的值是在执行过程确认的, 而不是在定义的时候(箭头函数除外). 因为this是执行上下文的一部分, 而执行上下文需要在代码执行的前一刻确定, 而非定义的时候.

执行上下文栈(Execution Context Stack)

函数的执行上下文是不限制数量的, 每次调用函数都会创建一个新的执行上下文, 那如何管理这些执行上下文呢.

JavaScript 引擎创建了执行上下文栈来管理执行上下文, 可以把执行上下文栈认为是一个存储函数调用的栈结构, 遵循先进后出.

alt

从流程图, 我们可以看到:

  1. js执行在单线程上, 所有代码都是排序执行的
  2. 一开始浏览器执行全局的代码, 首先创建全局的执行上下文, 压入执行栈的顶部
  3. 每当进入一个函数就创建函数的执行上下文, 并且把它压入执行栈的顶部. 当前函数执行完成后, 当前函数的执行上下文出栈, 并等待垃圾回收.
  4. 浏览器的JS执行引擎总是访问栈顶的执行上下文
  5. 全局的上下文只有一个, 会在页面关闭的时候出栈.

JS内存中的堆栈

堆和栈都是运行时内存中分配的一个数据区, 因此也被称为堆区和栈区.

两者的差别主要在于数据类型和处理速度的不同:

  • 堆(heap): 用于复杂数据类型(引用类型)分配空间, 例如数组对象, Object, 它是运行时动态分配内存的, 因此存取速度比较慢.
  • 栈(stack): 主要存放一些基本类型的变量和对象的引用, 包含池(池存放常量), 其优势是存取速度比堆要快, 并且栈中的护具是可以共享的. 缺点是存在栈中的数据大小和生存期是必须确定的, 缺乏灵活性.

::: tip
闭包中的变量并不保存在栈中, 而是保存在堆内存中. 这也是闭包中的变量能被访问到的原因
:::

JS堆的内容是不需要程序代码来显示的释放的, 因为堆是由自动的垃圾回收机制(GC)来负责的. 每种浏览器中的js解释引擎有不同的垃圾回收方式.

参考链接

  1. 你不知道的 JS: 作用域与闭包
  2. 前端基础进阶(四):详细图解作用域链与闭包
  3. ECMAScript 6 入门
  4. 前端基础进阶(八):深入详解函数的柯里化
  5. call 和 apply 的区别是什么
  6. 深入理解JavaScript执行上下文和执行栈
  7. JavaScript 执行上下文,执行栈,作用域链
© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享