Otimize uploads de arquivos em JS com Web Workers
O processamento de arquivos pode deixar uma interface de upload sem resposta. Web Workers tiram o trabalho de JavaScript da thread principal, deixando-a livre para entrada e renderização. Eles não aumentam a largura de banda da conexão: uploads pela rede já são assíncronos. Compare o custo de inicialização e de troca de mensagens dos workers com o trabalho de CPU que você precisa executar.
Introdução aos Web Workers e seus benefícios
Workers são úteis para tarefas como transformações de imagem ou análise de arquivos. Eles não têm acesso ao DOM da página, então um pequeno protocolo de mensagens conecta o processamento à interface visível. Mantenha um número limitado de workers e libere-os quando uma operação terminar ou for cancelada.
Este exemplo usa TypeScript com um bundler como o Vite. As anotações de tipo são removidas durante o build; os navegadores executam os módulos JavaScript resultantes. Mantemos uma única implementação do worker em vez de manter cópias separadas em JavaScript e TypeScript. O cálculo de SHA-256 ilustra uma etapa de processamento para arquivos pequenos, mas a Web Crypto já é assíncrona por si só e não comprova que mover um upload para um worker o torne mais rápido.
Comece com um projeto Vite vanilla com TypeScript ou instale as ferramentas de build em um projeto existente:
yarn add --dev typescript vite
Configurando um upload de arquivos básico em JavaScript
Adicione este formulário a index.html no seu projeto Vite. Sirva-o via localhost durante o
desenvolvimento ou via HTTPS em produção; não abra exemplos com workers por uma URL file:.
<label for="fileInput">File to upload</label>
<input type="file" id="fileInput" />
<button type="button" id="uploadBtn">Upload</button>
<button type="button" id="cancelBtn" disabled>Cancel</button>
<label for="uploadProgress">Upload progress</label>
<progress id="uploadProgress" value="0" max="100"></progress>
<p id="status" role="status"></p>
<script type="module" src="/src/main.ts"></script>
Os endpoints de servidor deste tutorial são contratos da aplicação, não rotas nativas do Vite. /upload
aceita um file multipart. /upload-chunk aceita chunk, uploadId, chunkIndex, totalChunks
e fileName; /complete-upload aceita JSON com uploadId e totalChunks. Cada um retorna um
status HTTP de sucesso somente depois de aceitar a operação correspondente. Os corpos de resposta não
são usados.
Autentique as requisições e autorize cada ID de upload, imponha limites, armazene os blocos de forma idempotente e verifique a completude antes da finalização. Nunca transforme o nome de arquivo fornecido em um caminho de armazenamento sem validação. Expire uploads abandonados. Para um protocolo retomável mantido, em vez deste contrato didático, use o tus com um servidor compatível.
Integrando Web Workers para processamento de arquivos
Crie src/upload.worker.ts. Cada worker cuida de uma operação. Arquivos pequenos recebem hash antes do
upload; arquivos maiores pulam o hash do arquivo inteiro e usam blocos sequenciais de 5 MiB. Os erros
viram mensagens sanitizadas, e a thread principal encerra o worker após a conclusão ou a falha.
import type { WorkerMessage, WorkerResponse } from './worker-types.ts'
declare const self: DedicatedWorkerGlobalScope
const CHUNK_SIZE = 5 * 1024 * 1024
function send(message: WorkerResponse): void {
self.postMessage(message)
}
function uploadRequest(body: FormData, endpoint: string, onProgress: (ratio: number) => void): Promise<void> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open('POST', endpoint)
xhr.timeout = 60_000
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) onProgress(event.loaded / event.total)
}
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) resolve()
else reject(new Error('Upload request rejected'))
}
xhr.onerror = () => reject(new Error('Upload network error'))
xhr.ontimeout = () => reject(new Error('Upload timed out'))
xhr.onabort = () => reject(new Error('Upload canceled'))
xhr.send(body)
})
}
async function run({ file }: WorkerMessage): Promise<void> {
if (file.size === 0) throw new Error('Empty file')
if (file.size <= CHUNK_SIZE) {
const hash = await crypto.subtle.digest('SHA-256', await file.arrayBuffer())
const sha256 = Array.from(new Uint8Array(hash), (byte) => byte.toString(16).padStart(2, '0')).join('')
send({ type: 'processed', sha256 })
const body = new FormData()
body.append('file', file)
await uploadRequest(body, '/upload', (ratio) => send({ type: 'progress', percent: ratio * 100 }))
} else {
const uploadId = crypto.randomUUID()
const totalChunks = Math.ceil(file.size / CHUNK_SIZE)
for (let index = 0; index < totalChunks; index++) {
const start = index * CHUNK_SIZE
const chunk = file.slice(start, start + CHUNK_SIZE)
const body = new FormData()
body.append('chunk', chunk)
body.append('uploadId', uploadId)
body.append('chunkIndex', String(index))
body.append('totalChunks', String(totalChunks))
body.append('fileName', file.name)
await uploadRequest(body, '/upload-chunk', (ratio) => {
send({ type: 'progress', percent: (start + ratio * chunk.size) / file.size * 100 })
})
}
const response = await fetch('/complete-upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uploadId, totalChunks }),
signal: AbortSignal.timeout(60_000),
})
if (!response.ok) throw new Error('Finalization failed')
}
send({ type: 'complete' })
}
self.onmessage = (event: MessageEvent<WorkerMessage>) => {
run(event.data).catch(() => send({ type: 'error', message: 'Upload failed. Please try again.' }))
}
Suporte a TypeScript para Web Workers
Crie src/worker-types.ts para os dois lados do contrato de mensagens:
export interface WorkerMessage {
file: File
}
export type WorkerResponse =
| { type: 'processed'; sha256: string }
| { type: 'progress'; percent: number }
| { type: 'complete' }
| { type: 'error'; message: string }
Verifique os arquivos da thread principal e do worker separadamente, para que o TypeScript não
combine globais conflitantes do DOM e do worker. Use este tsconfig.json para a página:
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler",
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": [],
"strict": true,
"verbatimModuleSyntax": true,
"allowImportingTsExtensions": true,
"noEmit": true
},
"include": ["src/main.ts", "src/worker-types.ts"]
}
Depois, adicione tsconfig.worker.json:
{
"extends": "./tsconfig.json",
"compilerOptions": { "lib": ["ES2022", "WebWorker"] },
"include": ["src/upload.worker.ts", "src/worker-types.ts"]
}
Execute as duas verificações. O Vite cuida do build do module worker separadamente da verificação de tipos:
yarn tsc --project tsconfig.json
yarn tsc --project tsconfig.worker.json
yarn vite
Lidando com uploads de arquivos grandes de forma eficiente
O ramo com blocos do worker fatia um bloco por vez em vez de materializar o arquivo inteiro na
memória. O worker escolhe esse ramo para arquivos maiores que o seu limite CHUNK_SIZE. O progresso
usa contagem de bytes, então um bloco final curto não tem o mesmo peso de um bloco completo.
Este exemplo não tem lógica de nova tentativa nem de retomada após recarregar a página. Uma requisição com falha interrompe a operação, e uma nova tentativa recebe um novo ID de upload. Não afirme que há retomada só porque um arquivo é dividido em blocos. O cancelamento interrompe a atividade do cliente, mas não desfaz os bytes já aceitos pelo servidor; a expiração no backend ou um endpoint de cancelamento explícito e autorizado precisa limpá-los.
Implementando indicadores de progresso robustos e tratamento de erros
Crie src/main.ts. O File selecionado é capturado uma vez por clique, e não relido após o
processamento. Os controles impedem execuções sobrepostas, enquanto sucesso, falha e cancelamento
liberam o worker.
import type { WorkerMessage, WorkerResponse } from './worker-types.ts'
const fileInput = document.getElementById('fileInput')
const uploadBtn = document.getElementById('uploadBtn')
const cancelBtn = document.getElementById('cancelBtn')
const uploadProgress = document.getElementById('uploadProgress')
const statusElement = document.getElementById('status')
if (!(fileInput instanceof HTMLInputElement) || !(uploadBtn instanceof HTMLButtonElement)
|| !(cancelBtn instanceof HTMLButtonElement) || !(uploadProgress instanceof HTMLProgressElement)
|| !(statusElement instanceof HTMLElement)) {
throw new Error('Missing upload controls')
}
let worker: Worker | null = null
const finish = (message: string): void => {
worker?.terminate()
worker = null
uploadBtn.disabled = false
fileInput.disabled = false
cancelBtn.disabled = true
statusElement.textContent = message
}
uploadBtn.addEventListener('click', () => {
const file = fileInput.files?.[0]
if (!file || file.size === 0) {
statusElement.textContent = 'Please select a nonempty file.'
return
}
if (worker) return
uploadProgress.value = 0
uploadBtn.disabled = true
fileInput.disabled = true
cancelBtn.disabled = false
statusElement.textContent = 'Preparing upload.'
try {
worker = new Worker(new URL('./upload.worker.ts', import.meta.url), { type: 'module' })
worker.onmessage = (event: MessageEvent<WorkerResponse>) => {
const response = event.data
switch (response.type) {
case 'processed':
statusElement.textContent = 'File processed. Uploading.'
break
case 'progress':
uploadProgress.value = response.percent
statusElement.textContent = `Uploading: ${Math.round(response.percent)}%`
break
case 'complete':
uploadProgress.value = 100
finish('Upload complete.')
break
case 'error':
finish(response.message)
break
}
}
worker.onerror = (event) => {
event.preventDefault()
finish('The upload worker failed. Please try again.')
}
worker.onmessageerror = () => finish('Could not read the upload worker response.')
worker.postMessage({ file } satisfies WorkerMessage)
} catch {
finish('Could not start the upload worker.')
}
})
cancelBtn.addEventListener('click', () => finish('Upload canceled.'))
window.addEventListener('pagehide', () => finish('Upload stopped.'))
Teste com um arquivo pequeno, um arquivo maior que um bloco, uma finalização com falha, um cancelamento e um segundo upload. Uma mensagem de sucesso do worker significa que o servidor aceitou o contrato de upload, não que o arquivo passou pela verificação de antivírus ou por outro processamento. Mantenha esses estados distintos em uma interface de produção.
Para um uploader mantido, com progresso e transferências retomáveis, o Uppy pode cuidar do fluxo de upload enquanto você reserva os workers para o processamento que realmente se beneficia deles.
