Reactで、文章を読むのにかかる時間の目安を表示する方法

​​React​HTML,Intl.Segmenter,Playwright,TypeScript

本文の文字数を「1分に読む文字数」で割ると、読むのにかかる時間の目安が出ます。日本語は Intl.Segmenter で見た目の1文字ごとに数え、英単語は1語ずつ数えて、それぞれの速さで割った時間を足します。React のコンポーネントは本文の文字列を props で受け取り、レンダーのたびに「約○分」を計算して表示します。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4(ICU 78.3)、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で本番用にビルドし、Playwright で入力欄と選択欄を操作して確かめました。Firefox、Safari、スマートフォンのブラウザ、日本語入力(IME)での入力は試していません。人が実際に読む速さは測っていないので、この記事の「1分に500字」「1分に200語」は、計算を確かめるために決めた例の値です。

「1分に読む文字数」の選択欄と本文の入力欄、その下に「この記事は1分未満で読めます(56字)」と出ている画面。本文の最後に長い文章を貼り付けると「約1分(704字)」に、選択欄を300字にすると「約2分」に、700字にすると「約1分」に変わる。変わった時間と文字数は、変わった瞬間だけピンクの文字になり、下の帯に何回目の操作かが出ている
長い文章を貼り付ける操作と、1分に読む文字数を300字、700字に変える操作。表示が変わったところが、その瞬間だけピンクになる

GIF の矢印、押した瞬間の輪、「Ctrl + V」の表示は、撮影のために重ねたものです。貼り付けは、Playwright の insertText で文章を入れて再現しました。

1分に読む文字数を決めて、文字数を割る

読む時間は、次の式で出します。

text
読む時間(分)= 日本語の文字数 ÷ 1分に読む文字数 + 英単語の数 ÷ 1分に読む語数

この記事のコンポーネントでは、1分に読む文字数と語数を props で受け取れるようにし、渡さなかったときは500字と200語で計算します。サイトに合う値が分かっていれば、その値を渡してください。

日本語は見た目の1文字ごと、英語は単語ごとに数える

英語の文章を1文字ずつ数えて日本語と同じ速さで割ると、Reading の1語が7文字と数えられ、日本語7文字を読むのと同じ時間がかかる計算になります。そこで、ラテン文字(英字)は単語ごとに、それ以外は1文字ごとに数える関数 countText() を作りました。

ts
export type ReadingTime = { chars: number; words: number; minutes: number };

const wordSegmenter = new Intl.Segmenter('ja', { granularity: 'word' });
const graphemeSegmenter = new Intl.Segmenter('ja', { granularity: 'grapheme' });
const LATIN = /\p{Script=Latin}/u;
const SPACE = /^\s+$/u;

// 英単語は語の数、それ以外は見た目の1文字ごとに数える(空白は数えない)
export function countText(text: string): { chars: number; words: number } {
  let chars = 0;
  for (const { segment } of graphemeSegmenter.segment(text)) {
    if (SPACE.test(segment) || LATIN.test(segment)) continue; // 空白と英字は文字数に入れない
    chars++;
  }
  let words = 0;
  for (const { segment, isWordLike } of wordSegmenter.segment(text)) {
    if (isWordLike && LATIN.test(segment)) words++; // 英字を含む語を、英単語として数える
  }
  return { chars, words };
}

// 1分に読む文字数と語数から、読むのにかかる時間(分)を出す
export function estimateReadingTime(
  text: string,
  charsPerMinute: number,
  wordsPerMinute: number,
): ReadingTime {
  const { chars, words } = countText(text);
  const minutes = chars / charsPerMinute + words / wordsPerMinute;
  return { chars, words, minutes };
}

Intl.Segmenter に granularity: 'grapheme' を渡すと、見た目の1文字(書記素)ごとに区切ります。文字列の length は UTF-16 のコード単位の数なので、絵文字1つが2や4と数えられることがありますが、この区切りなら1文字です。granularity: 'word' を渡すと単語ごとに区切り、isWordLike で、区切りが文字・数字・漢字などでできているかどうかが分かります。\p{Script=Latin} は、Unicode の文字の種類で選ぶ正規表現の書き方で、英字に一致します。

いくつかの文で countText() を試すと、次のようになりました。length は比べるために出した文字列の長さです。

text
"猫は窓辺で眠ります。"
  countText: {"chars":10,"words":0}  length: 10
"React の useState で state を持つ。"
  countText: {"chars":6,"words":3}  length: 29
"🐈‍⬛と🐈。"
  countText: {"chars":4,"words":0}  length: 8
"猫が3匹、2026年に。"
  countText: {"chars":12,"words":0}  length: 12
"Reading time is shown above the title."
  countText: {"chars":1,"words":7}  length: 38

日本語の文では、句点も1文字に入ります。日本語の中の React や useState は、前後の空白は数えず、それぞれ1語と数えました。絵文字を含む 🐈‍⬛と🐈。 は、length が8ですが、4文字と数えました。数字は \p{Script=Latin} に一致しないので、「2026」は4文字として数えます。英語だけの文では、最後のピリオドだけが文字数に入りました。

URL も英字を含む語に分かれます。https://example.com/neko を開く。 は、https、example.com、neko の3語と、記号と日本語の9文字になりました。

「約○分」の境目を決める

計算した時間(分)は、そのままでは小数になります。画面には、1分に満たないときは「1分未満」、それ以外は四捨五入した「約○分」を出すことにしました。

ts
// 画面に出す文。1分未満は「1分未満」、それ以外は四捨五入して「約○分」
export function formatMinutes(minutes: number): string {
  if (minutes < 1) return '1分未満';
  return `約${Math.round(minutes)}分`;
}

1分に500字のとき、文字数と表示は次のようになりました。

text
0字 → 0.000分 → (表示しない)
1字 → 0.002分 → 1分未満
499字 → 0.998分 → 1分未満
500字 → 1.000分 → 約1分
749字 → 1.498分 → 約1分
750字 → 1.500分 → 約2分
1200字 → 2.400分 → 約2分
3000字 → 6.000分 → 約6分

日本語1000字と英単語200語を混ぜた文章では、1000 ÷ 500 + 200 ÷ 200 で3分になり、「約3分」と出ました。

time 要素に、機械が読める長さを入れる

「約2分」は人が読むための文です。HTML の <time> 要素の datetime 属性には、PT2H30M のような形で長さ(期間)を書けます。分で表した時間をこの形にする関数を足しました。

ts
// <time datetime> に入れる長さの文字列(例:PT2M24S)。秒は切り上げる
export function toDuration(minutes: number): string {
  const seconds = Math.ceil(minutes * 60);
  return `PT${Math.floor(seconds / 60)}M${seconds % 60}S`;
}

1200字を1分に500字で読むと2.4分なので、datetime は PT2M24S になります。

読む時間を表示する React のコンポーネント

ReadingTime コンポーネントは、本文の文字列と、1分に読む文字数・語数を props で受け取ります。読む時間は state にせず、レンダーのたびに計算します。React の公式ドキュメントにある、props や state から計算できるものはレンダー中に計算するという考え方に沿った書き方です。

tsx
import { estimateReadingTime, formatMinutes, toDuration } from './reading-time';

type Props = {
  text: string;
  charsPerMinute?: number;
  wordsPerMinute?: number;
};

const numberFormat = new Intl.NumberFormat('ja-JP');

export function ReadingTime({ text, charsPerMinute = 500, wordsPerMinute = 200 }: Props) {
  const { chars, words, minutes } = estimateReadingTime(text, charsPerMinute, wordsPerMinute);
  if (chars === 0 && words === 0) return null;

  return (
    <p className="reading-time">
      この記事は<time dateTime={toDuration(minutes)}>{formatMinutes(minutes)}</time>で読めます
      <span className="reading-time-detail">
        ({numberFormat.format(chars)}字{words > 0 && `、英単語${numberFormat.format(words)}語`})
      </span>
    </p>
  );
}

本文が空のときや、空白と改行しかないときは null を返し、何も表示しません。文字数は Intl.NumberFormat で「1,200」のように3桁ごとに区切っています。

動きを確かめるために、本文の入力欄と「1分に読む文字数」の選択欄を持つ Editor コンポーネントを作り、ReadingTime に渡しました。

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

const SAMPLE =
  '猫は窓辺で眠ります。朝になると、猫は台所に来て、ごはんを待ちます。' +
  'ごはんを食べた猫は、また窓辺に戻って眠ります。';

export function Editor() {
  const [text, setText] = useState(SAMPLE);
  const [charsPerMinute, setCharsPerMinute] = useState(500);
  const textId = useId();
  const speedId = useId();

  return (
    <div className="editor">
      <label htmlFor={speedId}>1分に読む文字数</label>
      <select
        id={speedId}
        value={charsPerMinute}
        onChange={(e) => setCharsPerMinute(Number(e.target.value))}
      >
        <option value={300}>300字</option>
        <option value={500}>500字</option>
        <option value={700}>700字</option>
      </select>

      <label htmlFor={textId}>本文</label>
      <textarea id={textId} rows={8} value={text} onChange={(e) => setText(e.target.value)} />

      <ReadingTime text={text} charsPerMinute={charsPerMinute} />
    </div>
  );
}

ブログの記事ページで使うときは、Editor の代わりに、記事の本文の文字列を text に渡します。

Chromium で操作したときの表示

Playwright で本文と選択欄を操作し、そのつど、表示された文と datetime 属性の値を読み取りました。

text
--- 最初の表示(見本の文章、1分に500字)
  表示 : この記事は1分未満で読めます(56字)
  time : PT0M7S
--- 「猫」を1200字
  表示 : この記事は約2分で読めます(1,200字)
  time : PT2M24S
--- 1分に読む文字数を300字に
  表示 : この記事は約4分で読めます(1,200字)
  time : PT4M0S
--- 1分に読む文字数を700字に
  表示 : この記事は約2分で読めます(1,200字)
  time : PT1M43S
--- 「猫」1000字と英単語200語(700字)
  表示 : この記事は約2分で読めます(1,000字、英単語200語)
  time : PT2M26S
--- 空欄
  表示 : (なし)
--- 空白と改行だけ
  表示 : (なし)

選択欄を変えると、本文はそのままで時間だけが計算し直されました。1分に700字のとき、1200字は約1.7分なので「約2分」になり、datetime は PT1M43S でした。

区切りの中でさらに区切ると、長い文章で遅くなる

比べるために、単語に区切った結果を1回だけループで回し、英字を含まない区切りの中をさらに書記素で区切って数える countText() も用意しました。

ts
// 英単語は語の数、それ以外は見た目の1文字ごとに数える(空白は数えない)
export function countText(text: string): { chars: number; words: number } {
  let chars = 0;
  let words = 0;
  for (const { segment, isWordLike } of wordSegmenter.segment(text)) {
    if (isWordLike && LATIN.test(segment)) {
      words++;
      continue;
    }
    if (SPACE.test(segment)) continue;
    for (const _ of graphemeSegmenter.segment(segment)) chars++;
  }
  return { chars, words };
}

長くした文章で数えた結果(1万字で6,412字・468語、10万字で64,074字・4,686語)は、上の countText() と同じでした。ところが、日本語と英単語を混ぜた文章をくり返して長くし、Chromium の中で時間を測ると、次のように差が出ました。どちらも、結果を使わない実行を3回してから11回測った値です。

text
  区切りごとに書記素を数える版 10000字の文字列 {"chars":6412,"words":468} 中央値 76.8ms 最小 15.7ms 最大 104.6ms
  区切りごとに書記素を数える版 100000字の文字列 {"chars":64074,"words":4686} 中央値 885.1ms 最小 226.5ms 最大 985.8ms
  2回に分けて数える版         10000字の文字列 {"chars":6412,"words":468} 中央値 4.4ms 最小 4.1ms 最大 4.8ms
  2回に分けて数える版         100000字の文字列 {"chars":64074,"words":4686} 中央値 28.4ms 最小 27.6ms 最大 31.5ms

区切りの中でさらに区切る書き方では、1万字で中央値76.8ミリ秒、10万字で885.1ミリ秒かかりました。この書き方は測るたびのばらつきも大きく、1万字の最小は15.7ミリ秒でしたが、それでも2回に分ける書き方より遅い結果でした。文章全体を書記素で1回、単語で1回と、2回に分けて回す書き方では、1万字で4.4ミリ秒、10万字で28.4ミリ秒でした。Node.js で同じように測っても、1万字で中央値28.6ミリ秒と7.0ミリ秒になり、2回に分けるほうが速くなりました。

このコンポーネントは、本文が1文字変わるたびに全体を数え直します。入力欄と一緒に使うなら、文章全体を2回に分けて回す書き方にしておくと、長い本文でも1回の計算が短く済みます。

​Posted by 86