JavaScript学习笔记:对象与类
对象
定义
对象是JavaScript最基本的数据类型。在JS中,几乎一切数据都是对象。即使是原始类型,也都有对应的包装类型,除number外,字面量可以当作对象直接使用,因为js引擎在解析时会隐式地将其转换为对应的包装对象,用完后在销毁。
与静态的面向对象语言采用静态复制的方式来继承与创建对象不同,JS中对象的创建采用委托的设计,这种方式更加契合JS作为动态的弱类型的脚本语言的特性。这种设计在JS中被称为原型继承。
对象是一组属性的无序集合,每个属性都有属性名与值。属性名是字符串类型或Symbol(ES6及以后)类型,值为任意类型。
创建对象
定义一个对象有三种方式:
-
字面量定义
若属性名,是字符串或其他可以转换为字符串的直接量,可以省略引号及中括号。若是运算表达式,则需要用中括号包裹。let obj = { foo: bar, 'name': 'Tom', [1*0]: undefied, ['length']: 1 };
若已定义的变量与对象的属性同名,则对象字面量可以简写:
let x=0, y=1, z=2; ({x,y,z}).x // 0
当然,方法也是可以简写的:
let o = { fn: function() {}, ["foo"+'bar']: function() {} }; // 简写为 o = { fn() {}, ["foo"+'bar']() {} }
使用符号类型的属性名。符号类型使用Symbol()工厂函数生产,每个符号类型的值都是不同的,这意味这可以用它创建一个唯一的属性名来扩展对象而不与其他任何属性冲突。
let extension = Symbol('foo'); let o = { [extension]: {} }
处理数据属性,就是还支持访问器属性,有点像JavaBean。
let o = { foo: 123, get data() {return this.foo;}, set ['data'](v) {this.foo = v;} }
-
使用new关键字创建对象
与其他面向对象的编程语言一样,js也有new关键字,用于创建与初始化一个对象。new关键字后面必须跟一个函数调用,这个函数被称为构造函数。
Js内置了许多构造函数,构造函数可以看作是一个类。let o = new Object(), a = new Array(), d = new Date(), m = new Map; // 若不向构造函数传参,可以省略括号 // 构造函数使用驼峰写法是一种约定俗成的编码惯例,用于区分一般函数,主不是必需的 const SelfIntro = function (name, gender, age, hobby) { this.name = name; this.gender = gender; this.age = age; this.hobby = hobby; this.show = function() { console.log(`I am ${name},a ${gender}, ${age} yeard old.My hobby is ${hobby}.`); }; } let YingZhan = ['YingZhan', 'girl', 3, 'pulling bows'], YingZhanIntro = new SelfIntro(...YingZhan); YingZhanIntro.show();
-
使用Object.create(proto[, propertiesObject])
该静态方法可接收两个参数,第二个是可选的。第一个参数为新建的对象指定原型对象(还可以是null),第二个参数为对象指定属性及属性的特性,该参数与Object.defineProperties()的第二个参数格式一致。let obj = Object.create(Object.prototype), // 继承了Object对象的属性与方法 obj = Object.create(null), // 不继承任何属性与方法 obj = Object.create({x: 1, f: () => {}}); // 继承了x成员与f方法,另外还继承了Object对象的属性与方法
原型与继承
-
原型
JS对象的继承不是复制,而是委托。通过对象的protoype属性建立委托关系,访问属性时,若自有属性中找不到就委托原型对象查找,原型对象找不到继续委托原型的原型查找,一直上溯,直到找到该属性或找到不原型为止。比如Object.prototype的原型是null且规定不可被修改,找到Object.prototype这里还找不到也就到头了,返回undefined。使用Object.create()方法创建的对象是显式地指定对象,使用字面量与new关键字创建的对象则是隐式地指定。字面量对象使用Object.prototype作为其原型,new关键字创建的对象使用构造函数的prototype属性作为其原型。
一般来说,对象的原型指向是可以被修改的,指向的原型也可以被修改,而js对象的继承是基于原型链的委托,这就意味着修改原型对象后会影响继承它的所有对象。
构造函数的原型是显式的,通过修改其prototype属性为其指定原型;而其他类型的原型是隐式的,通过Object.setPrototypeOf()或Reflect.setPrototypeOf()来修改其原型。
-
继承
js对象对于继承来的属性,查询与赋值时有着不同的行为。查询时是沿原型链上溯,而赋值时则不然。
对于数据属性,赋值时,若对象自身不存在,就给自身添加属性,而不是修改原型链上的属性(这是合理的)。此时再访问该属性,因为自身有就不会查找原型了。若将该属性删除以后,访问时又会查找原型。
而对于访问器属性,若该属性定义了设置方法,则赋值时会调用原型上的该方法,而不会在对象自身上添加属性。这时候如果希望为对象自身定义属性,就要使用Object.defineProperty()
或Reflect.defineProperty()
了let o = {}, prototype = Object.getPrototypeOf(o); Object.defineProperty(prototype, 'foo', {get: function() {return this.fooVal;}, set : function(val) {console.log('call set fn');this.fooVal = val}, enumerable:true, configurable: true}); o.foo = 123; // 'call set fn' console.log(o.foo); // 123 o.hasOwnProperty('foo'); // false Object.defineProperty(o, 'foo', {value: 'own property', writable: true, enumerable:true, configurable: true}); console.log(o.foo); // own property o.hasOwnProperty('foo'); // true
-
class与extends
在ES6以前创建类(又或叫构造函数),就是定义一个函数对象,并在该对象的prototype属性上
或函数体内使用this
来定义成员,
在ES6及以后,新增了关键字class与extends,用来类的创建与继承。这种编程范式的转变,基本上抹平了JS与其他静态的面向对象编程语言在编写类时的差异,但是也容易造成困惑。它们是语法糖,语法改变了,但是功能不变,本质上还是基于原型链的委托。这样理解固然没有问题,但是这将原型与类完全对立起来了。我觉得,倒不如理解为委托的方式是除了复制外,类与对象的另一种实现方式。
查询与设置属性
使用.
或[]
操作符来访问对象的属性。.
后面跟一个标识符,与字符串属性名一致;[]
包裹一个表达式,其求值为可转换为字符串的值或符号类型的值。
({a: 123}).a;
({fn: function() {console.log('fn')}}).fn;
Date().valueOf();
JS的对象又可以被称作关联数组,又或者是散列、映射、字典。JS的Array类型对象是通过方括号来访问成员的,非Array类型的对象也可以。这意味着访问的属性名是可以动态计算的,这将提高程序的灵活性。
查询对象不存在的属性不是错误,只会返回undefined。但对不是对象的类型执行访问属性的操作可是会出错的,这往往发生在对象的链式调用时。写类型检查的代码固然健壮,但是未免太过臃肿。逻辑与运算符以及条件式属性访问操作符可以简化这个流程。
let person = {
name: 'Tom',
bro: {name: 'Jerry'}
}
person.bro;
person.bro.name;
person.bro.age; // undefined
person.dad; // undefined
person.dad.name; // TypeError
person && person.dad && person.dad.name; // undefined
person?.dad?.name; // undefined
person?.dad?.['age']; // undefined
删除属性
使用delete操作符来删除对象的属性,它值删除自有属性。若属性被删除或属性不存在,返回true;若属性不可配置则不会删除,在非严格模式下,返回false,在严格模式下抛出TypeError。
在非严格模式下,若访问一个全局对象上的属性时可以省略全局对象的引用,就像使用变量那样使用它。所以在非严格模式下,可以向下面这样删除全局对象的属性:
x = 123;
delete x;
但是严格模式下,globalThis
是不能省略的。
测试属性
在编码时,往往要检查对象自身是否有某个名称的属性存在。可以使用in操作符,也可以使用hasOwnProperty()与propertyIsEnumerable()来检查。
let o = {x:1};
x in o; // true
o.hasOwnProperty('y'); // false
o.hasOwnProperty('toString'); // false, toString为继承属性
Object.prototype.propertyIsEnumerable('toString'); // false,toString不可枚举
枚举属性
对象内置的属性默认是不可枚举的,但是代码添加的属性默认是可枚举的。
使用for/in循环可以枚举对象所有可枚举的属性,它不仅枚举自有属性,还在原型链上查找,枚举所有继承的可枚举属性。
let o = {x:1,y:2,z:3};
for (k in o) {console.log(k);}
// 略过继承的属性
Object.setPrototypeOf(o, {a:99})
for (k in o) {
if (!o.hasOwnProperty(k)) continue;
console.log(k);
}
除了使用for/in循环,还可以先获取包含属性名的数组,在使用for/of来遍历。
JS为枚举对象的自有属性定义了顺序:
- 先列出名字为非负整数的字符串属性,按数值从小到大。这是数组与类数组的排序规则。
- 再列出所有剩下的字符串名字的属性,按照它们被添加到对象的顺序列出。字面量也是如此。
- 最后将名字为符号类型的属性按它们的添加顺序列出。
Object.keys(),Object.getOwnPropertyNames(),Objet.getOwnPropertySymbols(),Reflect.ownKeys()及JSON.stringfy()都遵循这个顺序列出属性。
扩展对象与复制对象
给对象添加属性就是扩展对象。
将一个对象的属性复制到另一个对象的操作时很常见的,一般通过遍历另一个对象的方式来实现。
在ES6新增的Object.assign()就是扩展对象的一个便捷方法。它结接收两个或多个参数,第一个是要扩展的对象,扩展结束后返回;第二个及以后参数是要添加的属性集合,又或者说是被复制的对象。被复制的对象的属性会覆盖要扩展的对象的同名属性,后续被复制的对象又会覆盖前面的对象的同名属性。
对于获取器属性的复制,Object.assign()用常规方式取值与赋值,不会修改属性的特性。
let o = {x:1,y:2,z:3},
copyed = {z:0, foo: 'bar'};
Object.defineProperty(o, 'm', {get: ()=>{return 123;}, set: ()=>{}});
Object.defineProperty(copyed, 'm', {get: ()=>{return 321;}})
// 属性z会比覆盖为0,foo属性会被添加
// 属性m仍然是返回123,
Object.assign(o, copyed);
序列化对象
对象序列化是把对象的状态转换为字符串的过程,之后可以从字符串恢复为对象,这有称为反序列化。
JSON.stringfy()与JSON.parse()用于对象的序列化与反序列化。它们使用JOSN格式交换数据。
对象、数组、字符串、有限数值、true、false和null支持序列化与反序列化。NaN、Infinity、-Infinity会会被序列化为null。日期对象会被序列化为ISO格式的日期字符串,但JSON.parse()会保持其字符串格式。
这两个方法还接收第二个可选的参数,它用于自定义序列化与恢复的操作。
- 若该参数是一个数组,即是指定要序列化的属性名,序列化结果按该参数中属性名的顺序排列。如果某一个属性的值是对象,需要指定该对象的所有属性名才能将该对象序列化。
- 若该参数是一个函数,则序列化结果以其返回值为准,这个参数接收两个参数,第一个是属性名,第二个是属性名。
对象方法
-
Object.prototype.toString()
当某个操作要将一个对象转换为字符串时会自动调用它,它的默认返回结果受对象的[Symbol.toStringTag]()
属性的返回值的影响。 -
Object.prototype.toLocaleString()
返回本地字符串,可与国际化类配合使用。 -
Object.prototype.valueOf()
JS希望将对象转换为数值类型时自动调用。 -
toJson()
Object.prototype对象并没有定义该方法,但JSON.stringfy()会在要序列化的对象上查找该方法并序列该方法的返回值。 -
Object.prototype.hasOwnProperty(): 检查对象是否有某个属性
-
Object.prototype.isPrototypeOf(): 检查对象是否某个对象是否在另一个对象的原型链上。
-
Object.prototype.propertyIsEnumerable(): 检查对象的指定属性是否可枚举
-
Object.assign(): 扩展对象
-
Object.create(): 创建对象
-
Object.defineProperty()/Object.defineProperties():
为对象的属性配置特性 -
Object.entries():
返回一个给定对象自身可枚举属性的键值对数组,格式为[[k,v],[m,n]...]
-
Object.freeze(): 冻结对象。不可扩展,属性不可配置、不可写。
-
Object.fromEntries()
按照一个包含 [k, v] 对的可迭代对象中创建并一个新的对象(Object.entries 的反操作)。 -
Object.getOwnPropertyDescriptor():返回对象指定的属性配置。
-
Object.getOwnPropertyDescriptors():返回一个包含给定对象所有自有属性配置的对象。
-
Object.getOwnPropertyNames():返回一个包含了指定对象自身所有的可枚举或不可枚举的属性名的数组。
- Object.getOwnPropertySymbols():返回一个包含了指定对象自身所有的符号属性的数组。
-
Object.getPrototypeOf():返回指定对象的原型(内部的 [[Prototype]] 属性)。
-
Object.hasOwn()
如果指定属性是指定对象的自有属性,则返回 true,否则返回 false。如果该属性是继承的或不存在,则返回 false。 -
Object.is()
比较两个值是否相同。所有 NaN 值都相等(这与 == 使用的 IsLooselyEqual 和 === 使用的 IsStrictlyEqual 不同)。 -
Object.isExtensible(): 判断对象是否可扩展。
-
Object.isFrozen(): 判断对象是否已经冻结。
-
Object.isSealed(): 判断对象是否已经封存。
-
Object.keys(): 返回一个包含所有给定对象自身可枚举字符串属性名称的数组。
-
Object.preventExtensions(): 防止对象的任何扩展。
-
Object.seal():
封存对象,使其不可扩展,属性不可配置,不改变其可写性与枚举性。可以防止其他代码删除对象的属性。 -
Object.setPrototypeOf(): 为对象指定原型。
-
Object.values(): 返回给定对象自身所有可枚举字符串属性的值的数组。
扩展操作符
在ES2018以后,复制对象有了更便捷的方式,即在对象字面量中使用扩展操作符。它只扩展对象自有属性。
let a = {x:1},
b = {x:0, ...a,};
console.log(b); // {x:1}
虽然使用扩展操作符...
在编码时很方便,但是要注意操作的时间复杂度。若在一个循环或递归函数中使用扩展操作符向一个对象追加属性,那么就有可能造成性能问题。
类
定义
在JavaScript中,类意味这一组对象从同一个原型对象继承属性。
虽然ES6新增了class与extends关键字,但是并没有新增一个"class"类型,对象的继承机制的实现还是基于原型链的委托设计。使用该方式的表达式返回的值还是一个具有构造函数行为的函数对象。
在ES5的时代,类的实现并没有一个标准的编程范式,只要能生产原型指向同一个对象的对象,那它就称得上是一个类的实现。
使用工厂方法来定义类
function apple(color, taste) {
let inst = Object.create(apple.methods);
inst.color = color;
inst.taste = taste;
return inst;
}
apple.methods= {
see() {console.log(`a ${this.color} apple.`);},
eat() {console.log(`a ${this.taste} apple.`);}
};
let redApple = apple('red', 'sweet');
使用构造函数的方式来定义类
使用构造函数的方式实现类需要使用new关键字来创建对象
function Apple (color, taste) {
this.color = color;
this.taste = taste;
}
Apple.prototype = {
// 覆盖构造函数的prototype属性时,不要忘了定义一个constructor属性来指它自身
constructor: Apple,
color: undefined,
taste: undefined,
see() {console.log(`a ${this.color} apple.`);},
eat() {console.log(`a ${this.taste} apple.`);}
};
let apple = new Apple('green', 'sour');
新的对象在调用Apple()构造函数之前就被创建了,然后将新对象的原型指向构造函数的prototype属性,并将构造函数的this指向这个新的对象,然后才调用了Apple函数。
使用new.target
判断一个函数是否作为构造函数被调用。若new.target有定义,那么就是作为构造函数调用的。
它指向了被new调用的构造函数,这意味new.target未必只会指向其所在的构造函数,也会指向子类的构造函数。
function A () {
console.log('new.target: ', new.target?.name)
}
class B extends A {}
new A; // 'new.target: A'
new B; // 'new.target: B'
使用class与extends关键字的方式定义类
使用class+extends的方式定义类是标准做法。新的标准还新增了一些新的特性,如static、super、#私有属性等,这些特性使JS的面向对象编程方式变得简单、清晰;
class A {
constructor() {
this.prop = 123;
}
foo = 321;
get foo2() {return this.foo*2;};
bar() {console.log(this.prop + this.foo);}
#privateVal = 'private';
getPrivateVal() {console.log(this.#privateVal);}
callSuper() {console.log(super.toString);}
static sVal = 'static';
}
consolr.log(A); // function
consolr.log(new A().construtor === A); // true
consolr.log(new A().callSuper()); // ƒ toString() { [native code] }
上面的代码就好比:
function A () {
this.prop = 123;
}
A.prototype.foo = 321;
Object.defineProperty(A.prototype, 'foo2', {get: function(){return this.foo*2;}, enumerable:true, configurable: true});
A.prototype.bar = function() {console.log(this.prop + this.foo);};
A.prototype.getPrivateVal = function () {
let privateVal = 'privateVal';
console.log(privateVal);
}
A.prototype.callSuper = function() {console.log(this.prototype.toString);}
A.sVal = 'static';
以上大多数特性都可以模拟,但是私有属性与静态属性却不能模拟,另外‘new.target’的指向也无法模拟:
class A {
constructor() {
this.foo = 123;
console.log('new.target: ', new.target?.name);
}
bar = 321;
fn () {console.log(this);}
static sVal = 'static';
}
// 如果不显式的设置'constructor'方法,js将隐式的创建一个构造方法,它会调用super()来初始化
class B extends A {}
new B; // 'new.target: B'
A.sVal; 'static'
B.sVal; // 'static'
用构造函数模拟继承:
function A () {
this.foo = 123;
console.log('new.target: ', new.target?.name);
}
A.prototyp.bar = 321;
A.prototype.fn = function() {console.log(this);}
function B () {
// 想要父类也初始化,不得不这样为对象设置原型,这就好比调用了super()
// 但是又有不同,调用super(),new.target指向B
// 而这样做虽然也做到了初始化,但new.target无疑是指向A的
Object.setPrototypeOf(this, new A); // 'new.target: A'
}
JavaScript中抽象类的实现
不比静态语言,js不能实现实际意义上的抽象类与抽象方法,但是逻辑上是可以实现的。
class AbstractObject {
// 子类对象想要使用它,必须定义自己的实现
abstractMethod() {throw new Error('Abstract method');}
}
class Obj extends AbstractObject {
abstractMethod() {console.log('Obj');}
}
使用委托而不是继承
有时候我们想要实现一个功能,它会用到一个或多个类来实现,除此之外还要编写额外的逻辑。
这些额外的逻辑如果和既有的类有逻辑关系,创建一个子类是可以的。而如果没有关系,而仅仅是为了方便而去定义一个子类,是不可取的。
定义类的意义是代码的复用,这些额外的业务逻辑若没有复用的可能,即使和既有的类有逻辑上的关系,也不建议去定义子类。
不如创建一个Object对象,持有要用到的类的实例的引用,通过组合既有的类,委托它们去实现某些功能,把额外的业务逻辑写在新建对象里。这样不会造成逻辑上的混乱,而且将该功封装成一个对象,保证了该功能的独立性与可维护性。
比如一个在线商城搞节日活动,我们可能会用到用户、商品、购物车、优惠券、支付等功能,这些功能都封装成了类。而活动规则的具体实现和这些业务没有逻辑上的关系,也没有什么复用的价值,去继承哪一个既有的类也不合适,定义一个新的类也没有必要。这个时候使用组合与委托的设计就很合适。