JS代码的执行机制:执行上下文、作用域

190 阅读10分钟

一、作用域、作用域链

作用域参考

1. 作用域

作用域指的是代码中的变量、函数和对象的可访问性。ES5 只有全局作用域和函数作用域,ES6 新增了块级作用域,可通过let / const体现。

「JS 采用的是 静态/词法作用域,也就是说变量的作用域在定义时就确定了,一个函数能够访问到的上层作用域,在函数"创建"时就已经被确定,和函数在哪里"调用执行"无关。」 如果是动态作用域,那么作用域和函数执行的环境有关。

2. 作用域链

2.1 自由变量

当前作用域中没有定义的变量,就叫做自由变量。在函数 f 中,取自由变量的值时,要到 "创建 f 函数"的那个作用域中取,无论 f 函数在哪里被"调用"。,这也就是 “静态作用域”。

var a = 2;
function f() {
  console.log(a);  // 自由变量
}
function show(f) {
  var a = 20;
  (function() {
    f();
  })();
}
show(f);  // 2

2.2 作用域链

查找一个自由变量的值时,会先在当前作用域寻找,找不到的话就会到它的父级作用域寻找,逐层向外查找直到找到全局window对象,如果全局作用域还没有就返回undefined,这一层层的嵌套关系就是 作用域链。

3. 块级作用域

花括号内的区域就是块级作用域。

var、let、const

串联

自执行函数

函数定义后想要立即调用的解决方法:

(1)使用 IIFE:将整个 function 放在一个圆括号里。

// IIFE:立即调用的函数表达式,记得后面加分号!
(function() {})();
// or
(function() {}());

IIFE 的目的:不用给函数命名,避免污染了全局变量;IIFE 内部形成了一个单独的作用域(类似块级作用域),可以封装一些外部无法读取的私有变量。

(2)在函数表达式后直接加圆括号调用

var f = function f(){}();

二、执行上下文

面试官:说说执行上下文吧

1. 什么是执行上下文

执行上下文:当前代码执行的一个环境与作用域。

当 JS 引擎解析到可执行代码时(通常是函数调用阶段),会先做一些执行前的准备工作,这个 “准备工作” 就叫做 执行上下文 或 执行环境。JS 的任何代码都是在执行上下文中运行的。

2. 执行上下文的三种类型

  • 全局执行上下文:一个程序只会存在一个全局上下文,函数外的代码都在全局执行上下文中。它做了两件事:

    • 创建一个全局对象,以浏览器环境为例就是 window。
    • 将 this 值绑定到这个全局对象上。
  • 函数执行上下文:每当调用函数时,都会为该函数创建一个新的函数执行上下文(不管这个函数是不是被重复调用的)。

  • eval 函数执行上下文:运行在 eval 函数中的代码也有自己的执行上下文,使用少。

3. 执行上下文栈【重要】

执行上下文栈,也叫调用栈,用来存储在代码执行期间创建的所有执行上下文

当 JS 引擎首次读取代码时,会创建一个 全局执行上下文 并将其 推到 当前的执行栈,以后每调用一次函数,引擎都会为函数创建一个 函数执行上下文 并将其 推到 当前执行栈内。

js 引擎会运行执行上下文在执行栈顶端的函数,当栈顶函数运行完成后,将其弹出,上下文控制权将移到当前执行栈的下一个执行上下文。

代码示例:

function first(){
    second();
    console.log(1);
}
function second(){
    third();
    console.log(2);
}
function third(){
    console.log(3);
}

first(); // 3 2 1

执行过程如下:

// 1. 代码执行前创建全局执行上下文 
ECStack = [globalContext]; 
// 2. first 调用 
ECStack.push('first functionContext'); 
// 3. first 调用了 second ,等待 second 执行完毕再输出 1 
ECStack.push('second functionContext'); 
// 4. second 调用了 third ,等待 third 执行完毕再输出 2 
ECStack.push('third functionContext'); 
// 5. third 执行完毕,输出 3 并弹出栈 
ECStack.pop(); 
// 6. second 执行完毕,输出 2 并弹出栈 
ECStack.pop(); 
// 7. first 执行完毕,输出 1 并弹出栈 
ECStack.pop(); 
// 此时执行栈中只剩下一个全局执行上下文

4. 执行上下文的生命周期(ES5 版)

(1) 创建阶段

  • this 值绑定 - This Binding
  • 词法环境组件创建
  • 变量环境组件创建

(2)执行阶段

(3) 回收阶段

三、作用域 与 执行上下文

当 JS 引擎处理一段脚本内容的时候,它是以怎样的顺序解析和执行的?脚本中的那些变量是何时被定义的?它们之间错综复杂的访问关系又是怎样创建和链接的? JS 是解释型语言,代码的执行分为 解释执行 两个过程。

解释阶段

  • 词法分析:将字符流分解为一些有意义的单词。
  • 语法分析:对词法集合分析最终转换成一个 AST抽象语法树。
  • 作用域规则确定:因此作用域在定义时就确定、不会再改变。

执行阶段

  • 创建执行上下文:执行上下文在运行时确定,随时可能改变,this 指向也是执行时确定。
  • 执行函数代码
  • 垃圾回收

四、闭包

1. 闭包是什么

有时,我们需要从外部获取到函数内部的局部变量,但正常情况下无法实现。通常采用“在函数内部再定义一个函数” 的方法来实现。这就是闭包,相比于局部变量无法共享和长久地保存,而全局变量可能造成全局污染。闭包这种机制既可以长久地保存变量又不会造成全局污染!

闭包:

能够访问其他函数内部变量的函数。(实际上就是 定义在一个函数内部的函数),闭包中引用的变量的存储位置是堆内存,它的三个特点是:

  • 外部函数嵌套内部函数
  • 内部函数使用外部函数的变量(自由变量)
  • 把内部函数返回出去,并在外部调用

因为内部函数返回出去了,counter 函数还在使用,所以不会对外部函数 counterCreator 垃圾回收、不会销毁内部作用域,闭包使得函数可以继续访问定义时的词法作用域

function  counterCreator(){   
  let index = 1;  
  function counter(){  //闭包函数
      console.log(index++);
  }
  return counter;   
}
// 测试
let counterA = counterCreator();  
let counterB = counterCreator();
counterA();  //1
counterA();  //2,闭包可以记住它的执行环境
counterB();  //1
counterB();  //2

闭包是基于词法作用域的,正因为 js 采用词法作用域,函数的作用域在定义时就确定了,所以内部函数可以记住并访问所在的词法作用域(外部函数)

2. 闭包的优缺点(应用场景)

1. 优点 或 应用场景

闭包的应用,大多数是在需要维护内部变量的场景下(从外部读取函数内部的变量)。

  • 模拟私有属性,让函数的私有变量不受外部的干扰。(name getName())
  • 当希望一个变量长期保持在内存中时。
  • 函数的柯里化
  • 节流防抖

2. 缺点

function foo() {
  var a = 2;
  function bar() {
    console.log(a);
  }
  return bar;
}
var x = foo();
  • 容易导致内存泄漏:闭包会导致全局作用域中始终存在着一个变量x引用着内部函数barbar又用到了foo的变量a,所以垃圾回收机制不能把函数销毁,只能让这些数据占用着内存。

解决方法:dom 的事件处理函数定义在外部,解除闭包。或者删除对 dom 元素的引用。

  • 常驻内存,增加内存使用量

五、垃圾回收

垃圾回收(GC)就是 释放那些程序不用的内存。两种常见的垃圾回收策略是:标记清除法 和 引用计数法。

1. 标记清除法 - 是否可达

大多数浏览器的 JS引擎 都是基于标记清除法优化,分为 标记清除 两个阶段。

  • 标记:垃圾回收器会定期根对象(全局对象) 开始遍历,将 能从根对象访问到的对象 标记为 可到达对象

  • 清除:清理没被标记的对象,回收它们所占用的内存空间。然后将原来的标记都清除,等待下一轮垃圾回收

优点:实现简单,打标记可以用 1 位二进制位就能解决。

缺点:垃圾清除后,剩余的对象内存位置是不变的,因此空闲内存空间是不连续的,可能出现大量内存碎片

2. 引用计数法 - 是否有其他变量引用它

这是早期的垃圾回收算法,现在几乎不用了。

它的策略是记录每个变量被引用的次数。如果没有引用指向该对象,引用数为0,那么对象将被回收。

  • 当声明了一个变量并将一个引用类型赋值给该变量,那么这个值的引用次数为 1。
  • 如果同一个值又被赋值给另一个变量,值的引用次数加 1。
  • 如果该变量的值被其他值覆盖了,值的引用次数减 1。
  • 当这个值的引用次数等于 0 的时候,就回收空间。

缺点:无法处理循环引用。

3. Chrome V8 引擎的垃圾回收算法

对于栈内存,执行上下文栈切换后就被回收,比较简单。

分代回收策略:V8 将 「堆内存」 分为新生代和老生代两个区域,新生代存放存活时间较短的对象(经过一次垃圾回收就被回收掉),老生代存放存活时间较长的对象(经过一次垃圾回收还存在)。

  • 新生代内存回收机制:容量小,64位系统仅有32M。新生代内存分为 From(正在使用的内存)To(闲置的内存)两部分(各占一半),当From快填满时会垃圾回收扫描 From,根据标记清除法将非存活对象回收,将存活对象复制到 To 中,清空From然后调换 From/To,等待下一次回收。
  • 老生代内存回收机制:
    • 晋升:如果新生代的变量经过垃圾回收依然存在,那么会晋升到老生代内存中。
    • 标记清除:同上。
    • 整理内存碎片:清除结束后,把存活的对象全部挪到内存的一端。

六、内存泄漏

内存泄漏:不再用到的内存,没有及时释放。可能会导致应用程序卡顿或崩溃。

1. 引起内存泄漏的原因

(1) 意外的全局变量

  • 变量未声明直接使用,会创建一个全局变量,在页面关闭之前都不会被释放。
function fn() { a = 1; }
  • 使用 this 创建的变量
function fn() { this.a = 1; }

(2)闭包引起的内存泄漏

(3)DOM的事件绑定没有及时清理 。移除 DOM 元素前没有注销掉其绑定的事件方法,也会造成内存泄漏。

2. 内存泄漏的解决方案

(1)使用严格模式 use strict;,避免意外的全局变量。

(2)对 DOM 元素,在销毁阶段记得解绑相关事件。

(3)避免过度使用闭包。

参考文章

说说作用域和闭包