第一部分 作用域和闭包
第 1 章 作用域是什么
传统源码执行前的编译过程会经历三个步骤:
- 分词/词法分析(Tokenizing/Lexing):将字符串分解成有意义的代码块(词法单元token)
- 解析/语法分析(Parsing): 将“词法单元流(数组)”转换成一个由元素逐级嵌套所组成的代表了程序语法结构的“树(AST)”
- 代码生成: 将 AST 转换为可执行代码(一组机器指令)的过程
JS 引擎在语法分析和代码生成阶段有特定的步骤来对运行性能进行优化:
- JS 是动态编译,其编译过程不是发生在构建之前,因此不会有大量时间来进行优化。
JS 编译发生在代码执行前的几微秒,因此 JS引擎 用尽办法(JIT,可以延迟编译甚至重编译)来保证性能最佳
引擎:负责编译及执行过程;
- 编译器:负责语法分析及代码生成;
- 作用域:负责收集并维护标识符的查询及访问权限等。
引擎变量查询:一个赋值操作的左侧和右侧
- LHS:赋值。非严格模式下,如果在顶级作用域中也无法找到目标变量,全局作用域就会创建一个具有该名称的变量,并将其返还给引擎。
- RHS:取值。在遍历所有的作用域后找不到所需的变量引擎会抛出 ReferenceError.
错误类型:
- ReferenceError:作用域判别失败相关。
- TypeError:作用域判别成功了,但是对结果的操作是非法或不合理的。例如对一个非函数类型的值进行函数调用,或者引用null、undefined类型的值中的属性。
遍历嵌套作用域链:引擎从当前的执行作用域开始查找变量,如果找不到,就向上一级继续查找。当抵达最外层的全局作用域时,无论找到还是没找到,查找过程都会停止。
第 2 章 词法作用域
作用域模型:
- 词法作用域:定义在词法阶段的作用域。即词法作用域是由你在写代码时将变量和块作用域写在哪里来决定的,因此当词法分析其处理代码时会保持作用域不变。
- 动态作用域
遮蔽效应:在多层的嵌套作用域中可以定义同名的标识符。作用域查找会在找到第一个匹配的标识符时停止。
词法作用域只由函数被声明时所处的位置决定,与调用的位置无关。
词法作用域查找只会查找一级标识符,找到这个标识符后,”对象属性访问规则“会接管其后的属性访问。
欺骗词法作用域会导致性能下降:
eval(string)
: 通过代码欺骗和假装成书写时(词法期)代码就在那,来实现修改词法作用域。setTimeout(...)
和setInterval(...)
的第一个参数可以时字符串,字符串的内容可以被解释为一段动态生成的函数代码。new Function(str)
: 最后一个参数可以接受代码字符串,并将其转化为动态生成的函数(前面的参数是这个新生成的函数的形参)with(obj){}
: 通常被当作重复引用同一个对象中的多个属性的快捷方式,可以不需要重复引用对象本身。with 将一个对象及其属性放进一个完全隔离的词法作用域并同时分配标识符。创建全新的词法作用域。
function foo(str, a) {
eval(str); // 欺骗!
console.log(a, b);
}
var b = 2;
foo("var b = 3;", 1); // 1, 3
setTimeout('var a=1;console.log("a:", a)',1000) // 1s 后输出a:1
// let func = new Function ([arg1[, arg2[, ...argN]],] functionBody)
let sum = new Function('a', 'b', 'return a + b');
alert( sum(1, 2) ); // 3
function foo(obj) {
with(obj) {
a = 2;
}
}
var o1 = {
a: 3,
}
var o2 = {
b: 3,
}
foo(o1);
console.log(o1.a); // 2
console.log(a) // ReferenceError: a is not defined
foo(o2);
console.log(o2.a); // undefined
console.log(a); // 2, a 被泄露到全局作用域上了
第 13 行代码,当执行with(o1)时,因为 o1 有 a 变量,则直接将 a 重新赋值为 2。
第 17 行代码,当执行with(o2)时,因为 o2 没有 a 变量,会执行 a=2,即创建一个变量 a 并将其赋值为2.
第 3 章 函数作用域和块作用域
函数作用域:属于这个函数的全部变量都可以在整个函数的范围内使用及复用(事实上在嵌套作用域中也可以使用)。这种设计方案是非常有用的,能充分利用JS变量可以根据需要改变值类型的“动态”特性。
最小授权(最小暴露)原则: 在软件设计中,应该最小限度地暴露必要内容,而将其它内容都“隐藏”起来,比如某个模块或对象的API设计。
“隐藏”作用域中的变量和函数所带来的另一个好处,是可以避免同名标识符之间的冲突,两个标识符可能具有相同的名字但用途却不一样,无意间可能造成命名冲突,冲突会导致变量的值被意外覆盖。
全局命名空间
库通常会在全局作用域中声明一个名字足够独特的变量,通常是一个对象。这个对象被用作库的命名空间,所有需要暴露给外界的功能都会成为这个对象(命名空间)的属性,而不是将自己的标识符暴露在顶级的词法作用域中。
模块管理
通过模块管理器,任何库都无需将标识符加入到全局作用域中,而是通过依赖管理器的机制将库的标识符显式地导入到另外一个特定的作用域中。其本质就是利用作用域的规则强制所有标识符都不能注入到共享作用域中,而是保持在私有、无冲突的作用域中,这样可以有效规避掉所有的意外冲突。
如果function是声明中的第一个词,那么就是一个函数声明,否则就是一个函数表达式。
// 函数声明
function foo() {
...
}
// 函数表达式
(function foo() {..})()
区别:名称标识符绑定在何处**
- 函数声明:其名称标识符被绑定在所在作用域中,可以直接通过foo()来调用
- 函数表达式:其名称标识符被绑定在..所代表的位置中被访问(即IFEE会重新创建一个全新的词法作用域),外部作用域则不行。foo 变量名被隐藏在自身中意味着不会非必要地污染外部作用域。
函数表达式可以是匿名函数表达式:
- 匿名函数在栈追踪中不会显示出有意义的函数名,使得调试很困难。
- 如果没有函数名,当函数需要引用自身(递归、解绑事件监听器)时只能使用arguments.callee引用。
- 匿名函数省略了对于代码可读性/可理解性很重要的函数名。一个描述性的名称可以让代码不言自明。
所以始终给函数表达式命名是一个最佳实践。
IFEE
var a = 2;
(function IIFE(global) {
var a = 3;
console.log(a); // 3
console.log(global.a); // 2
})(window);
console.log(a); // 2
(function IIFE(def) {
def(window);
})(function def(global) {
var a = 3;
console.log(a); // 3
console.log(global.a); // 2
});
块级作用域
- 用
with
从对象中创建出的作用域仅在 with 声明中而非外部作用域中有效 try/catch
中的catch分句会创建一个块作用域,其中声明的变量仅在catch内部有效。let(const)
将变量隐式地绑定在{…}内部,即let声明附属于一个新的作用域而不是当前的函数作用域(也不属于全局作用域)try {
undefined(); // 执行一个非法操作来强制制造一个异常。
} catch (error) {
console.log(error); // 能够正常执行!
}
console.log(error); // ReferenceError: error is not defined
第 4 章 提升
JS 引擎在解释JS代码之前会先对其进行编译,编译阶段的一部分工作是找到所有的声明,并用合适的作用域将它们关联起来。即包括变量和函数在内的所有声明都会在任何代码被执行前首先被处理。
var a = 2 会被看成两个声明:
var a;
该定义声明是在编译阶段进行的;a=2;
该赋值声明会被留在原地等待执行阶段。
无论作用域中的声明出现在什么地方,都将在代码本身被执行前首先进行处理,可以将这个过程形象地想象成所有的声明(变量和函数)都会被”移动”到各自作用域的最顶端,这个过程被称为提升。
函数声明会被提升,但是函数表达式却不会被提升:
foo(); // TypeError
bar(); // ReferenceError
var foo = function bar() {...}
相当于
var foo;
foo(); // TypeError
bar(); // ReferenceError
foo = function() {
var bar = ...self...
...
}
第 5 章 作用域闭包
当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。
函数在定义时的词法作用域以外的地方被调用,但却可以继续访问定义时的词法作用域。
闭包分为创建闭包和使用闭包。
function foo() {
var a = 2;
function bar() {
console.log(a);
}
return bar;
}
var baz = foo();
baz(); // 2; bar在自己定义的词法作用域以外的地方执行,这里是闭包的效果
通常在foo()执行后,其整个内部作用域都会因为垃圾回收机制而被销毁。然而“闭包”阻止了这件事情发生。因为bar还在使用这个内部作用域。bar()依然有对该作用域的引用,而这个引用就叫作闭包。
无论使用何种方式对函数类型的值进行传递,当函数在别处被调用时都可以观察到闭包。
function foo() {
var a = 2;
function baz() {
console.log(a); // 2
}
bar(baz);
}
function bar(fn) {
fn(); // 这也是闭包
}
只要将函数当成第一级的值类型并到处传递就是应用了闭包。
如定时器、事件监听器、Ajax请求、跨窗口通信、Web Workers 或者其它的异步(或同步)任务中的回调函数。
for (var i = 0; i < 5; i++) {
setTimeout(() => {
console.log(new Date().getSeconds(), i); // 每隔1s输出一个5
}, i*1000)
}
首先尽管循环中的5个函数是在各个迭代中分别定义的,但是它们都被封闭在一个共享的全局作用域中,因此实际上只有一个 I。要想每个迭代在运行时都会给自己“捕获”一个i的副本,则意味着每个迭代都需要一个作用域:
- 而 IIFE 会通过声明并立即执行一个函数来创建作用域。并且不是一个空的作用域,而是存放了每个迭代的 i 值。
- 也可以通过 let 在每个迭代内生成一个块级作用域。for 循环头部的 let 声明有一个特殊的行为,这个行为指出变量在循环过程中不止被声明一个,每次迭代都会声明,随后的每个迭代都会使用上一个迭代结束时的值来初始化这个变量。
for (var i = 0; i < 5; i++) {
(function(j){
setTimeout(() => {
console.log(new Date().getSeconds(), j);
}, j*1000)
})(i)
}
模块模式
CoolModule() 返回的对象中含有对内部函数而不是内部数据变量的引用,保持内部数据变量是隐藏且私有的状态。
function CoolModule() {
var something = 'cool';
var another = [1, 2, 3];
function doSomething() {
console.log(something);
}
function doAnother() {
console.log(another.join('!'));
}
return {
doSomething: doSomething,
doAnother: doAnother,
}
}
var foo = CoolModule();
foo.doSomething(); // cool
foo.doAnother(); // 1!2!3
从模块中返回一个实际的对象并不是必须的,也可以直接返回一个内部函数(由于函数也是对象,它们本身也可以拥有属性).
即模块必须满足:
- 必须有外部的封闭函数,该函数必须至少被调用一次(每次调用都会创建一个新的模块实例)
- 封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中形成闭包,并且可以访问或者修改私有的状态。
一个具有函数属性的对象并不是真正的模块。
一个从函数调用所返回的,只有数据属性而没有闭包函数的对象并不是真正的模块。
单例模式
var foo = (function CoolModule() {
var something = 'cool';
var another = [1, 2, 3];
function doSomething() {
console.log(something);
}
function doAnother() {
console.log(another.join('!'));
}
return {
doSomething: doSomething,
doAnother: doAnother,
}
})()
foo.doSomething(); // cool
foo.doAnother(); // 1!2!3
命名 API 对象
通过在模块实例的内部保留对公共API对象的内部引用,可以从内部对模块实例进行CRUD其方法及属性,及修改它们的值。
var foo = (function CoolModule(id) {
var something = 'cool';
var another = [1, 2, 3];
function change() {
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.change();
foo.identify();
var MyModules = (function Manager() {
var modules = {};
function define(name, deps, impl) {
for (let 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 hello(who) {
return 'Let me introduce:' + who;
}
return {
hello: hello,
};
});
MyModules.define('foo', ['bar'], (bar) => {
var hungry = 'hippo';
function awesome() {
console.log(bar.hello(hungry).toUpperCase());
}
return {
awesome: awesome,
};
});
var foo = MyModules.get('foo');
foo.awesome();
ES6 的模块机制
基于函数的模块并不是一个能被稳定识别的模式(编译器无法识别),它们的 API 语义只有在运行时才会被考虑进来。因此可以在运行时修改一个模块的 API。
ES6的模块API更加稳定(API不会在运行时改变),因此可以在编译期检查对导入模块的 API 成员的引用是否真实存在。如果API引用并不存在,编译器会在运行时抛出一个或多个”早期”错误,而不会像往常一样在运行期采用动态的解决方案。
模块文件的内容会被当作好像包含在作用域闭包中一样来处理。
模块有两个主要特征:
- 为创建内部作用域而调用了一个包装函数
- 包装函数的返回值必须至少包括一个对内部函数的引用,这样就会创建涵盖整个包装函数内部作用域的闭包。
附录A 动态作用域
词法作用域:是一套关于引擎如何寻找变量以及会在何处找到变量的规则。其最重要的特征是它的定义过程发生在代码的书写阶段(evel或with除外)
动态作用域:让作用域作为一个在运行时就被动态确定的形式,而不是在写代码时进行静态确定的形式。动态作用域并不关心函数和作用域是如何声明以及在何处声明,只关心从何处调用。即作用域链是基于调用栈的,而不是代码中的作用域嵌套。
附录B 块级作用域
ES6 中的let其实是通过 try/catch 的catch块来实现pollyfill的块级作用域的。 try/catch 的性能很糟糕。
IFEE 实现的块级作用域并不是一个普适的解决方案(只适合手动操作),因为如果将一段代码中的任意一部分拿出来用函数进行包裹,会改变这段代码的含义,其中的 this、return、break 和 continue 都会发生变化。
附录C 词法 this
this 本质上和动态作用域类似,但是箭头函数使用的是词法作用域,它“继承”了包含函数的this绑定。
let obj = {
count: 0,
cool: function coolFn() {
let self = this;
if (self.count < 1) {
setTimeout(function timer() {
self.count++;
console.log('awesome?');
}, 100);
}
}
};
obj.cool();
let obj = {
count: 0,
cool: function () {
if (this.count < 1) {
setTimeout(() => {
this.count++;
console.log('awesome?');
}, 100);
}
}
};
obj.cool();
let obj = {
count: 0,
cool: function () {
if (this.count < 1) {
setTimeout(function() {
this.count++;
console.log('awesome?');
}.bind(this), 100);
}
}
};
obj.cool();
第二部分 this 和 对象原型
第 1 章 关于 this
this 通过隐式传递对象引用来达到复用函数 identify() 和 speak() 的目的:
function identify() {
return this.name.toUpperCase();
}
function speak() {
let greeting = "Hello, I'm " + identify.call(this);
console.log(greeting);
}
let me = {
name: 'Kyle'
};
let you = {
name: 'Reader'
};
identify.call(me);
identify.call(you);
speak.call(me); // Hello, I'm KYLE
speak.call(you); // Hello, I'm READER
如果不用 this, 就需要显式传入一个上下文对象。而随着使用模式越来越复杂,显式传递上下文对象会让代码变得越来越混乱。
function identify(context) {
return context.name.toUpperCase();
}
function speak(context) {
let greeting = "Hello, I'm " + identify(context);
console.log(greeting);
}
let me = {
name: 'Kyle'
};
let you = {
name: 'Reader'
};
identify(me);
identify(you);
speak(me); // Hello, I'm KYLE
speak(you); // Hello, I'm READER
函数虽然也是对象,也可以存储属性,但是其内部 this 并不指向那个函数对象。
想要通过在函数内部引用自身:
- 具名函数:通过词法标识符来引用
- 匿名函数:通过废弃的
argument.callee
function foo() {
foo.count = 4;
}
setTimeout(() => {
arguments.callee.count = 4;
}, 10);
“作用域”确实和对象类似,可见的标识符都是它的属性,但是作用域“对象”无法通过 JS 代码访问,它存在于JS引擎内部。
当一个函数被调用时,会创建一个活动记录(执行上下文),这个记录会包含函数在哪里被调用(调用栈)、函数的调用方法、传入的参数等信息。this 就是记录的其中一个属性,会在函数执行的过程中用到。