08 深入理解继承、Delegation和组合 你好,我是石川。

关于面向对象编程,最著名的一本书就数GoF(Gang of Four)写的《设计模式:可复用面向对象软件的基础》了。这本书里一共提供了23种不同的设计模式,不过今天我们不会去展开了解这些细节,而是会把重点放在其中一个面向对象的核心思想上,也就是组合优于继承

在JS圈,有不少继承和组合的争论。其实无论是继承还是组合,我们都不能忘了要批判性地思考。批判性思考的核心不是批判,而是通过深度思考核心问题,让我们对事物能有自己的判断。

所以,无论是继承还是组合,都只是方式、方法,它们要解决的核心问题就是如何让代码更加容易复用

图片

那么接下来,我们就根据这个思路,看看JavaScript中是通过哪些方法来解决代码复用这个问题的,以及在使用不同的方法时它们各自解决了什么问题、又引起了什么问题。这样我们在实际的业务场景中,就知道如何判断和选择最适合的解决方式了。

继承

在传统的OOP里面,我们通常会提到继承(Inheritance)和多态(Polymorphism)。继承是用来在父类的基础上创建一个子类,来继承父类的属性和方法。多态则允许我们在子类里面调用父类的构建者,并且覆盖父类里的方法。

那么下面,我们就先来看下在JavaScript里,要如何通过构建函数来做继承。

如何通过继承多态重用?

实际上,从ES6开始,我们就可以通过extends的方式来做继承。具体如下所示: class Widget { appName = “核心微件”; getName () { return this.appName; } } class Calendar extends Widget {} var calendar = new Calendar(); console.log(calendar.hasOwnProperty(“appName”)); // 返回 true console.log(calendar.getName()); // 返回 “核心微件” calendar.appName = “日历应用” console.log(typeof calendar.getName); // 返回 function console.log(calendar.getName()); // 返回 “日历应用”

接着来看多态。从ES6开始,我们可以通过super在子类构建者里面调用父类的构建者,并且覆盖父类里的属性。可以看到在下面的例子里,我们是通过super将Calendar的appName属性从“核心微件”改成了“日历应用”。

class Widget { constructor() { this.appName = “核心微件”; } getName () { return this.appName; } } class Calendar extends Widget { constructor(){ super(); this.appName = “日历应用”; } } var calendar = new Calendar(); console.log(calendar.hasOwnProperty(“appName”)); // 返回 true console.log(calendar.getName()); // 返回 “日历应用” console.log(typeof calendar.getName); // 返回 function console.log(calendar.getName()); // 返回 “日历应用”

在一些实际的例子,如React这样的三方库里,我们也经常可以看到一些继承的例子,比如我们可以通过继承React.Component来创建一个WelcomeMessage的子类。

class WelcomeMessage extends React.Component { render() { return <h1>Hello, {this.props.name}</h1>; } }

授权

说完了继承,我们再来看授权这个方法。

什么是授权(Delegation)呢?我打个比方,这里的授权不是我们理解的作为领导(父类)给下属(子类)授权,而是作为个体对象可以授权给一个平台或者他人来一起做一件事。

就好像我和极客时间合作,我的个人精力和专业能力只允许我尽量做好内容,但是我没有精力和经验去做编辑、后期和推广等等,这时就授权给极客时间相关的老师来一起做,我在这件事、这个过程中只是专心把内容的部分做好。

如何通过授权做到重用?

在前面的例子中,结合我们在第1讲里提到的基于原型链的继承,我们会发现使用JavaScript无论是通过函数构建也好,还是加了语法糖的类也好,来模拟一般的面向对象语言,比如Java的类和继承,对于有些开发者来说是比较反直觉的。在使用的时候需要大量的思想转换,才能把JavaScript的底层逻辑转换成实际呈现出来的实现。

那么有没有一种方式可以让代码更直观呢?这种方式其实就是通过原型本身来做授权会更符合直觉。从ES5开始,JavaScript就支持了Object.create()的方法。下面我们来看一个例子: var Widget = { setCity : function(City) {this.city = City; }, outputCity : function() {return this.city;} }; var Weather = Object.create(Widget); Weather.setWeather = function (City, Tempreture) { this.setCity(City); this.tempreture = Tempreture; }; Weather.outputWeather = function() { console.log(this.outputCity()+ “, “ + this.tempreture); } var weatherApp1 = Object.create(Weather); var weatherApp2 = Object.create(Weather); weatherApp1.setWeather(“北京”,”26度”); weatherApp2.setWeather(“南京”,”28度”); weatherApp1.outputWeather(); // 北京, 26度 weatherApp2.outputWeather(); // 南京, 28度

可见,我们创建的Weather天气预报这个对象,授权给了Widget,让Widget在得到授权的情况下,帮助Weather来设定城市和返回城市。Widget对象在这里更像是一个平台,它在得到Weather的授权后为Weather赋能。而Weather对象可以在这个基础上,专注于实现自己的属性和方法,并且产出weatherApp1和weatherApp2的实例。

当然也有开发者认为class的方式没有什么反直觉的,那授权同样可以通过class来实现。比如我们如果想在上一讲提到过的集合(Set)和字典(Map)的基础上,加上计数的功能,可以通过继承Set来实现。但是我们也可以反之,在把部分功能授权给Map的基础上,自己专注实现一些类似Set的API接口。 class SetLikeMap { // 初始化字典 constructor() { this.map = new Map(); } // 自定义集合接口 count(key) { //…// } add(key) { //…// } delete(key) { //…// } // 迭代返回字典中的键 Symbol.iterator { return this.map.keys(); } // 部分功能授权给字典 keys() { return this.map.keys(); } values() { return this.map.values(); } entries() { return this.map.entries(); } }

组合

说完了授权,我们再来看看组合。当然上面我们说的授权,广义上其实就是一种组合。但是这种组合更像是“个体和平台的合作”;而另一种组合更像是“团队内部的合作”,它也有很多的应用和实现方式,我们可以来了解一下。

图片

如何通过借用做到重用?

在JavaScript中,函数有自带的apply和call功能。我们可以通过apply或call来“借用”一个功能。这种方式,也叫隐性混入(Implicit mixin)。比如在数组中,有一个原生的slice的方法,我们就可以通过call来借用这个原生方法。

如下代码示例,我们就是通过借用这个功能,把函数的实参当做数组来slice。 function argumentSlice() {     var args = [].slice.call(arguments, 1, 3);     return args; } // example argumentSlice(1, 2, 3, 4, 5, 6); // returns [2,3]

如何通过拷贝赋予重用?

除了“借力”以外,我们还能通过什么组合方式来替代继承呢?这就要说到“拷贝”了。这个方法顾名思义,就是把别人的属性和方法拷贝到自己的身上。这种方式也叫显性混入(Explicit mixin)。

在ES6之前,人们通常要偷偷摸摸地“抄袭”。在ES6之后,JavaScript里才增加了“赋予”,也就是Object.assign()的功能,从而可以名正言顺地当做是某个对象“赋予”给另外一个对象它的“特质和能力”。

那么下面,我们就先看看在ES6之后,JavaScript是如何名正言顺地来做拷贝的。

首先,通过对象自带的assign(),我们可以把Widget的属性赋予calendar,当然在calendar里,我们也可以保存自己本身的属性。和借用一样,借用和赋予都不会产生原型链。如以下代码所示: var widget = { appName : “核心微件” } var calendar = Object.assign({ appVersion: “1.0.9” }, widget); console.log(calendar.hasOwnProperty(“appName”)); // 返回 true console.log(calendar.appName); // 返回 “核心微件” console.log(calendar.hasOwnProperty(“appVersion”)); // 返回 true console.log(calendar.appVersion); // 返回 “1.0.9”

好,接着我们再来看看在ES6之前,人们是怎么通过“抄袭”来拷贝的。

这里实际上分为“浅度拷贝”和“深度拷贝”两个概念。“浅度拷贝”类似于上面提到的赋予assign这个方法,它所做的就是遍历父类里面的属性,然后拷贝到子类。我们可以通过JavaScript中专有的for in循环,来遍历对象中的属性。

细心的同学可能会发现,我们在第2讲中说到用拷贝来做到不可变时,就了解过通过延展操作符来实现浅拷贝的方法了。 // 数组浅拷贝 var a = [ 1, 2 ]; var b = [ …a ]; b.push( 3 ); a; // [1,2] b; // [1,2,3] // 对象浅拷贝 var o = { x: 1, y: 2 }; var p = { …o }; p.y = 3; o.y; // 2 p.y; // 3

而在延展操作符出现之前,人们大概可以通过这样一个for in循环做到类似的浅拷贝。

function shallowCopy(parent, child) { var i; child = child   {}; for (i in parent) { if (parent.hasOwnProperty(i)) { child[i] = parent[i]; } } return child; }

至于深度拷贝,是指当一个对象里面存在嵌入的对象就会深入遍历。但这样会引起一个问题:如果这个对象有多层嵌套的话,是每一层都要遍历吗?究竟多深算深?还有就是如果一个对象也引用了其它对象的属性,我们要不要也拷贝过来?

所以相对于深度拷贝,浅度拷贝的问题会少一些。但是在第2讲的留言互动区,我们也说过,如果我们想要保证一个对象的深度不可变,还是需要深度拷贝的。深度拷贝的一个相对简单的实现方案是用JSON.stringify。当然这个方案的前提是这个对象必须是JSON-safe的。 function deepCopy(o) { return JSON.parse(JSON.stringify(o)); }

同时,在第2讲的留言区中,也有同学提到过另外一种递归的实现方式,所以我们也大致可以通过这样一个递归来实现:

function deepCopy(parent, child) { var i, toStr = Object.prototype.toString, astr = “[object Array]”; child = child   {};     for (i in parent) { if (parent.hasOwnProperty(i)) { if (typeof parent[i] === “object”) { child[i] = (toStr.call(parent[i]) === astr) ? [] : {};                 deepCopy(parent[i], child[i]);         } else {           child[i] = parent[i];         }       }   }   return child; }

如何通过组合做到重用?

上面我们说的无论是借用、赋予,深度还是浅度拷贝,都是一对一的关系。最后我们再来看看,如何通过ES6当中的assign来做到组合混入,也就是说把几个对象的属性都混入在一起。其实方法很简单,以下是参考: var touchScreen = { hasTouchScreen : () => true }; var button = { hasButton: () => true }; var speaker = { hasSpeaker: () => true }; const Phone = Object.assign({}, touchScreen, button, speaker); console.log( hasTouchScreen: ${ Phone.hasChocolate() } hasButton: ${ Phone.hasCaramelSwirl() } hasSpeaker: ${ Phone.hasPecans() } );

React中的组合优于继承

在React当中,我们也可以看到组合优于继承的无处不在,并且它同样体现在我们前面讲过的两个方面,一个是“团队内部的合作”,另一个是“个体与平台合作”。下面,我们先看看“团队内部的合作”的例子,在下面的例子里,WelcomeDialog就是嵌入在FancyBorder中的一个团队成员。 function FancyBorder(props) { return ( <div className={‘FancyBorder FancyBorder-‘ + props.color}> {props.children} </div> ); } function WelcomeDialog() { return ( <h1 className="Dialog-title"> Welcome </h1> <p className="Dialog-message"> Thank you for visiting our spacecraft! </p> ); }

另外,我们也可以看到“个体与平台合作”的影子。在这里,WelcomeDialog是一个“专业”的Dialog,它授权给Dialog这个平台,借助平台的功能,实现自己的title和message。这里就是用到了组合。

function Dialog(props) { return ( <h1 className="Dialog-title"> {props.title} </h1> <p className="Dialog-message"> {props.message} </p> ); } function WelcomeDialog() { return (

); }

总结

这节课,我们了解了通过JavaScript做到代码复用的几种核心思想和方法,从传统的继承,到JavaScript特色的授权以及组合等方式都有分析。虽然我说授权和组合优于继承,但实际上它们之间的关系不是非黑即白的。

我们看到在前端圈,有很多大佬比如道格拉斯·克罗克福德(Douglas Crockford)和凯尔·辛普森(Kyle Simpson),都是基于授权的对象创建的积极拥护者;而像阿克塞尔·劳施迈尔博士(Dr. Axel Rauschmayer)则是基于类的对象构建的捍卫者。

我们作为程序员,如果对对象和面向对象的理解不深入,可能很容易在不同的论战和观点面前左摇右摆。而实际的情况是,真理本来就不止一个。我们要的“真理”,只不过是通过一个观察角度,形成的一个观点。这样,才能分析哪种方式适合我们当下要解决的问题。这个方式,只有在当下,才是“真理”。而我们通过这个单元整理的方法,目的就是帮助我们做到这样的观测。

图片

思考题

在前面一讲中,我们试着通过去掉对象私有属性的语法糖,来看如何用更底层的语言能力来实现类似的功能。那么,今天你能尝试着实现下JS中的类和继承中的super,以及原型和授权中的Object.create()吗?

欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课见!

参考资料

https://learn.lianglianglee.com/%e4%b8%93%e6%a0%8f/JavaScript%20%e8%bf%9b%e9%98%b6%e5%ae%9e%e6%88%98%e8%af%be/08%20%e6%b7%b1%e5%85%a5%e7%90%86%e8%a7%a3%e7%bb%a7%e6%89%bf%e3%80%81Delegation%e5%92%8c%e7%bb%84%e5%90%88.md