JavaScript

01-类型 阅读更多

运行时类型是代码实际执行过程中用到的类型。所有的类型数据都会属于 7 个类型之一。从变量、参数、返回值到表达式中间结果,任何 JavaScript 代码运行过程中产生的数据,都具有运行时类型。 JavaScript 语言的每一个值都属于某一种数据类型。JavaScript 语言规定了 7 种语言类型。语言类型广泛用于变量、函数参数、表达式、函数返回值等场合。根据最新的语言标准,这 7 种语言类型是: Undefined; Null; Boolean; String; Number; Symbol; Object。 Undefined、Null Undefined 类型表示未定义,它的类型只有一个值,就是 undefined。 任何变量在赋值前是 Undefined 类型、值为 undefined,一般可以用全局变量 undefined(就是名为 undefined 的这个变量)来表达这个值,或者 void 运算来把任意一个表达式变成 undefined 值。 因为 JavaScript 的代码 undefined 是一个变量,而并非是一个关键字,这是 JavaScript 语言公认的设计失误之一,所以,为了避免无意中被篡改,建议使用 void 0 来获取 undefined 值。 Undefined 跟 Null 有一定的表意差别,Null 表示的是:“定义了但是为空”。所以,在实际编程时,一般不会把变量赋值为 undefined,这样可以保证所有值为 undefined 的变量,都是从未赋值的自然状态。 Null 类型也只有一个值,就是 null,它的语义表示空值,与 undefined 不同,null 是 JavaScript 关键字,所以在任何代码中,都可以放心用 null 关键字来获取 null 值。 Boolean Boolean 类型有两个值, true 和 false,它用于表示逻辑意义上的真和假,同样有关键字 true 和 false 来表示两个值。 String String 用于表示文本数据。String 有最大长度是 2^53-1,这在一般开发中都是够用的,但是有趣的是,这个所谓最大长度,并不完全是理解中的字符数。 因为 String 的意义并非“字符串”,而是字符串的 UTF16 编码,字符串的操作 charAt、charCodeAt、length 等方法针对的都是 UTF16 编码。所以,字符串的最大长度,实际上是受字符串的编码长度影响的。 现行的字符集国际标准,字符是以 Unicode 的方式表示的,每一个 Unicode 的码点表示一个字符,理论上,Unicode 的范围是无限的。UTF 是 Unicode 的编码方式,规定了码点在计算机中的表示方法,常见的有 UTF16 和 UTF8。 Unicode 的码点通常用 U+??? 来表示,其中 ??? 是十六进制的码点值。 0-65536(U+0000 - U+FFFF)的码点被称为基本字符区域(BMP)。 JavaScript 中的字符串是永远无法变更的,一旦字符串构造出来,无法用任何方式改变字符串的内容,所以字符串具有值类型的特征。 JavaScript 字符串把每个 UTF16 单元当作一个字符来处理,所以处理非 BMP(超出 U+0000 - U+FFFF 范围)的字符时,应该格外小心。 JavaScript 这个设计继承自 Java,最新标准中是这样解释的,这样设计是为了“性能和尽可能实现起来简单”。因为现实中很少用到 BMP 之外的字符。 Number Number 类型表示通常意义上的“数字”。这个数字大致对应数学中的有理数,在计算机中,有一定的精度限制。 JavaScript 中的 Number 类型有 18437736874454810627(即 2^64-2^53+3) 个值。 JavaScript 中的 Number 类型基本符合 IEEE 754-2008 规定的双精度浮点数规则,但是 JavaScript 为了表达几个额外的语言场景(比如不让除以 0 出错,而引入了无穷大的概念),规定了几个例外情况: NaN,占用了 9007199254740990,这原本是符合 IEEE 规则的数字 Infinity,无穷大 -Infinity,负无穷大 另外,值得注意的是,JavaScript 中有 +0 和 -0,在加法类运算中它们没有区别,但是除法的场合则需要特别留意区分,“忘记检测除以 -0,而得到负无穷大”的情况经常会导致错误。 区分 +0 和 -0 的方式,正是检测 1/x 是 Infinity 还是 -Infinity。 根据双精度浮点数的定义,Number 类型中有效的整数范围是 -0x1fffffffffffff 至 0x1fffffffffffff,所以 Number 无法精确表示此范围外的整数。 同样根据浮点数的定义,非整数的 Number 类型无法用 ==(=== 也不行) 来比较,一段著名的代码,为什么在 JavaScript 中,0.1+0.2 不能 =0.3: console.log(0.1+0.2==0.3);// false 这里输出的结果是 false,说明两边不相等的,这是浮点运算的特点,浮点数运算的精度问题导致等式左右的结果并不是严格相等,而是相差了个微小的值。 所以实际上,这里错误的不是结论,而是比较的方法,正确的比较方法是使用 JavaScript 提供的最小精度值: console.log(Math.abs(0.1+0.2-0.3) <= Number.EPSILON);// true 检查等式左右两边差的绝对值是否小于最小精度,才是正确的比较浮点数的方法。 Symbol Symbol 是 ES6 中引入的新类型,它是一切非字符串的对象 key 的集合,在 ES6 规范中,整个对象系统被用 Symbol 重塑。 Symbol 可以具有字符串类型的描述,但是即使描述相同,Symbol 也不相等。 创建 Symbol 的方式是使用全局的 Symbol 函数。例如: var mySymbol = Symbol("my symbol"); 一些标准中提到的 Symbol,可以在全局的 Symbol 函数的属性中找到。例如,可以使用 Symbol.iterator 来自定义 for…of 在对象上的行为: var o = new Object o[Symbol.iterator] = function(){ var v = 0 return { next : function(){ return {value: v++, done: v>10} } } }; for(var v of o) console.log(v);// 0 1 2 3 ... 9 代码中定义了 iterator 之后,用 for(var v of o) 就可以调用这个函数,然后可以根据函数的行为,产生一个 for…of 的行为。 这里给对象 o 添加了 Symbol.iterator 属性,并且按照迭代器的要求定义了一个 0 到 10 的迭代器,之后就可以在 for of 中愉快地使用这个 o 对象。 这些标准中被称为“众所周知”的 Symbol,也构成了语言的一类接口形式。它们允许编写与语言结合更紧密的 API。 Object Object 是 JavaScript 中最复杂的类型,也是 JavaScript 的核心机制之一。Object 表示对象的意思,它是一切有形和无形物体的总称。 在 JavaScript 中,对象的定义是“属性的集合”。 属性分为数据属性和访问器属性,二者都是 key-value 结构,key 可以是字符串或者 Symbol 类型。 从类型的角度来介绍对象类型。提到对象,必须要提到一个概念:类。因为 C++ 和 Java 的成功,在这两门语言中,每个类都是一个类型,二者几乎等同,以至于很多人常常会把 JavaScript 的“类”与类型混淆。 事实上,JavaScript 中的“类”仅仅是运行时对象的一个私有属性,而 JavaScript 中是无法自定义类型的。 JavaScript 中的几个基本类型,都在对象类型中有一个“亲戚”。它们是:Number;String;Boolean;Symbol。所以,必须认识到 3 与 new Number(3) 是完全不同的值,它们一个是 Number 类型, 一个是对象类型。 Number、String 和 Boolean,三个构造器是两用的,当跟 new 搭配时,它们产生对象,当直接调用时,它们表示强制类型转换。 Symbol 函数比较特殊,直接用 new 调用它会抛出错误,但它仍然是 Symbol 对象的构造器。 JavaScript 语言设计上试图模糊对象和基本类型之间的关系,日常代码可以把对象的方法在基本类型上使用,比如: console.log("abc".chatAt(0));// a 甚至在原型上添加方法,都可以应用于基本类型,比如以下代码,在 Symbol 原型上添加了 hello 方法,在任何 Symbol 类型变量都可以调用。 Symbol.prototype.hello = () => console.log("hello"); var a = Symbol("a"); console.log(typeof a);// Symbol a.hello();// hello 类型转换 因为 JS 是弱类型语言,所以类型转换发生非常频繁,大部分我们熟悉的运算都会先进行类型转换。大部分类型转换符合人类的直觉,但是如果不去理解类型转换的严格定义,很容易造成一些代码中的判断失误。 其中最为臭名昭著的是 JavaScript 中的“ == ”运算,因为试图实现跨类型的比较,它的规则复杂到几乎没人可以记住。 == 的规则,属于设计失误,并非语言中有价值的部分,很多实践中推荐禁止使用“ ==”,而要求程序员进行显式地类型转换后,用 === 比较。 其它运算,如加减乘除大于小于,也都会涉及类型转换。幸好的是,实际上大部分类型转换规则是非常简单的,如下表所示: Null Undefined Boolean(true) Boolean(false) Number String Symbol Object Boolean FALSE FALSE - - 0/NaN-false "'-false TRUE TRUE Number 0 NaN 1 0 - #StringToNumber TypeError #拆箱转换 String “null” “undefined” TRUE FALSE #NumberToString - TypeError #拆箱转换 Object TypeError TypeError #拆箱转换 #拆箱转换 #拆箱转换 #拆箱转换 #拆箱转换 - 表格中是黄色类型转换为绿色类型的规则。 StringToNumber 字符串到数字的类型转换,存在一个语法结构,类型转换支持十进制、二进制、八进制和十六进制,比如:30;0b111;0o13;0xFF。 此外,JavaScript 支持的字符串语法还包括正负号科学计数法,可以使用大写或者小写的 e 来表示:1e3;-1e-2。 需要注意的是,parseInt 和 parseFloat 并不使用这个转换,所以支持的语法跟这里不尽相同。在不传入第二个参数的情况下,parseInt 只支持 16 进制前缀“0x”,而且会忽略非数字字符,也不支持科学计数法。在一些古老的浏览器环境中,parseInt 还支持 0 开头的数字作为 8 进制前缀,这是很多错误的来源。 在任何环境下,都建议传入 parseInt 的第二个参数,而 parseFloat 则直接把原字符串作为十进制来解析,它不会引入任何的其他进制。 多数情况下,Number 是比 parseInt 和 parseFloat 更好的选择。 NumberToString 在较小的范围内,数字到字符串的转换是完全符合你直觉的十进制表示。当 Number 绝对值较大或者较小时,字符串表示则是使用科学计数法表示的。 装箱转换 每一种基本类型 Number、String、Boolean、Symbol 在对象中都有对应的类,所谓装箱转换,正是把基本类型转换为对应的对象,它是类型转换中一种相当重要的种类。 前文提到,全局的 Symbol 函数无法使用 new 来调用,但仍可以利用装箱机制来得到一个 Symbol 对象,可以利用一个函数的 call 方法来强迫产生装箱。 定义一个函数,函数里面只有 return this,然后调用函数的 call 方法到一个 Symbol 类型的值上,这样就会产生一个 symbolObject。 可以用 console.log 看一下这个东西的 type of,它的值是 object,使用 symbolObject instanceof 可以看到,它是 Symbol 这个类的实例,找它的 constructor 也是等于 Symbol 的,所以无论从哪个角度看,它都是 Symbol 装箱过的对象: var symbolObject = (function(){return this;}).call(Symbol("a")); console.log(typeof symbolObject);// object console.log(symbolObject installof Symbol);// true console.log(symbolObject.constructor == Symbol);// true 装箱机制会频繁产生临时对象,在一些对性能要求较高的场景下,应该尽量避免对基本类型做装箱转换。 使用内置的 Object 函数,可以在 JavaScript 代码中显式调用装箱能力。 var symbolObject = Object(Symbol("a")); console.log(typeof symbolObject); //object console.log(symbolObject instanceof Symbol); //true console.log(symbolObject.constructor == Symbol); //true 每一类装箱对象皆有私有的 Class 属性,这些属性可以用 Object.prototype.toString 获取: var symbolObject = Object(Symbol("a")); console.log(Object.prototype.toString.call(symbolObject)); //[object Symbol] 在 JavaScript 中,没有任何方法可以更改私有的 Class 属性,因此 Object.prototype.toString 是可以准确识别对象对应的基本类型的方法,它比 instanceof 更加准确。 但需要注意的是,call 本身会产生装箱操作,所以需要配合 typeof 来区分基本类型还是对象类型。 拆箱转换 在 JavaScript 标准中,规定了 ToPrimitive 函数,它是对象类型到基本类型的转换(即,拆箱转换)。 对象到 String 和 Number 的转换都遵循“先拆箱再转换”的规则。 通过拆箱转换,把对象变成基本类型,再从基本类型转换为对应的 String 或者 Number。 拆箱转换会尝试调用 valueOf 和 toString 来获得拆箱后的基本类型。如果 valueOf 和 toString 都不存在,或者没有返回基本类型,则会产生类型错误 TypeError。 var o = { valueOf : () => {console.log("valueOf"); return {}}, toString : () => {console.log("toString"); return {}} } o * 2 // valueOf // toString // TypeError 定义了一个对象 o,o 有 valueOf 和 toString 两个方法,这两个方法都返回一个对象,然后我们进行 o*2 这个运算的时候,先执行了 valueOf,接下来是 toString,最后抛出了一个 TypeError,这就说明了这个拆箱转换失败了。 到 String 的拆箱转换会优先调用 toString。把刚才的运算从 o*2 换成 String(o),那么调用顺序就变了。 var o = { valueOf : () => {console.log("valueOf"); return {}}, toString : () => {console.log("toString"); return {}} } String(o) // toString // valueOf // TypeError 在 ES6 之后,还允许对象通过显式指定 @@toPrimitive Symbol 来覆盖原有的行为。 var o = { valueOf : () => {console.log("valueOf"); return {}}, toString : () => {console.log("toString"); return {}} } o[Symbol.toPrimitive] = () => {console.log("toPrimitive"); return "hello"} console.log(o + "") // toPrimitive // hello 规范类型 除了这七种语言类型,还有一些语言的实现者更关心的规范类型。 List 和 Record: 用于描述函数传参过程。 Set:主要用于解释字符集等。 Completion Record:用于描述异常、跳出等语句执行过程。 Reference:用于描述对象属性访问、delete 等。 Property Descriptor:用于描述对象的属性。 Lexical Environment 和 Environment Record:用于描述变量和作用域。 Data Block:用于描述二进制数据。 typeof与运行时类型的不一致 事实上,“类型”在 JavaScript 中是一个有争议的概念。 一方面,标准中规定了运行时数据类型; 另一方面,JavaScript 语言中提供了 typeof 这样的运算,用来返回操作数的类型,但 typeof 的运算结果,与运行时类型的规定有很多不一致的地方。 示例表达式 typeof结果 运行时类型行为 null* object Null object Object (function())* function Obejct 3 number Number "ok" string String true boolean Boolean void 0 undefined Undefined Symbol("a") symbol Symbol 在表格中,多数项是对应的,但是请注意 object——Null 和 function——Object 是特例,理解类型的时候需要特别注意这个区别。

02-对象 阅读更多

语言和宿主机的基础设施由对象来提供,并且JavaScript程序即是一系列互相通讯的对象集合。 面向对象 Object(对象)在英文中,是一切事物的总称,这和面向对象编程的抽象思维有互通之处。 从人类的认知角度来说,对象应该是下列事物之一:一个可以触摸或者可以看见的东西;人的智力可以理解的东西;可以指导思考或行动(进行想象或施加动作)的东西。 从运行时角度来谈论对象,就是在讨论 JavaScript 实际运行中的模型,这是由于任何代码执行都必定绕不开运行时的对象模型。幸运的是,从运行时的角度看,可以不必受到这些“基于类的设施”的困扰,因为任何语言运行时类的概念都是被弱化的。 JavaScript对象的特征 划重点!!!,这是面向对象编程语言的基石,对象(唯一、状态、行为)。 对象的本质特征: 对象具有唯一标识性:即使完全相同的两个对象,也并非同一个对象。 对象有状态:对象具有状态,同一对象可能处于不同状态之下。 对象具有行为:即对象的状态,可能因为它的行为产生变迁。 唯一标识性 一般而言,各种语言的对象唯一标识性都是用内存地址来体现的, 对象具有唯一标识的内存地址,所以具有唯一的标识。任何不同的 JavaScript 对象其实是互不相等的。 var o1 = { a: 1}; var o2 = { a: 1}; console.log(o1==o2);// false o1 和 o2 初看是两个一模一样的对象,但是打印出来的结果却是 false。 状态和行为 不同语言会使用不同的术语来抽象描述它们,比如 : C++ 中称它们为“成员变量”和“成员函数” Java 中则称它们为“属性”和“方法” JavaScript 中,将状态和行为统一抽象为“属性” 考虑到 JavaScript 中将函数设计成一种特殊对象,所以 JavaScript 中的行为和状态都能用属性来抽象。 下面这段代码其实就展示了普通属性和函数作为属性的一个例子,其中 o 是对象,d 是一个属性,而函数 f 也是一个属性,对 JavaScript 来说,d 和 f 就是两个普通属性。 var o = { d: 1, f(){ console.log(this.d); } }; 在 JavaScript 中,对象的状态和行为都被抽象为了属性。如果用过 Java,一定不要觉得奇怪,尽管设计思路有一定差别,但是二者都很好地表现了对象的基本特征:标识性、状态和行为。 在实现了对象基本特征的基础上, JavaScript 中对象独有的特色是:对象具有高度的动态性,因为 JavaScript 赋予了使用者在运行时为对象添改状态和行为的能力。 比如,JavaScript 允许运行时向对象添加属性,这就跟绝大多数基于类的、静态的对象设计完全不同。下面这段代码就展示了运行时如何向一个对象添加属性,一开始定义了一个对象 o,定义完成之后,再添加它的属性 b,这样操作是完全没问题的。 var o = { a: 1}; o.b = 2; console.log(o.a,o.b);// 1 2 为了提高抽象能力,JavaScript 的属性被设计成比别的语言更加复杂的形式,它提供了数据属性和访问器属性(getter/setter)两类。 JavaScript对象的两类属性 对 JavaScript 来说,属性并非只是简单的名称和值,JavaScript 用一组特征(attribute)来描述属性(property)。 数据属性 第一类属性,数据属性。它比较接近于其它语言的属性概念。数据属性具有四个特征。 value:就是属性的值。 writable:决定属性能否被赋值。 enumerable:决定 for in 能否枚举该属性。 configurable:决定该属性能否被删除或者改变特征值。 在大多数情况下,我们只关心数据属性的值即可。 访问器属性 第二类属性,访问器(getter/setter)属性。它也有四个特征。 getter:函数或 undefined,在取属性值时被调用。 setter:函数或 undefined,在设置属性值时被调用。 enumerable:决定 for in 能否枚举该属性。 configurable:决定该属性能否被删除或者改变特征值。 访问器属性使得属性在读和写时执行代码,它允许使用者在写和读属性时,得到完全不同的值,它可以视为一种函数的语法糖。 通常用于定义属性的代码会产生数据属性,其中的 writable、enumerable、configurable 都默认为 true。可以使用内置函数 Object.getOwnPropertyDescripter 来查看,如以下代码所示: var o = { a:1}; o.b=2; // a和b皆为数据属性 Object.getOwnPropertyDescripter(o,"a");// {value: 1, writable: true, enumerable: true, configurable: true} Object.getOwnPropertyDescripter(o,"a");// {value: 2, writable: true, enumerable: true, configurable: true} 在这里使用了两种语法来定义属性,定义完属性后,用 JavaScript 的 API 来查看这个属性,可以发现,这样定义出来的属性都是数据属性,writeable、enumerable、configurable 都是默认值为 true。 如果要想改变属性的特征,或者定义访问器属性,可以使用 Object.defineProperty,示例如下: var o = { a: 1 }; Object.defineProperty(o, "b", {value: 2, writable: false, enumerable: false, configurable: true});//a和b都是数据属性,但特征值变化了 Object.getOwnPropertyDescriptor(o,"a"); // {value: 1, writable: true, enumerable: true, configurable: true} Object.getOwnPropertyDescriptor(o,"b"); // {value: 2, writable: false, enumerable: false, configurable: true} o.b = 3; console.log(o.b); // 2 使用 Object.defineProperty 来定义属性,这样定义属性可以改变属性的 writable 和 enumerable。同样用 Object.getOwnPropertyDescriptor 来查看,发现确实改变了 writable 和 enumerable 特征。因为 writable 特征为 false,所以重新对 b 赋值,b 的值不会发生变化。 在创建对象时,也可以使用 get 和 set 关键字来创建访问器属性,代码如下所示: var o = { get a(){ return 1 } }; console.log(o.a);// 1 访问器属性跟数据属性不同,每次访问属性都会执行 getter 或者 setter 函数。这里的 getter 函数返回了 1,所以 o.a 每次都得到 1。 实际上 JavaScript 对象的运行时是一个“属性的集合”,属性以字符串或者 Symbol(Symbol 类型,能够以 Symbol 为属性名,这是 JavaScript 对象的一个特色) 为 key,以数据属性特征值或者访问器属性特征值为 value。 对象是一个属性的索引结构(索引结构是一类常见的数据结构,可以把它理解为一个能够以比较快的速度用 key 来查找 value 的字典)。 以上面的对象 o 为例,可以想象一下“a”是 key。{writable:true,value:1,configurable:true,enumerable:true}是 value。 由于 JavaScript 的对象设计跟目前主流基于类的面向对象差异非常大。可事实上,这样的对象系统设计虽然特别,但是 JavaScript 提供了完全运行时的对象系统,这使得它可以模仿多数面向对象编程范式,所以它也是正统的面向对象语言。 JavaScript 语言标准也已经明确说明,JavaScript 是一门面向对象的语言,因为 JavaScript 的高度动态性的对象系统。 原型 从 ES6 开始,JavaScript 提供了 class 关键字来定义类,尽管,这样的方案仍然是基于原型运行时系统的模拟,但是它修正了之前的一些常见的“坑”,统一了社区的方案,这对语言的发展有着非常大的好处。 “基于类”并非面向对象的唯一形态,如果把视线从“类”移开,Brendan 当年选择的原型系统,就是一个非常优秀的抽象对象的形式。 在不同的编程语言中,设计者也利用各种不同的语言特性来抽象描述对象。 最为成功的流派是使用“类”的方式来描述对象,这诞生了诸如 C++、Java 等流行的编程语言。这个流派叫做基于类的编程语言。 还有一种就是基于原型的编程语言,它们利用原型来描述对象。我们的 JavaScript 就是其中代表。 “基于类”的编程提倡使用一个关注分类和类之间关系开发模型。在这类语言中,总是先有类,再从类去实例化一个对象。类与类之间又可能会形成继承、组合等关系。类又往往与语言的类型系统整合,形成一定编译时的能力。 “基于原型”的编程看起来更为提倡程序员去关注一系列对象实例的行为,而后才去关心如何将这些对象,划分到最近的使用方式相似的原型对象,而不是将它们分成类。基于原型的面向对象系统通过“复制”的方式来创建新对象。一些语言的实现中,还允许复制一个空对象。这实际上就是创建一个全新的对象。 基于原型和基于类都能够满足基本的复用和抽象需求,但是适用的场景不太相同。在 JavaScript 之前,原型系统就更多与高动态性语言配合,并且多数基于原型的语言提倡运行时的原型修改。 原型系统的“复制操作”有两种实现思路: 一个是并不真的去复制一个原型对象,而是使得新对象持有一个原型的引用;这是JavaScript的选择。 另一个是切实地复制对象,从此两个对象再无关联。 JavaScript的原型 如果抛开 JavaScript 用于模拟 Java 类的复杂语法设施(如 new、Function Object、函数的 prototype 属性等),原型系统可以说相当简单,可以用两条概括: 如果所有对象都有私有字段[[prototype]],就是对象的原型; 读一个属性,如果对象本身没有,则会继续访问对象的原型,直到原型为空或者找到为止。 这个模型在 ES 的各个历史版本中并没有很大改变,但从 ES6 以来,JavaScript 提供了一系列内置函数,以便更为直接地访问操纵原型。三个方法分别为: Object.create 根据指定的原型创建新对象,原型可以是 null; Object.getPrototypeOf 获得一个对象的原型; Object.setPrototypeOf 设置一个对象的原型。 利用这三个方法,可以完全抛开类的思维,利用原型来实现抽象和复用。 下面的代码展示了用原型来抽象猫和虎的例子。 var cat = { say(){ console.log("meow~"); }, jump(){ console.log("jump"); } } var tiger = Object.create(cat,{ say:{ value: function(){ console.log("roar!"); }, writable: true, enumerable: true, configurable: true } }) var anotherCat = Object.create(cat); anotherCat.say() var anotherTiger = Object.create(tiger); anotherTiger.say(); 这段代码创建了一个“猫”对象,又根据猫做了一些修改创建了虎,之后完全可以用 Object.create 来创建另外的猫和虎对象,可以通过“原始猫对象”和“原始虎对象”来控制所有猫和虎的行为。 考虑到 new 和 prototype 属性等基础设施今天仍然有效,而且被很多代码使用,学习这些知识也有助于理解运行时的原型工作原理,下面试着回到过去,追溯一下早年的 JavaScript 中的原型和类。 早期版本中的类与原型 在早期版本的 JavaScript 中,“类”的定义是一个私有属性 [[class]],语言标准为内置类型诸如 Number、String、Date 等指定了[[class]]属性,以表示它们的类。语言使用者唯一可以访问[[class]]属性的方式是 Object.prototype.toString。以下代码展示了所有具有内置 class 属性的对象: var o = new Object; var n = new Number; var s = new String; var b = new Boolean; var d = new Date; var arg = function(){ return arguments }(); var r = new RegExp; var f = new Function; var arr = new Array; var e = new Error; console.log([o, n, s, b, d, arg, r, f, arr, e].map(v => Object.prototype.toString.call(v))); 因此,在 ES3 和之前的版本,JS 中类的概念是相当弱的,它仅仅是运行时的一个字符串属性。 在 ES5 开始,[[class]] 私有属性被 Symbol.toStringTag 代替,Object.prototype.toString 的意义从命名上不再跟 class 相关。甚至可以自定义 Object.prototype.toString 的行为,以下代码展示了使用 Symbol.toStringTag 来自定义 Object.prototype.toString 的行为: var o = { [Symbol.toStringTag]: "MyObject"}; console.log(o+""); 这里创建了一个新对象,并且给它唯一的一个属性 Symbol.toStringTag,用字符串加法触发了 Object.prototype.toString 的调用,发现这个属性最终对 Object.prototype.toString 的结果产生了影响。 但是,考虑到 JavaScript 语法中跟 Java 相似的部分,对类的讨论不能用“new运算是针对构造器对象,而不是类”来试图回避。所以,仍然要把 new 理解成 JavaScript 面向对象的一部分。 new 运算接受一个构造器和一组调用参数,实际上做了几件事: 以构造器的 prototype 属性(注意与私有字段[[prototype]]的区分)为原型,创建新对象; 将 this 和调用参数传给构造器,执行; 如果构造器返回的是对象,则返回,否则返回第一步创建的对象。 new 这样的行为,试图让函数对象在语法上跟类变得相似,但是,它客观上提供了两种方式, 一是在构造器中添加属性, 二是在构造器的 prototype 属性上添加属性。 下面代码展示了用构造器模拟类的两种方法: // 第一种方法,直接在构造器中修改this,给this添加属性 function c1(){ this.p1=1; this.p2= function(){ console.log(this.p1); } } var o1 = new c1; o1.p2(); // 第二种方法,修改构造器的prototype属性指向的对象,它是从这个构造器构造出来的所有对象的原型 function c2(){ } c2.prototype.p1=1; c2.prototype.p2=function(){ console.log(this.p1); } var o2 = new c2; o2.p2(); 没有 Object.create、Object.setPrototypeOf 的早期版本中,new 运算是唯一一个可以指定[[prototype]]的方法(当时的 mozilla 提供了私有属性 __proto__,但是多数环境并不支持),所以,当时已经有人试图用它来代替后来的 Object.create,我们甚至可以用它来实现一个 Object.create 的不完整的 polyfill,见以下代码: Object.create = function(prototype){ var cls = function(){} cls.prototype = prototype; return new cls; } 这段代码创建了一个空函数作为类,并把传入的原型挂在了它的 prototype,最后创建了一个它的实例,根据 new 的行为,这将产生一个以传入的第一个参数为原型的对象。这个函数无法做到与原生的 Object.create 一致,一个是不支持第二个参数,另一个是不支持 null 作为原型,所以放到今天意义已经不大了。 ES6中的类 ES6 中加入了新特性 class,new 跟 function 搭配的怪异行为终于可以退休了(虽然运行时没有改变),在任何场景,都推荐使用 ES6 的语法来定义类,而令 function 回归原本的函数语义。 ES6 中引入了 class 关键字,并且在标准中删除了所有[[class]]相关的私有属性描述,类的概念正式从属性升级成语言的基础设施,从此,基于类的编程方式成为了 JavaScript 的官方编程范式。我们先看下类的基本写法: class Recttangle { constructor(height,width){ this.height = height; this.width = width; } // Getter get area(){ return this.calcArea(); } // Method calcArea(){ return this.height * this.width; } } 在现有的类语法中,getter/setter 和 method 是兼容性最好的。 通过 get/set 关键字来创建 getter 通过括号和大括号来创建方法 数据型成员最好写在构造器里面 类的写法实际上也是由原型运行时来承载的,逻辑上 JavaScript 认为每个类是有共同原型的一组对象,类中定义的方法和属性则会被写在原型对象之上。此外,最重要的是,类提供了继承能力。 class Animal{ constructor(name){ this.name = name; } speak(){ console.log(this.name + 'makes a noise.'); } } class Dog extends Animal{ constructor(name){ super(name);// 调用父类构造体并传入name参数 } speak(){ console.log(this.name + 'barks.'); } } let d = new Dog('Mitzie'); d.speak(); // Mitzie barks. 以上代码创造了 Animal 类,并且通过 extends 关键字让 Dog 继承了它,展示了最终调用子类的 speak 方法获取了父类的 name。 比起早期的原型模拟方式,使用 extends 关键字自动设置了 constructor,并且会自动调用父类的构造函数,这是一种更少坑的设计。 所以当我们使用类的思想来设计代码时,应该尽量使用 class 来声明类,而不是用旧语法,拿函数来模拟对象。 一些激进的观点认为,class 关键字和箭头运算符可以完全替代旧的 function 关键字,它更明确地区分了定义函数和定义类两种意图,这是有一定道理的。 JavaScript中的对象分类 可以把对象分成几类。 宿主对象(host Objects):由 JavaScript 宿主环境提供的对象,它们的行为完全由宿主环境决定。 内置对象(Built-in Objects):由 JavaScript 语言提供的对象。 固有对象(Intrinsic Objects ):由标准规定,随着 JavaScript 运行时创建而自动创建的对象实例。 原生对象(Native Objects):可以由用户通过 Array、RegExp 等内置构造器或者特殊语法创建的对象。 普通对象(Ordinary Objects):由语法、Object 构造器或者 class 关键字定义类创建的对象,它能够被原型继承。 宿主对象 JavaScript 宿主对象千奇百怪,但是前端最熟悉的无疑是浏览器环境中的宿主。 在浏览器环境中,全局对象是 window,在它之上又有很多属性,如 document。 实际上,这个全局对象 window 上的属性,一部分来自 JavaScript 语言,一部分来自浏览器环境。 JavaScript 标准中规定了全局对象属性,W3C 的各种标准中规定了 Window 对象的其它属性。 宿主对象分为: 固有对象:宿主提供一些构造器,比如可以使用 new Image 来创建 img 元素 用户可创建对象:比如 document.createElement 就可以创建一些 DOM 对象 内置对象 固有对象 固有对象是由标准规定,随着 JavaScript 运行时创建而自动创建的对象实例。 固有对象在任何 JavaScript 代码执行前就已经被创建出来了,它们通常扮演者类似基础库的角色。 前面提到的“类”其实就是固有对象的一种。ECMA 标准为我们提供了一份固有对象表,里面含有 150+ 个固有对象,点击链接查看。但是遗憾的是,这个表格并不完整。 原生对象 把 JavaScript 中,能够通过语言本身的构造器创建的对象称作原生对象。在 JavaScript 标准中,提供了 30 多个构造器。按照不同应用场景,把原生对象分成了以下几个种类。 基本类型 基础功能和数据结构 错误类型 二进制操作 带类型的数组 Boolean Array Error ArrayBuffer Float32Array String Date EvalError SharedArrayBuffer Float64Array Number RegExp RangeError DataView Int8Array Symbol Promise ReferenceError Int16Array Object Proxy SyntaxError Int32Array Map TypeError UInt8Array WeakMap URIError UInt16Array Set WeakSet Function 通过这些构造器,可以用 new 运算创建新的对象,所以把这些对象称作原生对象。 几乎所有这些构造器的能力都是无法用纯 JavaScript 代码实现的,它们也无法用 class/extend 语法来继承。 这些构造器创建的对象多数使用了私有字段, 例如: Error: [[ErrorData]] Boolean: [[BooleanData]] Number: [[NumberData]] Date: [[DateValue]] RegExp: [[RegExpMatcher]] Symbol: [[SymbolData]] Map: [[MapData]] 这些字段使得原型继承方法无法正常工作,所以,所有这些原生对象都是为了特定能力或者性能,而设计出来的“特权对象”。 对象来模拟函数与构造器:函数对象与构造器对象 在 JavaScript 中,还有一个看待对象的不同视角,这就是用对象来模拟函数和构造器。 事实上,JavaScript 为这一类对象预留了私有字段机制,并规定了抽象的函数对象与构造器对象的概念。 函数对象的定义是:具有[[call]]私有字段的对象, 构造器对象的定义是:具有私有字段[[construct]]的对象。 JavaScript 用对象模拟函数的设计代替了一般编程语言中的函数,它们可以像其它语言的函数一样被调用、传参。任何宿主只要提供了“具有[[call]]私有字段的对象”,就可以被 JavaScript 函数调用语法支持。 [[call]]私有字段必须是一个引擎中定义的函数,需要接受 this 值和调用参数,并且会产生域的切换。 可以这样说,任何对象只需要实现[[call]],它就是一个函数对象,可以去作为函数被调用。而如果它能实现[[construct]],它就是一个构造器对象,可以作为构造器被调用。 对于为 JavaScript 提供运行环境的程序员来说,只要字段符合,在上文中提到的宿主对象和内置对象(如 Symbol 函数)可以模拟函数和构造器。用户用 function 关键字创建的函数必定同时是函数和构造器。不过,它们表现出来的行为效果却并不相同。 对于宿主和内置对象来说,它们实现[[call]](作为函数被调用)和[[construct]](作为构造器被调用)不总是一致的。比如内置对象 Date 在作为构造器调用时产生新的对象,作为函数时,则产生字符串,见以下代码: console.log(new Date); // 1 console.log(Date()) 而浏览器宿主环境中,提供的 Image 构造器,则根本不允许被作为函数调用。 console.log(new Image); console.log(Image());//抛出错误 基本类型(String、Number、Boolean),它们的构造器被当作函数调用,则产生类型转换的效果。值得一提的是,在 ES6 之后 => 语法创建的函数仅仅是函数,它们无法被当作构造器使用,见以下代码: new(a => 0);// error 对于用户使用 function 语法或者 Function 构造器创建的对象来说,[[call]]和[[construct]]行为总是相似的,它们执行同一段代码。 function f(){ return 1; } var v = f(); // 把f作为函数调用 vat o = new f(); // 把f作为构造器调用 大致可以认为,它们[[construct]]的执行过程如下: 以 Object.protoype 为原型创建一个新对象; 以新对象为 this,执行函数的[[call]]; 如果[[call]]的返回值是对象,那么,返回这个对象,否则返回第一步创建的新对象。 这样的规则造成了个有趣的现象,如果构造器返回了一个新的对象,那么 new 创建的新对象就变成了一个构造函数之外完全无法访问的对象,这一定程度上可以实现“私有”。 function cls(){ this.a = 100; return { getValue:() => this.a } } var o = new cls; o.getValue(); //100 //a在外面永远无法访问到 特殊行为的对象 在固有对象和原生对象中,有一些对象的行为跟正常对象有很大区别。 它们常见的下标运算(就是使用中括号或者点来做属性访问)或者设置原型跟普通对象不同。 Array:Array 的 length 属性根据最大的下标自动发生变化。 Object.prototype:作为所有正常对象的默认原型,不能再给它设置原型了。 String:为了支持下标运算,String 的正整数属性访问会去字符串里查找。 Arguments:arguments 的非负整数型下标属性跟对应的变量联动。 模块的 namespace 对象:特殊的地方非常多,跟一般对象完全不一样,尽量只用于 import 吧。 类型数组和数组缓冲区:跟内存块相关联,下标运算比较特殊。 bind 后的 function:跟原来的函数相关联。 全局对象 JavaScript 语言规定了全局对象的属性。 三个值: Infinity NaN undefined 九个函数: eval isFinite isNaN parseFloat parseInt decodeURI decodeURIComponent encodeURI encodeURIComponent 一些构造器: Array Date RegExp Promise Proxy Map WeakMap Set WeakSet Function Boolean String Number Symbol Object Error EvalError RangeError ReferenceError SyntaxError TypeError URIError ArrayBuffer SharedArrayBuffer DataView Typed Array Float32Array Float64Array Int8Array Int16Array Int32Array UInt8Array UInt16Array UInt32Array UInt8ClampedArray 四个用于当作命名空间的对象: Atomics JSON Math Reflect