# JavaScript执行机制详解
# 1. 作用域
# 1.1 什么是作用域
作用域是指在程序中定义变量的区域,该位置决定了变量的生命周期。通俗地理解,作用域就是变量与函数的可访问范围,即作用域控制着变量和函数的可见性和生命周期。
作用域是JavaScript中一个重要的概念,它决定了变量和函数的可访问范围。主要特点包括:
- 变量隔离:作用域最大的用处就是隔离变量,不同作用域下同名变量不会有冲突
- 生命周期控制:作用域决定了变量的生命周期,影响内存管理
- 访问权限控制:控制变量和函数的可见性
# 1.2 JavaScript作用域类型
JavaScript中有以下几种作用域类型:
- 全局作用域:代码中最外层的范围,在整个程序中都可以访问
- 函数作用域:在函数内部定义的变量,只能在该函数内部访问
- 块级作用域:ES6引入,使用
let和const在代码块({})内定义的变量,只能在该代码块内访问
在ES6之前,JavaScript只有全局作用域和函数作用域。ES6的let和const关键字为我们提供了块级作用域的支持。
# 1.3 全局作用域
全局作用域是代码中最外层的范围,在整个程序中都可以访问。以下几种情况会创建全局变量:
- 在最外层函数外部定义的变量
- 在最外层函数内部定义但未使用
var、let或const声明的变量 - 所有
window对象的属性
// 全局变量
var globalVar = 'I am global';
function example() {
// 未声明直接赋值,也成为了全局变量
implicitlyGlobal = 'I am implicitly global';
console.log(globalVar); // 可以访问全局变量
}
example();
console.log(implicitlyGlobal); // 可以访问
// window对象的属性也是全局可访问的
console.log(window.location); // 可以访问
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 1.4 函数作用域
函数作用域是指在函数内部定义的变量,只能在该函数内部访问,对外部是不可见的。
注意:在函数内部定义变量时,如果不用var、let或const声明,而是直接赋值,那么会创建全局变量。
function outerFunction() {
var outerVar = 'I am outer';
function innerFunction() {
var innerVar = 'I am inner';
console.log(outerVar); // 可以访问外部函数的变量
console.log(innerVar); // 可以访问自己的变量
}
innerFunction();
console.log(outerVar); // 可以访问自己的变量
// console.log(innerVar); // 错误:无法访问内部函数的变量
}
outerFunction();
// console.log(outerVar); // 错误:无法访问函数内部的变量
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 1.5 块级作用域
ES6引入了块级作用域,使用let和const在代码块({})内定义的变量,只能在该代码块内访问。
块级作用域的特点:
- 作用域范围:在函数内部或代码块(由一对花括号包裹)内部创建
- 暂时性死区:在代码块内,
let/const声明的变量在声明之前是不可访问的 - 不允许重复声明:在同一作用域内,不允许用
let/const重复声明同名变量
function example() {
if (true) {
let blockVar = 'I am block scoped';
const blockConst = 'I am also block scoped';
console.log(blockVar); // 可以访问
console.log(blockConst); // 可以访问
}
// console.log(blockVar); // 错误:无法访问块级作用域的变量
// console.log(blockConst); // 错误:无法访问块级作用域的变量
}
example();
2
3
4
5
6
7
8
9
10
11
12
13
// for循环中的块级作用域
for (let i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i); // 输出 0, 1, 2
}, 100);
}
// 使用var的情况
for (var j = 0; j < 3; j++) {
setTimeout(() => {
console.log(j); // 输出 3, 3, 3
}, 100);
}
2
3
4
5
6
7
8
9
10
11
12
13
// eslint-disable-next-line
var b = 10;
(function b() {
// 内部作用域,会先去查找是有已有变量b的声明,有就直接赋值20,确实有了呀。发现了具名函数 function b(){},拿此b做赋值;
// IIFE的函数无法进行赋值(内部机制,类似const定义的常量),所以无效。
// (这里说的“内部机制”,想搞清楚,需要去查阅一些资料,弄明白IIFE在JS引擎的工作方式,堆栈存储IIFE的方式等)
b = 20;
console.log(b); // [Function b]
console.log(window.b); // 10,不是20
})();
2
3
4
5
6
7
8
9
10
# 1.6 作用域链
作用域是分层的,内层作用域可以访问外层作用域的变量,反之则不行。这种层级关系形成了作用域链。
var globalVar = 'I am global';
function outer() {
var outerVar = 'I am outer';
function inner() {
var innerVar = 'I am inner';
// 可以访问所有层级的变量
console.log(innerVar); // 访问自己的变量
console.log(outerVar); // 访问外层函数的变量
console.log(globalVar); // 访问全局变量
}
inner();
// console.log(innerVar); // 错误:无法访问内层函数的变量
}
outer();
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 2. 变量提升
# 2.1 什么是变量提升
变量提升(Hoisting)是JavaScript中一个重要的概念,它指的是JavaScript引擎在代码执行前会先进行一次扫描,将所有变量声明(var)和函数声明提升到其作用域的顶部。这意味着你可以在变量声明之前访问它,而不会报错。
注意:只有声明会被提升,赋值不会被提升。
# 2.2 变量声明与赋值
var a = 2; 这个语句其实包含两个过程:
var a;// 变量声明a = 2;// 变量赋值
变量提升只提升声明部分,不提升赋值部分:
// eslint-disable-next-line no-var
var name = 'liam';
// 等同于
// var name; // 声明部分
// name = 'liam'; // 赋值部分
2
3
4
5
# 2.3 函数提升
函数声明会被提升,但函数表达式不会被提升:
// 函数声明会被提升
console.log(fn); // [Function: fn]
function fn() {}
// 函数表达式不会被提升
console.log(fn2); // undefined
var fn2 = function () {};
2
3
4
5
6
7
函数声明的提升优先级高于变量声明:
showName()
console.log(name)
var name = 'liam'
function showName () {
console.log('函数showName被执行')
}
// 等同于
/*
* 变量提升部分
*/
// 把变量 name 提升到开头,
// 同时给 name 赋值为undefined
var name = undefined
// 把函数showName提升到开头
function showName () {
console.log('函数showName被执行')
}
/*
* 可执行代码部分
*/
showName()
console.log(name)
// 去掉var声明部分,保留赋值语句
name = 'liam'
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
当有多个同名变量声明的时候,函数声明会覆盖其他的声明。如果有多个函数声明,则是由最后的一个函数声明覆盖之前所有的声明
console.log(a) // 函数
var a = 1;
console.log(a) // 变量 1
function a() {
}
console.log(a) // 变量 1
2
3
4
5
6
# 2.4 let/const 与变量提升
let/const 声明的变量也会被提升,但它们不会被初始化,因此在声明之前访问它们会导致ReferenceError。这种现象被称为暂时性死区(Temporal Dead Zone)。
console.log(a); // ReferenceError: Cannot access 'a' before initialization
let a = 2;
2
var a = 1;
function fn() {
console.log(a); // undefined (var声明被提升但未赋值)
var a = 2;
console.log(a); // 2
}
fn();
2
3
4
5
6
7
var a = 1;
function fn() {
console.log(a); // ReferenceError: Cannot access 'a' before initialization
let a = 2;
console.log(a); // 2
}
fn();
2
3
4
5
6
7
# 2.5 变量提升的最佳实践
为了避免变量提升带来的问题,建议:
- 使用
let和const替代var - 在使用变量之前先声明变量
- 将变量声明放在函数或作用域的顶部
- 避免在声明之前使用变量
// 推荐的写法
function example() {
let a = 1; // 在使用前声明
const b = 2;
console.log(a); // 1
console.log(b); // 2
// 函数声明也放在顶部
function helper() {
return a + b;
}
return helper();
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 3. 执行上下文
# 3.1 什么是执行上下文
执行上下文(Execution Context)是JavaScript引擎在执行代码时创建的一个环境,它定义了变量或函数有权访问的其他数据,决定了它们各自的行为。
每当JavaScript代码执行时,都会创建一个执行上下文。执行上下文主要分为三种类型:
- 全局执行上下文:这是默认的执行上下文,在代码开始执行时创建
- 函数执行上下文:每当一个函数被调用时,都会为该函数创建一个新的执行上下文
- Eval执行上下文:在eval函数内部运行的代码会产生Eval执行上下文(不推荐使用eval)
# 3.2 执行上下文的组成部分
执行上下文主要由以下三个部分组成:
- 变量环境(Variable Environment):存储通过var声明的变量和函数声明
- 词法环境(Lexical Environment):存储通过let和const声明的变量
- this绑定:确定this关键字的值
# 3.3 执行上下文的创建过程
执行上下文的创建可以分为两个阶段:
# 3.3.1 创建阶段
在创建阶段,JavaScript引擎会执行以下操作:
- 确定this的值
- 创建词法环境
- 创建变量环境
- 进行变量提升
# 3.3.2 执行阶段
在执行阶段,JavaScript引擎会逐行执行代码,并完成变量赋值等操作。
# 3.4 执行上下文示例
var x = 10;
function outer() {
var y = 20;
function inner() {
var z = 30;
console.log(x, y, z); // 10 20 30
}
inner();
}
outer();
2
3
4
5
6
7
8
9
10
11
12
13
14
在这个例子中,会创建以下执行上下文:
- 全局执行上下文:包含变量x
- outer函数执行上下文:包含变量y
- inner函数执行上下文:包含变量z
当代码执行时,JavaScript引擎会维护一个执行上下文栈(Execution Context Stack),用于管理所有执行上下文。
# 3.5 执行上下文栈
执行上下文栈(Execution Context Stack),也称为调用栈(Call Stack),是一种后进先出(LIFO)的数据结构,用于存储代码执行期间创建的所有执行上下文。
执行上下文栈的工作过程如下:
- JavaScript引擎首先创建全局执行上下文并将其压入栈底
- 当函数被调用时,会为该函数创建一个新的执行上下文并压入栈顶
- 函数执行完毕后,其对应的执行上下文会从栈中弹出
- 当栈中所有的执行上下文都被弹出后,程序执行结束
function first() {
console.log('first');
second();
}
function second() {
console.log('second');
third();
}
function third() {
console.log('third');
}
first();
2
3
4
5
6
7
8
9
10
11
12
13
14
15
执行上下文栈的变化过程:
- 创建全局执行上下文并压入栈
- 调用first(),创建first执行上下文并压入栈
- first()调用second(),创建second执行上下文并压入栈
- second()调用third(),创建third执行上下文并压入栈
- third()执行完毕,third执行上下文出栈
- second()执行完毕,second执行上下文出栈
- first()执行完毕,first执行上下文出栈
- 全局执行上下文出栈,程序结束
输出结果:
first
second
third
2
3
# 4. 执行流程
# 4.1 变量和函数声明
实际上变量和函数声明在代码里的位置是不会改变的,而且是在编译阶段被 JavaScript 引擎放入内存中
一段 JavaScript 代码在执行之前需要被 JavaScript 引擎编译,编译完成之后,才会进入执行阶段。
# 4.2 编译阶段

经过编译后,会生成两部分内容:执行上下文(Execution context)和可执行代码。
执行上下文是 JavaScript 执行一段代码时的运行环境,比如调用一个函数,就会进入这个函数的执行上下文,确定该函数在执行期间用到的诸如 this、变量、对象以及函数等。
在执行上下文中存在一个变量环境的对象(Viriable Environment),该对象中保存了变量提升的内容,比如上面代码中的变量 myame 和函数 showName
结合下面这段代码来分析下是如何生成变量环境对象的:
showName();
console.log(name);
// eslint-disable-next-line
var name = 'liam';
function showName () {
console.log('函数showName被执行');
}
2
3
4
5
6
7
- 第 1 行和第 2 行,由于这两行代码不是声明操作,所以 JavaScript 引擎不会做任何处理;
- 第 3 行,由于这行是经过 var 声明的,因此 JavaScript 引擎将在环境对象中创建一个名为 name 的属性,并使用 undefined 对其初始化;
- 第 4 行,JavaScript 引擎发现了一个通过 function 定义的函数,所以它将函数定义存储到堆 (HEAP)中,并在环境对象中创建一个 showName 的属性,然后将该属性值指向堆中函数的位置。
这样就生成了变量环境对象。接下来 JavaScript 引擎会把声明以外的代码编译为字节码
# 4.3 执行阶段
JavaScript 引擎开始执行“可执行代码”,按照顺序一行一行地执行。下面我们就来一行一行分析下这个执行过程:
- 当执行到 showName 函数时,JavaScript 引擎便开始在变量环境对象中查找该函数,由于变量环境对象中存在该函数的引用,所以 JavaScript 引擎便开始执行该函数,并输出“函数 showName 被执行”结果。
- 接下来打印“name”信息,JavaScript 引擎继续在变量环境对象中查找该对象,由于变量环境存在 name 变量,并且其值为 undefined,所以这时候就输出 undefined。
- 接下来执行第 3 行,把“liam”赋给 name 变量,赋值后变量环境中的 name 属性值改变为“liam”
# 4.4 代码中出现相同的变量或者函数
showName()
var showName = function () {
console.log(2)
}
function showName () {
console.log(1)
}
//编译阶段:
var showName
function showName () { console.log(1) }
//执行阶段:
showName() // 输出1
showName = function () { console.log(2) }
// 如果后面再有showName执行的话,就输出2因为这时候函数引用已经变了
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
注意:一段代码如果定义了两个相同名字的函数,那么最终生效的是最后一个函数
# 5. 作用域链
# 5.1 什么是作用域链
作用域链是由当前环境与上层环境的一系列变量对象组成的,它保证了当前执行环境对符合访问权限的变量和函数的有序访问。
简单来说,作用域链就是查找变量和函数时的一条路径,这条路径从当前作用域开始,逐级向上查找,直到找到目标变量或函数,或者到达全局作用域。
# 5.2 作用域链的查找机制
当代码需要访问一个变量时,JavaScript引擎会按照以下顺序查找:
- 在当前作用域中查找
- 如果没找到,到上层作用域中查找
- 重复步骤2,直到找到变量或到达全局作用域
- 如果在全局作用域也没找到,则抛出ReferenceError
// eslint-disable-next-line
var x = 10;
function fn () {
console.log(x);
}
function show (f) {
// eslint-disable-next-line
var x = 20
(() => {
f(); // 10,而不是20
})();
}
show(fn);
2
3
4
5
6
7
8
9
10
11
12
13
在上面的例子中,fn函数中的变量x是在创建fn函数的作用域(全局作用域)中查找的,而不是在调用fn函数的作用域中查找。这体现了作用域链的一个重要特性:变量的查找是在函数定义时确定的,而不是在函数调用时确定的。
# 5.3 作用域链的构建过程
作用域链的构建遵循以下规则:
- 函数内部可以访问函数外部的变量
- 内层函数可以访问外层函数的变量
- 外层函数无法访问内层函数的变量
// eslint-disable-next-line
var a = 10
// eslint-disable-next-line
var b = 200
function fn () {
// eslint-disable-next-line
var b = 20
function bar () {
console.log(a + b); // 30
}
return bar;
}
// eslint-disable-next-line
var x = fn()
x(); // bar()
2
3
4
5
6
7
8
9
10
11
12
13
14
15
在这个例子中,作用域链的构建过程如下:
bar函数可以访问自己的作用域bar函数可以访问外层fn函数的作用域fn函数可以访问全局作用域
因此,bar函数可以访问到全局变量a和fn函数中的变量b。
# 5.4 延长作用域链
某些语句可以在作用域链的前端临时增加一个变量对象,该变量对象会在代码执行后被移除。有两种情况下会发生这种现象:
- try-catch语句中的catch块:会创建一个新的变量对象,其中包含被抛出的错误对象的声明
- with语句:会将指定对象添加到作用域链中
// try-catch示例
var errorMessage = 'Global error';
try {
throw new Error('Local error');
} catch (errorMessage) {
console.log(errorMessage); // 输出: Error: Local error
// 在catch块中,errorMessage指向错误对象,而不是全局变量
}
console.log(errorMessage); // 输出: Global error
2
3
4
5
6
7
8
9
10
11
// with语句示例
var obj = {
name: 'JavaScript',
version: 'ES6'
};
with (obj) {
console.log(name); // 输出: JavaScript
console.log(version); // 输出: ES6
// 在with块中,可以直接访问obj的属性
}
2
3
4
5
6
7
8
9
10
11
注意:由于with语句会影响性能并使代码难以优化,现代JavaScript开发中不推荐使用。
# 6. 变量环境对象
# 6.1 什么是变量环境对象
变量环境对象(Variable Environment Object)是执行上下文的一个组成部分,用于存储通过var声明的变量和函数声明。它是词法环境(Lexical Environment)的一个组成部分,在ES5及之前的版本中,它是存储变量的主要场所。
在全局执行上下文中,变量环境对象就是全局对象(在浏览器中是window对象)。在函数执行上下文中,变量环境对象是活动对象(Activation Object)的一部分。
需要注意的是,从ES6开始,JavaScript引入了词法环境的概念,let和const声明的变量存储在词法环境的词法绑定(Lexical Bindings)中,而不是变量环境对象中。
# 6.2 变量环境对象的创建过程
变量环境对象的创建遵循以下步骤:
- 创建一个空的对象
- 将函数声明添加到对象中(函数提升)
- 将通过
var声明的变量添加到对象中,初始值为undefined
console.log(x); // undefined (变量提升)
console.log(fn); // [Function: fn] (函数提升)
var x = 10;
function fn() {
console.log('Hello');
}
console.log(x); // 10
2
3
4
5
6
7
8
9
10
# 6.3 变量环境对象与词法环境的区别
在ES6及之后的版本中,JavaScript引入了词法环境来存储let和const声明的变量,与变量环境对象形成对比:
| 特性 | 变量环境对象 (Variable Environment) | 词法环境 (Lexical Environment) |
|---|---|---|
| 存储变量类型 | var声明的变量和函数声明 | let和const声明的变量 |
| 变量提升 | 会提升,初始值为undefined | 会提升,但存在暂时性死区 |
| 重复声明 | 允许重复声明 | 不允许重复声明 |
| 作用域 | 函数作用域 | 块级作用域 |
function example() {
// 变量环境对象存储var声明的变量
console.log(varVariable); // undefined
var varVariable = 'var';
// 词法环境存储let声明的变量
// console.log(letVariable); // ReferenceError: Cannot access 'letVariable' before initialization
let letVariable = 'let';
if (true) {
// 块级作用域中的let变量
let blockVariable = 'block let';
console.log(blockVariable); // 'block let'
}
// console.log(blockVariable); // ReferenceError: blockVariable is not defined
}
example();
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 6.4 变量环境对象的实际应用
理解变量环境对象有助于理解变量提升和作用域的工作原理:
function hoistingExample() {
console.log(a); // undefined
console.log(b); // [Function: b]
console.log(c); // ReferenceError: Cannot access 'c' before initialization
var a = 'var variable';
function b() {
return 'function';
}
let c = 'let variable';
console.log(a); // 'var variable'
console.log(b()); // 'function'
console.log(c); // 'let variable'
}
// hoistingExample(); // 取消注释以运行
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
通过这个例子可以看出:
var声明的变量a被提升到函数顶部,初始值为undefined- 函数声明
b被完整提升到函数顶部 let声明的变量c虽然也被提升,但由于暂时性死区的存在,在声明之前访问会报错
# 7. 闭包
# 7.1 什么是闭包
闭包是指有权访问另一个函数作用域中变量的函数,即使在外部函数返回后,这些变量仍然可以被访问和操作。
更准确地说,闭包是函数和声明该函数的词法环境的组合。这个环境包含了函数创建时所能访问的所有局部变量、参数和内部函数。
# 7.2 闭包的形成条件
闭包的形成需要满足以下条件:
- 函数嵌套
- 内部函数引用了外部函数的变量
- 外部函数返回了内部函数
function outerFunction(x) {
// 外部函数的局部变量
// eslint-disable-next-line
var outerVariable = x;
// 内部函数
function innerFunction(y) {
// 内部函数访问外部函数的变量
console.log(outerVariable + y);
}
// 返回内部函数,形成闭包
return innerFunction;
}
var closure = outerFunction(10);
closure(5); // 输出: 15
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 7.3 闭包的特点
- 变量持久化:闭包可以使得外部函数的局部变量在函数执行完毕后仍然保存在内存中
- 数据封装:可以创建私有变量,外部无法直接访问
- 模块化:可以创建具有私有状态的模块
// 计数器示例
function createCounter() {
var count = 0;
return {
increment: function() {
count++;
return count;
},
decrement: function() {
count--;
return count;
},
getCount: function() {
return count;
}
};
}
var counter = createCounter();
console.log(counter.increment()); // 1
console.log(counter.increment()); // 2
console.log(counter.getCount()); // 2
console.log(counter.decrement()); // 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 7.4 闭包的应用场景
- 模块模式:创建具有私有变量和方法的对象
- 回调函数:在异步操作中保持对变量的访问
- 函数工厂:创建具有特定行为的函数
- 事件处理:在事件处理器中保持对变量的引用
// 模块模式示例
var myModule = (function() {
var privateVariable = 'Hello';
function privateFunction() {
console.log(privateVariable);
}
return {
publicMethod: function() {
privateFunction();
},
setPrivateVariable: function(value) {
privateVariable = value;
}
};
})();
myModule.publicMethod(); // 输出: Hello
myModule.setPrivateVariable('World');
myModule.publicMethod(); // 输出: World
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 7.5 闭包的注意事项
- 内存消耗:由于闭包会使得函数中的变量都被保存在内存中,内存消耗较大,所以不能滥用闭包
- 性能影响:过多的闭包可能会影响性能
- 调试困难:闭包可能会使调试变得复杂
- 变量共享:多个闭包可能共享同一个变量
# 7.6 闭包常见问题及解决方案
# 循环中的闭包问题
// 问题代码
var data = [];
for (var i = 0; i < 3; i++) {
data[i] = function() {
console.log(i);
};
}
data[0](); // 3
data[1](); // 3
data[2](); // 3
2
3
4
5
6
7
8
9
10
11
解决方案1:使用立即执行函数表达式(IIFE)
var data = [];
for (var i = 0; i < 3; i++) {
data[i] = (function(num) {
return function() {
console.log(num);
};
})(i);
}
data[0](); // 0
data[1](); // 1
data[2](); // 2
2
3
4
5
6
7
8
9
10
11
12
解决方案2:使用let声明变量(ES6)
var data = [];
for (let i = 0; i < 3; i++) {
data[i] = function() {
console.log(i);
};
}
data[0](); // 0
data[1](); // 1
data[2](); // 2
2
3
4
5
6
7
8
9
10
# 7.7 闭包题目
const bar = {
myName: 'www.blog.huoyuhao.net',
printName () {
console.log(myName);
},
};
function foo () {
const myName = 'huoyuhao';
return bar.printName;
}
const myName = 'liam';
const _ = foo();
_();
bar.printName();
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 参考资料
极客时间课程 -- 浏览器工作原理与实践 (opens new window)