镇楼图
Pixiv:torino
六、JS中的面向对象
类(class)
博主视为你已拥有相关基础,这里不再赘述相关概念
类的语法如下,class在本质上是function,可以说class只是针对构造器的一种语法糖,但却不用像编写构造器那样麻烦。上一章博主给出了例子,需要编写prototype、constructor等内容,而且是分离开写的,class可以只在一个代码块内编写完成。其中constructor去编写构造器,若无构造器class会自动创建。constructor外可以编写属性,直接作用于要构造的对象上,而方法是作用于原型上。此外关于this指针,若要获取对象的属性,除了类中定义时不需要写this,其他的方法、构造器均需要this来获取
class MyClass {
prop = value;
["Test"] = value;
//属性作用于对象
constructor(...) {}
//构造器,编写function MyClass(...){}
method(...) {}
[Symbol.toStringTag]() {}
get something(/**/) {}
set something(value) {}
//方法作用于原型
//访问器属性也作用于原型,但属性something会同时出现在对象和原型上
}
class相当于封装了构造器、原型相关的编写,它存在一些约束
约束1——class内部有一属性[[IsClassConstructor]]:true,导致必须通过new创建实例,而在构造函数中可以使用new.target使得可以忽略new。(哪怕constructor内写了new.target相关处理也是无用,它是通过[[IsClassConstructor]]来判定的)
约束2——class定义的方法默认enumerable:false,如果选择构造函数必须要手动设置(毕竟大部分实际应用只希望枚举数据而不是函数)
约束3——class内代码默认使用use "strict",严格模式目前博主暂未给出解释,但严格模式在很多地方都做了好的约束
类表达式:类似于函数,它也有两种不同的定义方式
let MyClass1 = class{/**/};
let MyClass2 = class Inner{/**/};//作用参考NFE
类继承
JS提供了extends语法。当创建某个类的对象时,它会先执行constructor,若这个类是继承类,继承类的constructor必须存在super且只能位于constructor的第一行。super即创建父类的一个对象,子类的对象的[[Prototype]]会设置为创建的父类的对象。
如某个继承对象存在继承链A→B→C→D,那么虽然创建A的对象实际上还创建了B、C、D的三个对象,其中A的方法在B的对象中,B的方法在C的对象中,C的方法在D的对象中,D的方法在某个Object的对象中,而Object还存在Object.prototype
如果是继承类其构造器(派生构造器,derived constructor)内部存在特殊属性[[ConstructorKind:"derived"]]表明这是继承类的构造器,必须存在super
class Rect{
constructor(a=3,b=4){
this.a = a;
this.b = b;
}
}
class Square extends Rect{
constructor(side=5){
//必须存在super且必须位于第一行
super(side,side);
this.side = side;
}
}
let s = new Square;
console.log(s);
而类继承也不局限于类,它可以是一个任意的表达式,只要保证extends后是类即可,因此可以使用函数来创建一个复杂化的类
假设一个游戏的怪物有龙、人、史莱姆三种类型,那么可以设计一个函数去生成父类,而不是再去编写
function monsterClassGenerator(str){
let r = new Map([["Dragon",{name:"特征1",hp:"high",def:"high",atk:"high"}],["Human",{name:"特征1",hp:"low",def:"low",atk:"medium"}],["Slime",{name:"特征1",hp:"low",def:"medium",atk:"low"}]]);
if(r.has(str)){
return class{
constructor(){
this.tag = str;
this.feature = r.get(str);
}
getTag(){
return this.tag;
}
attack(){console.log("普通攻击")}
}
}
return class{tag = undefined;};
}
class FireDragon extends monsterClassGenerator("Dragon"){
//继承函数生成的类
/*...*/
tech1(){console.log("释放一技能")}
tech2(){console.log("释放二技能")}
}
重写
super另外一个作用就是去索引父类(原理上是索引原型),可以通过super来重写方法
class A{
Test(){console.log("A");}
}
class B extends A{
//备注:super仅能用在class内
Test(){super.Test();console.log("B");}
}
new B().Test();
除了重写constructor、方法外,重写属性看起来非常奇怪。如下代码,属性被覆盖后父类方法使用this却只使用其本身的,而方法可以正常指向派生类的方法
class A{
test = "test1";
func(){console.log("A");}
constructor(){console.log(this.test);this.func();}
}
class B extends A{
test = "test2";
func(){console.log("B");}
}
new A();//tset1,A
new B();//test1,B
这样的原因是由于初始化的顺序问题,创建一个子类对象它会优先创建父类(若父类还有父类会继续向上创建),初始化父类后才会初始化子类。上面代码仅限于constructor,在普通方法不会引发属性被错误使用的情况。另外可以使用访问器属性,它虽然形式上是属性,但本质上是函数可以避免被错误使用
super的原理
直接采用获取proto的形式去实现super是不可能的,如下代码,B去运行A的方法确实可行,因为this指向B其原型为A,恰好可以执行A的代码且数据为B的。但C去运行却报错了。当C去执行B的方法时,此时this依然是指向C的而不会变化到B,从而导致一个无限调用B的函数最终栈溢出
let A = {
data: 1,
func(){console.log(this.data)}
};
let B = {
__proto__: A,
data: 2,
func(){Object.getPrototypeOf(this).func.call(this);}
};
let C = {
__proto__: B,
data: 3,
func(){Object.getPrototypeOf(this).func.call(this);}
};
B.func();//成功运行
C.func();//异常
JS为函数添加了内部属性[[HomeObject]],当函数是类或对象的方法时,[[HomeObject]]永久指向该对象。super可以通过原型的[[HomeObject]]来获取方法。它与this的区别是this会随着上下文发生变化,[[HomeObject]]是永久绑定的,但违反了方法的自由性
let A = {
data: 1,
func(){console.log(this)}
};
let B = {
__proto__: A,
data: 2,
func(){super.func();}
};
let C = {
__proto__: B,
data: 3,
func(){super.func();}
};
B.func();
C.func();
但[[HomeObject]]仅用作super,随意被直接使用可能导致异常,如下代码,原本是想借用rabbit的方法,但却输入错误信息
let animal = {
sayHi() {
alert(`I'm an animal`);
}
};
let rabbit = {
__proto__: animal,
sayHi() {
super.sayHi();
}
};
let plant = {
sayHi() {
alert("I'm a plant");
}
};
let tree = {
__proto__: plant,
sayHi: rabbit.sayHi
// (*)rabbit中super指向animal
};
tree.sayHi(); // I'm an animal
虽然对象里函数、变量统称为数据属性,大部分情况下也没什么问题,但JS中直接存储函数才会设置[[HomeObject]],变量去存储函数不设置[[HomeObject]]可能导致super出现问题
class A{
func = function(){console.log("A")}
}
class B extends A{
func = function(){super.func();}
}
new B.func();//错误,super无法使用
静态成员
在之前使用构造函数时,若要创建额外的静态成员必须要单独写,而类中提供了static关键字直接编写静态成员。静态方法下的this即class本身,如果有需要的话还可以用静态方法改变类本身
class MyClass{
//...
static staticAttribute = 0;
static staticMethod(/*...*/){/*...*/}
}
//调用
console.log(MyClass.staticAttribute);
MyClass.staticMethod(/*...*/);
私有成员
JS特有的访问器属性支持一些对成员的控制。可以只用getter而不用setter完成只读的控制,使用getter、setter完成写入受限的属性。除了访问器属性外对于类里存在私有成员的支持,只需要成员名前加#
即可。私有成员即类外无法调用只能内部调用
calss MyClass{
//...
#privateAttribute = 0;
#privateMethod(/*...*/){/*...*/}
}
但JS私有成员与其他变量不同的是私有成员与其他成员的命名不会冲突,此外私有成员无法使用this["#xxx"]
的语法形式
class Test{
#test = "test";
get1(){return this.#test;}
get test(){
return this.#test;
}
set test(value){
this.#test = this.test;
}
get2(){/*console.log(this["#test"]);*/return this.test;}
}
let test = new Test;
console.log(test);
内建类
和Object一样所有内建对象也可当作内建类,若内建类功能不足以满足需求却非常接近可以extends制定某个内建类的子类来满足。但内建类的继承与普通类的继承稍有区别,加入A extends B。一般来说A.prototype的[[Prototype]]为B.prototype,A的[[Prototype]]为B,即A不仅继承B的非静态成员还继承B的静态成员。但若B是内建类,A没有[[Prototype]]无法继承B的静态成员
class Test extends Array{}
let a = new Test;
console.log(Test.isArray(a));// Error
虽然无法使用静态方法,但Symbol中的静态getter:species允许子类覆盖对象的默认构造函数,此时就可以“继承”静态成员了
class Test extends Array{
test(){console.log("test")}
static get [Symbol.species](){return Array;}
}
let a = new Test(1,2,3);
console.log(Test.isArray(a));
console.log(a);
不过species一般不太可能使用,它会导致生成的对象与一开始的不符合,若没用species则依然保持其子类
class Test extends Array{
test(){console.log("test")}
//static get [Symbol.species](){return Array;}
}
let a = new Test(1,2,3);
console.log(a);//Test类
a = a.map(x => x*2);
console.log(a);//Test类
//若写入species则为Array类
instanceof
instanceof是用来判断对象是否隶属于某个类(或某个类的子类)的运算符,和typeof一样重要,用来作类型校验
obj instanceof Class
class Test extends Array{}
console.log(new Test instanceof Array);
//true,是Array的子类
console.log(new Test instanceof Object);
//true
默认情况下会考虑其原型链,如上代码还可以隶属于Object,但实际应用可能不需要这么广的判定范围,Symbol中有静态方法hasInstance可以改变判定的逻辑
class Test extends Array{
static [Symbol.hasInstance](instance) {
//instance是指当前对象
return Array.isArray(instance);
}
}
let a = new Test;
console.log(a instanceof Test);//true
console.log(a instanceof Object);//false
instanceof的原理是Class的prototype是否为obj原型链上的一个
obj.__proto__ === Class.prototype?
obj.__proto__.__proto__ === Class.prototype?
obj.__proto__.__proto__.__proto__ === Class.prototype?
...
除了typeof、instanceof外还可使用Object.prototype.toString而且更加通用也可结合toStringTag自定义标签
class Test extends Array{}
let a = new Test;
console.log(typeof a);
console.log(a instanceof Test);
console.log(Object.prototype.toString.call(a));
Mixin模式
JS是单继承,但却可以有类似于接口的Mixin模式实现“多继承”。构建对象内含属性或方法(一般只含方法),然后使用Object.assign将mixin复制到类的prototype中即可
let mixin = {
test1: "test",
test2(){console.log("test")}
};
class Test{}
Object.assign(Test.prototype, mixin);
new Test().test2();
console.log(new Test().test1);
七、异常处理
try-catch
可以使用try-catch来捕获异常并处理,当try中的代码发生异常时会转向catch进行相关处理以保证程序的健壮性,error参数包含了错误信息
try{
//...
}catch(error){
//捕获错误后的处理
}
它和其他大多数编程语言类似,它只能处理运行时的错误(简称异常),而对解析时就遇到的错误(JS中只有语法错误SyntaxError)会直接报错。JS中有Error内建对象存储了各种错误类型,最基本的错误有SyntaxError、TypeError、URIError、ReferenceError、RangeError、InternalError、EvalError,当然也可以自定义错误
try{
{{//引发语法错误
}catch(error){
console.log("Error!");
}
try-catch是同步执行的,如果有延时后才错误的不会发现,若在异步的代码中保持异常处理必须在异步的代码内部使用try-catch
try {
setTimeout(function() {
error;
}, 1000);
} catch (err) {
console.log( "不会检查出而直接报错" );
}
setTimeout(()=>{
try{
error;
}catch(error){
console.log("发现错误");
}
},1000);
catch中也可以忽略Error对象,因为可能不需要处理Error对象
try{
//...
}catch{
console.log("Error!");
}
Error
Error也是内建对象,其中有name、message、cause属性(已忽略非标准属性),方法有Error.prototype.toString,该方法会返回name、message(若为空字符串则不显示)
name是语义性的标签,表明是什么类型的异常,默认为“Error”,用户可自定义
message用于简短描述该类错误,为字符串类型,默认空字符串
cause用于给定该类错误的具体原因,它可以是任何值
■构造Error
Error();
Error(message);
Error(message, {cause});
//Error构造可以忽略new
//构造Error无法指定name属性
function select(index){
if(index < 0 || !Number.isInteger(index)){
let e = Error("输入异常",{cause: `\n异常数据: ${index}\n可能原因: 输入小于零或非整数`});
throw e + e.cause;
}
console.log`选了${index}`;
}
select(-2);
throw
throw可以抛出一个Error对象,一般搭配try-catch使用,throw会引导至catch代码块
let json = '{ "age": 30 }';
try {
let user = JSON.parse(json);
if (!user.name) {
throw new SyntaxError("没有name");
}
console.log( user.name );
} catch(err) {
console.log( "JSON Error: " + err.message );
}
但try内的代码中会接收任何错误,如果需要锚定错误类型,可以作类型判断
try {
//...
}catch(err){
if(err instanceof ReferenceError){
console.log("ReferenceError");
}else{
console.log("OtherErroe");
}
}
try-catch也可以嵌套实现不同层级的异常处理,如你构建了数据,它可能会检查数据是否有异常1但不会处理可能的异常2,它只会在数据应用到某个功能上时才会处理
function 功能(data){
try{
//...
}catch(err){
console.log("err");
}
}
function 创建数据(){
let data = null;
try{
//...
return data;
}catch(err){
if(err instanceof Error1){
console.log("引发异常1");
}else{
throw err;
}
}
}
功能(创建数据());
//如果引发其他异常将会throw到[功能]上
finally
try-catch可以加上finally子句,不管是否出错最后都会执行finally子句。如你想做一个测量函数执行时间的函数,但函数执行时可能报错,但不管是否报错你都想直到测量的时间,那么测量时间的代码可以写在finally中
try {
console.log( 'try' );
if (confirm('Make an error?')) BAD_CODE();
} catch (err) {
console.log( 'catch' );
} finally {
console.log( 'finally' );
}
在函数中不管是否在try、catch中提前return、throw,finally都会执行,且finally优先执行
function func() {
try {
return 1;
} catch (err) {
/* ... */
} finally {
console.log( 'finally' );
}
}
console.log( func() );
//优先输出finally再输出1
而且也可以不用catch完全try-finally结构,如果出现异常直接跳出该结构但也会执行finally
function measure(func,count,...args){
let start = new Date();
try{
for(let i = 0;i < count;i++){
func.call(this,...args);
}
}finally{
let end = new Date();
return end-start;
}
}
function gcd(a,b){
if(tyepof(a) !== "number" || typeof(a) !== "number"){
throw Error("错误!");
}
return (b == 0) ? a : gcd(b,a%b);
}
console.log("执行1w次gcd所需时间:"+measure(gcd,10000,123456,654321)+"ms");
console.log("出错也可正常运行:"+measure(gcd,1000,-5,7)+"ms");
JS自带Error类型
(1)SyntaxError语法错误,try-catch无法捕获语法错误(因为不是运行时错误类型)
(2)ReferenceError引用错误,当不存在的变量被引用时发生该错误
try{
func();//不存在func
}catch(err){
console.log(err);
}
(3)TypeError类型错误,当函数参数类型不符或错误使用某类型数据时发生该错误
try{
console.log(Object.fromEntries([1,2,3]));
//fromEntries要求二元素数组
}catch(err){
console.log(err);
}
(4)RangeError范围错误,简单来说就是溢出,当可迭代对象长度过长或是调用栈过长时发生该错误
function func(){
func();
}
try{
func();
}catch(err){
console.log(err);
}
(5)URIError,当调用JS内置的URI相关函数时若有错误会触发该错误,URI相关函数有decodeURI、decodeURIComponent、encodeURI、encodeURIComponent
(6)EvalError,当调用eval函数时若有错误会触发该错误,此类型错误不再抛出仅为兼容性而存在
包装异常
若对异常专门设计,异常经常呈层次结构
class Exception extends Error{
constructor(msg){
super(msg);
this.name = "Exception";
}
}
class IOException extends Exception{
constructor(msg){
super(msg);
this.name = "IOException";
}
}
class FileNotFoundException extends IOException{
constructor(msg){
super(msg);
this.name = this.constructor.name;
//建议使用constructor提高通用性
}
}
在实际使用中可能会有不同层次的异常,一般检测类型时应当使用instanceof因为其可以校验任何子类
try{
throw new FileNotFoundException("");
}catch(err){
if(err instanceof Exception){
//Exception体系
console.log(err.name);
}else if(err instanceof Error体系2){
console.log(err.name);
}else{
console.log(err.name);
}
}
但上述体系显然存在一个缺陷,如果不同类型的Error过多可能导致实际捕获时过于繁琐,下面引入了“包装异常”的方法。ReadError相当于包装任何非运行时的异常,使得实际判断更容易。除了ReadError外还需要编写一个集中处理异常的函数read用于生成ReadError
class ReadError extends Error {
constructor(msg, cause) {
super(msg);
this.cause = cause;
this.name = this.constructor.name;
}
}
function read(data){
//...执行代码
try{
//...尝试捕获一类型Error
}catch(err){
if(err instanceof Error1){
throw new ReadError("xxx",err);
//err作为cause
}
}
try{
//...尝试捕获二类型Error
}catch(err){
if(err instanceof Error2){
throw new ReadError("xxx",err);
}
}
//...
}
//当
try{
read(data);
}catch(err){
//实际判断时仅需判断ReadError和其他Error
if(err instanceof ReadError){
//...
}else{
//...
}
}
参考资料
[1] 《JavaScrpit DOM 编程艺术》
[2] MDN
[3] 现代JS教程