javaScript/js知识梳理-js执行顺序/词法分析/预编译(执行上下文)/闭包/js栈/事件循环

js执行

我们可以把代码从“字符串”到“运行”的过程分为三个大的阶段:

阶段一:词法分析 & 语法分析(分词/解析)

这是引擎的 Parser(解析器) 在干活:

  • 分词(Tokenizing/Lexing):把 var a = 1; 拆成 var, a, =, 1
  • 生成 AST(抽象语法树):把这些词按照语法规则排成一棵树。
  • 重点:这一步主要检查语法错误(比如少了个括号),它还没开始分配变量内存

词法分析生成 AST 时作用域已经定下了雏形,引擎就已经通过代码的缩进和位置确定了:

  • 函数 A 包着 函数 B。
  • 函数 B 包着 变量 C。

阶段二:预编译(创建执行上下文)

一. 执行上下文

当 JS 引擎执行代码前,它会扫描所有的代码(并不是执行代码),统称为“编译”

(1):环境记录

  第一次扫描:创建快照(预解析)

  1. 建立环境记录(类似分类):
  • 扫描所有 function 关键字,把整个函数体存进去(函数提升)。
  • 扫描所有 var 声明,给它们分配内存并初始化为 undefined变量提升)。
  • 识别 letconst,虽然也记录了它们,但标记为“不可访问”(暂时性死区)。

面试总结:所谓的暂时性死区 (TDZ) 和变量提升,其实在js引擎第一次扫描代码的时候就已经决定了。

变量类型分类时的动作 (Creation Phase)内存状态访问结果
var创建并初始化已经分配了内存,并填入了 undefined 作为默认值。可以访问(返回 undefined)。
let / const仅创建(注册)内存地址已被预留,但引擎严禁对其进行任何形式的访问(甚至不给 undefined)。报错(ReferenceError)。
(2):词法分析

拍下照片时,引擎会记下这个函数是在哪里定义的,确定它的“父级”是谁。这个过程最重要的就是理解什么是域(词法作用域,块作用域,函数作用域)

词法作用域:简单理解是单词化,对所有的字符进行检查。就是你所写的代码,写在哪,定在哪,这个单词(变量/函数)到底写在谁的地盘里?

1:包含着整个全局作用域,其中只有一个标识符:foo
2:包含着 foo 所创建的作用域,其中有三个标识符:abar b
3: 包含着 bar 所创建的作用域,其中只有一个标识符:c

块级作用域 (Block Scope) —— ES6 核心

  • 地盘范围:由一对大括号 { ... } 包裹的区域(如 if, for, while 或纯 {})。
  • 快照特征:只对 letconst 生效。
  • 引擎动作:第一次扫描时,如果在大括号内看到 let/const,引擎会为这个 {} 专门创建一个临时的环境记录。
var a = 1;
function outer() {
  var b = 2;
  if (true) {
    var c = 3;
    let d = 4;
  }
  console.log(c); // 输出什么?
  console.log(d); // 输出什么?
}
outer();

你的“高级”分析步骤:

一:全局第一次扫描:创建全局环境记录,记录 a = undefined,记录 outer 函数体。

二:执行 outer(),触发局部第一次扫描:

      引擎操作:

  1. 建立 outer 的环境记录。
  2. 扫描 var b:存入 outer 记录,设为 undefined
  3. 扫描 var c:虽然在 if 里,但 var 穿透块级,存入 outer 记录,设为 undefined
  4. 扫描 let d:发现它在 {} 块里,引擎为这个 if 块单独创建了一个块级环境记录,记录 d 为“不可访问”。

函数作用域 (Function Scope)

  • 地盘范围:由 function 关键字包裹的代码块。

  • 快照特征

    • 只要看到 function,引擎就会在“快照阶段”为其预留一个独立的“环境记录”。

    • 函数内部定义的变量,外部绝对无法访问。

三:开始执行 outer 内部代码:

  1. 执行到 if 块内,给 c 赋值为 3,给块级记录里的 d 赋值为 4。
  2. 出了 if 块:块级环境记录被销毁,d 彻底消失。
  3. 结果:console.log(c):在 outer 记录里找到了 c,打印 console.log(d):在 outer 记录里找不到,向上级(全局)也找不到,报错 ReferenceError。
(3):变量提升

当同名的 varfunction 同时存在时,函数声明的优先级更高

  • 引擎动作:如果环境记录中已存在同名变量,函数声明会直接覆盖该变量的记录。

  • 代码表现

    console.log(a); // 输出 [Function: a]
    var a = 10;
    function a() {}
(4):作用域链 (Scope Chain)

1. 静态属性:[[Environment]]

作用域链的本质不是在执行时动态生成的,而是在函数创建时就“刻在基因里”的。

  • 当引擎创建一个函数对象时,会给它一个内部属性 [[Environment]]

  • 这个属性指向该函数定义时所在的执行上下文的环境记录。

  • 这就是为什么 JS 是词法作用域(静态作用域)的原因:无论函数在哪里被调用,它能访问哪些变量在它写下的那一刻就定死了。

2. 链式查找过程

当你在函数内访问一个变量时,引擎的动作如下:

  1. 当前环境记录中查找。

  2. 若找不到,通过 [[Environment]] 引用进入父级环境记录。

  3. 重复此过程,直到找到变量或到达 全局环境记录(Global Environment)

  4. 若全局也找不到,非严格模式下创建全局变量或报错。

深度扩展:this 指向

很多开发者容易把 this 和作用域混为一谈,但它们是两个完全不同的机制:

  • 作用域:是静态的,基于代码位置。

  • this:是动态的,基于调用方式

(5):调用栈 / 执行栈 (Call Stack)
  • 数据结构:栈(Stack)—— 先进后出 (LIFO, Last In First Out)

  • 规则

    • 代码执行最开始,先要把全局执行上下文 (Global Execution Context) 压入栈底。

    • 每当遇到一个函数调用 func(),引擎就会为该函数创建一个新的函数执行上下文,并将其压入 (Push) 栈顶。

    • 当函数执行完毕(遇到 return 或代码结束),该函数的上下文就会被弹出 (Pop) 销毁,控制权交还给下一层的上下文。

console.log('1. Global start');

function first() {
    console.log('2. Inside first');
    second(); // 调用 second
    console.log('4. Back to first');
}

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

first(); // 调用 first

console.log('5. Global end');

步骤 1:启动程序

|                   |
| [ Global Context ]|  <-- 栈底常驻
+-------------------+

步骤 2:执行 first() 引擎创建 first 的上下文并压入栈。

|                   |
| [ first Context  ]|  <-- 当前正在执行这里
| [ Global Context ]|
+-------------------+

步骤 3:在 first 中调用 second() 引擎暂停 first,创建 second 的上下文并压入栈顶

| [ second Context ]|  <-- 控制权在这里
| [ first Context  ]|  <-- 暂停等待
| [ Global Context ]|
+-------------------+

步骤 4:second() 执行完毕 second 出栈(内存销毁),控制权回到 first

|                   |  <-- second 被销毁
| [ first Context  ]|  <-- 恢复执行
| [ Global Context ]|
+-------------------+

步骤 5:first() 执行完毕 first 出栈。

|                   |
|                   |
| [ Global Context ]|  <-- 回到全局,直到程序关闭
+-------------------+

执行栈总结:

  1. 单线程:JS 只有一个主线程,同一时间栈顶只有一个上下文在运行。
  2. 爆栈 (Stack Overflow):如果递归调用没有终止条件(function a() { a(); }),栈会不断增加直到超出浏览器限制,抛出错误。
  3. 同步机制:必须等栈顶的函数执行完,下面的代码才能继续。
(6) 闭包 (Closure) —— 栈与堆的“博弈”

引出问题: 根据上面的栈机制,函数执行完,上下文弹出,内存被销毁(垃圾回收 GC)。 那么问题来了:如果不希望变量被销毁怎么办?

代码是如何在“栈”和“堆”之间配合的

var a = 1;               // 基础类型
var b = { name: "Hi" };  // 引用类型(对象)
var c = b;               // 赋值

内存图示 :

栈内存 (Stack)                       堆内存 (Heap)
(有序,存放基础值和地址)                 (无序,存放大数据)

+--------------------------+           +---------------------+
| 变量名 |    值 (Value)    |           |                     |
+--------------------------+           |                     |
|   a    |       1          |           |  地址: 0x001        |
|--------|------------------|           |  {                  |
|   b    |  地址(0x001)  ------指向----->    name: "Hi"       |
|--------|------------------|           |  }                  |
|   c    |  地址(0x001)  ------指向----->                     |
+--------------------------+           +---------------------+
  1. 执行顺序:代码依然在栈里一行行执行。
  2. 变量 a:直接在栈里存了 1
  3. 变量 b
  • JS 引擎在里开辟一块空间,存入 { name: "Hi" },生成地址 0x001
  • 然后把 0x001 这个地址写在b 的旁边。

   4.变量 c

  • c = b 并不是把大对象复制了一份。
  • 而是把 b 手里的地址 0x001 抄了一份给 c
  • 结果bc 指向堆里的同一个东西。

 回到刚才的“闭包”问题

你刚才困惑的是:为什么栈弹出了,堆里的东西还在?

function outer() {
    var name = "Stack Overflow";
    
    function inner() {
        // inner 引用了 outer 的变量 name
        console.log(name); 
    }
    
    return inner; // 把 inner 函数本身返出去了!
}

var myFunc = outer(); // outer 执行完毕,理应出栈销毁
myFunc(); // 这里在全局调用 inner,竟然还能打印出 "Stack Overflow"?

深度解析: 按照“栈”的逻辑,outer 执行完,name 变量应该随着 outer 上下文的销毁而消失。但是 myFunc() 依然访问到了它。 这就是闭包。

闭包的本质

  1. 词法作用域的规则:内部函数总是可以访问外部函数的变量。
  2. 生命周期的延长:当内部函数被返回并在外部被持有时,即便外部函数执行完毕(从栈中弹出),JS 引擎发现内部函数引用了外部的变量,它就不会回收这部分内存

图例解释 (闭包的内存模型):outer 出栈后,内存里发生了什么?

正常情况 (无闭包)

  • Stack: outer 弹出。
  • Heap (堆内存): outer 的环境记录(Environment Record)被 GC 回收。

闭包情况

Stack (执行栈)                   Heap (堆内存)
+----------------+              +---------------------------+
|                |              | Closure (outer) Scope     |
| [ Global ctx ] | ------------>| { name: "Stack Overflow" }| <---+
| (myFunc 正在运行)|              +---------------------------+     |
+----------------+                                                |
                                                                  |
                                +---------------------------+     |
                                | Function Object: inner    |     |
                                | [[Environment]] pointer --|-----+
                                +---------------------------+

  • outer 虽然从栈中弹出了。
  • 但是 inner 函数对象(现在赋给了 myFunc)依然存在。
  • inner 的身上有一个秘密属性 [[Environment]],它像一根脐带,紧紧抓住了 outer 创建时的作用域对象(Closure Scope)。
  • 因为这根“脐带”的存在,垃圾回收器(GC)不敢回收 outer 的变量对象。

闭包总结

闭包 = 函数 + 该函数对周围状态(词法环境)的引用。 简单说:闭包就是函数带着它出生时的环境(背包)一起流浪。

闭包的优缺点

  • 优点
  1. 数据私有化:外部无法直接修改 name,只能通过 inner 修改(模块化模式的基础)。
  2. 状态保持:柯里化(Currying)、防抖节流等高阶函数的基础。
  • 缺点

  1. 内存泄漏风险:因为变量一直不被回收,滥用闭包会导致内存占用过高。
// ==========================================
// Part : 闭包 (Closure) 原理演示
// ==========================================
console.log('--- Part 2: Closure Demo ---');

function createCounter() {
    // 这个 count 变量就是“被捕获”的变量,存在于堆内存的 Closure Scope 中
    let count = 0;

    return {
        increment: function() {
            count++;
            console.log(`Counter value: ${count}`);
        },
        decrement: function() {
            count--;
            console.log(`Counter value: ${count}`);
        },
        // 这一步证明了数据的私有性
        checkScope: function() {
            console.log('Can I access count directly from outside? No.');
        }
    };
}

const counterA = createCounter(); // 创建第一个闭包实例
const counterB = createCounter(); // 创建第二个闭包实例(互不干扰)

console.log('Operating Counter A:');
counterA.increment(); // 1
counterA.increment(); // 2

console.log('Operating Counter B:');
counterB.increment(); // 1 (证明闭包之间环境是独立的)

console.log('Operating Counter A again:');
counterA.decrement(); // 1 (Counter A 依然记得它之前的状态)

// 尝试销毁闭包释放内存
// counterA = null; // 切断引用,GC 此时才会回收对应的 Scope

阶段三:执行阶段 (Execution Phase)

这是 JS 引擎真正开始“干活”的阶段。在这个阶段,引擎会从上到下逐行读取代码,进行赋值逻辑运算

1. 核心任务:从 undefined 到 “真值”

还记得阶段二吗?

  • var a = 10; 在阶段二只是 var a(内存里是 undefined)。

  • 到了阶段三,引擎读到这一行,才会执行 a = 10赋值操作

对比图表:

代码行阶段二:创建阶段 (Creation Phase)阶段三:执行阶段 (Execution Phase)
var name = "a";内存中注册 name,值为 undefined将 "a" 赋值给 name
function add(a, b){...}整个函数体被存入内存跳过(除非被调用)
console.log(name);忽略执行打印操作,去内存找 name 的值

2. 引擎的内心戏:LHS 与 RHS 查询

这是高级工程师必须掌握的细节。在执行阶段,当引擎看到一个变量时,它的查找方式分为两种(这决定了报错类型):

LHS (Left-Hand Side) —— “容器在哪里?”

  • 场景:变量出现在赋值符号的左边
  • 目的:试图找到变量的容器,以便把值存进去
  • 例子a = 2; (引擎问:去哪找 a 这个位置给我存 2?)
  • 失败后果

  1. 非严格模式:如果在全局也找不到,引擎会在全局帮你自动创建一个(这就导致了隐式全局变量泄露)。
  2. 严格模式 ('use strict'): 报错 ReferenceError

RHS (Right-Hand Side) —— “值是什么?”

  • 场景:变量出现在赋值符号的右边(或者作为参数、或者单独出现)。
  • 目的:试图获取变量的
  • 例子console.log(a); (引擎问:a 的值是什么?拿出来给我。)
  • 失败后果:如果作用域链找遍了都找不到,直接报错 ReferenceError

3. 执行上下文出栈

阶段三的最后一步: 当一个函数内部的所有代码执行完毕(或者遇到 return):

  1. 该函数的执行上下文从执行栈 (Call Stack) 中弹出。

  2. 如果不涉及闭包,该上下文对应的环境记录(内存) 会被垃圾回收机制(GC)标记并回收


异步机制与事件循环 (Event Loop)

之前我们聊的“执行栈”是同步的,也就是“一条道走到黑”。但如果 JS 只有这一条道,当你请求一个接口需要 5 秒钟时,整个网页就会卡死 5 秒,用户什么都点不了。这显然是不可接受的。

为了解决这个问题,JS 引入了异步 (Asynchronous) 机制。

一、 宏观架构:浏览器的“多线程”辅助

首先要纠正一个误区:JS 引擎(如 V8)是单线程的,但是浏览器(宿主环境)是多线程的。你可以把 JS 引擎想象成一个光杆司令(主线程),但他背后有一个强大的秘书团(Web APIs)。

主线程 (JS Engine/Call Stack)

  • 也就是我们之前说的“执行栈”。
  • 特点:只有一个人,一次只能做一件事。所有同步代码必须在这里执行。

Web APIs (秘书团)

  • 浏览器提供的能力,比如 setTimeoutDOM EventsAJAX/Fetch
  • 特点:它们在后台运行,不占用主线程。比如你定了个闹钟,是浏览器在帮你倒计时,不是 JS 引擎自己在数数。

任务队列 (Task Queue)

  • 当秘书团把事情做完了(比如倒计时结束了、数据请求回来了),它们不能直接把回调函数塞回主线程(会打断正在执行的代码)。
  • 它们只能把回调函数扔进一个候车室(队列),排队等待被叫号。

Event Loop (交通指挥官)

  • 它的工作非常单一且枯燥:不断地检查
  • 它盯着两边:执行栈空了吗?队列里有人排队吗?
  • 规则:只有当执行栈彻底空了,它才会从队列里拉一个人出来,推入执行栈去执行。

二、 宏任务与微任务:VIP 通道

“候车室”其实不只一个。为了区分任务的轻重缓急,JS 设计了两条队列:

宏任务队列 (Macro-task Queue)微任务队列 (Micro-task Queue)

1. 宏任务 (Macro-task) —— 普通乘客

宏任务是由宿主环境(浏览器/Node)发起的任务。

常见成员

  • script (整体代码 script 标签)
  • setTimeout / setInterval
  • setImmediate (Node.js)
  • I/O (文件读写)
  • UI Rendering (页面渲染)

2. 微任务 (Micro-task) —— VIP 乘客

微任务是由 JS 引擎自身发起的任务,通常涉及状态改变,需要尽快处理。

常见成员

  • Promise.then / .catch / .finally (注意:new Promise 是同步的)
  • process.nextTick (Node.js,优先级极高)
  • MutationObserver (监听 DOM 变化)

三、 Event Loop 的完整循环流程

这是最关键的逻辑,请务必记在脑子里:

循环步骤:

1.执行栈 (Stack):同步代码执行,直到栈清空。

2.微任务检查 (Check Micro)

  • 执行栈空了,Event Loop 立刻去看微任务队列。
  • 重点:它会把微任务队列里的所有任务一口气全执行完!如果你在微任务里又产生了新的微任务,也会在这一轮里插队执行完。
  • 直到微任务队列清空为止

3.渲染 UI (Render)

  • (浏览器通过这里决定是否需要更新页面显示)。

4.宏任务检查 (Check Macro)

  • 去宏任务队列里取出一个(只取一个!)任务。
  • 推入执行栈执行。

5.回到步骤 1

口诀

同步执行完,全清微任务;渲染更界面,再拿宏任务

四、 代码实战分析 (手把手推演)

console.log('1. Script start'); // 同步

setTimeout(function() {
  console.log('2. setTimeout'); // 宏任务
}, 0);

new Promise(function(resolve) {
  console.log('3. Promise constructor'); // 同步!Promise 构造函数是立即执行的
  resolve();
}).then(function() {
  console.log('4. Promise then'); // 微任务
});

console.log('5. Script end'); // 同步

详细推演过程:

第一轮:同步代码 (Script 宏任务)

  • 遇到 console.log('1...') -> 输出 1
  • 遇到 setTimeout -> 这是一个宏任务。Web API 把它拿走,0ms 后将回调放入宏任务队列
  • 遇到 new Promise -> 构造函数内的代码是同步的!-> 输出 3
  • 遇到 resolve() -> 触发 .then。这是一个微任务。放入微任务队列
  • 遇到 console.log('5...') -> 输出 5
  • 当前状态
  1. 栈:空。

  2. 微队列:[then回调]

  3. 宏队列:[setTimeout回调]

第二轮:清空微任务

  • Event Loop 发现栈空了,优先看微队列。
  • 执行 then 回调 -> 输出 4
  • 微队列清空。

第三轮:执行宏任务

  • Event Loop 去宏队列拿一个任务。
  • 执行 setTimeout 回调 -> 输出 2

最终结果1 -> 3 -> 5 -> 4 -> 2

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

贾宝玉单臂擒方腊

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值