在專案裡,我們常需要把「人能讀的條件」變成「程式能跑的邏輯」:像是價錢規則、行銷折扣、清單篩選、告警條件。
直接用 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設計模式 : Flyweight(享元模式)
javaScript設計模式 : Chain of Responsibility(責任鏈)
javaScript設計模式 : Command Pattern(命令模式)
javaScript設計模式 : Iterator(迭代器)
javaScript設計模式 : Mediator(仲裁者)
javaScript設計模式 : Observer( 觀察者 )
javaScript設計模式 : Strategy(策略模式)
