3月阅读周·你不知道的JavaScript | JavaScript内部运行原理启蒙,从作用域和闭包开始

举报
叶一一 发表于 2024/03/25 09:45:20 2024/03/25
【摘要】 背景去年下半年,我在微信书架里加入了许多技术书籍,各种类别的都有,断断续续的读了一部分。没有计划的阅读,收效甚微。新年伊始,我准备尝试一下其他方式,比如阅读周。每月抽出1~2个非连续周,完整阅读一本书籍。这个“玩法”虽然常见且板正,但是有效,已经坚持阅读两个月。《你不知道的JavaScript》分上中下三卷,内容相对较多。3月份,我计划先读前面两卷。已读完书籍:《架构简洁之道》、《深入浅出的...

背景

去年下半年,我在微信书架里加入了许多技术书籍,各种类别的都有,断断续续的读了一部分。

没有计划的阅读,收效甚微。

新年伊始,我准备尝试一下其他方式,比如阅读周。每月抽出1~2个非连续周,完整阅读一本书籍。

这个“玩法”虽然常见且板正,但是有效,已经坚持阅读两个月。

《你不知道的JavaScript》分上中下三卷,内容相对较多。3月份,我计划先读前面两卷。

已读完书籍《架构简洁之道》、《深入浅出的Node.js》

当前阅读周书籍《你不知道的JavaScript(上卷)》、《你不知道的JavaScript(中卷)》

作用域和闭包

作用域是什么

理解作用域

1、作用域是一套设计良好的规则,用来存储变量,并且之后可以方便地找到这些变量。

2、变量的赋值操作会执行两个动作,首先编译器会在当前作用域中声明一个变量(如果之前没有声明过),然后在运行时引擎会在作用域中查找该变量,如果能够找到就会对它赋值。

3、LHS和RHS

LHS和RHS的含义是“赋值操作的左侧或右侧”。赋值操作的目标是谁(LHS),谁是赋值操作的源头(RHS)。

我们来看个例子,加深一下理解:

function foo (a) {
    console.log (a); // 2
}
foo (2);

上面的代码中,最后一行foo (..)函数的调用需要对foo进行RHS引用,意味着“去找到foo的值,并把它给我”。此外,代码中其实还有a=2这个操作。这个操作发生在2被当作参数传递给foo(..)函数时,2会被分配给参数a。为了给参数a(隐式地)分配值,需要进行一次LHS查询。

作用域嵌套

当一个块或函数嵌套在另一个块或函数中时,就发生了作用域的嵌套。

function foo(a) {
    console.log(a + b);
}
var b = 2;
foo(2); // 4

对b进行的RHS引用无法在函数foo内部完成,但可以在上一级作用域(在这个例子中就是全局作用域)中完成。

遍历嵌套作用域链的规则很简单:引擎从当前的执行作用域开始查找变量,如果找不到,就向上一级继续查找。当抵达最外层的全局作用域时,无论找到还是没找到,查找过程都会停止。

异常

为什么区分LHS和RHS是一件重要的事情?

因为在变量还没有声明(在任何作用域中都无法找到该变量)的情况下,这两种查询的行为是不一样的。

看下面这个例子:

function foo(a) {
    console.log(a + b);
    b = a;
}
foo(2);

第一次对b进行RHS查询时是无法找到该变量的。如果RHS查询在所有嵌套的作用域中遍寻不到所需的变量,引擎就会抛出ReferenceError异常。值得注意的是,ReferenceError是非常重要的异常类型。

1-1.png

在严格模式中LHS查询失败时,并不会创建并返回一个全局变量,引擎会抛出同RHS查询失败时类似的ReferenceError异常。

词法作用域

作用域共有两种主要的工作模型。第一种是最为普遍的,被大多数编程语言所采用的词法作用域。另外一种叫作动态作用域,仍有一些编程语言在使用(比如Bash脚本、Perl中的一些模式等)。

词法作用域就是定义在词法阶段的作用域。

词法阶段

词法作用域是由开发者在写代码时将变量和块作用域写在哪里来决定的,因此当词法分析器处理代码时会保持作用域不变(大部分情况下是这样的)。

比如下面的代码:

function foo(a) {
  var b = a * 2;
  function bar(c) {
    console.log(a, b, c);
  }
  bar(b * 3);
}
foo(2); // 2, 4, 12

在这个例子中有三个逐级嵌套的作用域。

1-2.png

如图中的示意:

①包含着整个全局作用域,其中只有一个标识符:foo。

②包含着foo所创建的作用域,其中有三个标识符:a、bar和b。

③包含着bar所创建的作用域,其中只有一个标识符:c。

作用域气泡由其对应的作用域块代码写在哪里决定,它们是逐级包含的。bar的气泡被完全包含在foo所创建的气泡中,唯一的原因是那里就是开发者希望定义函数bar的位置。

欺骗词法

词法作用域完全由写代码期间函数所声明的位置来定义,如何在运行时来“修改”(也可以说欺骗)词法作用域呢?

JavaScript中有两种机制来实现这个目的。

1、eval

JavaScript中的eval(..)函数可以接受一个字符串为参数,并将其中的内容视为好像在书写时就存在于程序中这个位置的代码。

function foo(str, a) {
    eval(str); // 欺骗!
    console.log(a, b);
}
var b = 2;
foo("var b = 3; ", 1); // 1, 3

eval(..)调用中的"var b = 3。这段代码实际上在foo(..)内部创建了一个变量b,并遮蔽了外部(全局)作用域中的同名变量。

2、with

JavaScript中另一个难以掌握(并且现在也不推荐使用)的用来欺骗词法作用域的功能是with关键字。

with通常被当作重复引用同一个对象中的多个属性的快捷方式,可以不需要重复引用对象本身。

var obj = {
    a: 1,
    b: 2,
    c: 3
};
// 单调乏味的重复"obj"
obj.a = 2;
obj.b = 3;
obj.c = 4;
// 简单的快捷方式
with (obj) {
    a = 3;
    b = 4;
    c = 5;
}

这个例子中创建了o1和o2两个对象。其中一个具有a属性,另外一个没有。foo(..)函数接受一个obj参数,该参数是一个对象引用,并对这个对象引用执行了with(obj) {..}。在with块内部,代码看起来只是对变量a进行简单的词法引用,实际上就是一个LHS引用。

性能

eval(..)和with会在运行时修改或创建新的作用域,以此来欺骗其他在书写时定义的词法作用域。

如果代码中大量使用eval(..)或with,那么运行起来一定会变得非常慢。无论引擎多聪明,试图将这些悲观情况的副作用限制在最小范围内,也无法避免如果没有这些优化,代码会运行得更慢这个事实。

函数作用域和块作用域

函数中的作用域

函数作用域的含义是指,属于这个函数的全部变量都可以在整个函数的范围内使用及复用(事实上在嵌套的作用域中也可以使用)。这种设计方案是非常有用的,能充分利用JavaScript变量可以根据需要改变值类型的“动态”特性。

function foo(a) {
  var b = 2;
  // 一些代码
  function bar() {
    // ...
  }
  // 更多的代码
  var c = 3;
}

上面的代码中,foo(..)的作用域气泡中包含了标识符a、b、c和bar。无论标识符声明出现在作用域中的何处,这个标识符所代表的变量或函数都将附属于所处作用域的气泡。bar(..)拥有自己的作用域气泡。

由于标识符a、b、c和bar都附属于foo(..)的作用域气泡,因此无法从foo(..)的外部对它们进行访问。也就是说,这些标识符全都无法从全局作用域中进行访问,因此下面的代码会导致ReferenceError错误:

bar(); // 失败
console.log(a, b, c); // 三个全都失败

函数作用域

如果函数不需要函数名(或者至少函数名可以不污染所在作用域),并且能够自动运行,这将会更加理想。

JavaScript提供了函数表达式来解决上面的问题。

var a = 2;
(function foo() {
  // <-- 添加这一行
  var a = 3;
  console.log(a); // 3
})(); // <-- 以及这一行
console.log(a); // 2

在上面的代码中,foo被绑定在函数表达式自身的函数中而不是所在作用域中。

(function foo(){ .. })作为函数表达式意味着foo只能在..所代表的位置中被访问,外部作用域则不行。foo变量名被隐藏在自身中意味着不会非必要地污染外部作用域。

块作用域

块作用域是一个用来对之前的最小授权原则进行扩展的工具,将代码从在函数中隐藏信息扩展为在块中隐藏信息。

var foo = true;

if (foo) {
  var bar = foo * 2;
  bar = something(bar);
  console.log(bar);
}  

bar变量仅在if声明的上下文中使用,因此如果能将它声明在if块内部中会是一个很有意义的事情。但是,当使用var声明变量时,它写在哪里都是一样的,因为它们最终都会属于外部作用域。这段代码是为了风格更易读而伪装出的形式上的块作用域,如果使用这种形式,要确保没在作用域其他地方意外地使用bar只能依靠自觉性。

作用域闭包

当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。

function foo() {
  var a = 2;

  function bar() {
    console.log(a);
  }

  return bar;
}

var baz = foo();
baz(); // 2
// 这就是闭包的效果。

函数bar()的词法作用域能够访问foo()的内部作用域。然后我们将bar()函数本身当作一个值类型进行传递。

在foo()执行后,其返回值(也就是内部的bar()函数)赋值给变量baz并调用baz(),实际上只是通过不同的标识符引用调用了内部的函数bar()。

来释放不再使用的内存空间。由于看上去foo()的内容不会再被使。拜bar()所声明的位置所赐,它拥有涵盖foo()内部作用域的闭包,使得该作用域能够一直存活,以供bar()在之后任何时间进行引用。

bar()依然持有对该作用域的引用,而这个引用就叫作闭包。

循环和闭包

要说明闭包,for循环是最常见的例子。

举一个常见的例子,我们希望实现一个功能:出数字1~5,每秒一次,每次一个。然后我们写了下面的代码:

for (var i = 1; i <= 5; i++) {
  setTimeout(function timer() {
    console.log(i);
  }, i * 1000);
}

实际打印之后,发现,输出的都是6。

1-3.png

原因就是:这个循环的终止条件是i不再<=5。条件首次成立时i的值是6。因此,输出显示的是循环结束时i的最终值。

想要实现预期的结果,还得这么办:使用IIFE,IIFE会通过声明并立即执行一个函数来创建作用域。它需要有自己的变量,用来在每个迭代中储存i的值。

代码重新编写一下:

for (var i = 1; i <= 5; i++) {
  (function (j) {
    setTimeout(function timer() {
      console.log(j);
    }, j * 1000);
  })(i);
}

这时候再打印就能得到想要的结果了:

1-4.png

总结

我们来总结一下本篇的主要内容:

  • 作用域是一套规则,用于确定在何处以及如何查找变量(标识符)。如果查找的目的是对变量进行赋值,那么就会使用LHS查询;如果目的是获取变量的值,就会使用RHS查询。
  • 赋值操作符会导致LHS查询。=操作符或调用函数时传入参数的操作都会导致关联作用域的赋值操作。
  • 词法作用域意味着作用域是由书写代码时函数声明的位置来决定的。编译的词法分析阶段基本能够知道全部标识符在哪里以及是如何声明的,从而能够预测在执行过程中如何对它们进行查找。
  • 函数是JavaScript中最常见的作用域单元。本质上,声明在一个函数内部的变量或函数会在所处的作用域中“隐藏”起来,这是有意为之的良好软件的设计原则。
  • 当函数可以记住并访问所在的词法作用域,即使函数是在当前词法作用域之外执行,这时就产生了闭包。
  • 模块有两个主要特征:(1)为创建内部作用域而调用了一个包装函数;(2)包装函数的返回值必须至少包括一个对内部函数的引用,这样就会创建涵盖整个包装函数内部作用域的闭包。

作者介绍
非职业「传道授业解惑」的开发者叶一一。
《趣学前端》、《CSS畅想》等系列作者。华夏美食、国漫、古风重度爱好者,刑侦、无限流小说初级玩家。
如果看完文章有所收获,欢迎点赞👍 | 收藏️ | 留言📝

【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息, 否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。