JavaScript – 原型與繼承(Prototype & Inheritance)
主題:new 運算子原理
簡介
在 JavaScript 中,new 是建立物件最常用的語法之一。它不只是「呼叫」一個函式那麼簡單,而是會觸發一連串隱藏的步驟,從 原型鏈 的設定到 建構子 的返回值,都與語言的繼承機制緊密相連。
了解 new 的內部運作,能讓你:
- 正確地使用 建構子函式(constructor)與 類別(class)
- 避免因為忘記
new或濫用new而產生的錯誤(如TypeError: undefined is not a function) - 在需要手動模擬
new時,寫出等價的程式碼,提升對原型繼承的直觀感受
本篇文章將從概念層面剖析 new 的六個步驟,搭配實用範例說明每一步的作用,並提供常見陷阱、最佳實踐與實務應用情境,讓初學者與中階開發者都能在日常開發中更自信地使用它。
核心概念
1. new 的六個隱藏步驟
當我們執行 new Constructor(arg1, arg2) 時,JavaScript 會依序完成以下動作:
| 步驟 | 說明 | 影響 |
|---|---|---|
| ① 建立一個空物件 | 由 Object.create(Constructor.prototype) 產生,稱為 實例物件(instance)。 |
此物件的 [[Prototype]](即 __proto__)指向 Constructor.prototype。 |
| ② 讓實例物件執行建構子函式 | 呼叫 Constructor,this 會被綁定到剛建立的空物件上。 |
建構子內的屬性(如 this.name = ...)會被寫入實例。 |
| ③ 取得建構子返回值 | 若建構子 明確 回傳物件(非 null、undefined),則以該回傳值作為 new 的結果;否則回傳步驟①產生的實例物件。 |
可用於「工廠函式」的寫法,或在子類別建構子中返回父類別的實例。 |
④ 設定 [[Prototype]] |
已在步驟①完成,確保實例可以繼承 prototype 上的方法與屬性。 |
形成原型鏈,使 instance.method() 能正確找到方法。 |
| ⑤ 產生屬性描述(Property Descriptor) | 依照建構子內的屬性賦值,為每個屬性建立 [[Value]]、[[Writable]]、[[Enumerable]]、[[Configurable]] 等描述。 |
控制屬性的可寫、可列舉與可設定性。 |
| ⑥ 返回結果 | 最終得到的值(步驟③的回傳或步驟①的實例)交給程式繼續執行。 | 這就是我們在程式碼中看到的 obj。 |
小結:
new的核心是「先產生一個連結到prototype的空物件,再把建構子執行於該物件上」。只要掌握這個流程,就能理解為什麼instanceof、Object.getPrototypeOf等檢測方式會如此運作。
2. 用原生 new 與手寫模擬比較
下面示範使用原生 new 建立物件,接著用自訂函式 myNew 完全模擬同樣的行為,讓讀者看到每一步的實作細節。
// ---------- 原生 new ----------
function Person(name, age) {
this.name = name;
this.age = age;
}
Person.prototype.greet = function () {
console.log(`嗨,我叫 ${this.name},今年 ${this.age} 歲。`);
};
const alice = new Person('Alice', 28);
alice.greet(); // 嗨,我叫 Alice,今年 28 歲。
// ---------- 手寫 myNew ----------
function myNew(Constructor, ...args) {
// ① 建立空物件,並把 prototype 設為 [[Prototype]]
const obj = Object.create(Constructor.prototype);
// ② 呼叫建構子,this 綁定到 obj
const result = Constructor.apply(obj, args);
// ③ 若建構子回傳物件則直接返回,否則返回 obj
return (typeof result === 'object' && result !== null) || typeof result === 'function'
? result
: obj;
}
const bob = myNew(Person, 'Bob', 35);
bob.greet(); // 嗨,我叫 Bob,今年 35 歲。
重點:
Object.create(Constructor.prototype)完成了 步驟① 與 步驟④。Constructor.apply(obj, args)完成了 步驟②。return判斷實作了 步驟③ 的回傳規則。
3. new 與 ES6 class 的關係
ES6 引入了 class 語法,讓原型繼承看起來更像傳統 OOP 語言。然而,class 仍然是 語法糖,背後仍然依賴 new 與原型鏈。
class Animal {
constructor(species) {
this.species = species;
}
speak() {
console.log(`${this.species} 發出聲音`);
}
}
// 這裡的 new 仍然走過前面說的六個步驟
const cat = new Animal('貓');
cat.speak(); // 貓 發出聲音
提醒:即使使用
class,若忘記new會拋出TypeError: Class constructor Animal cannot be invoked without 'new',因為 class constructors are not callable as regular functions。
4. new 與 Object.create 的差異
| 特性 | new Constructor() |
Object.create(proto) |
|---|---|---|
| 是否執行建構子 | 會(步驟②) | 不會 |
| 原型設定 | 以 Constructor.prototype 為原型 |
直接以提供的 proto 為原型 |
| 返回值 | 可能被建構子覆寫 | 永遠返回新物件 |
| 適用情境 | 建立具備初始化邏輯的實例 | 想要「純粹」繼承而不執行建構子時(如深拷貝、混入) |
程式碼範例
以下提供 5 個實用範例,涵蓋常見情境與細節說明。
範例 1:建構子裡返回自訂物件
function Wrapper(value) {
this.value = value;
// 明確回傳一個不同的物件
return { wrapped: value };
}
const w = new Wrapper(10);
console.log(w); // { wrapped: 10 }
console.log(w instanceof Wrapper); // false,因為回傳值取代了實例
說明:若建構子回傳 非 null/undefined 的物件,new 會直接使用該物件作為結果,原型鏈會失效。這在 工廠模式 中很常見。
範例 2:避免忘記 new 的防呆寫法
function Car(make, model) {
// 防止忘記 new
if (!(this instanceof Car)) {
return new Car(make, model);
}
this.make = make;
this.model = model;
}
Car.prototype.info = function () {
console.log(`汽車:${this.make} ${this.model}`);
};
const c1 = Car('Toyota', 'Corolla'); // 沒寫 new
c1.info(); // 汽車:Toyota Corolla
說明:在建構子最前面檢查 this instanceof Car,若不是實例就自動呼叫 new,讓 API 更容錯。
範例 3:子類別繼承父類別(ES5 寫法)
// 父類別
function Person(name) {
this.name = name;
}
Person.prototype.sayHello = function () {
console.log(`哈囉,我是 ${this.name}`);
};
// 子類別
function Student(name, school) {
// 呼叫父類別建構子,讓 this 繼承屬性
Person.call(this, name);
this.school = school;
}
// 設定原型鏈(步驟④)
Student.prototype = Object.create(Person.prototype);
Student.prototype.constructor = Student;
// 加上子類別自己的方法
Student.prototype.study = function () {
console.log(`${this.name} 正在 ${this.school} 學習`);
};
const s = new Student('小明', '台大');
s.sayHello(); // 哈囉,我是 小明
s.study(); // 小明 正在 台大 學習
說明:
Person.call(this, name)完成 步驟②(父建構子執行)。Object.create(Person.prototype)完成 步驟④(子類別的原型指向父類別 prototype)。
範例 4:使用 new 與 Symbol.species 控制子類別的返回值
class MyArray extends Array {
// 讓子類別的所有衍生方法(如 map)返回原生 Array,而非 MyArray
static get [Symbol.species]() {
return Array;
}
}
const arr = new MyArray(1, 2, 3);
const mapped = arr.map(x => x * 2);
console.log(mapped instanceof MyArray); // false
console.log(mapped instanceof Array); // true
說明:即使 new 建立的是 MyArray,透過 Symbol.species 可以在內建方法返回時「改寫」new 的行為,這在 自訂容器類別 時相當有用。
範例 5:手寫 new 的完整實作(含 Object.defineProperty)
function myNewFull(Constructor, ...args) {
// ① 建立空物件,設定原型
const instance = Object.create(Constructor.prototype);
// ② 呼叫建構子,綁定 this
const result = Constructor.apply(instance, args);
// ③ 處理返回值
const finalObj = (typeof result === 'object' && result !== null) || typeof result === 'function'
? result
: instance;
// ④ 為實例加上不可列舉的 __proto__(模擬引擎內部行為)
Object.defineProperty(finalObj, '__proto__', {
value: Constructor.prototype,
writable: false,
enumerable: false,
configurable: false
});
return finalObj;
}
// 測試
function Point(x, y) {
this.x = x;
this.y = y;
}
Point.prototype.toString = function () {
return `(${this.x}, ${this.y})`;
};
const p = myNewFull(Point, 5, 7);
console.log(p.toString()); // (5, 7)
說明:此實作加入了 步驟⑤(屬性描述)與 步驟④ 的更細緻控制,展示 new 背後的完整機制。
常見陷阱與最佳實踐
| 常見陷阱 | 原因 | 解決方式 |
|---|---|---|
忘記 new |
建構子被當成普通函式呼叫,this 為 undefined(嚴格模式)或 window(非嚴格) |
使用 防呆寫法(範例 2)或 ES6 class(編譯器會強制) |
| 在建構子內返回基本型別 | 基本型別會被忽略,仍返回實例物件 | 僅在需要時返回 物件,或避免 return |
| 原型鏈斷裂 | 手動改寫 prototype 後忘記設定 constructor |
Child.prototype = Object.create(Parent.prototype); Child.prototype.constructor = Child; |
多層繼承時 instanceof 判斷錯誤 |
只檢查第一層原型,忘記 Symbol.hasInstance |
如有特殊需求,可自行實作 Symbol.hasInstance |
使用 new 呼叫箭頭函式 |
箭頭函式沒有自己的 [[Construct]] 內部方法 |
不要把箭頭函式當作建構子,改用普通函式或 class |
最佳實踐
- 永遠使用
new或class:保持語意清晰,讓其他開發者一眼就能看出意圖。 - 在建構子內避免副作用:盡量只做屬性初始化,將複雜邏輯搬到原型方法或工廠函式。
- 使用
Object.create進行純粹繼承:當你只想共享方法而不想執行父建構子時,Object.create更安全。 - 保持
prototype不變:在執行期間不要隨意改變Constructor.prototype,會導致已建立實例的原型失效。 - 利用
Symbol.species控制子類別返回:自訂容器或集合類別時,避免不必要的子類別實例化,提高效能。
實際應用場景
| 場景 | 為何需要 new |
範例 |
|---|---|---|
| 資料模型(Model) | 每筆資料需擁有獨立狀態與方法(如 save、validate) |
const user = new User({name:'小華'}); |
| 自訂錯誤類別 | 需要保留 stack 與 message,同時擁有自訂屬性 |
class ValidationError extends Error { constructor(msg){ super(msg); this.code = 400; } } |
| UI 元件庫 | 每個元件是可獨立掛載、擁有生命周期的實例 | const btn = new Button({label:'送出'}); |
| 遊戲實體(Entity) | 角色、怪物等需要共享行為(move、attack)且各自持有狀態 | const hero = new Character('勇者', 100); |
| 測試框架的 Mock 物件 | 需要快速產生多個相同結構的假物件 | function MockUser(){ this.id = generateId(); } const m1 = new MockUser(); |
總結
new 並非只是一個簡單的關鍵字,它是 原型與繼承 的核心機制之一。透過六個隱藏步驟,new 完成了:
- 物件創建(連結到建構子
prototype) - 建構子執行(初始化屬性)
- 返回值決策(支援工廠模式)
- 原型鏈設定(支撐繼承)
掌握這些概念後,你可以:
- 正確地設計與使用建構子與類別
- 在需要時手寫
new的等價實作,深入了解底層行為 - 針對常見錯誤設置防呆機制,提升程式碼的健壯性
最後,記得 保持語意清晰、遵循最佳實踐,讓 new 成為你在 JavaScript 開發中可靠且強大的工具。祝你寫程式愉快,持續探索更深層的原型世界!