想像你有一棵資料樹:節點長相固定,但你老闆今天要匯出 CSV,明天要轉 Markdown,後天又想跑稽核規則。
硬把所有動作塞回節點,不但醜,還會越改越黏。Visitor 的做法就聰明很多:把「要做什麼」獨立成訪問者,讓每個節點只做一件事——被拜訪。
於是加新功能時,你寫個新的 Visitor,走訪同一棵樹就能得到新結果。本文不講玄學,只用原生 JavaScript 帶你從入門到上手:先看圖形面積與 SVG,接著玩一顆簡單語法樹,再落地到訂單與報表。中途會提醒常見雷點,讓你少踩幾次坑。希望本篇文章可以幫助到需要的您。
目錄
{tocify} $title={目錄}
什麼是 Visitor 模式?
Visitor(訪問者)是一種行為型設計模式,用於在不修改資料結構(元素的類別)的前提下,為其新增操作。
它透過「雙重分派(double dispatch)」的技巧,把「要做什麼」從元素本身抽離到「訪問者物件」,於是:
新增一種操作:只要新增一個 Visitor 類別(或物件),就能在不動到元素類別的情況下,為整個結構加上一種新行為。
穩定的資料結構 + 多變的操作:當元素型別與結構相對穩定,但你需要常常擴充處理方式,Visitor 是強項。
一句話總結:把「動作」外包給訪問者,讓元素只負責被訪問。
何時(不)該使用 Visitor?
適用情境
元素型別穩定,但操作經常新增:例如 AST(語法樹)、GUI 組件樹、檔案系統樹、表單定義樹。
需要跨節點型別的一致操作:如「匯出為 JSON/Markdown/SQL/SVG」、「統計、驗證、多語系轉換」、「權限稽核」等。
想消除到處 if/else 或 switch(type) 的型別分支。
不建議情境
元素型別變動頻繁(每天都長出新型別):每次多一種元素,就要改所有 Visitor,很傷維護。
操作只會有一兩種且很少變動:直接寫在元素內或用 Strategy/函式組合更簡單。
資料結構極扁平、動作很單純:Visitor 反而多此一舉。
角色與結構總覽
Element(元素):被訪問的對象。每個元素實作 accept(visitor),在裡面呼叫 visitor.visitXxx(this)。
ConcreteElement(具體元素):不同型別的元素(如 Circle、Rectangle、AddNode、NumberLiteral)。
Visitor(訪問者):定義一組對應各元素型別的 visitXxx。
ConcreteVisitor(具體訪問者):如 AreaCalculatorVisitor、SvgRenderVisitor、EvalVisitor。
ObjectStructure(物件結構):持有一組元素,提供遍歷並逐個 accept 的機制(可由樹或陣列承擔)。
雙重分派核心:
element.accept(visitor) → visitor.visitXxx(element)
由元素決定呼叫訪問者哪個具名方法,避免 if/else 做型別判斷。
基礎版實作(圖形例子)
以下用圖形(圓形、矩形)示範兩個不同訪問者:計算面積與輸出 SVG。重點在看清楚 accept() 與 visitXxx() 的互動。
// ===== Element 介面(以慣例示意) =====
class Shape {
accept(visitor) { throw new Error('Must implement accept'); }
}
// ===== ConcreteElements =====
class Circle extends Shape {
constructor(r) { super(); this.r = r; }
accept(visitor) { return visitor.visitCircle(this); }
}
class Rectangle extends Shape {
constructor(w, h) { super(); this.w = w; this.h = h; }
accept(visitor) { return visitor.visitRectangle(this); }
}
// ===== Visitor 介面(以慣例示意) =====
class ShapeVisitor {
visitCircle(circle) { throw new Error('Must implement visitCircle'); }
visitRectangle(rect) { throw new Error('Must implement visitRectangle'); }
}
// ===== ConcreteVisitors =====
class AreaVisitor extends ShapeVisitor {
visitCircle(c) { return Math.PI * c.r * c.r; }
visitRectangle(r) { return r.w * r.h; }
}
class SvgVisitor extends ShapeVisitor {
visitCircle(c) { return `<circle cx="0" cy="0" r="${c.r}"></circle>`; }
visitRectangle(r) { return `<rect x="0" y="0" width="${r.w}" height="${r.h}"></rect>`; }
}
// ===== 使用 =====
const shapes = [new Circle(10), new Rectangle(20, 5)];
const areaV = new AreaVisitor();
const svgV = new SvgVisitor();
const areas = shapes.map(s => s.accept(areaV));
const svgs = shapes.map(s => s.accept(svgV));
console.log(areas); // [314.159..., 100]
console.log(svgs); // ['<circle ...>', '<rect ...>']
要點:
新增操作:加新訪問者(例如 ExportToJSONVisitor)即可,原先 Circle/Rectangle 無需更動。
新增元素型別(例如 Triangle)則要調整所有訪問者:這是 Visitor 的交換條件(trade-off)。
進階版實作:AST(語法樹)的多種處理
對於 AST 這類結構,Visitor 幾乎是標配。下面以簡單「算術運算式」樹為例,展示三個訪問者:
EvalVisitor:計算值
PrintVisitor:輸出中序字串(加上括號)
SimplifyVisitor:做簡單代數化簡(例:x+0 → x、1*x → x)
1. AST 節點定義
// 抽象節點
class Expr {
accept(v) { throw new Error('Must implement accept'); }
}
// 具體節點
class Num extends Expr {
constructor(value) { super(); this.value = value; }
accept(v) { return v.visitNum(this); }
}
class Add extends Expr {
constructor(left, right) { super(); this.left = left; this.right = right; }
accept(v) { return v.visitAdd(this); }
}
class Mul extends Expr {
constructor(left, right) { super(); this.left = left; this.right = right; }
accept(v) { return v.visitMul(this); }
}
2. 訪問者介面與具體訪問者
class ExprVisitor {
visitNum(n) { throw new Error('Must implement'); }
visitAdd(a) { throw new Error('Must implement'); }
visitMul(m) { throw new Error('Must implement'); }
}
// 1) 求值
class EvalVisitor extends ExprVisitor {
visitNum(n) { return n.value; }
visitAdd(a) { return a.left.accept(this) + a.right.accept(this); }
visitMul(m) { return a = m.left.accept(this), b = m.right.accept(this), a * b; }
}
// 2) 輸出 (x + y) / (x * y)
class PrintVisitor extends ExprVisitor {
visitNum(n) { return String(n.value); }
visitAdd(a) { return `(${a.left.accept(this)} + ${a.right.accept(this)})`; }
visitMul(m) { return `(${m.left.accept(this)} * ${m.right.accept(this)})`; }
}
// 3) 簡化:回傳新樹(不就地修改,避免副作用)
class SimplifyVisitor extends ExprVisitor {
visitNum(n) { return new Num(n.value); }
visitAdd(a) {
const L = a.left.accept(this);
const R = a.right.accept(this);
// 0 + x => x, x + 0 => x
if (L instanceof Num && L.value === 0) return R;
if (R instanceof Num && R.value === 0) return L;
return new Add(L, R);
}
visitMul(m) {
const L = m.left.accept(this);
const R = m.right.accept(this);
// 0 * x => 0, 1 * x => x, x * 1 => x
if ((L instanceof Num && L.value === 0) || (R instanceof Num && R.value === 0)) return new Num(0);
if (L instanceof Num && L.value === 1) return R;
if (R instanceof Num && R.value === 1) return L;
return new Mul(L, R);
}
}
3. 使用示例
// 表示 (2 + 0) * (3 * 1)
const expr = new Mul(
new Add(new Num(2), new Num(0)),
new Mul(new Num(3), new Num(1))
);
const evalV = new EvalVisitor();
const printV = new PrintVisitor();
const simpV = new SimplifyVisitor();
console.log('原始:', expr.accept(printV)); // ((2 + 0) * (3 * 1))
const simplified = expr.accept(simpV);
console.log('簡化:', simplified.accept(printV)); // (2 * 3)
console.log('值:', simplified.accept(evalV)); // 6
重點:
同一棵樹,可以套用多個訪問者得到不同結果(求值/轉字串/簡化)。
擴充操作時,只新增訪問者;不動 AST 節點定義。
更貼近實務:訂單報表的擴充計算
假設你有一個訂單(Order)樹,內含不同的行項(LineItem)與優惠券(Coupon)。你可能想做:
小計/稅額/總額計算
匯率轉換(USD/TWD/JPY)
輸出報表(CSV/HTML/PDF)
實作各國稅制或行銷規則
當結構固定(Order → LineItem/Coupon),而需要常加新報表或計算方式時,Visitor 能讓你用「新增一個訪問者」就完成新能力。
1. 結構與訪問者
class OrderElement {
accept(v) { throw new Error('Must implement'); }
}
class Order extends OrderElement {
constructor(lines = [], coupons = []) { super(); this.lines = lines; this.coupons = coupons; }
accept(v) { return v.visitOrder(this); }
}
class LineItem extends OrderElement {
constructor(name, unitPrice, qty) { super(); this.name = name; this.unitPrice = unitPrice; this.qty = qty; }
accept(v) { return v.visitLineItem(this); }
}
class Coupon extends OrderElement {
constructor(code, type, value) { super(); this.code = code; this.type = type; this.value = value; } // type: 'percent' | 'fixed'
accept(v) { return v.visitCoupon(this); }
}
class OrderVisitor {
visitOrder(o) { throw new Error('Must implement'); }
visitLineItem(l) { throw new Error('Must implement'); }
visitCoupon(c) { throw new Error('Must implement'); }
}
// 計算小計/折扣/總額(不考慮稅收,示範用)
class SummaryVisitor extends OrderVisitor {
constructor() { super(); this.subtotal = 0; this.discount = 0; }
visitOrder(o) {
this.subtotal = o.lines.reduce((acc, line) => acc + line.accept(this), 0);
this.discount = o.coupons.reduce((acc, c) => acc + c.accept(this), 0);
return { subtotal: this.subtotal, discount: this.discount, total: Math.max(0, this.subtotal - this.discount) };
}
visitLineItem(l) { return l.unitPrice * l.qty; }
visitCoupon(c) {
if (c.type === 'percent') return this.subtotal * (c.value / 100);
if (c.type === 'fixed') return c.value;
return 0;
}
}
// 匯率轉換(示範固定匯率)
class CurrencyVisitor extends OrderVisitor {
constructor(rate, symbol) { super(); this.rate = rate; this.symbol = symbol; }
visitOrder(o) {
const sumV = new SummaryVisitor();
const { subtotal, discount, total } = o.accept(sumV);
return {
subtotal: this.symbol + (subtotal * this.rate).toFixed(2),
discount: this.symbol + (discount * this.rate).toFixed(2),
total: this.symbol + (total * this.rate).toFixed(2),
};
}
visitLineItem() { /* 不使用,借 SummaryVisitor 即可 */ }
visitCoupon() { /* 同上 */ }
}
2. 使用
const order = new Order(
[ new LineItem('Keyboard', 50, 2), new LineItem('Mouse', 20, 1) ],
[ new Coupon('WELCOME10', 'percent', 10), new Coupon('TAKE5', 'fixed', 5) ]
);
const summaryV = new SummaryVisitor();
console.log('USD:', order.accept(summaryV));
// 例如:{ subtotal: 120, discount: 17, total: 103 }
const toTwdV = new CurrencyVisitor(32.5, 'NT$');
console.log('TWD:', order.accept(toTwdV));
// 例如:{ subtotal: 'NT$3900.00', discount: 'NT$552.50', total: 'NT$3347.50' }
重點:
新增「不同的報表或計算」→ 新增不同訪問者。
結構穩定時,擴充效果最明顯。
實作細節與最佳實務
1. 命名與對應表
建議固定命名規則:visit + 類名(visitCircle、visitAdd)。
若元素型別很多,可做一層訪問表(visitor table)集中管理:
// 用 Map 讓某些場合可以簡化 method dispatch(函式式風格)
const visitorTable = new Map([
['Circle', (self, el) => Math.PI * el.r * el.r],
['Rectangle', (self, el) => el.w * el.h],
]);
class TabledAreaVisitor {
visit(el) {
const handler = visitorTable.get(el.constructor.name);
if (!handler) throw new Error(`No handler for ${el.constructor.name}`);
return handler(this, el);
}
}
提醒:這種「表」的寫法接近函式分派,雖不完全遵循傳統 Visitor 介面,但在 JS 實務中非常常見且好維護。
2. 保持「不可變」輸出
像 SimplifyVisitor 這種會「產出新結構」的訪問者,不要原地修改原樹,避免副作用引發難追蹤的臭蟲。
3. 控制副作用
Visitor 需要內部狀態(例如統計計數)時,請清楚初始化與生命週期,避免跨次呼叫污染結果。
4. 建立「空訪問者」基底
提供預設不做事的方法,讓子類只覆寫關心的節點,減少樣板碼:
class BaseVisitor {
visitCircle() {}
visitRectangle() {}
// ...
}
5. 例外策略與未知節點
保留「未知節點」的防呆:一旦訪問者沒有對應處理,快速拋錯,比默默忽略安全。
6. 與 Type 標註共舞(可選)
在純 JS 中可借助 JSDoc 提示型別;若你的專案使用 TypeScript,Visitor 的介面契約會更紮實,但本文維持 JS 範疇。
與其他模式比較
| 模式 | 適用重點 | 與 Visitor 差異 |
|---|---|---|
| Strategy | 同一資料,一次選一種策略執行 | Strategy 聚焦「可替換演算法」,但不會巡訪結構;Visitor 針對多型別元素逐一處理 |
| Decorator | 動態包裝行為、疊加能力 | Decorator 改變單個物件的能力;Visitor 對整個結構新增「一種操作」 |
| Chain of Responsibility | 事件沿著鏈傳遞,找到能處理者 | CoR 著重逐步交棒;Visitor 是逐節點指名處理 |
效能與測試思路
效能
分派成本:accept → visitXxx 的間接呼叫成本在 JS 中通常很小,瓶頸多半在遍歷量與訪問者邏輯。
避免重建:像 SimplifyVisitor 若能偵測「子節點無變化」時重用原節點,可降低 GC 壓力。
分階段訪問:對大型結構分批處理(管線化),減少峰值記憶體。
測試
契約測試:為每種元素建立共同測試,確保每個訪問者對該元素都有回應。
快照/黃金檔:對輸出(例如 Print/SVG/報表)做快照測試,輕鬆捕捉不小心的格式回歸。
隨機產生樹(Fuzz):對 AST 這類結構可用 random generator 產樹,測耐久性與例外路徑。
常見錯誤與雷點
1. 把分支寫回元素內
初衷是把「操作」外移;若你又在元素裡 if (visitor instanceof X),等於繞回原點。
2. 未實作 accept() 或命名不一致
accept(visitor) 必須在每個元素存在,且元素要呼叫正確的 visitXxx(this)。
3. 新增元素卻忘了更新所有訪問者
這是 Visitor 的成本。解法:快速失敗(預設拋錯)、加測試保護。
4. 訪問者內混雜大量狀態,沒有歸零
同一訪問者被重複使用時,殘留狀態會污染結果。要嘛每次新建,要嘛在入口重置。
5. 在訪問者中大幅修改結構(就地改)
非不得已別這樣做;優先選擇「回傳新結構」。若一定要改,請文件化並小心循環參照。
6. 把 Visitor 當成萬能路徑
若操作很少、元素變動頻繁,Visitor 並不划算;直接方法或 Strategy 會更乾淨。
7. 以字串 type + switch 到處散落
Visitor 目標之一是移除分支地獄。若仍保留大 switch,訊號是模式尚未落地。
8. 沒有處理未知節點/沒有拋錯
靜默失敗會讓除錯變苦難。務必對未知型別拋出明確錯誤。
9. 訪問者變成「超級上帝類」
把所有操作都塞一個訪問者,將來很難維護。請一種工作一個訪問者,單一職責。
10. 跨訪問者相互耦合
訪問者之間應保持鬆耦合。必要時可抽共同工具層,避免互相 new 彼此。
問題集
Q1:JS 沒有介面、會不會很彆扭?
A:不會。透過一致的命名慣例(accept/visitXxx)與測試保護,JS 實作相當平順。必要時用 JSDoc 或轉 TS 強化契約。
Q2:我應該一開始就導入 Visitor 嗎?
A:看需求曲線。若你預期「操作會一直增加、結構相對穩定」,Visitor 能明顯降低長期維護成本;反之就別急著上。
Q3:與函式遞迴有何不同?
A:函式遞迴常見於 AST,但當操作種類變多,Visitor 讓「新增一種處理」變得局部且可預期,比到處加條件更乾淨。
延伸閱讀推薦:
javaScript設計模式 : Factory Method (工廠方法)
javaScript設計模式 : Abstract Factory(抽象工廠)
javaScript設計模式 : Builder(建造者模式)
javaScript設計模式 : Prototype(原型)
javaScript設計模式 : Singleton (單例模式)
javaScript設計模式 : Adapter(轉接器模式)
javaScript設計模式 : Bridge( 橋接模式 )
javaScript設計模式 : Composite(組合模式)
javaScript設計模式 : Decorator(裝飾者)
javaScript設計模式 : Flyweight(享元模式)
javaScript設計模式 : Chain of Responsibility(責任鏈)
javaScript設計模式 : Command Pattern(命令模式)
javaScript設計模式 : Interpreter(直譯器)
javaScript設計模式 : Iterator(迭代器)
javaScript設計模式 : Mediator(仲裁者)
javaScript設計模式 : Observer( 觀察者 )
