編寫高質量JavaScript代碼之對象和原型

參考書籍:《Effective JavaScript》css

對象和原型

理解prototype、getPrototypeOf和__proto__之間的不一樣

原型包括三個獨立但相關的訪問器。html

  • C.prototype用於創建由new C()建立的對象的原型。
  • Object.getPrototypeOf(obj)是ES5中用來獲取obj對象的原型對象的標準方法。
  • obj.__proto__是獲取obj對象的原型對象的非標準方法。
function User(name, passwordHash) {
    this.name = name;
    this.passwordHash = passwordHash;
}

User.prototype.toString = function () {
    return '[User ' + this.name + ']';
};

User.prototype.checkPassword = function (password) {
    return hash(password) === this.passwordHash;
}

var u = new User('sfalken', '0ef33ae791068ec64b502d6cb0191387');

User函數帶有一個默認的prototype屬性,其包含一個開始幾乎爲空的對象。當咱們使用new操做符建立User的實例時,產生的對象u獲得了自動分配的原型對象,該原型對象被存儲在User.prototype中。程序員

Object.getPrototypeOf(u) === User.prototype; // true
u.__proto__ === User.prototype; // true

提示:編程

  • C.prototype屬性是new C()建立的對象的原型。
  • Object.getPrototypeOf(obj)是ES5中檢索對象原型的標準函數。
  • Obj.__proto__是檢索對象原型的非標準函數。
  • 類是由一個構造函數和一個關聯的原型組成的一種設計模式。

使用Object.getPrototypeOf函數而不要使用__proto__屬性

__proto__屬性提供了Object.getPrototypeOf方法所不具有的額外能力,即修改對象原型連接的能力。這種能力會形成嚴重的影響,應當避免使用,緣由以下:設計模式

  1. 可移植性:並非全部的平臺都支持改變對象原型的特性,因此沒法編寫可移植的代碼。
  2. 性能問題:現代的JavaScript引擎痘深度優化了獲取和設置對象屬性的行爲,如更改了對象的內部結構(如添加或刪除該對象或其原型鏈中的對象的屬性)會使一些優化失效。
  3. 可預測性:修改對象的原型鏈會影響對象的整個繼承層次結構,在某些狀況下這樣的操做可能有用,可是保持繼承層次結構的相對穩定是一個基本的準則。

可使用ES5中的Object.create函數來建立一個具備自定義原型鏈的新對象。數組

提示:安全

  • 始終不要修改對象的__proto__屬性。
  • 使用Object.create函數給新對象設置自定義的原型。

使構造函數與new操做符無關

function User(name, passwordHash) {
    this.name = name;
    this.passwordHash = passwordHash;
}

var u = User('baravelli', 'd8b74df393528d51cd19980ae0aa028e');
u; // undefined
this.name; // baravelli
this.passwordHash; // d8b74df393528d51cd19980ae0aa028e

若是調用者忘記使用new關鍵字,該函數不但會返回無心義的undefined,並且會建立(若是這些全局變量已經存在則會修改)全局變量name和passwordHash。閉包

若是將User函數定義爲ES5的嚴格代碼,那麼它的接收者默認爲undefined。app

function User(name, passwordHash) {
    "use strict";

    this.name = name;
    this.passwordHash = passwordHash;
}

var u = User('baravelli', 'd8b74df393528d51cd19980ae0aa028e'); // Uncaught TypeError: Cannot set property 'name' of undefined

一個更爲健壯的方式是提供一個無論怎麼調用都工做如構造函數的函數。函數

function User(name, passwordHash) {
    if (!this instanceof User) {
        return new User(name, passwordHash);
    }

    this.name = name;
    this.passwordHash = passwordHash;
}

var x = User('baravelli', 'd8b74df393528d51cd19980ae0aa028e'); 
var y = new User('baravelli', 'd8b74df393528d51cd19980ae0aa028e'); 
x instanceof User; // true
y instanceof User; // true

上述模式的一個缺點是它須要額外的函數調用,且難適用於可變參數函數,由於沒有一種模擬apply方法將可變參數函數做爲構造函數調用的方式。

一種更爲奇異的方式是利用ES5的Object.create函數。

function User(name, passwordHash) {
    var self = this instanceof User ? this : Object.create(User.prototype);

    self.name = name;
    self.passwordHash = passwordHash;

    return self;
}

Object.create須要一個原型對象做爲參數,並返回一個繼承自原型對象的新對象。

多虧了構造函數覆蓋模式,使用new操做符調用上述User函數的行爲與以函數調用它的行爲是同樣的,這能工做徹底得益於JavaScript容許new表達式的結果能夠被構造函數的顯示return語句所覆蓋

提示:

  • 經過使用new操做符或Object.create方法在構造函數定義中調用自身使得該構造函數與調用語法無關。
  • 當一個函數指望使用new操做符調用時,清晰地文檔化該函數。

在原型中存儲方法

JavaScript徹底有可能不借助原型進行編程。

function User(name, passwordHash) {
    this.name = name;
    this.passwordHash = passwordHash;
    this.toString = function () {
        return 'User ' + this.name + ']';
    };
    this.checkPassword = function (password) {
        return hash(password) === this.passwordHash;
    }
} 

var u1 = new User(/* ... */);
var u2 = new User(/* ... */);
var u3 = new User(/* ... */);

上述代碼中的每一個實例都包含toString和checkPassword方法的副本,而不是經過原型共享這些方法。

將方法存儲在原型,使其能夠被全部的實例使用,而不須要存儲方法實現的多個副本,也不須要給每一個實例對象增長額外的屬性。

同時,現代的JavaScript引擎深度優化了原型查找,因此將方法複製到實例對象並不必定保證查找的速度有明顯的提高,並且實例方法比起原型方法確定會佔用更多的內存。

提示:

  • 將方法存儲在實例對象中會建立該函數的多個副本,由於每一個實例對象都有一份副本。
  • 將方法存儲於原型中優於存儲在實例對象中。

使用閉包存儲私有數據

任意一段程序均可以簡單地經過訪問JavaScript對象的屬性名來獲取相應地對象屬性,例如for in循環、ES5的Object.keys函數和Object.getOwnPropertyNames函數。

一些程序員使用命名規範給私有屬性前置或後置一個下劃線字符_。

然而實際上,一些程序須要更高程度的信息隱藏。

對於這種情形,JavaScript爲信息隱藏提供了閉包。閉包將數據存儲到封閉的變量中而不提供對這些變量的直接訪問,獲取閉包內部結構的惟一方式是該函數顯式地提供獲取它的途徑。

利用這一特性在對象中存儲真正的私有數據。不是將數據做爲對象的屬性來存儲,而是在構造函數中以變量的方式存儲它。

function User(name, passwordHash) {
    this.toString = function () {
        return '[User ' + name + ']';
    };
    this.checkPassword = function (password) {
        return hash(password) === passwordHash;
    } 
}

上述代碼的toString和checkPassword方法是以變量的方式來引用name和passwordHash變量的,而不是以this屬性的方式來引用,User的實例不包含任何實例屬性,所以外部的代碼不能直接訪問User實例的name和passwordHash變量。

該模式的一個缺點是,爲了讓構造函數中的變量在使用它們的方法的做用域內,這些方法必須放置於實例對象中,這會致使方法副本的擴散。

提示:

  • 閉包變量是私有的,只能經過局部的引用獲取。
  • 將局部變量做爲私有數據從而經過方法實現信息隱藏。

只將實例狀態存儲在實例對象中

一種錯誤的作法是不當心將每一個實例的數據存儲到了其原型中。

function Tree(x) {
    this.value = x;
}

Tree.prototype = {
    children: [], // should be instance state!
    addChild: function(x) {
        this.children.push(x);
    }
};

var left = new Tree(2);
left.addChild(1);
left.addChild(3);

var right = new Tree(6);
right.addChild(5);
right.addChild(7);

var top = new Tree(4);
top.addChild(left);
top.addChild(right);

top.children; // [1, 3, 5, 7, left, right]

每次調用addChild方法,都會將值添加到Tree.prototype.children數組中。

實現Tree類的正確方式是爲每一個實例對象建立一個單獨的children數組。

function Tree(x) {
    this.value = x;
    this.children = []; // instance state
}

Tree.prototype = {
    addChild: function(x) {
        this.children.push(x);
    }
};

通常狀況下,任何不可變的數據能夠被存儲在原型中從而被安全地共享。有狀態的數據原則上也能夠存儲在原型中,只要你真正想共享它。然而迄今爲止,在原型對象中最多見的數據是方法,而每一個實例的狀態都存儲在實例對象中。

提示:

  • 共享可變數據可能會出問題,由於原型是被其全部的實例共享的。
  • 將可變的實例狀態存儲在實例對象中。

認識到this變量的隱式綁定問題

編寫一個簡單的、可定製的讀取CSV(逗號分隔型取值)數據的類。

function CSVReader(separators) {
    this.separators = separators || [','];
    this.regexp = new RegExp(this.separators.map(function (sep) {
        return '\\' + sep[0];
    }).join('|'));
}

實現一個簡單的read方法能夠分爲兩步來處理。第一步,將輸入的字符串分爲按行劃分的數組。第二步,將數組的每一行再分爲按單元格劃分的數組。結果得到一個二維的字符串數組。

CSVReader.prototype.read = function (str) {
    var lines = str.trim().split(/\n/);
    return lines.map(function (line) {
        return line.split(this.regexp);
    });
};

var reader = new CSVReader();
reader.read('a, b, c\nd, e, f\n'); // [['a, b, c'], ['d, e, f']]

上述代碼的bug是,傳遞給line.map的回調函數引用的this指向的是window,所以,this.regexp產生undefined值。

備註:'a, b, c'.split(undefined)返回['a, b, c']

  1. 幸運的是,數組的map方法能夠傳入一個可選的參數做爲其回調函數的this綁定。

    CSVReader.prototype.read = function (str) {
        var lines = str.trim().split(/\n/);
        return lines.map(function (line) {
            return line.split(this.regexp);
        }, this);
    };
    
    var reader = new CSVReader();
    reader.read('a, b, c\nd, e, f\n'); // [['a', 'b', 'c'], ['d', 'e', 'f']]
  2. 可是,不是全部基於回調函數的API都考慮周全。另外一種解決方案是使用詞法做用域的變量來存儲這個額外的外部this綁定的引用。

    CSVReader.prototype.read = function (str) {
        var lines = str.trim().split(/\n/);
        var self = this; // save a reference to outer this-binding
        return lines.map(function (line) {
            return line.split(this.regexp);
        });
    };
    
    var reader = new CSVReader();
    reader.read('a, b, c\nd, e, f\n'); // [['a', 'b', 'c'], ['d', 'e', 'f']]
  3. 在ES5的環境中,另外一種有效的方法是使用回調函數的bind方法。

    CSVReader.prototype.read = function (str) {
        var lines = str.trim().split(/\n/);
        return lines.map(function (line) {
            return line.split(this.regexp);
        }.bind(this)); // bind to outer this-binding
    };
    
    var reader = new CSVReader();
    reader.read('a, b, c\nd, e, f\n'); // [['a', 'b', 'c'], ['d', 'e', 'f']]

提示:

  • this變量的做用域老是由其最近的封閉函數所肯定。
  • 使用一個局部變量(一般命名爲self、me或that)使得this綁定對於內部函數是可用的。

在子類的構造函數中調用父類的構造函數

場景圖(scene graph)是在可視化的程序中(如遊戲或圖形仿真場景)描述一個場景的對象集合。一個簡單的場景包含了在該場景中的全部對象(稱爲角色),以及全部角色的預加載圖像數據集,還包含一個底層圖形顯示的引用(一般被稱爲context)。

function Scene(context, width, height, images) {
    this.context = context;
    this.width = width;
    this.height = height;
    this.images = images;
    this.actors = [];
}

Scene.prototype.register = function (actor) {
    this.actors.push(actor);
};

Scene.prototype.unregister = function (actor) {
    var i = this.actors.indexOf(actor);
    
    if (i >= 0) {
        this.actors.splice(i, 1);
    }
};

Scene.prototype.draw = function () {
    this.context.clearRect(0, 0, this.width, this.height);
    
    for (var a = this.actors, i = 0, n = a.length; i < n; i++) {
        a[i].draw();
    }
};

場景中的全部角色都繼承自基類Actor。

function Actor(scene, x, y) {
    this.scene = scene;
    this.x = x;
    this.y = y;
    scene.register(this);
}

Actor.prototype.moveTo = function (x, y) {
    this.x = x;
    this.y = y;
    this.scene.draw();
};

Actor.prototype.exit = function() {
    this.scene.unregister(this);
    this.scene.draw();
};

Actor.prototype.draw = function () {
    var image = this.scene.images[this.type];
    this.scene.context.drawImage(image, this.x, this.y);
};

Actor.prototype.width = function () {
    return this.scene.images[this.type].width;
};

Actor.prototype.height = function () {
    return this.scene.images[this.type].height;
};

咱們將角色的特定類型實現爲Actor的子類。例如,在街機遊戲中太空飛船就會有一個拓展自Actor的SpaceShip類。

爲了確保SpaceShip的實例能做爲角色被正確地初始化,其構造函數必須顯式地調用Actor的構造函數。經過將接收者綁定到該新對象來調用Actor能夠達到此目的。

function SpaceShip(scene, x, y) {
    Actor.call(this, scene, x, y);
    this.points = 0;
}

調用Actor的構造函數能確保Actor建立的全部實例屬性都被添加到了新對象(SpaceShip實例對象)中。爲了使SpaceShip成爲Actor的一個正確地子類,其原型必須繼承自Actor.prototype。作這種拓展的最好的方式是使用ES5提供的Object.create方法。

SpaceShip.prototype = Object.create(Actor.prototype);

一旦建立了SpaceShip的原型對象,咱們就能夠向其添加全部的可被實例共享的屬性。

SpaceShip.prototype.type = 'spaceShip';

SpaceShip.prototype.scorePoint = function () {
    this.points++;
};

SpaceShip.prototype.left = function () {
    this.moveTo(Math.max(this.x - 10, 0), this.y);
};

SpaceShip.prototype.right = function () {
    var maxWidth = this.scene.width - this.width();
    this.moveTo(Math.min(this.x + 10, maxWidth), this.y);
};

提示:

  • 在子類構造函數中顯示地傳入this做爲顯式地接收者調用父類構造函數。
  • 使用Object.create函數來構造子類的原型對象以免調用父類的構造函數。

不要重用父類的屬性名

function Actor(scene, x, y) {
    this.scene = scene;
    this.x = x;
    this.y = y;
    this.id = ++Actor.nextID;
    scene.register(this);
}

Actor.nextID = 0;
function Alien(scene, x, y, direction, speed, strength) {
    Actor.call(this, scene, x, y);
    this.direction = direction;
    this.speed = speed;
    this.strength = strength;
    this.damage = 0;
    this.id = ++Alien.nextID; // conflicts with actor id!
}

Alien.nextID = 0;

Alien類與其父類Actor類都視圖給實例屬性id寫數據。若是在繼承體系中的兩個類指向相同的屬性名,那麼它們指向的是同一個屬性。

該例子顯而易見的解決方法是對Actor標識數和Alien標識數使用不一樣的屬性名。

function Actor(scene, x, y) {
    this.scene = scene;
    this.x = x;
    this.y = y;
    this.actorID = ++Actor.nextID; // distinct from alienID
    scene.register(this);
}

Actor.nextID = 0;

function Alien(scene, x, y, direction, speed, strength) {
    Actor.call(this, scene, x, y);
    this.direction = direction;
    this.speed = speed;
    this.strength = strength;
    this.damage = 0;
    this.alienID = ++Alien.nextID; // distinct from actorID
}

Alien.nextID = 0;

提示:

  • 留意父類使用的全部屬性名。
  • 不要在子類中重用父類的屬性名。

避免繼承標準類

一個操做文件系統的庫可能但願建立一個抽象的目錄,該目錄繼承了數組的全部行爲。

function Dir(path, entries) {
    this.path = path;
    for (var i = 0, n = entries.length; i < n; i++) {
        this[i] = entries[i];
    }
}

Dir.prototype = Object.create(Array.prototype); // extends Array

遺憾的是,這種方式破壞了數組的length屬性的預期行爲。

var dir = new Dir('/tmp/mysite', ['index.html', 'script.js', 'style.css']);

dir.length; // 0

失敗的緣由是length屬性只對在內部標記爲「真正的」數組的特殊對象起做用。ECMAScript標準規定它是一個不可見的內部屬性,稱爲[[Class]]。

數組對象(經過Array構造函數或[]語法建立)被加上了值爲「Array」的[[Class]]屬性,函數被加上了值爲「Function」的[[Class]]屬性。

事實證實,length的行爲只被定義在內部屬性[[Class]]的值爲「Array」的特殊對象中。對於這些對象,JavaScript保持length屬性與該對象的索引屬性的數量同步。

但當咱們拓展Array類時,子類的實例並非經過new Array()或字面量[]語法建立的。因此,Dir的實例[[Class]]屬性值爲「Object」。

更好的實現是定義一個entries數組的實例屬性。

function Dir(path, entries) {
    this.path = path;
    this.entries = entries; // array property
}

Dir.prototype.forEach = function (f, thisArg) {
    if (typeof thisArg === 'undefined') {
        thisArg = this;
    }

    this.entries.forEach(f, thisArg);
};

提示:

  • 繼承標準類每每因爲一些特殊的內部屬性(如[[Class]])而被破壞。
  • 使用屬性委託優於繼承標準類。

將原型視爲實現細節

原型是一種對象行爲的實現細節。

JavaScript提供了便利的內省機制(introspection mechanisms)來檢查對象的細節。Object.prototype.hasOwnProperty方法肯定一個屬性是否爲對象「本身的」屬性(即一個實例屬性),而徹底忽略原型繼承機構。Object.getPrototypeOf__proto__特性容許程序員遍歷對象的原型鏈並單獨查詢其原型對象。

檢查實現細節(即便沒有修改它們)也會在程序的組件之間建立依賴。若是對象的生產者修改了實現細節,那麼依賴於這些對象的使用者就會被破壞。

提示:

  • 對象是接口,原型是實現。
  • 避免檢查你沒法控制的對象的原型結構。
  • 避免檢查實如今你沒法控制的對象內部的屬性。

避免使用輕率的猴子補丁

因爲對象共享原型,所以每個對象均可以增長、刪除或修改原型的屬性,這個有爭議的實踐一般被稱爲猴子補丁(monkey-patching)。

猴子補丁的吸引力在於它的強大,數組缺乏一個有用的方法,你本身就能夠增長它。

Array.prototype.split = function (i) { // alternative #1
    return [this.slice(0, 1), this.slice(i)];
};

可是當多個庫以不兼容的方式給同一個原型打猴子補丁時,問題就出現了。

Array.prototype.split = function (i) { // alternative #2
    var i = Math.floor(this.length / 2);
    return [this.slice(0, 1), this.slice(i)];
};

如今,任一對數組split方法的使用都大約有50%的機會被破壞。

一個方法能夠將這些修改置於一個函數中,用戶能夠選擇調用或忽略。

function addArrayMethods() {
    Array.prototype.split = function (i) {
        return [this.slice(0, 1), this.slice(i)];
    }
}

儘管猴子補丁很危險,可是有一種特別可靠並且有價值的使用場景:polyfill。

if (typeof Array.prototype.map !== 'function') {
    Array.prototype.map = function (f, thisArg) {
        var result = [];
        
        for (var i = 0, n = this.length; i < n; i++) {
            result[i] = f.call(thisArg, this[i], i);
        }

        return result;
    };
}

提示:

  • 避免使用輕率的猴子補丁。
  • 記錄程序庫所執行的全部猴子補丁。
  • 考慮經過將修改置於一個處處函數中,使猴子補丁稱爲可選的。
  • 使用猴子補丁爲缺失的標準API提供polyfills。
相關文章
相關標籤/搜索