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

 


在專案裡,我們常需要把「人能讀的條件」變成「程式能跑的邏輯」:像是價錢規則、行銷折扣、清單篩選、告警條件。

直接用 eval 當然快,但風險也最大。Interpreter(直譯器)模式的點子很單純:先定義一套小語法,解析成 AST,再用安全、可控的方式去執行。這篇用 JavaScript 從零實作一個迷你直譯器,會走過 tokenize、parse、evaluate 的整套流程,支援數字、字串、布林、比較、邏輯運算與簡單函式。

你會看到如何把「price < 1000 && contains(name, "Pro")」這類規則,變成好維護、好測的程式碼;也會聊常見坑點:運算子優先序、一元負號、浮點誤差、短路求值、安全白名單與資源限制。讀完後,你能把規則抽離程式,讓改需求不必改一堆 if-else。希望本篇文章能夠幫助到需要的您。


目錄

{tocify} $title={目錄} 


 為什麼需要 Interpreter 模式?

當你想讓非工程背景的人用更自然的語句去下指令(像「price < 1000 AND name contains "Pro"」),或是在程式內嵌一段可配置的邏輯(例如計價規則、行銷條件、警示規則),就會碰到「如何讓文字規則→程式行為」的問題。兩個常見選項:

eval:危險、不可控(安全性與維護性差)。

Interpreter 模式:用明確的語法規則與抽象語法樹(AST)定義可被直譯的語言,再以程式安全執行。


Interpreter 模式的核心價值在於:

可控的語法與行為(白名單式功能,安全)

可擴充(新增運算子、函式、關鍵字不破壞既有程式)

可測試(AST 與直譯分離,單元測試容易)

可優化(有機會做快取、靜態分析、限制資源)


典型結構(Participants)

Context:執行時所需上下文(變數表、函式表、執行限制…)

AbstractExpression:抽象表達式介面,約定 interpret(context) 或 evaluate(context)

TerminalExpression:終結符(字面量、變數)

NonterminalExpression:非終結符(加減乘除、比較、布林、函式呼叫等)

Client:把原始字串丟進 Lexer/Parser,得到 AST,再呼叫 evaluate

在 JS 中未必要用 class 階層,也可用具名物件 + switch/visitor 風格,方便日後擴充。


我們要做什麼?(需求與語法)

先定義一個迷你表達式語言,支援:

字面量:數字、字串(雙引號)、布林(true/false)

變數:x、price、user_name

運算子:+ - * / % ^、比較 == != < <= > >=、布林 && || !

括號:( ... )

函式:max(1, 2), len("abc"), contains("MacBook Pro", "Pro")


範例

1 + 2 * 3                  // 7
price < 1000 && contains(name, "Pro")
max(10, 5) ^ 2 + len("ab") // 102 + 2 = 104


實作步驟總覽

Lexer(斷詞/標記化):把原始字串切成 Token 串列

Parser(語法剖析):依優先序產生 AST(本文採 Pratt/precedence 風格)

AST 節點:以物件表示節點 { type, ... }

Interpreter/Evaluator:走訪 AST,依 Context 計算結果

擴充:新增內建函式與自訂運算子

安全與限制:深度限制、白名單、執行超時防護

測試:單元、隨機/fuzz、快照、等價性測試


Lexer:把字串變成 Token

重點

支援數字(含小數)、識別字(變數/函式名)、字串("...",處理跳脫字元)、操作符、括號、逗號

忽略空白與換行

紀錄 pos 方便錯誤訊息

// ===== Lexer =====
function tokenize(input) {
  const tokens = [];
  let i = 0;

  const isDigit = c => c >= '0' && c <= '9';
  const isAlpha = c => /[A-Za-z_]/.test(c);
  const isAlnum = c => /[A-Za-z0-9_]/.test(c);

  const push = (type, value) => tokens.push({ type, value, pos: i });

  while (i < input.length) {
    const ch = input[i];

    // 空白
    if (/\s/.test(ch)) { i++; continue; }

    // 數字(含小數)
    if (isDigit(ch) || (ch === '.' && isDigit(input[i+1]))) {
      let start = i;
      if (ch === '.') i++; // . 開頭小數
      while (isDigit(input[i])) i++;
      if (input[i] === '.') {
        i++;
        while (isDigit(input[i])) i++;
      }
      const numStr = input.slice(start, i);
      push('NUMBER', parseFloat(numStr));
      continue;
    }

    // 字串(雙引號)
    if (ch === '"') {
      i++; // skip "
      let str = '';
      while (i < input.length && input[i] !== '"') {
        if (input[i] === '\\') {
          const next = input[i+1];
          if (next === '"' || next === '\\' || next === 'n' || next === 't' || next === 'r') {
            str += next === 'n' ? '\n' : next === 't' ? '\t' : next === 'r' ? '\r' : next;
            i += 2;
          } else {
            // 未知跳脫,當作原樣
            str += '\\';
            i++;
          }
        } else {
          str += input[i++];
        }
      }
      if (input[i] !== '"') throw new SyntaxError('Unterminated string literal');
      i++; // closing "
      push('STRING', str);
      continue;
    }

    // 識別字 / 保留字
    if (isAlpha(ch)) {
      const start = i++;
      while (i < input.length && isAlnum(input[i])) i++;
      const word = input.slice(start, i);
      if (word === 'true' || word === 'false') {
        push('BOOLEAN', word === 'true');
      } else {
        push('IDENT', word);
      }
      continue;
    }

    // 雙字元操作符
    const two = input.slice(i, i+2);
    if (['==', '!=', '<=', '>=', '&&', '||'].includes(two)) {
      push('OP', two);
      i += 2;
      continue;
    }

    // 單字元操作符與符號
    const singleMap = {
      '+':'OP','-':'OP','*':'OP','/':'OP','%':'OP','^':'OP',
      '<':'OP','>':'OP','!':'OP',
      '(':'LPAREN',')':'RPAREN',',':'COMMA'
    };
    if (singleMap[ch]) {
      push(singleMap[ch], ch);
      i++;
      continue;
    }

    throw new SyntaxError(`Unexpected character '${ch}' at ${i}`);
  }

  tokens.push({ type: 'EOF', value: null, pos: i });
  return tokens;
}


Parser(Pratt/優先序解析):把 Token 變 AST

策略:以運算子優先序表控制「先乘除後加減」、布林與比較等。

右結合(如指數 ^):RHS 用 prec 而非 prec+1

函式呼叫:IDENT 後接 ( 視為 CallExpression

// ===== Parser =====
function parse(tokens) {
  let i = 0;
  const peek = () => tokens[i];
  const consume = () => tokens[i++];
  const expect = (type, value) => {
    const t = consume();
    if (t.type !== type || (value !== undefined && t.value !== value)) {
      throw new SyntaxError(`Expected ${type} ${value ?? ''} but got ${t.type} ${t.value}`);
    }
    return t;
  };

  const PRECEDENCE = {
    '||': 1,
    '&&': 2,
    '==': 3, '!=': 3,
    '<': 4, '<=': 4, '>': 4, '>=': 4,
    '+': 5, '-': 5,
    '*': 6, '/': 6, '%': 6,
    '^': 7
  };
  const RIGHT_ASSOC = new Set(['^']);

  function parsePrimary() {
    const t = peek();

    if (t.type === 'NUMBER') { consume(); return { type: 'NumberLiteral', value: t.value }; }
    if (t.type === 'STRING') { consume(); return { type: 'StringLiteral', value: t.value }; }
    if (t.type === 'BOOLEAN') { consume(); return { type: 'BooleanLiteral', value: t.value }; }

    if (t.type === 'IDENT') {
      consume();
      // 函式呼叫 or 變數
      if (peek().type === 'LPAREN') {
        consume(); // (
        const args = [];
        if (peek().type !== 'RPAREN') {
          while (true) {
            args.push(parseExpression(0));
            if (peek().type === 'COMMA') { consume(); continue; }
            break;
          }
        }
        expect('RPAREN');
        return { type: 'CallExpression', callee: t.value, args };
      } else {
        return { type: 'Identifier', name: t.value };
      }
    }

    if (t.type === 'LPAREN') {
      consume();
      const expr = parseExpression(0);
      expect('RPAREN');
      return expr;
    }

    // 前置一元:+ - !
    if (t.type === 'OP' && ['+', '-', '!'].includes(t.value)) {
      consume();
      const argument = parseExpression(7); // 高優先序處理
      return { type: 'UnaryExpression', operator: t.value, argument };
    }

    throw new SyntaxError(`Unexpected token ${t.type} '${t.value}' at ${t.pos}`);
  }

  function parseExpression(minPrec) {
    let left = parsePrimary();

    while (true) {
      const t = peek();
      if (t.type !== 'OP') break;

      const op = t.value;
      const prec = PRECEDENCE[op];
      if (prec === undefined || prec < minPrec) break;

      consume(); // eat operator

      const nextMin = RIGHT_ASSOC.has(op) ? prec : prec + 1;
      const right = parseExpression(nextMin);
      left = { type: 'BinaryExpression', operator: op, left, right };
    }

    return left;
  }

  const ast = parseExpression(0);
  if (peek().type !== 'EOF') {
    throw new SyntaxError(`Unexpected token after expression: ${peek().type}`);
  }
  return ast;
}


Evaluator(直譯器):走訪 AST 計算結果

Context 以白名單方式提供變數與函式。

        數學:+ - * / % ^

        比較/布林:== != < <= > >= && || !

        內建函式:max, min, abs, sqrt, pow, len, contains, startsWith, endsWith

// ===== Evaluator / Interpreter =====
class EvalContext {
  constructor(variables = {}, functions = {}) {
    this.vars = Object.create(null);
    Object.assign(this.vars, variables);

    const builtins = {
      max: (...xs) => Math.max(...xs),
      min: (...xs) => Math.min(...xs),
      abs: (x) => Math.abs(x),
      sqrt: (x) => Math.sqrt(x),
      pow: (a, b) => Math.pow(a, b),
      len: (s) => (typeof s === 'string' ? [...s].length : Array.isArray(s) ? s.length : 0),
      contains: (s, sub) => String(s).includes(String(sub)),
      startsWith: (s, prefix) => String(s).startsWith(String(prefix)),
      endsWith: (s, suffix) => String(s).endsWith(String(suffix)),
    };
    this.funcs = Object.create(null);
    Object.assign(this.funcs, builtins, functions);
  }

  getVar(name) {
    if (Object.prototype.hasOwnProperty.call(this.vars, name)) return this.vars[name];
    throw new ReferenceError(`Undefined variable: ${name}`);
  }

  getFunc(name) {
    if (Object.prototype.hasOwnProperty.call(this.funcs, name)) return this.funcs[name];
    throw new ReferenceError(`Undefined function: ${name}()`);
  }
}

function evaluate(ast, ctx) {
  switch (ast.type) {
    case 'NumberLiteral': return ast.value;
    case 'StringLiteral': return ast.value;
    case 'BooleanLiteral': return ast.value;

    case 'Identifier':
      return ctx.getVar(ast.name);

    case 'UnaryExpression': {
      const v = evaluate(ast.argument, ctx);
      switch (ast.operator) {
        case '+': return +v;
        case '-': return -v;
        case '!': return !v;
        default: throw new Error(`Unsupported unary operator ${ast.operator}`);
      }
    }

    case 'BinaryExpression': {
      const l = evaluate(ast.left, ctx);
      const r = evaluate(ast.right, ctx);
      switch (ast.operator) {
        case '+': return l + r;
        case '-': return l - r;
        case '*': return l * r;
        case '/': return l / r;
        case '%': return l % r;
        case '^': return Math.pow(l, r);

        case '==': return l === r;
        case '!=': return l !== r;
        case '<': return l < r;
        case '<=': return l <= r;
        case '>': return l > r;
        case '>=': return l >= r;

        case '&&': return Boolean(l) && Boolean(r);
        case '||': return Boolean(l) || Boolean(r);
        default: throw new Error(`Unsupported operator ${ast.operator}`);
      }
    }

    case 'CallExpression': {
      const fn = ctx.getFunc(ast.callee);
      const args = ast.args.map(arg => evaluate(arg, ctx));
      return fn(...args);
    }

    default:
      throw new Error(`Unknown AST node type: ${ast.type}`);
  }
}


使用示例

const src = 'price < 1000 && contains(name, "Pro")';
const tokens = tokenize(src);
const ast = parse(tokens);
const ctx = new EvalContext({ price: 899, name: 'MacBook Pro 14"' });
console.log(evaluate(ast, ctx)); // true


擴充:新增運算子與函式)

新運算子:只需更新 PRECEDENCE、RIGHT_ASSOC(若為右結合),並在 Evaluator 增加分支

新函式:放進 EvalContext.funcs(白名單)

例:新增 between(x, a, b)

const ctx = new EvalContext(
  { price: 512 },
  { between: (x, a, b) => x >= a && x <= b }
);
const ast = parse(tokenize('between(price, 100, 1000)'));
console.log(evaluate(ast, ctx)); // true


實戰 2:商品篩選 DSL(小而美)

在商務場景常見規則:「分類是 Laptop、價格 < 30000、名稱含 Pro」。

我們設計一個非常可讀的 DSL:

category = "Laptop" AND price < 30000 AND name contains "Pro"


1.    簡化做法

保持同一套 Lexer/Parser(因已支援 IDENT、字串、比較、AND/OR 可用 &&/|| 取代或額外關鍵字)

也可用關鍵字別名:在 Lexer 把 AND/OR/NOT 轉成對應操作符。

Keyword 支援(可選)

// 在 Lexer 識別字處:
const upper = word.toUpperCase();
if (upper === 'AND') { push('OP', '&&'); continue; }
if (upper === 'OR') { push('OP', '||'); continue; }
if (upper === 'NOT') { push('OP', '!'); continue; }
// 其他字再當 IDENT


自訂斷詞:contains/startsWith/endsWith 已在內建函式,不用額外改 Parser。

執行:把每個商品物件作為變數來源(Context.vars),逐筆 evaluate。

const products = [
  { name: 'MacBook Pro 14"', category: 'Laptop', price: 59900 },
  { name: 'iPad Pro 11"', category: 'Tablet', price: 27900 },
  { name: 'Surface Laptop 7', category: 'Laptop', price: 45900 },
];

const rule = 'category = "Laptop" AND price < 50000 AND contains(name, "Pro")'
  .replace(/\bAND\b/gi, '&&')
  .replace(/\bOR\b/gi, '||')
  .replace(/\bNOT\b/gi, '!')     // 簡單替換;或在 Lexer 層做更穩健處理
  .replace(/\s=\s/g, ' == ');    // 讓 `=` 變 `==`(注意:僅示範,正式應在 Lexer 做)

const ast2 = parse(tokenize(rule));
const result = products.filter(p => evaluate(ast2, new EvalContext(p)));
console.log(result.map(p => p.name)); // ["MacBook Pro 14\""]


生產環境建議:不要用字串 replace,直接在 Lexer 層做「保留字」與「=→==」邏輯,避免誤傷字串內容。


安全與資源限制(必做)

1.    拒絕 eval / Function 動態執行:本設計全程不靠 eval。

2.    白名單 Context:只有在 Context.vars/funcs 內的東西可以被存取;不得讀取全域。

3.    AST 深度/節點數量限制:防止惡意輸入造成 OOM 或遞迴過深。

function assertAstLimit(node, { maxDepth = 128, maxNodes = 5000 } = {}) {
  let nodes = 0;
  function walk(n, d) {
    if (d > maxDepth) throw new Error('AST too deep');
    nodes++; if (nodes > maxNodes) throw new Error('AST too large');
    switch (n.type) {
      case 'BinaryExpression': walk(n.left, d+1); walk(n.right, d+1); break;
      case 'UnaryExpression': walk(n.argument, d+1); break;
      case 'CallExpression': n.args.forEach(a => walk(a, d+1)); break;
      default: /* literal/identifier */ break;
    }
  }
  walk(node, 0);
}


4.    執行時間限制:在呼叫點上做超時計時(如 Web Worker、AbortSignal、或在伺服器端以 request 超時控制)。

5.    輸入長度限制與預先過濾:避免極長、無效字串。

6.    錯誤訊息淨化:不要洩漏內部細節(stack/路徑),友善回報位置與原因。


效能優化建議

AST 快取:相同字串的規則只 parse 一次,後續直接 evaluate。

常量摺疊(Constant Folding):在 AST 階段把 1+2*3 先化簡成 7。

短路求值:已於 && / || 以 Boolean(l) && Boolean(r) 的語意,但可改為真正短路(先判 l,決定是否求 r),降低成本。

避免深遞迴:Pratt 解析器一般安全,但對超深巢狀可改為迭代或限制深度。

函式純度:內建函式以純函式為佳,便於快取與測試。


錯誤處理與回報(實作範式)

function formatError(src, e) {
  if (!(e instanceof SyntaxError) && !(e instanceof ReferenceError) && !(e instanceof Error)) return String(e);
  if (e.pos == null) return e.message;
  const pointer = ' '.repeat(e.pos) + '^';
  return `${e.message}\n${src}\n${pointer}`;
}

// 例:在 tokenize/parse 失敗時把 pos 放進錯誤物件會更友善


實務上可在 throw 前把 pos 塞進錯誤物件,前端再漂亮地標示。


延伸閱讀推薦:

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設計模式 : Iterator(迭代器)

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

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

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

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

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

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

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

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