javaScript : class從原型到 ES6, 搞懂 constructor / new / this / prototype / static


如果你剛好在學 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){
  this.name=n
}
Person.prototype.greet=
function(){
return 'Hi '+this.name
}
class Person{
  constructor(n){ this.name=n
}
greet(){
return `Hi ${this.name}`
}
ES6 可讀性高、
語意集中。
本質 原型為核心;無真正「類別」 class 是語法糖;底層仍走原型 心智模型變友善,
但機制不變。
方法放置 手動掛到 Constructor.prototype 類別主體內宣告的為原型方法 兩者最終都走原型共享。
方法列舉性 預設 可列舉(enumerable: true) 預設 不可列舉(enumerable: false) ES5 要用 defineProperty
才能改成不可列舉。
嚴格模式 視檔案/函式而定 類別主體預設嚴格模式 可避免隱性全域
等地雷。
建構子呼叫 可被當一般函式呼叫
(易汙染 this
必須用 new,否則 TypeError 降低誤用。
new 行為 new→ 建物件、
__proto__、呼叫函式
相同步驟,但由語法包裝 語意更清楚;
錯誤更早暴露。
hoisting
/TDZ
函式提升規則適用於宣告式;
表達式不提升
class 存在 TDZ,宣告前使用 ReferenceError 避免未定義使用。
繼承語法 function Child(...){
  Parent.call(this,...)
}

Child.prototype=
Object.create(
Parent.prototype)

Child.prototype.constructor
=Child
class Child extends Parent{ 
  constructor(...){ super(...);
}
ES6 更簡潔且不易漏掉 constructor 修復。
super
/父類存取
無語法;用 Parent.callParent.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、私有欄位、存取子;以組合為主、繼承為輔,寫出容易測試與擴充的商用程式碼。


延伸閱讀推薦:


張貼留言 (0)
較新的 較舊