Einen eigenen Dateiuploader mit JavaScript und HTML erstellen
Behalten Sie das native Dateieingabefeld bei und gestalten Sie Ihre eigene Oberfläche darum herum. Diese Anleitung zeigt einen JavaScript-Dateiuploader ohne Framework mit Tastaturauswahl, Drag-and-drop, Fortschrittsanzeige und Retries. Dazu kommt ein lokaler Server, der die empfangenen Bytes anhand der SHA-256-Prüfsumme der ausgewählten Datei überprüft.

Ein lokales Upload-Projekt einrichten
Sie benötigen Node.js 24 oder neuer, einen Browser und ein Terminal. Das Beispiel nutzt die in Node integrierten APIs für Request und File, daher müssen Sie keine Pakete installieren. Die Anleitung wurde unter Linux mit Node.js 24.2.0, 26.5.0 und 26.8.1 sowie Chromium 145 und 152 getestet.
Wir laden jeweils eine nicht leere JPEG-, PNG- oder PDF-Datei mit bis zu 10 MiB hoch. Jeder Versuch sendet die gesamte Datei als Multipart-Formulardaten. So bleiben Browser und Server kompakt genug, um gemeinsam zu laufen, ohne ein Protokoll zum Zusammensetzen von Dateiblöcken zu implementieren.
Führen Sie Folgendes in einer POSIX-Shell wie Bash aus, und zwar in einem Verzeichnis für Experimente:
mkdir custom-uploader &&
cd custom-uploader &&
touch index.html styles.css script.js server.ts
Der Befehl lehnt ein bereits vorhandenes Verzeichnis ab. Falls ein Schritt fehlschlägt, halten Sie an und beheben Sie das Problem, bevor Sie fortfahren. Wählen Sie bei Bedarf einen anderen neuen Verzeichnisnamen. Fügen Sie die nächsten vier Beispiele in die gerade erstellten leeren Dateien ein. Uploads erstellen oder überschreiben keine Dateien: Der Empfänger berechnet den Hash der Bytes im Arbeitsspeicher und verwirft sie nach der Antwort. Eine erneute Ausführung prüft die Bytes erneut. Betreiben Sie diese Demo nur auf Ihrem eigenen Rechner.
Die HTML-Struktur einrichten
Speichern Sie dies als index.html. Das beschriftete Dateieingabefeld bleibt sichtbar
und per Tab-Taste erreichbar. Die Ablagefläche bietet eine alternative Möglichkeit zur Dateiauswahl;
die separate Schaltfläche „Upload“ startet die Anfrage. Statusmeldungen bleiben in einer Live-Region
mit der Priorität „polite“ auf dem Bildschirm.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Custom file uploader</title>
<link rel="stylesheet" href="styles.css" />
<script src="script.js" defer></script>
</head>
<body>
<main>
<h1>Upload a file</h1>
<form id="upload-form" aria-label="File upload">
<section id="drop-zone" aria-label="Drop a file">
<label for="file-input">Choose a file</label>
<input id="file-input" type="file" accept="image/jpeg,image/png,application/pdf"
aria-describedby="file-help selection" />
<p id="file-help">Choose or drop one JPEG, PNG, or PDF, up to 10 MiB.</p>
</section>
<p id="selection">No file selected.</p>
<button id="upload" type="submit" disabled>Upload</button>
<button id="retry" type="button" disabled>Retry</button>
</form>
<p><label for="progress">Request body sent</label></p>
<progress id="progress" max="100" value="0"></progress>
<p id="status" role="status" aria-live="polite" aria-atomic="true">Choose a file to begin.</p>
<pre id="receipt" aria-label="Server receipt"></pre>
<noscript>This uploader needs JavaScript enabled.</noscript>
</main>
</body>
</html>
Den Dateiuploader mit CSS gestalten
Speichern Sie dies als styles.css. Gestalten Sie die Auswahlschaltfläche und den
Fokusrahmen des Eingabefelds, ohne das Feld auszublenden. Mit display: none wäre es
weder per Tastaturnavigation noch für assistive Technologien zugänglich; siehe das
MDN-Beispiel zur Dateieingabe.
body {
font: 1rem/1.5 system-ui, sans-serif;
margin: 2rem auto;
padding: 0 1rem;
max-width: 40rem;
color: #172b4d;
background: #fff;
}
#drop-zone {
border: 2px dashed #52647c;
border-radius: 0.5rem;
padding: 1.5rem;
}
#drop-zone.dragover { background: #e8f1ff; }
label { display: block; font-weight: bold; }
input { max-width: 100%; }
button, input::file-selector-button {
font: inherit;
padding: 0.5rem 1rem;
margin: 0.5rem 0;
cursor: pointer;
}
:focus-visible { outline: 3px solid #075ac7; outline-offset: 3px; }
button:disabled { cursor: default; }
progress { width: 100%; }
#status { min-height: 3rem; }
pre { white-space: pre-wrap; overflow-wrap: anywhere; }
Dateiauswahl und Upload mit JavaScript implementieren
Speichern Sie dies als script.js. XMLHttpRequest stellt
Ereignisse zum Upload-Fortschritt bereit.
Sie messen die Übertragung des Anfragekörpers einschließlich des Multipart-Overheads. Wenn 100 %
erreicht sind, bedeutet das nicht, dass der Server die Datei akzeptiert hat. Nur eine HTTP-200-Antwort
mit übereinstimmender Größe und Prüfsumme führt zur Meldung „Accepted“.
Das Verhalten während einer laufenden Anfrage ist explizit festgelegt: Dateiauswahl und Schaltflächen sind deaktiviert, abgelegte Dateien und zusätzliche Übermittlungen werden ignoriert, und die aktuelle Datei bleibt ausgewählt, bis die Anfrage abgeschlossen ist. Ein Netzwerkfehler, eine Zeitüberschreitung oder ein Serverfehler aktiviert „Retry“, mit höchstens drei Versuchen pro Auswahl. Retries senden die gesamte Datei erneut; kein Retry erfolgt automatisch. Eine abgelehnte Anfrage oder eine ungültige Empfangsbestätigung erfordert eine neue Auswahl.
const form = document.getElementById('upload-form')
const input = document.getElementById('file-input')
const zone = document.getElementById('drop-zone')
const selection = document.getElementById('selection')
const upload = document.getElementById('upload')
const retry = document.getElementById('retry')
const progress = document.getElementById('progress')
const status = document.getElementById('status')
const receipt = document.getElementById('receipt')
const allowedTypes = ['image/jpeg', 'image/png', 'application/pdf']
const maxSize = 10 * 1024 * 1024
let selected = null
let busy = false
let attempts = 0
let retryable = false
function updateControls() {
input.disabled = busy
upload.disabled = busy || !selected || attempts > 0
retry.disabled = busy || !selected || !retryable || attempts >= 3
}
function choose(files) {
if (busy) return
selected = null
attempts = 0
retryable = false
progress.value = 0
receipt.textContent = ''
selection.textContent = 'No file selected.'
const file = files[0]
if (files.length !== 1) {
status.textContent = 'Choose exactly one file.'
} else if (!allowedTypes.includes(file.type)) {
status.textContent = 'Choose a JPEG, PNG, or PDF with a recognized MIME type.'
} else if (file.size === 0 || file.size > maxSize) {
status.textContent = 'The file must be nonempty and no larger than 10 MiB.'
} else {
selected = file
selection.textContent = file.name
status.textContent = 'Ready to upload.'
}
updateControls()
}
input.addEventListener('change', () => {
choose(Array.from(input.files))
// Retain the File ourselves so selecting the same file can fire change again.
input.value = ''
})
zone.addEventListener('dragover', (event) => {
event.preventDefault()
if (!busy) zone.classList.add('dragover')
})
zone.addEventListener('dragleave', () => zone.classList.remove('dragover'))
zone.addEventListener('drop', (event) => {
event.preventDefault()
zone.classList.remove('dragover')
choose(Array.from(event.dataTransfer.files))
})
function send(file, sha256) {
return new Promise((resolve) => {
const xhr = new XMLHttpRequest()
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) progress.value = (event.loaded / event.total) * 100
})
xhr.upload.addEventListener('load', () => {
progress.value = 100
status.textContent = 'Body sent. Waiting for server acceptance…'
})
xhr.addEventListener('load', () => {
const result = xhr.response
if (xhr.status === 200 && result?.bytes === file.size && result?.sha256 === sha256) {
resolve({ ok: true, result })
} else {
resolve({
ok: false,
retryable: xhr.status >= 500,
message: xhr.status === 200
? 'Invalid server receipt.'
: `Server rejected the upload (HTTP ${xhr.status}).`,
})
}
})
xhr.addEventListener('error', () => {
resolve({ ok: false, retryable: true, message: 'Network error.' })
})
xhr.addEventListener('timeout', () => {
resolve({ ok: false, retryable: true, message: 'Request timed out.' })
})
xhr.open('POST', '/upload')
xhr.responseType = 'json'
xhr.timeout = 30000
const body = new FormData()
body.append('file', file)
body.append('sha256', sha256)
xhr.send(body)
})
}
async function startUpload() {
if (busy || !selected || attempts >= 3 || (attempts > 0 && !retryable)) return
busy = true
retryable = false
attempts += 1
updateControls()
progress.value = 0
receipt.textContent = ''
status.textContent = `Preparing attempt ${attempts} of 3…`
try {
const digest = await crypto.subtle.digest('SHA-256', await selected.arrayBuffer())
const sha256 = Array.from(new Uint8Array(digest), (byte) =>
byte.toString(16).padStart(2, '0'),
).join('')
status.textContent = `Uploading ${selected.name} (attempt ${attempts} of 3)…`
const outcome = await send(selected, sha256)
if (outcome.ok) {
status.textContent = `Accepted: ${selected.name}. Size and SHA-256 match. No file was saved.`
receipt.textContent = JSON.stringify(outcome.result, null, 2)
} else {
retryable = outcome.retryable
const next = retryable && attempts < 3
? 'Choose Retry to send it again.'
: 'Select a file to start again.'
status.textContent = `${outcome.message} ${next}`
}
} catch {
status.textContent = 'Could not prepare or send this file. Select it again.'
} finally {
busy = false
updateControls()
}
}
form.addEventListener('submit', (event) => {
event.preventDefault()
void startUpload()
})
retry.addEventListener('click', () => void startUpload())
Der Browser berechnet die Prüfsumme mit
crypto.subtle.digest().
Dabei wird die kleine Datei in den Arbeitsspeicher eingelesen. Stellen Sie die Seite unter der
Loopback-URL bereit, die der unten gezeigte Server ausgibt, damit Web Crypto verfügbar ist. Öffnen
Sie index.html nicht direkt. Lassen Sie Content-Type ungesetzt:
FormData liefert seine eigene Multipart-Grenze.
Den lokalen Empfänger hinzufügen
Speichern Sie dies als server.ts. Er liefert nur unsere drei Browserdateien aus
und akzeptiert genau zwei Multipart-Felder: file und
sha256. Das Limit für den Anfragekörper erlaubt zusätzlich zur Datei mit
10 MiB weitere 16 KiB für Multipart-Header. Der Empfänger berechnet vor der Antwort eine eigene
Prüfsumme.
import type { IncomingMessage, ServerResponse } from 'node:http'
import { createHash } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { createServer } from 'node:http'
const maxSize = 10 * 1024 * 1024
const allowedTypes = ['image/jpeg', 'image/png', 'application/pdf']
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 })
}
let origin = ''
function reply(res: ServerResponse, status: number, data: object): void {
res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' })
res.end(JSON.stringify(data))
}
async function handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
if (req.headers.host !== new URL(origin).host) {
reply(res, 403, { error: 'Use the printed loopback URL.' })
return
}
const asset = assets.get(req.url ?? '')
if (req.method === 'GET' && asset) {
res.writeHead(200, { 'Content-Type': asset.type })
res.end(asset.body)
return
}
if (req.method !== 'POST' || req.url !== '/upload') {
reply(res, 404, { error: 'Not found.' })
return
}
if (req.headers.origin !== origin) {
reply(res, 403, { error: 'Use the uploader on this server.' })
return
}
const chunks: Buffer[] = []
let size = 0
// Keep the socket open long enough to return 413 when stopping iteration early.
for await (const chunk of req.iterator({ destroyOnReturn: false })) {
if (!Buffer.isBuffer(chunk)) throw new Error('Expected request bytes')
size += chunk.length
if (size > maxSize + 16 * 1024) {
reply(res, 413, { error: 'Request body is too large.' })
req.resume()
return
}
chunks.push(chunk)
}
let data: FormData
try {
data = await new Request(origin, {
method: 'POST',
headers: { 'Content-Type': req.headers['content-type'] ?? '' },
body: Buffer.concat(chunks),
}).formData()
} catch {
reply(res, 400, { error: 'Invalid multipart body.' })
return
}
const file = data.get('file')
const expected = data.get('sha256')
if ([...data.keys()].length !== 2 || !(file instanceof File) || typeof expected !== 'string') {
reply(res, 400, { error: 'Expected one file and one checksum.' })
return
}
if (file.size === 0 || file.size > maxSize) {
reply(res, 413, { error: 'File must be nonempty and no larger than 10 MiB.' })
return
}
if (!allowedTypes.includes(file.type)) {
reply(res, 415, { error: 'Unsupported declared MIME type.' })
return
}
const sha256 = createHash('sha256').update(new Uint8Array(await file.arrayBuffer())).digest('hex')
if (sha256 !== expected) {
reply(res, 422, { error: 'Checksum does not match.' })
return
}
reply(res, 200, { name: file.name, bytes: file.size, sha256 })
}
const server = createServer((req, res) => {
void handle(req, res).catch(() => {
reply(res, 500, { error: 'Unable to process the upload.' })
})
})
server.requestTimeout = 30000
server.listen(0, '127.0.0.1', () => {
const address = server.address()
if (!address || typeof address === 'string') throw new Error('Missing TCP address')
origin = `http://127.0.0.1:${address.port}`
console.log(`Open ${origin}`)
})
Das Attribut accept ist ein
Hinweis für die Dateiauswahl, keine Validierung.
Sowohl der Browser als auch dieser Empfänger prüfen den deklarierten MIME-Typ. Keiner von beiden
weist nach, dass die Bytes ein gültiges oder sicheres Bild beziehungsweise PDF darstellen. Die
Prüfsumme belegt die Übereinstimmung der Bytes, nicht ihre Vertrauenswürdigkeit. Diese Demo bietet
keine Benutzerkonten, dauerhafte Speicherung, Inhaltsprüfung oder Formatdecodierung. Sie bindet sich
an die Loopback-Adresse und prüft den Ursprung des Browsers, doch diese Prüfungen sind keine
Benutzerauthentifizierung. Ein öffentlicher Dienst benötigt eine eigene Autorisierung, CSRF-Schutz
für cookiebasierte Sitzungen, Inhaltsvalidierung und eine Richtlinie zur Speicherung.
Die Demo ausführen und das Ergebnis prüfen
Führen Sie im Verzeichnis custom-uploader Folgendes aus:
node server.ts
Öffnen Sie die ausgegebene URL, zum Beispiel http://127.0.0.1:49152. Der Server wählt einen
verfügbaren Port, der sich beim Neustart ändern kann. Er liest die Browserdateien beim Start ein;
starten Sie ihn daher nach Änderungen an diesen Dateien neu. Beenden Sie ihn anschließend mit Strg+C.
Drücken Sie die Tab-Taste, um „Choose a file“ zu fokussieren, öffnen Sie die Dateiauswahl per Tastatur und wählen Sie eine kleine PNG-Datei aus. Wechseln Sie mit Tab zu „Upload“ und aktivieren Sie die Schaltfläche. Der abschließende Status sollte „Accepted“ lauten. Die Empfangsbestätigung sollte den Dateinamen, die Länge in Bytes und einen SHA-256-Wert mit 64 Zeichen anzeigen. Der Browser hat diesen Wert mit seiner eigenen Prüfsumme verglichen; auf dem Server wurde nichts gespeichert.
Testen Sie auch diese Fehler- und Interaktionsfälle:
- Legen Sie eine Datei im umrandeten Bereich ab und laden Sie sie dann hoch. Wenn Sie zwei Dateien ablegen, sollte die Aufforderung erscheinen, genau eine Datei auszuwählen.
- Testen Sie eine leere Datei, eine Textdatei und eine Datei mit mehr als 10 MiB. In jedem Fall sollte eine Erklärung dauerhaft sichtbar bleiben, ohne dass eine Anfrage startet. Dateien mit leerem oder unbekanntem MIME-Typ werden ebenfalls abgelehnt, selbst wenn ihre Erweiterung zulässig erscheint.
- Verlangsamen Sie einen Upload mit den Netzwerkwerkzeugen Ihres Browsers. Während er läuft, sollten Dateiauswahl und Schaltflächen deaktiviert sein, und abgelegte Dateien sollten die aktive Auswahl nicht verändern. Der Balken kann 100 % erreichen, während der Status noch anzeigt, dass auf die Annahme durch den Server gewartet wird.
- Schalten Sie den Browser bei bereits geladener Seite offline und laden Sie eine Datei hoch. Gehen Sie nach „Network error“ wieder online und wählen Sie „Retry“. Dieselbe ausgewählte Datei sollte sich nun erfolgreich hochladen lassen. Bleiben Sie für alle drei Versuche offline, um das Retry-Limit zu sehen. Wählen Sie danach dieselbe Datei erneut aus, um eine neue Versuchsreihe zu starten.
Wenn eine Anfrage HTTP 400 zurückgibt, prüfen Sie die Multipart-Feldnamen. 403 bedeutet, dass der Ursprung oder Host nicht mit der ausgegebenen URL übereinstimmt. HTTP 413 weist auf das Größenlimit hin, 415 auf einen nicht unterstützten deklarierten MIME-Typ und 422 auf eine abweichende Prüfsumme. Werden 100 % erreicht und folgt darauf einer dieser Fehler, ist der Upload fehlgeschlagen. Die clientseitige Frist von 30 Sekunden umfasst Anfrage und Antwort. Erhöhen Sie sie bewusst, wenn Sie mit langsameren Verbindungen experimentieren.
Wenn Sie fortsetzbare Uploads benötigen
Ein Retry bedeutet hier, die gesamte Datei erneut zu senden, solange die Seite geöffnet ist. Beim Neuladen gehen die Auswahl und die Anzahl der Versuche verloren. Wenn Sie dauerhafte Speicherung hinzufügen, berücksichtigen Sie den Fall einer verlorenen Erfolgsantwort: Ein Retry darf keine doppelten Datensätze erzeugen. Verwenden Sie für diesen Ablauf einen serverseitig durchgesetzten Idempotenzschlüssel.
Für große Dateien, deren Übertragung ab einem zuvor akzeptierten Byte-Offset fortgesetzt werden muss, verwenden Sie einen tus-Client und -Server. Dafür ist auf beiden Seiten ein Protokoll für fortsetzbare Uploads erforderlich. Eine Datei allein mit JavaScript im Browser in Blöcke aufzuteilen, bietet diese Funktion nicht.
