Javascript的實例化與繼承:請停止使用new關鍵字


本文同時也發表在我另一篇獨立博客 《Javascript的實例化與繼承:請停止使用new關鍵字》(管理員請注意!這兩個都是我自己的原創博客!不要踢出首頁!不是轉載!已經誤會三次了!)

 

標題當然是有一點聳人聽聞了,但個人覺得使用new關鍵字確實並非是一個最佳的實踐。換句話說,我覺得有更好的實踐,讓實例化和繼承的工作在javascript更友好一些,本文所做的工作就是教你對new關聯的操作進行一系列封裝,甚至完全拋棄new關鍵字。

在閱讀本文之前你必須要對javascript中關於prototypeconstructor, 以及如何實現面向對象,this關鍵字的使用等概念非常熟悉,否則,相信我,你會看的非常頭大。如果目前還不是很熟悉的話,可以參考我的前兩篇博客Javascript: 從prototype漫談到繼承(1)Javascript: 從prototype漫談到繼承(2)。這兩篇文章目前還有一些敘述有誤的地方,但是還是可以提供一些參考。

 

傳統的實例化與繼承

 

還是先溫習一下javascript繼承的原理吧

假設我們有兩個類Class:function Class() {}和SubClass:function SubClass() {},SubClass需要繼承自Class,應該怎么做?

  • 首先,Class中被繼承的屬性和方法必須放在Class的prototype屬性中
  • 再者,SubClass中自己的方法和屬性也必須放在自己prototype屬性中
  • 別忘了SubClass的prototype也是一個對象,但這個對象的prototype(__proto__)指向的Class的prototype
  • 這樣以來,由於prototype鏈的一些特性,SubClass的實例便能追溯到Class的方法。這樣便實現繼承
new SubClass()      Object.create(Class.prototype)
    |                    |
    V                    V
SubClass.prototype ---> { }
                        { }.__proto__ ---> Class.prototype

 

我們舉的第一個例子,要做以下幾件事:

  • 有一個父類叫做Human
  • 使一個名為Man的子類繼承自Human
  • 子類繼承父類的一切,並調用父類的構造函數
  • 實例化這個子類
// 構造函數/基類
function Human(name) {
    this.name = name;
}

// 基類的方法保存在構造函數的prototype屬性中
// 便於子類的繼承
Human.prototype.say = function () {
    console.log("say");
}

// 道格拉斯的object方法
// 等同於Object.create
function object(o) {
    var F = function () {};
    F.prototype = o;
    return new F();
}

// 子類Man
function Man(name, age) {
    // 調用父類的構造函數
    Human.call(this, name);
    // 自己的屬性age
    this.age = age;
}

// 繼承父類的方法
Man.prototype = object(Human.prototype);
Man.prototype.constructor = Man;

// 實例化
var man = new Man("Lee", 22);
console.log(man);

 

以上我們可以總結出傳統的實例化與繼承的幾個特點:

  • 傳統方法中的“類”一定是一個構造函數——你可能會問還有可能不是構造函數嗎?當然可以,文章的最后會介紹如何實現一個不是構造函數的類。
  • 屬性和方法的繼承一定是通過prototype實現,也一定是通過Object.create方法,也就是道格拉斯的object方法。你可能又要問了:何以見得,Object.create與object方法是一致?這當然不是我說的,而是在MDN上object是作為Object.create的一個Polyfill方案。
  • 實例化一個對象,一定是通過new關鍵字來實現的。(你能回憶起除了new關鍵字,還有其他哪些方式來創建一個對象嗎?)

 

那么new關鍵字的不足之處在哪?

 

首先在《Javascript語言精粹》(Javascript: The Good Parts)中,道格拉斯原話是這樣敘述的:

If you forget to include the new prefix when calling a constructor function, then this will not be bound to the new object. Sadly, this will be bound to the global object, so instead of augmenting your new object, you will be clobbering global variables. That is really bad. There is no compile warning, and there is no runtime warning. (page 49)

大意是說在該使用new的時候忘了new關鍵字,將會非常糟糕。但我不覺得這是一個恰當的理由,或者說這個理由非常牽強。遺忘使用任何東西都會引起一系列的問題,何止於new關鍵字呢,再者說其實這個是有辦法解決的:

function foo()
{
   // if user accidentally omits the new keyword, this will 
   // silently correct the problem...
   if ( !(this instanceof foo) )
      return new foo();

   // constructor logic follows...
}

 

或者作為一個更通用的方案,拋出異常即可

function foo()
{
    if ( !(this instanceof arguments.callee) ) 
       throw new Error("Constructor called as a function");
}

 

又或者按照John Resig的方案,我們准備一個makeClass工廠函數,把大部分的初始化功能放在一個init方法中,而非構造函數自己中:

// makeClass - By John Resig (MIT Licensed)
function makeClass(){
  return function(args){
    if ( this instanceof arguments.callee ) {
      if ( typeof this.init == "function" )
        this.init.apply( this, args.callee ? args : arguments );
    } else
      return new arguments.callee( arguments );
  };
}

 

我認為new關鍵字不是一個好的實踐的原因是因為,

new is a remnant of the days where JavaScript accepted a Java like syntax for gaining “popularity”.

And we were pushing it as a little brother to Java, as a complementary language like Visual Basic was to C++ in Microsoft’s language families at the time.

和道格拉斯說的:

This indirection was intended to make the language seem more familiar to classically trained programmers, but failed to do that, as we can see from the very low opinion Java programmers have of JavaScript. JavaScript’s constructor pattern did not appeal to the classical crowd. It also obscured JavaScript’s true prototypal nature. As a result, there are very few programmers who know how to use the language effectively.

簡單來說,javascript是一種prototypal類型語言,在創建之初,為了迎合市場的需要,為了讓人們覺得它和Java是類似的,才引入了new關鍵字。Javascript本應通過它的Prototypical特性來實現實例化和繼承,但new關鍵字讓它變得不倫不類。想了解上面引用段落的全篇,可以參考本文最后的參考文獻。

 

把傳統方法加以改造

 

我們目前有兩種選擇,一是完全拋棄new關鍵字,二是把含有new關鍵字的操作封裝起來,只向外提供友好的接口。現在我們先做第二件事,最后來做第一件事。

那么封裝的接口是什么?:

  • 所有的類都派生自我們自己的一個基類Class
  • 派生出一個子類方法:Class.extend
  • 實例化一個類方法:Class.create

開始吧,先把結構搭起來:

// 基類
function Class() {}

Class.prototype.extend = function () {};
Class.prototype.create = function () {};

Class.extend = function (props) {
    return this.prototype.extend.call(this, props);
}

 

因為所有的類都能派生子類都能實例化,加上所有的類都派生自基類Class,所以我們把最關鍵的extendcreate方法放在Class的prototype中

接下來實現create和extend方法,解釋就寫在注釋中了:

Class.prototype.create = function (props) {
    /*
        正如開始所說,create實際上是對new的封裝
        create返回的實例實際上是new出來的實例
        this即指向調用當前create的子類構造函數
    */
    var instance = new this();
    /*
        將傳入的參數作為該實例的“私有”屬性
        更准確應該說是“實例屬性”,因為並非私有
        而是這個實例獨有
    */
    for (var name in props) {
        instance[name] = props[name];
    }
    return instance;
}

Class.prototype.extend = function (props) {
    /*
        派生出來的新的子類
    */
    var SubClass = function () {};
    /*
        繼承父類的屬性,
        當然前提是父類的屬性都放在prototype中
        而非上面的“實例屬性”中
    */
    SubClass.prototype = object(this.prototype);
    for (var name in props) {
        SubClass.prototype[name] = props[name];
    }
    SubClass.prototype.constructor = SubClass;

    /*
        因為需要以SubClass.extend的方式調用
        所以要重新賦值
    */
    SubClass.extend = SubClass.prototype.extend;
    SubClass.create = SubClass.prototype.create;

    return SubClass;
}

 

那么如何使用,如何對它進行測試呢,還是哪我們上面的Human和Man的例子:

var Human = Class.extend({
    say: function () {
        console.log("Hello");
    }
});

console.log(Human.create());

var Man = Human.extend({
    walk: function () {
        console.log("walk");
    }
})

console.log(Man.create({
    name: "Lee",
    age: 22
}));

 

進行再次改造

 

上面的例子還有兩個不足之處。

一是我們需要一個獨立的初始化實例的函數,比如說叫做init。其實構造函數自己不就是一個初始化函數嘛?對,但如果有一個正式的構造函數會更能滿足我們的某些需求,比如我們new一個構造函數,但是我們不想要它的實例,只想要實例上的prototype方法。這種情況就不必調用它的init函數。又或者這個init函數可以“借給”其他類使用

不足之二是我們一個類需要能調用父類方法的機制,比如在子類的同名函數中吼一聲this.callSuper,就能調用父類的同名方法。

開始吧

 

首先在派生一個類時,你需要定義一個初始化函數init,比如

// 基類
var Human = Class.extend({
    init: function () {
        this.nature = "Human";
    },
    say: function () {
        console.log("I am a human");
    }
})

 

然后Class.create就可以改造為

// 做了一點優化
Class.create = Class.prototype.create = function () {
    /*
        注意在這里我們只是實例化一個構造函數
        而非進行真正的“實例”
    */
    var instance = new this();

    /*
        這是我們即將做的,調用父類的構造函數
    */
    if (instance.__callSuper) {
        instance.__callSuper();
    }

    /*
        如果對init有定義的話
    */
    if (instance.init) {
        instance.init.apply(instance, arguments);
    }
    return instance;
}

 

注意上面的instance.__callSuper(),我們就靠這條語句來實現調用父類的構造函數,那么如何實現呢?具體解釋都注釋中

Class.extend = Class.prototype.extend = function () {
    var SubClass = function () {};
    var _super = this.prototype;

    ...

    // 前提是父類擁有init函數,才能召喚
    if (_super.init) {
        // 定義__callSuper方法
        SubClass.prototype.__callSuper = function () {
            /*
                有一種可能是,用戶已經定義了__callSuper方法,
                所以我們需要把用戶自己定義的方法暫存起來,
                以便以后還原

                因為在下一步,我們可能需要覆蓋這一個方法
            */
            var tmp = SubClass.prototype.__callSuper;
            if (_super._callSuper) {
                SubClass.prototype.__callSuper = _super.__callSuper;    
            }
            /*
                注意,上面一步非常關鍵。
                上面這一步處理的情況是,
                當有三層或者三層以上的繼承時,
                可能會出現子類調用父類的init,
                父類又調用祖父的init

                那么
                首先保證父類_super.init使用的上下文是子類的,
                (因為init中添加的各個屬性應該是最后添加在子類上)
                就是下面的_super.init.apply(this, arguments);

                再保證父類_super.init中調用的callSuper
                (如果存在的話)
                是父類的callSuper,而不是子類的callSuper
                因為父類調用父類的callSuper是也會
                是this.__callSuper的方式調用,
                那么此時的this應該是指向子類的,
                而this._callSuper調用的是子類的init,
                這樣就成了一個死循環

                子類調用子類的init,__callSuper
                所以此處要及時修改上下文

                如果你覺得比較繞的話
                你可以直接使用
                if (_super.init) {
                    SubClass.prototype.callSuper = _super.init;
                }
                在三層以上的繼承試試,就會出現問題了
            */

            _super.init.apply(this, arguments);

            // 還原用戶定義的方法
            SubClass.prototype.__callSuper = tmp;
        }
    }

    ...
}

 

最后,我們還需要一個在子類方法調用父類同名方法的機制,我們可以借用John Resig的實現方法,其實和上面是一個思想,先看看怎么使用:

var Man = Human.extend({
    init: function () {
        this.sex = "man";
    },
    say: function () {
        // 調用同名的父類方法
        this.callSuper();
        console.log("I am a man");
    }
});

實現方式

Class.extend = Class.prototype.extend = function (props) {
    var SubClass = function () {};
    var _super = this.prototype;

     SubClass.prototype = object(this.prototype);
     for (var name in props) {
        // 如果父類同名屬性也是一個function
        if (typeof props[name] == "function" 
            && typeof _super[name] == "function") {

            SubClass.prototype[name] 
                = (function (super_fn, fn) {
                // 返回一個新的函數,把用戶函數包裝起來
                return function () {
                    /*
                        callSuper是動態生成的,
                        只有當用戶調用同名方法時才會生成
                    */
                    // 把用戶自定義的callSuper暫存起來
                    var tmp = this.callSuper;
                    // callSuper即指向同名父類函數
                    this.callSuper = super_fn;
                    /*
                        callSuper即存在子類同名函數的上下文中
                        以this.callSuper()形式調用
                    */
                    var ret = fn.apply(this, arguments);
                    this.callSuper = tmp;

                    /*
                        如果用戶沒有callsuper方法,則delete
                    */
                    if (!this.callSuper) {
                        delete this.callSuper;
                    }

                    return ret;
                }
            })(_super[name], props[name])  
        } else {
            SubClass.prototype[name] = props[name];    
        }

        ..
    }

    SubClass.prototype.constructor = SubClass; 
}

 

  • 我並不贊同一般方法中的this.callSuper機制,從上面實現的代碼來看效率是非常低的。每一次生成實例都需要遍歷,與父類方法進行比較。在每一次調用同名方法是,也是要做一些列的操作。更重要的是在傳統的面向對象語言中,如C++,Java,子類的同名方法應該是覆蓋父類的同名方法的。何來調用父類同名方法之說?我在這里給出的是一種選擇,畢竟技術是為業務需求服務的。如果真的有這么一個需求那么也無可厚非。
  • 但是我贊成在init函數中的callSuper機制,在傳統的面向對象語言中,父類擁有的屬性子類不是默認就應該有的嗎?這也是繼承的意義之一吧

最后我們給出一個完整吧,並不僅僅是完整版,而且是升級版噢,哪里升級了呢?看代碼吧:

function Class() {}

Class.extend = function extend(props) {

    var prototype = new this();
    var _super = this.prototype;

    if (_super.init) {
        prototype.__callSuper = function () {
            var tmp = prototype.__callSuper;
            if (_super.__callSuper) {
                prototype.__callSuper = _super.__callSuper;
            }

            _super.init.apply(this, arguments);
            prototype.__callSuper = tmp;
        }
    }

    for (var name in props) {

        if (typeof props[name] == "function" 
            && typeof _super[name] == "function") {

            prototype[name] = (function (super_fn, fn) {
                return function () {
                    var tmp = this.callSuper;

                    this.callSuper = super_fn;

                    var ret = fn.apply(this, arguments);

                    this.callSuper = tmp;

                    if (!this.callSuper) {
                        delete this.callSuper;
                    }
                    return ret;
                }
            })(_super[name], props[name])
        } else {
            prototype[name] = props[name];    
        }
    }

    function Class() {}

    Class.prototype = prototype;
    Class.prototype.constructor = Class;

    Class.extend =  extend;
    Class.create = function () {

        var instance = new this();

        if (arguments.callSuper && instance.__callSuper) {
            instance.__callSuper();
        }

        if (instance.init) {
            instance.init.apply(instance. arguments);    
        }
        
        return instance;
    }

    return Class;
}

 

來,我們測試一下吧

var Human = Class.extend({
    init: function () {
        this.nature = "Human";
    },
    say: function () {
        console.log("I am a human");
    }
})

var human = Human.create();
console.log(human);
human.say();


var Man = Human.extend({
    init: function () {
        this.sex = "man";
    },
    say: function () {
        this.callSuper();
        console.log("I am a man");
    }
});

var man = Man.create();
console.log(man);
man.say();

var Person = Man.extend({
    init: function () {
        this.name = "lee";
    },
    say: function () {
        this.callSuper();
        console.log("I am Lee");
    }
})

var p = Person.create();
console.log(p);
p.say();

 

 

真的要拋棄new關鍵字了

 

無論如何上面的方法我們都使用了new關鍵字,接下來敘述的是真正不是用new關鍵字的方法

第一個問題是:如何生成一個對象?

  • var obj = {};
  • var obj = new Fn();
  • var obj = Object.create(null)

第一個方法可拓展性太低,第二個方法我們已經決定拋棄了,那重點就在第三個方法

你們還記得第三個方法是怎么用的嗎?在MDN中是這樣解釋的

Creates a new object with the specified prototype object and properties.

假設我們有一個矩形對象:

var Rectangle = {
    area: function () {
        console.log(this.width * this.height);
    }
};

 

我們想生成一個有它所有方法的對象應該怎么辦?

var rectangle =Object.create(Rectangle);

 

生成之后,我們還可以給這個實例賦值長寬,並且取得面積值

var rect = Object.create(Rectangle);
rect.width = 5;
rect.height = 9;
rect.area();

 

這是一個很神奇的過程,我們沒有使用new關鍵字,但是我們實例化了一個對象,給這個對象加上了自己的屬性,並且成功調用了類的方法。

但是我們希望能自動化賦值長寬,沒問題,那就定義一個create方法

var Rectangle = {
    create: function (width, height) {
      var self = Object.create(this);
      self.width = width;
      self.height = height;
      return self;
    },
    area: function () {
        console.log(this.width * this.height);
    }
};

 

怎么使用呢?

var rect = Rectangle.create(5, 9);
rect.area();

 

現在你可能大概明白了,在純粹使用Object.create的機制下,已經完全拋棄了構造函數這個概念了。一切都是對象,一個類也可以是對象,這個類的實例不過是裝飾過的它自己的復制品。

那么如何實現繼承呢,假設我們需要一個正方形,繼承自這個長方形

var Square = Object.create(Rectangle);

Square.create = function (side) {
  return Rectangle.create.call(this, side, side);
}

var sq = Square.create(5);
sq.area();

 

這種做法其實和我們第一種最基本的類似

function Man(name, age) {
    Human.call(this, name);
    this.age = age;
} 

 

上面的方法還是太復雜了,我們希望自動化,於是我們可以寫這么一個extend函數

function extend(extension) {
    var hasOwnProperty = Object.hasOwnProperty;
    var object = Object.create(this);

    for (var property in extension) {
      if (hasOwnProperty.call(extension, property) || typeof object[property] === "undefined") {
        object[property] = extension[property];
      }
    }

    return object;
}

/*
    其實上面這個方法可以直接寫成prototype方法:Object.prototype.extend
    但這樣盲目的修改原生對象的prototype屬性是大忌
    於是還是分開來寫了
*/

var Rectangle = {
    extend: extend,
    create: function (width, height) {
      var self = Object.create(this);
      self.width = width;
      self.height = height;
      return self;
    },
    area: function () {
        console.log(this.width * this.height);
    }
};

 

這樣當我們需要繼承時,就可以像前幾個方法一樣用了

var Square = Rectangle.extend({
    create: function (side) {
         return Rectangle.create.call(this, side, side);
    }
})

var s = Square.create(5);
s.area();

 

 

OK,今天的課就到這里了。其實還有很多工作可以做,比如實現多繼承(Mixin模式),如何實現自定義的instancef方法等等。這篇文章算拋磚引玉吧,有興趣的朋友可以繼續研究下去。

引用文獻


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM