目次を開く(全 15 章)
01学習レベル:基礎
読みやすいコード
基本編で学んだ名前と書式を、関数や条件を含むコードにも広げます。同じ処理を何度も読み返すので、短さより意図を追えることを優先します。
- 意味を表す名前 —
xや漠然としたnumberより、「売れたりんごの数」と分かるapplesSoldのような名前にします。 - 式を分ける — 長い条件や計算の途中結果を、説明する名前の
constに入れます。 - 書式をそろえる —
remaining = total - shownのように演算子の前後、if (condition) {のようにキーワードと(の間に半角空白を入れます。チームではフォーマッターの設定を共有します。 - コメントは理由を残す — How(どう処理するか)は「引き算の結果が負なら 0 にする」という手順、Why(なぜそうするか)は「更新のタイミングがずれても、負の在庫数を表示しないため」という理由です。コードを見れば分かる手順は繰り返さず、判断の理由を残します。複雑な手順は How も説明します。
- まとまりの見出し — 長いファイルなら主要なクラスや処理群の前に短い見出しコメントを置きます。各行への実況は不要です。
// ---- 売り場のりんごの数 ----
function countApplesLeft(applesForSale, applesSold) {
const applesLeft = applesForSale - applesSold;
// 販売数の更新が遅れても、画面に負の在庫数は出さない。
return Math.max(0, applesLeft);
}
// ---- りんごを売れるかの判定 ----
function canSellApple(applesForSale, applesSold, isShopOpen) {
const hasApplesLeft = applesSold < applesForSale;
return isShopOpen && hasApplesLeft;
}const は再代入しない値、let は表示件数など更新する値に使います。オブジェクトの中身を変えられる点も含め、基本編の変数で確認できます。
02学習レベル:実践
状態と画面を分ける
進捗や選択中の項目は JavaScript のデータとして持ち、画面はその状態を表示します。操作のたびに状態を更新してから描画すると、表示と内部データの食い違いを減らせます。
<section data-ui="progress-panel" class="progress-panel">
<!-- 数値の変化を読み上げられるようにする -->
<span data-ui="progress-text" aria-live="polite">0 / 100</span>
<!-- 棒は見た目用。読み上げる情報は上の文字にまとめる -->
<div class="progress-track" aria-hidden="true">
<div class="progress-fill" data-ui="progress-bar"></div>
</div>
<button type="button" class="button" data-action="continue">続ける</button>
</section>
<script>
// ---- このパネルの表示部品を探す ----
// パネル内だけを探し、別の部品を取り違えないようにする。
const panel = document.querySelector('[data-ui="progress-panel"]');
const progressText = panel.querySelector('[data-ui="progress-text"]');
const progressBar = panel.querySelector('[data-ui="progress-bar"]');
const continueButton = panel.querySelector('[data-action="continue"]');
// ---- 進み具合を画面とは別のデータとして持つ ----
const progress = { current: 25, max: 100 };
// ---- データから画面を更新する ----
// 文字・棒・ボタンを同じ値から更新し、表示の食い違いを防ぐ。
function renderProgress() {
progressText.textContent = `${progress.current} / ${progress.max}`;
// 棒の長さは CSS の --progress を通して変える。
progressBar.style.setProperty('--progress', `${progress.current / progress.max * 100}%`);
// 進捗が少ない状態を見分けられるよう、30 未満は注意表示にする。
panel.dataset.state = progress.current < 30 ? 'warning' : 'normal';
continueButton.disabled = progress.current >= progress.max;
}
// ---- 操作を受けたら、先にデータを変えてから画面へ反映する ----
continueButton.addEventListener('click', () => {
// 連続して押しても最大値を超えないようにする。
progress.current = Math.min(progress.max, progress.current + 10);
renderProgress();
});
// 最初の表示も同じ関数で作り、初期値と画面を揃える。
renderProgress();
</script>data-ui は表示部品、data-action は操作の目印です。同じ種類の部品を複数作るときは各部品の中だけを querySelector() で探します。id はラベルの for やページ内リンク、一意な起点には適しています。見た目は CSS の状態指定で管理します。
03学習レベル:基礎
ブラウザとサーバーの往復
ページを開くと、ブラウザ(クライアント)がサーバーへリクエストを送り、サーバーがレスポンスを返します。このやり取りの決まりが HTTP です。ブラウザは受け取った HTML・CSS・JavaScript・画像を使ってページを表示します。
ブラウザ ── GET /about.html ──→ サーバー
ブラウザ ←─ 200 OK + HTML ──── サーバーリクエストには取得先の URL と方法、必要なら送るデータが含まれます。レスポンスには結果を示す番号と、HTML などの中身が含まれます。GET はページやデータを取得するときに使います。ページ内の画像や CSS も、それぞれ別のリクエストで取得されます。サーバーが返すのは HTML に限らず、JavaScript が fetch() で受け取る JSON などもあります。200 は成功、404 は対象が見つからない、500 はサーバー側のエラーを表すステータスコードです。
問い合わせフォームでは、form の action が送信先、method="post" が送信方法を指定します。送信ボタンを押すと、ブラウザが入力値をサーバーへ送り、サーバーが保存やメール送信などの処理をして結果を返します。HTML とブラウザ内の JavaScript だけでは、問い合わせを受け取る処理は完成しません。 送信先のプログラムかフォームサービスが必要です。
ブラウザの開発者ツールで「ネットワーク」を開き、ページを再読み込みします。GET、ファイル名、200 などを探し、リクエストとレスポンスを見比べましょう。次の章では、送信前の入力確認を扱います。JavaScript でデータを取得する例は基本編の非同期処理にあります。
04学習レベル:実践
フォームを JavaScript で検証する
HTML の入力条件だけで、必須・文字数・メール形式・電話番号の形式などを確認できます。JavaScript は「空白だけの文章を送れないようにする」など、属性だけでは表しにくい条件を足すときに使います。
HTML 編の data-ui="contact-form" を付けたフォームに、次のスクリプトを読み込みます。
<script src="./contact-validation.js" defer></script>const contactForm = document.querySelector('[data-ui="contact-form"]');
const message = contactForm.querySelector('[name="message"]');
// ---- 入力し直したら、前回の独自エラーを解除する ----
// エラーを残したままだと、内容を直しても送信できない。
message.addEventListener('input', () => {
message.setCustomValidity('');
});
// ---- HTML の入力条件に「空白だけの本文は不可」を追加する ----
contactForm.addEventListener('submit', (event) => {
// 空白だけの文章は入力済みとして扱わない。
const hasMessage = message.value.trim().length > 0;
message.setCustomValidity(hasMessage ? '' : 'お問い合わせ内容を入力してください。');
// 問題があるときだけ送信を止め、ブラウザに理由を表示してもらう。
if (!contactForm.reportValidity()) {
event.preventDefault();
return;
}
});setCustomValidity() で独自のエラーを設定し、reportValidity() で理由を利用者へ示します。HTML の条件に違反したときは、ブラウザが送信を止めるため submit イベント自体が起きない場合もあります。入力後にエラーを解除しないと、修正しても送れなくなります。
フォームの action に実際の受信先を指定し、送信先でも必須項目・形式・文字数を検証します。JavaScript の検証は操作を助けるためのもので、送信データの信頼性は保証しません。送信完了や失敗の表示は、受信先の応答に合わせて作ります。form.submit() はブラウザの入力確認を通さないため、この例では通常の送信をそのまま使います。見た目の例は CSS 編を参照してください。
花屋サイトのお問い合わせ 制約検証 setCustomValidity() reportValidity() submit イベント
05学習レベル:基礎
オブジェクト指向という考え方
次の章からクラスを書きます。その前に、クラスのもとになった「オブジェクト指向」という考え方を見ておきます。書き方より先に「なぜそう書くのか」を掴んでおくと、クラス、# で始まるプライベートメンバー、継承が、どれも同じ考え方から出てきたものだと分かります。
変数と関数だけで成績を管理する
じゃんけんの成績(勝ち・負け・あいこの数と、対戦の履歴)を、変数と関数だけで書いてみます。データを変数に置き、処理を関数として順に並べていく書き方を手続き型と呼びます。
// 成績のデータ
let wins = 0;
let losses = 0;
let draws = 0;
const history = [];
// 1 回分の対戦を記録する
function addResult(result) {
if (result === 'win') {
wins++;
} else if (result === 'lose') {
losses++;
} else {
draws++;
}
history.push(result);
}
addResult('win');
addResult('draw');
console.log(wins, losses, draws); // 1 0 1
遊ぶ人が 1 人なら、これで困りません。困るのは、たとえば 1 台のパソコンを太郎さんと花子さんが交代で使い、2 人の成績を別々に残したくなったときです。
- 人数分の変数が要る … 上の
winsなどを太郎さんの分とすると、花子さんの分としてwins2・losses2・history2を足すことになります。3 人目が来れば、さらに増えます。addResult()も、どちらの人の変数を増やすかを受け取るように書き直すことになります。 - どこからでも書き換えられること … ページのほかの場所で
wins = 100;と書けてしまいます。勝ちの数と履歴が食い違ったとき、どこで書き換えたのかを探すのに手間がかかります。 - 組み合わせが見えにくいこと …
winsとaddResult()がひと組で使うものだということは、名前から推し量るしかありません。
独立した細胞がメッセージをやり取りする
この困りごとに、別の考え方で答えたのがオブジェクト指向です。アラン・ケイ(Alan Kay)は 1960 年代の終わりに、プログラムを生き物の細胞のように組み立てることを考え、「オブジェクト指向」という言葉を作りました。細胞は、自分の中身を膜で包んで持ち、ほかの細胞とは信号のやり取りだけでつながっています。同じように、プログラムを独立したオブジェクトの集まりにし、オブジェクトどうしはメッセージを送り合って仕事を進める、という考え方です。この考え方は、ケイたちが 1970 年代に作った Smalltalk という言語で形になりました。
JavaScript では、オブジェクトのメソッドを呼ぶことが、メッセージを送ることにあたります。上の成績を、オブジェクトとして書き直します。
class JankenRecord {
// # を付けた中身は、クラスの外から読み書きできない(07 章で詳しく見ます)
#totals = { win: 0, lose: 0, draw: 0 };
#history = [];
// 1 回分の対戦を記録する
add(result) {
this.#totals[result]++;
this.#history.push(result);
}
// 合計の写しを返す
get totals() {
return { ...this.#totals };
}
}
const taro = new JankenRecord(); // 太郎さんの成績
const hanako = new JankenRecord(); // 花子さんの成績
taro.add('win'); // 太郎さんの成績に「勝ちを記録して」と頼む
hanako.add('lose');
console.log(taro.totals); // { win: 1, lose: 0, draw: 0 }
console.log(hanako.totals); // { win: 0, lose: 1, draw: 0 }
taro.add('win') は、「太郎さんの成績」というオブジェクトに「勝ちを記録して」というメッセージを送る、と読めます。頼む側は、中でどう数えているかを知らなくて構いません。手続き型で困った 3 つの場面は、次のように解けています。
- 花子さんの成績がほしいときは、
new JankenRecord()でもう 1 つ作るだけです。taroとhanakoは同じ設計図から作った別々のインスタンスで、それぞれが自分の成績を持ちます。 #totalsは外から書き換えられないので、成績を変える方法はadd()だけになります。- データと、それを扱う処理が、
JankenRecordという 1 つの名前の中にまとまっています。
クラス、インスタンス、継承の仕組みは、ケイより前の 1967 年に、ノルウェーのオーレ=ヨハン・ダールとクリステン・ニガードが作った Simula 67 という言語で生まれました。ケイは、この仕組みにも影響を受けています。ただし、ケイ自身は後年、オブジェクト指向で大切なのはクラスよりも「メッセージのやり取り」と「中身を内側に持って隠すこと」だと述べています。
5 つの用語を掴む
オブジェクト指向の説明によく出てくる用語を、上の成績の例に当てはめます。
| 用語 | 意味 | 上の例では | 詳しく |
|---|---|---|---|
| クラス | オブジェクトの設計図 | class JankenRecord | 06 章 |
| インスタンス | 設計図から作った 1 つずつのオブジェクト。それぞれが自分のデータを持つ | taro と hanako | 06 章 |
| カプセル化(情報隠蔽) | 中身を隠し、決まった窓口(メソッド)からだけ扱えるようにする | #totals と add() | 07 章 |
| 継承 | 既存のクラスを土台に、違うところだけを書き足す | この例にはない(06 章の GiftCart extends Cart) | 06 章の「継承」 |
| 多態性(ポリモーフィズム) | 同じメッセージを送っても、受け取ったオブジェクトがそれぞれのやり方で応える | 次の例 | この章 |
同じメッセージに、それぞれのやり方で応える
多態性の例として、じゃんけんの対戦相手を 2 種類作ります。どちらも chooseHand()(手を選んで)というメッセージを受け取りますが、手の選び方が違います。
class RandomPlayer {
// ランダムに手を選ぶ
chooseHand() {
const hands = ['guu', 'choki', 'paa'];
return hands[Math.floor(Math.random() * hands.length)];
}
}
class GuuPlayer {
// いつもグーを出す
chooseHand() {
return 'guu';
}
}
// 相手がどちらのクラスかを気にせず、同じメッセージを送る
function play(player) {
console.log(`相手の手:${player.chooseHand()}`);
}
play(new RandomPlayer()); // 相手の手:(guu・choki・paa のどれか)
play(new GuuPlayer()); // 相手の手:guu
play() は、相手に chooseHand() を送るだけです。「前回と同じ手を出す相手」のような新しい種類を足すときも、chooseHand() を持つクラスを 1 つ書けば、play() は書き換えずに済みます。手続き型で書くと、if (type === 'random') { … } else if (type === 'guu') { … } のような分岐が、相手の種類が増えるたびに長くなっていきます。
クラスにするかどうかを決める
何でもクラスにする必要はありません。値をひとまとめに持つだけならオブジェクトで、処理を名前を付けてまとめるだけなら関数で足ります。次のようなときに、クラスを選びます。
- 同じ形のデータを、いくつも作る(太郎さんと花子さんの成績、複数のカート)
- データを、決まった方法でしか変えさせたくない(合計と履歴を食い違わせない)
- 同じメッセージに、種類ごとに違うやり方で応えさせたい(対戦相手の種類)
作例のじゃんけんゲームは、会員登録をせずにその場で遊ぶものなので、管理する成績は 1 人分です。それでも成績を JankenRecord クラスにしているのは、2 つめの目安に当たるからです。成績を変える方法を add() だけにして、合計と履歴が食い違わないようにしています。さらに、保存してあった成績や読み込んだファイルから成績を作るときは、中身を確かめる入口(static from())を通すようにしています(アプリ例 08 章)。
class の書き方は、2015 年の仕様(ES2015)で JavaScript に加わりました。内部では、プロトタイプという仕組みで、同じクラスから作ったインスタンスがメソッドを共有しています。最初はプロトタイプを知らなくても、class の書き方で困ることはありません。興味が出たら、MDN の解説を読んでみるのも良いですね。
06学習レベル:実践
クラス — データと処理をまとめる
クラスは、同じ形のデータと、それを扱う処理をまとめる設計図です。同じ種類のものを複数作るときに使います。一つのデータをまとめるだけなら、まずは普通のオブジェクトで十分です。
class で設計図を定義し、new でインスタンス(設計図から作った 1 つずつのオブジェクト)を作ります。constructor はインスタンスを作るときに呼ばれ、this は、いま扱っているインスタンスを指します。add() のような処理をメソッドといいます。
class Cart {
constructor(owner) {
this.owner = owner;
// カートごとに別の配列を作り、別の人の品目と混ざらないようにする。
this.items = [];
}
add(name, price) {
this.items.push({ name, price });
}
get total() {
// 合計を読むたびに計算し、品目を追加した後も古い値を残さない。
return this.items.reduce((sum, item) => sum + item.price, 0);
}
}
const firstCart = new Cart('山田');
firstCart.add('えんぴつ', 120);
firstCart.add('ノート', 280);
console.log(firstCart.total); // 400
const secondCart = new Cart('鈴木');
secondCart.add('ノート', 280);
console.log(secondCart.total); // 280
firstCart と secondCart は同じ設計図から作りましたが、items はそれぞれ別です。get total() はゲッターで、firstCart.total のように () なしで読めます。
ここから先の static と継承は、クラスを使い慣れてから読めば十分です。2 つは別々の仕組みなので、順に見ていきます。
static — クラスそのものに値や処理を持たせる
これまでのフィールド(this.items)やメソッド(add())は、インスタンスごとに持つものでした。firstCart.add() のように、インスタンスから使います。
static を付けると、その値や処理はインスタンスではなくクラスそのものに属します。値を静的フィールド、処理を静的メソッドと呼び、Cart.taxRate や Cart.priceWithTax() のように、クラス名から使います。インスタンスからは使えません。
class Cart {
// 税率はすべてのカートで同じなので、クラスに 1 つだけ持たせる(静的フィールド)
static taxRate = 0.1;
// 1 つの価格を税込みにする(静的メソッド)。カートの中身を使わないので、カートが無くても使える
static priceWithTax(price) {
return Math.round(price * (1 + Cart.taxRate));
}
constructor() {
// 品目の一覧は、カートごとに別々に持つ(インスタンスのフィールド)
this.items = [];
}
add(price) {
this.items.push(price);
}
get total() {
const subtotal = this.items.reduce((sum, price) => sum + price, 0);
// 静的メソッドは、this ではなくクラス名から呼ぶ
return Cart.priceWithTax(subtotal);
}
}
// 商品一覧で税込み価格を見せるだけなら、カートを作らなくてよい
console.log(Cart.priceWithTax(1000)); // 1100
console.log(Cart.taxRate); // 0.1
const cart = new Cart();
cart.add(1000);
cart.add(500);
console.log(cart.total); // 1650
console.log(cart.taxRate); // undefined(インスタンスには無い)
商品一覧のページで「1,000 円(税込 1,100 円)」と並べるだけなら、カートは要りません。priceWithTax() は、渡された価格だけで答えが決まり、カートの中身(this.items)を使わないので、インスタンスを作らずに呼べる静的メソッドにしています。
static を使うのは、主に次の場面です。
- すべてのインスタンスで共通の値 … 税率のように、カートごとに持つ必要がない値です。インスタンスごとに持たせると、税率が変わったときに全部のカートを直すことになります。
- インスタンスの中身を使わない処理 …
priceWithTax()のように、thisを使わずに答えが決まる処理です。このあとの章で使うNumber.isInteger()(整数かどうかを調べる)やArray.isArray()(配列かどうかを調べる)も、組み込みのクラスNumber・Arrayの静的メソッドです。数を 1 つ調べるのに、Numberのインスタンスは作りません。 - 作るかどうかを確かめてから、インスタンスを作る処理 …
newは、いつも新しいインスタンスを作ります。「中身が正しければ作り、誤っていれば作らずにnullを返す」といった判断は、newではできません。そこで、確かめる処理を静的メソッドにし、その中でnewを呼びます。
3 つめの例が、じゃんけんの作例の JankenRecord.from() です。保存してあった成績やファイルの中身を受け取り、形が正しいときだけ成績のインスタンスを作って返します。誤りがあれば null を返すので、誤ったデータが成績になることはありません。
じゃんけんの JankenRecord.from() static
継承 — 既存のクラスを土台に、違うところだけを書き足す
class 子クラス extends 親クラス と書くと、子クラスは親クラスのフィールドとメソッドを引き継ぎます。子クラスには、親と違うところだけを書きます。贈答用のカートを、上の Cart を土台にして作ります。デモは単独で実行するので、冒頭に上の例と同じ Cart を入れてあります。
// 上の static の例と同じ Cart。これを土台にする
class Cart {
static taxRate = 0.1;
static priceWithTax(price) {
return Math.round(price * (1 + Cart.taxRate));
}
constructor() {
this.items = [];
}
add(price) {
this.items.push(price);
}
get total() {
const subtotal = this.items.reduce((sum, price) => sum + price, 0);
return Cart.priceWithTax(subtotal);
}
}
// 贈答用カートは、カートの一種(is-a)
class GiftCart extends Cart {
constructor(message) {
// 子クラスで constructor を書くときは、this を使う前に super() で親の constructor を呼ぶ
super();
this.message = message; // 贈答用カートだけが持つ値
}
// 親と同じ名前で書くと、子クラスではこちらが使われる(オーバーライド)
get total() {
// super.total で親の計算を使い、包装代だけを足す
return super.total + 300;
}
}
const giftCart = new GiftCart('お誕生日おめでとう');
giftCart.add(1000); // add() は親から引き継いだもの
console.log(giftCart.total); // 1400
console.log(giftCart.message); // お誕生日おめでとう
console.log(giftCart instanceof Cart); // true(GiftCart は Cart の一種)
- 引き継ぐ …
GiftCartにはadd()を書いていませんが、親のCartから引き継いだものが使えます。 - 上書きする(オーバーライド) … 親と同じ名前のメソッドやゲッターを子クラスに書くと、子クラスのインスタンスでは子クラスのほうが使われます。
- 親の処理を呼ぶ …
super.totalのようにsuper.を付けると、上書きする前の親の処理を呼べます。親の計算をそのまま使えるので、税率の計算を 2 か所に書かずに済みます。 - constructor では super() を先に … 子クラスに
constructorを書くときは、thisを使う前にsuper()で親のconstructorを呼びます。呼ぶ前にthisを使うとエラーになります。子クラスにconstructorを書かなければ、親のものがそのまま使われます。
is-a(「〜は〜の一種」)は継承を考える目安です。GiftCart は Cart の一種で、品目を add() で追加し、total で合計を読めます。普通のカートを使う場所に贈答用カートを渡しても期待どおりに動く関係なら、extends が候補になります。これは、05 章で見た多態性の 1 つの形でもあります。
has-a(「〜を持つ」)は部品を中に持つ関係です。Cart は品目一覧を this.items に持ちますが、カート自体が配列の一種ではありません。そのため配列を継承せず、フィールドとして持ちます。ほかのクラスの機能を使いたいだけなら、親子関係を作る前に「部品として持てるか」を考えましょう。次は、内部の値を外から変更させない書き方を見ます。
07学習レベル:発展
# で始まるプライベートメンバー
前の節の買い物かごを使って考えます。items をそのまま公開すると、外側のコードが品目を直接書き換えられます。#items にするとクラスの中からだけ使え、品目の追加は add() に任せられます。
class Cart {
// 追加方法を add() に限定し、外から配列を直接書き換えさせない。
#items = [];
add(name, price) {
this.#items.push({ name, price });
}
get total() {
return this.#calculateTotal();
}
// 合計の計算方法も外へ公開しない。
#calculateTotal() {
let total = 0;
for (const item of this.#items) {
total += item.price;
}
return total;
}
}
const cart = new Cart();
cart.add('りんご', 100);
cart.add('みかん', 150);
console.log(cart.total); // 250
// cart.#items はクラスの外からは使えない
console.log(cart.items); // undefined(#items は外からは見えない)
#items はプライベートフィールド、#calculateTotal() はプライベートメソッドです。どちらも Cart の中だけで使えます。_items は名前で「触らないで」と示す慣習ですが、#items は JavaScript 自体が外部アクセスを禁止します。子クラスからも直接は使えないため、必要な操作は公開メソッドで渡します。
ゲッターとセッター — 読み書きの窓口を作る
前の例の get total() は、値を読むときに動くゲッターでした。set volume(value) は、値を代入するときに動くセッターです。たとえば音量を 0〜100 の整数に保ちたい場合、代入時に確認できます。
class SoundSettings {
// 外から直接書き換えて検証を飛ばせないよう、値を非公開にする。
#volume = 50;
get volume() {
return this.#volume;
}
set volume(value) {
// 範囲外の音量を保存しないため、代入時に値を確認する。
if (!Number.isInteger(value) || value < 0 || value > 100) {
throw new RangeError('音量は 0〜100 の整数にしてください');
}
this.#volume = value;
}
}
const sound = new SoundSettings();
sound.volume = 70; // セッターが呼ばれる
console.log(sound.volume); // ゲッターが呼ばれ、70 と表示される
// 範囲外の値を代入すると、セッターが例外を投げる
try {
sound.volume = 150;
} catch (error) {
console.log(`${error.name}: ${error.message}`);
}
console.log(sound.volume); // 70 のまま
sound.volume = 70 はセッターを呼び、sound.volume はゲッターを呼びます。どちらも使う側では () を書きません。sound.volume = 150 は RangeError になります。デモでは、この例外を try / catch(基本編 13 章)で受け止めて、内容を表示しています。
08学習レベル:実践
モジュール — ファイルを分ける
コードが増えてきたら、役割ごとにファイルを分けます。外のファイルでも使うものに export を付け、必要な側で import します。
<script type="module" src="./main.js"></script>export function addTax(price) {
return Math.round(price * 1.1);
}
export function formatYen(amount) {
return `${amount.toLocaleString()} 円`;
}import { addTax, formatYen } from './utils.js';
const priceWithTax = addTax(1000);
console.log(formatYen(priceWithTax)); // 1,100 円3 つのファイルを同じフォルダーに保存します。import { addTax, formatYen } の波括弧内の名前は、export 側と一致させます。ブラウザでは ./utils.js のように相対パスと拡張子まで書きます。HTML には入口の main.js だけを読み込ませれば、そこから必要なファイルがたどられます。
type="module"を付けたスクリプトは HTML の読み込みを妨げず、解析後に実行されます。中身は strict モードで動きます。- ファイル内で宣言した名前は、
exportしない限りほかのファイルから使えません。名前の衝突を避けやすくなります。 file://では通常モジュールを読み込めません。ローカルサーバーで確認します。
09学習レベル:実践
localStorage に設定を保存する
localStorage は同じサイトのブラウザに小さな設定を保存します。ページを閉じても残るので、テーマや表示件数などに向いています。入力途中の内容やゲームの進行状況は、再開機能を設計するときだけ保存しましょう。
// 保存形式を変えたとき区別できるよう、キーに版を含める。
const storageKey = 'site-settings-v1';
const defaultSettings = { theme: 'light', pageSize: 20 };
// ---- 保存済みの設定を読み込む ----
function loadSettings() {
try {
const savedText = localStorage.getItem(storageKey);
if (savedText === null) return defaultSettings;
const saved = JSON.parse(savedText);
// 保存データは古い形式や壊れた値かもしれないので、使う前に確認する。
if (!saved || !['light', 'dark'].includes(saved.theme) ||
!Number.isInteger(saved.pageSize) ||
saved.pageSize < 1 || saved.pageSize > 100) return defaultSettings;
return saved;
} catch (error) {
// 読み込みを拒否された場合も、初期設定で画面を動かせるようにする。
console.warn('設定を読み込めませんでした', error);
return defaultSettings;
}
}
// ---- 次回のために設定を保存する ----
function saveSettings(settings) {
try {
localStorage.setItem(storageKey, JSON.stringify(settings));
} catch (error) {
// 保存できない環境でも、現在の画面まで止めない。
console.warn('設定を保存できませんでした', error);
}
}
// 読み込んだ設定を残し、テーマだけを変えた新しい値を保存する。
const settings = loadSettings();
saveSettings({ ...settings, theme: 'dark' });
// localStorage.removeItem(storageKey); // 設定を消すとき保存できるのは文字列なので、オブジェクトは JSON.stringify()、読み出し時は JSON.parse() を使います。保存が拒否されたり、古い形式の値が残ったりするため、例ではエラーと値を確認しています。大量のデータには向きません。パスワードなどの秘密情報は保存しません。詳しくは MDN の localStorage 解説を参照してください。
file:// で直接開いたときの保存領域の扱いはブラウザごとに異なります。学習時も localhost のローカルサーバーで確かめると、公開時の動作に近づきます。
localStorage は利用者の設定や進行状況を保存する仕組みです。HTML・CSS・JS をオフラインでも読み込めるようにするには、後の章の Service Worker と Cache Storage を使います。
10学習レベル:発展
ファイルに書き出す・ファイルから読み込む
localStorage は同じブラウザでの再開に便利です。利用者が設定や記録を別の端末へ持っていくなら、JSON ファイルの書き出しと読み込みを用意できます。
次の例では、書き出しボタンで現在の設定を settings.json として保存し、ファイルを選ぶと内容を読み取って画面へ反映します。HTML と JavaScript を同じフォルダに保存してください。
<button type="button" data-action="export-settings">設定を書き出す</button>
<label for="settings-file">設定ファイルを読み込む</label>
<input id="settings-file" type="file" accept=".json,application/json" data-ui="settings-file">
<p data-ui="file-message" role="status"></p>
<script src="./files.js" defer></script>const exportButton = document.querySelector('[data-action="export-settings"]');
const fileInput = document.querySelector('[data-ui="settings-file"]');
const fileMessage = document.querySelector('[data-ui="file-message"]');
let settings = { theme: 'light', pageSize: 20 };
// ファイル選択時の accept は制限ではないので、読み込んだ中身を確認する。
function isValidSettings(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value) &&
['light', 'dark'].includes(value.theme) &&
Number.isInteger(value.pageSize) && value.pageSize >= 1 && value.pageSize <= 100;
}
// データを変えた後は、同じ関数で表示を更新する。
function showSettings() {
fileMessage.textContent = `テーマ: ${settings.theme}、表示件数: ${settings.pageSize}`;
}
// ---- 現在の設定を書き出す ----
exportButton.addEventListener('click', () => {
const json = JSON.stringify(settings, null, 2);
const file = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(file);
const link = document.createElement('a');
link.href = url;
link.download = 'settings.json';
link.click();
// ダウンロード開始後に、一時 URL のメモリを解放する。
setTimeout(() => URL.revokeObjectURL(url), 1000);
});
// ---- 利用者が選んだファイルを読み込む ----
fileInput.addEventListener('change', async () => {
const file = fileInput.files?.[0];
if (!file) return;
try {
// 大きすぎるファイルを読み込まず、誤操作や負荷を抑える。
if (file.size > 100_000) throw new Error('ファイルが大きすぎます');
const imported = JSON.parse(await file.text());
if (!isValidSettings(imported)) throw new Error('設定の形式が違います');
// 検証に通ったデータだけを現在の設定に反映する。
settings = imported;
showSettings();
} catch (error) {
fileMessage.textContent = `読み込みに失敗しました: ${error.message}`;
}
// 同じファイルを選び直しても change が起きるようにする。
fileInput.value = '';
});
// 初期値も読み込み後も、同じ表示処理を使う。
showSettings();Blob と一時 URL で書き出し、input type="file" で利用者が選んだ File を file.text() で読みます。accept は選択画面の目安なので、読み込み後も JSON の形式を確認します。利用者が選んでいないファイルを、ブラウザから読むことはできません。読み込んだ設定を次回も使うなら、前章の saveSettings(settings) に相当する処理で localStorage にも保存します。
API の詳細は MDN のファイル操作とオブジェクト URLを参照してください。
じゃんけんの成績ファイル <input type="file"> Blob URL.createObjectURL() text()
11学習レベル:実践
画像・音声・動画を扱う
画像は img、音声は audio、自分のサイトに置く動画は video を使います。まず HTML の標準機能で表示・再生し、操作に合わせて変えたい部分だけ JavaScript を足します。以下の ./images/ と ./media/ のファイルは、自分で用意した素材に置き換えてください。
画像を切り替える
商品の種類や写真のサムネイルを選ぶ場面では、表示中の img の src と alt を一緒に変えます。画像の場所と説明をボタンの data-* 属性に持たせると、ボタンを増やしても JavaScript を書き足さずに済みます。
<img data-ui="photo" src="./images/shop-front.jpg" alt="店舗の外観" width="800" height="450">
<button type="button" data-image-src="./images/shop-front.jpg" data-image-alt="店舗の外観">外観</button>
<button type="button" data-image-src="./images/shop-inside.jpg" data-image-alt="店内の様子">店内</button>const photo = document.querySelector('[data-ui="photo"]');
document.querySelectorAll('[data-image-src]').forEach((button) => {
button.addEventListener('click', () => {
// 写真の内容が変わるので、読み上げ用の説明も同時に変える。
photo.src = button.dataset.imageSrc;
photo.alt = button.dataset.imageAlt;
});
});音楽や動画を再生する
controls を付けると、ブラウザ標準の再生・一時停止・音量操作を使えます。音声付きの自動再生はブラウザに止められることがあるため、再生は利用者の操作から始めます。独自のボタンから play() する場合は、失敗も扱います。
<audio controls preload="none" data-ui="music">
<source src="./media/theme.mp3" type="audio/mpeg">
音声を再生できないブラウザです。
</audio>
<button type="button" data-action="play-music">音楽を再生</button>
<p data-ui="music-message" role="status"></p>
<video controls preload="metadata" poster="./images/video-cover.jpg" width="640" height="360">
<source src="./media/introduction.mp4" type="video/mp4">
<track kind="captions" src="./media/introduction-ja.vtt" srclang="ja" label="日本語" default>
動画を再生できないブラウザです。
</video>const music = document.querySelector('[data-ui="music"]');
const playButton = document.querySelector('[data-action="play-music"]');
const musicMessage = document.querySelector('[data-ui="music-message"]');
// 標準プレーヤーから操作したときも、表示を実際の再生状態に合わせる。
music.addEventListener('play', () => { musicMessage.textContent = '音楽を再生しています。'; });
music.addEventListener('pause', () => { musicMessage.textContent = '一時停止中です。'; });
music.addEventListener('ended', () => { musicMessage.textContent = '再生が終わりました。'; });
playButton.addEventListener('click', async () => {
try {
await music.play();
} catch (error) {
// ファイルの読み込み失敗やブラウザの再生制限を利用者に知らせる。
musicMessage.textContent = '音楽を再生できませんでした。';
console.error(error);
}
});長い動画には字幕ファイル(.vtt)と説明文も用意します。自動再生が必要な背景動画は、音を消す・停止手段を設ける・動きを減らす設定を尊重する、といった配慮が要ります。基本は利用者が再生を選ぶ形にしましょう。詳しくは MDN の音声・動画の解説を参照してください。
12学習レベル:発展
ライトボックスとスライドショーを作る
ライトボックスは小さな画像を押して大きく見る仕組み、Vegas のようなスライドショーは複数の画像を順に見せる仕組みです。どちらも「今どの画像を表示しているか」を状態として持ち、操作に応じて画面を更新します。
クリックで拡大する
ライトボックスの土台には dialog を使います。showModal() で開くと、背後の操作を止め、Esc キーで閉じる動作もブラウザが扱います。閉じるボタンも明示します。ここで使う method="dialog" は、サーバーへの送信ではなくダイアログを閉じる指定です。
<button type="button" data-full-image="./images/shop-front-large.jpg">
<img src="./images/shop-front-small.jpg" alt="店舗の外観" width="240" height="135">
</button>
<dialog data-ui="lightbox" aria-label="写真を拡大表示">
<form method="dialog"><button type="submit" autofocus>閉じる</button></form>
<img data-ui="large-photo" alt="">
</dialog>const lightbox = document.querySelector('[data-ui="lightbox"]');
const largePhoto = lightbox.querySelector('[data-ui="large-photo"]');
document.querySelectorAll('[data-full-image]').forEach((button) => {
button.addEventListener('click', () => {
const thumbnail = button.querySelector('img');
largePhoto.src = button.dataset.fullImage;
largePhoto.alt = thumbnail.alt;
lightbox.showModal();
});
});CSS ではダイアログの最大幅と暗い背景を整えます。MDN の dialog 解説も参考にしてください。
画像を順に切り替える
スライドショーは画像の配列と現在位置を持てば作れます。次の例では自動切り替えを最初は停止し、利用者が開始・一時停止を選べるようにします。写真を全面に敷く場合の object-fit: cover などの見た目は CSS が担当します。
<img data-ui="slide" src="./images/room-a.jpg" alt="明るい部屋" width="800" height="450">
<button type="button" data-action="next-slide">次の写真</button>
<button type="button" data-action="toggle-slides" aria-pressed="false">自動切り替えを開始</button>const slides = [
{ src: './images/room-a.jpg', alt: '明るい部屋' },
{ src: './images/room-b.jpg', alt: '窓際の席' }
];
const slideImage = document.querySelector('[data-ui="slide"]');
const nextButton = document.querySelector('[data-action="next-slide"]');
const toggleButton = document.querySelector('[data-action="toggle-slides"]');
let slideIndex = 0;
let timerId = null;
// 画像と説明を同じ配列の項目から更新する。
function showNextSlide() {
slideIndex = (slideIndex + 1) % slides.length;
slideImage.src = slides[slideIndex].src;
slideImage.alt = slides[slideIndex].alt;
}
nextButton.addEventListener('click', showNextSlide);
toggleButton.addEventListener('click', () => {
if (timerId !== null) {
window.clearInterval(timerId);
timerId = null;
toggleButton.textContent = '自動切り替えを開始';
toggleButton.setAttribute('aria-pressed', 'false');
} else {
// 利用者が開始を選んだときだけ、5 秒ごとに進める。
timerId = window.setInterval(showNextSlide, 5000);
toggleButton.textContent = '自動切り替えを一時停止';
toggleButton.setAttribute('aria-pressed', 'true');
}
});[data-ui="lightbox"] { max-width: 90vw; max-height: 90vh; border: 0; border-radius: 0.5rem; }
[data-ui="lightbox"]::backdrop { background: rgb(0 0 0 / 75%); }
[data-ui="large-photo"] { display: block; max-width: 100%; max-height: 75vh; }
[data-ui="slide"] { display: block; width: 100%; aspect-ratio: 16 / 9; object-fit: cover; }ここで作ったのは基本形です。写真の読み込み待ち、切り替え効果、前へ戻る操作、画像ごとのリンクなどを足すと、本格的なギャラリーになります。Vegas のようなライブラリを使う場合も、まずこの状態とタイマーの関係が分かれば設定を読み解きやすくなります。
写真の拡大表示 <dialog> showModal() setInterval()
setInterval と requestAnimationFrame を使い分ける
スライドショーでは、setInterval で 5 秒ごとに写真を切り替えました。数秒に 1 回の切り替えなら、これで十分です。一方、画面の中で物を動かすときや、画像を素早く切り替えるときには、requestAnimationFrame が向いています。requestAnimationFrame(関数) は、ブラウザが次に画面を描く直前に、渡した関数を 1 回呼びます。
setInterval | requestAnimationFrame | |
|---|---|---|
| 呼ばれる時機 | 指定したミリ秒ごと。ほかの処理で混んでいると遅れる | 次に画面を描く直前。1 秒に 60 回描く画面なら約 60 回、120 回なら約 120 回 |
| 画面の描き替えとの関係 | 合わせない。描き替えの間に 2 回呼ばれたり、1 回も呼ばれなかったりして、動きがかくつくことがある | 描き替えのたびに 1 回。動きが滑らかになる |
| タブが裏に回ったとき | 呼ばれ続ける(ブラウザが間隔を広げ、1 秒に 1 回程度まで間引くことが多い) | 呼ばれなくなる。見えない画面のために電池や CPU を使わない |
| くり返し方 | 1 回登録すれば、止めるまでくり返す | 1 回ずつ。関数の最後で、次の 1 回を予約し直す |
| 止め方 | clearInterval(番号) | cancelAnimationFrame(番号) |
| 向いている処理 | スライドの自動切り替え、一定時間ごとの確認 | アニメーション、ゲーム、ドラッグ中の表示の更新 |
requestAnimationFrame は描き替えのたびに呼ばれるので、呼ばれる回数は画面によって違います。「0.25 秒ごとに切り替える」のように一定の間隔で何かをしたいときは、呼ばれた回数ではなく経過した時間をためて判定します。呼ばれた関数には、ページを開いてからの時刻(ミリ秒)が渡されます。
const INTERVAL_MS = 250; // 250 ミリ秒ごとに切り替える
let elapsed = 0; // 前に切り替えてからの経過時間
let lastTime = performance.now();
let frameId = 0;
function tick(time) {
// 前のコマからの経過時間をためる
elapsed += time - lastTime;
lastTime = time;
if (elapsed >= INTERVAL_MS) {
// 250 で割った余りを残し、次に切り替えるまでの時間がずれないようにする
elapsed %= INTERVAL_MS;
showNextImage(); // ここで表示を切り替える(自分で用意する関数)
}
// 次のコマでも tick を呼ぶよう予約する
frameId = requestAnimationFrame(tick);
}
frameId = requestAnimationFrame(tick);
// 止めるときは、予約を取り消す
// cancelAnimationFrame(frameId);物を動かすときも考え方は同じで、「1 コマに 2px」ではなく「1 秒に 150px」の速さに経過時間を掛けて位置を決めます(Canvas 編のアニメーション)。こうしておくと、1 秒に 60 回描く画面でも 120 回描く画面でも、同じ速さで動きます。
requestAnimationFrame で動かすアニメーションは、CSS の prefers-reduced-motion の指定では止まりません。大きく動かすアニメーションでは、JavaScript で matchMedia('(prefers-reduced-motion: reduce)').matches を調べ、動きを控えめにするか、止めて結果だけを見せます。
じゃんけんの手のシャッフル requestAnimationFrame() cancelAnimationFrame() performance.now()
13学習レベル:実践
YouTube・Vimeo・Google マップを埋め込む
外部サービスの動画や地図は、通常 iframe でページ内に表示します。表示するだけなら JavaScript は要りません。プレーヤーを自作のボタンから操作する場合に、各サービスの JavaScript API を使います。
YouTube と Vimeo
YouTube は動画の「共有 → 埋め込む」、Vimeo は「共有 → 埋め込み」からコードを取得します。Vimeo は動画の所有者が埋め込みを許可している必要があります。次は形を示す例です。VIDEO_ID を実際の ID に置き換えるか、サービスが生成した iframe を貼り付けます。
<iframe class="video-embed" title="YouTube の紹介動画" src="https://www.youtube.com/embed/VIDEO_ID"
width="640" height="360" loading="lazy" allowfullscreen></iframe>
<iframe class="video-embed" title="Vimeo の紹介動画" src="https://player.vimeo.com/video/VIDEO_ID"
width="640" height="360" loading="lazy" allowfullscreen></iframe>埋め込み先の再生制限やプライバシー設定は、そのサービス側にも依存します。操作 API の詳細は YouTube の公式資料とVimeo の埋め込み案内を参照してください。
お店や会社の所在地を示す
まず Google マップで場所を開き、「共有 → 地図を埋め込む → HTML をコピー」で取得した iframe を貼ります。利用者が地図を別画面で開けるリンクも添えます。次の src は説明用の省略形なので、コピーした実際の URL に置き換えてください。リンクの「東京駅」も案内する所在地に変えます。
<iframe class="map-embed" title="店舗へのアクセス地図" src="https://www.google.com/maps/embed?pb=..."
width="600" height="450" loading="lazy" allowfullscreen></iframe>
<a href="https://www.google.com/maps/search/?api=1&query=東京駅">Google マップで開く</a>.video-embed { display: block; width: 100%; aspect-ratio: 16 / 9; height: auto; border: 0; }
.map-embed { display: block; width: 100%; max-width: 600px; aspect-ratio: 4 / 3; height: auto; border: 0; }住所と地図の場所が一致することを公開前に確認します。地図の表示を細かく制御する Maps Embed API は別の方法で、API キーなどの準備が必要です。通常の所在地案内なら、まず共有画面の埋め込みから始められます。手順は Google マップの共有・埋め込み案内を参照してください。
Instagram・X はリンクで案内する
公式アカウントを知ってもらう目的なら、フッターなどからプロフィールへ <a> でリンクすれば十分です。特定の投稿を記事の中で見せたいときだけ、埋め込みを検討します。埋め込みは外部のコードを読み込み、投稿の削除や非公開化で表示が変わる場合もあります。使う際は各サービスの公式手順を確認します。
iframe の中身は外部サービスから届きます。ページの表示速度や利用者のプライバシーに関わるため、必要な場所だけに置き、loading="lazy" で画面外の読み込みを遅らせます。
14学習レベル:発展
PWA — インストールとオフライン
PWA(Progressive Web App)は、Web サイトをアプリのように使えるようにする考え方です。Web App Manifest で名前やアイコンを指定し、オフライン対応には Service Worker で必要なファイルをキャッシュします。普通のサイトにも、ゲームにも応用できます。
- まず通常のページや Web アプリを完成させる
オンラインで動き、操作する部分があればキーボードでも使える状態を作ります。
- Manifest を用意する
アプリ名、開始 URL、表示方法、192px と 512px などのアイコンを指定します。インストールできる条件はブラウザによって異なります。
- Service Worker を登録する
オフラインで必要な HTML・CSS・JS・画像・音声をキャッシュします。初回はオンラインで読み込む必要があります。
- HTTPS か localhost で確かめる
file://からは登録できません。この教材のフォルダでruby -run -e httpd . -p 8000を実行し、http://localhost:8000/pwa-example/を開きます。開発者ツールでオフラインに切り替え、再読み込みして確かめます。
次は 実際に動くサンプルの主要ファイルです。192px のアイコンと512px のアイコンも用意しました。自分のアプリに移すときは、同じ名前のファイルを揃えてください。
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#2457a7">
<title>オフラインで読めるページ</title>
<link rel="manifest" href="./manifest.webmanifest">
<script src="./register-sw.js" defer></script>
<style>
body { max-width: 38rem; margin: 3rem auto; padding: 1rem; font: 1.2rem/1.7 system-ui, sans-serif; }
</style>
</head>
<body>
<h1>オフラインで読めるページ</h1>
<p>オンラインで一度開いた後、開発者ツールでオフラインに切り替えて再読み込みしてみましょう。</p>
</body>
</html>{
"name": "オフラインで読めるページ",
"short_name": "オフライン例",
"start_url": "./",
"scope": "./",
"display": "standalone",
"icons": [
{ "src": "./icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "./icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}// ページ側で Service Worker を登録する。
// 未対応のブラウザでは通常のページとして使えるよう、対応状況を確認する。
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('./sw.js').catch(console.error);
}// キャッシュするファイルを更新したらバージョンを変える。
const cacheName = 'offline-page-v1';
// 初回に必要なファイルだけを列挙する。存在しないものがあるとキャッシュの作成に失敗する。
const appFiles = [
'./', './index.html', './manifest.webmanifest', './register-sw.js',
'./icons/icon-192.png', './icons/icon-512.png'
];
// ---- 初回の読み込みで、オフライン用のファイルを揃える ----
self.addEventListener('install', (event) => {
event.waitUntil(caches.open(cacheName).then((cache) => cache.addAll(appFiles)));
});
// ---- 更新後に古い版のキャッシュを片付ける ----
self.addEventListener('activate', (event) => {
event.waitUntil(caches.keys().then((names) => Promise.all(
names.filter((name) => name.startsWith('offline-page-') && name !== cacheName)
.map((name) => caches.delete(name))
)));
});
// ---- 同じサイトの GET だけを、保存済みのファイルから返す ----
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') return;
if (new URL(event.request.url).origin !== self.location.origin) return;
// 保存されていなければネットワークから取得する。
event.respondWith(
caches.open(cacheName).then((cache) =>
cache.match(event.request).then((cached) => cached || fetch(event.request))
)
);
});この例では、列挙したファイルがすべて存在するので、初回のキャッシュを作れます。自分のアプリで使う画像・音声を加える場合も、実在するファイルだけを appFiles に書きます。内容を更新したら cacheName の版を変えます。更新頻度の高い内容には別のキャッシュ方針を選びます。
Manifest と Service Worker は役割が違います。インストールの条件はブラウザごとに異なり、Service Worker はオフライン対応に使います。キャッシュができる前の初回アクセスはネット接続が必要です。
ブラウザごとの条件や詳しい手順は MDN の PWA 解説で確認できます。
15
次の制作へ
ここまでを一度に覚える必要はありません。まず data-* で画面をつなぎ、状態を整理し、必要になったら保存や PWA を足します。次の作例では、ここまでに学んだ HTML・CSS・JavaScript が、1 つのサイトの中でどう組み合わさるかを読み解きます。
ボタンで変える設定を一つ作り、localStorage に保存して再読み込み後も残るようにしましょう。問い合わせフォームを作る場合は、空白だけの本文を送れないことも確かめます。