Datei-Uploads in Webanwendungen optimieren
Zur Upload-Dauer gehören die Vorbereitung einer Datei, ihre Übertragung, das Wiederholen verlorener Anfragen und das Warten auf die serverseitige Validierung. Dieser DevTip zeigt einen Worker mit festen Grenzen für die Bildvorbereitung und eine Browserintegration für fortsetzbare Uploads, mit Uppy als alternativer Oberfläche. Messen Sie diese Phasen auf Ihren eigenen Geräten und in Ihrem Netzwerk, bevor Sie Kompressionseinstellungen wählen oder die Parallelität erhöhen.
Diese Beispiele ergänzen eine bestehende Anwendung mit sitzungsbasierter Authentifizierung und dem
unten beschriebenen Gateway-Vertrag. Führen Sie Einrichtung und Builds im Yarn-4-Projektstamm dieser
Anwendung aus, mit package.json und einem Verzeichnis public/.
Die Befehle aktualisieren Abhängigkeiten und ersetzen die genannten Build-Artefakte; sie erstellen
oder starten keinen Anwendungsserver. Node 26.11.1 und Yarn 4.18.1 sind die getesteten Build-Versionen,
keine grundsätzlichen Mindestversionen für Browser-Uploads. Installieren Sie Yarn separat mithilfe
der Installationsanleitung. Der Browser benötigt Modul-Worker,
createImageBitmap, OffscreenCanvas und AbortSignal.any()/timeout().
Häufige Herausforderungen bei Datei-Uploads
Große Dateien können den Arbeitsspeicher erschöpfen, langsame Netzwerke Zeitüberschreitungen verursachen und Unterbrechungen den Browser im Unklaren lassen, ob eine Anfrage erfolgreich war. Eine hilfreiche Oberfläche unterscheidet zwischen übertragenen Bytes und der Annahme durch den Server. Sicherheit hängt von einer authentifizierten Besitzzuordnung und serverseitiger Validierung ab, auch wenn der Browser offensichtlich ungeeignete Dateien bereits vorab ablehnt.
Bewährte Verfahren zur Optimierung der Upload-Leistung
1. Dateien in Blöcken hochladen
Verwenden Sie ein fortsetzbares Protokoll mit expliziten Offsets, statt anonyme Dateiausschnitte zu senden. Die folgende tus-Integration verwendet Blöcke mit 1 MiB und eine begrenzte Zahl von Retries. Das begrenzt die Größe erneuter Übertragungen, verspricht aber keinen höheren Durchsatz bei jeder Verbindung. HTTP-Fehler, die sich nicht beheben lassen, führen zum Fehlschlagen des Vorgangs, auch wenn sie auftreten, nachdem das letzte Byte den Server erreicht hat.
2. Clientseitige Kompression implementieren
Eine Größenänderung von Bildern kann die Datenmenge reduzieren, kostet aber CPU-Leistung, Qualität und Metadaten. Der optionale Worker unten akzeptiert JPEG- oder PNG-Dateien bis 10 MiB, begrenzt decodierte Bilder auf 16 Millionen Pixel, passt beide Abmessungen in 1024 × 1024 ein und erzeugt ein JPEG. Transparenz wird weiß, und Metadaten werden nicht aus der Eingabe übernommen. Der JPEG-Encoder kann eigene Metadaten hinzufügen, etwa zur Auflösung. Die Farbkonvertierung im Browser kann Farben verändern, auch bei CMYK-JPEGs; prüfen Sie das Ergebnis und behalten Sie das Original, wenn diese Änderungen nicht akzeptabel sind. Beim Decodieren kann Arbeitsspeicher belegt werden, bevor die Abmessungen bekannt sind; dies ist eine praktische Lösung mit festen Grenzen für normale Nutzerfotos, kein Schutz vor bösartigen Bilddecodern.
Das Decodieren im Browser belegt außerdem nicht, dass eine beschädigte Eingabe intakt ist. Die serverseitige Inhaltsvalidierung gilt weiterhin für das vorbereitete JPEG. Für die Vorbereitung gilt ein Zeitlimit von 30 Sekunden; auf einem langsamen Gerät kann es auch bei einem ansonsten geeigneten Bild überschritten werden.
Erstellen Sie image-worker.js:
self.onmessage = async ({ data: file }) => {
let bitmap
try {
if (
!(file instanceof Blob) ||
file.size === 0 ||
file.size > 10 * 1024 * 1024 ||
!['image/jpeg', 'image/png'].includes(file.type)
) {
throw new Error('Unsupported image')
}
bitmap = await createImageBitmap(file)
if (bitmap.width * bitmap.height > 16_000_000) throw new Error('Image too large')
const scale = Math.min(1, 1024 / bitmap.width, 1024 / bitmap.height)
const width = Math.max(1, Math.round(bitmap.width * scale))
const height = Math.max(1, Math.round(bitmap.height * scale))
const canvas = new OffscreenCanvas(width, height)
const context = canvas.getContext('2d')
if (!context) throw new Error('Canvas unavailable')
context.fillStyle = 'white'
context.fillRect(0, 0, width, height)
context.drawImage(bitmap, 0, 0, width, height)
const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality: 0.8 })
if (blob.type !== 'image/jpeg' || blob.size === 0) throw new Error('Encoding failed')
self.postMessage({ blob })
} catch {
self.postMessage({ error: 'Image preparation failed.' })
} finally {
bitmap?.close()
}
}
3. Web Workers für die Hintergrundverarbeitung nutzen
Worker sind hier zum Decodieren und Codieren hilfreich. Fetch und das Erstellen von
Blob-Ausschnitten benötigen keinen Worker pro Anfrage. Dieser Wrapper namens
prepare-image.js beendet seinen einzigen Worker bei Erfolg, einem Laufzeitfehler,
einem Fehler beim Decodieren einer Nachricht, einem Abbruch, einer Zeitüberschreitung oder einem
fehlgeschlagenen Aufruf von postMessage:
export function prepareImage(file, signal) {
signal.throwIfAborted()
return new Promise((resolve, reject) => {
const worker = new Worker(new URL('./image-worker.js', import.meta.url), { type: 'module' })
let settled = false
let timer
const finish = (error, blob) => {
if (settled) return
settled = true
clearTimeout(timer)
signal.removeEventListener('abort', abort)
worker.terminate()
if (error) reject(error)
else resolve(new File([blob], 'upload.jpg', { type: 'image/jpeg' }))
}
const abort = () => finish(new Error('Image preparation canceled.'))
signal.addEventListener('abort', abort, { once: true })
timer = setTimeout(() => finish(new Error('Image preparation timed out.')), 30_000)
worker.onerror = () => finish(new Error('Image worker failed.'))
worker.onmessageerror = () => finish(new Error('Invalid worker message.'))
worker.onmessage = ({ data }) => {
if (
!(data?.blob instanceof Blob) ||
data.blob.size === 0 ||
data.blob.type !== 'image/jpeg'
) {
finish(new Error('Image preparation failed.'))
return
}
finish(null, data.blob)
}
try {
worker.postMessage(file)
} catch {
finish(new Error('Could not start image preparation.'))
}
})
}
Weitere Informationen finden Sie in der Plattformdokumentation zu OffscreenCanvas.convertToBlob und Worker.terminate.
Sicherheit bei Datei-Uploads gewährleisten
1. Dateitypen und Größen validieren
Die Upload-Funktionen akzeptieren Dateien bis 10 MiB. Clientseitige MIME-Typen, Namen und Größen sind nicht vertrauenswürdige Metadaten. Der Server muss die Datenmenge unabhängig begrenzen, unterstützte Inhalte erkennen und Richtlinien für Sicherheitsscans oder das Decodieren anwenden, bevor er einen Upload verfügbar macht.
2. Sichere Dateispeicherung nutzen
Die folgenden Browserbeispiele benötigen eine bestehende Anwendung mit Authentifizierung auf
demselben Ursprung und einem tus-Endpunkt unter /api/tus/files/. Diese Anleitung
behandelt den Client; sie stellt diesen Server nicht bereit. Der gepflegte
Node.js-tus-Server liefert Implementierungen für Protokoll und
Speicherung; das Gateway Ihrer Anwendung muss die Sitzungs- und Veröffentlichungsrichtlinien
bereitstellen. Konfigurieren Sie das Gateway so, dass es bei Änderungen die Sitzung und
X-CSRF-TOKEN validiert und bei jeder tus-Anfrage X-Upload-Owner
anhand der Sitzung durchsetzt. Erzwingen Sie insgesamt 10 MiB, Blöcke mit 1 MiB, Speicher- und
Anfragelimits pro Nutzer, eine Ablaufregelung und privates Staging. Prüfen Sie die Besitzzuordnung
bei der Erstellung, bei HEAD, PATCH und allen Anfragen zur Beendigung. Lehnen Sie Weiterleitungen
ab und geben Sie ausschließlich Location-URLs auf demselben Ursprung unterhalb von
/api/tus/files/ aus.
Nachdem tus den Abschluss meldet, akzeptiert POST /api/upload-publications den Wert
{uploadUrl}. Der Server darf diese URL nur anhand seiner eigenen Upload-Datensätze
mit zugeordnetem Eigentümer auflösen und niemals eine beliebige URL abrufen. Er prüft
Vollständigkeit und Inhalt und veröffentlicht anschließend genau einmal atomar; doppelte Anfragen
liefern denselben Beleg mit HTTP 200 oder 201 zurück. Antworten ohne Erfolgsstatus oder mit Status
202 bestätigen keine Veröffentlichung. Geben Sie {id} mit einer opaken,
nicht leeren Beleg-ID zurück. Für Download-Zugriffe muss der anfragende Nutzer separat autorisiert
werden. Zufällige Dateinamen allein bieten keine Zugriffskontrolle.
Fortsetzbare Uploads mit tus implementieren
Der Name des Protokolls lautet tus. Installieren Sie die getestete Client-Version in Ihrem Browserprojekt:
(command -v node >/dev/null && command -v yarn >/dev/null &&
yarn add --exact tus-js-client@4.3.1 &&
yarn add --dev --exact esbuild@0.28.2)
Erstellen Sie upload.js. Übergeben Sie die aktuelle Eigentümer-ID und das
CSRF-Token aus Ihrer authentifizierten Seite; keines davon ist ein Auth Secret von Transloadit.
Diese Funktion wiederholt unterbrochene Anfragen innerhalb einer Seitensitzung. Sie ordnet eine
neu ausgewählte Datei nicht automatisch allein anhand ihres Namens einem früheren Upload zu.
Ein abgebrochener oder fehlgeschlagener Upload läuft auf dem Server ab; ein neuer Aufruf erstellt
einen neuen Upload.
import { Upload } from 'tus-js-client'
export async function uploadFile(file, { owner, csrf, signal, onProgress }) {
signal.throwIfAborted()
if (
!(file instanceof Blob) ||
file.size === 0 ||
file.size > 10 * 1024 * 1024 ||
!owner ||
!csrf
) {
throw new Error('Choose a file between 1 byte and 10 MiB while signed in.')
}
const headers = { 'X-CSRF-TOKEN': csrf, 'X-Upload-Owner': owner }
const uploadUrl = await new Promise((resolve, reject) => {
let settled = false
let timer
const finish = (error, url) => {
if (settled) return
settled = true
clearTimeout(timer)
signal.removeEventListener('abort', abort)
if (error) {
void upload.abort().catch(() => {})
reject(error)
} else resolve(url)
}
const abort = () => finish(new Error('Upload canceled.'))
const upload = new Upload(file, {
endpoint: '/api/tus/files/',
headers,
httpStack: {
getName() { return 'SameOriginFetch' },
createRequest(method, url) {
const controller = new AbortController()
const requestHeaders = new Headers()
return {
getMethod() { return method },
getURL() { return url },
getUnderlyingObject() { return controller },
setHeader(name, value) { requestHeaders.set(name, value) },
getHeader(name) { return requestHeaders.get(name) },
setProgressHandler() {},
async abort() { controller.abort() },
async send(body) {
const response = await fetch(url, {
method, body, headers: requestHeaders,
credentials: 'same-origin', redirect: 'error', signal: controller.signal,
})
const text = response.ok ? await response.text() : ''
return {
getStatus() { return response.status },
getHeader(name) { return response.headers.get(name) },
getBody() { return text },
getUnderlyingObject() { return response },
}
},
}
},
},
chunkSize: 1024 * 1024,
storeFingerprintForResuming: false,
retryDelays: [0, 1000, 3000],
onShouldRetry(error) {
const status = error.originalResponse?.getStatus() ?? 0
return status === 0 || status === 409 || status === 423 || status === 429 || status >= 500
},
onBeforeRequest(request) {
const url = new URL(request.getURL(), location.href)
if (url.origin !== location.origin || !url.pathname.startsWith('/api/tus/files/')) {
throw new Error('Unexpected upload URL')
}
},
onProgress(loaded, total) {
try {
onProgress(Math.min(99, Math.floor((100 * loaded) / total)))
} catch {
finish(new Error('Progress display failed.'))
}
},
onError() {
finish(new Error('Upload failed.'))
},
onSuccess() {
if (!upload.url) finish(new Error('Missing upload URL.'))
else finish(null, upload.url)
},
})
signal.addEventListener('abort', abort, { once: true })
timer = setTimeout(() => finish(new Error('Upload timed out.')), 120_000)
try {
upload.start()
} catch {
finish(new Error('Could not start upload.'))
}
})
signal.throwIfAborted()
const response = await fetch('/api/upload-publications', {
method: 'POST',
credentials: 'same-origin',
redirect: 'error',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ uploadUrl }),
signal: AbortSignal.any([signal, AbortSignal.timeout(30_000)]),
})
if (!response.ok || ![200, 201].includes(response.status)) {
throw new Error(`Publication not confirmed (HTTP ${response.status}).`)
}
const receipt = await response.json()
if (typeof receipt.id !== 'string' || !receipt.id) throw new Error('Invalid publication receipt.')
onProgress(100)
return receipt.id
}
Hier kommen die tatsächlichen Optionen und Callbacks von tus-js-client zum Einsatz. Der schlanke Fetch-Transport implementiert die HTTP-Stack-Schnittstelle des Clients, um Weiterleitungen abzulehnen, bevor er ihnen folgt. Der Fortschritt steigt an den Grenzen der Blöcke von tus, statt gerade übertragene Bytes zu verfolgen. Nach einer verlorenen Antwort kann ein Browser eine Anfrage erneut senden, bevor tus den bereits bestätigten Offset abgleicht; der Server muss einen veralteten Offset ablehnen, ohne Bytes doppelt anzuhängen. Das Zeitlimit von zwei Minuten begrenzt die gesamte Übertragung einschließlich Retries. Geht die Antwort zur Veröffentlichung verloren, ist das Ergebnis unklar: Prüfen Sie die Upload-Liste Ihres Servers, bevor Sie einen weiteren Upload starten. Eine langsame Verbindung kann dieses Zeitlimit auch unter 10 MiB erreichen. Für die abschließende Veröffentlichung gilt ein separates Zeitlimit von 30 Sekunden. Ein Abbruch stoppt die Arbeit des Clients, beweist aber nicht, dass der Server einen Upload gelöscht oder zurückgehalten hat; zurückgelassene Staging-Daten müssen auf dem Server ablaufen.
Für eine vollständige Seitenintegration geben Sie die Attribute data-upload-owner
und data-csrf aus Ihrer sitzungsbasierten Seite mit korrektem Escaping auf
<html> aus und ergänzen Sie:
<label>File <input id="file" type="file" /></label>
<label><input id="resize" type="checkbox" /> Prepare JPEG or PNG as JPEG</label>
<button id="upload" type="button">Upload</button>
<button id="cancel" type="button">Cancel</button>
<progress id="progress" aria-label="Upload progress" max="100" value="0"></progress>
<p id="status" role="status"></p>
<script type="module" src="/main.js"></script>
Erstellen Sie main.js:
import { prepareImage } from './prepare-image.js'
import { uploadFile } from './upload.js'
const input = document.getElementById('file')
const button = document.getElementById('upload')
const status = document.getElementById('status')
const resize = document.getElementById('resize')
let controller = null
button.onclick = async () => {
if (controller || !input.files?.[0]) return
controller = new AbortController()
button.disabled = true
input.disabled = true
resize.disabled = true
document.getElementById('progress').value = 0
status.textContent = 'Preparing upload…'
try {
let file = input.files[0]
if (resize.checked) {
file = await prepareImage(file, controller.signal)
}
await uploadFile(file, {
owner: document.documentElement.dataset.uploadOwner,
csrf: document.documentElement.dataset.csrf,
signal: controller.signal,
onProgress(value) {
document.getElementById('progress').value = value
},
})
status.textContent = 'Upload published.'
} catch {
status.textContent = 'Upload failed or canceled. Check your uploads before retrying.'
} finally {
controller = null
button.disabled = false
input.disabled = false
resize.disabled = false
}
}
document.getElementById('cancel').onclick = () => controller?.abort()
Bündeln Sie den Haupteinstiegspunkt und liefern Sie den Worker daneben auf demselben HTTPS-Ursprung aus:
(command -v node >/dev/null && command -v yarn >/dev/null && command -v cp >/dev/null &&
yarn esbuild main.js --bundle --format=esm --tsconfig-raw='{}' --outfile=public/main.js &&
cp image-worker.js public/image-worker.js)
Uppy für ein nahtloses Nutzererlebnis einsetzen
Uppy bietet eine Oberfläche für Auswahl, Beschränkungen, Fortschritt und Abbruch. Verwenden Sie
für dieses Gateway die Schnittstelle von Uppy für individuelle Dateiuploader, um dieselbe
Funktion uploadFile aufzurufen. So bleibt die Veröffentlichung Teil des
Erfolgskriteriums, und es gelten dasselbe Zeitlimit, dieselben Authentifizierungsheader und
dieselbe Fehlerbehandlung. Dies ist ein alternativer Einstiegspunkt, kein zusätzlicher
Dateiuploader, den Sie neben der vorherigen Seite einbinden.
Installieren Sie kompatible Pakete:
(command -v node >/dev/null && command -v yarn >/dev/null &&
yarn add --exact @uppy/core@6.2.0 @uppy/dashboard@6.0.1)
Erstellen Sie uppy-main.js und binden Sie es auf einer Seite mit
<div id="drag-drop-area"></div> und denselben Sitzungsattributen ein. Die Anwendung muss die
zurückgegebene Bereinigungsfunktion aufrufen, wenn sie diese Oberfläche entfernt.
import Uppy from '@uppy/core'
import Dashboard from '@uppy/dashboard'
import '@uppy/core/css/style.min.css'
import '@uppy/dashboard/css/style.min.css'
import { uploadFile } from './upload.js'
export function mountUploader() {
const uppy = new Uppy({
restrictions: { maxNumberOfFiles: 1, maxFileSize: 10 * 1024 * 1024, minFileSize: 1 },
}).use(Dashboard, { inline: true, target: '#drag-drop-area' })
let active = null
let destroyed = false
const cancel = () => {
const previous = active
active = null
previous?.controller.abort()
}
uppy.on('cancel-all', cancel)
uppy.on('file-removed', (file) => {
if (active?.fileId === file.id) cancel()
})
uppy.addUploader(async (ids) => {
if (active) throw new Error('An upload is already running.')
const file = uppy.getFile(ids[0])
if (!file || !(file.data instanceof Blob)) throw new Error('Select a local file.')
const operation = { fileId: file.id, controller: new AbortController() }
const uploadStarted = Date.now()
active = operation
const current = () => !destroyed && active === operation && Boolean(uppy.getFile(file.id))
uppy.emit('upload-start', [file])
try {
const id = await uploadFile(file.data, {
owner: document.documentElement.dataset.uploadOwner,
csrf: document.documentElement.dataset.csrf,
signal: operation.controller.signal,
onProgress(value) {
if (!current()) return
uppy.emit('upload-progress', file, {
uploadStarted,
bytesUploaded: Math.floor((file.data.size * value) / 100),
bytesTotal: file.data.size,
})
},
})
if (current()) uppy.emit('upload-success', file, { status: 200, body: { id } })
} catch {
if (!current()) return
uppy.emit('upload-error', file, new Error('Upload not confirmed. Check your uploads.'))
throw new Error('Upload not confirmed.')
} finally {
if (active === operation) active = null
}
})
return () => {
destroyed = true
cancel()
uppy.destroy()
}
}
let cleanup = mountUploader()
window.addEventListener('pagehide', () => cleanup())
window.addEventListener('pageshow', (event) => {
if (event.persisted) cleanup = mountUploader()
})
Verwenden Sie dieses Markup anstelle der Bedienelemente und des Skripts der einfachen Seite,
mit denselben Sitzungsattributen auf <html>:
<link rel="stylesheet" href="/uppy-main.css" />
<div id="drag-drop-area"></div>
<script type="module" src="/uppy-main.js"></script>
Erstellen Sie den Build für den alternativen Einstiegspunkt:
(command -v node >/dev/null && command -v yarn >/dev/null &&
yarn esbuild uppy-main.js --bundle --format=esm --tsconfig-raw='{}' --outfile=public/uppy-main.js)
Ein Abbruch beendet den aktuellen Vorgang, bevor eine weitere Datei zugelassen wird. Dadurch
können verspätete Callbacks den nachfolgenden Vorgang weder als fehlgeschlagen markieren noch
dessen Abbruch-Handle löschen. Bei pagehide entfernt diese Integration das
Dashboard und bricht aktive Client-Vorgänge ab; bei einer Wiederherstellung aus dem Seitencache
wird ein leeres Dashboard eingebunden. Wählen Sie die Datei nach Ihrer Rückkehr erneut aus.
Uploads von entfernten Anbietern benötigen eine separate authentifizierte Companion-Integration;
dieses Beispiel akzeptiert nur lokale Blobs. Weitere Informationen finden Sie in der
Uploader-API von Uppy.
Das veröffentlichte Ergebnis bestätigen
Wählen Sie auf der einfachen Seite eine Datei aus, aktivieren Sie optional die Bildvorbereitung und wählen Sie Upload. Die Seite wartet auf einen gültigen Serverbeleg, bevor sie Upload published. anzeigt.
Der entsprechende Zustand in Uppy ist Complete. Eine Antwort mit Status 202, eine fehlgeschlagene Validierung oder eine Zeitüberschreitung lässt den Upload unbestätigt, selbst wenn jedes Byte gesendet wurde. Prüfen Sie die authentifizierte Upload-Liste, bevor Sie eine Veröffentlichung mit unklarem Ergebnis erneut versuchen; ein neuer Aufruf erzeugt neue Staging-Daten.
Vergleichen Sie bei einer nicht vorbereiteten Datei die gespeicherte Bytezahl und SHA-256 mit der ausgewählten Datei mithilfe der autorisierten Prüfwerkzeuge Ihrer Anwendung. Prüfen Sie bei einem vorbereiteten Bild das resultierende JPEG: Keine Abmessung überschreitet 1024, das Seitenverhältnis bleibt bis auf Rundungen erhalten, und transparente Bereiche haben einen weißen Hintergrund. Größenänderung und JPEG-Encoding können eine kleine PNG-Datei vergrößern. Messen Sie daher die vorbereitete Dateigröße und prüfen Sie die visuelle Qualität, bevor Sie die Vorbereitung standardmäßig aktivieren.
Weitere Ressourcen
Antworten auf häufige Fragen
Wie bestätigen Sie einen Upload? Die Bytezahl einer Übertragung reicht nicht aus. Bestätigen Sie nach Validierung und Veröffentlichung einen authentifizierten Serverbeleg. Anbieterspezifische Malware-Analyse ist eine separate Integration; dafür gibt es keinen universellen CLI-Befehl zur Upload-Verifizierung.
Was ist ein uneingeschränkter Datei-Upload? Dabei handelt es sich um einen Upload-Endpunkt, der Inhalte ohne ausreichende Beschränkungen akzeptiert. Dadurch können möglicherweise gefährliche Dateien gespeichert oder ausgeführt werden. Validieren Sie auf dem Server und isolieren Sie den Speicher von ausführbaren Webinhalten.
Welches HTML-Anfrageformat unterstützt Datei-Uploads? Ein klassisches Formular verwendet
method="post" und enctype="multipart/form-data" mit einem benannten
Dateiauswahlfeld. Wenn Sie FormData mit Fetch oder Axios im Browser senden,
lassen Sie den Browser die Multipart-Grenze setzen; erstellen Sie den Header nicht selbst.
