如果你剛好在學 JavaScript,遇到 class、constructor、this、prototype、static 這些關鍵字,腦袋開始打結,很正常。JS 的底層是「原型」,但 ES6 又給了我們比較貼近主流 OOP 的 class 語法,結果就是:同一件事有兩套說法。
這篇會把 ES5 舊寫法(建構子+prototype)和 ES6 class 放在一起比,帶你看清差異:方法到底掛哪裡?為什麼 ES6 需要 new?super 有什麼眉角?this 什麼時候會跑掉?還會補上常見地雷與實作步驟,包含繼承、私有欄位、靜態成員,讓你能在老專案與新專案之間切換自如,寫出可維護、好測試、又不會嚇到同事的程式碼。希望本篇文章能夠幫助到需要的您。
目錄
{tocify} $title={目錄}
為什麼需要 class:先理解 JavaScript 的物件模型
JavaScript 天生是原型(prototype-based)語言。所有物件在執行期都可以掛接到某個原型物件上,透過原型鏈(prototype chain)進行屬性查找。ES6 引入 class 並不是改變底層模型,而是提供一套更符合主流 OOP 開發者心智模型的語法。
一句話:class 是語法糖,底層仍是原型,但這個語法糖幫你處理許多細節(例如方法不可列舉、預設嚴格模式等)。
與「以物件導向為主」語言(Java/Kotlin/C#)的真正差異
| 面向 | JavaScript | Java / Kotlin / C#(以 OOP 為主) |
|---|---|---|
| 型別系統 | 動態型別、鴨子型別(duck typing) | 靜態型別、在編譯期檢查 |
| 物件模型 | 原型為核心;class 是語法糖 |
類別是核心(class-based) |
| 繼承 | 原型鏈;extends 對應到原型鏈連結 |
類別繼承樹 |
| 可見性 |
ES2022 起支援
#private 私有欄位;過去多靠閉包/慣例
|
public/protected/private |
| 多型 | 以結構與行為為主,介面靠約定(或 TypeScript) | 明確的介面/抽象類別 |
| 生成方式 |
new 綁定 this 與原型
|
new 建立實例,規則較固定 |
| 方法掛載 | 原則上掛在 prototype 節省記憶體 |
由類別定義,語言/VM 處理 |
小提醒:如果你來自 Java/Kotlin/C#,在 JS 裡先想物件(Object)與原型(Prototype),再看 class,學得更穩。
ES5(建構子+prototype) 與 ES6 class 在定義與使用上的差異
| 面向 | ES5(建構子+prototype) |
ES6 class |
備註/影響 |
|---|---|---|---|
| 基本定義 語法 |
function Person(n){
|
class Person{
|
ES6 可讀性高、 語意集中。 |
| 本質 | 原型為核心;無真正「類別」 | class 是語法糖;底層仍走原型 |
心智模型變友善, 但機制不變。 |
| 方法放置 | 手動掛到 Constructor.prototype |
類別主體內宣告的為原型方法 | 兩者最終都走原型共享。 |
| 方法列舉性 | 預設 可列舉(enumerable: true) | 預設 不可列舉(enumerable: false) |
ES5 要用 defineProperty 才能改成不可列舉。 |
| 嚴格模式 | 視檔案/函式而定 | 類別主體預設嚴格模式 | 可避免隱性全域 等地雷。 |
| 建構子呼叫 | 可被當一般函式呼叫 (易汙染 this) |
必須用 new,否則 TypeError
|
降低誤用。 |
new 行為 |
new→ 建物件、綁 __proto__、呼叫函式
|
相同步驟,但由語法包裝 | 語意更清楚; 錯誤更早暴露。 |
| hoisting /TDZ |
函式提升規則適用於宣告式; 表達式不提升 |
class 存在 TDZ,宣告前使用
ReferenceError
|
避免未定義使用。 |
| 繼承語法 |
function Child(...){
|
class Child extends Parent{
|
ES6 更簡潔且不易漏掉 constructor 修復。 |
super/父類存取
|
無語法;用 Parent.call、Parent.prototype.method.call
|
有 super(...) 與
super.method()
|
直觀、可讀性高。 |
this in 子類建構子 |
可在任何時點使用 this(但常見陷阱)
|
呼叫 super() 前不可用
this
|
不遵守會拋 ReferenceError。 |
| 靜態成員 | 手動掛在建構子本體:Ctor.util=... |
static util(){}、static field=...、static { ... }
|
ES6 語法一體化; 有 static block(ES2022+)。
|
| 類別欄位 | 無語法; 在 constructor 內賦值 |
公開欄位 field=...、私有欄位 #x
|
私有欄位是新標準(ES2022+), 編譯目標需支援。 |
| Getter/Setter |
透過 Object.defineProperty 或物件實字語法 |
類別內可直接寫 get/set |
兩者底層一致; ES6更順手。 |
| 內建型別 子類化 |
困難且行為不一致 (如 Array) |
class X extends Array {} 可正確繼承 |
ES6 修補了內建可繼承性。 |
name 屬性 |
依宣告方式而異 | 類別有穩定的 name |
有助除錯/日誌。 |
new.target |
無 | 可在 constructor 內用 new.target |
可寫抽象基底類別的保護檢查。 |
| 列舉 /反射差異 |
方法常被 for...in 列出 |
方法不被列舉;Object.keys 取不到 |
影響序列化與工具列舉邏輯。 |
| JSON 與實例還原 | 解析為純物件, 需手動設原型 |
同 | 不是 ES6 特有—但常見在 class 化專案。 |
| 工具鏈 /相容性 |
到處可跑 | 需 ES6+(或 Babel/TS 轉譯) | 舊環境要編譯。 |
ES6 class 語法總覽
1. 定義類別與建構子 constructor
class User {
constructor(name, age) {
this.name = name;
this.age = age;
}
greet() { // 原型方法(不可枚舉)
return `Hi, I’m ${this.name}.`;
}
}
const u = new User('Ada', 26);
console.log(u.greet());
重點:
constructor 在 new 時自動執行。
類別內定義的方法預設掛在 User.prototype,且不可枚舉(這是 ES6 與舊寫法的差異之一)。
類別主體內預設嚴格模式('use strict')。
2. 類別欄位(Public/Private Fields)
class Counter {
#value = 0; // 私有欄位(ES2022)
step = 1; // 公有欄位(實例屬性)
inc() { this.#value += this.step; }
value() { return this.#value; }
}
#value 只能在類別內部取用,外部 obj.#value 會語法錯誤。
類別欄位(不論 # 與否)屬於實例屬性,不是原型方法。
3. 繼承 extends 與 super
class Admin extends User {
constructor(name, age, level = 'super') {
super(name, age); // 呼叫父類別 constructor
this.level = level;
}
greet() { // 覆寫
return `[${this.level}] ` + super.greet();
}
}
4. 存取子(Getter/Setter)
class Article {
constructor(title) { this._title = title; }
get title() { return this._title.trim(); }
set title(v) { this._title = String(v); }
}
new 的內部機制:一步一步發生了什麼
new Foo(args) 大致等於(概念化):
1. 建立空物件 obj。
2. 設定 obj.__proto__ = Foo.prototype(掛上原型鏈)。
3. 執行 Foo.call(obj, args),在 constructor 內 this === obj。
4. 若 constructor 明確 return 一個物件,則回傳該物件;否則回傳 obj。
邊界狀況:
function Weird() {
this.x = 1;
return { y: 2 }; // 有物件回傳值,new Weird() 會得到 { y: 2 }
}
console.log(new Weird()); // { y: 2 }
this 綁定規則全圖
1. 一般呼叫:
fn() → 非嚴格模式下 this 指向全域(瀏覽器是 window);
嚴格模式下 this === undefined。
2. 方法呼叫:obj.method() → this === obj。
3. call/apply/bind:顯式綁定 this。
4. new:this 綁到新建的實例。
5. 箭頭函式:不綁定 this,改用外層詞法作用域的 this。
const obj = {
x: 42,
reg() { return function () { return this?.x; }; },
arrow() { return () => this?.x; }
};
const f1 = obj.reg();
const f2 = obj.arrow();
console.log(f1()); // 嚴格模式下 undefined
console.log(f1.call({x:1})); // 1
console.log(f2()); // 42(從外層 obj 捕捉 this)
類別方法預設在嚴格模式,因此「脫鉤呼叫」很容易讓 this 變成 undefined。解法是 .bind()、用箭頭函式包裹,或在呼叫端保留物件語境。
prototype 與原型鏈:方法為何放在原型上
每個由建構子/類別建立的實例,都透過 __proto__ 連到對應的 prototype。
將方法定義在 prototype,可以讓所有實例共享同一份函式(節省記憶體、利於一致行為)。
手寫 ES5 風格(理解原理):
function Person(name) { this.name = name; }
Person.prototype.greet = function () {
return `Hi, I’m ${this.name}.`;
};
const p = new Person('Lin');
console.log(p.greet());
ES6 class 寫法等效,但更整潔且方法不可枚舉。
靜態成員 static 與 static block
1. 何謂靜態成員
掛在類別本身,而非實例或原型。
適合:工廠方法、常數、快取、與整體類別有關的工具。
class Id {
static last = 0;
static next() { return ++Id.last; }
}
console.log(Id.next()); // 1
console.log(Id.next()); // 2
2. static block(初始化靜態狀態)
class Config {
static map = new Map();
static {
// 一次性靜態初始化
Config.map.set('api', '/v1');
}
}
ES6 以前 vs 以後:語法糖之外的「語意差異」
| 差異點 | ES5(建構子 + prototype) | ES6 class |
|---|---|---|
| 方法可列舉性 | 預設可列舉(除非 Object.defineProperty) |
不可列舉 |
| 嚴格模式 | 視檔案/函式而定 | 類別主體預設嚴格模式 |
| hoisting | 函式宣告可被提升,建構子函式要注意順序 | 類別有 TDZ(暫時性死區),宣告前使用會拋錯 |
| 當作函式呼叫 |
建構子可被當一般函式呼叫(會汙染全域 this)
|
class 必須用 new,否則 TypeError
|
| 私有成員 | 無(多靠閉包/約定) | 有 #private 欄位與方法(新標準) |
進階:私有欄位 #private、存取子、類別欄位
私有欄位/方法以 # 開頭,只能在類別內使用,解析器級別保護。
存取子提供計算或驗證邏輯。
類別欄位會在每次建立實例時初始化在實例上(別與原型方法混淆)。
class Wallet {
#balance = 0
static MIN = 0
deposit(n) {
if (n <= 0) throw new Error('amount must > 0')
this.#balance += n
}
withdraw(n) {
if (n <= 0 || n > this.#balance) throw new Error('bad amount')
this.#balance -= n
}
get balance() { return this.#balance }
}
逐步實作範例
1. 基礎版:User 與常見操作
class User {
constructor(name, age) {
this.name = name
this.age = age
}
greet() { return `Hi, I’m ${this.name} (${this.age}).` }
isAdult() { return this.age >= 18 }
}
const u = new User('Mia', 20)
console.log(u.greet()) // Hi, I’m Mia (20).
console.log(u.isAdult()) // true
2. 繼承版:Admin 覆寫與 super
class Admin extends User {
constructor(name, age, role = 'super') {
super(name, age)
this.role = role
}
greet() {
return `[${this.role}] ` + super.greet()
}
static canDeleteUser() { return true }
}
const a = new Admin('Ken', 30, 'editor')
console.log(a.greet()) // [editor] Hi, I’m Ken (30).
console.log(Admin.canDeleteUser()) // true
3. 事件小工具(實戰):迷你 EventEmitter
class Emitter {
#map = new Map()
on(type, fn) {
const arr = this.#map.get(type) || []
arr.push(fn)
this.#map.set(type, arr)
return () => this.off(type, fn) // 方便移除
}
off(type, fn) {
const arr = this.#map.get(type) || []
this.#map.set(type, arr.filter(f => f !== fn))
}
emit(type, payload) {
const arr = this.#map.get(type) || []
for (const fn of arr) fn(payload)
}
}
const bus = new Emitter()
const off = bus.on('login', (u) => console.log('Welcome', u.name))
bus.emit('login', { name: 'Jo' }) // Welcome Jo
off()
bus.emit('login', { name: 'Sue' }) //(已移除監聽,無輸出)
4. 與舊寫法對照(加深理解)
// ES5:建構子 + prototype
function OldUser(name) { this.name = name }
OldUser.prototype.greet = function () { return 'Hi, ' + this.name }
等效 ES6:
class NewUser {
constructor(name) { this.name = name }
greet() { return `Hi, ${this.name}` }
}
常見錯誤與雷點
1. 忘記 new
class A {}
const a = A(); // TypeError: Class constructor A cannot be invoked without 'new'
解法:務必 new A();若要防呆,可在工廠方法包裝。
2. 方法脫鉤導致 this 炸掉
class T {
constructor(){ this.x = 1 }
getX(){ return this.x }
}
const t = new T()
const fn = t.getX
fn() // TypeError: Cannot read properties of undefined
解法:fn.bind(t)、或在 constructor 中 this.getX = this.getX.bind(this)、或呼叫時保持 t.getX()。
3. 箭頭函式誤用在原型方法
class Oops {
x = 1
// 不要這樣定義原型方法,這會成為「每個實例都各自一份」的實例欄位函式
getX = () => this.x
}
這不是錯誤,但會每個實例各占一份記憶體。除非你需要綁死 this 或閉包參數,多數時候請用常規方法定義在原型上。
4. static 與實例混淆
class Util { static parse(){} }
const u = new Util()
u.parse() // TypeError
解法:Util.parse();或不要把實例 API 做成 static。
5. super 使用順序錯誤
在子類別 constructor 裡,存取 this 前必須先呼叫 super()。
class A { constructor(){ this.a=1 } }
class B extends A {
constructor(){
// this.b = 2 // ❌ ReferenceError
super()
this.b = 2 // ✅
}
}
6. 私有欄位 # 的支援度與語法錯誤
obj.#x 在類別外部直接報語法錯誤(不是執行期錯,是語法階段)。打包工具若太舊可能不支援,需更新 Babel/TS 或目標瀏覽器。
7. 類別宣告的 TDZ(暫時性死區)
const a = new C() // ReferenceError
class C {}
解法:確保在宣告之後再使用。
8. JSON 與 Class 的落差
JSON.parse() 得到的是純物件,沒有類別原型方法。
解法:需要還原時自行 Object.setPrototypeOf(obj, Class.prototype) 或在類別寫 fromJSON()。
9. 效能小坑:在 constructor 綁一堆方法
每個實例都會生成新函式。
優先用原型方法,除非你真的要捕捉實例私有狀態或綁死 this。
10. 繼承樹過深
JS 可以繼承,但別迷信深層繼承。
實務常用組合(composition)比繼承穩定、彈性更高。
問題集
Q1:class 到底是不是語法糖?
是。底層仍是原型;但 class 加了嚴格模式、方法不可枚舉、TDZ、必須 new 等語意上的約束,行為更可預期。
Q2:什麼時候用 static?
當行為與實例無關,而是整體類別狀態、工具方法、或工廠行為時。
Q3:要不要用箭頭函式當方法?
除非你需要固定 this 或閉包變數,否則用原型方法(宣告在類別內的常規方法)更省記憶體且語意清楚。
Q4:舊專案怎麼從 ES5 過渡到 ES6 class?
先把建構子 + prototype 的區塊用 class 包裝,保持方法名稱與行為不變;測試通過後再逐步引入 #private、static 等進階特性。
附錄:實作步驟拆解與「操作指令」
以下用「從零到有」的流程寫一次,適合做為你的開發腳本清單。
步驟 A:建立基礎類別
1. 新檔 user.js,輸出/輸入使用 ES 模組(若環境支援):
export class User {
constructor(name, age){ this.name = name; this.age = age; }
greet(){ return `Hi, I’m ${this.name}.`; }
isAdult(){ return this.age >= 18; }
}
2. 在 main.js 測試:
import { User } from './user.js'
const u = new User('Amy', 19)
console.log(u.greet(), u.isAdult())
步驟 B:加入繼承與 super
1. 新增 admin.js:
import { User } from './user.js'
export class Admin extends User {
constructor(name, age, role='super'){ super(name, age); this.role = role; }
greet(){ return `[${this.role}] ` + super.greet() }
static perms(){ return ['delete', 'ban', 'edit'] }
}
2. 測試:
import { Admin } from './admin.js'
console.log(new Admin('Eve', 28, 'editor').greet())
console.log(Admin.perms())
步驟 C:導入私有欄位、驗證、錯誤處理
1. 改寫錢包邏輯(或會員點數):
export class Wallet {
#balance = 0
deposit(n){ if(n<=0) throw new Error('amount must > 0'); this.#balance += n }
withdraw(n){ if(n<=0 || n>this.#balance) throw new Error('bad amount'); this.#balance -= n }
get balance(){ return this.#balance }
}
2. 在 main.js 驗證 try/catch:
import { Wallet } from './wallet.js'
const w = new Wallet()
w.deposit(100)
try { w.withdraw(999) } catch (e) { console.error(e.message) } // bad amount
console.log(w.balance) // 100
步驟 D:事件總線(組合勝於繼承的示例)
1. emitter.js:
export class Emitter {
#map = new Map()
on(t, fn){ const a=this.#map.get(t)||[]; a.push(fn); this.#map.set(t,a); return ()=>this.off(t,fn) }
off(t, fn){ const a=this.#map.get(t)||[]; this.#map.set(t, a.filter(f=>f!==fn)) }
emit(t, p){ const a=this.#map.get(t)||[]; for(const f of a) f(p) }
}
2. 在 main.js 組合使用,不必建立繼承關係:
import { Emitter } from './emitter.js'
import { User } from './user.js'
const bus = new Emitter()
const off = bus.on('created', u => console.log('Created:', u.name))
const u = new User('Neo', 33)
bus.emit('created', u)
off()
常見錯誤速查
忘記 new → TypeError
方法脫鉤 → this 變 undefined(請 bind 或保持語境)
箭頭函式用作方法 → 記憶體增加、失去原型共享好處
static 誤用在實例 → 呼叫錯誤
super 前使用 this → ReferenceError
#private 在外部取用 → 語法錯誤(不是執行期錯)
類別 TDZ → 宣告前不可用
JSON → 純物件,無原型方法(要手動還原)
過度繼承 → 組合可能更好
總結
class 是通往可靠結構的語法入口:保持方法在原型、狀態在實例。
搞懂 new / this / prototype,其餘皆水到渠成。
ES6 之後的差異不是只有漂亮語法:TDZ、嚴格模式、方法不可枚舉、必須 new,都讓行為更可預測。
實務上:善用 static、私有欄位、存取子;以組合為主、繼承為輔,寫出容易測試與擴充的商用程式碼。
