---
# System prepended metadata

title: 簡易計算欄位
tags: [計算欄位]

---

---
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