Drag-and-drop-Dateiupload in React implementieren
Eine ausgewählte Datei ist noch nicht auf Ihrem Server angekommen. Erstellen Sie eine React-Ablagefläche, mit der Sie eine Datei auswählen, an einen lokalen Node.js-Empfänger hochladen und sehen können, ob der Server sie gespeichert hat. Das Beispiel behandelt auch abgelehnte Auswahlen und fehlgeschlagene Anfragen, ohne einen zweiten Upload parallel zum ersten zuzulassen.
react-dropzone übernimmt die Auswahl,
einschließlich Drag-and-drop und Dateiauswahldialog. Für die Übertragung verwenden wir
fetch() und melden den Erfolg erst, nachdem der Empfänger die Datei vollständig
geschrieben hat.
Ein kleines React-Projekt einrichten
Verwenden Sie für diese Anleitung Node.js 24.15.0 und Corepack mit Yarn 4.12.0. Das Beispiel wurde unter Linux mit React und React DOM 19.3.0, react-dropzone 20.1.2, esbuild 0.28.2, TypeScript 6.0.3 und Chromium 145 getestet. Es ist ein lokales Lernprojekt ohne Konten oder Cloud-Speicher.
Fügen Sie Folgendes in Bash ein, ausgehend vom Verzeichnis, in dem Sie das Projekt erstellen möchten.
Die Klammern sorgen dafür, dass Ihre Shell in diesem übergeordneten Verzeichnis bleibt. Falls
react-dropzone-demo bereits existiert, wählen Sie ein neues übergeordnetes Verzeichnis;
der Block verweigert bewusst das Überschreiben eines bestehenden Projekts.
(
set -e
mkdir react-dropzone-demo
cd react-dropzone-demo
printf '%s\n' '{"private":true,"type":"module","packageManager":"yarn@4.12.0"}' > package.json
printf 'nodeLinker: node-modules\nenableGlobalCache: false\n' > .yarnrc.yml
touch yarn.lock
mkdir src public
corepack yarn add --exact react@19.3.0 react-dom@19.3.0 react-dropzone@20.1.2 \
esbuild@0.28.2 typescript@6.0.3 @types/react@19.3.0 @types/react-dom@19.3.0
)
Speichern Sie alle folgenden Dateien in react-dropzone-demo. Erstellen Sie
tsconfig.json, damit Compiler und Bundler die Einstellungen dieses Projekts verwenden:
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"skipLibCheck": true,
"types": ["react", "react-dom"]
},
"include": ["src/**/*.tsx"]
}
Eine Datei auswählen und das Upload-Ergebnis melden
Speichern Sie dies als src/DragAndDropUpload.tsx. Die Ablagefläche akzeptiert eine PNG-, JPEG-, GIF-,
PDF- oder Textdatei mit bis zu 5 MiB. Eine ungültige Auswahl löscht die vorherige Auswahl. Werden
akzeptierte und abgelehnte Dateien gemeinsam abgelegt, wird die gesamte Auswahl abgelehnt. So kann
ein Klick auf den Upload-Button nicht unbemerkt nur einen Teil davon senden.
import { useId, useRef, useState, type ReactNode } from 'react'
import { useDropzone } from 'react-dropzone'
const accept = {
'image/png': ['.png'],
'image/jpeg': ['.jpg', '.jpeg'],
'image/gif': ['.gif'],
'application/pdf': ['.pdf'],
'text/plain': ['.txt'],
}
export function DragAndDropUpload(): ReactNode {
const hintId = useId()
const [file, setFile] = useState<File | null>(null)
const [busy, setBusy] = useState(false)
const [status, setStatus] = useState('Choose a file to begin.')
const [error, setError] = useState('')
const uploading = useRef(false)
const { getRootProps, getInputProps, isDragActive } = useDropzone({
accept,
multiple: false,
maxSize: 5 * 1024 * 1024,
disabled: busy,
onDrop(accepted, rejected) {
if (uploading.current) return
setError('')
setStatus('Choose a file to begin.')
if (rejected.length > 0 || accepted.length !== 1) {
setFile(null)
setError('Choose one PNG, JPEG, GIF, PDF, or text file no larger than 5 MiB.')
return
}
setFile(accepted[0])
setStatus('Ready to upload.')
},
})
async function upload(): Promise<void> {
if (!file || uploading.current) return
uploading.current = true
setBusy(true)
setError('')
setStatus('Uploading…')
try {
const body = new FormData()
body.append('file', file)
const response = await fetch('/api/upload', {
method: 'POST',
body,
signal: AbortSignal.timeout(30_000),
})
if (!response.ok) {
setStatus('Upload rejected.')
setError(`The server rejected the upload (HTTP ${response.status}). Fix the cause, then retry.`)
return
}
setStatus('Saved on the server.')
setFile(null)
} catch {
setStatus('Upload unconfirmed.')
setError('Could not confirm the upload. Check the connection and server, then retry.')
} finally {
uploading.current = false
setBusy(false)
}
}
return (
<section aria-label="File upload">
<div {...getRootProps({
role: 'button',
'aria-label': 'Choose a file',
'aria-describedby': hintId,
className: isDragActive ? 'dropzone active' : 'dropzone',
})}>
<input {...getInputProps({ 'aria-label': 'Upload file', disabled: busy })} />
<p>{isDragActive ? 'Drop the file here.' : 'Drop a file here, or click to choose.'}</p>
</div>
<p id={hintId}>One PNG, JPEG, GIF, PDF, or text file. Maximum 5 MiB.</p>
<p>{file ? `Selected: ${file.name}` : 'No file selected.'}</p>
<button type="button" onClick={upload} disabled={busy || file === null}>
{busy ? 'Uploading…' : error && file ? 'Retry upload' : 'Upload'}
</button>
<p role="status">{status}</p>
{error ? <p role="alert">{error}</p> : null}
</section>
)
}
Der State deaktiviert die Auswahl- und Upload-Bedienelemente, solange die Anfrage läuft. Die Ref blockiert zudem schnelle wiederholte Aufrufe, bevor React diesen deaktivierten Zustand gerendert hat. Bei fehlgeschlagenen Anfragen bleibt die Datei für Retry upload verfügbar; nach bestätigtem Speichern wird die Auswahl geleert.
Setzen Sie Content-Type der Anfrage nicht selbst: Der Browser ergänzt die
Multipart-Boundary beim Senden von FormData.
Prüfen Sie auch response.ok:
fetch erfüllt das Promise auch bei HTTP-Fehlerantworten,
einschließlich einer Ablehnung durch den Server. Das Timeout von 30 Sekunden bedeutet, dass der Client
das Ergebnis nicht bestätigen konnte; der Server hat die Datei möglicherweise bereits gespeichert.
Ein erneuter Versuch kann daher eine weitere Kopie erzeugen.
Einen lokalen Empfänger hinzufügen
Speichern Sie server.ts im Projektstammverzeichnis. Der Empfänger liefert die Seite
aus und akzeptiert ein Multipart-Feld namens file unter
/api/upload, sodass Browser und Empfänger denselben Ursprung verwenden. Jede
erfolgreiche Anfrage schreibt eine neue Datei mit einer UUID als Namen unter
uploads/ und gibt HTTP 201 zurück. Das Terminal zeigt den Speicherpfad an.
Bestehende Dateien bleiben erhalten, auch nachdem der Server gestoppt wurde; löschen Sie die Uploads
der Demo, wenn Sie sie nicht mehr benötigen.
Dieser Empfänger dient lokalen Tests. Er prüft die Anzahl der Felder, die Größe und den angegebenen MIME-Typ. Weder der Filter im Dateiauswahldialog noch die MIME-Metadaten belegen jedoch, was die Bytes enthalten. Er puffert jede Anfrage und begrenzt sie auf 6 MiB für die Datei samt Multipart-Overhead. Ein produktiv eingesetzter Empfänger benötigt Inhaltsvalidierung, Authentifizierung, Speicherquoten und einen Streaming-Multipart-Parser, der zu seinen Limits passt.
import { randomUUID } from 'node:crypto'
import { once } from 'node:events'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
import { join } from 'node:path'
const allowedTypes = new Set([
'image/png', 'image/jpeg', 'image/gif', 'application/pdf', 'text/plain',
])
const uploads = join(import.meta.dirname, 'uploads')
const assets = new Map<string, { body: Buffer; type: string }>()
async function handle(request: IncomingMessage, response: ServerResponse): Promise<void> {
const asset = assets.get(request.url ?? '')
if (request.method === 'GET' && asset) {
response.writeHead(200, { 'Content-Type': asset.type }).end(asset.body)
return
}
if (request.method !== 'POST' || request.url !== '/api/upload') {
response.writeHead(404).end()
return
}
const chunks: Buffer[] = []
let bytes = 0
for await (const chunk of request) {
if (!Buffer.isBuffer(chunk)) throw new Error('Expected request bytes')
bytes += chunk.length
if (bytes > 6 * 1024 * 1024) {
response.writeHead(413).end()
return
}
chunks.push(chunk)
}
let form: FormData
try {
form = await new Response(Buffer.concat(chunks), {
headers: { 'Content-Type': request.headers['content-type'] ?? '' },
}).formData()
} catch {
response.writeHead(400).end()
return
}
const entries = [...form.entries()]
const file = form.get('file')
if (entries.length !== 1 || !(file instanceof File)) {
response.writeHead(400).end()
return
}
if (file.size > 5 * 1024 * 1024) {
response.writeHead(413).end()
return
}
if (!allowedTypes.has(file.type)) {
response.writeHead(415).end()
return
}
const id = randomUUID()
await writeFile(join(uploads, id), Buffer.from(await file.arrayBuffer()), { flag: 'wx' })
console.log(`Saved uploads/${id}`)
response.writeHead(201).end()
}
async function main(): Promise<void> {
const port = Number(process.argv[2] ?? 0)
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('Invalid port')
for (const [route, name, type] of [
['/', 'index.html', 'text/html; charset=utf-8'],
['/app.js', 'app.js', 'text/javascript; charset=utf-8'],
['/app.css', 'app.css', 'text/css; charset=utf-8'],
]) {
assets.set(route, { body: await readFile(join(import.meta.dirname, 'public', name)), type })
}
await mkdir(uploads, { recursive: true })
const server = createServer((request, response) => {
void handle(request, response).catch(() => {
console.error('Request failed; check that the uploads directory is writable.')
if (!response.headersSent) response.writeHead(500)
response.end()
})
})
server.listen(port, '127.0.0.1')
await once(server, 'listening')
const address = server.address()
if (!address || typeof address === 'string') throw new Error('Missing server address')
console.log(`Open http://127.0.0.1:${address.port}/`)
}
main().catch((error: unknown) => {
console.error('Server could not start:', error)
process.exitCode = 1
})
Mit Port null kann Node einen ungenutzten Port wählen. Der Server bindet ausschließlich an die Loopback-Adresse dieses Rechners. Seine Startprüfungen lesen die erstellten Assets, bevor eine URL ausgegeben wird. Fehlt der Build, schlägt der Start daher sofort fehl.
Bedienelemente gut sichtbar gestalten
Speichern Sie public/app.css. Behalten Sie die Fokusumrandung bei: Sie zeigt Personen,
die per Tastatur navigieren, ihre aktuelle Position. Lange Dateinamen werden umgebrochen, statt die
Seite zu verbreitern.
body { font: 1rem/1.5 system-ui, sans-serif; margin: 0; color: #17202a; background: #fff; }
main { max-width: 36rem; margin: 2rem auto; padding: 1rem; overflow-wrap: anywhere; }
.dropzone { border: 2px dashed #2367a1; border-radius: 0.5rem; padding: 1.5rem; cursor: pointer; }
.dropzone.active { background: #e7f2ff; }
.dropzone:focus-visible, button:focus-visible { outline: 3px solid #17202a; outline-offset: 4px; }
.dropzone[aria-disabled="true"], button:disabled { cursor: not-allowed; opacity: 0.6; }
button { font: inherit; padding: 0.5rem 1rem; }
[role="alert"] { color: #9b1c1c; }
Die Komponente einbinden und ausführen
Speichern Sie src/main.tsx:
import { createRoot } from 'react-dom/client'
import { DragAndDropUpload } from './DragAndDropUpload.tsx'
const root = document.getElementById('root')
if (!root) throw new Error('Missing root element')
createRoot(root).render(<main><h1>Upload a file</h1><DragAndDropUpload /></main>)
Speichern Sie public/index.html. Die Datei lädt das obige Stylesheet und das
JavaScript-Bundle, das wir als Nächstes erstellen:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>React file upload</title>
<link rel="icon" href="data:,">
<link rel="stylesheet" href="/app.css">
</head>
<body>
<div id="root"></div>
<script type="module" src="/app.js"></script>
</body>
</html>
Führen Sie im selben übergeordneten Verzeichnis wie beim Einrichtungsbefehl die Typprüfung und das Bundling aus und starten Sie den Empfänger:
(
cd react-dropzone-demo &&
corepack yarn exec tsc --project tsconfig.json &&
corepack yarn exec esbuild src/main.tsx --bundle --format=esm --jsx=automatic \
--tsconfig=tsconfig.json --outfile=public/app.js &&
node server.ts
)
Öffnen Sie die im Terminal ausgegebene URL. Wählen Sie eine Datei und klicken Sie auf
Upload.
Die Seite zeigt zunächst Uploading… und nach der
Antwort des Empfängers Saved on the server.. Vergleichen Sie
die Datei in uploads/ mit Ihrem Original; der generierte Dateiname ändert sich,
aber die Bytes sollten übereinstimmen. Stoppen Sie den Server mit Ctrl+C. Führen Sie diesen Block zum
Erstellen und Starten nach Dateiänderungen erneut aus; es gibt kein automatisches Neuladen. Ein
fehlgeschlagener Build stoppt den Block, bevor er ein älteres Bundle ausliefern kann.
Auswahl, Ablehnung und erneute Versuche prüfen
Navigieren Sie mit Tab zu Choose a file und drücken Sie die Eingabe- oder Leertaste, um den Dateiauswahldialog zu öffnen. Die Root-Props übernehmen die Tastatursteuerung; die Input-Props binden das native Dateieingabeelement an. Die Status- und Alarmbereiche vermitteln Rückmeldungen, ohne den Fokus zu verschieben. Auch ein Klick oder Tippen öffnet den Dateiauswahldialog, Ziehen ist also optional. Testen Sie vor der Veröffentlichung mit Ihren Zielbrowsern auf Mobilgeräten und mit assistiven Technologien; die Browserprüfungen dieser Anleitung decken Chromium auf dem Desktop ab.
Testen Sie neben einem erfolgreichen kleinen Upload auch diese Fehlerfälle:
- Legen Sie eine Datei mit mehr als 5 MiB, einen nicht unterstützten Dateityp oder zwei Dateien gleichzeitig ab. Die Seite erklärt die Auswahlregel und deaktiviert Upload.
- Versuchen Sie während einer laufenden Anfrage, eine andere Datei auszuwählen oder erneut hochzuladen. Die aktuelle Datei bleibt ausgewählt und die Bedienelemente bleiben deaktiviert, bis die Anfrage abgeschlossen ist.
- Stoppen Sie den Server nach der Dateiauswahl und klicken Sie dann auf
Upload.
Sie sollten Upload unconfirmed. sehen; die Datei bleibt
ausgewählt. Starten Sie den Server auf demselben Port neu, indem Sie im Ausführungsblock
node server.tsdurchnode server.ts PORTersetzen und dabei die Portnummer der vorherigen URL verwenden. Wählen Sie dann Retry upload. Ist dieser Port belegt, schlägt der Start fehl, statt unbemerkt zu einem anderen Ursprung zu wechseln.
Uppy für fortsetzbare Übertragungen wählen
Dieses Beispiel sendet eine gewöhnliche Multipart-Anfrage. Es setzt unterbrochene Übertragungen
nicht fort und zeigt keinen prozentualen Upload-Fortschritt an. Beginnen Sie für einen umfangreicheren
Dateiuploader mit der React-Integration von Uppy
und wählen Sie ein Upload-Plugin. Das Tus-Plugin von Uppy ergänzt
fortsetzbare Übertragungen und benötigt einen kompatiblen tus-Server; der Empfänger
/api/upload aus diesem Beispiel implementiert dieses Protokoll nicht.
