这是《你不知道的JavaScript》的第一部分。

本系列持续更新中,Github 地址请查阅这里

写在前面

对于一个前端开发者,应该没有不知道作用域的。它是一个既简单有复杂的概念,简单到每行代码都有它的影子,复杂到写过很多的代码依然不一定能完全理解。

最近在看《你不知道的JavaScript》,看完之后不写点什么,好像看的意义就不大了。今天就花点时间,从最简单又复杂的作用域开始缕一缕。

先来份关于《你不知道的JavaScript》第一部分作用域和闭包的目录感受一下:

  • 第1章 作用域是什么
  • 第2章 词法作用域
  • 第3章 函数作用域和块作用域
  • 第4章 提升
  • 第5章 作用域闭包

真正开始写的时候发现好像并没有什么好写了,因为这并不是很难的概念,但是要问自己闭包到底是什么,到底有什么用,什么时候场景用的时候,好像又有点模糊了?闭包这个经常写的代码,居然好像并没有彻底地理解。

开始之前

先问自己几个问题

  • 你真的理解了作用域吗?
  • 什么是动态作用域?什么是词法作用域?
  • 闭包到底是什么?闭包的应用场景有哪些?
  • 怎么实现一个简单的模块依赖加载器?
  • JS是一门解释型语言,它的编译过程不是发生在构建之前,那么声明提升为什么会发生呢?

如果这些你都知道的话,或许这篇内容并不是很需要看了,如果还有一点困惑,希望它能对你有点帮助。

作用域是什么

在讲作用域之前,我们先来看看什么是编译。在传统编译语言的流程中,编译分为3步:

  1. 分词/词法分析:将由字符组成的字符串分解成有意义的代码块(词法单元)
  2. 解析/语法分析:将词法单元流转换成一个由元素逐级嵌套所组成的代表程序语法机构的树(抽象语法树,AST)
  3. 代码生成:将AST转换成可执行代码

JS是一门解释型语言,它的编译过程不是发生在构建之前,大部分情况下编译发生在代码执行前的几微秒(甚至更短)的时间内,所以在作用域的背后,JS引擎用尽了各种办法来保证性能最佳。

简单了解了编译后,我们再来看看作用域是什么:

作用域负责收集并维护有所有声明的标识符(变量)组成的一系列查询,并实施一套非常严格的规则,确定当前执行的代码对这些标识符的访问权限

上面的话不是很好理解,我们来分解一下下面的代码:

var a = 2;
var b = a;

熟悉JS的同学都知道,包括变量和函数在内的所有声明都会有一个提升的过程,即首先被处理,但是赋值不会提升。所以上面的代码会被如下处理:

var a;
var b;

a = 2;
b = a;
  1. 当编译器遇到var a时,编译器会询问作用域是否存在名称为a的变量。如果存在,则忽略声明,继续编译;如果不存在,则声明一个新变量,命名a。(var b;同理)
  2. 当遇到a = 2时,引擎会询问作用域,当前作用域中是否存在该变量,如果找不到,就向上一级查找,当抵达最外层的全局作用域时,无论找到还是没找到,查找都会停止,找到了就赋值2给它,没找到就抛出异常。

其中,a = 2这一过程中对a的查找被称为LHS查询,在b = a这一句中,引擎会查找变量a的值和变量b的容器,并将a的值赋值给b,这一查找变量a的值被称为RHS查询。

词法作用域

作用域有两种工作模式,一种词法作用域,一种动态作用域。词法作用域(也叫静态作用域)就是在词法阶段的作用域,词法分析阶段就确定了,不会改变。JS采用的就是词法作用域,但是可以通过一些欺骗词法作用域的方法,在词法分析过后依然可以修改作用域。

词法作用域与动态作用域

我们先来看看词法作用域与动态作用域的区别(因为JS采用的是词法作用域,所以对动态作用域不做过多介绍):

var a = 2;

function foo () {
  console.log(a); // 会输出2还是3?
}

function bar () {
  var a = 3;
  foo();
}

bar();

熟悉JS的同学应该都知道通过RHS引用到了全局作用域中的a,所以输出2。但是如果JS是动态作用域,情况就不一样了,当foo()无法找到a的变量引用时,会在调用foo()的地方查找a,而不是在嵌套的词法作用域上查找,所以会输出3。

下面我们来看看什么是欺骗词法。

欺骗词法作用域

JS有两个机制可以欺骗词法作用域:eval()和with。大多数情况下,它们是不被推荐使用的,因为欺骗词法作用域导致引擎无法在编译时对作用域查找进行优化,所以会导致性能下降;另外,在严格模式下,with被完全禁止使用,间接或非安全的使用eval也被禁止。

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; 导致修改了原本的作用域。

with 通常被当作重复引用同一个对象中的多个属性的快捷方式。

function foo (obj) { 
  with (obj) {
    a = 2; 
  }
}

var o1 = { 
  a: 3
};

var o2 = { 
  b: 3
};

foo(o1);
console.log(o1.a); // 2

foo(o2);
console.log(o2.a); // undefined
console.log(a); // 2——不好,a被泄漏到全局作用域上了!

with 声明实际上是根据你传递给它的对象凭空创建了一个全新的词法作用域

声明提升

前面有个例子说到了声明提升,我们已经知道引擎会在解释 JavaScript 代码之前首先对其进行编译。编译阶段中的一部分工作就是找到所有的声明,并用合适的作用域将它们关联起来,这也正词法作用域的核心内容。声明提升在代码中比较常见,相信面试过的朋友肯定对它非常熟悉了,但是它也有几个必须要注意的点:

  • 每个作用域都会进行提升操作,声明会被提升到所在作用域的顶部(只有声明会被提升,赋值或其他运行逻辑会留在原地)
  • 并非所有的声明都会被提升,不同声明提升的权重也不同,具体来说函数声明会被提升,函数表达式不会被提升(就算是有名称的函数表达式也不会提升),通过var定义的变量会提升,而let和const进行的声明不会提升
  • 函数声明和变量声明都会被提升。但是一个值得注意的细节也就是函数会首先被提升,然后才是变量,也就是说如果一个变量声明和一个函数声明同名,那么就算在语句顺序上变量声明在前,该标识符还是会指向相关函数
  • 如果变量或函数有重复声明以会第一次声明为主,但是后面的函数声明还是可以覆盖前面的

闭包

闭包到底是什么?

  • 闭包就是能够读取其他函数内部变量的函数
  • 内部函数总是可以访问其所在的外部函数中声明的参数和变量,即使在其外部函数被返回(寿命终结)了之后
  • 闭包这个词的意思是封闭,将外部作用域中的局部变量封闭起来的函数对象称为闭包。被封闭起来的变量与封闭它的函数对象有相同的生命周期

关于闭包的解释,网上有很多,《你不知道的JavaScript》的作者也给出了他的定义:

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

看起来不是那么生动的解释,仔细看看好像也不是很难理解,不过,作为一个程序员,代码才是王道

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

函数bar()的词法作用域可以访问foo()的内部作用域,foo()执行之后,bar()依然持有对foo()内部作用域的引用(也就不会被垃圾回收机制回收),bar()对该作用域的这个引用就被叫做闭包。

再来看看下面这段代码,有没有很熟悉的感觉,看过一些面试题的朋友应该都不会陌生吧,答案是每隔一秒的频率输出五次6

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

浏览器运行机制,任务队列之类的我们就不讨论了,我们来看看怎么改进,从闭包的角度出现让它输出1~5

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

上面的代码可以吗?是的,不行,我们只是封闭了什么都没有的空作用域中,依然会向上查找全局的i。怎么实现?写了这么多,我的任务完成了,轮到你动一下脑瓜子了。

模块依赖加载器

require(['a', 'b'], callback)这样的模块加载方式有没有勾起你老人家什么回忆呢?作为一个年轻人,ES6的import大法还是比较适合我,不过前辈当时的先进经验还是有很多可以学习的地方的,直接贴代码了

var MyModules = (function Manager () {
  var modules = {};

  function define (name, deps, impl) {
    for (var i = 0; i < deps.length; i++) {
      deps[i] = modules[deps[i]];
    }
    modules[name] = impl.apply(impl, deps);
  }

  function get (name) {
    return modules[name];
  }
  return {
    define: define,
    get: get
  }
})();

上面实现了一个简单的模块加载器,下面是使用它来定义模块

MyModules.define("bar", [], function () {
  function hello (who) {
    return "Let me introduce: " + who;
  }
  return {
    hello: hello
  };
});

MyModules.define("foo", ["bar"], function (bar) {
  var hungry = "hippo";

  function awesome () {
    console.log(bar.hello(hungry).toUpperCase())
  }
  return {
    awesome: awesome
  };
});

var bar = MyModules.get("bar");
var foo = MyModules.get("foo");

console.log(bar.hello("hippo")); // Let me introduce: hippo 
foo.awesome(); // LET ME INTRODUCE: HIPPO

写在最后

不知不觉,篇幅已经不短,怎么总结的更精炼确实是一个技术活,我得好好学学才行。本篇主要从编译、词法作用域、声明提升的角度对JS的作用域进行了介绍,并慢慢打开了闭包的大门,最后展示了一个简单的模块加载器的代码。

关于《你不知道的JavaScript》的第一部分——作用域和闭包已经结束了,但是,更新不会就此止住

未完待续...


Lemo_Liu
307 声望11 粉丝

The road ahead will be long and our climb will be steep.