文章入力フォームに「文字数」を表示するだけなら、最初は text.length で十分に見えます。しかし、日本語・絵文字・改行・IME入力まで考えると、文字数は意外に曖昧です。
ブラウザで動く文字数カウントツールを作ったとき、特に迷ったポイントを5つに整理します。
1. length は「見た目の文字数」ではない
JavaScriptの文字列はUTF-16で保持されます。length が返すのはUnicodeコードポイント数ではなく、UTF-16コードユニット数です。
"A".length; // 1
"漢".length; // 1
"😀".length; // 2
多くの絵文字はサロゲートペアで表現されるため、length では2文字として数えられます。コードポイント単位で数えるなら、スプレッド構文または Array.from() が使えます。
const countCodePoints = (text) => Array.from(text).length;
countCodePoints("A漢😀"); // 3
ただし、これも「人が見て1文字」と感じる単位と常に一致するわけではありません。家族の絵文字、国旗、結合文字などは、複数のコードポイントから1つの書記素を作ります。
対応ブラウザを限定できる場合は Intl.Segmenter で書記素クラスタを数えられます。
const segmenter = new Intl.Segmenter("ja", {
granularity: "grapheme",
});
const countGraphemes = (text) =>
Array.from(segmenter.segment(text)).length;
実装前に、必要なのが「UTF-16コードユニット」「コードポイント」「書記素」のどれかを決めておくことが重要です。
2. 「空白を除く」の定義を分ける
文字数カウンターでは、次の値が別々に求められることがあります。
- 空白を含む文字数
- 半角・全角スペースを除いた文字数
- スペース、タブ、改行をすべて除いた文字数
要件を分けずに \s を使うと、改行まで消えてしまいます。用途ごとに正規表現を明示したほうが安全です。
function analyzeBasic(text) {
const all = Array.from(text).length;
const withoutSpaces = Array.from(
text.replace(/[ \u3000]/g, "")
).length;
const withoutWhitespace = Array.from(
text.replace(/[\s\u3000]/g, "")
).length;
return { all, withoutSpaces, withoutWhitespace };
}
日本語の原稿用紙換算では、空白・改行を除いた文字数を400で割るなど、表示目的に合わせた別の定義も必要になります。
const manuscriptPages = Math.ceil(withoutWhitespace / 400);
3. 改行コードの違いを吸収する
行数は単純な split("\n") でも数えられますが、Windows由来の \r\n や古い形式の \r が混ざる可能性があります。
function countLines(text) {
if (text.length === 0) return 0;
return text.split(/\r\n|\r|\n/).length;
}
一方、段落数は「空行で区切る」「行頭の空白だけの行も空行とする」など仕様が変わります。
function countParagraphs(text) {
if (!text.trim()) return 0;
return text
.split(/\n\s*\n/)
.filter((part) => part.trim().length > 0)
.length;
}
文字数・行数・段落数を同じ処理で求めようとせず、それぞれの定義を関数として残すと後から修正しやすくなります。
4. 文字数とバイト数は別物
API、データベース、SNS投稿などでは、文字数ではなくバイト数の制限に遭遇します。UTF-8のバイト数は TextEncoder で簡単に取得できます。
const utf8Bytes = (text) => new TextEncoder().encode(text).length;
utf8Bytes("A"); // 1
utf8Bytes("あ"); // 3
utf8Bytes("😀"); // 4
UTF-16なら、JavaScriptのコードユニット数に2を掛けることで、BOMを含まない基本的なバイト数を求められます。
const utf16Bytes = (text) => text.length * 2;
Shift_JISやEUC-JPは注意が必要です。文字によって表現可否やバイト数が異なるため、厳密な判定が必要なら実際のエンコーダーを利用し、「未定義文字をどう扱うか」まで決める必要があります。ASCIIなら1バイト、それ以外なら2バイト、という近似だけでは厳密な結果になりません。
5. 日本語IMEの変換中は更新を急がない
入力イベントごとに集計すればリアルタイム表示できますが、日本語IMEの変換中にも input イベントが発生します。重い分析やテキスト変換をそのまま実行すると、表示のちらつきや入力の邪魔につながります。
let composing = false;
textarea.addEventListener("compositionstart", () => {
composing = true;
});
textarea.addEventListener("compositionend", () => {
composing = false;
updateCounts(textarea.value);
});
textarea.addEventListener("input", () => {
if (!composing) updateCounts(textarea.value);
});
さらに、連続入力時の再計算を抑えるために短いデバウンスを加えると安定します。
function debounce(fn, delay = 100) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}
ブラウザ内で処理する利点
文字数カウンターに貼り付けられる文章には、公開前の記事、仕事の原稿、メール文面などが含まれることがあります。集計がクライアント側だけで完結する設計なら、入力内容を集計のためにサーバーへ送る必要がありません。
下書き保存を付ける場合も、利用者が有効にしたときだけ localStorage に保存する、すべて削除できる操作を用意する、といった境界を明確にしておくと安心です。
まとめ
日本語の文字数カウントでは、まず「何を1文字とするか」を決める必要があります。そのうえで、空白・改行の扱い、文字コード別のバイト数、IME入力中の挙動を別々に設計すると、実用的なカウンターになります。
今回の検証結果は、個人開発しているブラウザ完結型の文字数カウントにも反映しました。文字数、行数、原稿用紙換算、UTF-8などのバイト数を同時に確認したい場合に使えます。