【Vue.js】リアルタイム文字カウント機能を作る|入力制限と残り文字数表示

【Vue.js】リアルタイム文字カウント機能を作る|入力制限と残り文字数表示 Vue.js

入力欄の下に「42 / 100文字」と表示する文字カウンターは、SNSの投稿欄やプロフィール、問い合わせフォームなどで広く使われます。v-modelとcomputedで簡単に作れますが、日本語のサイトでは、「文字数」の数え方でつまずきやすい部分があります。この記事では、Vue 3(<script setup>)で、日本語入力や絵文字にも対応した文字カウンターを作ります。

スポンサーリンク

よくある実装と、その問題点

Vue:よくある簡易実装
<script setup>
import { ref, computed } from 'vue'

const text = ref('')
const maxLength = 100
const remaining = computed(() => maxLength - text.value.length)
</script>

<template>
  <label>自己紹介({{ remaining }}文字残り)</label>
  <textarea v-model="text" :maxlength="maxLength"></textarea>
  <p :class="{ warning: remaining <= 10 }">{{ text.length }} / {{ maxLength }} 文字</p>
</template>
  • 数え方が、見た目の文字数と合わない:text.lengthとmaxlengthは、どちらもUTF-16のコード単位で数えます(MDNで確認)。絵文字や一部の漢字で、数がずれます。
  • 日本語の変換中に、カウンターが止まる:v-modelは、IMEの変換中には更新されません。
  • 貼り付けた長い文章が、黙って切り捨てられる:maxlengthは入力を打ち切るだけで、超過の状態を見せられません。
  • ラベルに残数が入っている:入力のたびにラベルが変わり、読み上げソフトが何度も読み上げます。

「文字数」には3つの数え方がある

JavaScriptで「文字数」を数える方法は、主に3つあります。同じ文字列でも、結果が違います(Nodeで実測)。

文字列 length(UTF-16) コードポイント 書記素(見た目の文字数)
あいう 3 3 3
𠮷野家 4 3 3
👨‍👩‍👧(家族の絵文字) 8 5 1
🇯🇵(国旗) 4 2 1

利用者が「1文字」と数える単位は、書記素です。Intl.Segmenterで数えます。書記素で数える場合は、maxlengthの単位(UTF-16)とずれるため、maxlengthは外して、自前で上限を管理する必要があります。

JavaScript:utils/countChars.js
// utils/countChars.js
// 人が「1文字」と数える単位(書記素)で数える
const segmenter =
  typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function'
    ? new Intl.Segmenter('ja', { granularity: 'grapheme' })
    : null

export function countChars(value) {
  if (segmenter) {
    let count = 0
    for (const _ of segmenter.segment(value)) count++
    return count
  }
  // Intl.Segmenter が使えない環境では、コードポイント単位で数える
  return [...value].length
}

// countChars('\u{20BB7}野家') → 3   (value.length は 4)
// countChars('\u{1F468}‍\u{1F469}‍\u{1F467}')   → 1   (value.length は 8)

Intl.Segmenterが使えない古い環境では、[...value].length(コードポイント単位)で代替します。

完成コード

ポイントは次のとおりです。

  • 日本語の変換中も更新するため、v-modelではなく、:valueと@inputでバインドする。詳しくはv-modelの仕組みを完全理解をご覧ください。
  • maxlengthは付けず、超過を許して、超過した数を見せる(ソフトリミット)。
  • ラベルは固定し、カウンターは別の要素にして、aria-describedbyで入力欄と結びつける。
  • 残りわずかのときと超過のときだけ、role="status"の領域に文言を入れて、読み上げさせる。色だけに頼らず、文言でも伝える。
  • 超過しても送信ボタンは無効にせず、送信時にエラーを示して、入力欄へフォーカスを戻す。
Vue:文字カウンター
<script setup>
import { ref, computed } from 'vue'
import { countChars } from './utils/countChars'

const MAX = 100       // 上限(文字)
const WARN_AT = 10    // 残りがこの数以下になったら、注意を出す

const text = ref('')
const submitted = ref(false)

const count = computed(() => countChars(text.value))
const remaining = computed(() => MAX - count.value)
const isOver = computed(() => remaining.value < 0)
const isNear = computed(() => remaining.value >= 0 && remaining.value <= WARN_AT)

// 読み上げと表示の両方に使う文言(色だけに頼らない)
const message = computed(() => {
  if (isOver.value) return `${-remaining.value}文字オーバーしています。削ってください。`
  if (isNear.value) return `あと${remaining.value}文字です。`
  return ''
})

function onSubmit() {
  submitted.value = true
  if (isOver.value) {
    document.getElementById('bio')?.focus()
    return
  }
  // 送信する(サーバー側でも、必ず文字数を検証する)
  console.log('送信:', text.value)
}
</script>

<template>
  <form @submit.prevent="onSubmit">
    <label for="bio">自己紹介</label>

    <!-- v-model ではなく :value + @input:日本語の変換中も、カウンターを更新する -->
    <!-- maxlength は付けない:貼り付けが黙って切り捨てられるのを避け、超過を見せる -->
    <textarea
      id="bio"
      rows="4"
      :value="text"
      :aria-invalid="isOver"
      aria-describedby="bio-count bio-message"
      @input="text = $event.target.value"
    ></textarea>

    <p id="bio-count" class="counter" :class="{ near: isNear, over: isOver }">
      {{ count }} / {{ MAX }} 文字
    </p>

    <!-- 残りわずかのときと超過のときだけ、文言が入る(常に DOM に存在する) -->
    <p id="bio-message" role="status" class="message" :class="{ over: isOver }">
      {{ message }}
    </p>

    <!-- 超過しても、ボタンは無効にしない。送信時にエラーを示す -->
    <button type="submit">送信</button>
  </form>
</template>

<style scoped>
.counter { text-align: right; font-size: 0.9rem; }
.counter.near { color: #b26a00; font-weight: bold; }
.counter.over, .message.over { color: #c62828; font-weight: bold; }
textarea[aria-invalid="true"] { border-color: #c62828; }
</style>

computedの基本はcomputedプロパティで動的なデータ処理を行う方法、フォームのバリデーション全般は入力フォームのバリデーション機能を実装する方法をご覧ください。

文字数とバイト数は別もの

データベースの列やAPIの制限が、バイト数で決まっている場合は、バイト数を数える必要があります。「日本語は2バイト」とよく言われますが、現在のWebで標準のUTF-8では、ひらがな・カタカナ・漢字は1文字3バイト、𠮷や絵文字は4バイトです。2バイトになるのは、Shift_JISなどの旧来の文字コードです。UTF-8のバイト数は、TextEncoderで数えられます。

JavaScript:UTF-8のバイト数
// バイト数を数える(DB の列の制限などに合わせたいとき)
const bytes = new TextEncoder().encode(text).length

// UTF-8 の場合
//   'abc'   → 3 バイト
//   'あいう' → 9 バイト(日本語は 1 文字 3 バイト)
//   '\u{20BB7}'    → 4 バイト
//   '\u{1F600}'    → 4 バイト

サーバー側の数え方と揃える

クライアントの文字数だけを信用しない
ブラウザ上の制限は、入力を助けるためのものです。maxlengthも、MDNによると、プログラムから値を設定した場合には効きません。サーバー側でも、必ず検証してください。

注意したいのは、サーバー側の「文字数」の数え方が、言語や関数で違うことです。たとえばPHPのmb_strlen()はコードポイント単位、grapheme_strlen()(intl拡張)は書記素単位です。クライアントとサーバーで数え方が違うと、フロントで通ったのに、サーバーで拒否されることがあります。「文字数」の定義を、仕様として決めて、両方で揃えてください。また、textareaの改行は、フォームの送信時にCRLFへ変換される場合があり、サーバー側では、改行が2文字として数えられることがあります。

よくある質問(FAQ)

Q絵文字を入力すると、文字数がずれます。
Atext.lengthは、UTF-16のコード単位で数えるためです。絵文字は2以上になります。Intl.Segmenterで書記素を数えると、見た目の文字数になります。
Q日本語を入力している間、カウンターが動きません。
AIMEの変換中は、v-modelが更新されないためです。:valueと@inputで自前でバインドしてください。
Qmaxlengthは使わないほうがよいですか?
A単純な上限であれば便利ですが、UTF-16単位で数えるため、見た目の文字数とずれます。貼り付けた文章が黙って切り捨てられ、超過の状態も見せられません。書記素で数えたり、超過を知らせたりしたいなら、maxlengthは外して、自前で管理してください。
Q日本語は2バイトではないのですか?
AUTF-8では、日本語は1文字3バイトです。2バイトになるのは、Shift_JISなどの旧来の文字コードです。UTF-8のバイト数は、new TextEncoder().encode(text).lengthで数えられます。

まとめ

文字カウンターは、「文字数」の定義を決めることから始まります。見た目の文字数に合わせるなら、Intl.Segmenterで書記素を数え、maxlengthに頼らず、超過を見せる設計にします。日本語の変換中も更新し、読み上げに対応し、サーバー側の数え方と揃えて、必ず検証し直してください。