阅读视图

发现新文章,点击刷新页面。

JavaScript 词法作用域、作用域链与闭包:从代码看机制

在学习 JavaScript 的过程中,作用域 是一个绕不开的核心概念。很多人一开始会误以为“变量在哪调用,就在哪找”,但其实 JS 的作用域是 词法作用域(Lexical Scoping) ,也就是说,函数的作用域由它定义的位置决定,而不是调用位置。今天我们就通过几段简单的代码和图解,来深入浅出地理解 JavaScript 中的 词法作用域、作用域链 和 闭包 这三个重要机制。


一、什么是执行上下文?调用栈是如何工作的?

在 JavaScript 中,每当一个函数被调用时,都会创建一个「执行上下文」(Execution Context),并压入调用栈(Call Stack)。这个执行上下文包含两个关键部分:

  • 变量环境(Variable Environment) :存储用 var 声明的变量和函数声明。
  • 词法环境(Lexical Environment) :存储用 letconst 声明的块级作用域变量。

此外,每个执行上下文的词法环境中还有一个特殊的属性:outer,它指向该函数定义时所在的作用域的词法环境。

✅ 简单说:outer 指针决定了作用域查找路径,即“作用域链”

我们来看第一个例子(1.js):

function bar(){
  console.log(myName)
}

function foo(){
  var myName = '极客邦'
  bar()
}

var myName = '极客时间'
foo() // 输出: 极客时间

🤔 为什么输出的是 “极客时间” 而不是 “极客邦”?

很多人会误以为:bar() 是在 foo() 内部调用的,那它应该能访问到 foo() 里的 myName。但实际上,bar() 是在全局定义的,所以它的 outer 指向的是全局的词法环境。

bar() 执行时,它先在自己的词法环境中找 myName,没有;然后顺着 outer 指针去全局词法环境查找,找到了 var myName = '极客时间',于是打印出来。

👉 结论:作用域是由函数定义的位置决定的,而不是调用位置。这就是 词法作用域 的核心思想。


二、作用域链:查找变量的“路径”

作用域链就是由一个个执行上下文的 outer 指针串联而成的链条。我们可以通过以下代码进一步理解(2.js):

function bar(){
  var myName = '极客世界'
  let test1 = 100
  if(1){
    let myName = 'Chrome 浏览器'
    console.log(test) // ❌ 报错:test is not defined
  }
}

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

var myName = '极客时间'

let myAge = 10

let test = 1

foo()

这段代码中,bar() 函数内部试图打印 test,但它找不到。

🔍 查找过程如下:

  1. bar() 的词法环境中找 test → 没有;
  2. bar()outer 指向的词法环境(全局)找 → 全局有 let test = 1,但注意!bar() 是在全局定义的,所以它只能访问全局的 test
  3. 但是 bar() 执行时,test 被重新赋值了吗?没有,因为 bar() 并不在 foo() 内部定义,所以它不会继承 foo() 的作用域。

因此,console.log(test) 实际上是在全局查找 test,结果是 1

⚠️ 注意:bar() 无法访问 foo() 中的 test,即使它是从 foo() 中调用的。这再次证明了:JS 是词法作用域,不是动态作用域

我们可以结合下面这张图来理解调用栈和作用域链的关系:

lQLPKHIJLY1ZLgPNAnrNBHawi45ov3eSr18JAvLiVN8GAA_1142_634.png

  • bar() 的执行上下文在栈顶;
  • 它的 outer 指向全局;
  • 因此查找 test 时,直接跳到了全局词法环境。

三、闭包:函数的“专属背包”

接下来是最有意思的——闭包(Closure)。

闭包的本质是:一个函数能够访问并记住其外部函数的变量,即使外部函数已经执行完毕

我们来看第三个例子(3.js):

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() // foo 执行完毕,出栈
//它已经出栈了 那bar里面的变量应该回收吧?
//代码的执行证明 它不会回收
//foo函数确实是出栈了 但是getName/setName还需要foo()函数里面的变量 所以它会'打个包' (如果一个变量被引用的话 那么它们就不能顺利的进行垃圾回收)
bar.setName('极客邦')
bar.getName() // 输出: 极客邦

🤯 为什么 foo() 已经出栈了,还能修改和读取里面的变量?

因为在 foo() 返回 innerBar 对象时,getNamesetName 这两个方法都引用了 foo() 内部的变量 myNametest1。V8 引擎发现这些变量被“外部引用”了,就不会回收它们。

于是,foo() 的执行上下文虽然出栈了,但它的 词法环境被保留了下来,形成了一个“闭包”。

💡 这个被保留下来的词法环境,就是闭包本身。而其中被引用的变量,叫做 自由变量

我们再看一张图:

536a315a83aa48b870d03dd921b6c02a.png

  • setName 执行时,它的 outer 指向 foo() 的词法环境;
  • 即使 foo() 已经执行结束,这个环境依然存在;
  • 所以 myName 可以被修改为 '极客邦'
  • 后续调用 getName() 时,依然能拿到更新后的值。

✅ 闭包的形成条件:

  1. 函数嵌套函数;
  2. 内部函数被返回或暴露到外部;
  3. 内部函数引用了外部函数的变量。

四、闭包的生命周期:什么时候释放?

闭包并不会一直占用内存。只有当外部仍然持有对闭包函数的引用时,闭包才会被保留。

比如:

var bar = foo()
bar = null // 此时,bar 不再引用 innerBar,闭包可以被垃圾回收

一旦 bar 被置为 nullgetNamesetName 就不再被引用,V8 引擎就会回收 foo() 的词法环境,释放内存。

🔒 闭包是一种“记忆”机制,但也会带来内存泄漏的风险。使用完后记得释放引用!


五、总结:词法作用域 vs 动态作用域

特性 词法作用域(JavaScript) 动态作用域
查找依据 函数定义的位置 函数调用的位置
是否依赖调用栈顺序
示例语言 JavaScript、Python、C++ Bash、一些脚本语言

JavaScript 是典型的词法作用域语言,这意味着:

  • 函数的 outer 指针在编译阶段就确定;
  • 不管你在哪调用,只要函数定义在全局,它的 outer 就指向全局;
  • 闭包的存在正是基于这种静态作用域的特性。

六、常见误区澄清

❌ 误区一:“在哪个函数里调用,就查哪个函数的作用域”

这是动态作用域的思维。JavaScript 不是这样工作的。

✅ 正确做法:看函数定义在哪outer 指向哪里,就从哪里开始查。

❌ 误区二:“函数执行完,里面的变量就没了”

不一定!如果函数返回了一个引用了内部变量的函数,那么这些变量会被保留,形成闭包。

❌ 误区三:“闭包就是匿名函数”

不对。闭包是一种现象,不一定是匿名函数。只要满足条件,任何函数都可以形成闭包。


七、图解回顾

我们再来快速回顾一下几张关键图:

图1:bar() 调用时的作用域链

lQLPKHIJLY1ZLgPNAnrNBHawi45ov3eSr18JAvLiVN8GAA_1142_634.png

  • bar()outer 指向全局;
  • 查找 test 时,从全局找到 test = 1

图2:foo() 执行时的执行上下文

d70143c661ed9c209cdc5991f27fcab9.png

  • foo() 的词法环境包含 test1, test2
  • 变量环境包含 myName, innerBar

图3:闭包生效时的状态

lQLPJwCC0KWlAbPNA03NBHawINj2y-qMdT0JAv8hJbJKAA_1142_845.png

  • setName 执行时,outer 指向 foo() 的词法环境;
  • 即使 foo() 已出栈,数据依然可访问。

八、写在最后

JavaScript 的作用域机制看似复杂,但只要抓住一个核心:词法作用域 + outer 指针 + 闭包,就能轻松应对大多数场景。

记住一句话:

函数的作用域由它定义的位置决定,而不是调用的位置。

当你看到一个函数在别处被调用时,不要慌,先问一句:“它是在哪定义的?” 然后顺着 outer 指针去找,一切就清晰了。

闭包虽然强大,但也需要谨慎使用,避免不必要的内存占用。

希望这篇文章能帮你理清思路,下次遇到作用域问题时,不再迷茫!


📌 附注:本文所用代码和图解均来自个人学习笔记,图片仅为示意,实际运行时请自行验证逻辑。欢迎在评论区交流你的理解!

我的变量去哪了?JS 作用域入门指南

在 JavaScript 的学习过程中,作用域变量提升是两个绕不开的核心概念。它们不仅影响着代码的执行逻辑,也常常成为初学者“踩坑”的重灾区。本文将结合几段典型代码,从实际运行结果出发,梳理 JS 中作用域的演变过程,重点解释 var 的缺陷let/const 的改进,以及现代 JS 引擎如何通过执行上下文统一处理这两类变量声明。


一、变量提升:JS 的“历史包袱”

先看这段代码(1.js):

showName() // ✅ 正常执行
console.log(myname) // undefined

var myname = '路明非'

function showName(){
  console.log('函数showName 执行了')
}

这里体现了两个关键现象:

  • 函数声明提升showName 不仅声明被提升,函数体也被提升,因此可以在定义前调用。
  • 变量提升(仅声明)var myname 的声明被提升到顶部,但赋值仍在原位置执行,所以首次 console.log 输出 undefined

这就是经典的 hoisting(变量提升) 机制。它源于 JS 引擎的两阶段执行模型:编译阶段收集声明,执行阶段进行赋值和调用。

⚠️ 变量提升虽解决了早期 JS 的作用域问题,但也带来了不符合直觉的行为,被视为语言设计上的缺陷。


二、作用域链:全局 vs 局部

2.js 中:

var globalVar = '我是全局变量'

function myFunction(){
  var localVar = '我是局部变量'
  console.log(globalVar) // ✅ 打印全局变量
  console.log(localVar)  // ✅ 打印局部变量
}

myFunction()
console.log(globalVar) // ✅
console.log(localVar)  // ❌ ReferenceError

这展示了 作用域链 的查找规则:

  • 函数内部优先查找局部作用域
  • 若未找到,则沿作用域链向上查找至全局作用域
  • 全局无法访问函数内部的局部变量

这是 JS 作用域的基本规则,也是封装和避免命名冲突的基础。


三、var 的致命伤:不支持块级作用域

来看 3.js

var name = '刘锦苗'

function showName(){
  console.log(name) // undefined
  if(false){
    var name = '大厂的苗子'
  }
  console.log(name) // undefined
}

尽管 if(false) 块永远不会执行,但 var name 仍被提升到函数作用域顶部,导致函数内 name 被初始化为 undefined。这是因为 var 不支持块级作用域,其声明会被提升到最近的函数或全局作用域。

对比 4.js 使用 let

var name = '刘锦苗'

function showName() {
  console.log(name) // '刘锦苗'
  if (false) {
    let name = '大厂的苗子' // ❌ 不会影响外层
  }
}

由于 let 具有块级作用域if 内的 name 仅在该块中有效,不会污染函数作用域,因此外层仍能正确访问全局变量。


四、let/const 如何解决提升问题?

8.js 展示了一个关键特性:

let name = '刘锦苗'

{
  console.log(name) // ❌ ReferenceError: Cannot access 'name' before initialization
  let name = '大厂的苗子'
}

这里报错并非因为变量未声明,而是进入了 暂时性死区(Temporal Dead Zone, TDZ)
let/const 虽然也会“提升”,但不会像 var 那样初始化为 undefined,而是在声明前处于不可访问状态。

这正是 ES6 对变量提升缺陷的修正:提升存在,但禁止提前访问


五、执行上下文视角:变量环境 vs 词法环境

现代 JS 引擎(如 V8)通过 执行上下文(Execution Context) 统一管理变量:

  • 变量环境 :存放 var 声明的变量。
  • 词法环境 :存放 let/const 声明的变量,并支持块级作用域栈结构

7.js 为例:

function foo(){
  var a = 1
  let b = 2
  {
    let b = 3  // 新的 b,与外层无关
    var c = 4
    let d = 5
    console.log(a) // 1(从变量环境中找到)
    console.log(b) // 3(当前块级作用域栈顶)
  }
  console.log(b) // 2(块级作用域出栈,恢复外层 b)
  console.log(c) // 4(var 提升到函数作用域)
  console.log(d) // ❌ ReferenceError(d 已随块级作用域销毁)
}

这里的关键在于:

  • let 在块级作用域中创建独立的绑定,块执行完后自动出栈销毁;
  • var 无视块级作用域,始终属于函数或全局作用域;
  • 引擎通过词法环境的栈结构实现了对块级作用域的支持。

六、为什么早期 JS 要这样设计?

JavaScript 最初是“KPI 项目” ,设计周期极短,目标只是给网页加点动态效果。为了快速实现,设计者选择了最简单的方案:

  • 不引入复杂的块级作用域;
  • 用“变量提升”统一处理作用域问题;
  • 用函数模拟“类”,规避面向对象的复杂性。

这种设计在当时够用,但随着 JS 应用复杂度飙升,var 的缺陷日益凸显——变量覆盖、生命周期混乱、难以调试。

ES6 引入 let/const 和块级作用域,正是对这一历史问题的修复。


结语:拥抱 let/const,理解执行上下文

如今,我们应优先使用 letconst,避免 var 带来的陷阱。同时,理解 JS 引擎如何通过 变量环境 + 词法环境 的双轨机制,兼容新旧语法,是深入掌握作用域的关键。

JavaScript 的演进告诉我们:好的语言设计,既要向前兼容,也要勇于修正过去的错误

通过这几段小代码,我们不仅看到了变量提升的“坑”,更见证了 JS 如何在保持灵活性的同时,逐步走向严谨与规范。希望这篇文章能帮你理清思路,在掘金社区分享你的成长!

栈:那个“先进后出”的小可爱,其实超好用!

大家好!今天咱们来聊一个数据结构界的小明星——栈(Stack) 。它不像链表那样复杂,也不像树那样高深,但它在算法和工程实践中却无处不在。从浏览器的“返回”按钮,到表达式求值、括号匹配,甚至函数调用本身,都离不开栈的身影。

如果你刚入门数据结构,或者正在刷 LeetCode,那这篇轻松愉快的小文,或许能帮你把“栈”这个概念彻底搞明白!


什么是栈?

简单来说,栈是一种“先进后出”(FILO, First In Last Out)的线性数据结构。你可以把它想象成一摞盘子:

  • 你只能从最上面放盘子(入栈)
  • 也只能从最上面拿盘子(出栈)
  • 想拿中间的?不好意思,得先把上面的全拿走!

这种“只在一端操作”的特性,让栈成为一种非常简洁但强大的工具。


JavaScript 里,栈怎么实现?

JavaScript 虽然没有内置“栈”类型,但它的 数组(Array)天生就是个开箱即用的栈

const arr = [1, 2, 3];
arr.push(4);   // 入栈 → [1,2,3,4]
arr.pop();     // 出栈 → 返回 4,arr 变成 [1,2,3]

你看,pushpop 就是栈的核心操作!再加上 arr[arr.length - 1] 就能“偷看”栈顶元素(这叫 peek),一个简易栈就完成了。

不过,如果想更规范一点,我们可以用 ES6 的 class 来封装一个真正的栈类:

✅ 用数组实现栈(推荐!)

class ArrayStack {
  #stack = []; // 私有属性,外部无法直接访问

//定义一个getter 外部可以通过stack.size获取栈的大小 其实也就是数组的长度
  get size() {
    return this.#stack.length;
  }

  isEmpty() {
    return this.size === 0;
  }

  push(num) {
    this.#stack.push(num);
  }

  pop() {
    if (this.isEmpty()) throw new Error('栈为空');
    return this.#stack.pop();
    //移除数组最后一个元素 (即栈顶)
  }

  peek() {
    if (this.isEmpty()) throw new Error('栈为空');
    return this.#stack[this.size - 1];
  }
}

是不是很清爽?而且 JS 引擎对数组做了大量优化,日常开发中,用数组实现栈是最高效、最简洁的选择


那……链表也能实现栈?

当然可以!虽然不常用,但作为学习,用链表实现栈能帮你更深入理解“指针”和“动态内存”的概念。

我们先定义一个链表节点:

class ListNode {
  constructor(val) {
    this.val = val;
    this.next = null; // 指向下一个节点
  }
}

然后用它构建一个栈:

class LinkedListStack {
  #stackPeek = null; // 栈顶(即链表头)
  #size = 0;

  push(num) {
    const node = new ListNode(num);
    node.next = this.#stackPeek;
    this.#stackPeek = node;
    this.#size++;
  }

  pop() {
    const num = this.peek();
    this.#stackPeek = this.#stackPeek.next;
    this.#size--;
    return num;
  }

  peek() {
    if (!this.#stackPeek) throw new Error('栈为空');
    return this.#stackPeek.val;
  }

  get size() { return this.#size; }
  isEmpty() { return this.size === 0; }
}

这里的关键是:每次入栈都在链表头部插入节点,这样就能保证“后进先出”。


数组 vs 链表:谁更适合做栈?

维度 数组实现 链表实现
时间效率 大部分 O(1),扩容时 O(n) 稳定 O(1)
空间效率 连续内存,无额外开销 每个节点需存 next 指针,略占空间
实际使用 ✅ 推荐!JS 引擎高度优化 学习用,生产少见

所以结论很明确:在 JavaScript 中,优先用数组实现栈


实战:LeetCode 经典题 —— 有效的括号

说到栈的应用,不得不提这道面试高频题:20. 有效的括号

题目要求判断字符串中的括号是否匹配,比如 "([{}])" 是有效的,而 "([)]" 不是。

思路超简单

  1. 遇到左括号 ([{,就把对应的右括号压入栈;
  2. 遇到右括号,就检查是否和栈顶一致;
  3. 最后栈必须为空才算有效。

代码如下:

const leftToRight = {
  '(': ')',
  '[': ']',
  '{': '}'
};

const isValid = function(s) {
  if (!s) return true;
  //如果s是null undefined 或者"" 则被认为是有效的 返回true
  
  const stack = [];
  for (let ch of s) {
    if (ch in leftToRight) {
      stack.push(leftToRight[ch]); // 压入期待的右括号
    } else {
         //否则  说明当前字符是右括号
      if (!stack.length || stack.pop() !== ch) {
      //此时还会分两种情况返回false
      //1.!stack.length 表示 栈为空的情况下 却来了一个右符号(单单一个右括号) 说明没有匹配的左括号 那么返回false
      //2.stack.pop() !== ch 弹出栈顶(期待的右括号) 与当前字符不一致时 也就是类型不匹配 返回false
        return false; // 不匹配 or 多余右括号
      }
    }
  }
  return !stack.length
  //如果栈为空 说明所有的左括号都被正确匹配 返回true
  //如果栈非空 说明有多余的左括号未闭合 返回false
};

是不是一气呵成?这就是栈的魅力——用最简单的规则,解决看似复杂的问题


小结:栈虽小,作用大

  • 栈是 FILO 的线性结构,操作只在“顶端”进行。
  • JavaScript 中,数组 + push/pop 就是最天然的栈
  • class 封装可以让代码更清晰、安全(私有字段 # + get 访问器)。
  • 链表实现虽可行,但日常开发没必要“杀鸡用牛刀”。
  • 栈的经典应用:括号匹配、表达式求值、撤销操作、DFS 遍历等。

下次当你看到“后进先出”的场景,不妨想想:嘿,这不就是栈该上场的时候吗?


希望这篇轻松的小文能帮你把“栈”这个知识点稳稳拿下!如果你觉得有用,欢迎点赞、收藏,也欢迎在评论区聊聊你用栈解决过哪些有趣的问题~ 🚀

❌