JavaScript执行机制与闭包:一场代码世界的奇幻冒险

99 阅读10分钟

开场白:程序员的魔法世界

你有没有想过,写代码其实就像在玩一个超级复杂的 RPG 游戏?你不是在打怪升级,而是在和“作用域”、“调用栈”、“闭包”这些神秘生物打交道。今天,我们就来揭开 JavaScript 执行机制背后的神秘面纱,看看这段代码到底是怎么一步步被执行的,顺便聊聊那个传说中令人又爱又恨的——闭包。

第一章:调用栈 —— 程序员的探险地图

想象一下,你是一个勇敢的冒险者(也就是我们写的函数),进入了一个巨大的迷宫(程序)。每当你进入一个新的房间(调用一个函数),系统都会给你一张地图,记录你当前的位置、你带了什么物品(变量)、以及你是从哪个门进来的(调用路径)。

这张地图,就是所谓的执行上下文(Execution Context) ,调用函数就会生成一个执行上下文的对象,而所有这些地图堆叠起来的地方,就叫做调用栈(Call Stack) 。它被用来记录函数的执行顺序,管理执行上下文和变量环境

比如:

function a() {
    b();
}
function b() {
    c();
}
function c() {
    console.log("我在最底层!");
}

a();

调用栈的变化是这样的:

  1. 全局执行上下文被压入栈底。
  2. a() 被调用,它的上下文压入栈
  3. b() 被调用,它的上下文压入栈
  4. c() 被调用,它的上下文压入栈
  5. c() 执行完毕,弹出栈
  6. b() 执行完毕,弹出栈
  7. a() 执行完毕,弹出栈

整个过程就像是你在一层层往下探索迷宫,完成任务后又原路返回。是不是很像《盗梦空间》里的梦境嵌套?

这个其实就是我们熟知的先入后出的概念

第二章:作用域链 —— 寻宝的路线图

现在你已经知道,每次调用一个函数,JavaScript 都会为你创建一个专属的小天地(执行上下文)。但这个小天地并不是孤立的,它可以通过一条神奇的链条,访问到外面的世界。

这条链条,就是作用域链(Scope Chain)

举个简单的例子:

var treasure = "黄金圣斗士的披风";
let person='勇士'

function findTreasure()执行上下文,一个是 {
    var clue = "向东走三步";
    let dummy='恶龙'
    function openChest(),需要clue和treasure两个变量, {
        console.log(`${clue},然后发现了${treasure}`);
    }
    openChest()执行上下文;
}

findTreasure();

在这个例子里:

openChest 函数可以访问外部函数 findTreasure 中的变量cule,甚至还能访问全局的 treasure。 它是通过作用域链一层层往上找的。

上述代码共有三个执行上下文,一个是全局执行上下文,一个是 findTreasure()执行上下文,一个是openChest()执行上下文,每个执行上下文中都有变量环境词法环境,var声明的变量和函数声明就放在变量环境,let和const声明就放在词法环境

每个变量环境中都会包含一个outer属性它会指向该环境的“外层”或“父级”环境。这也就是为什么openChest 函数可以访问外部函数 findTreasure 中的变量cule,甚至还能访问全局的 treasure。

简单来说:变量的可访问性由它在源代码中定义的位置决定。

image.png

第三章:词法作用域 —— 代码出生时的命运

你以为作用域链是动态变化的吗?不!JavaScript 的作用域规则早在你写代码的时候就已经定好了,这叫做词法作用域(Lexical Scope)

什么意思呢?看下面这个例子:

var location = "藏宝洞入口";

function enterCave() {
    var location = "洞穴深处";
    explore();
}

function explore() {
    console.log(location);
}

enterCave(); // 输出啥?

你觉得输出的是“洞穴深处”还是“藏宝洞入口”?

答案是:“藏宝洞入口”。

这也于我们第二章所提到的outer决定

因为 explore 这个函数定义在全局下,outer会指向该作用域的外层域或父级环境,而不是指向调用它的那个函数。也就是说,作用域是由函数定义的位置决定的,而不是调用位置!这也就是我们上文提到的:变量的可访问性由它在源代码中定义的位置决定。

这就像是你出生在北方,无论你后来搬到南方生活多久,你的身份证上永远写着“北方人”。

简单来说:词法作用域是代码编译阶段就决定好了的,和函数怎么调用没有关系

第四章:TDZ 与词法环境 —— 变量出生前的黑暗时刻

接下来我们要聊一个让人头疼的问题:为什么 letconst 声明的变量不能在声明之前使用?

这是因为它们存在一个叫**暂时性死区(Temporal Dead Zone, TDZ)**的区域。

比如:

console.log(myName); // 报错!
let myName = "骑士";

而如果换成 var,结果就不一样:

console.log(myName); // 输出 undefined
var myName = "极客";

这是因为在编译阶段,var 会被提升到作用域顶部(变量提升),但是赋值是发生在执行阶段的,不会被提升(函数则是连着函数的定义一起提升)注意:用var声明的函数表达式一样赋值不会提升

上述代码其实就是翻译成了以下代码

var myName
console.log(myName); // 输出 undefined
 myName= "极客";

letconst 也会变量提升,但是它们提升至TDZ(暂时性死区)简单来说在声明前访问就会报错

你可以把 let 想象成一个高冷的朋友:没见到她之前不要随便搭话,不然她会直接甩你一句“你谁啊?”

第五章:闭包 —— 属于函数的秘密背包

终于到了重头戏——闭包(Closure)

其实闭包的基础就是理解词法作用域等,变量的可访问性由它在源代码中定义的位置决定。

闭包听起来很高大上,其实就是一个函数记住并访问它诞生时的作用域的能力。换句话说,函数随身带着一个背包,里面装着它爸爸、爷爷、太爷爷……留下的东西。

来看一个经典例子:

function createCounter() {
    let count = 0;
    function fn() {
        count++;
        console.log(count);
    };
    return fn
}

const counter = createCounter();
counter(); // 1
counter(); // 2
counter(); // 3

虽然 createCounter 已经执行完了,但是 count 这个变量并没有被销毁,而是被内部的fn函数“封印”在了自己的小宇宙里。

闭包就像一个忠实的管家,即使主人搬走了,它依然记得主人的习惯和秘密。

简单来说闭包就是:内部函数+外部函数变量,这样说只是为了更好的理解

我们来借用垃圾回收机制来理解为什么createCounter 已经执行完了,但是 count 这个变量并没有被销毁

现在常用的垃圾回收机制——标记清除法

现代浏览器通用的大多是基于标记清除算法的某些改进算法,总体思想都是一致的。 核心:

  • 标记清除算法将“不再使用的对象”定义为“无法达到的对象”。
  • 就是从根部(在JS中就是全局对象)出发定时扫描内存中的对象。 凡是能从根部到达的对象,都是还需要使用的那些无法由根部出发触及到的对象被标记为不再使用,稍后进行回收。

image.png

再回到我们上述的代码,我们从counert出发,是能够访问到createCounter的内部函数fn的,然后再提到我们上述的词法作用域,变量的可访问性由它在源代码中定义的位置决定。所以我们能找到count这个变量所以并不会被回收,也就是闭包阻止了我们的变量被回收

我们再来看看这段代码

function foo() {
    var myName = "极客时间"
    let test1 = 1
    const test2 = 2
    var innerBar = {
        getName: function () {
            console.log(test1)
            return myName
        },
        setName: function (newName) {
            myName = newName
        }
    }
    return innerBar
}
var bar = foo()
// 函数嵌套函数,外部访问的时候
// 沿着词法作用域链,找到它声明的时候的函数中的变量
// 函数就好像有一个背包一样,里面放着外层函数的变量
bar.setName("极客邦")
bar.getName()//1
console.log(bar.getName())//1 极客邦

这也是一个闭包,我们外部执行函数,我们现在有了词法作用域的概念,能很清楚的明白为什么打印的是极客邦 变量的可访问性由它在源代码中定义的位置决定。

根据词法作用域的规则,内部函数总是可以访问其外部函数中声明的变量(自由变量)当通过调用一个外部函数返回一个内部函数后, 即使该外部函数已经执行结束了,但是内部函数 引用外部函数的变量依然保存在内存中,我们就把这些变量的集合打个包称为闭包。 这些变量的集合也是内部函数运行的专属背包

我们试着打个断点,执行后可以很清晰的看到闭包 image.png

第六章:实战解析 —— 让代码说话

让我们一起分析一段有点烧脑的代码,看看你能不能找出其中的奥秘:

function bar() {
    var myName = "极客世界";
    let test1 = 100;
    if (1) {
        let myName = "Chrome浏览器";
        console.log(test);
    }
}

function foo() {
    var myName = "极客邦";
    let test = 2;
    {
        let test = 3;
        bar();
    }
}

var myName = "极客时间";
let myAge = 10;
let test = 1;

foo();

请问控制台输出的结果是什么?

答案是:1

为什么会这样?

  • 在 bar 函数中,test 全局作用域中找到了test
  • 虽然 foo 函数中有 test = 2 和 test = 3,但它们都在函数作用域中,并不是outer所指向的外部环境或父级环境,无法影响到 bar
  • 所以 console.log(test) 实际上打印的是 1

这就像是你在一个山洞里找宝藏,明明听到别人说“宝藏在他那”,但是我们还是在山洞里找到了宝藏

第七章:总结 —— 成为真正的代码勇士

JavaScript 的执行机制虽然复杂,但它背后有一套非常清晰的逻辑体系:

  • 调用栈记录函数的执行顺序;
  • 作用域链决定了变量查找的路径;
  • 词法作用域让变量的归属不再模糊;
  • TDZ让 let 和 const 更加安全;
  • 闭包则赋予函数持久的记忆能力。

掌握了这些知识,你就不再是那个只会抄代码的菜鸟,而是一个能够理解代码本质、写出优雅结构的真正开发者。

彩蛋章节:面试官最爱问的闭包题

面试官:请解释什么是闭包,并给出一个实际应用场景。

你可以这样回答:

闭包是指一个函数能够访问并记住其词法作用域,即使该函数在其作用域外执行。通俗点说,闭包就像是函数随身携带的一个小背包,里面装着它能访问的所有变量。

应用场景包括:封装私有变量、实现计数器、等。


最后一句话送给大家:

写代码就像修炼内功,基础越扎实,未来才能走得越远。愿你在编程的路上越挫越勇,早日成为那个“闭包都讲不清楚”的高手