「譯」一起探討 JavaScript 的物件

阿里雲前端發表於2019-03-04

一起探討 JavaScript 的物件

物件是多個屬性的動態集合,它有一個連結著原型的隱藏屬性(注:__proto__)。

一個屬性擁有一個 key 和一個 value 。

屬性的 key

屬性的 key 是一個唯一的字串。

訪問屬性有兩種方式:點表示法和括號表示法。當使用點表示法,屬性的 key 必須是有效的識別符號。

let obj = {
  message : "A message"
}
obj.message //"A message"
obj["message"] //"A message"
複製程式碼

訪問一個不存在的屬性不會丟擲錯誤,但是會返回 undefined

obj.otherProperty //undefined
複製程式碼

當使用括號表示法,屬性的 key 不要求是有效的識別符號 —— 可以是任意值。

let french = {};
french["thank you very much"] = "merci beaucoup";

french["thank you very much"]; //"merci beaucoup"
複製程式碼

當屬性的key是一個非字串的值,會用toString()方法(如果可用的話)把它轉換為字串。

let obj = {};
//Number
obj[1] = "Number 1";
obj[1] === obj["1"]; //true
//Object
let number1 = {
  toString : function() { return "1"; }
}
obj[number1] === obj["1"]; //true
複製程式碼

在上面的示例中,物件 number1 被用作一個 key 。它會被轉換為字串,轉換結果 “1” 被用作屬性的 key 。

屬性的值

屬性的值可以是任意的基礎資料型別,物件,或函式。

物件作為值

物件可以巢狀在其他物件裡。看下面這個例子

let book = {
  title : "The Good Parts",
  author : {
    firstName : "Douglas",
    lastName : "Crockford"
  }
}
book.author.firstName; //"Douglas"
複製程式碼

通過這種方式,我們就可以建立一個名稱空間:

let app = {};
app.authorService = { getAuthors : function() {} };
app.bookService = { getBooks : function() {} };
複製程式碼

函式作為值

當一個函式被作為屬性值,通常成為一個方法。在方法中,this 關鍵字代表著當前的物件。

this ,會根據函式的呼叫方式有不同的值。瞭解更多關於this 丟失上下文的問題,可以檢視當”this”丟失上下文時應該怎麼辦

動態性

物件本質上就是動態的。可以任意新增刪除屬性。

let obj = {};
obj.message = "This is a message"; //add new property
obj.otherMessage = "A new message"; //add new property
delete obj.otherMessage; //delete property
複製程式碼

Map

我們可以把物件當做一個 Map。Map 的 key 就是物件的屬性。

訪問一個 key 不需要去掃描所有屬性。訪問的時間複雜度是 o(1)。

原型

物件有一個連結著原型物件的“隱藏”屬性 __proto__,物件是從這個原型物件中繼承屬性的。

舉個例子,使用物件字面量建立的物件有一個指向 Object.prototype 的連結:

var obj = {};
obj.__proto__ === Object.prototype; //true
複製程式碼

原型鏈

原型物件有它自己的原型。當一個屬性被訪問的時候並且不包含在當前物件中,JavaScript會沿著原型鏈向下查詢直到找到被訪問的屬性,或者到達 null 為止。

只讀

原型只用於讀取值。物件進行更改時,只會作用到當前物件,不會影響物件的原型;就算原型上有同名的屬性,也是如此。

空物件

正如我們看到的,空物件 {} 並不是真正意義上的空,因為它包含著指向 Object.prototype 的連結。為了建立一個真正的空物件,我們可以使用 Object.create(null) 。它會建立一個沒有任何屬性的物件。這通常用來建立一個Map。

原始值和包裝物件

在允許訪問屬性這一點上,JavaScript 把原始值描述為物件。當然了,原始值並不是物件。

(1.23).toFixed(1); //"1.2"
"text".toUpperCase(); //"TEXT"
true.toString(); //"true"
複製程式碼

為了允許訪問原始值的屬性, JavaScript 創造了一個包裝物件,然後銷燬它。JavaScript引擎對建立包裝和銷燬包裝物件的過程做了優化。

數值、字串和布林值都有等效的包裝物件。跟別是:NumberStringBoolean

nullundefined 原始值沒有相應的包裝物件並且不提供任何方法。

內建原型

Numbers 繼承自Number.prototypeNumber.prototype繼承自Object.prototype

var no = 1;
no.__proto__ === Number.prototype; //true
no.__proto__.__proto__ === Object.prototype; //true
複製程式碼

Strings 繼承自 String.prototype。Booleans 繼承自 Boolean.prototype

函式都是物件,繼承自 Function.prototype 。函式擁有 bind()apply()call() 等方法。

所有物件、函式和原始值(除了 nullundefined )都從 Object.prototype 繼承屬性。他們都有 toString() 方法。

使用 polyfill 擴充內建物件

JavaScript 可以輕鬆地使用新功能擴充內建物件。

polyfill 就是一個程式碼片段,用於在不支援某功能的瀏覽器中實現該功能。

實用工具

舉個例子,這個為 Object.assign() 寫的polyfill,如果它不可用,那麼就在 Object 上新增一個新方法。

Array.from() 寫了類似的polyfill,如果它不可用,就在 Array 上新增一個新方法。

原型

新的方法可以被新增到原型。

舉個例子,String.prototype.trim() polyfill讓所有的字串都能使用 trim() 方法。

let text = "   A text  ";
text.trim(); //"A text"
複製程式碼

Array.prototype.find() polyfill讓所有的陣列都能使用find()方法。polyfill也是同樣的。

let arr = ["A", "B", "C", "D", "E"];
arr.indexOf("C"); //2
複製程式碼

單一繼承

Object.create() 用特定的原型物件建立一個新物件。它用來做單一繼承。思考下面的例子:

let bookPrototype = {
  getFullTitle : function(){
    return this.title + " by " + this.author;
  }
}
let book = Object.create(bookPrototype);
book.title = "JavaScript: The Good Parts";
book.author = "Douglas Crockford";
book.getFullTitle();//JavaScript: The Good Parts by Douglas Crockford
複製程式碼

多重繼承

Object.assign() 從一個或多個物件拷貝屬性到目標物件。它用來做多重繼承。看下面的例子:

let authorDataService = { getAuthors : function() {} };
let bookDataService = { getBooks : function() {} };
let userDataService = { getUsers : function() {} };
let dataService = Object.assign({},
 authorDataService,
 bookDataService,
 userDataService
);
dataService.getAuthors();
dataService.getBooks();
dataService.getUsers();
複製程式碼

不可變物件

Object.freeze() 凍結一個物件。屬性不能被新增、刪除、更改。物件會變成不可變的。

"use strict";
let book = Object.freeze({
  title : "Functional-Light JavaScript",
  author : "Kyle Simpson"
});
book.title = "Other title";//Cannot assign to read only property `title`
複製程式碼

Object.freeze() 實行淺凍結。要深凍結,需要遞迴凍結物件的每一個屬性。

拷貝

Object.assign() 被用作拷貝物件。

let book = Object.freeze({
  title : "JavaScript Allongé",
  author : "Reginald Braithwaite"
});
let clone = Object.assign({}, book);
複製程式碼

Object.assign() 執行淺拷貝,不是深拷貝。它拷貝物件的第一層屬性。巢狀的物件會在原始物件和副本物件之間共享。

物件字面量

物件字面量提供一種簡單、優雅的方式建立物件。

let timer = {
  fn : null,
  start : function(callback) { this.fn = callback; },
  stop : function() {},
}
複製程式碼

但是,這種語法有一些缺點。所有的屬性都是公共的,方法能夠被重定義,並且不能在新例項中使用相同的方法。

timer.fn;//null 
timer.start = function() { console.log("New implementation"); }
複製程式碼

Object.create()

Object.create()Object.freeze() 一起能夠解決最後兩個問題。

首先,我要使用所有方法建立一個凍結原型 timerPrototype ,然後建立物件去繼承它。

let timerPrototype = Object.freeze({
  start : function() {},
  stop : function() {}
});
let timer = Object.create(timerPrototype);
timer.__proto__ === timerPrototype; //true
複製程式碼

當原型被凍結,繼承它的物件不能夠更改其中的屬性。現在,start()stop() 方法不能被重新定義。

"use strict";
timer.start = function() { console.log("New implementation"); } //Cannot assign to read only property `start` of object
複製程式碼

Object.create(timerPrototype) 可以用來使用相同的原型構建更多物件。

建構函式

最初,JavaScript 語言提出建構函式作為這些的語法糖。看下面的程式碼:

function Timer(callback){
  this.fn = callback;
}
Timer.prototype = {
  start : function() {},
  stop : function() {}
}
function getTodos() {}
let timer = new Timer(getTodos);
複製程式碼

所有的以 function 關鍵字定義的函式都可以作為建構函式。建構函式使用功能 new 呼叫。新物件將原型設定為 FunctionConstructor.prototype

let timer = new Timer();
timer.__proto__ === Timer.prototype;
複製程式碼

同樣地,我們需要凍結原型來防止方法被重定義。

Timer.prototype = Object.freeze({
  start : function() {},
  stop : function() {}
});
複製程式碼

new 操作符

當執行 new Timer() 時,它與函式 newTimer() 作用相同:

function newTimer(){
  let newObj = Object.create(Timer.prototype);
  let returnObj = Timer.call(newObj, arguments);
  if(returnObj) return returnObj;
    
  return newObj;
}
複製程式碼

使用 Timer.prototype 作為原型,創造了一個新物件。然後執行 Timer 函式併為新物件設定屬性欄位。

ES2015為這一切帶來了更好的語法糖。看下面的例子:

class Timer{
  constructor(callback){
    this.fn = callback;
  }
  
  start() {}
  stop() {}  
}
Object.freeze(Timer.prototype);
複製程式碼

使用 class 構建的物件將原型設定為 ClassName.prototype 。在使用類建立物件時,必須使用 new 操作符。

let timer= new Timer();
timer.__proto__ === Timer.prototype;
複製程式碼

class 語法不會凍結原型,所以我們需要在之後進行操作。

Object.freeze(Timer.prototype);
複製程式碼

基於原型的繼承

在 JavaScript 中,物件繼承自物件。

建構函式和類都是用來建立原型物件的所有方法的語法糖。然後它建立一個繼承自原型物件的新物件,併為新物件設定資料欄位
基於原型的繼承具有保護記憶的好處。原型只建立一次並且由所有的例項使用。

沒有封裝

基於原型的繼承模式沒有私有性。所有物件的屬性都是公有的。

Object.keys() 返回一個包含所有屬性鍵的陣列。它可以用來迭代物件的所有屬性。

function logProperty(name){
  console.log(name); //property name
  console.log(obj[name]); //property value
}
Object.keys(obj).forEach(logProperty);
複製程式碼

模擬的私有模式包含使用 _ 來標記私有屬性,這樣其他人會避免使用他們:

class Timer{
  constructor(callback){
    this._fn = callback;
    this._timerId = 0;
  }
}
複製程式碼

工廠模式

JavaScript 提供一種使用工廠模式建立封裝物件的新方式。

function TodoStore(callback){
    let fn = callback;
    
    function start() {},
    function stop() {}
    
    return Object.freeze({
       start,
       stop
    });
}
複製程式碼

fn 變數是私有的。只有 start()stop() 方法是公有的。start()stop() 方法不能被外界改變。這裡沒有使用 this ,所以沒有 this 丟失上下文的問題。

物件字面量依然用於返回物件,但是這次它只包含函式。更重要的是,這些函式是共享相同私有狀態的閉包。 Object.freeze() 被用來凍結公有 API。

Timer 物件的完整實現,請看具有封裝功能的實用JavaScript物件.

結論

JavaScript 像物件一樣處理原始值、物件和函式。

物件本質上是動態的,可以用作 Map。

物件繼承自其他物件。建構函式和類是建立從其他原型物件繼承的物件的語法糖。

Object.create() 可以用來單一繼承,Object.assign() 用來多重繼承。

工廠函式可以構建封裝物件。

有關 JavaScript 功能的更多資訊,請看:

Discover the power of first class functions

How point-free composition will make you a better functional programmer

Here are a few function decorators you can write from scratch

Why you should give the Closure function another chance

Make your code easier to read with Functional Programming

相關文章