重学 JavaScript01:就从面向对象说起吧

用户头像
张理查rootv
关注
发布于: 2020 年 08 月 01 日
重学JavaScript01:就从面向对象说起吧

Macmillan的解释是:



a thing that you can see and touch that is not alive and is usually solid

一种你能够触摸到的实物,并非活物。



新华字典中,对象的基本含义是:



行动或思考时作为目标的人或事物

特指恋爱的对方



“你是我的研究对象,你是我的学习对象,这是今天的讨论对象”但如果单独用,就成了恋爱对方。“这是我对象”



因此对象在中文世界中并非像英文所指的那么普世,于是对象成了计算机领域中的专有名词,代表一切事物的总称。



而从英文解释中,可以体会到,object是人类认知世界,而产生的一种思维抽象。什么意思呢?也就是人类成长过程中,对眼前事物的感知。



我是两个孩子的爸爸,我认真观察过他们,他俩小时候都是先用眼睛看,进入口欲期后用嘴巴感知,这个东西是红色,那个东西冰冰凉,再然后大人们教他们数数,OK,这是两个苹果。于是对象有了属性和值。



《面向对象分析与设计中》,Grady Booch 说,从人类认知角度来说,对象是:



  • 一个可以触摸或者可以看见的东西;

  • 人的智力可以理解的东西;

  • 可以指导思考或行动(进行想象或施加动作)的东西。



对象的特征是:



  • 对象具有唯一标识性:即使完全相同的两个对象,也并非同一个对象。

  • 对象有状态:对象具有状态,同一对象可能处于不同状态之下。

  • 对象具有行为:即对象的状态,可能因为它的行为产生变迁。



计算机中的OBJECT



在计算机语言的设计中,不同的设计者利用着他们对object理解,来对object进行了描述,也就是我们今天耳熟能详的,比如Java、C++ 中基于“类”的面相对象编程的概念。



但Javascript有点特立独行,使用了“原形(Prototype)”。



the first form of something new, made before it is produced in large quantities



这里先不展开。(TODO)



JAVASCRIPT 的OBJECT 之路



由于产生之时的政治原因,JS受管理层要求模仿Java,于是你看到了创始人 Brendan Eich 在“原型运行时”的基础上引入了 new、this 等语言特性,使之“看起来更像 Java”。ES6规范之前,甚至有大量的框架将JS改造成基于类来编程,而这样做的弊端明显要大于收益。



JavaScript 的对象特征



幸运的是,任何语言的运行时,类的概念都会被弱化。



对照上面说的,面向对象的三个特征:唯一、状态、行为



唯一标识性



一般,对所有的语言来说,对象的唯一标识性都是通过内存地址来实现的,对象具有唯一标识的内存地址,也就具有唯一标识了。



也就是为什么,下面的返回为false:



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



状态与行为



对不同的语言来说,状态和行为会使用不同的描述:



  • C++ 中的“成员变量”和“成员函数”

  • Java中的“属性”和“方法”

而在JS中,状态和行为统一抽象成了属性(函数也是一种特殊的对象)。



如下例中,对对象o来说,d 和 f 就是两个普通的属性



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



也就是说,JS能很好地体现对象的基本特征。



JS对象的独有特色



但为什么很多人说“JS不是面向对象”的呢?因为JS的对象设计有其独有的特点,那就是:



对象具有高度的动态性,因为JS能在运行时被修改状态和行为。



你试过Java在运行时添加向对象添加属性吗?JS 能做到!



var o = { a: 1 };
o.b = 2;
console.log(o.a, o.b); //1 2



JS的属性



JS为了提高抽象能力,将属性设计成了(比别的语言)更加复杂的形势,提供了数据属性访问器属性两类。也就是说,JS的属性并非简单的名称(键)和值,而是用了一组attribute(特征)来描述property(属性)。



数据属性



数据属性类似其他语言的“属性”,具有四个特征。



  • 属性的值:value

  • 能否赋值:writable

  • 能否被(如for in)枚举:enumerable

  • 能否被更新(改变或删除):configurable

虽然,我们通常只关系值。



访问器属性



也就是getter/setter属性,它也有四个特征。



  • 取值时调用:getter

  • 设置时调用:setter

  • 能否被(如for in)枚举:enumerable

  • 能否被更新(改变或删除):configurable

也就是说,读和写的时候,访问器属性可以执行代码,让读和写得到完全不同的值,可以看作一种函数的语法糖。



实践一下



这位客官问了,我怎么没设置过什么writable、enumerable、configurable啊?因为通常我们定义属性时,其默认值都是true,那我们怎么看到呢?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}



那如何改变呢?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



那访问器属性呢?



var o = { get a() { return 1 } };
o.a = 2
console.log(o.a); // 1



每次访问属性都会实行getter 或者setter 函数,因此o.a 每次都得到1。



总结一下, JS中的对象,实际上是一个“属性的集合”:



  • 属性的key 为字符串或Symbol,

  • 属性的value 为数据属性特征值或者访问器属性特征值

上面例子中的的a就是key,而它的value,是{writable:true,value:1,configurable:true,enumerable:true}



因此,JS的对象与其他语言相比,有些另类。



特殊却普通



正如我们前面说到的:



任何语言的运行时,类的概念都会被弱化。



因此JS可以模仿多数面向对象编程范式,比如基于原型,甚至基于类。也正因此,JS是正如其语言标准所说的:JS是一门面向对象的语言。



既然这样,我们就不要机械地用JS来模仿其他语言,充分利用它的高度动态性的属性集合这一对象系统,挖掘它的能力吧。



JAVASCRIPT 的面向对象



前面提到了,由于政治原因,JS推出就要求模仿Java, 所以创始人Brendan 在“原型运行时”的基础上引入了new、this等特性。但是,JS缺少了继承等关键特性,导致那个时候社区产生了许多互不兼容的解决方案。直到ES6的推出,JS提供了class 关键字,虽然还是基于原型,但统一了社区方案。



问题来了,基于原型和基于类貌似都可以是面向对象的一种型态。



“基于类”提倡的是关注类和类之间关系。套路是先有类,再去实例化对象。类与类之间可能继承、组合,类又与语言的类型系统整合,形成一定的编译时能力。



我们先假设这个世界上没有类。



什么是原型?



你应该听过“照猫画虎”,猫就被看做了老虎的原型。



“基于原型”符合我们对世界的认知,套路是工程师关注一系列对象实例的行为,然后再关心如何将这些对象,划分到使用方式相似的原型对象,而非将它们分成类。基于原型的对象系统是通过“复制”来创建新对象的。这当中有两种复制思路:



  • 新对象持有一个原型的引用

  • 完全复制一个对象,新老没有关联

 

JavaScript的原型



原型系统相当简单,两条原则:



  • 如果所有对象都有私有字段[[prototype]],就是对象的原型

  • 读取一个属性,如果对象本身没有,就会继续访问对象的原型,直到原型为空,或者找到为止。

ES6 为了更直接地操纵原型,提供了一系列内置函数,三个方法:



  • 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();



从ES6回到远古



回到模仿Java那时,早期的JavaScript中,“类”的定义是一个私有属性[[class]],内置类型如Number、String、Date等指定了[[class]]属性,唯一可以访问的方式是Object.prototype.toString:



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)));
// 0: "[object Object]"
// 1: "[object Number]"
// 2: "[object String]"
// 3: "[object Boolean]"
// 4: "[object Date]"
// 5: "[object Arguments]"
// 6: "[object RegExp]"
// 7: "[object Function]"
// 8: "[object Array]"
// 9: "[object Error]"



ES5开始,[[class]] 私有属性被 Symbol.toStringTag 代替,甚至可以改变toString的行为(字符串加法可以触发toString 的调用):



var o = { [Symbol.toStringTag]: "MyObject" }
console.log(o + "");



JS中的new 到底做了哪些事情呢?



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



  • 以构造器的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__,但是大多数环境不支持)。所以,当时就有人把new 来作为Object.create 使用



// 创建一个空函数作为类,并把传入的原型挂在它的prototype
// 最后创建了一个它的实例
// 这将产生一个以传入参数为原型的对象
Object.create = function(prototype){
var cls = function(){}
cls.prototype = prototype;
return new cls;
}



但这样有个问题,它不支持第二个参数,不支持null作为原型,因此意义已经不大了,算是当时的一种临时解决方案。



回到ES6中的类



ES6中终于加入了新特性class, new 与 function 搭配的怪异代码终于没了,任何情况下,都推荐使用ES6的语法定义类,而让function回归函数的语义。



class Rectangle {
constructor(height, width) {
this.height = height;
this.width = width;
}
// Getter
get area() {
return this.calcArea();
}
// Method
calcArea() {
return this.height * this.width;
}
}



  • get/set 关键字创建getter/setter

  • 数据型成员最好写在构造器中

  • 括号和大括号来创建方法

类的写法实际上也是由原型运行时来承载的,逻辑上JS认为每一个类是有共同原型的一组对象,类中定义的方法和属性则会被写在原型对象上。



类提供了继承能力



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.



与早期的原型模拟方式相比,extends关键字自动设置了constructor, 并自动调用父类的构造函数,这种设计坑更少。



新世界中, class 关键字箭头运算符可以完全替代旧的function关键字,更明确地区分了定义函数和定义类两种意图。



我们不再需要用function关键字来模拟类了,有了正式的新语法,原型体系同时作为一种编程范式和运行时机制存在。但理解运行时的原型系统是十分必要的。



JAVASCRIPT 中的对象



我们所创建对象的能力是十分有限的:比如原生数组底层实现的随下标变化的length属性,比如浏览器中只能靠document.createElement创建div对象,也就是说Javascript 的对象机制并非简单的属性集合 + 原型。



Javascript 中的对象分类



  • 宿主对象(host Objects)由宿主环境提供的对象

  • 内置对象(Built-in Object)Javascript语言提供的对象

普通对象我们暂时不管,我们来看看剩下的几个对象。



宿主对象



先说浏览器这个宿主。



在浏览器环境中,全局对象是window,window又有很多的属性,比如document



而实际上,window上的属性,一部分当然来自浏览器环境,还有一部分来自Javascript 语言,这是由于Javascript 标准和W3C的各种标准。



与内置对象类似,宿主对象也包括固有的对象和用户创建的两种,比如document.createElement 就能创建一些DOM对象



宿主也会提供一些构造器,比如用new Image 来创建img 元素等。



内置对象-固有对象



前面提到,固有对象是标准规定的,随着Javascript 运行时创建而自动创建的对象实例。



固有对象在任何JS代码执行之前就已经创建出来了,通常扮演的是基础库的角色。前面提到的“类”就是固有对象的一种。ECMA中有150+个固有对象。但这个表格也并不完整,一会儿我们会证明。



内置对象-原生对象



JS中,能够通过语言本身的构造器创建的对象叫做原生对象。JS标准中,提供了30多个构造器,大概分为以下几类:





正如前面说的,几乎所有这些构造器的能力,是无法用纯javascript代码实现的,也无法用class/extend 语法来继承。



这些构造器创建的对象,多数使用了私有字段,如:



  • Error: [[ErrorData]]

  • Boolean: [[BooleanData]]

  • Number: [[NumberData]]

  • Date: [[DateValue]]

  • RegExp: [[RegExpMatcher]]

  • Symbol: [[SymbolData]]

  • Map: [[MapData]]

而这些私有字段使得原型继承方法无法正常工作,因此可以将这些原生对象看作是“特权对象”,满足某些特定的能力或者性能。



函数对象与构造器对象



我们换一个角度来看待对象,也就是,用对象来模拟函数和构造器。



JavaScript为这一类对象:



  • 预留了私有字段机制

  • 规定了函数对象

  • 规定了构造器对象

函数对象:具有[[call]]私有字段的对象



构造器对象:具有[[construct]]私有字段的对象



JS用“对象模拟函数”的设计替代了一般变成语言中的函数,任何宿主只要提供了”具有[[call]]私有字段的对象“就能够被JS函数调用语法支持。(这其中其实还有些复杂,[[call]]私有字段必须是一个引擎中定义的函数,需要接受 this 值和调用参数,并且会产生域的切换,后面详细说。)



简单来说:



  • 任何对象只要实现了[[call]], 它就是一个函数对象,可以去作为函数被调用。

  • 任何对象如果实现了[[construct]], 它就是一个构造器对象,可以作为构造器被调用。

用户用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.prototype 为原型创建一个新对象;

  • 以新对象为 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:跟原来的函数相关联。

 

附:JS有多少对象?



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);
}
}



发布于: 2020 年 08 月 01 日 阅读数: 1208
用户头像

张理查rootv

关注

还未添加个人签名 2017.10.17 加入

还未添加个人简介

评论

发布
暂无评论
重学JavaScript01:就从面向对象说起吧