javaScript設計模式 : Visitor(訪問者模式)

 


想像你有一棵資料樹:節點長相固定,但你老闆今天要匯出 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設計模式 : Facade(外觀模式)

javaScript設計模式 : Flyweight(享元模式)

javaScript設計模式 : Proxy(代理模式)

javaScript設計模式 : Chain of Responsibility(責任鏈)

javaScript設計模式 : Command Pattern(命令模式)

javaScript設計模式 : Interpreter(直譯器)

javaScript設計模式 : Iterator(迭代器)

javaScript設計模式 : Mediator(仲裁者)

javaScript設計模式 : Memento(備忘錄)

javaScript設計模式 : Observer( 觀察者 )

javaScript設計模式 : State(狀態模式)

javaScript設計模式 : Strategy(策略模式)

javaScript設計模式 : Template Method (模板方法)


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