执行上下文与闭包

236 阅读14分钟

JS引擎在解析JS代码的时候并非是逐行解析,而是自上而下一块一块地去解析的,在解析过程中就会去创建这一块代码的执行上下文

执行上下文分为:全局执行上下文、函数执行上下文、eval执行上下文

  • 全局执行上下文:JS引擎在编译全局代码时,创建全局执行上下文;
  • 函数执行上下文:在函数执行的前一刻,JS引擎会创建一个函数执行上下文;
  • eval执行上下文:在执行eval代码块前,会创建一个独立的eval执行上下文。

执行上下文的创建分为两个阶段:预编译阶段、执行阶段

执行上下文创建会做两件事情:

  • 创建词法环境 LexicalEnvironment
  • 创建变量环境 VariableEnvironment

1、在预编译阶段JavaScript引擎会初始化词法环境和变量环境

  • let/const变量,在初始化时会被置为标志位,在没有执行到 let xxx 或 let xxx = ??? 时,提前读取变量会报ReferenceError的错误,这个特性又叫 暂时性死区
  • var变量,在初始化时会先被赋值为undefined,这就是为什么我们在还没有执行到 var xxx 或 var xxx = ??? 时,提前读取变量时不会报错,尽管它的值为undefined,这个特性叫做 变量提升
  • 函数变量,在初始化的时候就会被直接赋值为这个函数,这也是为什么我们能够在还没有执行到 function func(){} 时,依然能够执行func() 函数的原因,这个特性叫做 函数提升

2、在执行阶段对初始化的变量进行赋值并按序执行

词法环境和变量环境

官方定义:词法环境是一种规范类型,基于 ECMAScript 代码的词法嵌套结构来定义标识符与特定变量和函数的关联关系.

变量环境也是一种词法环境,词法环境组件和变量环境组件的区别在于:

  • 词法环境存储 let const class 等除了 var 函数 声明之外的所有变量, 而变量环境存储 var 函数 声明的变量
    • 执行代码块内的 let、const、class 声明的标识符合集记录为 lexNames
    • 执行代码块内的 var 和 function 声明的标识符合集记录为 varNames
      • 如果 lexNames 内的任何变量在 varNames 或 lexNames 内出现过,则报错 SyntaxError

        这就是为什么可以用 var、function 声明多个同名变量,但是不能用 let、const、class 声明多个同名变量

      • let、const、class 等声明的变量被置为 标志位,并没有被初始化赋值,在运行至其声明处代码时才会进行初始化

        这就是我们所说的暂时性死区,let、const、class 声明的变量并没有被初始化赋值,而是被置为 标志位, 所以在声明前访问会报错

      • var 声明的变量初始化赋值为undefined,如果有同名变量则跳过

        这就是所谓的变量提升,我们用 var 声明的变量,在声明位置之前访问并不会报错,而是返回undefined

      • function 声明的变量变量初始化赋值为对应的函数体,如果有同名 function 声明的变量,前面的都会忽略,取最后一个声明的

        函数声明会被直接赋值,所有我们在函数声明位置之前也可以调用函数

      • varNames 内的 function 声明的变量和 var 声明的变量,如果同名,则 var 声明的变量会被忽略

        即会被赋值为一个函数体而不是undefined,即使 var 声明的位置在 function 声明位置的后面

词法环境由三个个部分组成

  • 环境记录(EnvironmentRecord):存储变量和函数声明的实际位置
  • 对外部词法环境的引用(Outer Reference):提供了访问父词法环境的引用,可能为null
  • this绑定(ThisBinding):确定当前环境中this的指向

词法环境有三种类型

  • 全局词法环境,是整个词法环境链的顶端,它的外部词法环境值为null
    • 全局词法环境的环境记录中会绑定一个全局对象,用于存储
  • 函数词法环境,在函数执行的前一刻会创建函数词法环境
    • 函数词法环境的外部词法环境指向调用该函数的父环境
  • 模块词法环境,在模块词法环境中你可以读取到export、module等变量,这些变量都是记录在模块环境的环境记录中
    • 模块词法环境的外部词法环境指向全局环境
环境记录(EnvironmentRecord)

代码中声明的变量和函数都会存放在环境记录(EnvironmentRecord)中等待执行时访问

环境记录有两个不同类型,分别为object和declarative

  • 对象环境记录(object),用来定义那些将标识符与某些对象相绑定的JS语法元素
    • 比如全局对象就定义在对象环境记录中,一些内置对象如Array、Object等和内置方法如eval、parseInt等和全局变量都定义在全局对象中
  • 声明式环境记录(declarative),用来定义那些直接将标识符与语言值绑定的JS语法元素

我们常说的作用域其实说的就是词法环境,全局作用域就是全局词法环境,函数作用域就是函数词法环境,块级作用域就是模块词法环境

对外部词法环境的引用(Outer Reference)
  • 全局词法环境,它的外部词法环境值为null
  • 函数词法环境,它的外部词法环境指向调用该函数的父环境
  • 模块词法环境,它的外部词法环境指向全局环境
  • 在查找变量的时候,会先在自己的词法环境中查找,如果找不到就到引用的外部词法环境中查找,沿着这条对外部词法环境的引用路线一直向上,直到找到或者到达全局词法环境为止,这条对外部词法环境的引用的链路就叫做词法环境链,也叫做作用域链
this绑定(ThisBinding)
默认绑定

在全局执行上下文中,this 的值绑定为全局对象

// 获取全局对象
function getGlobalObject() {
  if (typeof globalThis !== 'undefined') { return globalThis; }
  if (typeof self !== 'undefined') { return self; }
  if (typeof window !== 'undefined') { return window; }
  if (typeof global !== 'undefined') { return global; }
  throw new Error('cannot find the global object');
}

// 更可靠的获取全局对象
(function() {
  if (typeof globalThis === 'object') return;
  Object.defineProperty(Object.prototype, '__magic__', {
    get: function() {
      return this;
    },
    configurable: true // This makes it possible to `delete` the getter later.
  });
  __magic__.globalThis = __magic__; // lolwat
  delete Object.prototype.__magic__;
}())

在函数执行上下文中,this 的值取决于函数的调用方式。如果它被一个对象引用调用,那么 this 的值被设置为该对象,否则 this 的值在非严格模式下被绑定为为全局对象,在严格模式下this 的值为undefined

箭头函数

  • 1、箭头函数内部没有绑定this,所以无法手动指定this
  • 2、箭头函数没有原型,因而不能作为构造函数,不能使用new来创建实例对象
  • 3、箭头函数不绑定Arguments 对象,但是可以使用剩余参数(...rest)
  • 4、在箭头函数执行前,会通过它的词法环境链来确定它的this值,因而箭头函数内部的this是其定义位置的this,而不是调用位置的this
手动绑定

通过call/apply/bind能够手动绑定函数内部的this

  • Function.prototype.call
    • 调用方式:function.call(thisArg, [arg1, arg2, ...])
    • call方法能够为函数手动绑定this传入参数列表,并且在绑定之后立即执行该函数
  • Function.prototype.apply
    • 调用方式:func.apply(thisArg, [argsArray])
    • apply方法能够为函数手动绑定this传入参数数组,并且在绑定之后立即执行该函数
  • Function.prototype.bind
    • 调用方式:function.bind(thisArg, [arg1, arg2, ...])
    • bind方法能够为函数手动绑定this传入参数列表,但是在在绑定之后不会立即执行该函数

原生实现 call apply bind

// call
// thisBinding 绑定this
// call方法传递的是参数列表
Function.prototype.myCall = function(thisBinding){
  // 只允许函数调用call方法
  if (typeof this !== 'function') throw new TypeError(`${this} is not a function`)

  // 确定绑定this是一个对象
  // 绑定this为null时,将其指定为全局对象
  // 绑定this为原始类型,将其处理为引用类型
  let context = thisBinding == null ? globalThis : typeof thisBinding !== 'object' ? Object(thisBinding) : thisBinding
  
  // 把函数赋值到绑定this的属性
  // 使用Symbol防止使这个属性具有唯一性,这样
  // 既不会影响绑定this原本的属性值,比如原本绑定this有一个属性a,在这里使用a就会将其覆盖掉,改变了绑定this的原本属性结构
  // 也不会被绑定this所影响,比如原本绑定this有一个属性a在某种情况下被修改了,有可能会影响到这里的操作
  let key = Symbol('KEY')
  context[key] = this
  
  // 基于“对象[成员]()”方法把函数执行,此时函数中的this就是绑定this了
  // 获取参数列表
  const paramsList = [...arguments].slice(1) 
  // 把参数传递给函数,并且接收返回值
  const result = context[key](...paramsList)
  /* 这里也可以直接使用剩余参数的形式而不用使用arguments,然后需要在参数出多传一个剩余参数function(thisBinding, ...rest){...}
     const result = context[key](...rest)
  */
  
  // 函数执行完毕后将这个属性删除,恢复绑定this的原本结构
  delete context[key]
  
  //把函数的返回值作为call方法执行的结果返回
  return result
}
// apply
// thisBinding 绑定this
// apply传递的是参数数组
Function.prototype.myApply = function(thisBinding){
  // 只允许函数调用call方法
  if (typeof this !== 'function') throw new TypeError(`${this} is not a function`)

  // 确定绑定this是一个对象
  // 绑定this为null时,将其指定为全局对象
  // 绑定this为原始类型,将其处理为引用类型
  let context = thisBinding == null ? globalThis : typeof thisBinding !== 'object' ? Object(thisBinding) : thisBinding
  
  // 把函数赋值到绑定this的属性
  // 使用Symbol防止使这个属性具有唯一性,这样
  // 既不会影响绑定this原本的属性值,比如原本绑定this有一个属性a,在这里使用a就会将其覆盖掉,改变了绑定this的原本属性结构
  // 也不会被绑定this所影响,比如原本绑定this有一个属性a在某种情况下被修改了,有可能会影响到这里的操作
  let key = Symbol('KEY')
  context[key] = this
  
  // 基于“对象[成员]()”方法把函数执行,此时函数中的this就是绑定this了(把参数传递给函数,并且接收返回值)
  const paramsList = [...arguments].slice(1)
  const result = context[key](paramsList)
  
  // 函数执行完毕后将这个属性删除,恢复绑定this的原本结构
  delete context[key]
  
  //把函数的返回值作为call方法执行的结果返回
  return result
}
// bind
// thisBinding 绑定this
// bind方法传递的是参数列表
// bind方法不会立即执行函数
Function.prototype.myBind = function(thisBinding){
  // 只允许函数调用call方法
  if (typeof this !== 'function') throw new TypeError(`${this} is not a function`)

  // 确定绑定this是一个对象
  // 绑定this为null时,将其指定为全局对象
  // 绑定this为原始类型,将其处理为引用类型
  let context = thisBinding == null ? globalThis : typeof thisBinding !== 'object' ? Object(thisBinding) : thisBinding
  
  // 把函数赋值到绑定this的属性
  // 使用Symbol防止使这个属性具有唯一性,这样
  // 既不会影响绑定this原本的属性值,比如原本绑定this有一个属性a,在这里使用a就会将其覆盖掉,改变了绑定this的原本属性结构
  // 也不会被绑定this所影响,比如原本绑定this有一个属性a在某种情况下被修改了,有可能会影响到这里的操作
  let key = Symbol('KEY')
  context[key] = this
  
  // 基于“对象[成员]()”方法把函数执行,此时函数中的this就是绑定this了
  // 获取参数列表
  const paramsList = [...arguments].slice(1) 
  
  // 只绑定this,不立即执行函数
  // ...args 函数执行时传递的参数
  return function(...args){
    // 把参数传递给函数,并且接收返回值
    const result = context[key](...paramsList.concat(args))
    
    // 函数执行完毕后将这个属性删除,恢复绑定this的原本结构
    delete context[key]

    //把函数的返回值作为call方法执行的结果返回
    return result
  }
}

// 利用call或者apply简写
Function.prototype.myBind = function myBind(thisBinding){
  const _this = this
  const paramsList = [...arguments].slice(1)
  
  return function(...agrs){
    return _this.call(thisBinding, ...paramsList.concat(agrs))
  }
}

执行上下文创建之后就会被推入执行环境栈中存储起来,每创建一个新的执行上下文时都会被推入当前执行环境栈的顶端,等到代码执行完毕后,其对应的执行上下文就会从执行环境栈中弹出,上下文控制权将移到当前执行环境栈的下一个执行上下文

let a = 'Hello World!';

function first() {
  console.log('Inside first function');
  second();
  console.log('Again inside first function');
  function third() {
    console.log('Inside third function');
  }
  return third
}

function second() {
  console.log('Inside second function');
}

var f = first();
f()
console.log('Inside Global Execution Context');

// 当上述代码在浏览器中加载时
/*
1、创建一个全局执行上下文,并将其推入执行环境栈
2、执行 first 函数时,Javascript 引擎为该函数创建了一个新的执行上下文,并将其推到当前执行环境栈的顶端
3、当在 first 函数中执行 second 函数时Javascript 引擎为该函数创建了一个新的执行上下文,并将其推到当前执行环境栈的顶端
4、second 函数执行完成后,它的执行上下文从当前执行环境栈中弹出,上下文控制权将移到当前执行环境栈的下一个执行上下文,即 first 函数的执行上下文
5、first 函数执行完成后,它的执行上下文从当前执行环境栈中弹出,上下文控制权将移到当前执行环境栈的下一个执行上下文,即全局执行上下文
6、执行 f 函数,即执行 third 函数,Javascript 引擎为该函数创建了一个新的执行上下文,并将其推到当前执行环境栈的顶端
7、third 函数执行完成后,它的执行上下文从当前执行环境栈中弹出,上下文控制权将移到当前执行环境栈的下一个执行上下文,即全局执行上下文
8、一旦所有代码执行完毕,Javascript 引擎把全局执行上下文从执行栈中移除。
*/

闭包

当函数可以记住并访问其声明位置的词法环境时,就产生了闭包,即使该函数是在当前词法环境之外执行

无论通过何种方式将内部函数传递到其声明位置的词法环境之外,它都会持有对其所在位置的词法环境的引用,而且无论在何处执行这个函数,都会使用到闭包

所以说闭包其实是内部函数对外部词法环境的引用,即使外部词法环境所在的执行上下文已经被回收,但是这个内部函数对外部词法环境的引用依然存在于内存中,所以滥用闭包可能会导致内存泄漏

闭包的应用场景

闭包的特点就是能够访问到函数声明位置的词法环境,不论这个函数在哪里调用,利用这个特性我们可以:

  • 1、封装私有变量和方法
function module(){
  let var1 = 1
  function fnc1()

  return {
    var1,
    fnc1
  }
}

let myModule = module()

2、实现防抖和节流

function throttle(fn, interval) {
  // last为上一次触发回调的时间
  let last = 0

  // 将throttle处理结果当作函数返回
  return function () {
    // 保留调用时的this上下文
    let context = this
    // 保留调用时传入的参数
    let args = arguments
    // 记录本次触发回调的时间
    let now = +new Date()

    // 判断上次触发的时间和本次触发的时间差是否小于时间间隔的阈值
    if (now - last >= interval) {
    // 如果时间间隔大于我们设定的时间间隔阈值,则执行回调
      last = now;
      fn.apply(context, args);
    }
  }
}
function throttle(fn, delay) {
  // last为上一次触发回调的时间, timer是定时器
  let last = 0, timer = null
  // 将throttle处理结果当作函数返回

  return function () { 
    // 保留调用时的this上下文
    let context = this
    // 保留调用时传入的参数
    let args = arguments
    // 记录本次触发回调的时间
    let now = +new Date()

    // 判断上次触发的时间和本次触发的时间差是否小于时间间隔的阈值
    if (now - last >= delay) {
      // 如果时间间隔超出了我们设定的时间间隔阈值,那就不等了,无论如何要反馈给用户一次响应
      last = now
      fn.apply(context, args)
    } else {
     // 如果时间间隔小于我们设定的时间间隔阈值,则为本次触发操作设立一个新的定时器
      clearTimeout(timer)
      timer = setTimeout(function () {
        last = now
        fn.apply(context, args)
      }, delay)
    }
  }
}

3、实现once函数

function once(fn){
  let called = fasle

  return function (){
    if(!called){
      called = true
      fn.apply(this, arguments)
    }
  }
}
                                 
let b = once(function(){})
b()                               

4、实现记忆函数,用来缓存一些大数组,避免多次循环浪费性能

function memoize(list){
  let map = Object.create(null)
  
  list.forEach(item => {
    map[item] = true
  })
  
  return function(arg){
    return !!map[arg]
  }
}

感谢 @拳打南山敬老院 / @Tusi / @ Logan70 / @zhangwinwin / @无邪 提供的优质文章

详解global对象的获取

一文带你重写call、apply、bind

深入JavaScript系列(一):词法环境

JavaScript代码是怎么执行的?

如何理解js的执行上下文与执行栈