从引擎视角看 JavaScript:词法作用域、作用域链与闭包的真正运行方式

153 阅读5分钟

本文从 语言底层机制、执行过程、内存模型 三个角度,系统性解释 JS 中的作用域、词法作用域链与闭包。既保留通俗风格,又加强专业性,适合前端开发者作为“长期收藏级”的参考文档。


🧠 01. JavaScript 的运行机制:理解作用域的基础

在深入作用域之前,我们必须理解 JS 的运行方式。JS 并非逐行解释执行,而是由 V8 在执行前经历两个关键阶段:

(1)编译阶段:建立作用域结构与词法环境

在代码执行前,V8 会进行一次“轻量编译”:

  • 收集所有声明(var、let、const、function)
  • 为每个作用域创建 词法环境(Lexical Environment)
  • 确定作用域链结构
  • 分析变量引用位置

在这个阶段,函数的作用域链已经完全确定,这就是“词法作用域静态决定”的根本原因。

(2)执行阶段:进入调用栈、解析变量、执行逻辑

每当函数执行,就会创建一个新的 执行上下文(Execution Context) ,入栈并运行。

执行上下文包括三部分:

  • 变量环境(Variable Environment) :管理 var 声明
  • 词法环境(Lexical Environment) :管理 let/const 声明与块级作用域
  • 作用域链(Scope Chain) :决定变量查找的路径

当函数执行完毕,其执行上下文会从调用栈中弹出,但并不是所有变量都会被回收——这就是闭包的关键前提。


🔍 02. 词法作用域(Lexical Scope):作用域在“定义时”决定

词法作用域指:

一个函数在“定义时”所处的词法环境,决定它能访问哪些变量,与调用方式无关。

来看例子:

function bar() {
  console.log(myName);
}
function foo() {
  var myName = '极客邦';
  bar();
}
var myName = '极客时间';
foo();

很多开发者误以为 bar()foo() 调用后就能访问 foo 的变量。

事实完全相反:

  • bar 的作用域链在定义时已固定为:bar → 全局
  • 与 foo 是否调用它完全无关

所以输出结果是:

极客时间

这体现了词法作用域的两个本质特性:

  1. 静态性(static) :作用域在编译时确定
  2. 与调用位置无关:调用方式无法改变作用域链

这是理解闭包的基础。


🔗 03. 作用域链(Scope Chain):变量查找的路径结构

每个函数在创建时都会生成一个 外部引用指针 Outer,指向其定义位置的词法环境。

因此变量查找顺序为:

当前作用域 → 外层作用域 → 全局作用域

例如:

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

查找 test 的过程为:

  1. 块级作用域(无)
  2. bar 函数作用域(无)
  3. 全局(如果全局无 test → ReferenceError)

注意:

  • let/const 会创建 块级词法环境
  • var 不会创建块级作用域,只归属于函数作用域

作用域链是一种链式结构,但它在编译阶段已构建好,永不因函数调用方式改变。


🧩 04. 闭包(Closure):函数携带“出生时作用域”的能力

闭包的专业定义:

当一个函数可以在其词法作用域之外被访问时,它仍然“记住”定义时的词法环境,这种组合结构就是闭包。

我们来看实际的闭包示例:

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('极客邦');
console.log(bar.getName());

理解闭包必须理解:

✔ foo 已经执行完毕,但其变量没有被回收

因为:

  • 其内部函数仍然引用 myNametest1test2
  • V8 判断这些变量仍然“活着”
  • 所以不会回收它们的内存

📌 闭包的本质:

内层函数保存了其外层函数的词法环境引用,使得外层函数生命周期被延长。

图示:

innerBar → [[Environment]] → foo Lexical Environment
                           → { myName, test1, test2 }

🎯 05. 闭包的三个充分必要条件(非常关键)

要形成闭包,必须同时满足:

① 有函数嵌套(Nested Function)

没有嵌套就谈不到外部变量引用。

② 内部函数被外部访问(逃逸)

  • return 返回出去
  • 赋值给外部对象
  • 注册为事件回调

③ 内部函数引用外部函数的变量(自由变量)

这些变量会被保存在闭包中。

三个条件同时满足闭包才真正形成。


🧠 06. 闭包的专业应用场景

闭包在现代前端中无处不在:

(1)模拟私有变量(封装)

function Counter() {
  let count = 0;
  return {
    inc() { return ++count },
    get() { return count }
  }
}

(2)函数式编程的基础(柯里化、高阶函数)

如 lodash 的 curry 实现。

(3)模块化的基础(IIFE 模式)

ES6 以前的大型库如 jQuery 依靠闭包隔离命名空间。

(4)异步回调的变量捕获

事件监听、定时器等使用闭包保存外部变量。

这些都是闭包最具代表性的“专业用途”。


⚠ 07. 闭包是否会造成内存泄漏?专业结论:不会,但可被误用导致泄漏

闭包本身 不会自动造成内存泄漏,它只是延长了变量生命周期。

但是以下情况可能导致真·泄漏:

  • 将闭包存放到全局变量,导致大量变量无法释放
  • 在循环中创建大量闭包对象
  • 将 DOM 对象与闭包相互引用

现代 V8 对闭包有严格优化:

  • 未被使用的变量会从闭包中剔除
  • 闭包结构会被压缩

只要合理使用,闭包完全是安全的工具。


📝 08. 总结(专业 + 实用)

如果你掌握以下内容,你已经彻底理解 JS 的作用域体系:

✔ 词法作用域:作用域在定义时静态决定

✔ 作用域链:按词法嵌套关系自内向外查找变量

✔ 执行上下文:函数执行时的运行环境

✔ 闭包:内部函数捕获外部变量并延长其生命周期

一句话总结:

作用域链在编译阶段形成,闭包在执行阶段体现。理解它们,你就理解了 JS 的核心机制。