JavaScript 中創建對象的那些事兒


JavaScript 中創建對象的那些事兒

JavaScript 中創建對象的那些事兒

本文原載自 http://js-professional.lxfriday.xyz/blog/2019/12/31/JavaScript%E4%B8%AD%E5%88%9B%E5%BB%BA%E5%AF%B9%E8%B1%A1%E7%9A%84%E9%82%A3%E4%BA%9B%E4%BA%8B%E5%84%BF,作為學習筆記總結呈現。

創建對象的幾種基本方式

  • {} 對象字面量
  • Object() 或者 new Object()
  • new Constructor()
  • Object.create()
  • Object.assign()

關於 new Constructor() Object.create() 和 Object.assign() 創建對象的過程和模擬實現可以參考這篇文章 前端面試必備 | 5000字長文解釋千萬不能錯過的原型操作方法及其模擬實現(原型篇:下)

工廠模式

<code>function createPerson(name, age, job) {
const o = new Object();
o.name = name;
o.age = age;
o.job = job;
o.sayName = function() {
console.log(this.name);
};
return o;
}
const person1 = createPerson("Nicholas", 29, "Software Engineer");
const person2 = createPerson("Greg", 27, "Doctor");
/<code>

每一次調用上面的 createPerson 工廠函數都可以創建一個對象,這個對象有 name age job 三個屬性和一個 sayName 方法,依據傳入的參數的不同,返回對象的值也會不同。

缺點:沒有解決這個對象是一個什麼類型的對象(沒有更精確的對象標識,即沒有精確的構造函數)。

構造函數模式

將工廠改造成構造函數之後,如下

<code>function Person(name, age, job) {
this.name = name;
this.age = age;
this.job = job;
this.sayName = function() {
console.log(this.name);
};
}
const person1 = new Person("Nicholas", 29, "Software Engineer");
const person2 = new Person("Greg", 27, "Doctor");
person1.sayName(); // Nicholas
person2.sayName(); // Greg
/<code>

構造函數和工廠的區別:

  1. 沒有顯式創建對象;
  2. 直接把屬性和方法賦值給 this
  3. 沒有 return

使用構造函數創建對象將會有以下幾個步驟:

  1. 在內存中創建一個新對象
  2. 新對象內部的 [[Prototype]] 指針指向構造函數的 prototype 屬性指向的對象;
  3. 將構造函數的上下文 this 指向新創建的對象;
  4. 執行構造函數內部的代碼(給新對象添加屬性);
  5. 如果構造函數 return 非 null 的對象,那返回的就是這個對象,否則返回新創建的這個對象。沒有 return 時,隱式返回新創建的對象,return null 會返回新創建的對象;

缺點:每次實例化一個新對象,都會在內部創建一個 sayName 對應的匿名函數,而這個函數對所有實例來講是沒有必要每次都創建的,他們只需要指向同一個函數即可。

所以上面的代碼經過改造之後,變成下面這樣:

<code>function Person(name, age, job) {
this.name = name;
this.age = age;
this.job = job;
this.sayName = sayName;
}
function sayName() {
console.log(this.name);
}
const person1 = new Person("Nicholas", 29, "Software Engineer");
const person2 = new Person("Greg", 27, "Doctor");

person1.sayName(); // Nicholas
person2.sayName(); // Greg
/<code>

上述的做法雖然解決了重複創建匿名函數的問題,但是又引入了新的問題。

外面的 sayName 函數僅僅在構造函數中用到,如果對象需要很多個這樣的函數,那麼就需要在外部定義很多個這種函數,這無疑會導致代碼很難組織。

原型模式

函數創建之後都會有一個 prototype 屬性,每個使用該構造函數創建的對象都有一個 [[prototype]] 內部屬性指向它。

使用原型的好處在於它所有的屬性和方法會在實例間共享,並且這個共享的屬性和方法是直接在原型上設置的。

<code>function Person() {}

Person.prototype.name = "Nicholas";
Person.prototype.age = 29;
Person.prototype.job = "Software Engineer";
Person.prototype.sayName = function() {
console.log(this.name);
};
const person1 = new Person();
person1.sayName(); // "Nicholas"
const person2 = new Person();
person2.sayName(); // "Nicholas"
console.log(person1.sayName == person2.sayName); // true
/<code>

關於原型的工作原理,可以查看下面三篇文章,看完之後相信你對原型的認識比大多數人都要深刻!

  1. 前端面試必備 | 5000 字長文解釋千萬不能錯過的原型操作方法及其模擬實現(原型篇:下)
  2. 前端面試必備 | 古怪的原型(雞生蛋還是蛋生雞)(原型篇:中)
  3. 前端面試必備 | 使用原型和構造函數創建對象(原型篇:上)

理解原型的層級

對象中屬性的查找機制:

當從對象中訪問一個屬性的時候,JS 引擎將會按屬性名進行查找。JS 引擎會先查找對象自身。如果找到了這個屬性,就會停止查找並返回屬性對應的值,如果在對象自身沒有找到,則會通過原型鏈到原型對象中繼續查找這個屬性,如果找到了這個屬性,就會停止查找並返回屬性對應的值,否則會繼續到上層原型鏈中查找,直到碰到 null 。

當一個屬性添加到實例中時,這個屬性會覆蓋原型上的同名屬性,這個覆蓋指的是查找的時候不會到原型中查找同名屬性。即使屬性的值被賦值為 null 或 undefined,它依然會阻止到原型鏈上訪問。所以如果想要訪問,就需要刪除這個屬性,使用 delete obj.xx 。

可以使用 hasOwnProperty 判斷實例是否擁有某個屬性,返回 true 則表示實例本身擁有該屬性,否則表示它沒有這個屬性。當一個屬性存在於原型鏈上時,可以訪問到這個屬性,但是使用 hasOwnProperty 將返回 false。

in 操作符

in 操作符用在兩個地方,一個是用在 for ... in 循環中,另一個是單獨使用。單獨使用時,返回 true 表示屬性可以在對象或者其原型鏈上找到。

<code>function Person() {}
Person.prototype.name = "Nicholas";
Person.prototype.age = 29;
Person.prototype.job = "Software Engineer";
Person.prototype.sayName = function() {
console.log(this.name);
};
const person1 = new Person();
const person2 = new Person();
console.log(person1.hasOwnProperty("name")); // false
console.log("name" in person1); // true
person1.name = "Greg";
console.log(person1.name); // "Greg" - from instance
console.log(person1.hasOwnProperty("name")); // true
console.log("name" in person1); // true
console.log(person2.name); // "Nicholas" - from prototype
console.log(person2.hasOwnProperty("name")); // false
console.log("name" in person2); // true
delete person1.name;
console.log(person1.name); // "Nicholas" - from the prototype
console.log(person1.hasOwnProperty("name")); // false
console.log("name" in person1); // true
/<code>

可以通過組合使用 hasOwnProperty 和 in 來實現判斷一個屬性是否存在於原型鏈上:

<code>function hasPrototypeProperty(object, name) { 

return !object.hasOwnProperty(name) && name in object;
}
const obj = Object.create({ name: "lxfriday" });
console.log(obj);
console.log(hasPrototypeProperty(obj, "name"));
/<code>

關於對象屬性的枚舉順序

for ... in Object.keys() Object.getOwnPropertyNames/Symbols() 和 Object.assign() 在處理屬性枚舉順序的時候會有很大差別。

for ... in Object.keys() 沒有確定的枚舉順序,它們的順序取決於瀏覽器實現。

而 Object.getOwnPropertyNames() Object.getOwnPropertySymbols() 和 Object.assign() 是有確定的枚舉順序的。

  1. 數字鍵會按照升序先枚舉出來;
  2. 字符串和 symbol 鍵按照插入的順序枚舉出來;
  3. 對象字面量中定義的鍵會按照代碼中的逗號分割順序枚舉出來;
<code>const k2 = Symbol("k2");
const k1 = Symbol("k1");
const o = { 1: 1, [k2]: "sym2", second: "second", 0: 0, first: "first" };
o[k1] = "sym1";
o[3] = 3;
o.third = "third";
o[2] = 2;
// [ '0', '1', '2', '3', 'second', 'first', 'third' ]
console.log(Object.getOwnPropertyNames(o));
// [ Symbol(k2), Symbol(k1) ]
console.log(Object.getOwnPropertySymbols(o));
/<code>

對象的迭代

ES 2017 引入了兩個靜態方法來將對象的內容轉換為可迭代的格式。

Object.values() 返回對象值構成的數組; Object.entries() 返回一個二維數組,數組中的每個小數組由對象的屬性和值構成,類似於 [[key, value], ...]。

<code>const o = { foo: "bar", baz: 1, qux: {} };
console.log(Object.values(o)); // ["bar", 1, {}]
console.log(Object.entries(o)); // [["foo", "bar"], ["baz", 1], ["qux", {}]]
/<code>

在輸出的數組中,非字符串的屬性會轉換成字符串,上述的兩個方法對引用類型是採取的淺拷貝。

<code>const o = { qux: {} };
console.log(Object.values(o)[0] === o.qux); // true
console.log(Object.entries(o)[0][1] === o.qux); // true
/<code>

symbol 鍵名會被忽略掉。

<code>const sym = Symbol();
const o = { [sym]: "foo" };
console.log(Object.values(o)); // []
console.log(Object.entries(o)); // []
/<code>

原型的另一種寫法

上面的例子中,給原型賦值都是一個個賦,比較繁瑣,看看下面的賦值方式:

<code>function Person() {}
Person.prototype = {
name: "Nicholas",
age: 29,

job: "Software Engineer",
sayName() {
console.log(this.name);
}
};
/<code>

上面的例子中,Person 的原型直接指向一個對象字面量,這種方式最終的結果和前面的單個賦值是一樣的,除了原型的 constructor 屬性,constructor 不再指向 Person 構造函數。默認情況下,當一個函數創建的時候,會創建一個 prototype 對象,並且這個對象上的 constructor 屬性也會自動指向這個函數。所以這種做法覆蓋了默認的 prototype 對象,意味著 constructor 屬性指向新對象的對應屬性。雖然 instanceof 操作符依然會正常工作,但是已經無法用 constructor 來判斷實例的類型。看下面的例子:

<code>const friend = new Person();
console.log(friend instanceof Object); // true
console.log(friend instanceof Person); // true
console.log(friend.constructor == Person); // false
console.log(friend.constructor == Object); // true
/<code>

如果 constructor 屬性很重要,那麼你可以手動的給它修復這個問題:

<code>function Person() {}
Person.prototype = {
constructor: Person,
name: "Nicholas",
age: 29,
job: "Software Engineer",
sayName() {
console.log(this.name);
}
};
/<code>

不過上面的設置方法有一個問題,constructor 的屬性描述如下

<code>{
value: [Function: Person],
writable: true,
enumerable: true,
configurable: true
}
/<code>

我們再看看 Object.prototype.constructor:

<code>{
value: [Function: Object],
writable: true,
enumerable: false,
configurable: true
}
/<code>

我們自己賦值時枚舉屬性會被默認設置為 true,所以需要通過 Object.defineProperty 來設置不可枚舉:

<code>Object.defineProperty(Person.prototype, "constructor", {
value: Person,
enumerable: false,
configurable: true,
writable: true
});
/<code>

原型存在的問題

我們知道原型屬性對所有實例是共享的,當原型屬性是原始值時沒有問題,當原型屬性是引用類型時將會出現問題。看看下面的例子:

<code>function Person() {}
Person.prototype = {
constructor: Person,
name: "Nicholas",
age: 29,
job: "Software Engineer",
friends: ["Shelby", "Court"],

sayName() {
console.log(this.name);
}
};
const person1 = new Person();
const person2 = new Person();
person1.friends.push("Van");
console.log(person1.friends); // "Shelby,Court,Van"
console.log(person2.friends); // "Shelby,Court,Van"
console.log(person1.friends === person2.friends); // true
/<code>

上述例子中,原型屬性 friends 原本是一個包含兩個字符串的數組,但是由於 person1 修改了它的內容,導致了原型上的這個屬性被更改了,所以 person2 訪問的時候也會打印三個字符串。

由於這個問題,原型模式並不會單獨使用,我們經常會結合構造函數和原型來創建對象。

總結

我們知道,使用構造函數或者原型創建對象都會存在問題,接下來我們組合使用這兩者來解決上面的問題。

  1. 構造函數的問題:每個對象都會聲明對應的函數,浪費內存;
  2. 原型的問題:更改引用類型的原型屬性的值會影響到其他實例訪問該屬性;

為了解決上面的問題,我們可以把所有對象相關的屬性定義在構造函數內,把所有共享屬性和方法定義在原型上

<code>// 把對象相關的屬性定義在構造函數中
function Human(name, age){
this.name = name,
this.age = age,
this.friends = ["Jadeja", "Vijay"]
}
// 把共享屬性和方法定義在原型上
Human.prototype.sayName = function(){
console.log(this.name);
}
// 使用 Human 構造函數創建兩個對象
var person1 = new Human("Virat", 31);
var person2 = new Human("Sachin", 40);

// 檢查 person1 和 person2 的 sayName 是否指向了相同的函數
console.log(person1.sayName === person2.sayName) // true

// 更改 person1 的 friends 屬性
person1.friends.push("Amit");

// 輸出: "Jadeja, Vijay, Amit"
console.log(person1.friends)
// 輸出: "Jadeja, Vijay"
console.log(person2.friends)
/<code>

我們想要每個實例對象都擁有 name age 和 friends 屬性,所以我們使用 this 把這些屬性定義在構造函數內。另外,由於 sayName 是定義在原型對象上的,所以這個函數會在所有實例間共享。

在上面的例子中,person1 對象更改 friends 屬性時, person2 對象的 friends 屬性沒有更改。這是因為 person1 對象更改的是自己的 friends 屬性,不會影響到 person2 內的。

JavaScript 中創建對象的那些事兒

最後

往期精彩:

  • 前端面試必備 | 5000字長文解釋千萬不能錯過的原型操作方法及其模擬實現(原型篇:下)
  • 前端面試必備 | 古怪的原型(雞生蛋還是蛋生雞)(原型篇:中)
  • 前端面試必備 | 使用原型和構造函數創建對象(原型篇:上)
  • 前端面試必會 | 一文讀懂 JavaScript 中的 this 關鍵字
  • 前端面試必會 | 一文讀懂現代 JavaScript 中的變量提升 - let、const 和 var
  • 前端面試必會 | 一文讀懂 JavaScript 中的閉包
  • 前端面試必會 | 一文讀懂 JavaScript 中的作用域和作用域鏈
  • 前端面試必會 | 一文讀懂 JavaScript 中的執行上下文
  • IntersectionObserver 和懶加載
  • 初探瀏覽器渲染原理
  • CSS 盒模型、佈局和包含塊
  • 詳細解讀 CSS 選擇器優先級


分享到:


相關文章: