私の備忘録として記載しました。
登山ログ投稿機能で、
複数画像プレビューをStimulusで実装した。
特に、
- 再選択時に画像が消える問題
- FileList の扱いづらさ
に苦戦したため、
JavaScript側で画像状態を管理する構成にした。
やりたかったこと
- 画像を1枚ずつ追加
- 画像の複数枚の同時追加
- プレビュー表示
- 任意削除
困ったこと
multiple file input は
再選択時に以前の画像が消えた。
解決方法
selectedFilesをJavaScript側で管理した。
画像プレビューの処理の流れ
- file inputで画像選択
- input.files(FileList)取得
- selectedFilesへ追加
- syncInputでinput.filesへ再同期
- renderPreviewで画面再描画
ユーザー画像選択
↓
input.files(FileList)
↓
selectedFilesへ保存
↓
syncInput()
↓
input.files再生成
↓
renderPreview()
↓
画面更新
1. ユーザー画像選択
file input で画像を選択するとchangeイベントが発火する。
例えば今回なら、Railsの
<%= f.file_field :images, multiple: true %>
これが最終的にHTMLではこんな感じになります。
<input type="file" multiple>
この input 要素へ
JavaScript側からアクセスすると、
this.imagesTarget
で input 要素を取得できる。
さらに、
this.imagesTarget.files
で FileList を取得した。
2. FileListの取得
this.imagesTarget.files
で、
input要素が保持している
ファイル一覧(FileList)を取得できる。
ただし FileList は通常の配列ではないため、
そのままでは配列メソッドを扱いづらい。
そのため、
Array.from(this.imagesTarget.files)
で通常配列へ変換した。
3. selectedFilesへ保存
this.selectedFiles = [...this.selectedFiles, ...newFiles]
selectedFiles を
JavaScript側の正しい状態として扱った。
これにより、
- 1枚ずつ追加
- 任意削除
- 再描画
を柔軟に行えるようになった。
4. syncInput()
selectedFiles の状態から新しい FileList を生成し、
input.filesへ再同期する。
最終的なフォーム送信時は、
input.files の内容が送信される。
しかし今回は
selectedFiles を本体として管理しているため、
syncInput()
で selectedFiles の状態を
input.files へ再同期した。
const dataTransfer = new DataTransfer()
を使い、
新しい FileList を生成している。
5. renderPreview()
selectedFiles の状態を元に
毎回プレビューHTMLを再生成した。
これにより、
画像削除後も
画面表示と状態を同期しやすくなった。
URL.createObjectURL(file)
で一時URLを生成し、
imgタグへ設定した。
[補足:メモリ管理について]
URL.createObjectURL で生成したURLは、ページを閉じるまでメモリを占有し続けます。プレビューを再描画する前に URL.revokeObjectURL() を呼ぶことで、ブラウザが重くなるのを防ぐことができます。
6. 画面更新
this.previewTarget.insertAdjacentHTML(
"beforeend",
previewHtml
)
で preview領域へHTMLを追加し、
現在の selectedFiles の状態を画面へ再描画した。
最終的なコード(一部抜粋)
画像選択時
previewImages() {
const newFiles = Array.from(this.imagesTarget.files)
this.selectedFiles = [
...this.selectedFiles,
...newFiles
]
this.syncInput()
this.renderPreview()
}
FileList同期
syncInput() {
const dataTransfer = new DataTransfer()
this.selectedFiles.forEach(file => {
dataTransfer.items.add(file)
})
this.imagesTarget.files = dataTransfer.files
}
プレビュー再描画
renderPreview() {
this.previewTarget.innerHTML = ""
this.selectedFiles.forEach(file => {
const imageUrl = URL.createObjectURL(file)
const previewHtml = `
<img src="${imageUrl}">
`
this.previewTarget.insertAdjacentHTML(
"beforeend",
previewHtml
)
})
}
まとめ
今回の実装では、
「画像を表示する」よりも、
- どこを正しい状態として持つか
- どう画面へ同期するか
を考えることが重要だった。
特に selectedFiles を中心に管理する構成にしたことで、
画像追加・削除・再描画をシンプルに扱えるようになった。