《JavaScript 模式》读书笔记(6)— 代码复用模式2

  上一篇讲了最简单的代码复用模式,也是最基础的,我们普遍知道的继承模式,但是这种继承模式却有不少缺点,我们下面再看看其它可以实现继承的模式。

四、类式继承模式#2——借用构造函数

  本模式解决了从子构造函数到父构造函数的参数传递问题。本模式借用了父构造函数,它传递子对象以绑定到this,并且还转发任意参数。

function Child(a,c,b,d) {
    parent.apply(this,arguments);
}

  在这种方式中,只能继承在父构造函数中添加到this的属性。同时,并不能继承那些已添加到原型中的成员。

  使用该借用构造函数模式时,子对象获得了继承成员的副本,这与类式继承模式#1中,仅获取引用的方式是不同的。下面的例子演示了其差异:

// 父构造函数
function Article() {
    this.tags = ['js','css'];
}
var article = new Article();

// blog 文章对象继承了article对象
// via the classical pattern #1
function BlogPost() {}
BlogPost.prototype = article;
var blog = new BlogPost();

// 注意以上代码,你不需要new Article()
// 是因为你已经有一个可用的实例

// static page (静态页面)继承了article
// 通过借用构造函数模式

function StaticPage() {
    Article.call(this);
}

var page = new StaticPage();

console.log(article.hasOwnProperty('tags')); //true
console.log(blog.hasOwnProperty('tags')); //false
console.log(page.hasOwnProperty('tags')); //true

  在以上代码片段中,有两种方式都继承了父构造函数Article()。默认模式导致了blog对象通过原型以获得tags属性的访问,因此blog对象中没有将article作为自身的属性,因此当调用hasOwnProperty()时会返回false。相反,page对象本身则具有一个tags属性,这是由于它在使用借用构造函数的时候,新对象会获得父对象中tags成员的副本(不是引用)。

  请注意修改继承的tags属性时表现出来的差异:

blog.tags.push('html');
page.tags.push('php');
console.log(article.tags.join(', '));// 'js, css, html'

  在上面这个例子中,子对象blog修改了其tags属性,而这种方式同时也会修改父对象article,这是由于本质上blog.tags和article.tags都指向了同一个数组。但是,修改page.tags时却不会影响其父对象article,这是由于在继承过程中page.tags是独立创建的一个副本。

 

原型链

  当使用本模式以及熟悉的Parent()和Child()构造函数时,让我们来看原型链(prototype chain)的工作流程。其中,Child()需要根据这个新模式的需求略加修改:

// 父构造函数
function Parent(name) {
    this.name = name || 'Adam';
}

// 向该原型添加功能
Parent.prototype.say = function () {
    return this.name;
};

// 子构造函数

function Child(name) {
    Parent.apply(this,arguments);
}

var kid = new Child('Patrick');
console.log(kid.name); // 输出“Patrick”
console.log(typeof kid.say); //输出undefined

  如果仔细查看下图,将会注意到在new Child对象和Parent对象之间不再有链接。出现这种现象的原因在于本模式中根本就没有使用Child.prototype,并且它只是指向一个空对象。使用本模式时,kid获得了自身的属性name,但是却从未继承过say()方法,如果试图调用该方法将会导致错误。继承是一个一次性的操作,它仅会复制父对象的属性并将其作为子对象自身的属性,仅此而已。因此,也就不会保留__proto__链接。

 

通过借用构造函数实现多重继承

  当使用借用构造函数模式时,可以通过借用多个构造函数从而简单的实现多重继承。

function Cat() {
    this.legs = 4;
    this.say = function () {
        return "meaowww";
    }
}
function Bird() {
    this.wings = 2;
    this.fly = true;
}

function CatWings() {
    Cat.apply(this);
    Bird.apply(this);
}

var jane = new CatWings();
console.log(jane);

  上述代码的运行结果是这样的:

legs: 4
say: ƒ ()
wings: 2
fly: true

  在解析任意的副本属性时,将会通过最后一个获胜的方式来解析该属性(这句话的意思是,如果复制的属性中有相同的属性名,那么会后者优先)。

 

借用构造函数模式的优缺点

  借用构造函数模式的缺点是很明显的,如前面所述,其问题在于根本无法从原型中继承任何东西,并且原型也仅是添加可重用方法以及属性的位置,它并不会为每个实例重新创建原型。

  本模式的一个优点在于可以获得父对象自身成员的真实副本,并且也不会存在于子对象意外覆盖父对象属性的风险。

  因此,在前面的情况中,如何才能使子对象也能够继承原型属性?以及如何使kid能够访问say()方法?下面这个模式将解决这个问题

 

五、类式继承模式#3——借用和设置原型

  类式继承模式#3主要思想是结合前两种模式,即先借用构造函数,然后还设置子构造函数的原型使其指向一个构造函数创建的新实例。如下所示:

function Child(a,c,b,d) {
    Parent.apply(this,arguments);
}

Child.prototype = new Parent()

  这样做的优点在于,以上代码运行后的结果对象能够获得父对象本身的成员副本以及指向父对象中可复用功能(以原型成员方式实现的那些功能)的引用。同时,子对象也能够将任意参数传递到父构造函数中。这种行为可能是最接近您希望在Java中实现的方式。可以继承父对象中的一切东西,同时这种方法也能够安全的修改自身属性,且不会带来修改其父对象的风险。

  这种模式的一个缺点是,父构造函数被调用了两次,因此这导致了其效率低下的问题。最后,自身的属性(比如本例中扽ame属性)会被继承两次:

function Parent(name) {
    this.name = name || 'Adam';
}

// adding functionality to the prototype
Parent.prototype.say = function () {
    return this.name;
}

// 子构造函数
function Child(name) {
    Parent.apply(this,arguments);
}

Child.prototype = new Parent();

var kid = new Child('Patrick');
console.log(kid.name); //输出“Patrick”
console.log(kid.say());// 输出“Patrick”
delete kid.name;
console.log(kid.say());// 输出“Adam”

  在上面的代码中,不同于先前的模式,现在say()方法已被正确的继承。还可以注意到name属性却被继承了两次,在我们删除了kid本身的name属性的副本后,随后看到的输出是原型链表现出来所引出的name属性。

  下图显示了对象之间的链接关系。这些关系非常类似于之前#1模式的最后一张图中所示的原型链,但这里我们所采用的继承方式是不同的。

 

六、类式继承模式#4——共享原型

  不同于前面的那种需要两次调用父构造函数的模式(类式继承模式#3),接下来介绍的模式根本就不涉及调用任何父构造函数。

  本模式的经验法则在于:可复用成员应该转移到原型中而不是放置在this中。因此,出于继承的目的,任何值得继承的东西都应该放置在原型中实现。所以,可以仅将子对象的原型与父对象的原型设置为相同的即可:

function inherit(C, P){
    C.prototype = P.prototype;
}

  这种模式能够向您提供剪短而迅速的原型链查询,这是由于所有的对象实际上共享了同一个原型。但是,这同时也是一个缺点,因为如果在继承链下方的某处存在一个子对象或者孙子对象修改了原型,它将会影响到所有的父对象和祖先对象。

  如下图所示,下面的子对象和父对象共享了同一个原型,并且可以同等的访问say()方法。然而,需要注意到子对象并没有继承name属性。

 

 

七、类式继承模式#5——临时构造函数

  类式继承模式#5通过断开父对象与子对象的原型之间的直接链接关系,从而解决共享同一个原型所带来的问题,而且同时还能够继续受益于原型链带来的好处。

  下面的代码是本模式的一种实现方式,在该代码中有一个空白函数F(),该函数充当了子对象与父对象之间的代理。F()的prototype属性指向父对象的原型。子对象的原型则是一个空白函数实例。

function inherit(C, P){
    var F = function(){};
    F.prototype = P.prototype;
    C.prototype = new F();
}

  这种模式在行为上与默认模式(类式继承模式#1)略有不同,这是由于这里的子对象仅继承了原型的属性(见下图)。这种情况通常来说是很好的,实际上也是更加可取的,因为原型也正是放置可复用功能的位置。在这种模式中,父构造函数添加到this中的任何成员都不会被继承。

  让我们创建一个新的子对象,并审查其行为:

var kid = new Child();

  如果访问kid.name,其结果将是undefined类型。在这种情况下,name是父对象所拥有的一个属性,然而在继承的时候我们实际上从未调用过new Parent(),因此也从未创建过该属性。当您访问kid.say()时,在对象#3中该方法并不可用,因此需要开始查询原型链。然而对象#4中也没有该方法,但是对象#1中确实存在该方法并且位于内存中的同一个位置,因此所有继承了Parent()的不同构造函数,以及所有由其子构造函数所创建的对象都可重用该say()方法。

 

存储超类

  在上面模式的基础上,还可以添加一个指向原始父对象的引用。这就像在其他编程语言中访问超类一样,这可以偶尔派上用场。

  该属性被称之为uber,这仅是由于“super”是保留的关键词,并且“superclass”可能导致存心的程序员不加思考便顺势根据该关键词认为JavaScript中具有类(class)。下面是该类式继承模式的一个改进实现:

function inherit(C, P){
    var F = function(){};
    F.prototype = P.prototype;
    C.prototype = new F();
    C.uber = P.prototype;
}

  

重置构造函数指针

  最后,针对这个几乎完美的类式继承函数,还需要做的一件事情就是重置该构造函数的指针,以免在将来的某个时候还需要该构造函数。

  如果不重置该构造函数的指针,那么所有子对象将会报告Parent()是它们的构造函数,这是没有任何用处的。因此,使用前面的inherit()实现代码,可以观察到此行为:

// 父子继承
function Parent() {}
function Child() {}
inherit(Child,Parent);

// 投石问路
var kid = new Child();
console.log(kid.constructor.name); //Parent
console.log(kid.constructor === Parent); //true

  虽然我们很少用到constructor属性,但是这种功能却可以很方便的用于运行时对象的内省。可以重置constructor属性使其指向期望的构造函数且不会影响其功能,这是由于该属性主要是用于提供对象的信息。

  这个类式继承模式最后的圣杯版本看起来如下所示:

function inherit(C, P){
    var F = function(){};
    F.prototype = P.prototype;
    C.prototype = new F();
    C.uber = P.prototype;
    C.prototype.constructor = C;
}

  如果认为这种模式是适用于项目中的最佳方法,需要说明的是,在开源YUI库或者其他库中也存在一个与本函数相似的函数,并且它还在没有类的情况下实现了类式继承。

  对于该圣杯模式的一个常见优化是避免在每次需要继承时都创建临时(代理)构造函数。仅创建一次临时构造函数,并且修改它的原型,这已经是非常充分的。在具体实现方式上,可以使用即时函数并且在闭包中存储代理函数。

var inherit = (function () {
    var F = function () { };
    return function (C, P) {
        F.prototype = P.prototype;
        C.prototype = new F();
        C.uber = P.prototype;
        C.prototype.constructor = C;
    }
}());

 

八、Klass  

  许多JavaScript库都模拟了类的概念,并引进类一些语法糖。这些库中类的实现方式各有不同,但是往往都有一些共性,其中包括了以下内容:

  • 都有一套有关如何命名类方法的公约,这也被认为是类的构造函数,比如initialize,_init以及一些其他类似的构造函数名,并且在创建对象时这些方法将会被自动调用。
  • 存在从其他类所继承的类。
  • 在子类中可以访问父类或超类。

  tips:让我们从这里改变思维,由于在贲张的这一步分钟讨论的主题是有关模拟类的概念,因此尽在这里自由的使用“class”这个词语。

  在没有深入研究其细节的情况下,让我们看一个在JavaScript中模拟类的实现示例。首先,从客户的角度来看应该如何使用该解决方案?

var Man = klass(null,{
    _construct: function (what) {
        console.log("Man's constructor");
        this.name = what
    },
    getName: function () {
        return this.name
    }
});

  上面代码中的语法糖以一个名为klass()的函数形式出现。在其他一些实现中,可能会看到它以klass()构造函数或以增强的Object.prototype出现。但在本例子中,让我们将其保持为一个简单的函数。

  该函数有两个参数:第一个参数为将被继承的父类。第二个参数为对象字面量所提供的新类的实现。由于受到PHP的影响,让我们建立一个公约,即类的构造函数必须是名为_construct的方法。在前面的代码片段中,创建了一个名为Man的新类,该类并没有继承任何其他类(这意味着在后台继承了Object类)。Man类中有一个在_construct中所创建的属性name,以及一个方法getName()。该类同时也是一个构造函数,因此下面的代码仍然能够正常运行(看起来就像一个类的实例化)。

var first = new Man('Adam'); //记录了“Man's constructor”
first.getName(); // 'Adam'

  现在,让我们扩充该类,并创建一个SuperMan类:

var SuperMan = klass(Man,{
    _construct: function(what) {
        console.log("SuperMan's constructor");
    },
    getName: function () {
        var name = SuperMan.uber.getName.call(this);
        return "I am" + name;
    }
});

  在上面的代码中,传递给klass()的第一个参数是将被继承的父类Man。同时请注意到在getName()中,其父类的函数getName()由于通过使用SuperMan的uber(super)静态属性首先被调用。为证实该行为,我们进行如下测试:

var clark = new SuperMan('Clark Kent');
clark.getName(); // 结果为“I am Clark Kent”

  记录到控制台的第一行输出为“Man‘s constructor”,然后输出”Superman's constructor“。实际上,在大多数基于类的语言中,每次在调用子类的构造函数时,弗雷德构造函数也将会被自动调用。因此,在JavaScript中为何不模拟成与那些语言是一样的呢?

  测试instanceof操作符会返回如下期望的结果:

clark instanceof Man // true
clark instanceof SuperMan // true

  最后,我们来看一下klass()函数是如何实现的:

var klass = function (Parent,props) {
    var Child , F, i;
    // 1.
    // 新构造函数
    Child = function () {
        if(Child.uber && Child.uber.hasOwnProperty("_construct")) {
            Child.uber._construct.apply(this,arguments);
        }
        if(Child.prototype.hasOwnProperty("_construct")) {
            Child.prototype._construct.apply(this,arguments);
        }
    };

    // 2.
    // 继承
    Parent = Parent || Object;
    F = function () {};
    F.prototype = Parent.prototype;
    Child.prototype = new F();
    Child.uber = Parent.prototype;
    Child.prototype.constructor = Child;

    // 3.
    // 添加实现方法
    for(i in props) {
        if(props.hasOwnProperty(i)) {
            Child.prototype[i] = props[i];
        }
    }

    // 返回该“class”
    return Child
}

  在klass()的实现中有三个令人关注且独特的部分:

  1. 创建了Child()构造函数。该函数将是最后返回的,并且该函数也用作类。在这个函数中,如果存在_construct方法,那么将会调用该方法。另外,在此之前,通过使用静态uber属性,其父类的_construct方法也会被自动调用(同样,如果存在该方法的话)。可能在有些情况下,当没有定义uber属性时,比如直接从Object类中继承时,这与从Man类的定义中继承是相似的。
  2. 第二部分在一定程度上处理继承关系。他只是采用了本章前面章节中所讨论的类式继承的圣杯版本模式。从代码上看,只有一个新的语句,即Parent = Parent || Object,即如果没有传递需要被继承的类,那么就将Parent设置为Object。
  3. 最后一节是遍历所有的实现方法(比如,本例中的_construct和getName),这些是该类的实际定义,并且也是将它们添加到Child的原型中的部分代码。

  那么,什么时候应该使用这种模式呢?实际上答案是避免使用它会更好,原因在于它导致类整个类的概念的混淆,而在JavaScript语言中严格来说是不存在类的概念的。这种模式增加了新的语法并且还需要学习和记忆新规则。也就是说,如果您或者团队使用类时感觉很轻松,并且同时对于原型感到不适应,那么就值得探索该模式。这种模式允许您完全忘记原型,并且其有点还在于您可以调整语法和公约以使其与您喜爱得到语言风格相类似。

 

  最后,最基本的类式继承模式到这里就告一段落类,但是这远远不是结束。

站在巨人的肩膀上,希望我可以看的更远。
posted @ 2020-04-19 17:11  Zaking  阅读(275)  评论(0编辑  收藏  举报