Old-School Front-End / 2026 ・ No.15
いにしえのフロントエンド入門
第15回(最終回) 仕上げ ── 道具で点検し、守りを固め、公開し、フレームワークの入口に立つ
公開
AI に頼まず、一行ずつ手で書く「いにしえの」フロントエンド入門。最終回は仕上げです。
15回かけて作った経費精算システムを、道具で点検し、見つかったところを直します。
次に、もしもの穴をふさぐ守りを足し、公開のしかたを整理します。
最後に、同じ明細の表を Vue で書いて、フレームワークの入口から中をのぞきます。
最終回のゴール
新井先輩、全部の画面ができました! 申請して、承認して、集計して、印刷までできます。もう完成ですよね?
古井動くようになった、だな。完成は、人に使ってもらえる状態のことだ。点検して、守りを固めて、外に出す。
新井点検って、目で見て回るんですか。
古井目は疲れるし、見落とす。道具に任せられるところは任せる。いにしえの職人も、ものさしは使ったものだ。
いまここ:第15回(最終回)
- 点検する ── HTML の検査、読み上げ・操作のしやすさ、ページの重さを、道具で数える。見つかったら直す
- 守りを固める ── 書いていないスクリプトを動かさない(CSP)、よそのサイトの枠に入れさせない、借りたファイルのすり替えに気づく(SRI)
- 公開のしかたを知る ── 画面のファイルはどこに置き、API はどこに置くか。公開の前にやること
- フレームワークの入口に立つ ── 第8回の明細の表を Vue で書き、何が楽になり、何が要るようになるかを確かめる
道具で点検する
この連載では毎回、HTML を Nu Html Checker(vnu)で確かめてきました。最後に、ログイン・申請一覧・申請フォーム・承認・ダッシュボードの5つの画面をまとめて調べ、さらに読み上げ・操作のしやすさを axe-core(4.14.0)で調べました。点検の結果は、表のページにまとめています。
# HTML の検査(Nu Html Checker。3言語とも同じ) ページ エラー login.html 0 index.html 0 form.html 0 approve.html 0 dashboard.html 0 vue.html 16 ← Vue のテンプレートの属性(v-for・:key・@click など)
HTML は5画面ともエラーなし。vue.html の16件は、八で書く Vue のページの話なので、あとで戻ります。axe-core のほうは、見落としがありました。
# axe-core が指摘したところ(color-contrast。直す前の値は axe-core の記録、後は同じ式で計算) 部品 文字の色 背景 直す前 直した後 ヘッダーのメニューと名前 #9aafbc → #d2dce1 #1f4e6b 3.91:1 6.38:1 グレーのボタン(戻る・下書き保存・印刷する) #6c757d → #5c636a #f8f9fa 4.44:1 5.78:1 # 指摘の数(5ページ合計) 直す前 14 か所 → 直した後 0 か所(3言語とも)
直した2か所。目で読めても、数字では足りていなかった
第4回で「文字と背景のコントラストは 4.5:1 以上」と書いたのに、ヘッダーの文字は 3.91:1 でした。Bootstrap の暗いナビゲーションの決まりの色(白の55%)を、そのまま使っていたからです。グレーのボタンも 4.44:1 で、わずかに足りません。どちらも CSS の変数を2行ずつ書きかえて直しました。
/* 第15回:文字と背景のコントラストを 4.5:1 以上に(axe-core の指摘で直した) */
.brand-bar {
--bs-navbar-color: rgba(255, 255, 255, .8); /* ヘッダーの文字(もとは白の 55%) */
--bs-navbar-hover-color: #fff;
}
.btn-outline-secondary {
--bs-btn-color: #5c636a; /* グレーのボタン(もとは #6c757d) */
--bs-btn-border-color: #5c636a;
}
直したあと、3つの言語の5画面すべてで、指摘は0になりました。もう1つ、ふだんの操作を通したブラウザの記録に、404 が1件ありました。ブラウザがタブのアイコンを探して /favicon.ico を取りに行き、無かったのです。全ページの <head> に <link rel="icon" href="img/logo.svg"> を足して、0件にしました。
新井14回も見てきた画面なのに、まだあったんですね。
古井人の目は、慣れたものほど見ない。だから道具を使う。ただし、道具が見つけられるのは一部だけだ。キーボードだけで最後まで使えるか、読み上げで意味が通るかは、人が試す(第8回・第12回でやったように)。
重さを量る
申請フォームを開いたときに読み込むファイルを数え、実際に操作したとき(チェック・行の追加と削除・スマホ幅のメニュー)にどれだけ使われたかを、Chromium のカバレッジで量りました。
# 申請フォームを開いたときに読み込むファイル(バイト)と、操作して実際に使われた割合(Chromium のカバレッジ。日本語版で計測) ファイル バイト gzip 使われた form.html 7,880 2,596 css/custom.css 3,131 1,551 30% vendor/bootstrap/css/bootstrap.min.css 232,111 30,869 11% vendor/bootstrap/js/bootstrap.bundle.min.js 80,496 23,743 22% js/app.js 2,228 1,209 61% js/auth.js 1,819 1,070 54% js/validate.js 3,650 1,684 53% js/form.js 10,398 4,096 70% img/logo.svg 356 235 合計(9ファイル) 342,069 67,053
- 全部で約 342KB。そのうち 232KB が Bootstrap の CSS で、使われたのは約1割
- 自分で書いた JavaScript(app.js・form.js など)は、半分以上が使われている。使われていないのは、エラーのときや、ドルを選んだときにだけ動く部分
- gzip で送れば、全体は約 67KB になる。圧縮は、公開先の配信のしくみがやってくれることが多い(七)
使っていない CSS を削るには、Bootstrap を Sass から必要な部品だけ組み立てるか、使っていない規則を取り除く道具を使います。どちらも、ファイルを変換するビルドの道具の仕事です。ここが、手書きだけで進む道と、道具を組み合わせる道の分かれ目になります(八)。経費精算のような社内の画面なら、342KB でも十分に軽いので、今回は削りませんでした。
守り①:書いていないスクリプトを止める
第8回では、人が書いた文字は textContent で入れ、innerHTML に入れない、と決めました。第14回では、jQuery の .html() に入れたスクリプトが本当に動くのを見ました。穴をふさぐのは、コードの書き方です。でも、どこかで1か所だけ抜けたら? その二重の守りが CSP(Content Security Policy)です。
「このページで動いてよいもの」の約束を、ページと一緒に渡す
CSP は、サーバーが返事の見出しに付けます。練習用サーバーには、次の関数を足しました。
// 第15回:守りのヘッダー。ページにも API の返事にも付ける(S.secure を 0 にすると付けない。比べるため)
function secureHeaders(res, p) {
if (!S.secure) return;
const evalOk = S.cspEval && p === '/vue.html'; // ビルドなしの Vue のページだけ、文字からプログラムを作ることを許す(比べるため)
res.setHeader('Content-Security-Policy', [
"default-src 'self'",
"script-src 'self'" + (evalOk ? " 'unsafe-eval'" : ''),
"style-src 'self'",
"img-src 'self' data:",
"connect-src 'self'",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
].join('; '));
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Referrer-Policy', 'same-origin');
}
- script-src 'self' ── スクリプトは、このサイトのファイルだけ。HTML の中に直接書いたスクリプトや onclick="…"、文字からプログラムを作る eval は動かない
- style-src 'self' ── スタイルも、このサイトの CSS ファイルだけ。HTML に書いた style="…" は効かない(JavaScript の el.style.width = … は効く)
- img-src 'self' data: ── Bootstrap の CSS は、チェックボックスなどの印を data: の画像で持っているので、それは許す
- frame-ancestors 'none'、nosniff ── 五で
この連載では、最初からスクリプトもスタイルもファイルに分けて書いてきたので、ヘッダーを足しても書き直すところはありませんでした。実際に、申請を送る・承認する・ダッシュボードを開く、を3つの言語で通して、違反は1件も出ていません。ヘッダーなしとありで、差し込みを試しました。
# 守りを確かめる(Chromium で実測。左がヘッダーなし、右があり) 試したこと ヘッダーなし ヘッダーあり innerHTML で差し込んだ img の onerror 実行された 止まった(script-src-attr) よそのサイト(:3001)の iframe で開く 開けた 断られた(frame-ancestors) SRI:正しい integrity 読み込んだ 読み込んだ SRI:1文字だけ違う integrity エラー(読み込まない) エラー(読み込まない) ふだんの操作(申請・承認・ダッシュボード)での違反 – 0 件(3言語)
innerHTML で差し込んだ <img onerror> は、ヘッダーなしでは動き、ありでは止まりました(違反の種類は script-src-attr、つまり「属性に書いたスクリプト」)。ちなみに、点検に使った axe-core も、ページに差し込むスクリプトなので、CSP に止められました。道具を使うときだけ、ブラウザの設定で CSP を外して調べています。それくらい、よく効きます。
守り②:枠に入れさせない・すり替えさせない
枠に入れさせない。悪いサイトが、承認の画面を透明な枠(iframe)で重ね、「プレゼントを受け取る」ボタンのつもりで「承認する」を押させる手口があります(クリックジャッキング)。frame-ancestors 'none' を付けると、よそのページの枠の中では開かなくなります。上の表のとおり、別のサイト(ポート 3001)から枠で開くと、ヘッダーなしでは開け、ありでは断られました。
中身を決めつけさせない。X-Content-Type-Options: nosniff は、「このファイルは JavaScript ではない」と言ったものを、ブラウザが中身を見て勝手に JavaScript として動かすのを止めます。
すり替えに気づく。この連載では Bootstrap も jQuery も自分のサイトに置きましたが、CDN から読み込むことも多いでしょう。そのとき、CDN のファイルが書きかえられていたら? SRI(Subresource Integrity)は、ファイルの「指紋」(ハッシュ)を HTML に書いておき、違ったら動かさないしくみです。
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/jquery.min.js" integrity="sha384-fgGyf7Mo7DURSOMnOy7ed+dkq5Job205Gnzu6QIg0BOHKaqt4D76Dt8VlDCzcMHV" crossorigin="anonymous"></script>
この値は、npm の jQuery 4.0.0 の配布ファイルから計算しました。正しい値なら読み込み、1文字だけ変えるとエラーになって動きませんでした(上の表)。ファイルの版を上げたら、指紋も書き直します。
守り③:画面では守れないもの
ここまでの守りは、ブラウザに「約束を守ってもらう」ものです。でも、画面の JavaScript は利用者の手元で動くので、だれでも読めて、だれでも書きかえられます。この連載で何度も言ってきた「守るのはサーバー」を、表にまとめます。
画面ができることと、サーバーにしかできないこと
- 入力のチェックは、画面では案内、サーバーでは本番(第9回)。画面のチェックは、fetch を直接打てば通り抜けられる
- ログインと権限(第11回)、同時に押されたときの 409(第12回)は、サーバーだけが判断できる
- パスワードは、サーバーでそのまま保存しない(ハッシュにする)。外部のサービスの秘密の鍵は、画面の JavaScript に書かない。書いた瞬間に、全員に配ったのと同じになる
新井AI に「API の鍵を入れて画面から呼んで」って頼んだら、普通に JavaScript に書いてくれましたよ。
古井頼まれたとおりに書いたんだろう。どこに置くかを決めるのは、頼む側の仕事だ。
公開する
手書きの画面の良いところは、ファイルをそのまま置けば動くことです。変換の手順がありません。いま読んでいるこのブログも、HTML と CSS と画像を、静的なファイルを配信するサービス(Cloudflare Pages)に置いて公開しています。
画面のファイルは静的な配信へ、API はサーバーへ
- https ── ログインのクッキー(第11回)に Secure を付け、暗号化した通信でしか送らないようにする。練習用サーバーは http なので付けていない
- 守りのヘッダー ── 静的な配信のサービスでは、サーバーのコードの代わりに設定ファイルで付ける。たとえば Cloudflare Pages なら _headers というファイルに書ける
- キャッシュ ── ブラウザや CDN は、同じ名前のファイルを覚えておく。中身を変えたら、form.js?v=2 や form.2.js のように名前も変えると、古いものを使われない
- 裏口を消す ── 練習用の /__ctl や、デモ用のアカウント・パスワードは、本番に持ち込まない
フレームワークの入口
最後に、第8回の「明細の行の追加・削除と合計」を、Vue 3(3.5.44)で書いてみます。React や Vue のようなフレームワークは、いまの現場でよく使われる道具です。何がちがうのでしょうか。
「どうやって画面を変えるか」から、「データと画面の関係」へ
第8回の合計は、こう書きました。表の行を自分で1つずつ読み、計算して、画面の文字を自分で書きかえています。
/** 画面から明細を読み、合計とお知らせを表示する(第10回:ドルは換算して足す) */
function updateTotal() {
const items = [];
let unreadable = 0;
let waiting = 0;
for (const row of tbody.rows) {
const text = row.querySelector('[name="amount"]').value;
const usd = row.querySelector('[name="currency"]').value === 'USD';
const amount = usd ? toCents(text) : toAmount(text);
if (amount === null) continue; // 空欄は数えない
if (Number.isNaN(amount)) { unreadable++; continue; } // 金額として読めない
if (!usd) items.push({ amount });
else if (rate === null) waiting++; // レートがまだ無い
else items.push({ amount: centsToYen(amount, rate) }); // ドルは円に換算
}
const total = calcTotal(items);
totalOutput.textContent = formatYen(total);
receiptNote.hidden = !needsReceipt(total);
const notes = [];
if (unreadable > 0) notes.push(`金額として読めない行が ${unreadable} 行あります(合計に入れていません)`);
if (waiting > 0) notes.push(`為替レートがないため、ドルの行が ${waiting} 行、合計に入っていません`);
skipNote.textContent = notes.join(' / ');
skipNote.hidden = notes.length === 0;
}
Vue では、HTML の側に「データ rows の1つずつを、表の1行として出す」「合計はここに出す」と書きます(テンプレート)。
<div id="app" v-cloak>
<div>
<table>
<thead><tr><th scope="col">区分</th><th scope="col">内容</th><th scope="col">金額</th><th scope="col">通貨</th><th scope="col"><span>削除</span></th></tr></thead>
<tbody>
<tr v-for="(row, i) in rows" :key="row.id">
<td><select v-model="row.category" :aria-label="`${i + 1}行目の区分`">…</select></td>
<td><input v-model="row.item" :aria-label="`${i + 1}行目の内容`"></td>
<td><input v-model="row.amount" inputmode="numeric" autocomplete="off" :aria-label="`${i + 1}行目の金額`"></td>
<td><select v-model="row.currency" :aria-label="`${i + 1}行目の通貨`"><option value="JPY">円</option><option value="USD">ドル</option></select></td>
<td><button type="button" @click="remove(i)" :aria-label="`${i + 1}行目を削除`">削除</button></td>
</tr>
</tbody>
</table>
</div>
<div>
<button type="button" id="add-row" @click="add" :disabled="rows.length >= MAX_ROWS">+ 行を追加</button>
<p>合計:<strong id="total">{{ formatYen(total) }}</strong></p>
</div>
<p v-if="needsReceipt(total)">合計が3万円以上なので、領収書の添付が必要です。</p>
<p id="rate-note">{{ rate === null ? '為替レートを取得中…' : `ドルは 1ドル = ${rate}円 で換算しています` }}</p>
</div>
JavaScript の側は、データと計算だけです。画面を探す querySelector も、書きかえる textContent も出てきません。行を消すのも、配列から1つ取り除くだけです。
// 経費精算システム:明細の表 ── Vue 版(第15回)
// 第8回の「行の追加・削除と合計」を Vue 3 で書いたもの。画面を書きかえる代わりに、データを書きかえると画面がついてくる
const { createApp, ref, computed, onMounted } = Vue;
const MAX_ROWS = 10;
createApp({
setup() {
let nextId = 1;
const newRow = () => ({ id: nextId++, category: '', item: '', amount: '', currency: 'JPY' });
const rows = ref([newRow()]); // 明細のデータ。画面はこれを映すだけ
const rate = ref(null); // 1ドル何円か(第10回と同じく、サーバーから取る)
const total = computed(() => { // rows か rate が変わると、自動で計算し直される
let sum = 0;
for (const row of rows.value) {
const usd = row.currency === 'USD';
const amount = usd ? toCents(row.amount) : toAmount(row.amount); // 計算は第7回・第10回の関数をそのまま使う
if (amount === null || Number.isNaN(amount)) continue;
if (!usd) sum += amount;
else if (rate.value !== null) sum += centsToYen(amount, rate.value);
}
return sum;
});
const add = () => {
if (rows.value.length < MAX_ROWS) rows.value.push(newRow());
};
const remove = (i) => {
rows.value.splice(i, 1);
if (rows.value.length === 0) add(); // 最低1行は残す
};
onMounted(async () => { // 画面ができたら、レートを取りに行く
const res = await fetch('/api/rates');
if (res.ok) rate.value = (await res.json()).rate;
});
return { rows, rate, total, add, remove, formatYen, needsReceipt, MAX_ROWS }; // テンプレート(vue.html の #app の中)から使うものを渡す
},
}).mount('#app');
requireLogin(); // ログインの確認とヘッダーの名前(第11回の auth.js)
動かすと、こうなりました。
# vue.html を開いたとき(Chromium で実測) CSP 画面 記録 厳しいまま(script-src 'self') 表は出ない 違反:script-src eval 'unsafe-eval' を足す 明細3行・合計 46,851 円 1行消すと 2 行・18,193 円
CSP を厳しくしたままだと、表がまるごと出ません。ビルドなしで使う Vue は、ブラウザの中で HTML のテンプレートをプログラムに変換します。そのとき文字からプログラムを作る(eval と同じ)ので、四の CSP に止められたのです。止まった先の行(ヘッダーの名前を出す処理)も動かず、「確認中…」のままでした。'unsafe-eval' を許すと、合計 46,851 円まで、第8回と同じに動きました。また、v-for や @click のような書き方は HTML の決まりにないので、vnu には16件のエラーと出ます(二)。
実際の現場では、Vue のテンプレートは .vue という専用のファイルに書き、公開の前にビルドの道具(Vite など)でふつうの JavaScript に変換しておきます。そうすれば eval は要らず、CSP も厳しいままにできます。React も、HTML に似た JSX という書き方を、ビルドで JavaScript に変換して使います。つまり、フレームワークの入口には、ビルドの道具という門があるのです。この連載が「ファイルをそのまま置けば動く」ところまでで止まっているのは、その門の手前で、中で何が起きているかを手で確かめたかったからです。
新井Vue、気持ちいいですね……。データを変えるだけで画面がついてくる。
古井そうだろう。で、その「ついてくる」の中身は?
新井……行を読んで、計算して、textContent を書きかえてる。第8回でやったことを、Vue がやってくれてるんですね。
古井それが分かっていれば、どの道具を使っても迷子にならない。
15回をふりかえる
全15回で作ったもの、学んだこと
HTML で意味を書き、CSS で見た目を整え、JavaScript で動きを足し、サーバーと話し、ログインと承認と集計を作り、jQuery で書き直し、最後に点検して守りを固めました。どの回でも、書いて、ブラウザで動かして、記録して、確かめるを繰り返してきました。表に並べた数字は、すべて実際に動かして取った値です。
「いにしえ」は、古い道具にこだわることではありません。中で何が起きているかを、自分の手で確かめられることです。その力があれば、AI に書いてもらったコードも、フレームワークのコードも、落ち着いて読み、直し、守ることができます。手で書く力は、AI を使いこなす土台です。
結 — 一枚まとめと、これから
- 点検は道具で数える。vnu で HTML、axe-core で読み上げ・操作のしやすさ。今回はコントラストが2か所足りず、直して 14 → 0。
- 重さはカバレッジで量る。Bootstrap の CSS は約1割しか使っていない。削るならビルドの道具の出番。
- CSP で、書いていないスクリプトを止める。スクリプトとスタイルをファイルに分けておけば、足すだけで済む。
- frame-ancestors で枠に入れさせず、nosniff で決めつけさせず、SRI ですり替えに気づく。
- 秘密の鍵とパスワードは画面に書かない。守るのはサーバー。
- 公開は、画面は静的な配信、API はサーバー。https・ヘッダー・キャッシュ・裏口に気をつける。
- フレームワークは「データと画面の関係」を書く道具。入口にはビルドの道具がある。
新井先輩、15回ありがとうございました。次は Vite と Vue を、ちゃんとやってみます。
古井いい。AI も使え。ただし、出てきたコードは、今日みたいに動かして、記録して、確かめろ。
新井はい。……ところで、打ち上げの領収書、申請してもいいですか?
古井3万円以上なら、領収書の添付を忘れるなよ。画面が教えてくれるはずだ。
参考
- The Nu Html Checker (v.Nu)
- axe-core (Deque Systems)
- W3C — Understanding SC 1.4.3: Contrast (Minimum)
- MDN — Content Security Policy (CSP)
- MDN — CSP: script-src
- MDN — CSP: script-src-attr
- MDN — CSP: frame-ancestors
- MDN — X-Content-Type-Options
- MDN — Subresource Integrity
- Cloudflare Pages — Headers
- Vue.js — Quick Start
ページの写真は、筆者が作ったデモページを、練習用のサーバー(Node.js)経由で2026年10月19日にクラウド上の Chromium(Playwright)で撮影したものです(ページの時計は 2026年10月17日 15:00 に固定)。撮影環境には Hiragino Sans がないため、日本語は Noto Sans CJK JP で表示されています。アカウント・申請は、練習用サーバーの架空のデータです。HTML の検査は Nu Html Checker(vnu)、読み上げ・操作のしやすさは axe-core 4.14.0、ページの重さと使われた割合は Chromium のカバレッジ、CSP・frame-ancestors・SRI の効き目と Vue(3.5.44)のページの動きは、同じ Chromium を Playwright で操作・実行して記録した実際の値です。点検に axe-core を差し込むときだけ、ブラウザの設定で CSP を外しています。登場人物は架空の人物です。15回のお付き合い、ありがとうございました。
いにしえのフロントエンド入門 第15回(最終回) — 仕上げ / 2026.10