首页/文章/javascript

小白也能看懂的JavaScript作用域链和闭包

2024-11-19
378513 分钟
...

这篇文章主要介绍了 JavaScript 中的作用域链和闭包。先讲解了全局作用域、函数作用域和块级作用域的概念及特点,包括变量的访问规则和小测验。接着阐述了作用域链,通过代码和图解解释了词法作用域、outer 及作用域链的概念。然后说明了闭包的概念、产生原因,通过示例展示其应用,并给出了相关测验。最后总结了闭包的优缺点。

关联问题: 作用域链如何优化 闭包内存如何管理 全局作用域有何限制

前言

在之前的写文章中我们已经对js的运行机制有了初步的了解,并且在其中简单认识了三个作用域。那么接下来我们将由浅入深继续学习作用域相关的知识,先域的概念再学习作用域链和闭包

1.作用域

概念:变量/函数可以被访问的区域

特点:外层作用域无法直接访问内层作用域

在一段js的代码中,我们可以将代码分为三种区域:全局作用域、函数作用域和块级作用域。下面我们通过几段代码来了解一下三个作用域分别是是什么以及他们的特征

全局作用域

概念:{}和函数最外层的区域

let a = 2

function varTest() {
  console.log(1);
}

function test() {
  varTest()
  console.log(a);
}
test()

在上面的代码中let定义的变量a、函数test和varTest都处于全局作用域中,varTest和变量a可以在函数test内部被访问,所以输出的结果为1 2

  • 全局作用域中的声明的变量是全局变量,在页面的任意的部分都可以访问
  • 全局作用域中无法访问函数作用域的变量

函数作用域

概念:函数体内部的区域(即function(){}中{}部分的区域)

function varTest() {
  var b = 3
  console.log(1);
}


varTest()
console.log(b);

上面代码之所以报错,是因为变量b定义在函数作用域内,而作用域的规则是内层可以访问外层而外层不能访问内层,所以在全局想要打印变量b时,找不到变量b从而报错

块级作用域

概念:由let和{}组成

{
  var a = 1
  var b = 2
}
{
  let c = 3
  let d = 4
}

console.log(a, b);
console.log(c, d);

输出:

在上面代码中,a和b之所以可以被访问是因为是由var所定义,而var和{}不会构成块级作用域。下面的c和d之所以报错,是因为由let所定义,而let和{}构成了块级作用域,不能被外层作用域所访问

小测验

在了解完了三个作用域之后,我们来看一下下面一段代码会输出什么:

function varTest() {
  var x = 1
  if (true) {
    let x = 2
    console.log(x);
  }
  console.log(x);
}
varTest()


在这里第一个输出x输出是2大家可能没什么问题,而第二个x输出的是1,这里可能会有点问题。这是 因为let x = 2和{}构成了块级作用域,所以下面输出x访问不了块级作用域里面的变量这才会输出1。可能有些人会问为什么不是输出别的而是找到上面那个变量呢,这就关系到一个小知识了——上下文中变量的查找。

上下文中变量的查找

在前一篇文章js的运行机制中说到,在V8引擎中函数在被编译的时候会生成函数上下文压入调用栈中,而这个上下文中包含了变量环境和词法环境,var所定义的变量储存在变量环境中。那么块级作用域是存储在哪里呢?答案是词法环境中。

下面我们通过一段代码来解释在上下文中变量的查找:

function foo() {
  var a = 1
  let b = 2
  {
    let b = 3
    var c = 4
    console.log(a);
    console.log(b);
  }
  console.log(b);
  console.log(c);
}
foo()

在foo这个函数的上下文中var所定义的变量都是存储在变量环境中,而let定义的变量和块级作用域都是储存在词法环境中,在执行过程中,会先从词法环境(内部结构类似于调用栈)从上到下依次查找,如果查找不到再去变量环境中进行查找,下面来看一下查找变量c的图解:

2.作用域链

在通过上面了解了三个作用域之后,接下来我们来深入一下,了解js中的作用域链。我们先通过一段代码初步认识一下作用域链是什么:

function bar() {
  console.log(myname);
}
function foo() {
  var myname = '牛哥'
  bar()
  console.log(myname);
}
var myname = '小朱'
foo()

这时候可能就会有些困惑了,明明是在函数foo中myname定义的变量值是'牛哥',为什么bar函数调用的时候不是输出'牛哥'而是输出了小朱呢。这就不得不提到词法作用域和函数中自带的outer了,下面我们来了解一下这两个东西。

词法作用域和outer

词法作用域的概念:函数定义在了哪个域中,这个域就叫该函数的词法作用域。

outer:执行上下文当中存在一个outer属性,而这个outer会指向该函数的词法作用域,简而言之就是内部函数可以访问外部函数声明的变量。(可以看成一个指针)

在了解完了这两个东西的概念之后,我们来解析一下上面的代码。

函数bar和函数foo都是定义在全局上下文当中,当函数进行编译的时候,先将var定义的变量和函数bar、foo都放入全局上下文的变量环境中并且赋予默认值undefined和函数,将全局上下文压入调用栈内,然后执行代码。当调用函数foo时,先创建foo上下文放入调用栈内,然后将foo函数内部定义的变量myname放入foo上下文的变量环境中,然后执行代码。

当运行到调用函数bar时,可以看到函数bra内部要打印myname。此时的打印语句由于是存放于函数bar内部,而bar内部并没有myname这个变量,那么函数内部的outer就会指向bar的词法作用域内寻找myname变量,而由于bar的词法作用域是全局,全局中定义了变量myname = '小朱',此时找到了myname就会输出小朱。然后继续向下执行打印foo函数内部myname所定义的牛哥。

下面让我们看一下上述代码在调用栈中的图解:

作用域链

在了解完了词法作用域和outer之后,下面我们来了解一下作用域链。这时候可能会问了,什么是作用域链呢,是不是一条链子把作用域给串起来了呢?

概念:V8 在查找变量的过程中,顺着执行上下文中的 outer 指向查清一整根链,这种链状关系就叫作用域链

下面呢我们通过一段代码来展示一下作用域链:

let count = 1
function main() {
  let count = 2
  function bar() {
    let count = 3
    function foo() {
      let count = 4
    }
    foo()
  }
  bar()
}
main()

在上面的代码中并没有输出,我们只需要知道每个函数中的查找关系即可。相信大家已经猜到了,在上面这个例子中,三个函数的嵌套组成了一条作用域链:foo -> bar -> main -> 全局

下面用调用栈中上下文来展示一下作用域链:

3.闭包

闭包的概念

在了解完了作用域链之后接下来我们最后来深入一下,了解一下闭包。首先我们得知道什么是闭包,接下来用一段代码来演示:

function foo() {
  function bar() {
    var a = 1
    console.log(b);
  }
  var b = 2
  return bar
}
const baz = foo()
baz()

在执行函数baz的时候(就相当于执行函数bar),这时候会发现,根据之前学习的js运行机制foo函数已经被出栈销毁,而函数bar需要变量b,这时候变量b跟随着函数foo销毁了,那么最后为什么还能输出b呢。

下面用调用栈来解释一下上述代码的执行过程:

在上面的图片中我们可以看到foo在销毁之后旁边出现了一个区域用来储存b = 2,而这个b正是函数foo当中的变量b。当执行函数bar的时候由于在函数内部找不到变量b,所以bar内部的outer会往词法作用域查找变量,而词法作用域已经被销毁了。这时候就得用到闭包了,js官方为了弥补这一缺点,设置了一片区域用来储存需要用到的变量来方便调用,这时候outer便会去这片区域查找变量b,而这正是闭包

闭包的概念:在 js 中,根据词法作用域的规则,内部函数总是可以访问其外部函数中声明的变量。当通过调用一个外部函数返回的一个内部函数时(参考上面代码的baz()),即使外部函数调用完了,但是内部函数引用了外部函数中的变量,那么这些变量依然需要保存在内存中,我们把这些变量的集合称为闭包(只有被用到的变量才会保存)。

为什么会有闭包呢

在看完了上面闭包的概念以及闭包的例子之后,大家可能会想,为什么会有闭包呢?相信大家已经想到了,这是因为在js中词法作用域的规则 和 函数调用完毕它的执行上下文一定会被销毁这一规则冲突(不一定所有场景都冲突) 。而这,正是闭包所产生的原因。

4.小测验

在经过了前面对作用域链以及闭包的学习之后,下面我们来检测一下自己的学习成果如何,接下来呢我会提供两个题目给大家测试一下对闭包的掌控如何。

4.1 利用闭包设置一个计数器
function add() {
  let num = 0
  return function foo() {
    console.log(++num);
  }
}
const res = add()
res()
res()
res()

在上面的代码中,我们利用闭包的性质设置了一个计数器,用num来储存数量。上面代码在执行的时候,由于foo函数是在函数add内部,并且foo函数内部需要调用num。而num并不在函数foo内部,而是在foo的词法作用域,所以num会形成一个闭包用来储存每次所记录的数量。

下面用调用栈内的两个函数上下文来展示一下:

4.2 观察一下下面代码会输出什么
function fn() {
  var arr = []
  for (var i = 0; i < 5; i++) {
    arr.push(function () {
      console.log(i);
    })
  }
  return arr
}

var funcs = fn()
for (var j = 0; j < 5; j++) {
  funcs[j]() 
}

在这里呢,大家可能就会有点疑惑了,为什么最后会是五个5呢,而不是0或者0 1 2 3 4呢。这是因为fn中的for循环执行完之后,i的值是5。而i要被数组arr中的函数所调用,所以此时i = 5就会被保存下来用以接下来对数组中函数的调用。

下面我简单用这几个函数在调用栈内的样子画个图(全局上下文省略了):

接下来我们再思考一下,如何利用闭包,让这个函数输出0-4呢。想必大家已经想到了,接下来我就展示一下代码:

function fn() {
  var arr = []
  for (var i = 0; i < 5; i++) {
    (function(n){
      arr.push(function () {
        console.log(n);
      })
    })(i)
  }
  return arr
}
var funcs = fn()
for (var j = 0; j < 5; j++) {
  funcs[j]() 
}

这时候大家可能会有点疑惑,为什么在for循环中加个立即执行函数就能实现了呢,这就利用到了闭包的特性。大家可以想一想闭包的概念(忘了可以往上面翻翻),当一个函数执行完被销毁时,它里面的变量需要被它内部的函数所使用时,它里面的变量会被保存在一个集合中。利用这一特性,我们在每一次循环的时候都立即执行一下函数,这样每一个i都会被保存在一个集合中,那么接下来在依次执行数组中的函数时,这些i就会被依次使用。

接下来简单画个图让大家看得更清晰:

由于画布有点小,立即执行函数就省略了)上图中的紫色箭头为outer指向,右边的每一块绿色就是for循环中每一次立即执行函数中的i所产生的闭包。

5.总结

  • 闭包的缺点:内存泄漏(指的是调用栈的可用空间在缩小。闭包越多,那么存的变量越多从而导致调用栈空间变小)
  • 闭包的优点:变量私有化,封装模块

所以说呢大家在平时使用闭包的时候,得节制一点,注意不要过度使用闭包,不然会内存不够。

最后呢谢谢大家的观看,喜欢的话点个赞吧。

如果您觉得这篇文章有帮助,请点个赞吧~

分享文章

相关文章

更多文章 →
javascript2026-02-24
navigator.sendBeacon全指南
在前端开发中,埋点系统是必不可少的一环。我们经常需要在用户 关闭页面 、 刷新 或 跳转路由 时,向服务器发送最后一条统计数据(比如用户停留时长、页面跳出率)。 但这看似简单的需求,在实现时却危机四伏:请求发不出去?页面跳转卡顿?今天我们就来聊聊这个问题的终极解决方案 —— 。 一、 痛点与传统方案的挣扎 场景还原 当用户点击关闭按钮时,浏览器会触发生命周期事件( 或 )。如果我们直接使用普通的异步 AJAX ( 或 ) 发送请求,浏览...
学习
javascript2025-11-02
理解浏览器事件系统,从用户点击到事件对象的完整旅程
深入理解浏览器事件系统:从用户点击到事件对象的完整旅程 “当我点击页面按钮时,背后发生了什么?为什么回调函数能收到一个包含丰富信息的event对象?今天,让我们一起揭开浏览器事件系统的神秘面纱。” 一个令人困惑的现象 作为前端开发者,我们每天都在写这样的代码: 这段代码如此熟悉,以至于我们很少停下来思考:​ ​这个 对象到底从哪里来?它为什么能知道点击的精确坐标?为什么能识别是哪个元素被点击了?​ ​ 更神奇的是,当我们手动创建事件时:...
学习
javascript2025-10-01
实现大文件上传全流程详解
在日常开发中,大文件上传是个绕不开的坎——动辄几百 MB 甚至 GB 级的文件,直接上传不仅容易超时,还会让用户体验大打折扣。最近我用 Vue+Express 实现了一套完整的大文件上传方案,支持分片上传、断点续传、秒传和手动中。 一、先看效果:我们要实现什么? 先上核心功能清单,确保大家明确目标,知道我们要解决哪些实际问题: 大文件分片上传 :将文件切成固定大小的小片段分批上传,避免单次请求超时 秒传 :服务器已存在完整文件时,直接返...
学习
javascript2025-09-18
JavaScript 的多线程能力:Worker
如果你写过一些计算量稍大的 JavaScript 代码,比如图像处理、大量数据排序或者复杂的算法,你几乎肯定遇到过浏览器“卡死”的现象。点击页面没反应,动画也停了,就像整个世界都静止了。 这就是主线程被阻塞的典型后果。因为主线程既要负责执行 JavaScript,又要负责渲染页面、响应用户操作,一旦它被繁重的计算任务占满,就无暇顾及其他,用户体验便直线下降。 这个问题的根源,正是“主线程是单线程的”。那么,如何解决呢? 答案很简单:把这...
学习面试
javascript2025-09-15
一张 8K 海报差点把首屏拖垮
你给后台管理系统加了一个「企业风采」模块,运营同学一口气上传了 200 张 8K 宣传海报。首屏直接飙到 8.3 s,LCP 红得发紫。 老板一句「能不能像朋友圈那样滑到哪看到哪?」——于是你把懒加载重新翻出来折腾了一轮。 解决方案:三条技术路线,你全踩了一遍 1\. 最偷懒:原生 一行代码就能跑,浏览器帮你搞定。 🔍 关键决策点 2020 年后现代浏览器全覆盖,IE 全军覆没。 必须写死 ,否则 CLS 会抖成 PPT。 适用场景...
学习
javascript2025-09-10
🚀 Web Worker让你的应用丝滑
🌟 引言 在日常的前端开发中,你是否遇到过这样的困扰: 大数据处理时页面卡死 :处理几万条数据时,页面直接卡成PPT,用户点击毫无反应 复杂计算阻塞UI :图片处理、数据分析等计算密集型任务让整个应用假死 文件上传/下载卡顿 :大文件操作时,其他功能完全无法使用 实时数据处理性能差 :WebSocket接收大量数据时,页面渲染严重滞后 今天分享6个Web Worker的核心技巧,让你的应用告别卡顿,用户体验丝滑如德芙! 💡 核心技巧...
学习

评论

请登录后发表评论

去登录
加载评论中...

目录