請不要在JavaScript中使用new關鍵字
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();
通過上面的代碼可以總結出傳統的實例化與繼承的幾個特點:
- 傳統方法中的“類”一定是一個構造函數。
- 屬性和方法綁定在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();
至此,基本框架已經搭建起來,接下來繼續補充功能。
- 我們希望把構造函數獨立出來,并且統一命名為init。就好像Backbone.js中每一個view都有一個initialize方法一樣。這樣能讓初始化更靈活和標準化,甚至可以把init構造函數借出去
- 我還想新增一個子類方法調用父類同名方法的機制,比如說在父類和子類的中都定義了一個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();
是時候徹底拋棄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關鍵字的基礎上繼續實現多繼承?希望本文的內容在這里只是拋磚引玉,能夠開拓大家的思路。
來源:InfoQ - 李光毅