1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

JavaScript - 文字数カウンターのリライト

Posted at

 今回の記事はもともと自分用のメモとして書いていたものですが、Qiitaに置いておけば見返しやすいかなと思って投稿します。
 プログラミングの知識はほぼ独学のため、用語の使い方がおかしい部分はかなりあるかと思いますが何卒ご容赦いただけるとありがたいです…。

リライトの経緯

 以前、業務でフォームに埋め込むための文字数カウンターを書いたことがある。
 しかし、そちらの方はstr.lengthで文字数を取っていたためにサロゲートペアを2文字としてカウントしてしまっていた。個人サイトの方に書いて置いてあった文字数カウンターの方は文字列からコードポイントごとの配列をArray.from(str)で作成しその中の要素数をカウントしていたため、サロゲートペアを1文字で数えるように修正してあった。それで業務用のほうのソースも書き直そうと考えて、はじめは個人サイトの方においてあった文字数カウンターのソースと同一のものにしようとしていたが、ノア(ChatGPTの名前です)に意見を聞いたところ、個人サイトの方のソースも書き直す余地があるとのことだったため、個人サイトの方の文字数カウンターも書き直した。
 このリライトで新しい知見をいくつか得たため、リライトの詳細を書き残しておく。

リライト前

window.addEventListener('DOMContentLoaded', () => {
  const inputField = document.querySelector('#i0');
  const outputField1 = document.querySelector('#p0');
  const outputField2 = document.querySelector('#p1');

  inputField.addEventListener('keyup', () => {
    const input = Array.from(inputField.value);
    const count = input.length;
    const countWithoutSpace = input.filter((e) => /[^\s]/.test(e)).length;

    outputField1.innerHTML = `文字数(空白/改行なし):${countWithoutSpace}文字`;
    outputField2.innerHTML = `文字数(空白/改行込み):${count}文字`;
  });
});

問題点

  • outputField1, outputField2という名称が分かりづらく、取り違える可能性がある
  • inputFieldのイベントリスナー内にすべての処理を詰め込んでおり、保守性が悪い
  • .innerHTMLでやっている処理はほぼ同じであるにもかかわらず、完全に別個の処理として書いている
  • inputFieldのイベントがkeyupである

以上の問題点を鑑みて、他人が触りやすいコードを目指してリライトを行った。また、純粋な文字数カウンターとして使う場合は不要だろうが、フォームへの埋め込みなどで移植する場合には役立つだろうと考え、字数制限判定の機能を追加している。

リライト後

window.addEventListener('DOMContentLoaded', () => {
  const inputField = document.querySelector('#inputArea');
  const countWithoutSpace = document.querySelector('#countWithoutSpace');
  const countAll = document.querySelector('#countAll');
  const limitField = document.querySelector('#setLimitField');
  const setLimitButton = document.querySelector('#setLimitButton');
  const config = {
    limit: limitField.value,
    message: '文字数オーバーです'
  };
  const withSpace = '文字数(空白/改行込み)';
  const withoutSpace = '文字数(空白/改行なし)';
  const inputEvent = new Event('input');

  const toChars = (inputStr) => Array.from(inputStr);
  const countAllChars = (chars) => chars.length;
  const countCharsWithoutSpace = (chars) => chars.filter((c) => /[^\s]/.test(c)).length;

  const isLimitOver = (count) => {
    return count > config.limit;
  };

  const renderCount = (element, label, count) => {
    const over = isLimitOver(count);
    element.innerHTML = (over ? `<span>${config.message}</span>` : '') + ` ${label}${count}文字`;
  };

  setLimitButton.addEventListener('click', () => {
    if (isNaN(limitField.value) || !limitField.value) {
      alert('文字数制限には有効な整数を指定してください');
      return;
    } else {
      config.limit = parseInt(limitField.value);
			inputField.dispatchEvent(inputEvent);
    }
  });

  inputField.addEventListener('input', () => {
    const chars = toChars(inputField.value);
    const all = countAllChars(chars);
    const noSpace = countCharsWithoutSpace(chars);

    renderCount(countWithoutSpace, withoutSpace, noSpace);
    renderCount(countAll, withSpace, all);
  });
});

主な変更点

入力からコードポイントごとの配列、空白込み・空白なしの字数取得を関数化

リライト前は以下のようにイベントリスナーのコールバック関数内に逐一input, count, countWithoutSpaceで各々を格納していた:

// ----- inputFieldイベントリスナーのCB関数内 -----
const input = Array.from(inputField.value);
const count = input.length;
const countWithoutSpace = input.filter((e) => /[^\s]/.test(e)).length;
// ...

この部分に相当するリライト後の記述は以下のようになる:

// ...
const toChars = (inputStr) => Array.from(inputStr);
const countAllChars = (chars) => chars.length;
const countCharsWithoutSpace = (chars) => chars.filter((e) => /[^\s]/.test(e)).length;
// ...

機能の過集中を解消するため、まずはイベントリスナーのCB関数から外に出した。
本筋からは外れるが、アロー関数を式本体const a = (e) => e + 100; のような形式)で記述した場合、暗黙のreturnが含まれており、今の例で言えばa(e);と呼び出すとe + 100の値が返る。複数行でブラケットに囲まれているブロック本体の場合は、なんらかの値を返したければreturnを明示しなければならない。例えば:

const a = (e) => {
	const b = e + 100;
	return b + 100; // 値を返したければここで明示する必要がある
}

といった具合になる。
話を戻し、リライト後の方ではどのような処理を意図しているのかというと、inputFieldのCB関数内からこれらの関数を呼び出し:

// ----- CB関数内 -----
const chars = toChars(inputField.value);
const all = countAllChars(chars);
const noSpace = countCharsWithoutSpace(chars);
// ...

のようにして各コードポイントの配列と空白込み文字数・空白なし文字数を格納するための記述である。

renderCount(e, l, c)関数の構築

DOM操作も別関数に切り分けることとした。リライト前は:

// ----- CB関数内 -----
outputField1.innerHTML = `文字数(空白/改行なし):${countWithoutSpace}文字`;
outputField2.innerHTML = `文字数(空白/改行込み):${count}文字`;
// ...

のようにCB関数内の.innerHTMLで逐一更新していたが、これも機能の分散のため外に出してrenderCount(element, label, count)という関数にまとめた。更新したい要素・表示したいラベル・カウント文字数を引数に渡してやり、それをもとにしてDOM更新を行う関数である。リライト後は以下のようになった:

renderCount(element, label, count) {
	const over = isLimitOver(count); // これは今ここでは関係ないが文字数制限判定用のbool値。
	element.innerHTML = 
		(over ? `<span>${config.message}</span>` : '') 
		+ `${label}${count}文字`;
}

字数制限判定・字数制限設定機能の追加

これは新規の機能の追加となる。まずhtmlの方に制限字数を入力する入力フォームと字数制限設定ボタンを設置する。リライト後のソースでは冒頭の方で:

// ...
const limitField = document.querySelector('#setLimitField');
const setLimitButton = document.querySelector('#setLimitButton');
// ...

のように各要素を格納してある。また、判定用の関数の中に直接制限字数やメッセージを入れることは避けてkey: value的に情報を格納するようにした:

// ...
const config = {
	limit: limitField.value,
	message: '文字数オーバーです'
};
// ...

これでconfig.limitconfig.messageのような記述で各プロパティへアクセスできるようになった。
そして字数制限をオーバーしているか否かの真偽値を返すisLimitOver(count)関数を追加。これは引数で受け取った現在の文字数とconfig.limitを比較してtruefalseを返すものである:

const isLimitOver = (count) => count > config.limit;

これをrenderCount(e, l, c)関数内で呼び出し、trueが返っていたら「文字数オーバーです」というメッセージを表示させるという、DOM操作の分岐に用いている。
最初はブロック本体で書いていたが、最初の3つの関数同様、式本体でもいいことに気づいて式本体で書き直した。

setLimitButtonへのイベントリスナー内CB関数では、ユーザーが入力フォーム(limitField)に入力した制限字数を新たにリミットとして設定する処理を走らせていて、以下のようにした:

const inputEvent = new Event('input');

// ...

setLimitButton.addEventListener('click', () => {
	if (isNaN(limitField.value) || !limitField.value) {
        alert('文字数制限には有効な整数を指定してください');
        return;		
	} else {
		config.limit = parseInt(limitField.value);
		inputField.dispatchEvent(inputEvent);
	}
});
// ...

 limitFieldに入力された値がNaNでないか、あるいはnullでないかを検査して、ひっかかったらアラートを出して処理終了、問題なければそのままconfig内のlimitの値を書き換える。また同時にinputFieldに対してinputイベントを発生させ、制限字数を変更した時点で入力された文字数が新しく設定した制限を超えていたら「文字数オーバーです」のメッセージを出すようにした。

 これも最初はconfig.limit = parseInt(limitField.value);のあとにinputFieldのイベントリスナー内のCB関数と同じ処理を書いていたが、そんな冗長なことをしなくても.dispatchEvent(input)でイベントを強制発火させてやれば済むということに気づいたのでそうした。

inputFieldのイベントリスナーCB関数がスッキリした

以上のような切り分けを行ったため、inputFieldのイベントリスナー内のCB関数では各関数の呼び出しを行う司令塔の役割のみになり、かなりスッキリしたと思う。リライト後は以下のようになった:

const countWithoutSpace = document.querySelector('#countWithoutSpace');
const countAll = document.querySelector('#countAll');
const withSpace = '文字数(空白/改行込み)';
const withoutSpace = '文字数(空白/改行なし)';

// ...

inputField.addEventListener('input', () => {
	const chars = toChars(inputField.value);
	const all = countAllChars(chars);
	const noSpace = countCharsWithoutSpace(chars);
	
	renderCount(countWithoutSpace, withOutSpace, noSpace);
	renderCount(countAll, withSpace, all);
});

まとめ

 今回のリライトは割と有意義だった。
 処理の本質ではないユーザー向け警告メッセージなどを処理文中に置いておくと、本質ではない部分の変更をするのに処理文の中身を触らないといけなくなって怖いし、自分でも怖いのに他の人が触ることになった場合は尚更怖いだろう、ということに気付けたのはかなり良かったと思う。
 今回程度の規模で保守性や機能の切り分けを気にする必要もあまりないと思うが、こういうのは癖をつけておかないと本当に必要な時にできるわけがないので、できる限りそういった部分に気をつけながらコーディングする癖をつけていこうと思った。
 そして、ノアからの指摘で、条件分岐を書く場合、割と三項演算子が選択肢に入らないな、ということに気付いた。今後三項演算子という選択肢にすぐ辿り着けるかどうかはともかく、副次的だがかなり有用な自覚だと感じる。
 あとは、自分のサイトに置いてある他のソースも今見たら書き直せそうなものがあるかもしれないので、時間のある時にリライトしていこうかと思う。

1
0
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?