本文首发在个人我的博客:http://muyunyun.cn/css
《你不知道的JavaScript》系列丛书给出了不少颠覆以往对JavaScript认知的点, 读完上卷,受益不浅,因而对其精华的知识点进行了梳理。html
做用域是一套规则,用于肯定在何处以及如何查找变量。程序员
JavaScript是一门编译语言。在传统编译语言的流程中,程序中一段源代码在执行以前会经历三个步骤,统称为“编译”。编程
分词/词法分析
将字符串分解成有意义的代码块,代码块又称词法单元。好比程序var a = 2;
会被分解为var、a、=、二、;
设计模式
解析/语法分析
将词法单元流转换成一个由元素逐级嵌套所组成的表明了程序语法接口的书,又称“抽象语法树”。数组
代码生成
将抽象语法树转换为机器可以识别的指令。安全
做用域 分别与编译器、引擎进行配合完成代码的解析闭包
引擎执行时会与做用域进行交流,肯定RHS与LHS查找具体变量,若是查找不到会抛出异常。app
编译器负责语法分析以及生成代码。编程语言
做用域负责收集并维护全部变量组成的一系列查询,并肯定当前执行的代码对这些变量的访问权限。
对于 var a = 2
这条语句,首先编译器会将其分为两部分,一部分是 var a
,一部分是 a = 2
。编译器会在编译期间执行 var a,而后到做用域中去查找 a 变量,若是 a 变量在做用域中尚未声明,那么就在做用域中声明 a 变量,若是 a 变量已经存在,那就忽略 var a 语句。而后编译器会为 a = 2 这条语句生成执行代码,以供引擎执行该赋值操做。因此咱们平时所提到的变量提高,无非就是利用这个先声明后赋值的原理而已!
对于 var a = 10
这条赋值语句,其实是为了查找变量 a, 而且将 10 这个数值赋予它,这就是 LHS
查询。 对于 console.log(a)
这条语句,其实是为了查找 a 的值并将其打印出来,这是 RHS
查询。
为何区分 LHS
和 RHS
是一件重要的事情?
在非严格模式下,LHS 调用查找不到变量时会建立一个全局变量,RHS 查找不到变量时会抛出 ReferenceError。 在严格模式下,LHS 和 RHS 查找不到变量时都会抛出 ReferenceError。
做用域共有两种主要的工做模型。第一种是最为广泛的,被大多数编程语言所采用的词法做用域( JavaScript 中的做用域就是词法做用域)。另一种是动态做用域,仍有一些编程语言在使用(好比Bash脚本、Perl中的一些模式等)。
词法做用域是一套关于引擎如何寻找变量以及会在何处找到变量的规则。词法做用域最重要的特征是它的定义过程发生在代码的书写阶段(假设没有使用 eval() 或 with )。来看示例代码:
function foo() { console.log(a); // 2 } function bar() { var a = 3; foo(); } var a = 2; bar()
词法做用域让foo()中的a经过RHS引用到了全局做用域中的a,所以会输出2。
而动态做用域只关心它们从何处调用。换句话说,做用域链是基于调用栈的,而不是代码中的做用域嵌套。所以,若是 JavaScript 具备动态做用域,理论上,下面代码中的 foo() 在执行时将会输出3。
function foo() { console.log(a); // 3 } function bar() { var a = 3; foo(); } var a = 2; bar()
对于函数表达式一个最熟悉的场景可能就是回调函数了,好比
setTimeout( function() { console.log("I waited 1 second!") }, 1000 )
这叫做匿名函数表达式
。函数表达式能够匿名,而函数声明则不能够省略函数名。匿名函数表达式书写起来简单快捷,不少库和工具也倾向鼓励使用这种风格的代码。但它也有几个缺点须要考虑。
匿名函数在栈追踪中不会显示出有意义的函数名,使得调试很困难。
若是没有函数名,当函数须要引用自身时只能使用已通过期的 arguments.callee 引用,好比在递归中。另外一个函数须要引用自身的例子,是在事件触发后事件监听器须要解绑自身。
匿名函数省略了对于代码可读性 / 可理解性很重要的函数名。一个描述性的名称可让代码不言自明。
始终给函数表达式命名是一个最佳实践:
setTimeout( function timeoutHandler() { // 我有名字了 console.log("I waited 1 second!") }, 1000 )
考虑如下代码:
a = 2; var a; console.log(a); // 2
考虑另一段代码
console.log(a); // undefined var a = 2;
咱们习惯将 var a = 2; 看做一个声明,而实际上 JavaScript 引擎并不这么认为。它将 var a 和 a = 2 看成两个单独的声明,第一个是编译阶段的任务,而第二个是执行阶段的任务。
这意味着不管做用域中的声明出如今什么地方,都将在代码自己被执行前首先进行处理。能够将这个过程形象地想象成全部的声明(变量和函数)都会被“移动”到各自做用域的最顶端,这个过程称为提高。
能够看出,先有声明后有赋值。
再来看如下代码:
foo(); // TypeError bar(); // ReferenceError var foo = function bar() { // ... };
这个代码片断通过提高后,实际上会被理解为如下形式:
var foo; foo(); // TypeError bar(); // ReferenceError foo = function() { var bar = ...self... // ... };
这段程序中的变量标识符 foo() 被提高并分配给全局做用域,所以 foo() 不会致使 ReferenceError。可是 foo 此时并无赋值(若是它是一个函数声明而不是函数表达式就会赋值
)。foo()因为对 undefined 值进行函数调用而致使非法操做,所以抛出 TypeError 异常。另外即时是具名的函数表达式,名称标识符(这里是 bar )在赋值以前也没法在所在做用域中使用。
以前写过关于闭包的一篇文章深刻浅出JavaScript之闭包(Closure)
要说明闭包,for 循环是最多见的例子。
for (var i = 1; i <= 5; i++) { setTimeout( function timer() { console.log(i); }, i*1000 ) }
正常状况下,咱们对这段代码行为的预期是分别输出数字 1~5,每秒一次,每次一个。但实际上,这段代码在运行时会以每秒一次的频率输出五次6。
它的缺陷在于:根据做用域的工做原理,尽管循环中的五个函数是在各个迭代中分别定义的,可是它们都被封闭在一个共享的全局做用域中,所以实际上只有一个i。所以咱们须要更多的闭包做用域。咱们知道IIFE会经过声明并当即执行一个函数来建立做用域,咱们来进行改进:
for (var i = 1; i <= 5; i++) { (function() { var j = i; setTimeout( function timer() { console.log(j); }, j*1000 ) })(); }
还能够对这段代码进行一些改进:
for (var i = 1; i <= 5; i++) { (function(j) { setTimeout( function timer() { console.log(j); }, j*1000 ) })(i); }
在迭代内使用 IIFE 会为每一个迭代都生成一个新的做用域,使得延迟函数的回调能够将新的做用域封闭在每一个迭代内部,每一个迭代中都会含有一个具备正确值的变量供咱们访问。
咱们使用 IIFE 在每次迭代时都建立一个新的做用域。换句话说,每次迭代咱们都须要一个块做用域。咱们知道 let 声明能够用来劫持块做用域,那咱们能够进行这样改:
for (var i = 1; i <= 5; i++) { let j = i; setTimeout( function timer() { console.log(j); }, j*1000 ) }
本质上这是将一个块转换成一个能够被关闭的做用域。
此外,for循环头部的 let 声明还会有一个特殊行为。这个行为指出每一个迭代都会使用上一个迭代结束时的值来初始化这个变量。
for (let i = 1; i <= 5; i++) { setTimeout( function timer() { console.log(i); }, i*1000 ) }
以前写过一篇深刻浅出JavaScript之this。咱们知道this是在运行时进行绑定的,并非在编写时绑定,它的上下文取决于函数调用时的各类条件。this的绑定和函数声明的位置没有任何关系,只取决于函数的调用方式。
来看下面这段代码的问题:
var obj = { id: "awesome", cool: function coolFn() { console.log(this.id); } }; var id = "not awesome"; obj.cool(); // awesome setTimeout( obj.cool, 100); // not awesome
obj.cool() 与 setTimeout( obj.cool, 100 ) 输出结果不同的缘由在于 cool() 函数丢失了同 this 之间的绑定。解决方法最经常使用的是 var self = this;
var obj = { count: 0, cool: function coolFn() { var self = this; if (self.count < 1) { setTimeout( function timer(){ self.count++; console.log("awesome?"); }, 100) } } } obj.cool(); // awesome?
这里用到的知识点是咱们很是熟悉的词法做用域。self 只是一个能够经过词法做用域和闭包进行引用的标识符,不关心 this 绑定的过程当中发生了什么。
ES6 中的箭头函数引人了一个叫做 this 词法的行为:
var obj = { count: 0, cool: function coolFn() { if (this.count < 1) { setTimeout( () => { this.count++; console.log("awesome?"); }, 100) } } } obj.cool(); // awesome?
箭头函数弃用了全部普通 this 绑定规则,取而代之的是用当前的词法做用域覆盖了 this 原本的值。所以,这个代码片断中的箭头函数只是"继承"了 cool() 函数的 this 绑定。
可是箭头函数的缺点就是由于其是匿名的,上文已介绍过具名函数比匿名函数更可取的缘由。并且箭头函数将程序员们常常犯的一个错误给标准化了:混淆了 this 绑定规则和词法做用域规则。
箭头函数不只仅意味着能够少写代码。本书的做者认为使用 bind() 是更靠得住的方式。
var obj = { count: 0, cool: function coolFn() { if (this.count < 1) { setTimeout( () => { this.count++; console.log("more awesome"); }.bind( this ), 100) } } } obj.cool(); // more awesome
函数在执行的过程当中,能够根据下面这4条绑定规则来判断 this 绑定到哪。
默认绑定
独立函数调用
隐式绑定
当函数引用有上下文对象时,隐式绑定规则会把函数调用中的 this 绑定到这个上下文对象
显示绑定
call/apply
bind(本质是对call/apply函数的封装 fn.apply( obj, arguments )
)
第三方库的许多函数都提供了一个可选的参数(上下文),其做用和 bind() 同样,确保回调函数使用指定的 this
new 绑定
JavaScript 中的 new 机制实际上和面向类的语言彻底不一样
实际上并不存在所谓的“构造函数”,只有对于函数的“构造调用”
书中对4条绑定规则的优先级进行了验证,得出如下的顺序优先级:
函数是否在 new 中调用(new 绑定)?若是是的话 this 绑定的是新建立的对象。
函数是否经过 call、apply(显式绑定)或者硬绑定(bind)调用?若是是的话,this 绑定的是指定对象。
函数是否在某个上下文对象中调用(隐式绑定)?若是是的话,this 绑定的是那个上下文对象。
若是都不是的话,使用默认绑定。在严格模式下,绑定到 undefined,不然绑定到全局对象。
若是你把 null 或者 undefined 做为 this 的绑定对象传入 call、apply 或者 bind,这些值在调用时会被忽略,实际应用的是默认规则。
何时会传入 null/undefined 呢?一种很是常见的作法是用 apply(..) 来“展开”一个数组,并看成参数传入一个函数。相似地,bind(..) 能够对参数进行柯里化(预先设置一些参数),以下代码:
function foo(a, b) { console.log( "a:" + a + ", b:" + b ); } // 把数组"展开"成参数 foo.apply(null, [2, 3]); // a:2, b:3 // 使用 bind(..) 进行柯里化 var bar = foo.bind( null, 2); bar(3); // a:2, b:3
其中 ES6 中,能够用 ... 操做符代替 apply(..) 来“展开”数组,可是 ES6 中没有柯里化的相关语法,所以仍是须要使用 bind(..)。
使用 null 来忽略 this 绑定可能产生一些反作用。若是某个函数(好比第三库中的某个函数)确实使用了 this ,默认绑定规则会把 this 绑定到全局对象,这将致使不可预计的后果。更安全的作法是传入一个特殊的对象,一个 “DMZ” 对象,一个空的非委托对象,即 Object.create(null)。
function foo(a, b) { console.log( "a:" + a + ", b:" + b ); } var ø = Object.create(null); // 把数组"展开"成参数 foo.apply( ø, [2, 3]); // a:2, b:3 // 使用 bind(..) 进行柯里化 var bar = foo.bind( ø, 2); bar(3); // a:2, b:3
JavaScript中的对象有字面形式(好比var a = { .. }
)和构造形式(好比var a = new Array(..)
)。字面形式更经常使用,不过有时候构造形式能够提供更多选择。
做者认为“JavaScript中万物都是对象”的观点是不对的。由于对象只是 6 个基础类型( string、number、boolean、null、undefined、object )之一。对象有包括 function 在内的子对象,不一样子类型具备不一样的行为,好比内部标签 [object Array] 表示这是对象的子类型数组。
思考一下这个对象:
function anotherFunction() { /*..*/ } var anotherObject = { c: true }; var anotherArray = []; var myObject = { a: 2, b: anotherObject, // 引用,不是复本! c: anotherArray, // 另外一个引用! d: anotherFunction }; anotherArray.push( myObject )
如何准确地表示 myObject 的复制呢?
这里有一个知识点。
浅复制。复制出的新对象中 a 的值会复制旧对象中 a 的值,也就是 2,可是新对象中 b、c、d 三个属性其实只是三个引用。
深复制。除了复制 myObject 之外还会复制 anotherArray。这时问题就来了,anotherArray 引用了 myObject, 因此又须要复制 myObject,这样就会因为循环引用致使死循环。
对于 JSON 安全的对象(就是能用 JSON.stringify 序列号的字符串)来讲,有一种巧妙的复制方法:
var newObj = JSON.parse( JSON.stringify(someObj) )
我认为这种方法就是深复制。相比于深复制,浅复制很是易懂而且问题要少得多,ES6 定义了 Object.assign(..) 方法来实现浅复制。 Object.assign(..) 方法的第一个参数是目标对象,以后还能够跟一个或多个源对象。它会遍历一个或多个源对象的全部可枚举的自由键并把它们复制到目标对象,最后返回目标对象,就像这样:
var newObj = Object.assign( {}, myObject ); newObj.a; // 2 newObj.b === anotherObject; // true newObj.c === anotherArray; // true newObj.d === anotherFunction; // true
JavaScript 有一些近似类的语法元素(好比 new 和 instanceof), 后来的 ES6 中新增了一些如 class 的关键字。可是 JavaScript 实际上并无类。类是一种设计模式,JavaScript 的机制其实和类彻底不一样。
类的继承(委托)其实就是复制,但和其余语言中类的表现不一样(其余语言类表现出来的都是复制行为),JavaScript 中的多态(在继承链中不一样层次名称相同,可是功能不一样的函数)并不表示子类和父类有关联,子类获得的只是父类的一份复本。
JavaScript 经过显示混入和隐式混入 call() 来模拟其余语言类的表现。此外,显示混入实际上没法彻底模拟类的复制行为,由于对象(和函数!别忘了函数也是对象)只能复制引用,没法复制被引用的对象或者函数自己。
思考下面的代码:
function Foo() { // ... } Foo.prototype.blah = ...; var a = new Foo();
咱们如何找出 a 的“祖先”(委托关系)呢?
方法一:a instanceof Foo; // true
(对象 instanceof 函数)
方法二: Foo.prototype.isPrototypeOf(a); // true
(对象 isPrototypeOf 对象)
方法三: Object.getPrototypeOf(a) === Foo.prototype; // true
(Object.getPrototypeOf() 能够获取一个对象的 [[Prototype]]) 链;
方法四: a.__proto__ == Foo.prototype; // true
函数不是构造函数,而是当且仅当使用 new 时,函数调用会变成“构造函数调用”。
使用 new 会在 prototype 生成一个 constructor 属性,指向构造调用的函数。
constructor 并不表示被构造,并且 constructor 属性并非一个不可变属性,它是不可枚举的,但它是能够被修改的。
来看下面的代码:
var foo = { something: function() { console.log("Tell me something good..."); } }; var bar = Object.create(foo); bar.something(); // Tell me something good...
Object.create(..)会建立一个新对象 (bar) 并把它关联到咱们指定的对象 (foo),这样咱们就能够充分发挥 [[Prototype]] 机制的为例(委托)而且避免没必要要的麻烦 (好比使用 new 的构造函数调用会生成 .prototype 和 .constructor 引用)。
Object.create(null) 会建立一个拥有空连接的对象,这个对象没法进行委托。因为这个对象没有原型链,因此 instanceof 操做符没法进行判断,所以老是会返回 false 。这些特殊的空对象一般被称做“字典”,它们彻底不会受到原型链的干扰,所以很是适合用来存储数据。
咱们并不须要类来建立两个对象之间的关系,只须要经过委托来关联对象就足够了。而Object.create(..)不包含任何“类的诡计”,因此它能够完美地建立咱们想要的关联关系。
此书的第二章第6部分就把面对类和继承
和行为委托
两种设计模式进行了对比,咱们能够看到行为委托是一种更加简洁的设计模式,在这种设计模式中能感觉到Object.create()
的强大。
来看一段 ES6中Class 的例子
class Widget { constructor(width, height) { this.width = width || 50; this.height = height || 50; this.$elem = null; } render($where){ if (this.$elem) { this.$elem.css({ width: this.width + "px", height: this.height + "px" }).appendTo($where); } } } class Button extends Widget { constructor(width, height, label) { super(width, height); this.label = label || "Default"; this.$elem = $("<button>").text(this.label) } render($where) { super($where); this.$elem.click(this.onClick.bind(this)); } onClick(evt) { console.log("Button '" + this.label + "' clicked!") } }
除了语法更好看以外,ES6还有如下优势
基本上再也不引用杂乱的 .prototype 了。
Button 声明时直接 “继承” 了 Widget。
能够经过 super(..)来实现相对多态,这样任何方法均可以引用原型链上层的同名方法。
class 字面语法不能声明属性(只能声明方法)。这是一种限制,可是它会排除掉许多很差的状况。
能够经过 extends 很天然地扩展对象(子)类型。
可是 class 就是完美的吗?在传统面向类的语言中,类定义以后就不会进行修改,因此类的设计模式就不支持修改。但JavaScript 最强大的特性之一就是它的动态性,在使用 class 的有些时候仍是会用到 .prototype 以及碰到 super (指望动态绑定然而静态绑定) 的问题,class 基本上都没有提供解决方案。
这也是本书做者但愿咱们思考的问题。