11期前端冲刺必备指南-执行上下文/作用域链/闭包/一等公民

6,341 阅读21分钟

前言

大家好,我是吒儿👦,每天努力一点点💪,就能升职加薪💰当上总经理出任CEO迎娶白富美走上人生巅峰🗻,想想还有点小激动呢😎。

这是我的第11期文章内容✍,我并不希望把👉这篇文章内容成为笔记去记,或者说是总结一些要点。而是希望通过这篇文章真正地去理解,掌握,一行一行的解析其内容本质,去思考✅每一行,每一段的内容。

希望能够把每一处知识点,说明白,(当然,如果哪一处不了解,可以在评论区进行探讨哦!)⏰,计时开始!

如果您发现本文有帮助,请您点赞,收藏,评论,留下您学习的脚印👣,我很乐意谈论😃

1. 执行上下文/作用域链/闭包

什么鬼,这是什么鬼?😑,想必有部分开发者懂,但是对于初学者或者说是(浅入学习者)来说,执行上下文和执行堆栈,在脑袋中想必是一片空白呢?📖,您说是不是?

1.1 那么什么是执行上下文?

执行上下文,它是比较抽象的概念,就是当前JavaScript代码被解析和执行时所在环境,so,在JavaScript中运行任何的代码都是在执行上下文中运行的。

执行上下文有三种类型:

🔷第一种类型:全局执行上下文

记住全局执行上下文,只有一个即一个程序中只能有一个全局执行上下文,如果是在浏览器中,那么全局对象就是window对象,this指向就是这个全局对象

🔷第二种类型:函数执行上下文

函数执行上下文可以存在多个,甚至是无数个;只有在函数被调用时才会被创建(函数执行上下文),每次调用函数都会创建一个新的执行上下文

🔷第三种类型:Eval函数执行上下文

Eval函数执行上下文,什么鬼!这是神马?想必一部分程序员很少用过这,so,不必解释,但记住这是运行在eval函数中的代码,只有在eval函数中的代码才有eval函数执行上下文

理解了执行上下文(即环境),那么需要了解在JavaScript程序中的执行流,以及控制机制(执行堆栈)流程。

1.2 执行栈

其实执行堆栈(调用堆栈)具有后进先出结构的堆栈,该结构用于存储在代码执行执行期间创建的所有执行上下文。

压栈出栈的过程——执行上下文栈

当JavaScript引擎运行JavaScript代码时它会创建一个全局执行上下文并将其push到当前执行堆栈。(函数还没解析或者是执行、调用)仅存在全局执行上下文,每当引擎发现函数调用时,引擎都会为该函数创建一个新的函数执行上下文,并将其推入到堆栈的顶部(当前执行栈的栈顶)

当引擎执行其执行上下文位于堆栈顶部的函数之后,将其对应的函数执行上下文将会从堆栈中弹出,并且控件到达当前堆栈中位于其下方的上下文(如果有下一个函数的话)

执行上下文的生命周期:

创建过程:1.生成变量对象,2.建立作用域链,3.确定this的指向。

执行过程:1.变量赋值,2.函数引用,3.执行其他代码。

销毁阶段:执行完毕后出栈,,等待被回收。

现在,我们了解了JavaScript引擎如何管理执行上下文,那么如何创建呢?😲

1.3 执行上下文的创建

学习如何创建执行上下文,执行上下文分两个阶段创建:

  • 第一种:创建阶段-执行上下文

  • 第二种:执行阶段-执行上下文

执行上下文是在创建阶段创建的,创建阶段发生的事情:

创建阶段-执行上下文

确定this的指向,this确定或设置的值

在全局执行上下文中,this的值指向全局对象,在浏览器中,this的值➡window对象;在nodejs中指向的是➡module对象

在函数执行上下文中,this的值取决于函数的调用方式(即如何被调用的)。当它被一个引用对象调用,则将的值this设置为该对象,否则this的值将的值this设置为全局对象或undefined(在严格模式下)

下面来看看this的代码示例:

const dadaqianduan = {
    name: 'dadaqiandaun',
    love: '魔王哪吒',
    foo: function(){
        console.log('dadaqianduan');
    }
}

dadaqianduan.foo();
// this的指向dadaqianduan,因为foo是通过dadaqianduan对象引用调用的

const Jeskson = dadaqianduan.foo;
Jeskson();
// this指向全局窗口对象,因为没有给出对象引用

抽象地,词汇环境在伪代码中看起来像这样:

GlobalExectionContext = {  
// 全局执行上下文
  LexicalEnvironment: {    	  
  // 词法环境
    EnvironmentRecord: {   		
    // 环境记录
      Type: "Object",      		   
      // 全局环境
      // 标识符绑定在这里 
      outer: <null>  	   		   
      // 对外部环境的引用
  }  
}

FunctionExectionContext = { 
// 函数执行上下文
  LexicalEnvironment: {  	  
  // 词法环境
    EnvironmentRecord: {  		
    // 环境记录
      Type: "Declarative",  	   
      // 函数环境
      // 标识符绑定在这里 			  
      // 对外部环境的引用
      outer: <Global or outer function environment reference>  
  }  
}

LexicalEnvironment(词法环境)组件已创建

首先看到词法环境?究竟什么是词法环境呢?这个名词概念如何理解?😮

那么首先上来就是,词法环境的定义:

官方规范对词法环境的说明,词法环境是一种规范类型,用于根据ECMAScript代码的词法嵌套结构来定义标识符与特定变量和函数的关联。

词法环境是保存标识符,变量映射的结构。(这里的标识符是指变量/函数的名称,而变量是对实际对象(包括函数对象和数组对象)或原始值的引用)

词法环境由一个环境记录和可能为空引用(Null)的外部词法环境组成。通常,词法环境和ECMAScript代码的特定语法结构相关联。

环境记录是在词法环境中存储变量和函数声明的地方。

环境记录主要适用两种环境记录:声明性环境记录和对象环境记录。环境记录分别是声明式环境记录,对象环境记录和全局环境记录。(全局环境记录在逻辑上是单个记录,但是它被指定为封装对象环境记录和声明性环境记录的组合

声明性环境记录(绑定了包含在其作用域内声明定义的标识符集),就是它存储变量和函数声明,功能代码的词法环境包含一个声明性环境记录。

对象环境记录(绑定对象),全局代码的词法环境包含一个客观环境记录,除了变量和函数声明外,对象环境记录还存储全局绑定对象。so,对于每个绑定对象的属性,将在记录中创建一个新的条目。

so,对于功能代码来说,环境记录中包含一个arguments对象,该对象包含传递给该函数的索引和参数与传递给该函数的参数的长度之间的映射。

如下代码:

function foo(a,b){
    var c = a+b;
}
foo(1,2);
// 参数对象
参数: {0:1,1:2,长度:2}

对外部环境的引用,意味着它可以访问其外部词法环境,如果在当前词法环境中找不到变量,则JavaScript引擎可以在外部环境中查找变量。

这里就听不懂了,词法环境有两个组成部分:

  • 环境记录,记录相应环境中的形参,函数声明变量声明等(存储变量和函数声明的实际位置)
  • 对外部环境的引用,可以访问其外部词法环境

用伪代码表示:

function LexicalEnvironment() {
    this.EnvironmentRecord = undefined;
    this.outer = undefined; 
    //outer Environment Reference
}

环境记录记录了在其关联的词法环境作用域内创建的标识符绑定。👇

其实词法环境就是描述环境的对象,先确定当前环境的外部引用,环境记录初始化,就是常遇到的声明提前,全局代码执行之前,先初始化全局环境;函数代码执行之前,先初始化函数环境。

  1. 全局环境(用于表示在共同领域中处理所有共享最外层作用域的ECMAScript Script元素)是一个没有外部环境的词法环境,so,全局环境的外部环境引用为null。
  2. 模块环境是一个包含模块顶层声明绑定的词法环境,它的外部环境是一个全局环境。
  3. 函数环境是一个对应于ECMAScript函数对象调用的词法环境。

现在用代码表示词法环境:

var a = 1;
var b = 2;

function foo(){
    console.log('达达前端');
}

这段代码的词法环境表示:

lexicalEnvironment = {
    a: 1,
    b: 2,
    foo: <ref. to foo function> 
}
执行阶段-执行上下文

在此阶段,将完成对所有这些变量的分配,最后执行代码。

VariableEnvironment(变量环境)组件已创建

在ES6中,词法组件和变量环境组件之间的区别是前者用于存储函数声明和变量(let和const)绑定,而后者仅用于存储变量var绑定。

说说变量提升的原因,在创建阶段,函数声明存储在环境中,而变量会被设置为undefined或保持未初始化。

so,这就是为什么可以在声明之前访问var定义的变量,但如果在声明之前访问let和const定义的变量就会提升引用错误的原因。

现在举个例子:
var da1, da2 = 1;
function foo() {
    var da3, da4;
};
foo();

js在执行这段代码时,创建了一个词法环境(global environment- ge),确定(ge)的环境记录,里面包含了da1,da2,foo标识符的记录,设置外部词法环境的引用,因为(ge)已经在最外面了,so,外部词法环境引用就是Null,到此(ge)就确立完毕了。

接着执行代码,当执行到foo(),js调用了foo函数,foo函数是一个(FunctionDeclaration),js开始执行函数创建了一个新的词法环境表示为(ge2),设置(ge2)的外部词法环境引用,很明显就是(ge),(ge2)的环境记录(da3,da4)。

所有创建词法环境以及环境记录都是不可见的,在编译器内部完成

示例词法环境:

全局的词法环境,源文件代码,就是一个词法环境
函数代码,eval词法环境,with结构,catch结构

// 全局的词法环境
var a = 1;
function da1() {
    // 函数da1的词法环境
    var b = 2;
    function da2() {
    // 函数da2的词法环境
        return a*b;
    }
    return da2();
}

with({c:3, d:5}){
    // with声明的词法环境
    console.log(this.c)
}

try {
    var e = da1()
} catch(e){
    // catch块声明的词法环境
    console.log('达达前端,魔王哪吒')
}

1.4 JavaScript执行上下文栈过程

思考,JavaScript引擎并非一行一行分析和执行程序,而是一段一段地分析执行。如何管理创建的那么多执行上下文?

so,JavaScript引擎创建了执行上下文栈来管理。

1.5 面试题

比较下面两段代码,试述两段代码的不同之处
// A--------------------------
var scope = "global scope";
function checkscope(){
    var scope = "local scope";
    function f(){
        return scope;
    }
    return f();
}
checkscope();

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

解释如下:

因为JavaScript采用的是词法作用域,函数的作用域基于函数创建的位置。

  • 第一个内部函数f在初始化时,会建立一个活动对象,它会添加一个属性名为scope的属性,会给它建立一个隐藏属性[[scope]],这个就是用于指向父级活动对象的。在到这个函数执行时,scope会被赋值,顺着它的[[scope]]就可以找到父级的值,返回一个代指的变量,继续返回到函数外部。输出local scope
  • 第二个内部函数f在初始化的时候也是建立一个活动对象,这个活动对象上会添加一个属性名为scope属性。也会建立一个指向父级活动对象的[[scope]]隐藏属性。在checkscope第一次执行进入checkscope函数体的时候返回的是f指针值(对内部函数的一个引用),而非第一个返回的直接就是个原始值变量。第二次执行才进入f函数体,内部活动对象及[[scope]]私有属性已经建立,它便顺着这条链查找scope变量的值,并返回,形成闭包。

对于函数对象来说,当外层函数执行完就该销毁所有变量的,但此时一个函数指针被返回了,就意味着外部跟函数内部建立了联系,这个指针指向函数内部区域,它无法销毁,作用域链还在,so,内部那个函数就可以访问到私有变量了。

变量对象,每一个执行上下文都会分配一个变量对象,变量对象的属性由变量和函数声明构成。在函数上下文情况下,参数列表也会被加入到变量对象中作为属性,变量对象与当前作用域相关。

不同作用域的变量对象互不相同,它保存了当前作用域的所有函数和变量。

只有函数声明会被加入到变量对象中,而函数表达式不会。

// 函数声明
function da(){}
console.log(typeof da); // "function"

// 函数表达式
var da2 = function da1(){};
console.log(typeof da2); // "function"
console.log(typeof da1); // "undefined"

当Js编辑器开始执行的时,会初始化一个全局对象用于关联全局的作用域,对于全局环境而言,全局对象就是变量对象。

之前提到变量对象对于程序而言是不可读的,只有编译器才有权访问变量对象。在浏览器端,全局对象被具象成window对象,即全局对象===window===全局环境的variable object。

当函数被调用,那么一个活动对象就会被创建并分配给执行上下文。则将其活动对象作为变量对象,活动对象由特殊对象arguments初始化。

arguments对象,这个对象在全局环境中是不存在的

示例如下:

function dada(name, love){
    var job = 'it',
    function dada1(){}
}
dada('Jeskson',girl);

dada被调用时,在dada的执行上下文会创建一个活动对象AO,并且被初始化为AO=[arguments],随后AO被当做变量对象variable object,vo进行变量初始化,此时VO=[arguments].concat([name,love,jog])。

词法作用域,词,单词,法,语法,就是单词(标识符,原始值,操作符等),语法就是JavaScript中的各种语法规则,so,词法作用域在js中,一种全局,一种函数。

作用域控制着变量和参数的可见性以及生命周期,在一块代码块中定义的所有变量在代码块的外部是不可见的 ,定义在代码块中的变量在代码块执行结束后会释放。在函数中的参数和变量在函数外部是不可见的,在一个函数内部任何定义的变量,在该函数内部都是可见的。

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

1.6 动态作用域

动态作用域,函数的作用域是在函数调用的时候才决定的。

总而言之,作用域的好处是内部函数可以访问定义他们的外部函数的参数和变量,除this和arguments。

综上,每个执行上下文,都有变量对象,作用域链,this。

1.7 作用域链

这篇说明了作用域链知识点:JavaScript之从原型到原型链

作用域链:当查找某个变量时,会先在当前上下文的变量对象中查找,如果没有找到,就会从父级执行上下文的变量的对象中查找,一直找到全局上下文的变量对象,也就是全局对象。(即由多个执行上下文的变量构成)

函数内部有一个内部属性[[scope]],当函数创建时,会保存所有父变量到这个属性中,[[scope]]为所有父变量对象的层级链,不代表全部完整的作用域链。

1.8 闭包

第一:如何使用闭包;第二:什么是闭包;第三:闭包是什么时候被创建的;第四:什么时候被销毁的。

面试题
for(var i=0; i<5; i++) {// 从0-4
    setTimeout(function(){
        console.log(new Date, i);
    },1000);
}
console.log(new Date, i);

使用闭包让其输出5 -> 0,1,2,3,4

for (var i = 0; i < 5; i++) {
    (function(j) {  // j = i
        setTimeout(function() {
            console.log(new Date, j);
        }, 1000);
    })(i);
}

console.log(new Date, i);

优化:

var output = function(i) {
    setTimeout(function(){
        console.log(new Date, i);
    },1000);
};
// 变量赋值匿名函数
for(var i=0; i<5; i++){
    output(i); // 传递i值
}
console.log(new Date, i);
// 这段代码最后的i在运行时会报错
for(let i=0; i<5; i++) {
    setTimeout(function(){
        console.log(new Date, i);
    },1000);
}
console.log(new Date, i);
for(var i = 0; i<5; i++){
    function(j){
        setTimeout(function(){
            console.log(new Date, j);
        }, 1000 * j);
    })(i);
}

setTimeout(function(){
    console.log(new Date, i);
},1000 * i);

使用es6编写:

const tasks = []; // 存放异步操作promise
const output = (i) => new Promise((resolve) => {
    setTimeout(()=>{
        console.log(new Date, i);
        resolve();
    }, 1000 * i);
});
for(var i=0; i<5; i++){
    tasks.push(output(i));
}

Promise.all(tasks).then(()=>{
    setTimeout(()=>{
        console.log(new Data, i);
    },1000);
});
// async/await
const sleep = (timeountMS) => new Promise((resolve) => {
    setTimeout(resolve, timeountMS);
});

(async () => {  
    for (var i = 0; i < 5; i++) {
        if (i > 0) {
            await sleep(1000);
        }
        console.log(new Date, i);
    }

    await sleep(1000);
    console.log(new Date, i);
})();
window.setTimeout

setTimeout()方法设置一个定时器,该定时器在定时器到期后执行一个函数或指定的一段代码。

var id = setTimeout(fn, delay)启动单个计时器,该计时器将在延迟后调用指定的功能,返回一个唯一的id,以后可以使用该id取消计时器。

var id = setInterval(fn, delay)类似于setTimeout但连续调用该函数,直到被取消。clearInterval(id),clearTimeout(id),接收计算器id,并停止计算器回调。

不能保证计算器的延迟,由于浏览器中所有JavaScript都在单线程上执行,so,异步事件仅在执行中存在空缺时才运行。

由于JavaScript一次只能执行一段代码,因此这些代码块中的每一个都“阻塞”了其他异步事件的过程,当发生异步事件时,它将排队等待稍后执行。

setTimeoutsetInterval

setTimeout(function(){
    setTimeout(arguments.callee, 10);
},10);
setInterval(function(){
    
},10);
// setTimeout代码在上一次执行回调之后将始终至少有10ms的延迟,最终可能会更多,但是不会少,而setInterval无论最后一次执行回调的时间如何,都会尝试每10ms执行

Promise

Promise对象用于表示一个异步操作的最终完成或失败,以及其结果值。

示例:

const promise1 = new Promise((resolve,reject)=>{
    setTimeout(()=>{
        resolve('foo');
    },200);
});

promise1.then((value)=>{
    console.log(value);
});
console.log(promise1);

Promise对象是一个代理对象,被代理的值在Promise对象创建时可能是未知的。它允许你为异步操作的成功和失败分别绑定相应的处理方法。这让异步方法可以像同步方法那样返回值,但并不是立即返回最终执行结果,而是一个能代表未来出现的结果的promise对象。

一个Promise有几种状态😬:

  1. pending初始状态,即不是成功,也不是失败状态;
  2. fulfilled表示操作成功完成;
  3. rejected表示操作失败。

Promise.prototype.then和Promise.prototype.catch方法返回promise对象,所以它们可以被链式调用。

方法:

Promise.prototype.catch(onRejected)添加一个拒绝回调到当前promise,返回一个新的promise。

Promise.prototype.then(onFulfilled, onRejected)添加解决和拒绝回调到当前promise,返回一个新的promise,将以回调的返回值来resolve。

Promise.prototype.finally(onFinally)添加一个事件处理回调于当前promise对象,并且在原promise对象解析完毕后,返回一个新的promise对象。

示例:

function myAsyncFunction(url) {
    return new Promise((resolve, reject) => {
        const xhr = new XMLHttpRequest();
        xhr.open('GET',url);
        xhr.onload = () => resolve(xhr.responseText);
        xhr.onerror = () => reject(xhr.statusText);
        xhr.send();
    }
}

async function

async function用来定义一个返回AsyncFunction对象的异步hash。

示例:

function resolveAfter2Seconds() {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve('resolved');
    }, 2000);
  });
}

async function asyncCall() {
  console.log('calling');
  const result = await resolveAfter2Seconds();
  console.log(result);
  // expected output: 'resolved'
}

asyncCall();

结果:

> "calling"
> "resolved"

一个async异步函数可以包含await指令,该指令会暂停异步函数的执行,并等待Promise执行,然后继续执行异步函数,并返回结果。

await 关键字只在异步函数内有效。如果你在异步函数外使用它,会抛出语法错误。

1.9 强大的闭包

示例:😦

"use strict";
var dada = (function outerFunction(){
    var da = 1;
    return {
        inc: function innerFunction(){
            return da++;
        }
    };
}());
dada.inc(); // 1
dada.inc(); // 2
dada.inc(); // 3

全局环境中运行的代码:😫

// my_script.js
"use strict";

var foo = 1;
var bar = 2;

没有被嵌套的函数😊

"use strict";
var foo = 1;
var bar = 2;

function myFunc() {
  var a = 1;
  var b = 2;
  var foo = 3;
  console.log("inside myFunc");
}

console.log("outside");

myFunc();

当myFunc被执行的时候,对象之间的关系如下图所示

闭包是同时含有对函数对象以及作用域对象引用的最想,实际上,所有JavaScript对象都是闭包。so,当你定义一个函数的时候,你就定义了一个闭包。当闭包不被任何其他的对象引用时,会被销毁。

闭包是一个可以访问外部作用域的内部函数。通过 var 创建的变量只有函数作用域,通过 let 和 const 创建的变量既有函数作用域,也有块作用域。

嵌套作用域:

(function dada(){
    let a = 1;
    function dada1() {
        console.log(a);
    }
    dada1();
})();
// dada1函数就是一个闭包
// 可以通过在一个函数内部或者{}块里面定义一个函数来创建闭包

内部函数可以访问外部函数:

(function autorun(da){
    let da1 = 1;
    setTimeout(function log(){
      console.log(da1);//1
      console.log(da);//6
    }, 10000);
})(6);

词法作用域是指内部函数在定义的时候就决定了其外部作用域,闭包的外部作用域是在其定义的时候就决定了。

示例:

(function dada(){
    let a = 1;
    function da(){
      console.log(a);
    };
    
    function run(fn){
      let a = 100;
      fn();
    }
    
    run(da);//1
})();

dada()的函数作用域是da()函数的词法作用域

外部作用域执行完毕后,内部函数还在(在其他地方被引用),闭包才真正发挥作用。😏

(function dadaqianduan(){
    let a = 1;
    setTimeout(function log(){
      console.log(a);
    }, 1000);
})();
(function dada(){
    let a = 1;
    $("#btn").on("click", function log(){
      console.log(a);
    });
})();
(function dada(){
    let ax = 1;
    fetch("http://").then(function log(){
      console.log(a);
    });
})();

闭包只存储外部变量的引用,而不会拷贝这些外部变量的值,注意,这玩意用多了内存泄漏了就不好了😂😂

闭包可以引用函数外部变量,并且会沿着原型链向上查找,闭包引用的变量在闭包存在时不会被回收,函数的词法作用域在函数声明的时候已经决定了,所以闭包可引用的外部变量只能是父级。

在垃圾回收中😱,局部变量会随着函数的执行完毕而被销毁😱,除非还有指向他们的引用。当闭包本身被垃圾回收后,闭包中的私有状态随后也会被垃圾回收。

函数是一等公民

您是不是常常听到-“函数是一等公民”这样的描述,在编程中,一等公民可以作为函数参数,可以作为函数返回值,也可以赋值给变量。😘

例如,字符串在几乎所有编程语言中都是一等公民,字符串可以做为函数参数,可以作为函数返回值,也可以赋值给变量。

so,函数在JavaScript中是一等公民。一等公民具有最高的优先权,当函数被看作是“一等公民”, 就是函数优先。

  • 函数可以存储到变量中
  • 函数可以存储为数组的一个元素
  • 函数可以作为对象的成员变量
  • 函数与数字一样可以在使用时直接创建出来
  • 函数可以被传递给另一个函数
  • 函数可以被另一个函数返回

参考文献

How do JavaScript closures work under the hood

Understanding Execution Context and Execution Stack in Javascript

How JavaScript Timers Work

**前端面试(80% 应聘者不及格系列):从闭包说起

JavaScript高级程序设计(第3版)

JavaScript权威指南