3月阅读周·你不知道的JavaScript | 行为委托,搞懂对象之间的关系

举报
叶一一 发表于 2024/03/25 09:53:58 2024/03/25
【摘要】 背景去年下半年,我在微信书架里加入了许多技术书籍,各种类别的都有,断断续续的读了一部分。没有计划的阅读,收效甚微。新年伊始,我准备尝试一下其他方式,比如阅读周。每月抽出1~2个非连续周,完整阅读一本书籍。这个“玩法”虽然常见且板正,但是有效,已经坚持阅读两个月。《你不知道的JavaScript》分上中下三卷,内容相对较多。3月份,我计划先读前面两卷。已读完书籍:《架构简洁之道》、《深入浅出的...

背景

去年下半年,我在微信书架里加入了许多技术书籍,各种类别的都有,断断续续的读了一部分。

没有计划的阅读,收效甚微。

新年伊始,我准备尝试一下其他方式,比如阅读周。每月抽出1~2个非连续周,完整阅读一本书籍。

这个“玩法”虽然常见且板正,但是有效,已经坚持阅读两个月。

《你不知道的JavaScript》分上中下三卷,内容相对较多。3月份,我计划先读前面两卷。

已读完书籍《架构简洁之道》、《深入浅出的Node.js》

当前阅读周书籍《你不知道的JavaScript(上卷)》、《你不知道的JavaScript(中卷)》

行为委托

类的机制

4-1.png


1、构造函数

类实例是由一个特殊的类方法构造的,这个方法名通常和类名相同,被称为构造函数。这个方法的任务就是初始化实例需要的所有信息(状态)。

class CoolGuy {
  specialTrick = nothing;

  CoolGuy(trick) {
    specialTrick = trick;
  }

  showOff() {
    output("Here's my trick: ", specialTrick);
  }
}
Joe = new CoolGuy("jumping rope")

Joe.showOff() // 这是我的绝技:跳绳

上面的代码中,CoolGuy类有一个CoolGuy()构造函数,执行new CoolGuy()时实际上调用的就是它。构造函数会返回一个对象(也就是类的一个实例),之后可以在这个对象上调用showOff()方法,来输出指定CoolGuy的特长。

类的继承

在面向类的语言中,可以先定义一个类,然后定义一个继承前者的类。后者通常被称为“子类”,前者通常被称为“父类”。

1、多态

多态的一个方面是,任何方法都可以引用继承层次中高层的方法(无论高层的方法名和当前方法名是否相同)。

多态的另一个方面是,在继承链的不同层次中一个方法名可以被多次定义,当调用方法时会自动选择合适的定义。

上面的图1中,子类Bar应当可以通过相对多态引用(或者说super)来访问父类Foo中的行为。

2、多重继承

多重继承意味着所有父类的定义都会被复制到子类中。

多重继承的好处是可以把许多功能组合在一起。缺点是可能带来复杂问题,比如经典的钻石问题。

4-2.png

子类D继承自两个父类(B和C),这两个父类都继承自A。如果A中有drive()方法并且B和C都重写了这个方法(多态),那当D引用drive()时应当选择哪个版本呢(B:drive()还是C:drive())?

JavaScript要简单得多:它本身并不提供“多重继承”功能。

混入

在继承或者实例化时,JavaScript的对象机制并不会自动执行复制行为。JavaScript开发者也想出了一个方法来模拟类的复制行为,这个方法就是混入。

1、显式混入

开发者一般手动实现复制功能。这个功能在许多库和框架中被称为extend(..),但是为了方便理解我们称之为mixin(..)。

下面的代码实现一个非常简单的mixin(..)例子:

function mixin(sourceObj, targetObj) {
  for (var key in sourceObj) {
    // 只会在不存在的情况下复制
    if (!(key in targetObj)) {
      targetObj[key] = sourceObj[key];
    }
  }

  return targetObj;
}

var Vehicle = {
  engines: 1,
  ignition: function () {
    console.log('Turning on my engine.');
  },

  drive: function () {
    this.ignition();
    console.log('Steering and moving forward! ');
  },
};

var Car = mixin(Vehicle, {
  wheels: 4,
  drive: function () {
    Vehicle.drive.call(this);
    console.log('Rolling on all ' + this.wheels + ' wheels! ');
  },
});

Car中的属性ignition只是从Vehicle中复制过来的对于ignition()函数的引用。相反,属性engines就是直接从Vehicle中复制了值1。

2、隐式混入

var Something = {
  cool: function () {
    this.greeting = 'Hello World';
    this.count = this.count ? this.count + 1 : 1;
  },
};
Something.cool();
Something.greeting; // "Hello World"
Something.count; // 1

var Another = {
  cool: function () {
    // 隐式把Something混入Another
    Something.cool.call(this);
  },
};

Another.cool();
Another.greeting; // "Hello World"
Another.count; // 1(count不是共享状态)

通过在构造函数调用或者方法调用中使用Something.cool.call(this),实际上“借用”了函数Something.cool()并在Another的上下文中调用了它。最终的结果是Something.cool()中的赋值操作都会应用在Another对象上而不是Something对象上。

即把Something的行为“混入”到了Another中。

原型

[[Prototype]]

JavaScript中的对象有一个特殊的[[Prototype]]内置属性,其实就是对于其他对象的引用。几乎所有的对象在创建时[[Prototype]]属性都会被赋予一个非空的值。

var anotherObject = {
  a: 2,
};
// 创建一个关联到anotherObject的对象
var myObject = Object.create(anotherObject);

for (var k in myObject) {
  console.log('found: ' + k);
}
// found: a

'a' in myObject; // true

通过各种语法进行属性查找时都会查找[[Prototype]]链,直到找到属性或者查找完整条原型链。

1、Object.prototype

所有普通的[[Prototype]]链最终都会指向内置的Object.prototype。

2、属性设置和屏蔽

给一个对象设置属性并不仅仅是添加一个新属性或者修改已有的属性值。

myObject.foo = 'bar';

实际的过程是:

1)如果myObject对象中包含名为foo的普通数据访问属性,这条赋值语句只会修改已有的属性值。

2)如果foo不是直接存在于myObject中,[[Prototype]]链就会被遍历,类似[[Get]]操作。如果原型链上找不到foo, foo就会被直接添加到myObject上。

3)如果foo存在于原型链上层,赋值语句myObject.foo = "bar"的行为就会有些不同(而且可能很出人意料)。

如果属性名foo既出现在myObject中也出现在myObject的[[Prototype]]链上层,那么就会发生屏蔽。myObject中包含的foo属性会屏蔽原型链上层的所有foo属性,因为myObject.foo总是会选择原型链中最底层的foo属性。

(原型)继承

如果没有“继承”机制的话,JavaScript中的类就只是一个空架子。

4-3.png


这种图不仅展示出对象(实例)a1到Foo.prototype的委托关系,还展示出Bar.prototype到Foo.prototype的委托关系。

1、Object.create(..)

调用Object.create(..)会凭空创建一个“新”对象并把新对象内部的[[Prototype]]关联到指定的对象。

2、检查“类”关系

在传统的面向类环境中,检查一个实例(JavaScript中的对象)的继承祖先(JavaScript中的委托关联)通常被称为内省(或者反射)。

instanceof操作符只能处理对象(a)和函数(带.prototype引用的Foo)之间的关系,但是不能判断两个对象(比如a和b)之间是否通过[[Prototype]]链关联。

function Foo() {
  // ...
}

Foo.prototype.blah = ...;

var a = new Foo();
a instanceof Foo; // true

Foo.prototype.isPrototypeOf(..)可以判断[[Prototype]]反射:

Foo.prototype.isPrototypeOf(a); // true

isPrototypeOf(..)回答的问题是:在a的整条[[Prototype]]链中是否出现过Foo.prototype?

绝大多数(不是所有)浏览器也支持一种非标准的方法来访问内部[[Prototype]]属性:

a.__proto__ === Foo.prototype; // true

.__proto__实际上并不存在于对象中(本例中是a)。实际上,它和其他的常用函数一样,存在于内置的Object.prototype中。

对象关联

[[Prototype]]机制就是存在于对象中的一个内部链接,它会引用其他对象。

如果在对象上没有找到需要的属性或者方法引用,引擎就会继续在[[Prototype]]关联的对象上进行查找。同理,如果在后者中也没有找到需要的引用就会继续查找它的[[Prototype]],以此类推。这一系列对象的链接被称为“原型链”。

1、创建关联

Object.create(..)可以创建想要的关联关系。

Object.create()的polyfill代码:

if (!Object.create) {
  Object.create = function (o) {
    function F() {}
    F.prototype = o;
    return new F();
  };
}

上面这段polyfill代码使用了一个一次性函数F,通过改写它的.prototype属性使其指向想要关联的对象,然后再使用new F()来构造一个新对象进行关联。

行为委托

面向委托的设计

我们来尝试使用委托行为解决问题:

Task = {
  setID: function (ID) {
    this.id = ID;
  },
  outputID: function () {
    console.log(this.id);
  },
};

// 让XYZ委托Task
XYZ = Object.create(Task);

XYZ.prepareTask = function (ID, Label) {
  this.setID(ID);
  this.label = Label;
};

XYZ.outputTaskDetails = function () {
  this.outputID();
  console.log(this.label);
};

// ABC = Object.create(Task);
// ABC ... = ...

在这段代码中,Task和XYZ并不是类(或者函数),它们是对象。XYZ通过Object. create(..)创建,它的[[Prototype]]委托了Task对象。

在上面的代码中,id和label数据成员都是直接存储在XYZ上(而不是Task)。通常来说,在[[Prototype]]委托中最好把状态保存在委托者(XYZ、ABC)而不是委托目标(Task)上。

在委托行为中则恰好相反:我们会尽量避免在[[Prototype]]链的不同级别中使用相同的命名,否则就需要使用笨拙并且脆弱的语法来消除引用歧义。

我们和XYZ进行交互时可以使用Task中的通用方法,因为XYZ委托了Task。

委托行为意味着某些对象(XYZ)在找不到属性或者方法引用时会把这个请求委托给另一个对象(Task)。

类与对象

从设计模式的角度来说,我们并没有像类一样在两个对象中都定义相同的方法名render(..),相反,我们定义了两个更具描述性的方法名(insert(..)和build(..))。同理,初始化方法分别叫作init(..)和setup(..)。

在委托设计模式中,除了建议使用不相同并且更具描述性的方法名之外,还要通过对象关联避免丑陋的显式伪多态调用(Widget.call和Widget.prototype.render.call),代之以简单的相对委托调用this.init(..)和this.insert(..)。

从语法角度来说,我们同样没有使用任何构造函数、.prototype或new,实际上也没必要使用它们。

使用类构造函数的话,你需要(并不是硬性要求,但是强烈建议)在同一个步骤中实现构造和初始化。然而,在许多情况下把这两步分开(就像对象关联代码一样)更灵活。


总结

我们来总结一下本篇的主要内容:

  • 类构造函数属于类,而且通常和类同名。此外,构造函数大多需要用new来调,这样语言引擎才知道你想要构造一个新的类实例。
  • 类意味着复制。传统的类被实例化时,它的行为会被复制到实例中。类被继承时,行为也会被复制到子类中。多态(在继承链的不同层次名称相同但是功能不同的函数)看起来似乎是从子类引用父类,但是本质上引用的其实是复制的结果。
  • 如果要访问对象中并不存在的一个属性,[[Get]]操作就会查找对象内部[[Prototype]]关联的对象。这个关联关系实际上定义了一条“原型链”,在查找属性时会对它进行遍历。
  • 所有普通对象都有内置的Object.prototype,指向原型链的顶端(比如说全局作用域),如果在原型链中找不到指定的属性就会停止。toString()、valueOf()和其他一些通用的功能都存在于Object.prototype对象上,因此语言中所有的对象都可以使用它们。
  • 只用对象来设计代码时,不仅可以让语法更加简洁,而且可以让代码结构更加清晰。对象关联(对象之前互相关联)是一种编码风格,它倡导的是直接创建和关联对象,不把它们抽象成类。对象关联可以用基于[[Prototype]]的行为委托非常自然地实现。

作者介绍
非职业「传道授业解惑」的开发者叶一一。
《趣学前端》、《CSS畅想》等系列作者。华夏美食、国漫、古风重度爱好者,刑侦、无限流小说初级玩家。
如果看完文章有所收获,欢迎点赞👍 | 收藏️ | 留言📝

【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息, 否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。