Eine individuelle Datei-Upload-UI mit JavaScript erstellen
Behalten Sie das native Dateieingabefeld bei und ergänzen Sie, was ihm fehlt: Bildvorschauen, eine Auswahlliste mit entfernbaren Einträgen und Rückmeldungen, wenn ein Bild nicht lesbar ist. In diesem Beispiel können Sie eine Auswahl ersetzen oder leeren, während Vorschauen noch laden, ohne dass ein älteres Ergebnis die neue Liste verändert. Die Dateien bleiben im Browser; diese Seite sendet sie nicht.
Zuständigkeiten Ihrer Datei-Upload-UI festlegen
Ein natives Eingabefeld unterstützt bereits die Mehrfachauswahl von Dateien.
Wir lassen es für den Tastaturzugriff sichtbar und erstellen eine Vorschauliste auf Basis seiner
Objekte vom Typ File.
Das Auswählen oder Ablegen von Dateien ersetzt die bisherige Auswahl.
Remove löscht eine Zeile,
Clear selection alle Zeilen. Wenn Sie den Auswahldialog
abbrechen, bleibt die Liste unverändert.
Die Demo nimmt bis zu 10 nicht leere JPEG-, PNG- oder GIF-Bilder gleichzeitig an, jeweils höchstens
2 MiB groß. Sie prüft den deklarierten MIME-Typ, eine kurze Signatur und ob der Browser eine
Vorschau decodieren kann. Diese Prüfungen liefern Rückmeldungen zur Auswahl, keinen Nachweis
sicherer Inhalte. Das
Attribut accept ist nur ein Hinweis für den Auswahldialog.
Auch eine kleine komprimierte Datei kann beim Decodieren ein großes Bild ergeben; die Bytegrenze
begrenzt nicht den Speicherbedarf des decodierten Bildes.
Sie benötigen einen Browser, eine POSIX-Shell wie Bash und Node.js 24.15 oder neuer aus der
24.x-Reihe oder 26.5 oder neuer aus der 26.x-Reihe. Abhängigkeiten oder ein Build-Schritt sind
nicht nötig. Behalten Sie für die Serverdatei die Erweiterung .mts bei:
Node behandelt sie als ES-Modul,
auch innerhalb eines CommonJS-Projekts.
Erstellen Sie in dem Verzeichnis, in dem Sie Ihre Experimente ablegen, einen neuen Ordner:
mkdir file-preview-demo
Falls dieses Verzeichnis bereits existiert, halten Sie an und wählen Sie einen neuen Namen,
statt seine Dateien zu überschreiben. Speichern Sie die nächsten vier Beispiele unter den
angegebenen Dateinamen in file-preview-demo.
HTML-Struktur einrichten
Speichern Sie das Beispiel als index.html. Das beschriftete native Eingabefeld
ist die Tastaturalternative zum Ablegen von Dateien. Die Liste enthält Rückmeldungen zu jeder
Datei, und der Statusbereich meldet die aktuelle Auswahl, ohne laufende Ansagen zu unterbrechen.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Image selection preview</title>
<link rel="stylesheet" href="styles.css" />
<script src="script.js" defer></script>
</head>
<body>
<main>
<h1>Preview selected images</h1>
<section id="drop-zone" aria-label="Drop images">
<label for="file-input">Choose images</label>
<input id="file-input" type="file" multiple accept="image/jpeg,image/png,image/gif"
aria-describedby="file-help" />
<p id="file-help">Choose or drop up to 10 JPEG, PNG, or GIF images, each up to 2 MiB.</p>
</section>
<button id="clear-selection" type="button" disabled>Clear selection</button>
<p id="status" role="status" aria-atomic="true">No files selected. Nothing has been uploaded.</p>
<ul id="file-preview" aria-label="Selected images"></ul>
<noscript>Enable JavaScript to preview and manage your selection.</noscript>
</main>
</body>
</html>
Dateieingabefeld mit CSS gestalten
Speichern Sie das Beispiel als styles.css. Die Schaltfläche des Eingabefelds
zum Öffnen des Auswahldialogs lässt sich gestalten, ohne das Eingabefeld auszublenden.
Systemfarben folgen dem hellen oder dunklen Design des Browsers, und lange Dateinamen werden
innerhalb ihrer Zeile umgebrochen.
:root { color-scheme: light dark; }
body {
font: 1rem/1.5 system-ui, sans-serif;
max-width: 42rem;
margin: 2rem auto;
padding: 0 1rem;
color: CanvasText;
background: Canvas;
}
#drop-zone { border: 2px dashed currentColor; padding: 1rem; }
#drop-zone.dragover { outline: 3px solid Highlight; }
label { display: block; font-weight: bold; }
input { max-width: 100%; }
button, input::file-selector-button { font: inherit; padding: 0.5rem; }
button { margin-block: 0.5rem; }
:focus-visible { outline: 3px solid Highlight; outline-offset: 3px; }
#file-preview { list-style: none; padding: 0; }
#file-preview li { border-block-start: 1px solid currentColor; padding-block: 1rem; }
#file-preview p { overflow-wrap: anywhere; }
#file-preview img { width: 8rem; height: 8rem; object-fit: contain; max-width: 100%; }
Erweiterte Funktionen mit JavaScript implementieren
Speichern Sie das Beispiel als script.js. Jede Zeile verwaltet ihren eigenen
Reader und ihre eigene Objekt-URL. Beim Entfernen wird die Zeile als inaktiv markiert, bevor ein
laufender Lesevorgang abgebrochen und ihre URL freigegeben wird. Beide asynchronen Phasen prüfen
diese Markierung, bevor sie den sichtbaren Zustand ändern. So kann weder ein verspätetes Lesen
der Signatur noch ein verspätetes Decodieren des Bildes eine entfernte Zeile wiederherstellen.
FileReader.abort()
bricht einen laufenden Lesevorgang ab; die Behandlung des Abbruchereignisses beendet auch unser
Promise. Für das Decodieren von Bildern gibt es hier keine entsprechende Abbruchmethode.
Deshalb verwerfen wir das Ergebnis, wenn die Zeile bereits entfernt wurde.
Mit img.decode()
können wir das Thumbnail erst nach erfolgreichem Decodieren anzeigen.
const fileInput = document.getElementById('file-input')
const preview = document.getElementById('file-preview')
const clearButton = document.getElementById('clear-selection')
const dropZone = document.getElementById('drop-zone')
const status = document.getElementById('status')
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif']
const maxSize = 2 * 1024 * 1024
let entries = []
function updateStatus() {
clearButton.disabled = entries.length === 0
const ready = entries.filter((entry) => entry.state === 'ready').length
const checking = entries.filter((entry) => entry.state === 'checking').length
const rejected = entries.filter((entry) => entry.state === 'rejected').length
status.textContent = entries.length === 0
? 'No files selected. Nothing has been uploaded.'
: `${ready} previews ready, ${checking} checking, ${rejected} rejected. Nothing has been uploaded.`
}
function dispose(entry) {
entry.active = false
if (entry.reader.readyState === FileReader.LOADING) entry.reader.abort()
if (entry.url !== null) URL.revokeObjectURL(entry.url)
entry.url = null
entry.image.removeAttribute('src')
}
function clearSelection() {
for (const entry of entries) dispose(entry)
entries = []
preview.replaceChildren()
fileInput.value = ''
updateStatus()
}
function removeEntry(entry) {
const index = entries.indexOf(entry)
const focusTarget = entries[index + 1]?.remove ?? entries[index - 1]?.remove ?? fileInput
entries.splice(index, 1)
dispose(entry)
entry.row.remove()
updateStatus()
focusTarget.focus()
}
function createEntry(file) {
const row = document.createElement('li')
const image = document.createElement('img')
image.alt = `Preview of ${file.name}`
image.hidden = true
const name = document.createElement('p')
name.textContent = file.name
const message = document.createElement('p')
message.textContent = 'Checking image…'
const remove = document.createElement('button')
remove.type = 'button'
remove.textContent = 'Remove'
remove.setAttribute('aria-label', `Remove ${file.name}`)
const entry = {
file, row, image, message, remove,
reader: new FileReader(), url: null, active: true, state: 'checking',
}
remove.addEventListener('click', () => removeEntry(entry))
row.append(image, name, message, remove)
preview.appendChild(row)
return entry
}
function finish(entry, state, message) {
entry.state = state
entry.message.textContent = message
updateStatus()
}
function checkFileSignature(file, reader = new FileReader()) {
return new Promise((resolve) => {
function complete(valid) {
reader.onload = reader.onerror = reader.onabort = null
resolve(valid)
}
reader.onerror = reader.onabort = () => complete(false)
reader.onload = () => {
const bytes = new Uint8Array(reader.result)
if (bytes.length < 4) return complete(false)
const header = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
const types = { '89504e47': 'image/png', '47494638': 'image/gif' }
const detectedType = header.startsWith('ffd8ff') ? 'image/jpeg' : types[header]
complete(detectedType === file.type)
}
reader.readAsArrayBuffer(file.slice(0, 4))
})
}
async function loadPreview(entry) {
const valid = await checkFileSignature(entry.file, entry.reader)
if (!entry.active) return
if (!valid) {
finish(entry, 'rejected', 'Cannot read this file, or its signature does not match its MIME type.')
return
}
entry.url = URL.createObjectURL(entry.file)
entry.image.src = entry.url
try {
await entry.image.decode()
if (!entry.active) return
entry.image.hidden = false
finish(entry, 'ready', 'Preview ready.')
} catch {
if (!entry.active) return
URL.revokeObjectURL(entry.url)
entry.url = null
entry.image.removeAttribute('src')
finish(entry, 'rejected', 'Image could not be decoded. Choose another file.')
}
}
function choose(files) {
if (files.length === 0) return
clearSelection()
if (files.length > 10) {
status.textContent = 'Choose no more than 10 files at once. Nothing has been uploaded.'
return
}
for (const file of files) {
const entry = createEntry(file)
entries.push(entry)
if (!allowedTypes.includes(file.type)) {
finish(entry, 'rejected', 'Choose a JPEG, PNG, or GIF with a recognized MIME type.')
} else if (file.size === 0 || file.size > maxSize) {
finish(entry, 'rejected', 'The image must be nonempty and no larger than 2 MiB.')
} else {
void loadPreview(entry)
}
}
updateStatus()
}
fileInput.addEventListener('change', () => {
choose(Array.from(fileInput.files))
// Retain the File objects ourselves so choosing the same files can fire change again.
fileInput.value = ''
})
clearButton.addEventListener('click', () => {
clearSelection()
fileInput.focus()
})
dropZone.addEventListener('dragover', (event) => {
event.preventDefault()
dropZone.classList.add('dragover')
})
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover'))
dropZone.addEventListener('drop', (event) => {
event.preventDefault()
dropZone.classList.remove('dragover')
choose(Array.from(event.dataTransfer.files))
})
window.addEventListener('pagehide', clearSelection)
Dateinamen werden mit textContent verarbeitet, sodass ein Name mit HTML als
Text angezeigt wird.
Lassen Sie Objekt-URLs gültig, solange ihre Bilder verfügbar sind.
Remove, Clear selection,
das Ersetzen der Auswahl, ein Fehler beim Decodieren und das Verlassen der Seite geben sie frei.
Beim Neuladen oder Verlassen der Seite geht die Auswahl verloren.
Die Prüfung der vier Bytes erkennt nur ein Präfix. Selbst ein erfolgreiches Decodieren im Browser kann eine beschädigte Datei tolerieren; keiner der beiden Schritte beweist, dass das Bild intakt ist oder sicher gespeichert werden kann. Abgelehnte Zeilen bleiben mit einer Erklärung sichtbar und lassen sich weiterhin entfernen.
Vorschauseite lokal bereitstellen
Speichern Sie das Beispiel als server.mts. Der Server liefert nur die drei
Browserdateien aus und hat keinen Upload-Endpunkt. Standardmäßig wählt er einen verfügbaren
Loopback-Port; setzen Sie optional PORT, wenn Sie einen festen benötigen.
import { readFile } from 'node:fs/promises'
import { createServer } from 'node:http'
async function main(): Promise<void> {
const portText = process.env.PORT ?? '0'
const port = Number(portText)
if (!/^\d+$/.test(portText) || !Number.isInteger(port) || port < 0 || port > 65535) {
throw new Error('PORT must be an integer from 0 to 65535.')
}
const assets = new Map<string, { body: Buffer; type: string }>()
for (const [route, file, type] of [
['/', 'index.html', 'text/html; charset=utf-8'],
['/styles.css', 'styles.css', 'text/css'],
['/script.js', 'script.js', 'text/javascript'],
]) {
assets.set(route, { body: await readFile(new URL(file, import.meta.url)), type })
}
const server = createServer((req, res) => {
if (req.method === 'GET' && req.url === '/favicon.ico') {
res.writeHead(204).end()
return
}
const asset = assets.get(req.url ?? '')
if (req.method !== 'GET' || !asset) {
res.writeHead(404).end('Not found.')
return
}
res.writeHead(200, { 'Content-Type': asset.type, 'Cache-Control': 'no-store' })
res.end(asset.body)
})
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(port, '127.0.0.1', resolve)
})
const address = server.address()
if (!address || typeof address === 'string') throw new Error('Missing TCP address')
console.log(`Open http://127.0.0.1:${address.port}`)
}
main().catch((error: unknown) => {
const code = error instanceof Error && 'code' in error ? error.code : ''
console.error(code === 'EADDRINUSE'
? 'Port is already in use. Choose another PORT or leave it unset.'
: 'Could not start preview server. Check PORT and the three browser files.')
process.exitCode = 1
})
Führen Sie im übergeordneten Verzeichnis, in dem Sie file-preview-demo erstellt haben,
Folgendes aus:
(cd file-preview-demo && node server.mts)
Öffnen Sie die ausgegebene URL im Browser. Lassen Sie das Terminal geöffnet und beenden Sie den
Server mit Strg+C, wenn Sie fertig sind; Ihre Shell bleibt im übergeordneten Verzeichnis.
Starten Sie den Server nach Änderungen an einer Browserdatei neu, da er diese Dateien beim Start
liest. Ein belegter Port, ein ungültiger Wert für PORT oder eine fehlende
Datei beendet den Start mit einem Status ungleich null, statt eine einsatzbereite URL auszugeben.
Browserkompatibilität
Die Anleitung wurde unter Linux mit Node.js 24.15.0, 26.5.0 und 26.8.1 sowie Chromium 145
und 152 getestet. Sie nutzt natives FileReader, Objekt-URLs, Drag-and-drop und
img.decode(); auf Geräten, auf denen sich keine Dateien ablegen lassen, bleibt
der Auswahldialog die Alternative.
Testen Sie den gesamten Auswahlablauf:
- Navigieren Sie mit der Tabulatortaste zu Choose images, öffnen Sie den Auswahldialog und wählen Sie zwei kleine Bilder. Jede Zeile sollte ihren Dateinamen, ein Thumbnail und Preview ready. anzeigen. Der Status sollte 2 previews ready, 0 checking, 0 rejected. Nothing has been uploaded. melden.
- Navigieren Sie mit der Tabulatortaste zur Schaltfläche Remove einer Zeile und aktivieren Sie sie. Der Fokus wechselt zur Schaltfläche Remove der nächsten Zeile, zur vorherigen, falls keine nächste Zeile vorhanden ist, oder zum Dateieingabefeld, wenn die Liste leer ist. Auch Clear selection setzt den Fokus zurück auf das Dateieingabefeld.
- Wählen Sie andere Dateien aus oder legen Sie diese ab, während die Vorschauen laden. Die bisherigen Zeilen verschwinden und können nach Abschluss ihrer Lesevorgänge nicht zurückkehren. Wählen Sie dieselben Dateien erneut, um die erneute Auswahl zu bestätigen; beim Abbrechen des Auswahldialogs sollte die aktuelle Liste erhalten bleiben.
- Testen Sie ein leeres Bild, einen nicht unterstützten Typ, eine Datei mit mehr als 2 MiB und ein beschädigtes Bild mit erkennbarem Präfix. Die jeweiligen Zeilen sollten Erklärungen anzeigen. Wenn Sie mehr als 10 Dateien auswählen, wird die gesamte Auswahl abgelehnt.
Auswahl mit einem Dateiuploader verbinden
Diese Liste verwaltet ihre eigenen Objekte vom Typ File und setzt das
native Eingabefeld nach jeder Auswahl zurück. Wenn Sie dieses Eingabefeld als Formular absenden,
werden deshalb keine ausgewählten Dateien gesendet. Um die Übertragung zu ergänzen, senden Sie
die beibehaltenen, akzeptierten Dateien und stimmen Sie das Entfernen oder Ersetzen mit laufenden
Anfragen ab.
Ein vollständiges Beispiel zum Senden und Empfangen finden Sie in der Anleitung zum individuellen JavaScript-Dateiuploader. Er verarbeitet pro Anfrage eine JPEG-, PNG- oder PDF-Datei mit einer Empfangsbestätigung samt Prüfsumme; sein Empfänger ist kein Endpunkt für mehrere Bilder und nimmt keine GIF-Dateien an. Betrachten Sie ihn als separate Anleitung, nicht als direkt einsetzbaren Empfänger für diese Vorschauliste. Jeder öffentliche Upload-Dienst benötigt eine eigene serverseitige Validierung und Autorisierung; diese lokale UI bietet weder eine serverseitige Annahme noch CSRF-Schutz.
