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

作用域、执行上下文(作用域链、变量对象、this)

  1. 作用域
    静态, 负责收集并维护由所有声明的标识符(变量)组成的一系列查询,确定当前执行的代码对这些标识符的访问权限
词法作用域和动态作用域:
js采用词法作用域(静态作用域),词法作用域主要在代码的编译阶段,一个变量和函数的词法作用域取决于该变量和函数声明的地方
  1. 执行上下文
    动态的,可以理解为代码执行前的准备工作。使用执行上下文栈(Execution context stack, ECStack)来管理执行上下文, 栈底为全局执行上下文globalContext
全局执行上下文是在全局作用域确定之后,JS代码执行之前创建;函数执行上下文是在调用函数时,函数体代码执行之前创建
var scope = "global scope"; function checkscope(){ var scope = "local scope"; function f(){ return scope; } return f(); } checkscope(); var scope = "global scope"; function checkscope(){ var scope = "local scope"; function f(){ return scope; } return f; } checkscope()(); // 分析以上两段代码执行上下文的不同 1. ECStack.push( functionContext); ECStack.push( functionContext); ECStack.pop(); ECStack.pop(); 2. ECStack.push( functionContext); ECStack.pop(); ECStack.push( functionContext); ECStack.pop();

一个执行上下文的生命周期分为:
  • 创建阶段
    在这个阶段中,执行上下文会分别创建变量对象VO,建立作用域链,以及确定this的指向。
  • 代码执行阶段
    创建完成之后,就会开始执行代码,这个时候,会完成变量赋值,函数引用,以及执行其他代码。
执行上下文包含三个属性
(1)变量对象(Variable object,VO)
  • 全局上下文的变量对象初始化是全局对象
  • 函数上下文的变量对象初始化只包括 Arguments 对象
  • 在进入执行上下文时会给变量对象添加形参、函数声明、变量声明等初始的属性值且函数声明优先于变量声明
  • 在代码执行阶段,会再次修改变量对象的属性值
(2)作用域链(Scope chain)
由多个执行上下文的变量对象构成的链表
变量查找顺序:当前EC的变量对象 -> 父级EC的变量对象 -> 父级的父级EC的变量对象 -> ... -> 全局EC的变量对象
函数有一个内部属性 [[scope]],当函数创建的时候,就会保存所有父变量对象到其中
(3)this
从 ECMASciript 规范讲解 this 的指向,参考文末链接
以一个具体的案例详述函数执行上下文中作用域链的建立和变量对象的创建过程以及this指定:
var scope = "global scope"; // 函数执行上下文中,用AO(Active Object)表示变量对象 function foo(a) { var b = 2; // g(); // 函数声明提升,正常运行 // d(); // 'd is not a function'变量声明提升 // var d = function() {}; // 未声明,不存在于AO中 console.log(e); // Uncaught ReferenceError: e is not defined e = 4; console.log(f); // undefined // 非严格模式下,变量声明提前, 创建阶段AO中存在值为undefined属性f, 因此此处读取为undefined // 严格模式下,Uncaught ReferenceError: Cannot access 'e' before initialization var f = 5; // 函数声明优先 console.log(g); // function g(){ console.log("g"); } var g = 6; function g(){ console.log("g"); } console.log(g); // 6 g(); }foo(1); 执行过程如下: 1. 函数foo声明,保存作用域链到内部属性[[scope]], 此时所有父级变量对象已创建可以拿到,但未包含该函数执行上下文的变量对象,因此不是完整的作用域链foo.[[scope]] = [globalContext.VO]; 2. 函数foo执行,先创建foo执行上下文并压入执行上下文栈ECStack = [fooContext, globalContext]; 3. foo函数体代码执行之前,进行准备工作的第一步建立作用域链,复制函数[[scope]]属性创建作用域链fooContext = { Scope: foo.[[scope]], }4. 进行准备工作的第二步,创建活动对象,以arguments对象、形参、函数声明、变量声明来初始化AO对象fooContext = { AO = { arguments: { 0: 1, length: 1 }, a: 1, b: undefined, f: undefined, g: reference to function g(){}, ~g: undefined } Scope: foo.[[scope]], }5. 将活动对象压入foo作用域链前端, 形成foo函数的完整作用于链fooContext = { AO = { arguments: { 0: 1, length: 1 }, a: 1, b: undefined, f: undefined, g: reference to function g(){}, ~g: undefined } Scope: [AO, foo.[[scope]]], }6. 函数体代码执行,修改活动对象属性值fooContext = { AO = { arguments: { 0: 1, length: 1 }, a: 1, b: 2, f: 5, g: 6 } Scope: [AO, foo.[[scope]]], }7. 函数执行完毕,函数执行上下文从执行上下文栈中弹出ECStack = [globalContext];

注意点
以上案例中,有一个注意点,函数声明可以被提升,但是函数表达式不能被提升(可以理解为变量声明提升,此时变量为undefined,,无法作为一个函数被调用)
闭包
由函数以及声明该函数的词法环境组合而成, 函数封闭了定义时的环境
词法环境:
由环境记录(一个存储局部变量作为其属性的对象)以及对外部的词法环境(Lexical Environment)的引用组成。
所有函数都有名为 [[Environment]] 的隐藏属性,该属性保存了对创建该函数的词法环境的引用
  • 理论上的闭包
    指一个函数可以记住其外部变量并可以访问这些变量, 因为所有函数都能访问全局变量window,因此可以理解为所有函数都是闭包
  • 实际上的闭包
    引用了外部变量,且该外部变量和该函数一同存在,在创建该函数的上下文销毁时,该函数依然存在
闭包的特点:
在函数外部能访问函数内部变量,引用的自由变量不能被清除, 因此内存占用较高
let foo = () => { let a = 1; return { // 相当于返回了一个通道,这个通道可以访问这个函数词法环境中的变量,即函数所需要的数据结构保存了下来 add: () => { return ++a; }, sub: () => { return --a; } } } let f = foo(); f.add(); // 2 f.sub(); // 1f = null; // 词法环境从内存中被删除

应用场景
  1. 解决for循环绑定问题
    本质就是通过更改作用域链解决
const helpTexts = [ { msg: 'Your e-mail address' }, { msg: 'Your full name' }, { msg: 'Your age (you must be over 16)' } ]; const data = https://www.it610.com/article/[]; // 方案1 for (let i = 0; i < helpTexts.length; i++) { // let/const生成块级作用域, 每次迭代创建独立的词法环境 const item = helpTexts[i]; data[i]= function () { console.log(item.msg); } }// 方案2, 使用闭包 for (var i = 0; i < helpTexts.length; i++) { var item = helpTexts[i]; data[i]= (function (msg) { return function () { console.log(msg); } }(item.msg)) }// 修改前后的data[i]函数的作用域链对比 // 修改前: data[0]Context = { AO: {}, Scope: [AO, globalContext.VO], } // 修改后: 匿名函数Context = { AO: { arguments: { 0: helpTexts[0].msg, length: 1 }, msg: helpTexts[0].msg } } data[0]Context = { AO: {}, Scope: [AO, 匿名函数Context.AO, globalContext.VO], } ------------------------ 或 for (var i = 0; i < helpTexts.length; i++) { (function () { var item = helpTexts[i]; data[i] = function () { console.log(item.msg); } })() }data[0](); // Your e-mail address data[1](); // Your full name data[2](); // Your age (you must be over 16)

  1. 模拟私有方法
// 多个闭包共用一个词法环境 const Counter = (function() { let privateCounter = 1; function changeBy(val) { privateCounter += val; } return { increment: function() { changeBy(1); }, decrement: function() { changeBy(-1); }, value: function() { return privateCounter; } } })(); console.log(Counter.value()); // 0 Counter.increment(); // 1 Counter.decrement(); // 0// 类似于 function Counter() { let counter = 0; this.increment = function() { return ++counter; } this.decrement = function() { return --counter; } } let counter = new Counter(); counter.increment(); // 1 counter.decrement(); // 0

  1. 构造辅助(功能)函数
// 构造过滤器 function isBetween(a, b) { return function(x) { return x >= a && x <= b; } } arr = [2, 3, 4, 5, 6, 7, 8] arr.filter(isBetween(3, 5)); // [3, 4, 5]// 排序 const users = [ { name: "John", age: 20, surname: "Johnson" }, { name: "Pete", age: 18, surname: "Peterson" }, { name: "Ann", age: 19, surname: "Hathaway" } ]; function byField(key) { return function(a, b) { return a[key] > b[key]; } } users.sort(byField('name')); // (Ann, John, Pete)

最后思考,其实闭包的本质原因是因为“作用域链”
var scope = "global scope"; function checkscope(){ var scope = "local scope"; function f(){ return scope; } return f; }var foo = checkscope(); foo(); // "local scope"通过前文可得函数f的EC为: fContext = { AO: { ... }, Scope: [AO, checkscopeContext.AO, globalContext.VO] } 即使函数checkscope的执行上下文销毁,但f函数的作用域链中引用了checkscope上下文的变量对象,因此该变量对象仍然存在于内存中,函数f能通过作用域链找到

this指向
当前执行上下文(global、function 或 eval)的一个属性
  1. 全局上下文
严格/非严格模式: window
  1. 函数上下文
严格模式:undefined
非严格模式:window(浏览器) / globalThis(node)
【js作用域、执行上下文、闭包】改变this指向, 使用call、apply、bind
function add(c, d) { console.log(Object.prototype.toString.call(this)); return this.a + this.b + c + d; }var o = {a: 1, b: 3}; add.call(o, 5, 7); // 16, [object Object] add.apply(o, [10, 20]); // 34, [object Object] add.call(null, 5, 7); // 非严格模式下,null, undefined转换为window;严格模式下不做转换 // 16, [object Window]// bind一次绑定,永久生效 const bindFunc = add.bind(o, 1, 2); bindFunc(); // 7 bindFunc.bind({a: 2, b: 4}, 1, 2)(); // 7// 箭头函数中,无法修改this指向 a = {b:1} const foo = () => this.a foo.call({a : 2}); // {b: 1}

  1. 构造函数的this
function C(){ // 默认返回this this.a = 37; } function C2(){ this.a = 37; return {a:38}; }var o = new C(); o.a; // 37 o = new C2(); o.a; // 38

  1. 类中的this
class Car { constructor() { // 更改sayBye中的this为Car类的实例 this.sayBye = this.sayBye.bind(this); } sayHi() { console.log(`Hello from ${this.name}`); } sayBye() { console.log(`Bye from ${this.name}`); } get name() { return 'Ferrari'; } }class Bird { get name() { return 'Tweety'; } }const car = new Car(); const bird = new Bird(); bird.sayBye = car.sayBye; bird.sayBye(); // Bye from Ferrari

this绑定优先级:
new绑定 > bind绑定 > 绑定(apply/call) > 隐式绑定(obj.foo()) > 默认绑定(独立函数使用))
参考链接:
闭包
JavaScript深入之从ECMAScript规范解读this

    推荐阅读