1. 编译原理
传统的编程语言,在程序运行之前都需要进行编译,主要分为三个步骤:
第一阶段:
分词/词法分析(Tokenizing/Lexing)
这个过程会将由字符组成的字符串分解成有意义的代码块,这些代码块被称为词法单元(token)。例如,考虑程序
var a = 2;
。这段程序通常会被分解成为下面这些词法单元:var
、a
、=
、2
、;
。空格是否会被当作词法单元,取决于空格是否有意义。
第二阶段:
解析/语法分析(Parsing)
将词法单元流(数组)转换成一个由元素逐级嵌套所组成的代表了程序语法结构的树,即“抽象语法树”(Abstract Syntax Tree,AST)。
第三阶段:
解析/语法分析(Parsing)
将 AST 转换为可执行代码的过程称被称为代码生成。简单来说就是将
var a = 2;
的 AST 转化为一组机器指令,用来创建一个叫作 a 的变量(包括分配内存等),并将一个值储存在 a 中。
JavaScript 在语法分析和代码生成阶段还会有特定的步骤来对运行性能进行优化,包括对冗余元素进行优化等。对于 JavaScript 来说,大部分情况下编译发生在代码执行前的几微秒(甚至更短)的时间内,基本上就是程序编译好后就会立马执行。
2. 词法作用域
作用域主要有两种工作模型:动态作用域和词法作用域。
动态作用域:定义在运行时阶段的作用域。JavaScript 并不具有动态作用域,只有词法作用域。但是 JavaScript 中的 this
机制比较类似词法作用域。
词法作用域:定义在词法阶段的作用域,例如下方的 foo
函数,在定义时已经确定好了它的作用域(当前函数作用域及全局作用域),而不是在 bar
执行时才确定好作用域,否则结果就是 3 而不是 2 了。
function foo() {
console.log(a); // 2
}
function bar() {
var a = 3;
foo();
}
var a = 2;
bar();
两者的主要区别就是,词法作用域是在定义时确定的,而动态作用域是在运行时确定的。词法作用域关注函数在何处声明,而动态作用域关注函数从何处调用。
3. 函数作用域
JavaScript 具有函数作用域,函数作用域是指属于这个函数的全部变量都可以在整个函数的范围内使用及复用(嵌套作用域也可以使用)。
3.1 作用一:隐藏内部实现
如果变量和函数都写在全局作用域中,在所有的内部的作用域都可以访问得到它们。暴漏过多的变量或函数可能被有意或无意地以非预期的方式使用,如下:
function doSomething(a) {
b = a + doSomethingElse(a * 2);
console.log(b * 3);
}
function doSomethingElse(a) {
return a - 1;
}
var b;
doSomething(2); // 15
变量 b
本身应该是 doSomething
内部的私有变量,然而暴漏在全局作用域中,很有可能会被其他程序修改,从而产生非预期的结果。
而下面程序的设计会更加的合理,可以避免此类问题的发生,将私有的具体内容隐藏在函数内部,从而无法被外界访问,设计良好的软件都会 依此进行实现。
function doSomething(a) {
function doSomethingElse(a) {
return a - 1;
}
var b;
b = a + doSomethingElse(a * 2);
console.log(b * 3);
}
doSomething(2); // 15
这种基于作用域的隐藏方法,叫做最小特权/授权/暴露原则。这个原则是指在软件设计中,应该最小限度地暴露必 要内容,而将其他内容都“隐藏”起来,比如某个模块或对象的 API 设计。
3.2 作用二:规避冲突
“隐藏”作用域中的变量和函数所带来的另一个好处,是可以避免同名标识符之间的冲突。下面就是因为同意作用域命名冲突导致无限循环的问题。
function foo() {
function bar(a) {
i = 3; // 修改for循环所属作用域中的i
console.log(a + i);
}
for (var i = 0; i < 10; i++) {
bar(i * 2); // 糟糕,无限循环了!
}
}
foo();
3.2.1 全局命名空间
变量冲突的一个典型例子存在于全局作用域中。当程序中加载了多个第三方库时,如果它 们没有妥善地将内部私有的函数或变量隐藏起来,就会很容易引发冲突。
这些库通常会在全局作用域中声明一个名字足够独特的变量,通常是一个对象。这个对象被用作库的命名空间,所有需要暴露给外界的功能都会成为这个对象(命名空间)的属性,而不是将自己的标识符暴漏在顶级的词法作用域中。
const MyReallyCoolLibrary = {
awesome: "stuff",
doSomething: function () {
// ...
},
doAnotherThing: function () {
// ...
},
};
3.2.2 模块管理
另外一种避免冲突的办法和现代的模块机制很接近,就是从众多模块管理器中挑选一个来 使用。使用这些工具,任何库都无需将标识符加入到全局作用域中,而是通过依赖管理器 的机制将库的标识符显式地导入到另外一个特定的作用域中。
3.3 函数声明和函数表达式
区分函数声明和表达式最简单的方法是看 function
关键字出现在声明中的位 置(不仅仅是一行代码,而是整个声明中的位置)。如果 function
是声明中的第一个词,那么就是一个函数声明,否则就是一个函数表达式。
// 函数声明
function wscat(type){
return type==="wscat";
}
// 函数表达式
var oaoafly = function(type){
return type==="oaoafly";
}
两者还有一个重要的区别就是两者变量的提升优先级不一样,具体可参考 JavaScript 提升 。
3.4 匿名函数和具名函数
带有名称标识符的函数就是具名函数,最常见的就是函数声明。相反没有名称标识符的函数就是匿名函数,比较熟悉的场景就是回调函数:
setTimeout(function() {
console.log("I waited 1 second!");
}, 1000);
匿名函数比较简单快捷,但是有个缺点也需要注意:
- 匿名函数在栈追踪中不会显示出有意义的函数名,使得调试很困难。
- 如果没有函数名,当函数需要引用自身时只能使用已经过期的
arguments.callee
引用, 比如在递归中。另一个函数需要引用自身的例子,是在事件触发后事件监听器需要解绑自身。 - 匿名函数省略了对于代码可读性/可理解性很重要的函数名。一个描述性的名称可以让代码不言自明。
3.5 立即执行函数表达式(IIFE)
IIFE 的全称:Immediately Invoked Function Expression
如下,函数被包含在一对 () 括号内部,形成了一个表达式,然后通过在末尾添加另外一个 () 就可以立即执行这个函数。也就是说第一个括号将函数变为表达式,第二个括号则是执行了这个函数。这种常见的模式就是立即执行函数表达式。
// 第一种方式
(function foo() {
var a = 3;
console.log(a);
})();
// 第二种方式
(function foo() {
var a = 3;
console.log(a);
}());
// 进阶用法,传递参数
var a = 2;
(function IIFE(global) {
var a = 3;
console.log(a); // 3
console.log(global.a); // 2
})(window);
IIFE 有以下的应用场景:
3.5.1 解决作用域变量被覆盖的问题
如下,期望的是 1s 后依次打印出 0, 1, 2, 3, 4,但是真正的结果是打印了 5 次 5。这是因为 setTimeout
是异步执行的,不会立即执行。当执行到 setTimeout
时,会将回调函数放到任务队列里面。当主线程上的同步任务执行完成之后,这时候 i 的值变成了 5,然后开始执行任务队列里面的函数,这五个函数共享在一个作用域里面,并且调用的是同一个 i,这时放入执行栈中执行,5 个函数依次执行,就打印了 5 个 5。
for (var i = 0; i < 5; i++) {
setTimeout(() => {
console.log(i); // 打印 5 次 5
}, 1000);
}
如果想解决此类问题,思路就是让每个函数都拥有一个自己作用域的 i。
通过 IIFE,可以解决这个问题。在迭代内使用 IIFE 会为每个迭代都生成一个新的作用域,使得延迟函数的回调可以将新的作用域封闭在每个迭代内部,每个迭代中都会含有一个具有正确值的变量供我们访问。
IIFE 其实并不属于闭包的范畴。因为函数没有在本身的词法作用域意外执行。
for (var i = 0; i < 5; i++) {
((i) => {
setTimeout(() => {
console.log(i); // 依次打印 0, 1, 2, 3, 4
}, 1000);
})(i);
}
3.5.2 倒置代码的运行顺序
将需要运行的函数放在第二位,在 IIFE 执行之后当作参数传递进去。
函数表达式 def 定义在片段的第二部分,然后当作参数(这个参数也叫作 def)被传递进 IIFE 函数定义的第一部分中。最后,参数 def(也就是传递进去的函数)被调用,并将 window 传入当作 global 参数的值。
var a = 2;
(function IIFE(def) {
def(window);
})(function def(global) {
var a = 3;
console.log(a); // 3
console.log(global.a); // 2
});
4. 块作用域
块级作用域就是包含在 {...}
中的作用域。在这个作用域中,拥有着和函数作用域相同的行为。
块作用域可以让变量的声明距离使用的地方越近,并最大限度地本地化。
4.1 ES6 之前的块级作用域
很多人会认为 JavaScript 的块级作用域是在 ES6 引入的,ES5 之前没有块级作用域,其实不然。
4.1.1 with
用 with
从对象中创建出的作用域仅在 with
声明中而非外部作用域中有效。
function m(obj) {
with(obj) {
a = 2;
console.log(a); // 2
}
console.log(obj); // {}
console.log(obj.a); // undefined
}
var obj = {};
m(obj);
4.1.2 try/catch
在 ES3 中引入的 try/catch
结构中,catch 分局就具有块作用域。如下代码:
try {
undefined(); // 执行一个非法操作来强制制造一个异常
} catch (err) {
console.log(err); // 能够正常执行!
}
console.log(err); // ReferenceError: err not found
4.2 ES6 的块级作用域
4.2.1 let
let
关键字可以将变量变动到所在任意作用域(通常是 {..}
内部),换句话说,let
为其声明的变量隐式的劫持了所在的作用域。
var a = true;
if (a) {
let b = a * 2;
b = func(b);
console.log(b); // 3
}
function func(b) {
return b + 1;
}
console.log(b); // ReferenceError
4.2.2 const
除了 let
以外,ES6 还引入了 const
,同样可以用来创建块级作用域变量,但其值是固定的(常量)。
4.3 块作用域的好处
4.3.1 防止内层变量会覆盖外层变量
var tmp = new Date();
function f() {
console.log(tmp); // undefined
if (false) {
var tmp = 'hello world';
}
}
f();
上面代码的原意是,if 代码块的外部使用外层的 tmp 变量,内部使用内层的 tmp 变量。但是,函数执行后,输出结果为 undefined,原因在于变量提升,导致内层的 tmp 变量覆盖了外层的 tmp 变量。
4.3.2 let 循环
for (let i = 0; i < 5; i++) {
console.log(i);
}
console.log(i);
for 循环头部的 let 不仅将 i 绑定到 for 循环的块中,事实上它将其重新绑定到了循环的每一个迭代里面,确保使用上一个循环迭代结束时的值重新进行赋值。
4.3.3 垃圾收集
function func(obj) {
// doSomething
}
var obj = {...};
func(obj);
var bnt = document.getElementById('xxx');
bnt.addEventListener('click', function() {
// doSomething
});
在上述代码中,点击元素,触发 click 事件,在这里并不需要 obj 对象,理论上,当 func 执行后,在内存中 obj 就会被垃圾回收机制回收,但是 click 函数形成了一个覆盖整个作用域的闭包。JavaScript 引擎极有可能依然保持这个结构,而不进行回收。
function func(obj) {
// doSomething
}
{
let obj = {...};
func(obj);
}
var bnt = document.getElementById('xxx');
bnt.addEventListener('click', function() {
// doSomething
});
块级作用域可以让引擎清楚的理解到没有必要保持的内存,让垃圾回收机制进行回收。
5. 闭包(Closure)
当函数可以记住并访问所在的词法作用域,就产生了闭包,即函数是在当前词法作用域之外执行。
如下代码,在 foo
执行后,其返回值(即 bar
函数)赋值给变量 baz
并调用,实际上只是通过不同的标识符引用调用了内部的函数 bar
。 函数 baz
可以访问 foo
内部的作用域,即在自己定义的词法作用域以外的地方执行,形成闭包。
function foo() {
var a = 2;
function bar() {
console.log(a);
}
return bar;
}
var baz = foo(); // 闭包
baz(); // 2
在 foo
执行后,通常会期待 foo
的整个内部作用域都被销毁,引擎有垃圾回收器用来释放不再使用的内存空间。由于看上去 foo
的内容不会再被使用,所以正常情况下会考虑对其进行回收。而闭包的存在则会阻止垃圾回收器对 foo
的回收。这是因为 bar
函数仍然在使用 foo
内部的作用域,其拥有涵盖 foo
内部作用域的闭包,使得该作用域能够一直存活,以供 bar
在之后任何时间进行引用。
bar
依然持有对 foo
作用域的引用,而这个引用就叫作闭包。
function foo() {
var a = 2;
function baz() {
console.log(a); // 2
}
bar(baz);
}
function bar(fn) {
fn(); // 闭包
}
在定时器、事件监听器、 Ajax 请求、跨窗口通信、Web Workers 或者任何其他的异步(或者同步)任务中,只要使用了回调函数,实际上就是在使用闭包。