この記事の目次
選択欄には新しいファイル名が出ているのに、見本は前の写真のまま。選択を解除しても写真が残る。画像プレビューでは、入力欄だけでなく、画像とその表示に使ったURLを一組で片付ける必要があります。
DOM操作、イベント、async/awaitが分かる人向けに、架空の作品応募の見本を作ります。PNGかJPEGの練習用画像を二枚用意してください。送信や保存は実装せず、選んだファイルをブラウザー内で表示するところまでが範囲です。
ファイルのパスではなく、表示用のURLを持つ
file型inputのvalueに見える文字列は、画像を読み込むための実ファイルパスではありません。選択内容はinput.filesからFileとして取り出します。file入力の説明に沿い、valueをimgのsrcへコピーする方法は使いません。
URL.createObjectURL(file)で作るObject
URLは、そのファイルを参照する一時的なURLです。公開用の画像URLやアップロード先ではありません。同じFileから作り直しても別のURLになるため、作った側が使い終えたものを管理します。
今回の持ち主は一つのプレビューです。「選択A → Aを表示 → 選択BでAを外す → Bを表示 → 解除でBを外す」と進めます。File APIの利用例が説明するように、表示に不要になった時点で解放します。画像を読み終えた瞬間にURLを消すと、別タブで開くなど表示中の操作を妨げる場合があります。
選び直しと解除が同じ後始末を通る見本
次をpreview.htmlへ保存し、ブラウザーで開きます。標準のファイル入力を見えるまま使い、選択解除は別ボタンにします。容量の2MiBはこの練習の上限で、画像の安全性や読み込み後のメモリー量を保証する値ではありません。
<!doctype html>
<html lang="ja"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>作品画像のプレビュー</title>
<style>
* { box-sizing: border-box; }
body { margin: 16px; font-family: sans-serif; line-height: 1.8; color: #222; }
main { max-width: 560px; margin: auto; }
input, button { font: inherit; max-width: 100%; margin-block: 8px; }
label { display: block; }
#preview img { display: block; max-width: 100%; max-height: 320px; }
#status { overflow-wrap: anywhere; }
:focus-visible { outline: 3px solid #174ea6; outline-offset: 3px; }
</style></head><body><main>
<h1>作品画像を選ぶ(架空の練習)</h1>
<p id="help">PNG・JPEG、2MiB以下。外部への送信や保存は行いません。</p>
<label for="photo">作品の画像</label>
<input id="photo" type="file" accept="image/png,image/jpeg" aria-describedby="help" disabled>
<button id="clear" type="button" disabled>選択を解除する</button>
<p id="status" role="status">画像は未選択です。</p>
<div id="preview"></div>
<noscript><p>プレビューにはJavaScriptが必要です。</p></noscript>
</main><script type="module">
const input = document.querySelector('#photo');
const clear = document.querySelector('#clear');
const status = document.querySelector('#status');
const preview = document.querySelector('#preview');
let active = null;
function releasePreview() {
const previous = active;
active = null;
preview.replaceChildren();
if (previous) {
previous.image.removeAttribute('src');
URL.revokeObjectURL(previous.url);
}
}
function resetSelection(message) {
releasePreview();
input.value = '';
status.textContent = message;
}
async function showSelection() {
releasePreview();
const file = input.files[0];
if (!file) {
status.textContent = '画像は未選択です。';
return;
}
if (!['image/png', 'image/jpeg'].includes(file.type) || file.size > 2 * 1024 * 1024) {
resetSelection('PNGかJPEGの2MiB以下のファイルを選んでください。');
return;
}
const own = { url: URL.createObjectURL(file), image: new Image() };
active = own;
own.image.alt = '選択した作品画像のプレビュー';
own.image.src = own.url;
status.textContent = '画像を読み込んでいます。';
try {
await own.image.decode();
if (active !== own) return;
preview.replaceChildren(own.image);
status.textContent = `${file.name}を表示しています。`;
} catch {
if (active !== own) return;
resetSelection('画像を読み取れません。別のPNGかJPEGを選んでください。');
}
}
input.addEventListener('change', showSelection);
clear.addEventListener('click', () => resetSelection('選択を解除しました。'));
window.addEventListener('pagehide', () => resetSelection('画像は未選択です。'));
input.disabled = false;
clear.disabled = false;
</script></body></html>
releasePreviewは、古い選択を無効にしてから画像を外し、srcを取り除いてURLを解放します。選び直しと明示的な解除の両方で同じ関数を呼ぶため、片方だけに後始末を忘れにくい構成です。revokeObjectURLの説明にある解放は、このURLへの参照を終える操作で、端末の元ファイルを削除する操作ではありません。
ファイル名の表示にはtextContentを使い、名前をHTMLとして組み立てません。この点は文字列をそのまま表示する記事と共通です。altは選択した見本の役割を示す仮の説明であり、公開作品に付ける代替文を自動生成したものではありません。
解除ボタンと、選択画面のキャンセルを区別する
第一の失敗は、解除ボタンでinput.valueだけを空にすることです。欄は未選択へ戻っても、imgとObject URLはそのままなので写真が残ります。修正はresetSelectionからreleasePreviewも呼ぶこと。解除後に入力欄・画像・案内文の三つを照合します。
逆に、画像を隠すだけでも選択中のFileは残ります。送信機能を後で付けたとき、見えない選択を送る原因になり得ます。今回の解除は「表示を隠す」ではなく「選択をなくす」操作なので、入力欄も空へ戻します。
ファイル選択画面のキャンセルは別です。以前の選択を変えず閉じる場合や、同じファイルを再選択した場合にはcancelイベントが発生します。この例はcancelで解除しません。前の選択が保持されたなら、その見本も保持するのが期待する動きです。新しい画像を選ばなかったことと、明示的に選択を消したことを分けます。
読み込みが終わっても、今の選択とは限らない
画像は読み込んで表示できる状態になるまで待つ必要があります。decodeの説明では、成功をPromiseで受け取り、破損した画像などでは失敗を扱います。拡張子がpngでも、中身が読めなければcatchへ進みます。
第二の失敗は、decodeの後に無条件で画像を置くことです。Aの処理を待つ間にBを選んだり、解除したりすると、前の処理が遅れて表示を書き換える可能性があります。完成例では一回の選択をownとして保存し、今のactiveと同じ場合だけ反映します。古い失敗も同じ比較で無視します。
この比較を戻したら、画像を選んですぐ解除し、待った後も画像が戻らないか確認します。非同期の結果を採用する基本は古い検索結果を反映しない記事を参照できます。本例では、それに加えて古いURLを選び直した時点で解放する必要があります。
acceptとプレビュー成功で、送信の検証を済ませない
acceptはファイル選択画面へのヒントです。完成例のfile.typeと容量の検査も、練習の表示対象を絞るためのもので、改ざんに対する安全確認にはなりません。サーバーへ送るアプリでは、受信側で形式、内容、容量などを別途検証します。
複数画像を同時に持つなら、active一つでは足りません。画像ごとにURLの持ち主を作り、削除した一枚だけを解放する設計が必要です。また、ページ内で部品だけを取り外す構成なら、pagehideを待たず、その部品の終了時に後始末を呼びます。イベント登録自体の寿命はイベント解除の記事で分けて考えられます。
今回はページを離れる際に選択を消す方針です。戻る操作で前の画像を復元する機能は用意していません。Object URLを保存して後日使うのではなく、復元が必要なら元のデータの保管方法から設計します。
AからBへ替え、最後に未選択へ戻す
15〜20分の確認用に、見分けられる二枚と、拡張子だけpngにした短いテキストファイルを練習フォルダへ用意します。実際の応募資料は不要です。
| 操作 | 期待する結果 |
|---|---|
| Aを選択する | A一枚とAのファイル名を表示 |
| Bへ選び直す | Aが外れ、B一枚とBの名前を表示 |
| 選択を解除する | 入力欄は未選択、画像なし、解除の案内 |
| 壊れたpngを選ぶ | 読取り失敗の案内、画像なし、入力欄も空 |
| 選択直後に解除する | 待っても画像が戻らず、再びAを選べる |
Tabで入力欄から解除ボタンへ進み、Enterで解除できるかも試します。URLの解放は見た目だけでは判定できないので、開発者ツールでcreateObjectURLとrevokeObjectURLの行にブレークポイントを置き、AのURLがBの選択時、BのURLが解除時に渡るか追えます。
JavaScriptの無料レッスンで入力と表示を組み合わせる際にも、値を入れた後だけでなく空へ戻した後を確認できます。最後に選択欄と画像の両方が空になり、次の選択が動けば、このプレビューの一往復を確認できています。
TRY A LESSON
記事の内容を、課題で確かめる
記載内容へのご質問や訂正のご連絡は、hello@junkbranding.comまでお願いします。