Crear un cargador de archivos a medida con JavaScript y HTML
Subir archivos es una función crítica en muchas aplicaciones web. Sin embargo, el elemento nativo de entrada de archivos que ofrecen los navegadores suele carecer de la estética y la funcionalidad avanzada que esperan los usuarios modernos. En este DevTip te mostramos cómo crear un cargador de archivos a medida con JavaScript y HTML para conseguir una experiencia elegante, robusta y fácil de usar.

Entender los cargadores de archivos
Un cargador de archivos es un componente que permite a los usuarios seleccionar y subir archivos a un servidor. Aunque la entrada de archivos predeterminada de HTML es funcional, ofrece poco en cuanto a personalización o experiencia de usuario enriquecida. Crear un cargador de archivos a medida te permite:
- Ofrecer un aspecto coherente y alineado con el diseño de tu aplicación.
- Mejorar la usabilidad con funciones como el soporte para arrastrar y soltar.
- Ofrecer retroalimentación en tiempo real con indicadores de progreso y mensajes de error.
Configurar la estructura HTML
Empieza creando la estructura HTML básica de tu cargador de archivos a medida. El siguiente ejemplo muestra una zona para soltar archivos en la que también se puede hacer clic para seleccionarlos. Tu servidor debe reemplazar el marcador de posición del token CSRF por el token de la sesión actual antes de enviar este HTML al navegador:
<!-- index.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="csrf-token" content="REPLACE_WITH_SERVER_RENDERED_TOKEN" />
<title>Custom File Uploader</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<div class="file-uploader">
<p>Drag and drop files here or click to upload</p>
<input type="file" id="file-input" multiple />
<div class="upload-progress"></div>
</div>
<script src="script.js"></script>
</body>
</html>
En esta configuración:
- El div
.file-uploaderfunciona a la vez como zona para soltar archivos y como área en la que se puede hacer clic. - El elemento
inputoculto facilita la selección de archivos. - El div
.upload-progressmuestra el progreso de subida de cada archivo.
Dar estilo al cargador de archivos con CSS
Mejora el atractivo visual y la retroalimentación interactiva con el siguiente CSS:
/* styles.css */
.file-uploader {
border: 2px dashed #ccc;
padding: 20px;
text-align: center;
cursor: pointer;
position: relative;
border-radius: 8px;
transition: background-color 0.3s ease;
}
.file-uploader.dragover {
background-color: #f0f0f0;
border-color: #007bff;
}
.file-uploader input[type='file'] {
display: none;
}
.upload-progress {
margin-top: 20px;
}
.progress-bar {
background-color: #007bff;
height: 24px;
margin-bottom: 8px;
color: #fff;
line-height: 24px;
padding: 0 12px;
width: 0%;
transition: width 0.3s ease;
border-radius: 4px;
font-size: 14px;
}
.progress-bar.upload-complete {
background-color: #28a745;
}
.progress-bar.upload-error {
background-color: #dc3545;
}
Implementar JavaScript para gestionar la selección y la subida de archivos
El JavaScript que aparece a continuación envía fragmentos secuenciales con un presupuesto de
reintentos limitado. Requiere un servidor que implemente /upload/chunk y
/upload/finalize; esos endpoints no se incluyen aquí. Renderiza un token CSRF de sesión
en un elemento meta llamado csrf-token y verifícalo en
ambos endpoints. Sirve la página por HTTPS, o usa localhost durante el desarrollo, para que
crypto.randomUUID() esté disponible.
Cada subida tiene un uploadId propio. El servidor debe vincular ese ID al
usuario autenticado, validar los índices de los fragmentos, su cantidad, el tamaño total y el
contenido, y almacenar los datos en rutas controladas por el servidor. Trata
fileName como metadatos de visualización, nunca como una ruta de
almacenamiento. Reintentar un fragmento debe reemplazar o confirmar el mismo
(uploadId, chunkIndex) sin duplicar bytes. La finalización debe verificar todos los
fragmentos y ser idempotente, de modo que reintentarla tras una respuesta perdida no pueda crear
archivos duplicados. Haz que las subidas abandonadas expiren en el servidor.
Este ejemplo solo reintenta dentro de la página actual. No implementa la cancelación ni la recuperación después de recargar. Para esas funciones, usa un cliente y servidor del protocolo tus que conserven las identidades de las subidas y negocien el offset aceptado.
class FileUploader {
constructor() {
this.fileUploader = document.querySelector('.file-uploader')
this.fileInput = document.getElementById('file-input')
this.progressContainer = document.querySelector('.upload-progress')
this.maxFileSize = 10 * 1024 * 1024 // 10MB
this.allowedTypes = ['image/jpeg', 'image/png', 'application/pdf']
this.csrfToken = document.querySelector('meta[name="csrf-token"]')?.content
if (!this.csrfToken) {
throw new Error('The server must provide a CSRF token before uploads can start.')
}
this.retryAttempts = 3
this.chunkSize = 1024 * 1024 // 1MB chunks
this.initializeEventListeners()
}
initializeEventListeners() {
this.fileUploader.addEventListener('click', (event) => {
if (event.target !== this.fileInput) this.fileInput.click()
})
this.fileInput.addEventListener('change', (event) => this.handleFiles(event.target.files))
this.fileUploader.addEventListener('dragover', this.handleDragOver.bind(this))
this.fileUploader.addEventListener('dragleave', this.handleDragLeave.bind(this))
this.fileUploader.addEventListener('drop', this.handleDrop.bind(this))
}
handleDragOver(event) {
event.preventDefault()
this.fileUploader.classList.add('dragover')
}
handleDragLeave() {
this.fileUploader.classList.remove('dragover')
}
handleDrop(event) {
event.preventDefault()
this.fileUploader.classList.remove('dragover')
this.handleFiles(event.dataTransfer.files)
}
async handleFiles(files) {
for (const file of files) {
if (this.validateFile(file)) {
await this.uploadFileWithChunks(file)
}
}
}
validateFile(file) {
if (!this.allowedTypes.includes(file.type)) {
this.showError(`${file.name} is not an allowed file type.`)
return false
}
if (file.size === 0 || file.size > this.maxFileSize) {
this.showError(`${file.name} must be nonempty and no larger than 10MB.`)
return false
}
return true
}
async uploadFileWithChunks(file) {
const uploadId = crypto.randomUUID()
const progressBar = this.createProgressBar(file.name)
const chunks = Math.ceil(file.size / this.chunkSize)
let uploadedChunks = 0
for (let i = 0; i < chunks; i++) {
const start = i * this.chunkSize
const end = Math.min(start + this.chunkSize, file.size)
const chunk = file.slice(start, end)
try {
await this.uploadChunk(chunk, i, chunks, file, uploadId)
uploadedChunks++
const progress = (uploadedChunks / chunks) * 100
this.updateProgress(progressBar, progress)
} catch (error) {
if (await this.handleUploadError(error, progressBar)) {
i-- // Retry the current chunk
} else {
break
}
}
}
if (uploadedChunks === chunks) {
await this.finalizeUpload(file.name, uploadId, progressBar)
}
}
async uploadChunk(chunk, chunkIndex, totalChunks, file, uploadId) {
const formData = new FormData()
formData.append('chunk', chunk)
formData.append('chunkIndex', chunkIndex)
formData.append('totalChunks', totalChunks)
formData.append('fileName', file.name)
formData.append('fileSize', file.size)
formData.append('uploadId', uploadId)
const response = await fetch('/upload/chunk', {
method: 'POST',
headers: {
'X-CSRF-Token': this.csrfToken,
},
body: formData,
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
}
async finalizeUpload(fileName, uploadId, progressBar) {
while (true) {
try {
const response = await fetch('/upload/finalize', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': this.csrfToken,
},
body: JSON.stringify({ uploadId }),
})
if (!response.ok) throw new Error('Failed to finalize upload')
progressBar.classList.add('upload-complete')
progressBar.textContent = `${fileName} uploaded successfully`
return
} catch (error) {
if (!(await this.handleUploadError(error, progressBar))) return
}
}
}
createProgressBar(fileName) {
const progressBar = document.createElement('div')
progressBar.classList.add('progress-bar')
progressBar.dataset.fileName = fileName
progressBar.textContent = `Uploading ${fileName}`
this.progressContainer.appendChild(progressBar)
return progressBar
}
updateProgress(progressBar, percent) {
progressBar.style.width = `${percent}%`
progressBar.textContent = `${progressBar.dataset.fileName} - ${percent.toFixed(1)}%`
}
async handleUploadError(error, progressBar) {
console.error('Upload error:', error)
progressBar.classList.add('upload-error')
if (progressBar.dataset.retryCount === undefined) {
progressBar.dataset.retryCount = '0'
}
const retryCount = parseInt(progressBar.dataset.retryCount)
if (retryCount < this.retryAttempts) {
progressBar.dataset.retryCount = (retryCount + 1).toString()
progressBar.textContent = `Retrying… (${retryCount + 1}/${this.retryAttempts})`
await new Promise((resolve) => setTimeout(resolve, 1000))
progressBar.classList.remove('upload-error')
return true
}
progressBar.textContent = 'Upload failed after multiple attempts'
return false
}
showError(message) {
const errorBar = document.createElement('div')
errorBar.classList.add('progress-bar', 'upload-error')
errorBar.textContent = message
this.progressContainer.appendChild(errorBar)
setTimeout(() => errorBar.remove(), 5000)
}
}
// Initialize the uploader when the DOM is ready
document.addEventListener('DOMContentLoaded', () => new FileUploader())
Probar el cargador de archivos
Para garantizar un funcionamiento fiable:
- Prueba con distintos tipos y tamaños de archivo.
- Verifica la precisión de la funcionalidad de subida por fragmentos.
- Simula errores de red para validar la lógica de reintentos.
- Confirma el manejo correcto del token CSRF.
- Prueba la funcionalidad de arrastrar y soltar en distintos navegadores.
- Comprueba que los indicadores de progreso se actualicen con precisión.
- Asegúrate de que sea compatible con navegadores modernos como Chrome, Firefox, Edge y Safari.
Conclusión
Este cliente ilustra las subidas por fragmentos, los reintentos y el progreso después de cada fragmento aceptado. El presupuesto de tres reintentos se comparte entre los fallos de fragmento y los de finalización de cada archivo. Un despliegue funcional también necesita el contrato de servidor anterior, validación de contenido, autorización y comprobaciones de CSRF.
Para una solución lista para producción con funciones adicionales como las subidas reanudables y la integración con almacenamiento en la nube, considera usar Uppy 4.x (consulta la Guía de migración), un potente cargador de archivos de código abierto mantenido por Transloadit.
