inuinu blog(開発用)

BOT @wagagun の開発ノウハウや、IT向け?の雑記ブログです。

素のJavascript(Vanilla)だけでフロントエンド開発(10:一覧画面編)

このページでは、ReactやVueではなく、Vanillaと呼ばれる素のJavascriptのみで、フロントエンド開発が可能であるか?を解説します。今回は一覧画面を実装します。

今回は、前回…

inuinu-tech.hatenablog.jp

の続きになります。

いきなりここにたどり着いたのでしたら、お手すきの際で構いませんので、下記の記事からも読んでみていただければ幸いです。

inuinu-tech.hatenablog.jp

今回の課題のゴールは?

今回は、一覧画面を作表しますが、前回の明細の追加処理を応用したものを実装します。

入力画面ではないので、簡単そうに見えますが、さて…

一覧画面とは?

一覧画面とは、Ruby on Railsのscaffoldのリストのようなものです。

ただ今回は、一覧画面のみを作成し、scaffoldのような行削除=レコード削除や、登録・変更画面への遷移は実装しません。

ページングも実装しないことにします。

プログラム例

それでは早速、一覧画面のHTMLから作成していきましょう。

プログラム例

HTMLファイルは2つ作成します(一覧と明細)。

●test_ichiran.html

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<script src="js/vanilla-front-end.js"></script>
<script>
// form名
let FRM_NAME = 'frm';
// 最大明細行数
let MAX_LINE = 20;

async function setIchiran(val) {
  // 一覧テーブルをクリア
  rmvIchiran();

  // TABLEタグの値をセット
  // API実行
  result = await fetch("api_mock/get_ichiran" + val + ".json", {
      method: "GET"
  });
  // JSONを取り出す
  jsonData = await result.json();
  //console.log(jsonData.result);

  // テーブルの枠を作成
  // MAX_LINEを超えない範囲で作成
  let cnt = (jsonData.line_count < MAX_LINE) ? jsonData.line_count : MAX_LINE;
  addIchiranArea(cnt, jsonData.unique_key);

  // 入力値をセットする
  setInputValue(jsonData.result, FRM_NAME);

} // function

// 明細画面へ
function goMeisai(objThis) {
  //console.log(objThis.dataset.uniqueKey);
  let pgm = "test_meisai.html?cs=" + objThis.dataset.uniqueKey;
  //console.log(pgm);
  location.href = pgm;
}

// DOMコンテンツのロード完了時に実行
window.addEventListener('DOMContentLoaded', (ex) => {
  // APIを使用した初期表示
  setIchiran(1)
});
</script>
</head>
<body>
<template id="TEMPLATE">
    <tr id="line_no_" data-unique-key="" data-line-no="" onclick="goMeisai(this)">
      <td id="cust_code_"></td>
      <td id="cust_name_"></td>
      <td id="cust_tantousha_"></td>
    </tr>
</template>
<form name="frm" id="FORM_AREA">
<button type="button" onclick="setIchiran(1);">一覧1</button>
<button type="button" onclick="setIchiran(2);">一覧2</button>
<input type="text" name="detail_id" value="line_no_,cust_code_,cust_name_,cust_tantousha_">
</form>
<br/ >
<table>
  <thead>
    <tr>
      <th>コード</th>
      <th>取引先名</th>
      <th>担当者</th>
    </tr>
  </thead>
  <tbody id="ICHIRAN_AREA">
  </tbody>
</table>

</body>
</html>

●test_meisai.html

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<script src="js/vanilla-front-end.js"></script>
<script>
// form名
let FRM_NAME = 'frm';
// 最大明細行数
let MAX_LINE = 20;

async function setMeisai() {

  // GETパラメータを取得
  let prm = new URL(window.location.href).searchParams;

  // 変換
  let prm_uk = cnvDefault(prm.get('uk'));

  // API実行
  result = await fetch("api_mock/get_meisai" + prm_uk + ".json", {
      method: "GET"
  });
  // JSONを取り出す
  jsonData = await result.json();
  //console.log(jsonData.result);

  // 入力値をセットする
  setInputValue(jsonData.result, FRM_NAME);

} // function

// 一覧画面へ戻る
function goIchiran() {
  location.href = "test_ichiran.html";
}

// DOMコンテンツのロード完了時に実行
window.addEventListener('DOMContentLoaded', (ex) => {
  // APIを使用した初期表示
  setMeisai()
});
</script>
</head>
<body>
<form name="frm" id="FORM_AREA">
<button type="button" onclick="goIchiran();">一覧へ戻る</button>
<br/ >
<br/ >
コード:<span id="cust_code"></span><br/ >
取引先名:<span id="cust_name"></span><br/ >
住所:<span id="cust_addr"></span><br/ >
担当者:<span id="cust_tantousha"></span><br/ >
</form>
<br/ >

</body>
</html>

jsディレクトリを作成し、以下のJavascriptソース(vanilla-front-end.js)を作成します。

他の演習を先に行い、vanilla-front-end.jsがすでに存在する場合は、追記してください。

●vanilla-front-end.js

// 明細行追加
function addIchiranArea(cnt, uniqueKey) {
  //console.log(cnt);
  //console.log(uniqueKey);

  let dtl_cnt = 0;
  Array.from({ length: cnt }).forEach(function(strID) {
  //Array(cnt).fill().forEach(function(strID) {
  //[...Array(cnt)].forEach(function(strID) {
    dtl_cnt++;
    //console.log(dtl_cnt);

    // テンプレートを取得し、クローンを作成
    let tmp = document.getElementById('TEMPLATE');
    let cln = tmp.content.cloneNode(true);

    // クローンをコピペ
    document.getElementById('ICHIRAN_AREA').appendChild(cln);

    // datasetに行番号を挿入
    document.getElementById('line_no_').dataset.lineNo = dtl_cnt;
    // datasetにユニークキーを挿入
    document.getElementById('line_no_').dataset.uniqueKey = uniqueKey[dtl_cnt - 1];

    // idを変更
    chgCloneID(document.forms[FRM_NAME].detail_id.value, dtl_cnt);

  });

} // function

// 明細行の一括削除
function rmvIchiran() {
  // 中身をすべて削除
  document.getElementById('ICHIRAN_AREA').innerHTML = '';

  /*
  // あるいはこちらの方法で行う
  let parent = document.getElementById('ICHIRAN_AREA');
  // 子要素を全て削除
  while (parent.firstChild) {
    parent.removeChild(parent.firstChild);
  }
  */
} // function

api_mockディレクトリにJSONファイルを3つを用意します。

●get_ichiran1.json

{
  "line_count" : 3,
  "unique_key" : [
    "C0001","C0002","C0003"
  ],
  "result" : [
    {"name" : "cust_code_1", "value" : "C0001"},
    {"name" : "cust_name_1", "value" : "株式会社ああああああ"},
    {"name" : "cust_tantousha_1", "value" : "東京太郎"},
    {"name" : "cust_code_2", "value" : "C0002"},
    {"name" : "cust_name_2", "value" : "株式会社いいいいいい"},
    {"name" : "cust_tantousha_2", "value" : "大阪次郎"},
    {"name" : "cust_code_3", "value" : "C0003"},
    {"name" : "cust_name_3", "value" : "株式会社うううううう"},
    {"name" : "cust_tantousha_3", "value" : "名古屋三郎"}
  ]
}

●get_ichiran2.json

{
  "line_count" : 4,
  "unique_key" : [
    "C0004","C0005","C0006","C0007"
  ],
  "result" : [
    {"name" : "cust_code_1", "value" : "C0004"},
    {"name" : "cust_name_1", "value" : "株式会社ええええええ"},
    {"name" : "cust_tantousha_1", "value" : "アメリカ太郎"},
    {"name" : "cust_code_2", "value" : "C0005"},
    {"name" : "cust_name_2", "value" : "株式会社おおおおおお"},
    {"name" : "cust_tantousha_2", "value" : "イギリス次郎"},
    {"name" : "cust_code_3", "value" : "C0006"},
    {"name" : "cust_name_3", "value" : "株式会社かかかかかか"},
    {"name" : "cust_tantousha_3", "value" : "フランス三郎"},
    {"name" : "cust_code_4", "value" : "C0007"},
    {"name" : "cust_name_4", "value" : "株式会社きききききき"},
    {"name" : "cust_tantousha_4", "value" : "ドイツ四郎"}
  ]
}

●get_meisaiC0001.json

{
  "result" : [
    {"name" : "cust_code", "value" : "C0001"},
    {"name" : "cust_name", "value" : "株式会社ああああああ"},
    {"name" : "cust_addr", "value" : "東京都新宿区四谷番外地"},
    {"name" : "cust_tantousha", "value" : "東京太郎"}
  ]
}

実行方法

http://localhost/~(ユーザー名)/test_ichiran.html

初期表示はget_ichiran1.jsonの値が一覧表示されているはずです。

「一覧1」を押下した場合は、get_ichiran1.jsonの値が一覧表示されます。
「一覧2」を押下した場合は、get_ichiran2.jsonの値が一覧表示されます。

このボタンは、絞り込みの代わりだと思ってください。

C0001の行をクリックすると、明細画面(test_meisai.html)へ遷移します。

他のコードでも明細画面に遷移しますが、対応するJSONが用意されていませんので、コード・名称等は表示されません。
(404エラーになります。)
必要の都度、明細用のJSONファイルを追加してみてください。
(C0002の場合は、get_meisaiC0002.jsonを追加してください。)

プログラムの説明

一覧画面は、前回の明細行追加を応用して実装していますが、前回の明細行追加と異なる点としては、

  • 枠を作ってデータを流し込むという手法自体は同じ
  • 1行追加というニーズがないので、function内でループするようにする
  • 入力項目がないので、nameはサポートせず、idのみのセットとする
    (detail_idにtemplateにあるtdのidをセット)
  • TRタグ内にユニークキーを忍ばせておき、これを明細に遷移する際のGETパラメータとして活用する

最大行の、入力画面との扱いの違い

入力画面では、最大表示行は、予め表示する行という意味合いだったので、明細行より多くの空行を設定することが出来ました。

一覧画面はそうではなく、あくまでも表示行を超える空行は表示してはいけません。
また、明細行が最大行を超えた場合は、最大行を優先します

今回は実装しませんでしたが、最大表示行数を変更した場合(onchange)、API⇒再表示することも可能かと思います。

明細の一括クリア

// 明細行の一括削除
function rmvIchiran() {
  // 中身をすべて削除
  document.getElementById('ICHIRAN_AREA').innerHTML = '';

  /*
  // あるいはこちらの方法で行う
  let parent = document.getElementById('ICHIRAN_AREA');
  // 子要素を全て削除
  while (parent.firstChild) {
    parent.removeChild(parent.firstChild);
  }
  */
} // function

ボタンを押下するたび、一度、明細行をすべてクリアし、再度明細行を描画しています。

rmvIchiran()で有効となっているロジックは、子要素すべてをinnerHTMLを消すといった、ざっくりとしたロジックになっています。
とてもシンプルなロジックですが、他のブログ等を見ると、多数のエレメントが入っている場合は、時間がかかるとのことです。

状況によっては、コメントとなっている行に入れ替えてください。
手間がかかっている処理のようですが、こちらのほうが多少は速いとのことです。

明細画面表示用にユニークキーを忍ばせておく

明細に画面遷移する際に使用するユニークキー(プログラム例では取引先コード)は、JSON内の、unique_keyにまとまっています。

  "unique_key" : [
    "C0001","C0002","C0003"
  ],

HTMLでは、

<tr id="line_no_" data-unique-key="" data-line-no="" onclick="goMeisai(this)">

にて、器(detaset)を用意し、

// datasetにユニークキーを挿入
document.getElementById('line_no_').dataset.uniqueKey = uniqueKey[dtl_cnt - 1];

で、JSON値(unique_key)からセットします。

TRタグ内をクリックした場合、

// 明細画面へ
function goMeisai(objThis) {
  let pgm = "test_meisai.html?uk=" + objThis.dataset.uniqueKey;
  location.href = pgm;
}

(中略 HTML展開後)
<tr id="line_no_1" data-unique-key="C0001" data-line-no="1" onclick="goMeisai(this)">

TRタグ内のdata-unique-keyの中身をパラメータcsにセットし、明細画面への遷移を行います。

例題ではdata-unique-keyと、1つのdatasetで行っています。
ユニークキーが2つ以上のコードで構成されている場合は、カンマ区切りでセットし、明細側のJavascript(あるいは明細側で呼ばれるAPI内部)で、カンマ区切りを配列化すると良いでしょう。

この記事の一覧画面をアレンジする

この記事の一覧画面はなんだか物足りない気がします。
下記が足りませんね。

  • 行削除がない(scaffoldみたいなヤツ?)
  • ページ制御がない
  • 明細件数も変更できない
  • 条件の絞り込みがない
  • ソートがない
  • 一覧画面に戻った場合、各種条件がリセットされる

これらの処理を付加する際の処理手順や注意点を洗い出していきます。

行削除を実装するには?(画面遷移は不要)

今回の記事では未実装ですが、Ruby on Railsのscaffoldの一覧のように、一覧から、レコード削除したいこともあるかと思います。

もし、実装する場合は、以下のようにすると良いでしょう。

  1. (各行に設置した)「行削除」の旨のボタンを押下した場合、Confirmダイアログを表示する
    • 「キャンセル」の場合は何もしない
  2. 行削除(上記例の場合は顧客レコードの削除)のAPIを実行する
  3. 削除完了後、再度、一覧画面APIを呼び出す
  4. JSONをもとに一覧を再作成する

行をJavascriptで削除しても良いかと思いますが、常に最新のデータを表示させたいのであれば、削除用のAPIで、削除後の一覧をJSON化して渡してもらうようにすれば良いと思います。
(一覧の表示ロジックは初期表示用のAPIと共通化しておいたほうがよいでしょう(バックエンド側)。)

これは、追加や更新も同じかと思いますが、追加や更新は画面遷移があり、更新後、一覧に画面遷移した際に通常の一覧取得APIを実行しますので、追加・更新APIで一覧を返す必要はないと思います。

ページ制御を考える場合は?(画面遷移は不要)

今回はページングも未実装ですが、行う場合は…
(ページごとにAPIを実行する場合。)

  1. フロントエンド側は、APIに以下のパラメータを渡す
    • 1ページの表示可能行
    • 今、何ページ目か
  2. API側は、ページ数に対応するデータと、実際の最大レコード数を返す(あるいは、最大(=最終)ページ数を渡す)
  3. フロントエンド側は、一覧をクリアし、再描画する
  4. フロントエンド側は、取得した最大レコード数or 最大(=最終)ページ数でページ欄を表示する

といった工夫が必要かと思います。

すべてのレコードをAPIで取得し、Javascriptで制御するケースもあるかもしれませんが、絞り込みや、削除や更新・追加などまで考えた場合、ちょっと大変なような気がします。
ページごとにAPIを実行したほうが楽かもしれません。

条件の絞り込みを行いたい場合は?(画面遷移は不要)

今回の記事では未実装ですが、絞り込みを行いたいというニーズはあると思います。

「一覧1」「一覧2」ボタンが、絞り込みの代わりになっています。

  1. フロントエンド側は、APIに以下のパラメータを渡す
    • 絞り込み条件
    • ページング情報(前項参照)
  2. API側は、対応するデータと、実際の最大レコード数を返す(あるいは、最大(=最終)ページ数を渡す)
  3. フロントエンド側は、一覧をクリアし、再描画する
  4. フロントエンド側は、取得した最大レコード数or 最大(=最終)ページ数でページ欄を表示する

ソート順を変更したい場合は?(画面遷移は不要)

処理詳細は省略しますが、同様にAPIを起動し、表を再描画することで解決できそうです。
その場合の注意点として…

  • ソート順や検索条件、1ページの表示件数が変更された場合は、1ページ目からにリセットする

といった、仕様上のキメは必要ですね。

一覧画面に戻った場合、各種条件がリセットされる(画面遷移あり)

プログラム例では、test_ichiran.html ⇒ test_meisai.htmlのみ、画面遷移がありますよね。
画面遷移があった場合は、GETパラメータで受け渡ししないと、ページングもソートも検索条件もすべて消えてしまいます。

この場合は、

  1. 一覧⇒明細の際に、明細に一覧の条件をGETパラメータで渡す
  2. 一覧に戻る際もGETパラメータで一覧画面に渡す

とすることにより、少なくとも、一覧の条件はGETパラメータで取得できることになります。

ただし…そこまで頑張るかどうかですね。
というのも、検索条件だけではなく、昇順・降順や、ページの明細件数、現在のページ数といった項目も、受け渡しする必要があるからです。
さらに、それらを画面に反映する必要がありますし、パラメータに対するXSS攻撃対策を施す必要があります

パラメータのXSS対策については、下記の記事をご参照ください。

inuinu-tech.hatenablog.jp

明細画面をなんとか画面遷移しないで済ませられないか?

GETパラメータのXSS攻撃まで考えるなんて、ちょっと大変ですよね。
なんとか回避できないでしょうか?

それには、2つの手法があると思います。

  • 一覧に各行にアコーディオン機能を付け、ボタン押下時に、補足行として明細を表示できるようにする
  • モーダル画面を用意し、行押下時にモーダル表示をONにし、モーダルに明細を描画する

どちらも割と見かける方法だと思います。 PCで表示する分にはどちらでも良いかと思いますが、モバイルで表示する際は、どちらを採用してもレイアウトがシビアになります。

ただ、画面遷移でパラメータに四苦八苦するくらいならば、このふたつの手法で乗り切ったほうが、メンテナンスが楽かと思います。

例として明細表示をあげましたが、これが明細の変更や追加といった入力処理では、アコーディオンやモーダルでは、処理の複雑化を招く気がします。
もし、これが管理画面であるのならば、サーバーサイドフレームワークのscaffold機能などで、かんたんに作成したほうが、工数やメンテンナンス性で、かなり楽になります。

一覧画面用のデータ取得はすべて一つのAPIで行う

一覧画面には、ソート順やページング、明細行数の変更、これらを別々のAPIを呼んで…なんて考えていると、とんでもない数のAPIを作らなければいけませんよね。
ではなく、以下の3つのAPIで処理しましょう。

  • 一覧画面表示用のAPI
    ページング、ソート、絞り込みなどはすべてこちらで
  • 明細削除用のAPI
  • 明細表示用のAPI
    (アコーディオンを開いたり、モーダルを表示する段階でAPI起動)

そのうち、一覧画面表示用のAPIについて、アレンジ部分のパラメータや画面遷移を、再度、整理してみます。

タイミング 画面遷移 APIに渡すパラメータ※1の内、左欄に関わる箇所
初期表示 (いわゆるデフォルト値でAPI起動)
絞り込み検索欄の変更 絞りこみ項目の数のパラメータが必要 ※2
1ページの表示可能件数を変更 1ページの可能表示件数
次ページへ、前ページへ、
あるいはページ番号を押下
表示したいページ番号
ソート順番の変更 ソート項目
昇順・降順の区分
明細画面から画面遷移ありで戻った場合 ○※3 ※4 (すべてを受け渡しする必要がある)
明細を
アコーディオンやモーダルで代用

※1 原則的にすべてのパラメータを渡したほうが良い
※2 項目によってはSQLインジェクション対策が必要(API側でプレースホルダを利用するなど)
※3 明細画面に上記の状態をすべてGETで渡し、一覧画面に戻ってきた際にもそのGETパラメータを取得することが前提
※4 項目によってはエンコード・デコード(XSS攻撃対策)が必要

といったところでしょうか。

SQLインジェクションについては、API側の対策ですので、こちらでは割愛します。

GETパラメータのXSS攻撃対策については、こちらをご参照ください。

inuinu-tech.hatenablog.jp

こういったことを考えると、一覧画面は、パラメータ制御がキモで、意外と難易度が高いです。
ですので、何らかのJavascriptのテーブル描画パッケージを活用するか、あるいは、管理画面的なものはバックエンドのSSRフレームワークで構築したほうが楽かもしれませんね。

最後に

細かい箇所については、ひととおり解説したと思います。

次回は、共通箇所をどうするか?です。
これが、Vanilla(素のJavascriptでの開発)の一番の苦手箇所かと思います。

inuinu-tech.hatenablog.jp

それでは、また。