Reactで、四則演算の式を計算する入力欄を作る方法

​​React​CSP,JavaScript,TypeScript,フォーム

金額の入力欄に「1200*3+500」と打つと、すぐ下に「= 4,100」が出ます。Enter を押すと、欄の中身が「4100」に置き換わります。式の計算には eval を使わず、足し算・引き算・掛け算・割り算と括弧だけを読む関数を TypeScript で書きました。eval を禁止する Content-Security-Policy(CSP)を設定したページでも、そのまま動きます。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、StrictMode で包んだ開発用のビルドを Playwright で操作しました。全角の文字は Playwright の insertText で入れたもので、実際の日本語入力(IME)では試していません。Firefox と Safari、スマートフォン、スクリーンリーダーでの読み上げも試していません。

「金額(円)」の入力欄に 1200*3+500 と入っていて、その下に水色の文字で「= 4,100」と出ている
式を打つと、計算結果が入力欄の下に出る

eval を使わない理由

JavaScript には、文字列をコードとして実行する eval() があり、検証環境の Chromium でも eval('1200*3+500') は 4100 を返しました。ただ、MDN の eval() のページには「直接的な eval() を使わないでください!」という節があり、悪意のある文字列を渡すと、そのページの権限でコードが動いてしまうことが書かれています。入力欄の文字列は利用者が自由に打てるので、eval に渡すのは避けます。

もう1つの理由は CSP です。MDN の script-src の説明によると、script-src に 'unsafe-eval' が入っていないページでは、eval() や Function() コンストラクターがブロックされます。検証環境で Content-Security-Policy: script-src 'self' を付けてページを配信したところ、Chromium では eval('1200*3+500') も new Function('return 1200*3+500')() も EvalError を投げました。同じページで、この記事の入力欄に「1200*3+500」を入れて Enter を押すと、欄の中身は「4100」になりました。

式を読む関数

計算の処理は calc.ts にまとめました。文字列を数と記号の並びに分ける tokenize と、その並びを読んで計算する parse の2段です。

ts
// 四則演算の式を読み、計算する関数(eval を使わない)

export type CalcResult =
  | { kind: 'empty' }
  | { kind: 'ok'; value: number }
  // incomplete は、続けて打てば直るかもしれないエラー(「1+」「(2」「5/0」など)
  | { kind: 'error'; message: string; incomplete: boolean };

type Token =
  | { type: 'num'; value: number; pos: number }
  | { type: 'op'; value: '+' | '-' | '*' | '/' | '(' | ')'; pos: number };

const INCOMPLETE = '式が途中で終わっています';
const NO_CLOSE = '「)」が足りません';
const DIV_ZERO = '0で割ることはできません';

// Number で正しく表せる整数の範囲(±9007199254740991)を超えたときの文
const TOO_LARGE = '大きすぎて正確に計算できません';

// 全角の数字・記号、×÷ を、半角の記号にそろえる
export function normalize(text: string): string {
  return text
    .normalize('NFKC')
    .replace(/×/g, '*')
    .replace(/÷/g, '/')
    .replace(/−/g, '-');
}

// 式の文字列を、数と記号の並びに分ける
function tokenize(s: string): Token[] {
  const tokens: Token[] = [];
  // 直前に読んだ数の文字列(数のすぐあとでないときは空)
  let lastNum = '';
  let i = 0;
  while (i < s.length) {
    const c = s[i];
    if (/\s/.test(c)) {
      lastNum = '';
      i++;
      continue;
    }
    if ('+-*/()'.includes(c)) {
      tokens.push({ type: 'op', value: c as '+', pos: i });
      lastNum = '';
      i++;
      continue;
    }
    // 数:1,200 のような3けたごとのカンマと、小数点を受け付ける
    const m = /^(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?|^\.\d+/.exec(s.slice(i));
    if (m) {
      const value = Number(m[0].replace(/,/g, ''));
      if (value > Number.MAX_SAFE_INTEGER) throw new Error(TOO_LARGE);
      tokens.push({ type: 'num', value, pos: i });
      lastNum = m[0];
      i += m[0].length;
      continue;
    }
    // 「1200.」や「1,20」のように、数を打っている途中で式が終わっている
    const rest = s.slice(i);
    if (!lastNum.includes('.')) {
      if (rest === '.') throw new Error(INCOMPLETE);
      if (/^,\d{0,2}$/.test(rest) && /^\d{1,3}(?:,\d{3})*$/.test(lastNum)) throw new Error(INCOMPLETE);
    }
    throw new Error(`${i + 1}文字目の「${c}」は使えません`);
  }
  return tokens;
}

// 掛け算・割り算を先に計算するよう、3段の関数で読む
//   式 = 項 (('+' | '-') 項)*
//   項 = 因子 (('*' | '/') 因子)*
//   因子 = ('+' | '-') 因子 | 数 | '(' 式 ')'
function parse(tokens: Token[]): number {
  // 途中の計算結果も、正確に表せる範囲に収まっているかを確かめる
  const safe = (v: number) => {
    if (!(Math.abs(v) <= Number.MAX_SAFE_INTEGER)) throw new Error(TOO_LARGE);
    return v;
  };
  let i = 0;
  const peek = () => tokens[i];

  function expr(): number {
    let v = term();
    for (let t = peek(); t?.type === 'op' && (t.value === '+' || t.value === '-'); t = peek()) {
      i++;
      const r = term();
      v = safe(t.value === '+' ? v + r : v - r);
    }
    return v;
  }

  function term(): number {
    let v = factor();
    for (let t = peek(); t?.type === 'op' && (t.value === '*' || t.value === '/'); t = peek()) {
      i++;
      const r = factor();
      if (t.value === '/' && r === 0) throw new Error(DIV_ZERO);
      v = safe(t.value === '*' ? v * r : v / r);
    }
    return v;
  }

  function factor(): number {
    const t = peek();
    if (!t) throw new Error(INCOMPLETE);
    i++;
    if (t.type === 'num') return t.value;
    if (t.value === '-') return -factor();
    if (t.value === '+') return factor();
    if (t.value === '(') {
      const v = expr();
      if (peek()?.value !== ')') throw new Error(NO_CLOSE);
      i++;
      return v;
    }
    throw new Error(`${t.pos + 1}文字目の「${t.value}」の位置がおかしいです`);
  }

  const v = expr();
  const rest = peek();
  if (rest) throw new Error(`${rest.pos + 1}文字目の「${rest.value}」の位置がおかしいです`);
  return v;
}

export function calculate(text: string): CalcResult {
  const s = normalize(text);
  if (s.trim() === '') return { kind: 'empty' };
  try {
    const value = parse(tokenize(s));
    // 0.1 + 0.2 が 0.30000000000000004 になるような誤差を、有効数字15けたで丸める
    return { kind: 'ok', value: Number.isInteger(value) ? value : Number(value.toPrecision(15)) };
  } catch (e) {
    const message = (e as Error).message;
    return { kind: 'error', message, incomplete: [INCOMPLETE, NO_CLOSE, DIV_ZERO].includes(message) };
  }
}

掛け算と割り算を先に計算する

parse の中の expr・term・factor の3つの関数が、計算の順番を決めています。expr は「+」と「-」でつながった部分を、term は「」と「/」でつながった部分を読みます。expr は足す・引く相手を term に読ませるので、「1+23」では先に term が「2*3」を 6 にし、そのあとで 1 に足して 7 になります。

括弧は factor が読みます。「(」が来たら expr を呼び直し、括弧の中を1つの数として返します。数の前の「-」も factor で読むので、「2*-3」や「-(2+3)*4」も計算できます。Node.js で試した結果は次のとおりです。

式 結果
1+2*3 7
(1+2)*3 9
10-2-3 5
100/4/5 5
2*-3 -6
-(2+3)*4 -20
1,200*1.1 1320

「10-2-3」が 5 になるのは、for の繰り返しで左から順に計算しているからです。「1,200」のように3けたごとのカンマが入った数も読めます。「1200.」や「1,20」のように、数を打っている途中で文字列が終わっているときは、「式が途中で終わっています」として扱います。

全角の数字や記号、×と÷も読む

自作の normalize 関数では、最初に文字列の normalize() メソッドで NFKC 正規化をかけます。これで、全角の数字と「+」「-」「*」「/」「(」「)」は半角になります。一方、「×」「÷」と、数学のマイナス記号「−」(U+2212)は、NFKC をかけても変わりませんでした。この3つは replace で置き換えています。

この結果、「1200×3+500」は 4100、「(1+2)÷3」は 1 になりました。

小数の誤差は有効数字15けたで丸める

MDN の Number の説明にあるとおり、JavaScript の数は IEEE 754 の倍精度の2進数で表され、精度には限りがあります。検証環境では、0.1 + 0.2 は 0.30000000000000004、0.1 * 3 は 0.30000000000000004、1.1 * 1.1 は 1.2100000000000002 でした。このまま入力欄に戻すと、利用者には誤りに見えます。

そこで、整数でない結果は toPrecision(15) で有効数字15けたの文字列にし、Number で数に戻しています。これで 0.1+0.2 は 0.3、1.1*1.1 は 1.21 になりました。1/3 は 0.333333333333333 と、3が15個並びます。

整数の結果は丸めません。整数まで toPrecision(15) に通すと、16けたの 1234567890123457 は 1234567890123460 になり、末尾が0に変わります。整数を丸めない今の書き方では、「1234567890123456+1」は 1234567890123457 になりました。

大きすぎる数は、エラーにしました。Number.MAX_SAFE_INTEGER の説明にあるとおり、JavaScript の数で正確に表せる整数は ±(253 − 1)、つまり ±9007199254740991 までです。式の中の数か、途中を含む計算の結果がこれを超えたら、「大きすぎて正確に計算できません」と返します。「9007199254740991+1」や「3000000000*3000000000」に加えて、最後の結果は範囲に収まる「9007199254740991+2-2」もこのエラーになりました。

読めない式は、どこがおかしいかを返す

読めない式では例外を投げ、calculate で受け止めてエラーの文にしています。

式 エラーの文
1+ 式が途中で終わっています
(1+2 「)」が足りません
1+*2 3文字目の「*」の位置がおかしいです
1+2) 4文字目の「)」の位置がおかしいです
2^3 2文字目の「^」は使えません
1e3 2文字目の「e」は使えません
1/0、5/(2-2) 0で割ることはできません

JavaScript では 1 / 0 は Infinity になりますが、入力欄に「Infinity」を戻しても意味がないので、割る数が0のときはエラーにしました。「1e3」のような指数の書き方と「^」によるべき乗は、四則演算の外なので読みません。

入力欄のコンポーネント

tsx
import { useId, useState } from 'react';
import { calculate } from './calc';

const format = new Intl.NumberFormat('ja-JP', { maximumFractionDigits: 15 });

type Props = {
  label: string;
  initial?: string;
  // 計算結果で確定したときに呼ばれる
  onCommit?: (value: number) => void;
};

export function CalcInput({ label, initial = '', onCommit }: Props) {
  const id = useId();
  const [text, setText] = useState(initial);
  // Enter やフォーカスを外したときに計算できなかったら true にする
  const [tried, setTried] = useState(false);
  const result = calculate(text);
  // 続けて打てば直るかもしれないエラーは、確定しようとするまで出さない
  const error = result.kind === 'error' && (tried || !result.incomplete) ? result.message : '';

  // 式を計算結果に置き換える
  function commit() {
    if (result.kind !== 'ok') {
      setTried(result.kind === 'error');
      return;
    }
    setText(String(result.value));
    onCommit?.(result.value);
  }

  return (
    <div className="calc">
      <label htmlFor={id}>{label}</label>
      <input
        id={id}
        autoComplete="off"
        value={text}
        aria-invalid={error !== ''}
        aria-describedby={`${id}-msg`}
        onChange={(e) => {
          setText(e.target.value);
          setTried(false);
        }}
        onKeyDown={(e) => {
          // 日本語入力の変換を確定する Enter では、計算しない
          if (e.key !== 'Enter' || e.nativeEvent.isComposing) return;
          // 式を計算結果に置き換えるときと、エラーのときは、フォームを送信しない
          const settled = result.kind === 'empty' || (result.kind === 'ok' && String(result.value) === text);
          if (!settled) e.preventDefault();
          commit();
        }}
        onBlur={commit}
      />
      <p id={`${id}-msg`} className={error ? 'error' : 'preview'}>
        {error || (result.kind === 'ok' ? `= ${format.format(result.value)}` : '')}
      </p>
    </div>
  );
}
css
.calc {
  display: grid;
  gap: 4px;
  max-width: 320px;
}
.calc input {
  font-size: 1.125rem;
  padding: 6px 8px;
}
.calc p {
  min-height: 1.5em;
  margin: 0;
}
.calc .preview {
  color: #067a98;
}
.calc .error {
  color: #d85878;
}

state に持つのは、入力欄の文字列 text と、確定しようとして失敗したかどうかの tried の2つです。計算結果は state に持たず、レンダリングのたびに text から作ります。入力欄は type="text" のままにしました。Chromium で type="number" の欄に「1200*3+500」と打つと、value は空文字列になり、式を受け取れませんでした。

打っている途中はエラーを出さない

「12003+500」を1文字ずつ打つと、「1200」や「1200*3+」の時点では、式が途中で終わっています。ここで毎回エラーの文を出すと、打つたびに表示が赤くなったり消えたりします。

そこで、calculate は、続けて打てば直るかもしれないエラーのときに incomplete: true を返します。式が途中で終わっているとき、閉じ括弧が足りないとき、0で割っているときの3つです。0で割るエラーを含めたのは、「5/0」のあとに「.5」と続けると、正しい式になるからです。コンポーネントでは、tried が true になるまで、これらのエラーを出しません。その代わり、「10/(5-5)」のように続けて打っても直らない0割りも、確定しようとするまでエラーは出ません。

Chromium で1文字ずつ打った結果は次のとおりです。続けて打てば直るかもしれない間は、下の表示が空になります。

打った式 1文字ずつの表示
1200*3+500 = 1、= 12、= 120、= 1,200、(空)、= 3,600、(空)、= 3,605、= 3,650、= 4,100
1200.5 = 1、= 12、= 120、= 1,200、(空)、= 1,200.5
5/0.5 = 5、(空)、(空)、(空)、= 10

「2^」や「1.5.」のように、続けて打っても直らない誤りは、打った時点でエラーの文を出し、入力欄の aria-invalid を true にします。

Enter を押したときとフォーカスが外れたときに確定する

commit は、計算できたときに入力欄の文字列を結果に置き換え、onCommit で親に数を渡します。これを、Enter を押したときと、フォーカスが外れたとき(onBlur)に呼びます。Chromium で「1200*3+500」のまま Enter を押すと、欄の中身は「4100」になりました。全角の「1200×3」を入れて Tab キーでフォーカスを外すと、「3600」になりました。

「1+」のように途中で終わった式で Enter を押すと、欄の中身は「1+」のまま「式が途中で終わっています」が出ます。続けて「2」を打つと、onChange で tried が false に戻るので、エラーの文は消えて「= 3」に変わりました。

「(1200+800)/(3-3)」のように0で割る式も、入れた直後は何も出ず、Enter を押すと「0で割ることはできません」が出ました。

「金額(円)」の入力欄に (1200+800)/(3-3) と入っていて、その下にピンクの文字で「0で割ることはできません」と出ている
割る数が0になる式で Enter を押すと、エラーの文が出る

入力欄が <form> の中にあると、Enter でフォームが送信されます。式を結果に置き換えるときとエラーのときは preventDefault() を呼び、送信を止めています。検証では、「12003+500」と「1+」で Enter を押しても送信されませんでした。欄が「4100」のように数だけのときと空のときは止めないので、Enter でそのまま送信されました。「12003+500」を入れて Enter を2回続けて押すと、1回目で「4100」に置き換わり、2回目で送信されました。数だけの欄で Enter を押したときも、onCommit は同じ値でもう一度呼ばれます。

Enter の判定では、e.nativeEvent.isComposing が true のときは何もしないようにしています。MDN の isComposing の説明によると、このプロパティは、キーのイベントが日本語入力などの変換の途中(compositionstart のあと、compositionend の前)に起きたかどうかを表します。変換を確定するための Enter で、計算まで進まないようにするためのものです。

確定すると式は消えて、結果の数だけが欄に残ります。あとから式を見直すことはできないので、式そのものを残したい欄には向きません。

​Posted by 86