重读《你不知道的JavaScript》(上)- 作用域和闭包

作用域是什么

理解作用域

作用域是根据名称查找变量的一套规则。

当我们为变量赋值时,当变量出现在赋值操作的左侧时引擎会进行 LHS 查询,出现在右侧时会进行 RHS 查询。

当然变量的查询并不仅仅出现在赋值操作的时候。

例如下面这段代码既有 LHS 查询,也有 RHS 查询:

javascript 复制代码
function foo(a) {
  console.log(a); // 2
}
foo(2);
  1. 当调用 foo 时,会对 foo 进行一次 RHS 查询,找到 foo 的值,为一个函数
  2. 当给 foo 传递一个参数 2 时,为了给 形参 a 赋值,会对形参 a 进行一次 LHS 查询
  3. 最后打印 a 的时候,会对 a 进行一次 RHS 查询,找到 foo 内部的 a 变量,并打印 2

作用域嵌套

当一个块或函数嵌套在另一个块或函数中时,就发生了作用域的嵌套,因此,在当前作用域中无法找到某个变量时,引擎就会在外层嵌套的作用域中继续查找,直到找到该变量,知道找到最外层的作用域(也就是全局作用域)为止。

引用书中的一张图:

异常

为什么要区分 RHSLHS 查询?

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

  1. RHS 查询:如果变量还没有声明,那么会抛出 ReferenceError 异常。
  2. LHS 查询:如果在顶层(全局作用域)中也无法找到目标变量,全局作用域中就会创建一个具有该名称的变量,并将其返还给引擎,前提是程序运行在非"严格模式"下。

ES5 中引入了严格模式,在严格模式下,LHS 查询失败时也会抛出 ReferenceError 异常。

如果 RHS 查询找到了一个变量,但是对其做了不合理的操作,比如对一个非函数类型的值做了函数调用,或者引用 nullundefined 类型值中的属性时,则会抛出一个 TypeError 异常。

ReferenceError 同作用域判别失败相关,而 TypeError 则代表作用域判别成功了,但是对结果的操作是非法或不合理的。

词法作用域

JavaScript 所采用的作用域模型是词法作用域模型。

词法阶段

大部分标准语言编译器的第一个工作阶段叫作词法化(也叫单词化),词法化的过程会对源代码中的字符进行检查,如果是有状态的解析过程,还会赋予单词语义。

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

换句话说,词法作用域是由你在写代码时将变量和块作用域写在哪里来决定的,因此当词法分析器处理代码时会保持作用域不变。

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

上述代码中有三个作用域:

  1. 全局作用域,其中只有一个标识符:foo
  2. foo 所创建的作用域,其中有三个标识符:abbar
  3. bar 所创建的作用域,只有个一个标识符:c

作用域是严格包含的,换句话说,没有任何函数的作用域可以同时出现在两个外部作用域中。

作用域查找始终从运行时所处的最内部作用域开始,逐级向外或者说向上进行,直到遇见 第一个匹配的标识符为止。

无论函数在哪里被调用,也无论它如何被调用,它的词法作用域都只由函数被声明时所处的位置决定。

欺骗词法

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

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

但需要注意的是:欺骗词法作用域会导致性能下降。

eval

javascript 复制代码
function foo(str, a) {
  eval(str); // 欺骗!
  console.log(a, b);
}
var b = 2;
foo('var b = 3;', 1); // 1, 3

这段代码会在 foo 内部创建了一个变量 b,当 console.log 被执行时,会在 foo 的内部同时找到 ab,但是永远也无法找到外部的 b。因此会输出 1, 3, 而不是 1, 2

在严格模式中,eval 在运行时有其自己的词法作用域,意味着其中的声明无法修改所在的作用域。上面的代码会输出 1, 2

setTimeoutsetInterval 的第一个参数也可以是字符串,字符串的内容可以被解释为一段动态生成的函数代码。另外,new Function 函数也可以达到这种功能。

with

javascript 复制代码
function foo(obj) {
  with (obj) {
    a = 2;
  }
}
let o1 = {
  a: 3,
};
let o2 = {
  b: 3,
};
foo(o1);
console.log(o1.a); // 2
foo(o2);
console.log(o2.a); // undefined
console.log(a); // 2------不好,a被泄漏到全局作用域上了!

这个例子中创建了 o1o2 两个对象。其中一个具有 a 属性,另外一个没有。foo 函数接受一个 obj 参数,该参数是一个对象引用,并对这个对象引用执行了 with(obj) {..} 语法。当我们将 o1 传递进去,a = 2 赋值操作找到了 o1.a 并将 2 赋值给它,而当 o2 传递进去,o2 并没有 a 属性,因此不会创建这个属性,o2.a 保持 undefined

但是可以注意到一个奇怪的副作用,a = 2 赋值操作创建了一个全局的变量 a

with 可以将一个没有或有多个属性的对象处理为一个完全隔离的词法作用域,因此这个对象的属性也会被处理为定义在这个作用域中的词法标识符。

可以这样理解,当我们传递 o1with 时,with 所声明的作用域是 o1,而这个作用域中含有一个同 o1.a 属性相符的标识符。但当我们将 o2 作为作用域时,其中并没有 a 标识符,因此进行了正常的 LHS 标识符查找

性能

JavaScript 引擎会在编译阶段进行数项的性能优化。其中有些优化依赖于能够根据代码的词法进行静态分析,并预先确定所有变量和函数的定义位置,才能在执行过程中快速找到标识符。

但如果引擎在代码中发现了 evalwith,它只能简单地假设关于标识符位置的判断都是无效的,因为无法在词法分析阶段明确知道 eval 会接收到什么代码,这些代码会如何对作用域进行修改,也无法知道传递给 with 用来创建新词法作用域的对象的内容到底是什么,因此最简单的做法就是完全不做任何优化。

结论就是:如果代码中大量使用 evalwith,那么运行起来一定会变得非常慢。

词法作用域和快作用域

函数中的作用域

函数作用域的含义是指,属于这个函数的全部变量都可以在整个函数的范围内使用及复用(事实上在嵌套的作用域中也可以使用),而无法从外层作用域中访问。

隐藏内部实现

从所写的代码中挑选出一个任意的片段,然后用函数声明对它进行包装,实际上就是把这些代码"隐藏"起来了。

也就是说这段代码中的任何声明(变量或函数)都将绑定在这个新创建的包装函数的作用域中,而不是先前所在的作用域中。

换句话说,可以把变量和函数包裹在一个函数的作用域中,然后用这个作用域来"隐藏"它们。

为什么"隐藏"变量和函数是一个有用的技术?

最小特权原则

javascript 复制代码
function doSomething(a) {
  b = a + doSomethingElse(a * 2);
  console.log(b * 3);
}
function doSomethingElse(a) {
  return a - 1;
}
var b;
doSomething(2); // 15

变量 b 和函数 doSomethingElse 应该是 doSomething 内部具体实现的"私有"内容。给予外部作用域对 bdoSomethingElse的"访问权限"不仅没有必要,而且可能是"危险"的,因为它们可能被有意或无意地以非预期的方式使用,从而导致超出了 doSomething 的控制范围。

规避冲突

"隐藏"作用域中的变量和函数所带来的另一个好处,是可以避免同名标识符之间的冲突, 两个标识符可能具有相同的名字但用途却不一样,无意间可能造成命名冲突。

函数作用域

虽然使用函数将变量和函数包裹起来可以使其隐藏,但是会导致一些额外的问题。首先,必须声明一个具名函数 foo,意味着 foo 这个名称本身"污染"了所在作用域。其次,必须显式地通过函数名 foo 调用这个函数才能运行其中的代码。

JavaScript 提供了能够同时解决这两个问题的方案:

IIFE(Immediately Invoked Function Expression)立即执行函数:

javascript 复制代码
var a = 2;

(function foo() {
  var a = 3;
  console.log(a); // 3
})();

console.log(a); // 2

此时,foo 被绑定在函数表达式自身的函数中而不是所在作用域中,因此外部作用域访问不到它。

当然 IIFE 也可传递参数进去。

快作用域

with

with 从对象中创建出的作用域仅在 with 声明中而非外部作用域中有效。

try/catch

try/catchcatch 分句会创建一个块作用域,其中声明的变量仅在 catch 内部有效。

javascript 复制代码
try {
  undefined(); // 执行一个非法操作来强制制造一个异常
} catch (err) {
  console.log(err); // 能够正常执行!
}
console.log(err); // ReferenceError: err not found

let

let 关键字可以将变量绑定到所在的任意作用域中(通常是 { } 内部,在 for 循环中也可以)。

并且使用 let 进行的声明不会在块作用域中进行提升。声明的代码被运行之前,声明并不"存在"。

另一个块作用域非常有用的原因和闭包及回收内存垃圾的回收机制相关:

javascript 复制代码
function process(data) {
  // 在这里做点有趣的事情
}
var someReallyBigData = {};
process(someReallyBigData);

var btn = document.getElementById('my_button');
btn.addEventListener(
  'click',
  function () {
    console.log('button clicked');
  },
  false,
);

click 函数的点击回调并不需要 someReallyBigData 变量。理论上这意味着当 process 执行后,在内存中占用大量空间的数据结构就可以被垃圾回收了。但是,由于 click 函数形成了一个覆盖整个作用域的闭包,JavaScript 引擎极有可能依然保存着这个结构(取决于具体实现)。

但是使用 let 关键字可以避免这种情况:

javascript 复制代码
function process(data) {
  // 在这里做点有趣的事情
}
{
  let someReallyBigData = {};
  process(someReallyBigData);
}

var btn = document.getElementById('my_button');
btn.addEventListener(
  'click',
  function () {
    console.log('button clicked');
  },
  false,
);

提升

针对 var a = 2 这一句代码, JavaScript 引擎会将 var aa = 2 当作两个单独的声明,第一个是编译阶段的任务,而第二个则是执行阶段的任务。

这意味着无论作用域中的声明出现在什么地方,都将在代码本身被执行前首先进行处理。 可以将这个过程形象地想象成所有的声明(变量和函数)都会被"移动"到各自作用域的 最顶端,这个过程被称为提升。

并且函数是优先的,即如果函数和变量同名,则函数将会覆盖变量。

作用域闭包

定义

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

javascript 复制代码
function foo() {
  var a = 2;
  function bar() {
    console.log(a);
  }
  return bar;
}

var baz = foo();
baz(); // 2   -- 闭包

在这个例子中,bar 在自己定义的词法作用域以外的地方被执行。在 foo 执行后,通常会认为 foo 的整个内部作用域都被销毁,因为我们知道引擎有垃圾回收器用来释放不再使用的内存空间。

而闭包的"神奇"之处正是可以阻止这件事情的发生。事实上内部作用域依然存在,因此 没有被回收。

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

当一个函数在定义时的词法作用域以外的地方被调用。闭包可以使得函数可以继续访问定义时的词法作用域。

javascript 复制代码
function foo() {
  var a = 2;
  function baz() {
    console.log(a); // 2
  }
  bar(baz);
}
function bar(fn) {
  fn(); // baz 在 bar 的作用域下被调用了, 依然可以访问 foo 的作用域中的变量 a

模块

模块有两个主要特征:

  1. 为创建内部作用域而调用了一个包装函数;
  2. 包装函数的返回值必须至少包括一个对内部函数的引用,这样就会创建涵盖整个包装函数内部作用域的闭包。
javascript 复制代码
var foo = (function CoolModule(id) {
  function change() {
    // 修改公共API
    publicAPI.identify = identify2;
    function identify1() {
      console.log(id);
      function identify2() {
        console.log(id.toUpperCase());
      }
    }
  }
  var publicAPI = {
    change: change,
    identify: identify1,
  };
  return publicAPI;
})('foo module');
foo.identify(); // foo module
foo.change();
foo.identify(); // FOO MODULE

通过在模块实例的内部保留对公共 API 对象的内部引用,可以从内部对模块实例进行修改,包括添加或删除方法和属性,以及修改它们的值。

相关推荐
小小小小宇5 小时前
TS泛型笔记
前端
小小小小宇6 小时前
前端canvas手动实现复杂动画示例
前端
小小小小宇6 小时前
前端PerformanceObserver使用
前端
zhangxingchao7 小时前
Flutter中的页面跳转
前端
前端风云志7 小时前
TypeScript实用类型之Omit
javascript
烛阴7 小时前
Puppeteer入门指南:掌控浏览器,开启自动化新时代
前端·javascript
全宝8 小时前
🖲️一行代码实现鼠标换肤
前端·css·html
小小小小宇9 小时前
前端模拟一个setTimeout
前端
萌萌哒草头将军9 小时前
🚀🚀🚀 不要只知道 Vite 了,可以看看 Farm ,Rust 编写的快速且一致的打包工具
前端·vue.js·react.js