本文首发在我的个人博客:http://muyunyun.cn/
《你不知道的 JavaScript》系列丛书给出了很多颠覆以往对 JavaScript 认知的点, 读完上卷,受益匪浅,于是对其精华的知识点进行了梳理。
作用域是一套规则,用于确定在何处以及如何查找变量。
JavaScript 是一门编译语言。在传统编译语言的流程中,程序中一段源代码在执行之前会经历三个步骤,统称为 "编译"。
会被分解为
- var a = 2;
- var、a、=、2、;
作用域 分别与编译器、引擎进行配合完成代码的解析
对于
这条语句,首先编译器会将其分为两部分,一部分是
- var a = 2
,一部分是
- var a
。编译器会在编译期间执行 var a,然后到作用域中去查找 a 变量,如果 a 变量在作用域中还没有声明,那么就在作用域中声明 a 变量,如果 a 变量已经存在,那就忽略 var a 语句。然后编译器会为 a = 2 这条语句生成执行代码,以供引擎执行该赋值操作。所以我们平时所提到的变量提升,无非就是利用这个先声明后赋值的原理而已!
- a = 2
对于
这条赋值语句,实际上是为了查找变量 a, 并且将 10 这个数值赋予它,这就是
- var a = 10
查询。 对于
- LHS
这条语句,实际上是为了查找 a 的值并将其打印出来,这是
- console.log(a)
查询。
- RHS
为什么区分
和
- LHS
是一件重要的事情? 在非严格模式下,LHS 调用查找不到变量时会创建一个全局变量,RHS 查找不到变量时会抛出 ReferenceError。 在严格模式下,LHS 和 RHS 查找不到变量时都会抛出 ReferenceError。
- RHS
作用域共有两种主要的工作模型。第一种是最为普遍的,被大多数编程语言所采用的词法作用域 (JavaScript 中的作用域就是词法作用域)。另外一种是动态作用域,仍有一些编程语言在使用 (比如 Bash 脚本、Perl 中的一些模式等)。
词法作用域是一套关于引擎如何寻找变量以及会在何处找到变量的规则。词法作用域最重要的特征是它的定义过程发生在代码的书写阶段 (假设没有使用 eval() 或 with )。来看示例代码:
- function foo(){
- console.log(a); // 2
- }
- function bar(){
- vara= 3;
- foo();
- }
- vara= 2;
- bar()
词法作用域让 foo() 中的 a 通过 RHS 引用到了全局作用域中的 a,因此会输出 2。
而动态作用域只关心它们从何处调用。换句话说,作用域链是基于调用栈的,而不是代码中的作用域嵌套。因此,如果 JavaScript 具有动态作用域,理论上,下面代码中的 foo() 在执行时将会输出 3。
- function foo(){
- console.log(a); // 3
- }
- function bar(){
- vara= 3;
- foo();
- }
- vara= 2;
- bar()
对于函数表达式一个最熟悉的场景可能就是回调函数了,比如
- setTimeout(function() {
- console.log("I waited 1 second!")
- },
- 1000)
这叫作
。函数表达式可以匿名,而函数声明则不可以省略函数名。匿名函数表达式书写起来简单快捷, 很多库和工具也倾向鼓励使用这种风格的代码。但它也有几个缺点需要考虑。
- 匿名函数表达式
始终给函数表达式命名是一个最佳实践:
- setTimeout(function timeoutHandler() { // 我有名字了
- console.log("I waited 1 second!")
- },
- 1000)
考虑以下代码:
- a = 2;
- var a;
- console.log(a); // 2
考虑另外一段代码
- console.log(a); // undefined
- vara= 2;
我们习惯将 var a = 2; 看作一个声明,而实际上 JavaScript 引擎并不这么认为。它将 var a 和 a = 2 当作两个单独的声明,第一个是编译阶段的任务,而第二个是执行阶段的任务。 这意味着无论作用域中的声明出现在什么地方,都将在代码本身被执行前首先进行处理。可以将这个过程形象地想象成所有的声明(变量和函数)都会被 "移动" 到各自作用域的最顶端,这个过程称为提升。
可以看出,先有声明后有赋值。
再来看以下代码:
- foo(); // TypeError
- bar(); // ReferenceError
- varfoo= function bar(){
- // ...
- };
这个代码片段经过提升后,实际上会被理解为以下形式:
- varfoo;
- foo(); // TypeError
- bar(); // ReferenceErrorfoo= function(){
- varbar=...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(vari= 1;i<= 5;i++){(function(){
- varj=i;
- setTimeout(function timer(){
- console.log(j);
- },j*1000)})();
- }
还可以对这段代码进行一些改进:
- for(vari= 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 绑定到哪。
)
- fn.apply( obj, arguments )
书中对 4 条绑定规则的优先级进行了验证,得出以下的顺序优先级:
如果你把 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 的复制呢? 这里有一个知识点。
对于 JSON 安全的对象 (就是能用 JSON.stringify 序列号的字符串) 来说,有一种巧妙的复制方法:
- varnewObj= 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 的机制其实和类完全不同。
思考下面的代码:
- function Foo(){
- // ...
- }
- Foo.prototype.blah =...;
- vara= new Foo();
我们如何找出 a 的 "祖先"(委托关系)呢?
(对象 instanceof 函数)
- a instanceof Foo; // true
(对象 isPrototypeOf 对象)
- Foo.prototype.isPrototypeOf(a); // true
(Object.getPrototypeOf() 可以获取一个对象的 [[Prototype]]) 链;
- Object.getPrototypeOf(a) === Foo.prototype; // true
- a.__proto__ == Foo.prototype; // true
来看下面的代码:
- 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 的例子
- classWidget{
- 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);
- }
- }
- }
- classButtonextendsWidget{
- 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 还有以下优点
但是 class 就是完美的吗?在传统面向类的语言中,类定义之后就不会进行修改,所以类的设计模式就不支持修改。但 JavaScript 最强大的特性之一就是它的动态性,在使用 class 的有些时候还是会用到 .prototype 以及碰到 super (期望动态绑定然而静态绑定) 的问题,class 基本上都没有提供解决方案。
这也是本书作者希望我们思考的问题。
来源: http://www.cnblogs.com/MuYunyun/p/6954155.html