---
robots: noindex, nofollow
tags: 計算欄位
---
# 簡易計算欄位
最近在產品上實作計算欄位功能,和設計師討論後,決定先讓使用者直接打語法,而不是全部以 GUI 操作。於是我們得設計一個小小的程式語言,處理像 `{name} + " is awesome"` 或 `{total} - 10` 這種式子。
## 語法(syntax)
從這些式子出發,我們可以設想一種簡單的 syntax nodes :
```javascript=
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"` 可以寫成:
```javascript=
const expr = Binary('+', Field('name'), Str(' is awesome'));
```
`{total} - 10` 可以寫成:
```javascript=
const expr = Binary('-', Field('total'), Num(10));
```
## 環境(context)
我們要面對的環境是一個類似 spreadsheet 的系統,在簡化後,可以這樣描述欄位與式子的關係:
```javascript=
const ctx = {
'name': Str('Matt'),
'title': Binary('+', Field('name'), Str(' is awesome')),
'total': Num(42),
'discount': Binary('-', Field('total'), Num(10)),
};
```
而希望經過計算後,變成:
```javascript=
const ctx = {
'name': Str('Matt'),
'title': Str('Matt is awesome')),
'total': Num(42),
'discount': Num(32),
};
```
## 計算(evaluation)
為了達成這件事,首先要把環境變成充滿著「算一下才知道」的值,而不是一個一個語法樹:
```javascript=
const ctx = {
'name': (ctx) => Str('Matt'),
'title': (ctx) => {/* 一些程式 */},
'total': (ctx) => Num(42),
'discount': (ctx) => {/* 另一些程式 */},
};
```
而我們的計算程式在做的事情,就是不斷地看看現在要算的欄位,需不需要從環境 `ctx` 中取得其他欄位的資訊,再把結果求出來:
```javascript=
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][1] ,該文解釋得比較詳細。現在我們只要知道它會幫我們遞迴計算出不同欄位的值就好了。
我們關心的是怎麼做一個正確的 `construct` 函數,來處理不同的 syntax node :
```javascript=
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');
},
};
```
可以看出來,改變 `add` 和 `sub` 的實作,甚至可以支援字串與數字相加,或是兩個字串相減這種不直覺但是有時候很有用的功能。
## 小結
這就是目前 FormEditor 計算欄位的基本結構。實際上為了和產品整合在一起,還得加上更多種語法與函數,也得替各種語法準備對應的 UI 元件。有機會再分享實作細節。
[1]: https://rufflewind.com/2015-04-06/loeb-moeb-javascript