Code Reuse Patterns

JavaScript

JavaScript 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 - Classical Inheritance - #1 The Default Pattern

圖片來源: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。

看到這個記憶體配置狀況與流程,我們可以歸納出兩個缺點:

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)。

我們將 #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 - Classical Pattern #3 - Rent and Set Prototype

圖片來源:JavaScript Patterns。

這樣的好處是改上 #1 的缺點

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 中。

Classical Pattern #5 - A Temporary Constructor

圖片來源: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)。

Prototypal Inheritance

圖片來源: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 - Code Reuse Patterns

由於部落格搬遷至此,因此在這裡放了一份,以便閱讀;部份文章片段也做了些許修改,以期提供更好的內容。

javascript prototype Design Pattern javascript 設計模式