这本书是翻译的Kyle Simpson的 《You Don't Know JS》 上半部分。主要包括:作用域和闭包、this和对象原型。
编译型语言在编译过程中生成目标平台的指令,解释型语言在运行过程中才生成目标平台的指令。
首先我们需要理清的是,尽管js归类为动态解释型语言,但其具备编译特性。并不只是js这一种动态语言这样。应该是说,现在关于解释和编译的界限越来越不清晰了。一般将从源代码到生成可执行代码的编译动作分为如下三步:
js编译并不会提前编译。在运行前很短的时间内才进行编译。比如v8引擎就将代码编译成机器码然后再执行。而 作用域 维护所有声明的标识符组成的一系列查询(where)和确定这些标识符访问权限的规则(how)。比如这句代码:var a = 2;,在js引擎中奖范围两个完全不同的声明:一个由编译器在编译时处理,一个由引擎在执行时处理。首先编译器在当前作用域 声明 一个变量,然后引擎在运行时询问当前作用域,如果能找到就会对其赋值。
引擎在执行查找时分为两种查找:LHS和RHS。在赋值操作的左边(目标是谁)就是LHS,在赋值操作的右边(源头是谁)就为RHS。LHS、RHS都会在当前执行作用域进行查询。当一个块或者函数嵌套在另一个块或者函数中时,就形成了作用域链。当引擎在当前作用域无法找到的变量,就会在外层作用域继续查找。直到找到变量或者抵达全局作用域。
再重申一遍,作者将作用域定义为一套规则,来管理引擎如何在作用域链上找到变量。作用域有两种工作模型。一种JS和大多语言采用的 词法作用域 ,一种是Bash脚本采用的 动态作用域 。词法作用域是在定义时确定的,而动态作用域实在运行时确定的。在编译第一步词法阶段中,如果是有状态的解析过程,就会进行词法分析以赋予单词语义。词法作用域就是定义在词法阶段的作用域,因此当词法分析器处理代码时会保证作用域不变(作用域欺骗除外)。比如书中的例子:
作用域1为全局作用域,其中的foo标识符创建了函数作用域2。作用域2中有a、b、bar三个标识符,其中bar又创建了一个作用域3.作用域3中只有c一个标识符。这种结构给引擎提供了足够的位置信息,比如上图中的console.log()执行时,将查找a、b和c3个变量的引用。它依此从作用域3、作用域2中查找a,并在第一个匹配的标识符(作用域2的a)停止。词法作用域查找只会查找一级作用域。如果代码中引用了foo.bar.baz,词法作用域只会试图查找foo标识符。找到变量后,又对象属性访问规则接管对bar属性的访问。
上文提到了欺骗词法来在运行时修改作用域。比如eval、with。eval可以动态执行字符串代码,with在运行时创建一个新的作用域。需要注意的一点是,欺骗作用域会导致性能下降。因为js引擎在编译阶段会继续数项性能优化,其中一些优化将依赖于静态词法。如果代码中有eval、with,这些优化就是无意义的。总之,词法作用域意味着作用域是由书写代码时函数声明的位置来决定的。编译的词法分析阶段基本能够知道全表标识符在哪里以及是如何声明的,从而能够预测在执行过程中如何对他们进行查找。
前面提到的函数作用域通过声明函数创建。而函数作用域的含义是指,属于这个函数的全部变量都可以在整个函数的范围内使用和复用。通过函数声明隐藏内部实现可以最少暴露必要内容和避免同名冲突。JS中有一种不需要函数名(或者说不污染所在作用域),并且能过自动运行来隐藏内部内容的函数,叫作 立即调用的函数表达式 (IIFE, Immediately-Invoked Function Expression)。其通过括号或一些一元运算符来引导解析器,指明运算符附近是一个表达式。这其实也是函数表达式和函数声明的区别。如果function是声明的第一个词,那么就是函数声明,否则是一个函数表达式。比如下代码就是一个函数表达式。
(function(){ console.log("test"); })();
函数表达式和函数声明最大的区别就是他们的名称标识符将会绑定在何处。函数表达式中的标准覅被绑定在表达式自身的函数中而不是所在作用域,而函数声明将标识符绑定在所在作用域。
函数表达式可视是匿名的,而函数声明必须是具名的。虽然在一些库和工具中鼓励这种风格的代码,但是匿名函数表达书还是有几个缺点的。比如没有函数名使得调试困难、引用自身需要通过arguments.callee、缺少代码可读性。所以在IIFE中也是值得使用具名函数的。
尽管函数作用域是最常见的作用域单元,但是其他类型的作用域单元也是存在的。除了JS外,很多语言都支持块作用域,块作用域是用来扩展最小授权原则的工具,将代码从在函数中隐藏信息扩展到在块中隐藏信息。只可惜表面上JS并没有块作用域。而表面之下就有一些块作用域。比如上面提及过的with关键字创造出来的就是块作用域。此外还有try/catch也会在catch中产生块作用域。而ES6引入的let关键字就可以将变量绑定在块作用域(通常是{})内部。而且使用let的变量在块作用域内也不会进行 提升 ,也可以让引擎知道回收块内的数据。除了let,ES6还引入了cons他来创建块作用域变量。
而提升是什么呢?首先看下面这段代码:
console.log(a); var a = 2;
JS引擎会将编译阶段进行变量的声明,而赋值会留在原地等待执行阶段。所以上述代码会以如下形式处理:
var a; console.log(a); // undefined a = 2;
这个将声明“移动”到作用域的开头的过程就叫做 提升 。所以函数声明也会被提升,而函数表达式却不会被提升。
foo(); // a function foo(){ console.log('a'); } bar(); // TypeError 调用undefined的结果 baz(); // ReferenceError var bar = function baz(){ //var bar = ...self... console.log('b'); }
最后当函数声明和变量声明都被提升时,是函数首先被提升,然后是变量。
foo(); // 3 var foo; //重复声明被忽略 function foo(){ console.log('1'); } foo = function(){ //没有第三个函数定义,就输出1而不是2。因为函数首先被提升,然后是变量。 console.log('2'); }; function foo(){ console.log('3'); };
接下来将注意力转移到这门语言非常重要的概念:闭包。
当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。
注意是在函数所在作用域外执行,比如下例:
function foo(){ var a = 2; function bar(){ //被封闭在foo()作用域里,拥有涵盖foo()内部作用域的闭包 console.log(a); } return bar; //返回函数,而不是执行函数 } var baz = foo(); baz(); //2
而闭包的出现会阻止foo()被垃圾回收。事实上内部作用域依然存在,bar()本身在使用。在以后的任何时间内bar()对该作用域的引用,就叫作闭包。在定时器、事件监听、Ajax请求或者其他异步任务中,只要使用了回调函数,实际上就是在使用闭包。无论通过何种手段将内部函数传递到所在的词法作用域以外,它都会持有对原始定义作用域的引用,无论在何处执行这个函数都会使用闭包。
那么在循环中的闭包呢?在循环内部包含函数定义时,会发出警告的。但是下列代码将巫师警告而执行。
for(var i = 1; i <= 5; i++){ setTimeout(function timer(){ console.log(i); }, i * 1000); }
这段代码将每秒输出一个6(即i的最终值)。事实上,当setTimeout(..., 0)也是每次输出5个6(关于setTimeout在node.js中的事件调度可看 《Node.js的事件驱动模型》 )。因为根据作用域的工作原理,它们都封闭在一个共享的全局作用域中,因此实际上只有一个i。如果我们想要获得与语义一致的行为则需要使用IIFE。
for(var i = 1; i <= 5; i++){ (function(){ var j = i; //该作用域下私有变量 setTimeout(function timer(){ console.log(i); }, i * 1000); })(); }
这样也就是说,每次迭代我们都需要一个块作用域。所以使用上ES6的let声明在循环中,随后每个迭代都会使用上一次迭代结束时的变量值。还有其他代码模式使用闭包的强大威力。比如模块化中通过返回API来访问属性变量就是使用闭包的功能。并且可以通过IIFE改进成单例模式:
var foo = (function BarModule(){ var something = 'bar'; function doSomething(){ console.log(something ); }; return { doSomething: doSomething }; })(); foo.doSomething(); // bar
再说说ES6的模块功能。ES6的模块参考了node.js模块(CommonJS规范)。import导入模块到当前作用域,export导出为公共API,module将整个模块的API绑定在一个变量上。