Reactで、四則演算の式を計算する入力欄を作る方法
金額の入力欄に「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、スマートフォン、スクリーンリーダーでの読み上げも試していません。

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段です。
// 四則演算の式を読み、計算する関数(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」のような指数の書き方と「^」によるべき乗は、四則演算の外なので読みません。
入力欄のコンポーネント
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>
);
}
.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で割ることはできません」が出ました。

入力欄が <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 で、計算まで進まないようにするためのものです。
確定すると式は消えて、結果の数だけが欄に残ります。あとから式を見直すことはできないので、式そのものを残したい欄には向きません。





