美文网首页前端
《重学前端》笔记: 101 JavaScript: 类型和对象

《重学前端》笔记: 101 JavaScript: 类型和对象

作者: Ian_Sean | 来源:发表于2020-06-28 15:21 被阅读0次

    《重学前端》笔记: 模块一: JavaScript: 类型和对象

    JavaScript类型:关于类型,有哪些你不知道的细节?

    • 就从运行时的角度去看 JavaScript 的类型系统。运行时类型是代码实际执行过程中我们用到的类型。所有的类型数据都会属于 7 个类型之一。从变量、参数、返回值到表达式中间结果,任何 JavaScript 代码运行过程中产生的数据,都具有运行时类型。

    问题

    • 为什么有的编程规范要求用 void 0代替 undefined.
    • 字符串有最大长度吗?
    • 0.1 + 0.2 不是等于 0.3 么?为什么 JavaScript 里不是这样的
    • ES6 新加入的 Symbol 是个什么东西?
    • 为什么给对象添加的方法能用在基本类型上?

    类型

    • Undefined
    • Null
    • Boolean
    • String
    • Number
    • Symbol
    • Object

    Undefined、Null

    • Undefined类型表示未定义,它的类型只有一个值,就是 undefined
    • 任何变量在赋值前是 Undefined 类型、值为 undefined
    • 一般我们可以用全局变量 undefined(就是名为 undefined 的这个变量)来表达这个值,或者 void 运算来把任意一个表达式变成 undefined 值。
    • 为什么有的编程规范要求用 void 0 代替 undefined.
      • 因为 JavaScript 的代码 undefined 是一个变量,而并非是一个关键字,这是 JavaScript 语言公认的设计失误之一,所以,我们为了避免无意中被篡改,建议使用 void 0 来获取 undefined 值。
    • UndefinedNull 有一定的表意差别,Null 表示的是:“定义了但是为空”。所以,在实际编程时,我们一般不会把变量赋值为 undefined,这样可以保证所有值为 undefined 的变量,都是从未赋值的自然状态。
    • Null 类型也只有一个值,就是 null,它的语义表示空值,与 undefined 不同,null 是 JavaScript 关键字,所以在任何代码中,你都可以放心用 null 关键字来获取 null 值。

    Boolean

    Boolean 类型有两个值, truefalse,它用于表示逻辑意义上的真和假,同样有关键字 truefalse 来表示两个值。

    String

    我们来看看字符串是否有最大长度。

    • String 用于表示文本数据。String 有最大长度是 2^53 - 1,这在一般开发中都是够用的,但是有趣的是,这个所谓最大长度,并不完全是你理解中的字符数

    因为 String 的意义并非“字符串”,而是字符串的 UTF16 编码,我们字符串的操作 charAtcharCodeAtlength 等方法针对的都是 UTF16 编码。所以,字符串的最大长度,实际上是受字符串的编码长度影响的。
    Note:现行的字符集国际标准,字符是以 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 之外的字符。

    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 的行为。
    这些标准中被称为“众所周知”的 Symbol,也构成了语言的一类接口形式。它们允许编写与语言结合更紧密的 API。

    Object

    Object 是 JavaScript 中最复杂的类型,也是 JavaScript 的核心机制之一。Object 表示对象的意思,它是一切有形和无形物体的总称。

    为什么给对象添加的方法能用在基本类型上?

    • 在 JavaScript 中,对象的定义是“属性的集合”。属性分为数据属性和访问器属性,二者都是 key-value 结构,key 可以是字符串或者 Symbol 类型。
    • 因为 C++ 和 Java 的成功,在这两门语言中,每个类都是一个类型,二者几乎等同,以至于很多人常常会把 JavaScript 的“类”与类型混淆。
    • JavaScript 中的“类”仅仅是运行时对象的一个私有属性,而 JavaScript 中是无法自定义类型的。
    • JavaScript 中的几个基本类型,都在对象类型中有一个“亲戚”。它们是:Number;String;Boolean;Symbol。
    • 所以,我们必须认识到 3new Number(3) 是完全不同的值,它们一个是Number 类型, 一个是对象类型
    • Number、String 和 Boolean,三个构造器是两用的,当跟 new 搭配时,它们产生对象,当直接调用时,它们表示强制类型转换。
    • Symbol 函数比较特殊,直接用 new 调用它会抛出错误,但它仍然是 Symbol 对象的构造器。
    • JavaScript 语言设计上试图模糊对象和基本类型之间的关系,我们日常代码可以把对象的方法在基本类型上使用,比如:console.log("abc".charAt(0)); //a
    • 甚至我们在原型上添加方法,都可以应用于基本类型,比如以下代码,在 Symbol 原型上添加了 hello 方法,在任何 Symbol 类型变量都可以调用。
    Symbol.prototype.hello = () => console.log("hello");
    var a = Symbol("a");
    console.log(typeof a); //symbol,a 并非对象
    a.hello(); //hello,有效
    

    所以我们文章开头的问题,答案就是: 运算符提供了装箱操作,它会根据基础类型构造一个临时对象,使得我们能在基础类型上调用对应对象的方法。

    类型转换

    • 臭名昭著的是 JavaScript 中的“ == ”运算,因为试图实现跨类型的比较,它的规则复杂到几乎没人可以记住。
    • 其它运算,如加减乘除大于小于,也都会涉及类型转换。幸好的是,实际上大部分类型转换规则是非常简单的,如下表所示:
    image.png

    StringToNumber

    • 字符串到数字的类型转换,存在一个语法结构,类型转换支持十进制、二进制、八进制和十六进制,比如:30; 0b111; 0o13; 0xFF。
    • 此外,JavaScript 支持的字符串语法还包括正负号科学计数法,可以使用大写或者小写的 e 来表示:1e3; -1e-2。
    • 需要注意的是,parseIntparseFloat不使用这个转换,所以支持的语法跟这里不尽相同。
      • 在不传入第二个参数的情况下,parseInt 只支持 16 进制前缀“0x”,而且会忽略非数字字符,也不支持科学计数法
    • 多数情况下,Number 是比 parseIntparseFloat 更好的选择

    NumberToString

    • 在较小的范围内,数字到字符串的转换是完全符合你直觉的十进制表示。当 Number 绝对值较大或者较小时,字符串表示则是使用科学计数法表示的。这个算法细节繁多,我们从感性的角度认识,它其实就是保证了产生的字符串不会过长。
    • 具体的算法,你可以去参考 JavaScript 的语言标准。由于这个部分内容,我觉得在日常开发中很少用到,所以这里我就不去详细地讲解了。

    装箱转换

    • 每一种基本类型 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 instanceof 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
    • 拆箱转换会尝试调用 valueOftoString 来获得拆箱后的基本类型。如果 valueOftoString 都不存在,或者没有返回基本类型,则会产生类型错误 TypeError
    var o = {
        valueOf : () => {console.log("valueOf"); return {}},
        toString : () => {console.log("toString"); return {}}
    }
    o * 2
    // valueOf
    // toString
    // TypeError
    

    我们定义了一个对象 o,o 有 valueOftoString 两个方法,这两个方法都返回一个对象,然后我们进行 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:用于描述二进制数据。

    补充阅读

    事实上,“类型”在 JavaScript 中是一个有争议的概念。一方面,标准中规定了运行时数据类型; 另一方面,JavaScript 语言中提供了 typeof 这样的运算,用来返回操作数的类型,但 typeof 的运算结果,与运行时类型的规定有很多不一致的地方。我们可以看下表来对照一下。

    image.png

    在表格中,多数项是对应的,但是请注意 object——Null 和 function——Object 是特例,我们理解类型的时候需要特别注意这个区别。

    JavaScript对象:面向对象还是基于对象?

    一些新人在学习 JavaScript 面向对象时,往往也会有疑惑:

    • 为什么 JavaScript(直到 ES6)有对象的概念,但是却没有像其他的语言那样,有类的概念呢;
    • 为什么在 JavaScript 对象里可以自由添加属性,而其他的语言却不能呢?

    甚至,在一些争论中,有人强调:JavaScript 并非“面向对象的语言”,而是“基于对象的语言”。这个说法一度流传甚广,而事实上,我至今遇到的持有这一说法的人中,无一能够回答“如何定义面向对象和基于对象”这个问题。
    实际上,基于对象和面向对象两个形容词都出现在了 JavaScript 标准的各个版本当中。
    我们可以先看看 JavaScript 标准对基于对象的定义,这个定义的具体内容是:“语言和宿主的基础设施由对象来提供,并且 JavaScript 程序即是一系列互相通讯的对象集合”。
    这里的意思根本不是表达弱化的面向对象的意思,反而是表达对象对于语言的重要性。

    什么是面向对象?

    我们先来说说什么是对象,因为翻译的原因,中文语境下我们很难理解“对象”的真正含义。事实上,Object(对象)在英文中,是一切事物的总称,这和面向对象编程的抽象思维有互通之处。
    中文的“对象”却没有这样的普适性,我们在学习编程的过程中,更多是把它当作一个专业名词来理解。
    但不论如何,我们应该认识到,对象并不是计算机领域凭空造出来的概念,它是顺着人类思维模式产生的一种抽象(于是面向对象编程也被认为是:更接近人类思维模式的一种编程范式)。

    那么,我们先来看看在人类思维模式下,对象究竟是什么。

    对象这一概念在人类的幼儿期形成,这远远早于我们编程逻辑中常用的值、过程等概念。
    在幼年期,我们总是先认识到某一个苹果能吃(这里的某一个苹果就是一个对象),继而认识到所有的苹果都可以吃(这里的所有苹果,就是一个类),再到后来我们才能意识到三个苹果和三个梨之间的联系,进而产生数字“3”(值)的概念。

    在《面向对象分析与设计》这本书中,Grady Booch 替我们做了总结,他认为,从人类的认知角度来说,对象应该是下列事物之一:

    • 一个可以触摸或者可以看见的东西;
    • 人的智力可以理解的东西;
    • 可以指导思考或行动(进行想象或施加动作)的东西。

    有了对象的自然定义后,我们就可以描述编程语言中的对象了。在不同的编程语言中,设计者也利用各种不同的语言特性来抽象描述对象,最为成功的流派是使用“类”的方式来描述对象,这诞生了诸如 C++、Java 等流行的编程语言。

    而 JavaScript 早年却选择了一个更为冷门的方式:原型(关于原型,我在下一篇文章会重点介绍,这里你留个印象就可以了)。这是我在前面说它不合群的原因之一。

    然而很不幸,因为一些公司政治原因,JavaScript 推出之时受管理层之命被要求模仿 Java,所以,JavaScript 创始人 Brendan Eich 在“原型运行时”的基础上引入了 new、this 等语言特性,使之“看起来更像 Java”。

    在 ES6 出现之前,大量的 JavaScript 程序员试图在原型体系的基础上,把 JavaScript 变得更像是基于类的编程,进而产生了很多所谓的“框架”,比如 PrototypeJS、Dojo。

    事实上,它们成为了某种 JavaScript 的古怪方言,甚至产生了一系列互不相容的社群,显然这样做的收益是远远小于损失的。

    如果我们从运行时角度来谈论对象,就是在讨论 JavaScript 实际运行中的模型,这是由于任何代码执行都必定绕不开运行时的对象模型。

    不过,幸运的是,从运行时的角度看,可以不必受到这些“基于类的设施”的困扰,这是因为任何语言运行时类的概念都是被弱化的。

    首先我们来了解一下 JavaScript 是如何设计对象模型的。

    JavaScript 对象的特征

    在我看来,不论我们使用什么样的编程语言,我们都先应该去理解对象的本质特征(参考 Grandy Booch《面向对象分析与设计》)。总结来看,对象有如下几个特点。

    • 对象具有唯一标识性:即使完全相同的两个对象,也并非同一个对象。
    • 对象有状态:对象具有状态,同一对象可能处于不同状态之下。
    • 对象具有行为:即对象的状态,可能因为它的行为产生变迁。

    我们先来看第一个特征,对象具有唯一标识性。一般而言,各种语言的对象唯一标识性都是用内存地址来体现的, 对象具有唯一标识的内存地址,所以具有唯一的标识。

    所以,JavaScript 程序员都知道,任何不同的 JavaScript 对象其实是互不相等的,我们可以看下面的代码,o1 和 o2 初看是两个一模一样的对象,但是打印出来的结果却是 false。

    var o1 = { a: 1 };
    var o2 = { a: 1 };
    console.log(o1 == o2); // false
    

    关于对象的第二个和第三个特征“状态和行为”,不同语言会使用不同的术语来抽象描述它们,比如 C++ 中称它们为“成员变量”和“成员函数”,Java 中则称它们为“属性”和“方法”。

    在 JavaScript 中,将状态和行为统一抽象为“属性”,考虑到 JavaScript 中将函数设计成一种特殊对象(关于这点,我会在后面的文章中详细讲解,此处先不用细究),所以 JavaScript 中的行为和状态都能用属性来抽象。

    下面这段代码其实就展示了普通属性和函数作为属性的一个例子,其中 o 是对象,d 是一个属性,而函数 f 也是一个属性,尽管写法不太相同,但是对 JavaScript 来说,df 就是两个普通属性。

    var o = { 
        d: 1,
        f() {
            console.log(this.d);
        }    
    };
    

    所以,总结一句话来看,在 JavaScript 中,对象的状态和行为其实都被抽象为了属性。如果你用过 Java,一定不要觉得奇怪,尽管设计思路有一定差别,但是二者都很好地表现了对象的基本特征:标识性、状态和行为。

    在实现了对象基本特征的基础上, 我认为,JavaScript 中对象独有的特色是:对象具有高度的动态性,这是因为 JavaScript 赋予了使用者在运行时为对象添改状态和行为的能力

    我来举个例子,比如,JavaScript 允许运行时向对象添加属性,这就跟绝大多数基于类的、静态的对象设计完全不同。如果你用过 Java 或者其它别的语言,肯定会产生跟我一样的感受。

    下面这段代码就展示了运行时如何向一个对象添加属性,一开始我定义了一个对象 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.getOwnPropertyDescriptor(o,"a") // {value: 1, writable: true, enumerable: true, configurable: true}
    Object.getOwnPropertyDescriptor(o,"b") // {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 为 key,以数据属性特征值或者访问器属性特征值为 value。

    对象是一个属性的索引结构(索引结构是一类常见的数据结构,我们可以把它理解为一个能够以比较快的速度用 key 来查找 value 的字典)。我们以上面的对象 o 为例,你可以想象一下“a”是 key, {writable:true,value:1,configurable:true,enumerable:true} 是 value。我们在前面的类型课程中,已经介绍了 Symbol 类型,能够以 Symbol 为属性名,这是 JavaScript 对象的一个特色。

    讲到了这里,如果你理解了对象的特征,也就不难理解我开篇提出来的问题。

    你甚至可以理解为什么会有“JavaScript 不是面向对象”这样的说法了。这是由于 JavaScript 的对象设计跟目前主流基于类的面向对象差异非常大。

    可事实上,这样的对象系统设计虽然特别,但是 JavaScript 提供了完全运行时的对象系统,这使得它可以模仿多数面向对象编程范式(下一节课我们会给你介绍 JavaScript 中两种面向对象编程的范式:基于类和基于原型),所以它也是正统的面向对象语言。

    JavaScript 语言标准也已经明确说明,JavaScript 是一门面向对象的语言,我想标准中能这样说,正是因为 JavaScript 的高度动态性的对象系统。

    所以,我们应该在理解其设计思想的基础上充分挖掘它的能力,而不是机械地模仿其它语言。

    结语

    要想理解 JavaScript 对象,必须清空我们脑子里“基于类的面向对象”相关的知识,回到人类对对象的朴素认知和面向对象的语言无关基础理论,我们就能够理解 JavaScript 面向对象设计的思路。

    在这篇文章中,我从对象的基本理论出发,和你理清了关于对象的一些基本概念,分析了 JavaScript 对象的设计思路。接下来又从运行时的角度,介绍了 JavaScript 对象的具体设计:具有高度动态性的属性集合。

    很多人在思考 JavaScript 对象时,会带着已有的“对象”观来看问题,最后的结果当然就是“剪不断理还乱”了。

    在后面的文章中,我会继续带你探索 JavaScript 对象的一些机制,看 JavaScript 如何基于这样的动态对象模型设计自己的原型系统,以及你熟悉的函数、类等基础设施。

    JavaScript对象:我们真的需要模拟类吗?

    早期的 JavaScript 程序员一般都有过使用 JavaScript“模拟面向对象”的经历。

    在上一篇文章我们已经讲到,JavaScript 本身就是面向对象的,它并不需要模拟,只是它实现面向对象的方式和主流的流派不太一样,所以才让很多人产生了误会。

    那么,随着我们理解的思路继续深入,这些“模拟面向对象”,实际上做的事情就是“模拟基于类的面向对象”。

    尽管我认为,“类”并非面向对象的全部,但我们不应该责备社区出现这样的方案,事实上,因为一些公司的政治原因,JavaScript 推出之时,管理层就要求它去模仿 Java。

    所以,JavaScript 创始人 Brendan Eich 在“原型运行时”的基础上引入了 new、this 等语言特性,使之“看起来语法更像 Java”,而 Java 正是基于类的面向对象的代表语言之一。

    但是 JavaScript 这样的半吊子模拟,缺少了继承等关键特性,导致大家试图对它进行修补,进而产生了种种互不相容的解决方案。

    庆幸的是,从 ES6 开始,JavaScript 提供了 class 关键字来定义类,尽管,这样的方案仍然是基于原型运行时系统的模拟,但是它修正了之前的一些常见的“坑”,统一了社区的方案,这对语言的发展有着非常大的好处。

    实际上,我认为“基于类”并非面向对象的唯一形态,如果我们把视线从“类”移开,Brendan 当年选择的原型系统,就是一个非常优秀的抽象对象的形式。

    我们从头讲起。

    什么是原型?

    原型是顺应人类自然思维的产物。中文中有个成语叫做“照猫画虎”,这里的猫看起来就是虎的原型,所以,由此我们可以看出,用原型来描述对象的方法可以说是古已有之。

    我们在上一节讲解面向对象的时候提到了:在不同的编程语言中,设计者也利用各种不同的语言特性来抽象描述对象。

    最为成功的流派是使用“类”的方式来描述对象,这诞生了诸如 C++、Java 等流行的编程语言。这个流派叫做基于类的编程语言。

    还有一种就是基于原型的编程语言,它们利用原型来描述对象。我们的 JavaScript 就是其中代表。

    “基于类”的编程提倡使用一个关注分类和类之间关系开发模型。在这类语言中,总是先有类,再从类去实例化一个对象。类与类之间又可能会形成继承、组合等关系。类又往往与语言的类型系统整合,形成一定编译时的能力。

    与此相对,“基于原型”的编程看起来更为提倡程序员去关注一系列对象实例的行为,而后才去关心如何将这些对象,划分到最近的使用方式相似的原型对象,而不是将它们分成类。

    基于原型的面向对象系统通过“复制”的方式来创建新对象。一些语言的实现中,还允许复制一个空对象。这实际上就是创建一个全新的对象。

    基于原型和基于类都能够满足基本的复用和抽象需求,但是适用的场景不太相同。

    这就像专业人士可能喜欢在看到老虎的时候,喜欢用猫科豹属豹亚种来描述它,但是对一些不那么正式的场合,“大猫”可能更为接近直观的感受一些(插播一个冷知识:比起老虎来,美洲狮在历史上相当长时间都被划分为猫科猫属,所以性格也跟猫更相似,比较亲人)。

    我们的 JavaScript 并非第一个使用原型的语言,在它之前,self、kevo 等语言已经开始使用原型来描述对象了。

    事实上,Brendan 更是曾透露过,他最初的构想是一个拥有基于原型的面向对象能力的 scheme 语言(但是函数式的部分是另外的故事,这篇文章里,我暂时不做详细讲述)。

    在 JavaScript 之前,原型系统就更多与高动态性语言配合,并且多数基于原型的语言提倡运行时的原型修改,我想,这应该是 Brendan 选择原型系统很重要的理由。

    原型系统的“复制操作”有两种实现思路:

    • 一个是并不真的去复制一个原型对象,而是使得新对象持有一个原型的引用;
    • 另一个是切实地复制对象,从此两个对象再无关联。

    历史上的基于原型语言因此产生了两个流派,显然,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:{
            writable:true,
            configurable:true,
            enumerable:true,
            value:function(){
                console.log("roar!");
            }
        }
    })
    
    
    var anotherCat = Object.create(cat);
    anotherCat.say();
    
    var anotherTiger = Object.create(tiger);
    anotherTiger.say();
    
    

    这段代码创建了一个“猫”对象,又根据猫做了一些修改创建了虎,之后我们完全可以用 Object.create 来创建另外的猫和虎对象,我们可以通过“原始猫对象”和“原始虎对象”来控制所有猫和虎的行为。

    但是,在更早的版本中,程序员只能通过 Java 风格的类接口来操纵原型运行时,可以说非常别扭。

    考虑到 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 + ""); // [object MyObject]
    

    这里创建了一个新对象,并且给它唯一的一个属性 Symbol.toStringTag,我们用字符串加法触发了 Object.prototype.toString 的调用,发现这个属性最终对 Object.prototype.toString 的结果产生了影响。

    但是,考虑到 JavaScript 语法中跟 Java 相似的部分,我们对类的讨论不能用“new 运算是针对构造器对象,而不是类”来试图回避。

    所以,我们仍然要把 new 理解成 JavaScript 面向对象的一部分,下面我就来讲一下 new 操作具体做了哪些事情。

    new 运算接受一个构造器和一组调用参数,实际上做了几件事:

    • 以构造器的 prototype 属性(注意与私有字段 [[prototype]] 的区分)为原型,创建新对象;
    • 将 this 和调用参数传给构造器,执行;
    • 如果构造器返回的是对象,则返回,否则返回第一步创建的对象。

    new 这样的行为,试图让函数对象在语法上跟类变得相似,但是,它客观上提供了两种方式,一是在构造器中添加属性,二是在构造器的 prototype 属性上添加属性。

    下面代码展示了用构造器模拟类的两种方法:

    
    function c1(){
        this.p1 = 1;
        this.p2 = function(){
            console.log(this.p1);
        }
    } 
    var o1 = new c1;
    o1.p2();
    
    function c2(){
    }
    c2.prototype.p1 = 1;
    c2.prototype.p2 = function(){
        console.log(this.p1);
    }
    
    var o2 = new c2;
    o2.p2();
    
    

    第一种方法是直接在构造器中修改 this,给 this 添加属性。

    第二种方法是修改构造器的 prototype 属性指向的对象,它是从这个构造器构造出来的所有对象的原型。

    没有 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 中的类。

    ES6 中引入了 class 关键字,并且在标准中删除了所有 [[class]] 相关的私有属性描述,类的概念正式从属性升级成语言的基础设施,从此,基于类的编程方式成为了 JavaScript 的官方编程范式。

    我们先看下类的基本写法:

    class Rectangle {
      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); // call the super class constructor and pass in the name parameter
      }
    
      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 关键字,它更明确地区分了定义函数和定义类两种意图,我认为这是有一定道理的。

    总结

    在新的 ES 版本中,我们不再需要模拟类了:我们有了光明正大的新语法。而原型体系同时作为一种编程范式和运行时机制存在。

    我们可以自由选择原型或者类作为代码的抽象风格,但是无论我们选择哪种,理解运行时的原型系统都是很有必要的一件事。

    JavaScript对象:你知道全部的对象分类吗?

    JavaScript 中的对象分类

    • 宿主对象(host Objects):由 JavaScript 宿主环境提供的对象,它们的行为完全由宿主环境决定。
    • 内置对象(Built-in Objects):由 JavaScript 语言提供的对象。
      • 固有对象(Intrinsic Objects ):由标准规定,随着 JavaScript 运行时创建而自动创建的对象实例。
      • 原生对象(Native Objects):可以由用户通过 Array、RegExp 等内置构造器或者特殊语法创建的对象。
      • 普通对象(Ordinary Objects):由{}语法、Object 构造器或者 class 关键字定义类创建的对象,它能够被原型继承。

    宿主对象

    JavaScript 宿主对象千奇百怪,但是前端最熟悉的无疑是浏览器环境中的宿主了。
    在浏览器环境中,我们都知道全局对象是 windowwindow 上又有很多属性,如 document
    实际上,这个全局对象 window上的属性,一部分来自 JavaScript 语言,一部分来自浏览器环境。

    JavaScript 标准中规定了全局对象属性,W3C 的各种标准中规定了 Window 对象的其它属性。

    宿主对象也分为固有的和用户可创建的两种,比如 document.createElement 就可以创建一些 DOM 对象。

    宿主也会提供一些构造器,比如我们可以使用 new Image 来创建 img 元素,这些我们会在浏览器的 API 部分详细讲解。

    内置对象·固有对象

    我们在前面说过,固有对象是由标准规定,随着 JavaScript 运行时创建而自动创建的对象实例。
    固有对象在任何 JavaScript 代码执行前就已经被创建出来了,它们通常扮演者类似基础库的角色。我们前面提到的“类”其实就是固有对象的一种。

    ECMA 标准为我们提供了一份固有对象表,里面含有 150+ 个固有对象。你可以通过这个链接查看

    但是遗憾的是,这个表格并不完整。所以在本篇的末尾,我设计了一个小实验(小实验:获取全部 JavaScript 固有对象),你可以自己尝试一下,数一数一共有多少个固有对象。

    内置对象·原生对象

    我们把 JavaScript 中,能够通过语言本身的构造器创建的对象称作原生对象。在 JavaScript 标准中,提供了 30 多个构造器。按照我的理解,按照不同应用场景,我把原生对象分成了以下几个种类。


    image.png

    通过这些构造器,我们可以用 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 作为函数调用
    var 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 中用对象来模拟函数和构造器的机制。

    这是一些不那么有规律、不那么优雅的知识,而 JavaScript 正是通过这些对象,提供了很多基础的能力。

    我们这次课程留一个挑战任务:不使用 new 运算符,尽可能找到获得对象的方法。

    小实验:获取全部 JavaScript 固有对象

    我们从 JavaScript 标准中可以找到全部的 JavaScript 对象定义。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

    我们使用广度优先搜索,查找这些对象所有的属性和 Getter/Setter,就可以获得 JavaScript 中所有的固有对象。

    请你试着先不看我的代码,在自己的浏览器中计算出来 JavaScript 有多少固有对象。

    var set = new Set();
    var objects = [
        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,
        Float32Array,
        Float64Array,
        Int8Array,
        Int16Array,
        Int32Array,
        Uint8Array,
        Uint16Array,
        Uint32Array,
        Uint8ClampedArray,
        Atomics,
        JSON,
        Math,
        Reflect];
    objects.forEach(o => set.add(o));
    
    for(var i = 0; i < objects.length; i++) {
        var o = objects[i]
        for(var p of Object.getOwnPropertyNames(o)) {
            var d = Object.getOwnPropertyDescriptor(o, p)
            if( (d.value !== null && typeof d.value === "object") || (typeof d.value === "function"))
                if(!set.has(d.value))
                    set.add(d.value), objects.push(d.value);
            if( d.get )
                if(!set.has(d.get))
                    set.add(d.get), objects.push(d.get);
            if( d.set )
                if(!set.has(d.set))
                    set.add(d.set), objects.push(d.set);
        }
    }
    

    相关文章

      网友评论

        本文标题:《重学前端》笔记: 101 JavaScript: 类型和对象

        本文链接:https://www.haomeiwen.com/subject/uokwfktx.html