請不要在JavaScript中使用new關鍵字

InfoQ - 李光毅發表於2015-02-05

JavaScript中的new關鍵字可以實現例項化和繼承的工作,但個人認為使用new關鍵字並非是最佳的實踐,還可以有更友好一些的實現。本文將介紹使用new關鍵字有什麼問題,然後介紹如何對與new相關聯的一系列物件導向操作進行封裝,以便提供更快捷的、更易讓人理解的實現方式。

傳統的例項化與繼承

假設我們有兩個類,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();
}

// 子類建構函式
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);
// 呼叫父類的say方法:
man.say();

DEMO

通過上面的程式碼可以總結出傳統的例項化與繼承的幾個特點:

  • 傳統方法中的“類”一定是一個建構函式。
  • 屬性和方法繫結在prototype屬性上,並藉助prototype的特性實現繼承。
  • 通過new關鍵字來例項化一個物件。

為什麼我會十分的肯定Object.create方法與道格拉斯的object方法是一致呢?因為在MDN上,object方法就是作為Object.create的一個Polyfill方案:

new關鍵字的不足之處

在《Javascript語言精粹》(Javascript: The Good Parts)中,道格拉斯認為應該避免使用new關鍵字:

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關鍵字,會引發一些問題。

當然了,你遺忘使用任何關鍵字都會引起一系列的問題。再退一步說,這個問題是完全可以避免的:

function foo()
{   
   // 如果忘了使用關鍵字,這一步驟會悄悄幫你修復這個問題
   if ( !(this instanceof foo) )
      return new foo();

   // 建構函式的邏輯繼續……
}

或者更通用的丟擲異常即可

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是一種prototypical型別語言,在建立之初,是為了迎合市場的需要,讓人們覺得它和Java是類似的,才引入了new關鍵字。Javascript本應通過它的Prototypical特性來實現例項化和繼承,但new關鍵字讓它變得不倫不類。

把傳統方法加以改造

既然new關鍵字不夠友好,那麼我們有兩個辦法可以解決這個問題:一是完全拋棄new關鍵字,二是把含有new關鍵字的操作封裝起來,只向外提供友好的介面。下面將介紹第二種方法的實現思路,把傳統方法加以改造。

我們開始構造一個最原始的基類Class(類似於JavaScript中的Object類),並且只向外提供兩個介面:

  • Class.extend 用於擴充子類
  • Class.create 用於建立例項
// 基類
function Class() {}

// 將extend和create置於prototype物件中,以便子類繼承
Class.prototype.extend = function () {};
Class.prototype.create = function () {};

// 為了能在基類上直接以.extend的方式進行呼叫
Class.extend = function (props) {
    return this.prototype.extend.call(this, props);
}

extend和create的具體實現:

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中
        而非上面create方法的“例項屬性”中
    */
    SubClass.prototype = Object.create(this.prototype);
    // 並且新增自己的方法和屬性
    for (var name in props) {
        SubClass.prototype[name] = props[name];
    }
    SubClass.prototype.constructor = SubClass;

    /*
        介於需要以.extend的方式和.create的方式呼叫:
    */
    SubClass.extend = SubClass.prototype.extend;
    SubClass.create = SubClass.prototype.create;

    return SubClass;
}

仍然以Human和Man類舉例使用說明:

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

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

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

var man = Man.create({
    name: "Lee",
    age: 22
});

console.log(man);
// 呼叫父類方法
man.say();

man.walk();

DEMO

至此,基本框架已經搭建起來,接下來繼續補充功能。

  1. 我們希望把建構函式獨立出來,並且統一命名為init。就好像Backbone.js中每一個view都有一個initialize方法一樣。這樣能讓初始化更靈活和標準化,甚至可以把init建構函式借出去
  2. 我還想新增一個子類方法呼叫父類同名方法的機制,比如說在父類和子類的中都定義了一個say方法,那麼只要在子類的say中呼叫this.callSuper()就能呼叫父類的say方法了。例如:
// 基類
var Human = Class.extend({
    /*
        你需要在定義類時定義構造方法init
    */
    init: function () {
        this.nature = "Human";
    },
    say: function () {
        console.log("I am a human");
    }
})

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

那麼Class.create就不僅僅是new一個建構函式了:

Class.create = Class.prototype.create = function () {
    /*
        注意在這裡我們只是例項化一個建構函式
        而非最後返回的“例項”,
        可以理解這個例項目前只是一個“殼”
        需要init函式對這個“殼”填充屬性和方法
    */
    var instance = new this();

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

實現在子類方法呼叫父類同名方法的機制,我們可以借用John Resig的方案

Class.extend = Class.prototype.extend = function (props) {
    var SubClass = function () {};
    var _super = this.prototype;
     SubClass.prototype = Object.create(this.prototype);
     for (var name in props) {
        // 如果父類同名屬性也是一個函式
        if (typeof props[name] == "function" 
            && typeof _super[name] == "function") {
            // 重新定義使用者的同名函式,把使用者的函式包裝起來
            SubClass.prototype[name] 
                = (function (super_fn, fn) {
                return function () {

                    // 如果使用者有自定義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; 
}

最後給出一個完整版,並且做了一些優化:

function Class() {}

Class.extend = function extend(props) {

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

    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 = Class.prototype.create = function () {

        var instance = new this();

        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.callSuper();
        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.callSuper();
        this.name = "lee";
    },
    say: function () {
        this.callSuper();
        console.log("I am Lee");
    }
})

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

DEMO

是時候徹底拋棄new關鍵字了

如果不使用new關鍵字,那麼我們需要轉投上兩節中反覆使用的Object.create來生產新的物件

假設我們有一個矩形物件:

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

藉助Object.create,我們可以生成一個擁有它所有方法的物件:

var rectangle = Object.create(Rectangle);

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

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

注意這個過程我們沒有使用new關鍵字,但是我們相當於例項化了一個物件(rectangle),給這個物件加上了自己的屬性,並且成功呼叫了類(Rectangle)的方法。

但是我們希望能自動化賦值長寬,沒問題,那就定義一個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;
}

/*
    其實上面這個方法可以直接繫結在原生的Object物件上:Object.prototype.extend
    但個人不推薦這種做法
*/

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();

結束語

本文對去new關鍵字的方法做了一些羅列,但工作還遠遠沒有結束,有非常多的地方值得擴充,比如:如何重新定義instance of方法,用於判斷一個物件是否是一個類的例項?如何在去new關鍵字的基礎上繼續實現多繼承?希望本文的內容在這裡只是拋磚引玉,能夠開拓大家的思路。

相關文章