Code Reuse Patterns
25 Jun 2015JavaScript Pattern 之 Code Reuse Patterns 筆記。
JavaScript 沒有 class 的概念,而物件也僅是名值對(key-value pair),表示可以即時建立和改變。但 JavaScript 卻有建構式,類似其他語言(例如:Java)使用 class 的語法。
例如,在 Java 中,我們可以這樣寫
Person adam = new Person(); // 這個「Person」是 class
而在 JavaScript 我們是這樣寫
var adam = new Person(); // 這個「Person」是建構式函式
因此,在 JavaScript 中,我們就利用建構式函式(Constructor Function)來模擬 class 的繼承,達成程式碼重用的目的。而使用模擬 class 的繼承方式,我們稱之為 Classical 模式,而非使用 class 的模式,例如物件複合的方式,我們就稱之為 Modern 模式。提醒,基本上永遠選用 Modern 模式而非 Classical 模式。
Classical Inheritance - #1 The Default Pattern
我們來看 Classical Inheritance 的第一種方式 - 預設的模式。
Classical Inheritance 的實作目的是希望子建構式所建立的物件,能夠擁有父建構式的屬性。因此在預設模式下,就使用 Parent()
建構式建立一個物件,並將此物件指派給 Child()
的原型(Prototype)。
function Parent(name) {
this.name = name || 'Adam';
}
Parent.prototype.say = function() {
// --- (1)
return this.name;
};
function Child(name) {
// no-op
}
// 使用 Parent() 建構式建立一個物件,並將此物件指派給 Child() 的原型
function inherit(C, P) {
C.prototype = new P(); // --- (2)
}
inherit(Child, Parent);
var kid = new Child(); // 透過「new Child()」來讓這個模式生效 --- (3)
console.log(kid.say()); // 'Adam'
看原始碼。
這邊要注意的是,prototype 要指向父建構式所建立的實體物件,而非指向建構式本身。意即,我們需要透過 new Child()
來讓這個模式生效。新物件就會透過 prototype 取得 Parent()
實體的功能,包含加在 this 中的 name、prototype 中的say()
。
接下來來看看這樣的預設模式的記憶體配置情況。
圖片來源:JavaScript Patterns。
從上圖來看,當我們使用 new Parent()
建立物件的時候,即圖中(2),它擁有 name 這個屬性。而當我們在使用 inherit()
後,經由 var kid = new Child()
建立新物件,即產生(3)區塊。由於這個物件沒有加入任何屬性,也沒有任何屬性加入 Child.prototype
,因此除了 __proto__
這個隱藏版屬性外皆是空的。當我們呼叫 kid.say()
時,由於在(3)找不到這個方法,因此回到(2),但(2)也沒有這個方法,於是再到(1),終於在(1)找到。而 say()
當中的 this.name
需要被判斷,而又從(3)開始找起,在(2)找到 name 的這個屬性,且其值為 Adam。
看到這個記憶體配置狀況與流程,我們可以歸納出兩個缺點:
- 無法選擇要重複使用的屬性。一般來說我們將可重用的部份放在 prototype 裡面。
- 無法傳遞參數給子建構式。也就是說,當我們傳了 name 的值 Lucky,得到的依舊是 Adam。而如果使用
son.name = Luck
則會修改到父建構式。
var son = new Child('Lucky');
console.log(son.say()); // Adam
Classical Pattern #3 - Rent and Set Prototype
「#2 - Rent-a-Constructor」實作的功能只有 #3 的一半(Rent),所以直接跳到 #3。首先借用建構式(Rent),再將子建構式的原型指向父建構式的新實體(Set Prototype)。
- 借用建構式(Rent):讓子建構式可傳遞參數給父建構式,見程式碼(2)。
- 再將子建構式的原型指向父建構式的新實體(Set Prototype):取得指向父建構式原型成員的參考,也就是可重用的部份,見程式碼(1)。
我們將 #1 的程式碼稍做修改即可。
function Parent(name) {
this.name = name || 'Adam';
}
Parent.prototype.say = function() {
return this.name;
};
// 借用建構式,複製父建構式中加至 this 的屬性 --- (2)
function Child(name) {
var arguments = [];
arguments.push(name);
Parent.apply(this, arguments);
}
// 取得 prototype 的成員 --- (1)
function inherit(C, P) {
C.prototype = new P();
}
inherit(Child, Parent);
var son = new Child('Lucky'); // --- (3)
console.log(son.say()); // Lucky
delete son.name;
console.log(son.say()); // Adam --- (4)
看原始碼。
圖片來源:JavaScript Patterns。
這樣的好處是改上 #1 的缺點
- 可以繼承父建構式的全部功能:包含父建構式中加至 this 的屬性、加至原型的成員。
- 修改自身屬性而不影響父建構式。
- 可傳遞參數給父建構式,如下程式碼:
var son = new Child('Lucky');
console.log(son.say()); //'Lucky'
但同時也有缺點 - 父建構式被呼叫兩次,效率較差,見程式碼(1)、(3),導致自身屬性被繼承兩次。因此我們刪除了 kid.name
後,執行 kid.say()
,得到的不是預期的 undefined,而是 Adam,見程式碼(4)。
多重繼承
另外來看看多重繼承 - 假設我們有個物件,希望能多重繼承就可以這麼撰寫 - 有一個混種(Mix)想要同時擁有貓(Cat)和鳥(Bird)的特性,我們可以使用兩個建構式 Cat()
和 Bird()
,再使用 Mix()
繼承它們就可以了。
function Cat() {
this.legs = 4;
this.say = function() {
return 'mmeaowww';
};
}
function Bird() {
this.legs = 2;
this.fly = true;
}
function Mix() {
Cat.apply(this);
Bird.apply(this);
}
var animal = new Mix();
console.log(animal);
所以 animal 同時擁有 Cat()
和 Bird()
的屬性。
備註:任何重複的屬性都是最後的贏,如下圖,執行 console.log(animal)
後的結果。由於最後才繼承 Bird()
,因此 animal 的屬性中 legs 值為 2,而不是 4。
Classical Pattern #4 - Share the Prototype
「分享原型」(Share the Prototype)不像 #3 會呼叫兩次父建構式,或說根本不會呼叫父建構式 - 由於要重用的成員都放在 prototype 中,因此 Child.prototype
直接指向 Parent.prototype
。
function Parent(name) {
this.name = name || 'Adam';
}
Parent.prototype.say = function() {
return this.name;
};
function Child(name) {
var arguments = [];
arguments.push(name);
Parent.apply(this, arguments);
}
function inherit(C, P) {
C.prototype = P.prototype;
}
inherit(Child, Parent);
var son = new Child('Lucky');
console.log(son.say()); // Lucky
Child.prototype.say = function() {
return 'Apple';
};
console.log(son.say()); // Apple,被修改了...一旦孩子修改了 prototype,其繼承的父輩的 prototype 都會被修改到
var child = new Child();
console.log(child.say()); // 預期是 Adam,結果得到被修改後的 Apple
看原始碼。
優點是由於每個物件都是分享同一個 prototype,所以可以帶來快速的 prototype chain 查詢。而缺點是一旦孩子修改了 prototype,其繼承的父輩的 prototype 都會被修改到。我們可以看到修改了 Child.prototype.say
後,即修改到 Parent.prototype.say
,讓後續新產生的物件 child
執行 child.say()
後得到非預期的結果 Apple。
Classical Pattern #5 - A Temporary Constructor
「暫時的建構式 / 代理建構式」(A Temporary Constructor)解決 #4 的問題 - 若子建構式修改到 prototye,即會修改父 prototype,導致其繼承的父輩的 prototype 都會被修改到的問題。解決方法是切斷 parent prototype 和 child prototype 間的連結。
function Parent(name) {
this.name = name || 'Adam';
}
Parent.prototype.say = function() {
return this.name;
};
function Child(name) {
var arguments = [];
arguments.push(name);
Parent.apply(this, arguments);
}
var inherit = (function(C, P) {
var F = function() {};
return function(C, P) {
F.prototype = P.prototype; //(4) -> (1)
C.prototype = new F(); //(3) -> (4)
C.uber = P.prototype;
C.prototype.constructor = C;
};
})();
inherit(Child, Parent);
var kid = new Child('Lucky');
console.log(kid.name); // Lucky
console.log(kid.say()); // Lucky
console.log(kid.constructor.name); // Child
console.log(kid.constructor); // function Child(name)
看原始碼。
我們使用一個空的函式 F()
,做為父 / 子建構式的代理(proxy),將 F()
的建構式指向父建構式,而子建構式的 prototype 則是 F()
的實體。為了避免每次繼承都要建立暫時的建構式,並且,我們將 inherit()
優化 - 將其包在一個立即函式裡面,將 proxy 函式儲存在它的 closure 中。
圖片來源:JavaScript Patterns。
註:Klass 由於不建議使用,因此就不寫在本筆記裡面了。
Prototypal Inheritance
基本上永遠使用 Modern 模式而非 Classical 模式。第一個 Modern Inheritance 來看「原型繼承」(Prototypal Inheritance)模式。這個模式沒有 class 的概念,物件繼承於其他物件。也就是說,若想重用某些功能來創造另一個物件,必定是繼承某個物件以取得這些功能。
function obj(o) {
function F() {}
F.prototype = o; // --- (1)
return new F(); // --- (2)
}
var parent = {
name: 'Papa',
};
var child = obj(parent);
child.name = 'Lucky';
console.log(child.name); // Lucky
var son = obj(parent);
console.log(son.name); // Papa
看原始碼。
經由 obj()
產生的子物件總是從一個空物件開始(2),並經由 __proto__
連結而取得父物件的所有功能(1)。
圖片來源:JavaScript Patterns。
Inheritance by Copying Properties
「用複製屬性實作繼承」(Inheritance by Copying Properties),利用迴圈尋訪父物件的每個成員並複製它們。
function extend(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 ? [] : {};
extend(parent[i], child[i]);
} else {
child[i] = parent[i];
}
}
}
return child;
}
var dad = {
name: 'Adam',
counts: [1, 2, 3],
reads: { paper: true },
};
var kid = extend(dad);
console.log(kid); // Object {name: "Adam", counts: Array[3], reads: Object}
看原始碼。
Mix-ins
「混搭」(Mix-ins),我們不但從一個物件複製,還可以從任意數量的物件複製,並將它們混合到一個物件中。實作方法是使用一個迴圈跑過參數列,將傳遞進來的每一個物件的每個屬性都複製起來即可。例如我們傳入多個物件 eggs、butter、flour、sugar,複製每個物件的每個屬性,並混合到一個物件 cake 中。
function mix() {
var arg,
prop,
child = {};
for (arg = 0; arg < arguments.length; arg += 1) {
for (prop in arguments[arg]) {
if (arguments[arg].hasOwnProperty(prop)) {
child[prop] = arguments[arg][prop];
}
}
}
return child;
}
var cake = mix(
{ eggs: 2, large: true },
{ butter: 1, salted: true },
{ flour: '3 cups' },
{ sugar: 'sure!' },
);
console.log(cake); // Object {eggs: 2, large: true, butter: 1, salted: true, flour: "3 cups"…}
看原始碼。
Borrowing Methods
使用「借用方法」Borrowing Methods 的原因是,我們可能只喜歡現有物件的其中一兩個方法,並想要重用它們,但又不希望建立父子物件繼承關係,因為不想繼承根本用不到的方法。實現的方法是利用 call()
或 apply()
向現有物件借用功能。
例如:notmyobj 這個物件中有一個很好用的方法 doStuff()
,但我們並不想要繼承 notmyobj 的所有功能,於是利用 call()
或 apply()
,使得 myobj 能暫時借用 doStuff()
這個方法。我們將 myobj 和參數傳入,使得借來的方法 doStuff()
的 this 綁定到 myobj 這個物件上。
//call() example
notmyobj.doStuff.call(myobj, param1, p2, p3);
//apply() example
notmyobj.doStuff.apply(myobj, [param1, p2, p3]);
EX 1: Borrow from Array
向陣列借方法,以下是 arguments 向陣列借 slice()
。
function f() {
var args = [].slice.call(arguments, 1, 3);
return args;
}
var result = f(1, 2, 3, 4, 5, 6);
console.log(result); // [2,3]
不一定要向空陣列借方法,也可以向陣列的原型借。
function f() {
var args = Array.prototype.slice.call(arguments, 1, 3);
return args;
}
var result = f(1, 2, 3, 4, 5, 6);
console.log(result); // [2,3]
EX 2: Borrow and Bind
以下範例有一個物件 one,而另外一個物件 two 想要借用 one 的方法 say()
。而為了避免物件成為全域物件,因此另外使用 bind()
這個方法優化。
var one = {
name: 'object',
say: function(greet) {
return greet + ', ' + this.name;
},
};
console.log(one.say('hi')); // 'hi, object'
// 假設物件two想要借用one的方法say()
var two = {
name: 'another object',
};
console.log(one.say.apply(two, ['hello'])); // 'hello, another object'
// 使用bind()優化這個方法,避免物件成為全域物件
function bind(o, m) {
return function() {
return m.apply(o, [].slice.call(arguments));
};
}
var twosay = bind(two, one.say);
console.log(twosay('yo')); // "yo, another object"
EX 3: Function.prototype.bind()
利用Function.prototype.bind()
。用法如下:
var newFunc = obj.someFunc.bind(myobj, 1, 2, 3);
範例如下。
var one = {
name: 'object',
say: function(greet) {
return greet + ', ' + this.name;
},
};
var two = {
name: 'another object',
};
if (typeof Function.prototype.bind === 'undefined') {
Function.prototype.bind = function(thisArg) {
var fn = this,
slice = Array.prototype.slice,
args = slice.call(arguments, 1);
return function() {
return fn.apply(thisArg, args.concat(slice.call(arguments)));
};
};
}
var twosay2 = one.say.bind(two);
console.log(twosay2('Bonjour')); // "Bonjour, another object"
var twosay2 = one.say.bind(two);
console.log(twosay2('Bonjour')); // "Bonjour, another object"
看原始碼。
推薦閱讀 / 參考資料
- JavaScript Patterns:每個 pattern 都有範例程式碼,可以線上或下載下來玩玩看。
- 以上程式碼都可以在這裡找到 - Code Reuse Patterns 系列…
- Javascript 設計模式 教學文件與範例
這篇文章的原始位置在這裡-JavaScript - Code Reuse Patterns
由於部落格搬遷至此,因此在這裡放了一份,以便閱讀;部份文章片段也做了些許修改,以期提供更好的內容。