Try   HackMD

簡易計算欄位

最近在產品上實作計算欄位功能,和設計師討論後,決定先讓使用者直接打語法,而不是全部以 GUI 操作。於是我們得設計一個小小的程式語言,處理像 {name} + " is awesome"{total} - 10 這種式子。

語法(syntax)

從這些式子出發,我們可以設想一種簡單的 syntax nodes :

export const NodeType = { NUMBER: 'number', STRING: 'string', FIELD: 'field', BINARY: 'binary', ERROR: 'error', }; export const Num = (value) => ({ type: NodeType.NUMBER, value: value, }); export const Str = (value) => ({ type: NodeType.STRING, value: value, }); export const Field = (name) => ({ type: NodeType.FIELD, name: name, }); export const Binary = (name, left, right) => ({ type: NodeType.BINARY, name: name, left: left, right: right, }); export const Err = (message) => ({ type: NodeType.ERROR, message: message, });

於是 {name} + " is awesome" 可以寫成:

const expr = Binary('+', Field('name'), Str(' is awesome'));

{total} - 10 可以寫成:

const expr = Binary('-', Field('total'), Num(10));

環境(context)

我們要面對的環境是一個類似 spreadsheet 的系統,在簡化後,可以這樣描述欄位與式子的關係:

const ctx = { 'name': Str('Matt'), 'title': Binary('+', Field('name'), Str(' is awesome')), 'total': Num(42), 'discount': Binary('-', Field('total'), Num(10)), };

而希望經過計算後,變成:

const ctx = { 'name': Str('Matt'), 'title': Str('Matt is awesome')), 'total': Num(42), 'discount': Num(32), };

計算(evaluation)

為了達成這件事,首先要把環境變成充滿著「算一下才知道」的值,而不是一個一個語法樹:

const ctx = { 'name': (ctx) => Str('Matt'), 'title': (ctx) => {/* 一些程式 */}, 'total': (ctx) => Num(42), 'discount': (ctx) => {/* 另一些程式 */}, };

而我們的計算程式在做的事情,就是不斷地看看現在要算的欄位,需不需要從環境 ctx 中取得其他欄位的資訊,再把結果求出來:

export function run(exprTable) { // 把欄位變成好計算的樣子 let ctx = contextMap(table, construct); // 實際計算它 ctx = contextLoeb(ctx); // 把計算結果取出來 return contextMap(ctx, x => x()); } function contextMap(ctx, f) { let result = {}; for (let key in ctx) { result[key] = f(ctx[key]); } return result; } // 神秘的 löb ! function contextLoeb(ctx) { let results; const go = (f) => () => f(results); results = contextMap(ctx, go); return results; }

我們先不關心這個神秘的 löb 函數是什麼,有興趣的人可以讀看看 Löb and möb in JavaScript ,該文解釋得比較詳細。現在我們只要知道它會幫我們遞迴計算出不同欄位的值就好了。

我們關心的是怎麼做一個正確的 construct 函數,來處理不同的 syntax node :

function construct(expr) { switch (expr.type) { // 如果是個數字,那它就繼續當數字,是計算的終點,通常稱為「值」(value) case NodeType.NUMBER: return () => expr; // 如果是個字串,就繼續當字串,也是計算的終點 case NodeType.STRING: return () => expr; // 錯誤也是一種值 case NodeType.ERROR: return () => expr; case NodeType.FIELD: return (ctx) => { // 欄位參考不是計算的終點,這種不是終點的東西,通常稱為「表達式」(term 或 expression) // 可以把前面的 value 看成一種特殊的 expression // 如果是個欄位參考,那就從 context 中取得「可以計算出這個欄位結果的函數」,有點類似 redux-thunk 中的 thunk -- 你要跑一下才能知道最後的結果 const thunk = ctx[expr.name]; // 要是這東西無法求值,就報錯 if (typeof thunk !== 'function') { return Err(`field ${expr.name} not found`); } // 傳回計算結果 return thunk(ctx); } case NodeType.BINARY: return (ctx) => { // 二元運算子 // 先到一個預先準備好的 function table 查到對應的實作 const func = functionTable[expr.name]; if (typeof func !== 'function') { return Err(`operator ${expr.name} not found`); } // 對運算子左邊的東西求值 const left = construct(expr.left)(ctx); // 對運算子右邊的東西求值 const right = construct(expr.right)(ctx); /* 省略一些錯誤處理用的程式碼 */ // 算算看結果 return func.call(undefined, left, right); } default: return () => Err('unknown expression'); } } const functionTable = { '+': function add(left, right) { if (left.type === NodeType.NUMBER && right.type === NodeType.NUMBER) { return Num(left.value + right.value); } if (left.type === NodeType.STRING && right.type === NodeType.STRING) { return Str(left.value + right.value); } return Err('can\'t add things other than numbers or strings'); }, '-': function sub(left, right) { if (left.type === NodeType.NUMBER && right.type === NodeType.NUMBER) { return Num(left.value - right.value); } return Err('can\'t subtract things other than numbers'); }, };

可以看出來,改變 addsub 的實作,甚至可以支援字串與數字相加,或是兩個字串相減這種不直覺但是有時候很有用的功能。

小結

這就是目前 FormEditor 計算欄位的基本結構。實際上為了和產品整合在一起,還得加上更多種語法與函數,也得替各種語法準備對應的 UI 元件。有機會再分享實作細節。