你不知道的JavaScript 上卷

102 阅读17分钟

1.png

你不知道的JavaScript 上.jpg

注:以下是从书中抄写的一些知识点,仅供参考。

你不知道的JavaScript 上卷

LHS 和 RHS

当变量出现在赋值操作的左侧时进行 LHS 查询,出现在右侧时进行 RHS 查询。 讲得更准确一点,RHS 查询与简单地查找某个变量的值别无二致,而 LHS 查询则是试图 找到变量的容器本身,从而可以对其赋值。 你可以将 RHS 理解成 retrieve his source value(取到它的源值),这意味着“得到某某的 值”。 在概念上最 好将其理解为“赋值操作的目标是谁(LHS)”以及“谁是赋值操作的源头 (RHS)

欺骗词法

1.eval:默认情况下,如果 eval(..) 中所执行的代码包含有一个或多个声明(无论是变量还是函 数),就会对 eval(..) 所处的词法作用域进行修改。

2.with 关键字:with 声明实际上是根据你传递给它的对象凭空创建了一个全新的词法作用域。

不推荐使用 eval(..) 和 with 的原因是会被严格模式所影响(限 制)。with 被完全禁止,而在保留核心功能的前提下,间接或非安全地使用 eval(..) 也被禁止了。

3.性能:JavaScript 引擎会在编译阶段进行数项的性能优化。其中有些优化依赖于能够根据代码的 词法进行静态分析,并预先确定所有变量和函数的定义位置,才能在执行过程中快速找到 标识符。 但如果引擎在代码中发现了 eval(..) 或 with,它只能简单地假设关于标识符位置的判断 都是无效的,因为无法在词法分析阶段明确知道 eval(..) 会接收到什么代码,这些代码会 如何对作用域进行修改,也无法知道传递给 with 用来创建新词法作用域的对象的内容到底 是什么。

函数作用域

区分函数声明和表达式最简单的方法是看 function 关键字出现在声明中的位 置(不仅仅是一行代码,而是整个声明中的位置)。如果 function 是声明中 的第一个词,那么就是一个函数声明,否则就是一个函数表达式。

因为 function().. 没有名称标识符。函数表达式可以是匿名的, 而函数声明则不可以省略函数名——在 JavaScript 的语法中这是非法的。

立即执行函数表达式

(function foo(){ .. })()。第一个 ( ) 将函数变成表 达式,第二个 ( ) 执行了这个函数。 相较于传统的 IIFE 形式,很多人都更喜欢另一个改进的形式:(function(){ .. }())。

IIFE 的另一个非常普遍的进阶用法是把它们当作函数调用并传递参数进去:

var a = 2; 
(function IIFE( global ) {
	var a = 3; 
	console.log( a ); // 3 console.log( global.a })( window ); console.log( a ); // 2

IIFE 还有一种变化的用途是倒置代码的运行顺序,将需要运行的函数放在第二位,在 IIFE 执行之后当作参数传递进去。这种模式在 UMD(Universal Module Definition)项目中被广 泛使用。

var a = 2;
(function IIFE( def ) { 
	def( window ); 
})(function def( global ) {
	var a = 3; 
	console.log( a ); // 3 console.log( global.a ); // 2 
});

函数优先

函数声明和变量声明都会被提升。但是一个值得注意的细节(这个细节可以出现在有多个 “重复”声明的代码中)是函数会首先被提升,然后才是变量。

//考虑以下代码: 
foo(); // 1

var foo;

function foo() { 
	console.log( 1 ); 
}

foo = function() { 
	console.log( 2 ); 
}; 

//会输出 1 而不是 2 !这个代码片段会被引擎理解为如下形式:
function foo() { 
	console.log( 1 ); 
}

foo(); // 1 

foo = function() { 
	console.log( 2 ); 
};

我们习惯将 var a = 2; 看作一个声明,而实际上 JavaScript 引擎并不这么认为。它将 var a 和 a = 2 当作两个单独的声明,第一个是编译阶段的任务,而第二个则是执行阶段的任务。 这意味着无论作用域中的声明出现在什么地方,都将在代码本身被执行前首先进行处理。 可以将这个过程形象地想象成所有的声明(变量和函数)都会被“移动”到各自作用域的 最顶端,这个过程被称为提升。

模块

模块模式需要具备两个必要条件。:

  1. 必须有外部的封闭函数,该函数必须至少被调用一次(每次调用都会创建一个新的模块 实例)。
  2. 封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中形成闭包,并 且可以访问或者修改私有的状态。

附录B 动态作用域

需要明确的是,事实上 JavaScript 并不具有动态作用域。它只有词法作用域,简单明了。 但是 this 机制某种程度上很像动态作用域。 主要区别:词法作用域是在写代码或者说定义时确定的,而动态作用域是在运行时确定的。(this 也是!)词法作用域关注函数在何处声明,而动态作用域关注函数从何处调用。

this到底是什么

之前我们说过 this 是在运行时进行绑定的,并不是在编写时绑定,它的上下文取决于函数调 用时的各种条件。this 的绑定和函数声明的位置没有任何关系,只取决于函数的调用方式。

new绑定

使用 new 来调用函数,或者说发生构造函数调用时,会自动执行下面的操作。

  1. 创建(或者说构造)一个全新的对象。
  2. 这个新对象会被执行 [[ 原型 ]] 连接。
  3. 这个新对象会绑定到函数调用的 this。
  4. 如果函数没有返回其他对象,那么 new 表达式中的函数调用会自动返回这个新对象。

this绑定规则

默认绑定,隐式绑定,显示绑定,new绑定 new 绑定比隐式绑定优先级高,硬绑定(也是显式绑定的一种)似乎比 new 绑定的优先级更高

判断this

现在我们可以根据优先级来判断函数在某个调用位置应用的是哪条规则。可以按照下面的 顺序来进行判断:

  1. 函数是否在 new 中调用(new 绑定)?如果是的话 this 绑定的是新创建的对象。 var bar = new foo()
  2. 函数是否通过 call、apply(显式绑定)或者硬绑定调用?如果是的话,this 绑定的是 指定的对象。 var bar = foo.call(obj2)
  3. 函数是否在某个上下文对象中调用(隐式绑定)?如果是的话,this 绑定的是那个上 下文对象。 var bar = obj1.foo()
  4. 如果都不是的话,使用默认绑定。如果在严格模式下,就绑定到 undefined,否则绑定到 全局对象。 var bar = foo() 就是这样。对于正常的函数调用来说,理解了这些知识你就可以明白 this 的绑定原理了。 不过……凡事总有例外。

对象类型

• string • number • boolean • null • undefined • object

注意,简单基本类型(string、boolean、number、null 和 undefined)本身并不是对象。 null 有时会被当作一种对象类型,但是这其实只是语言本身的一个 bug,即对 null 执行 typeof null 时会返回字符串 "object"。1 实际上,null 本身是基本类型。

内置对象

• string • Number • Boolean • Object • Function • Array • Date • RegExp • Error

存在性

in 操作符会检查属性是否在对象及其 [[Prototype]] 原型链中(参见第 5 章)。相比之下, hasOwnProperty(..) 只会检查属性是否在 myObject 对象中,不会检查 [[Prototype]] 链。

propertyIsEnumerable(..) 会检查给定的属性名是否直接存在于对象中(而不是在原型链 上)并且满足 enumerable:true。 Object.keys(..) 会返回一个数组,包含所有可枚举属性,Object.getOwnPropertyNames(..) 会返回一个数组,包含所有属性,无论它们是否可枚举。

in 和 hasOwnProperty(..) 的区别在于是否查找 [[Prototype]] 链,然而,Object.keys(..) 和 Object.getOwnPropertyNames(..) 都只会查找对象直接包含的属性。

(目前)并没有内置的方法可以获取 in 操作符使用的属性列表(对象本身的属性以 及 [[Prototype]] 链中的所有属性,参见第 5 章)。不过你可以递归遍历某个对象的整条 [[Prototype]] 链并保存每一层中使用 Object.keys(..) 得到的属性列表——只包含可枚举属性。

小结

对象就是键 / 值对的集合。可以通过 .propName 或者 ["propName"] 语法来获取属性值。

属性的特性可以通过属性描述符来控制,比如 writable 和 configurable。此外,可以使用 Object.preventExtensions(..)、Object.seal(..) 和 Object.freeze(..) 来设置对象(及其 属性)的不可变性级别。

属性不一定包含值——它们可能是具备 getter/setter 的“访问描述符”。此外,属性可以是 可枚举或者不可枚举的,这决定了它们是否会出现在 for..in 循环中。

你可以使用 ES6 的 for..of 语法来遍历数据结构(数组、对象,等等)中的值,for..of 会寻找内置或者自定义的 @@iterator 对象并调用它的 next() 方法来遍历数据值。

显式混入

这个功能在许多库和框架中被称为 extend(..),但是为了方便理解我们称之为 mixin(..)。

显式多态(相对多态)

JavaScript 中(由于屏蔽)使用显式伪多态会在所有需要使用(伪)多态引用的地 方创建一个函数关联,这会极大地增加维护成本。此外,由于显式伪多态可以模拟多重继 承,所以它会进一步增加代码的复杂度和维护难度。 使用伪多态通常会导致代码变得更加复杂、难以阅读并且难以维护,因此应当尽量避免使 用显式伪多态,因为这样做往往得不偿失。

混合复制

JavaScript 中的函数无法(用标准、可靠的方法)真正地复制,所以你只能复制对共享 函数对象的引用(函数就是对象;参见第 3 章)。

如果你向目标对象中显式混入超过一个对象,就可以部分模仿多重继承行为,但是仍没有 直接的方式来处理函数和属性的同名问题。有些开发者 / 库提出了“晚绑定”技术和其他的 一些解决方法,但是从根本上来说,使用这些“诡计”通常会(降低性能并且)得不偿失。

一定要注意,只在能够提高代码可读性的前提下使用显式混入,避免使用增加代码理解难 度或者让对象关系更加复杂的模式。

小结

传统的类被实例化时,它的行为会被复制到实例中。类被继承时,行为也会被复制到子类 中。

多态(在继承链的不同层次名称相同但是功能不同的函数)看起来似乎是从子类引用父 类,但是本质上引用的其实是复制的结果。

混入模式(无论显式还是隐式)可以用来模拟类的复制行为,但是通常会产生丑陋并且脆 弱的语法,比如显式伪多态(OtherObj.methodName.call(this, ...)),这会让代码更加难 懂并且难以维护。

此外,显式混入实际上无法完全模拟类的复制行为,因为对象(和函数!别忘了函数也 是对象)只能复制引用,无法复制被引用的对象或者函数本身。忽视这一点会导致许多 问题。 总地来说,在 JavaScript 中模拟类是得不偿失的,虽然能解决当前的问题,但是可能会埋 下更多的隐患。

[[Prototype]]

使用 for..in 遍历对象时原理和查找 [[Prototype]] 链类似,任何可以通过原型链访问到 (并且是 enumerable,参见第 3 章)的属性都会被枚举。使用 in 操作符来检查属性在对象 中是否存在时,同样会查找对象的整条原型链(无论属性是否可枚举):

Object.prototype

所有普通的 [[Prototype]] 链最终都会指向内置的 Object.prototype。由于所有的“普通” (内置,不是特定主机的扩展)对象都“源于”(或者说把 [[Prototype]] 链的顶端设置为) 这个 Object.prototype 对象,所以它包含 JavaScript 中许多通用的功能。

属性设置和屏蔽

屏蔽比我们想象中更加复杂。下面我们分析一下如果 foo 不直接存在于 myObject 中而是存在于原型链上层时 myObject.foo = "bar" 会出现的三种情况。

  1. 如果在 [[Prototype]] 链上层存在名为 foo 的普通数据访问属性(参见第 3 章)并且没 有被标记为只读(writable:false),那就会直接在 myObject 中添加一个名为 foo 的新 属性,它是屏蔽属性。 2. 如果在 [[Prototype]] 链上层存在 foo,但是它被标记为只读(writable:false),那么 无法修改已有属性或者在 myObject 上创建屏蔽属性。如果运行在严格模式下,代码会 抛出一个错误。否则,这条赋值语句会被忽略。总之,不会发生屏蔽。
  2. 如果在 [[Prototype]] 链上层存在 foo 并且它是一个 setter(参见第 3 章),那就一定会 调用这个 setter。foo 不会被添加到(或者说屏蔽于)myObject,也不会重新定义 foo 这 个 setter。 大多数开发者都认为如果向 [[Prototype]] 链上层已经存在的属性([[Put]])赋值,就一 定会触发屏蔽,但是如你所见,三种情况中只有一种(第一种)是这样的。 如果你希望在第二种和第三种情况下也屏蔽 foo,那就不能使用 = 操作符来赋值,而是使 用 Object.defineProperty(..)(参见第 3 章)来向 myObject 添加 foo。

类 技术-回顾“构造函数”

实际上,对象的 .constructor 会默认指向一个函数,这个函数可以通过对象的 .prototype 引用。“constructor”和“prototype”这两个词本身的含义可能适用也可能不适用。最好的 办法是记住这一点“constructor 并不表示被构造”。

.constructor 并不是一个不可变属性。它是不可枚举(参见上面的代码)的,但是它的值 是可写的(可以被修改)。此外,你可以给任意 [[Prototype]] 链中的任意对象添加一个名 为 constructor 的属性或者对其进行修改,你可以任意对其赋值。

a1.constructor 是一个非常不可靠并且不安全的引用。通常来说要尽量避免使用这些引用。

(原型)继承

如果能有一个标准并且可靠的方法来修改对象的 [[Prototype]] 关联就好了。在 ES6 之前, 我们只能通过设置 .proto 属性来实现,但是这个方法并不是标准并且无法兼容所有浏 览器。ES6 添加了辅助函数 Object.setPrototypeOf(..),可以用标准并且可靠的方法来修 改关联。

我们来对比一下两种把 Bar.prototype 关联到 Foo.prototype 的方法: // ES6 之前需要抛弃默认的 Bar.prototype Bar.ptototype = Object.create( Foo.prototype ); // ES6 开始可以直接修改现有的 Bar.prototype Object.setPrototypeOf( Bar.prototype, Foo.prototype ); 如果忽略掉 Object.create(..) 方法带来的轻微性能损失(抛弃的对象需要进行垃圾回 收),它实际上比 ES6 及其之后的方法更短而且可读性更高。不过无论如何,这是两种完 全不同的语法。

检查“类”关系

如果使用内置的 .bind(..) 函数来生成一个硬绑定函数(参见第 2 章)的话, 该函数是没有 .prototype 属性的。在这样的函数上使用 instanceof 的话, 目标函数的 .prototype 会代替硬绑定函数的 .prototype。

通常我们不会在“构造函数调用”中使用硬绑定函数,不过如果你这么 做的话,实际上相当于直接调用目标函数。同理,在硬绑定函数上使用 instanceof 也相当于直接在目标函数上使用 instanceof。

我们只需要两个对象就可以判断它们之间的关系。举例来说: // 非常简单:b 是否出现在 c 的 [[Prototype]] 链中? b.isPrototypeOf( c ); 注意,这个方法并不需要使用函数(“类”),它直接使用 b 和 c 之间的对象引用来判断它 们的关系。换句话说,语言内置的 isPrototypeOf(..) 函数就是我们的 isRelatedTo(..) 函 数。我们也可以直接获取一个对象的 [[Prototype]] 链。在 ES5 中,标准的方法是: Object.getPrototypeOf( a ); 可以验证一下,这个对象引用是否和我们想的一样: Object.getPrototypeOf( a ) === Foo.prototype; // true 绝大多数(不是所有!)浏览器也支持一种非标准的方法来访问内部 [[Prototype]] 属性: a.proto === Foo.prototype; // true

.proto 是可设置属性,之前的代码中使用 ES6 的 Object.setPrototypeOf(..) 进行设 置。然而,通常来说你不需要修改已有对象的 [[Prototype]]。 一些框架会使用非常复杂和高端的技术来实现“子类”机制,但是通常来说,我们不推荐 这种用法,因为这会极大地增加代码的阅读难度和维护难度。

创建关联

Object.create(null) 会 创 建 一 个 拥 有 空( 或 者 说 null)[[Prototype]] 链接的对象,这个对象无法进行委托。由于这个对象没有原型链,所以 instanceof 操作符(之前解释过)无法进行判断,因此总是会返回 false。 这些特殊的空 [[Prototype]] 对象通常被称作“字典”,它们完全不会受到原 型链的干扰,因此非常适合用来存储数据。

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 还解决了什么问题呢? 1.(基本上,下面会详细介绍)不再引用杂乱的 .prototype 了。 2. Button 声 明 时 直 接“ 继 承 ” 了 Widget, 不 再 需 要 通 过 Object.create(..) 来 替 换 .prototype 对象,也不需要设置 .proto 或者 Object.setPrototypeOf(..)。 3. 可以通过 super(..) 来实现相对多态,这样任何方法都可以引用原型链上层的同名方 法。这可以解决第 4 章提到过的那个问题:构造函数不属于类,所以无法互相引用—— super() 可以完美解决构造函数的问题。 4. class 字面语法不能声明属性(只能声明方法)。看起来这是一种限制,但是它会排除 掉许多不好的情况,如果没有这种限制的话,原型链末端的“实例”可能会意外地获取 其他地方的属性(这些属性隐式被所有“实例”所“共享”)。所以,class 语法实际上 可以帮助你避免犯错。 5. 可以通过 extends 很自然地扩展对象(子)类型,甚至是内置的对象(子)类型,比如 Array 或 RegExp。没有 class ..extends 语法时,想实现这一点是非常困难的,基本上 只有框架的作者才能搞清楚这一点。但是现在可以轻而易举地做到! 平心而论,class 语法确实解决了典型原型风格代码中许多显而易见的(语法)问题和 缺点。

class陷阱

class 并不会像传统面向类的语言一样在声明时静态复制所有行为。如果你 (有意或无意)修改或者替换了父“类”中的一个方法,那子“类”和所有实例都会受到 影响,因为它们在定义时并没有进行复制,只是使用基于 [[Prototype]] 的实时委托:

class C { 
	constructor() {
		this.num = Math.random(); 
	}rand() { 
		console.log( "Random: " + this
	} 
}
var c1 = new C(); 
c1.rand(); // "Random: 0.4324299..."
C.prototype.rand = function() { 
	console.log( "Random: " + Math.rou
};
var c2 = new C(); 
c2.rand(); // "Random: 867" c1.rand(); // "Random: 432" ——噢!