O que um minificador pode remover e o que não pode tocar
Publicado a 19/05/2025 · 18 min de leitura · Ferramentas para programadores
Daniel Okonkwo — Programador front-end e redator de Tecnologia na OneKitly
Desempenho web · Formatos de ficheiro
Verificado a partir de 6 fontes
Um minificador só pode fazer alterações que preservem a semântica, e os casos difíceis são todos espaços que não são decoração. Em CSS, o espaço de «div p» é um combinador descendente: retire-o e o seletor passa a corresponder a algo completamente diferente. Os espaços à volta de >, + e ~ podem sair — o esbuild converteu «.card > footer» em «.card>footer» deixando «.card .card-title» intacto. Dentro de calc(), os espaços à volta de + e − são obrigatórios: o Chrome reporta CSS.supports para calc(100% - 2px) como true e para calc(100%-2px) como false, e atribuir o segundo deixa a propriedade vazia. Os espaços à volta de * e / são opcionais. Em HTML, o espaço entre elementos em linha é conteúdo renderizado — dois spans mediram 36,92 px com um espaço entre eles e 27,28 px sem, um desvio de 9,64 px — e é plenamente significativo dentro de pre e textarea. Em JavaScript, a inserção automática de ponto e vírgula faz com que juntar linhas altere o comportamento: uma função cujo return está sozinho na sua linha devolve undefined até a juntar, altura em que devolve o objeto. E a renomeação para na fronteira das cadeias: o mangling de propriedades transformou uma leitura que funcionava em undefined. Quanto ao ganho, nos recursos desta página o brotli sozinho poupou 66,2 % e minificar antes acrescentou apenas 18,1 %.
A minificação tem de preservar o significado, e o interessante são os espaços que carregam significado: o combinador descendente, os espaços dentro de calc(), o intervalo entre dois elementos em linha. Medido aqui em ficheiros reais, incluindo o que o brotli já lhe ia poupar.
A única regra: a saída tem de se comportar de forma idêntica
A minificação é um passo de compilação com um só contrato: a saída tem de ser observacionalmente idêntica à entrada. Não parecida, não suficientemente próxima — idêntica em todo o comportamento de que uma página possa depender. Tudo o que um minificador faz decorre daí, e todo o erro de minificação é um sítio onde alguém assumiu que um byte era decoração quando a especificação diz que é dado.
Essa distinção explica porque os minificadores por expressões regulares são perigosos e os baseados em analisador não são. Uma ferramenta que retira espaços por padrão não sabe se um dado espaço separa dois tokens por legibilidade ou junta dois tokens num significado composto. Uma ferramenta que tokeniza a entrada segundo o módulo CSS Syntax ou a gramática ECMAScript, constrói uma árvore e a reemite não consegue cometer o erro: quando imprime seja o que for, o significado já está fixado na árvore.
Tudo o que se segue foi executado e não recordado. As amostras foram minificadas com o esbuild, os tamanhos medidos com o zlib do node em gzip nível 9 e brotli qualidade 11, e as afirmações de CSS e de layout verificadas no Chrome sem interface.
CSS: o espaço que é um combinador
Num seletor, o espaço entre dois seletores compostos é o combinador descendente. «div p» seleciona todo o p dentro de um div; «divp» seleciona um tipo de elemento inexistente, e «.card .card-title» seleciona um .card-title dentro de um .card enquanto «.card.card-title» seleciona um elemento que traz ambas as classes. O espaço é um token, não formatação, e nenhum minificador correto o retira.
Os outros três combinadores são pontuação e os espaços à sua volta são gratuitos. Dando ao esbuild as quatro formas «div p», «div>p», «div + p» e «div ~ p», devolveu exatamente «div p», «div>p», «div+p» e «div~p». O espaço descendente sobreviveu; os que rodeiam >, + e ~ não, porque esses carateres são inequívocos por si sós. Na folha de exemplo aconteceu o mesmo com regras reais: «.card .card-title» passou intacto enquanto «.card > footer», «.card + .card» e «.card ~ .aside-note» se apertaram.
O caso das regras-at é mais subtil e merece atenção. A consulta de media «@media (min-width: 600px) and (max-width: 900px)» saiu como «@media(min-width:600px)and (max-width:900px)». O espaço antes de «and» desapareceu, porque um parêntese de fecho já termina o token anterior. O espaço depois de «and» ficou, porque «and(» seria tokenizado como token de função em vez de identificador seguido de parêntese. Toda a disciplina cabe numa linha: um espaço é removível exatamente quando os dois tokens de cada lado não podem fundir-se num token diferente.
CSS: os espaços dentro dos valores
O calc() é o exemplo mais nítido, porque a exigência é assimétrica. A especificação CSS Values impõe espaços de ambos os lados de + e −, pois sem eles um token como «-2px» é lido como uma única dimensão negativa e a expressão perde o seu operador. O Chrome concorda com precisão: CSS.supports para width e calc(100% - 2px) devolve true, enquanto calc(100%-2px), calc(100% -2px) e calc(100%- 2px) devolvem todos false. Atribuir style.width = «calc(100%-2px)» deixa a propriedade como cadeia vazia, porque a declaração inteira é descartada como inválida.
Os operadores de multiplicação e divisão não têm esse problema, e o navegador confirma-o: calc(100%*2) e calc(100%/2) devolvem ambos true. Um minificador que entendesse a gramática poderia, portanto, apertar esses dois e não os outros. Na prática o esbuild é conservador e manteve cada espaço de «calc(100% - 2 * var(--gap))» — correto para o menos, e uma pequena oportunidade perdida para o asterisco.
Outras duas categorias de espaço intocável surgiram na mesma execução. Os valores de cadeia são literais: a declaração content: « new » manteve ambos os pares de espaços interiores, porque esses carateres são inseridos no documento. E as propriedades personalizadas são fluxos de tokens e não valores analisados, pelo que o esbuild deixou «--card-bg: #ffffff» com o espaço a seguir aos dois-pontos enquanto retirava o espaço idêntico de «color: var(--card-fg)». O resto da amostra mostra o que um minificador ganha quando percebe a gramática dos valores: rgba(0, 0, 0, 0.08) passou a #00000014, #0000ff a #00f, 150ms a .15s, opacity 0.7 a .7, margin: 0 0 8px 0 a margin:0 0 8px, e ::after a :after.
HTML: o espaço entre elementos em linha é conteúdo
O processamento de brancos do CSS reduz uma série de espaços em fluxo normal a um único espaço — mas um espaço, não nada. Entre dois elementos de nível linha esse espaço é renderizado e ocupa largura, pelo que apagá-lo desloca o layout. Medido no Chrome sem interface a monospace de 16 px, dois spans adjacentes separados por uma quebra de linha na fonte terminavam em x = 36,92, enquanto os mesmos dois spans escritos sem branco entre eles terminavam em x = 27,28. A diferença de 9,64 px é exatamente um carácter de espaço, e é a diferença entre uma fila de ligações que lê «one two three» e outra que lê «onetwothree».
É por isso que os minificadores HTML agressivos são configuráveis e os seus valores por omissão costumam ser cautelosos. Reduzir cinco espaços e duas quebras de linha a um espaço é sempre seguro em fluxo normal. Apagar o último espaço restante entre duas caixas em linha não é, e um minificador que o faça como regra geral irá refluir em silêncio menus, trilhos de navegação, listas de etiquetas e ícones em linha. Qualquer ferramenta que ofereça remover os brancos entre etiquetas está a oferecer mudar o seu layout a troco de bytes.
Dois elementos estão absolutamente vedados: pre e textarea. Ambos valem por omissão white-space: pre, pelo que cada espaço, tabulação e quebra de linha lá dentro é preservado e renderizado. A página de exemplo contém um bloco de código indentado e um textarea com espaços iniciais significativos, e o colapsador de brancos usado para a medição teve de receber uma exceção explícita para ambos. Qualquer minificador sem essa exceção destrói em silêncio exemplos de código e campos de formulário pré-preenchidos. O mesmo cuidado vale dentro dos elementos script e style, e para a quebra de linha inicial logo após uma etiqueta pre de abertura, que o analisador HTML descarta por especificação — subtileza que deixa as ferramentas caseiras erradas nos dois sentidos.
JavaScript: inserção automática de ponto e vírgula e renomeação
O ECMAScript insere pontos e vírgulas em certas quebras de linha, o que torna uma quebra de linha portadora de sentido. O caso canónico é um return sozinho na sua linha. Executar function f(){ return \n { ok: true } } devolveu undefined, porque é inserido um ponto e vírgula logo depois de return. Escrever o mesmo código numa linha devolveu { ok: true }. Um minificador ingénuo que junte linhas altera, portanto, o valor que a função produz. O inverso também morde: o excerto let x = 1 \n ++x avalia x como 2, enquanto juntar essas duas linhas lança SyntaxError: Invalid left-hand side expression in postfix operation.
Um minificador baseado em analisador não consegue cometer nenhum dos dois erros, porque quando imprime o ponto e vírgula já está decidido. Dando a mesma função com o return isolado ao esbuild obtém-se function t(){}export const r=void 0; — manteve a semântica, viu que a função só podia devolver undefined e dobrou a chamada para void 0. É essa a diferença entre uma transformação de texto e um compilador.
O outro perigo do JavaScript é a renomeação, e a sua fronteira é exata: um minificador pode renomear tudo aquilo cujas referências vê todas, e mais nada. As variáveis locais e os parâmetros de função qualificam-se, e é daí que vem a maior parte da poupança. Os nomes de propriedades de objeto não, porque uma propriedade pode ser alcançada por uma cadeia que o minificador não consegue seguir. Demonstração: um módulo que devolvia [config.userName, o[«userName»], o[«retryCount»]] deu [«ada», «ada», 3] com minificação simples, e [«ada», undefined, undefined] assim que o mangling de propriedades foi ligado. O acesso por ponto foi renomeado com a definição; as duas leituras por cadeia continuavam a pedir os nomes antigos e não encontraram nada. A mesma armadilha apanha tudo o que é alcançado por nome em tempo de execução — acesso por parênteses retos construído a partir de uma variável, idas e voltas por JSON, ligações de framework e eval direto.
Medido: quanto vale a minificação depois da compressão
A minificação e a compressão removem redundância sobreposta, pelo que a segunda a correr parece sempre menos impressionante. Nas amostras escritas à mão: o CSS passou de 1 434 para 1 066 bytes, menos 25,7 %, mas depois do brotli o par era 552 contra 459 — apenas 16,8 %. O JavaScript passou de 1 518 para 747 bytes, menos 50,8 %, mas 552 contra 395 depois do brotli, 28,4 %. O HTML passou de 1 033 para 782 bytes, 24,3 %, e 322 contra 302 depois do brotli — 6,2 %, ou vinte bytes.
Juntar os três numa carga de página põe o número honesto na mesa. Os recursos em bruto somam 3 985 bytes e o brotli leva-os a 1 346 — 66,2 % de poupança só pela compressão, sem passo de build. Minificar primeiro e comprimir depois dá 1 102 bytes. Portanto a contribuição marginal da minificação, por cima de uma camada de compressão que já tem, é de 244 bytes: 18,1 %. Real e digna de se aproveitar, mas uma ordem de grandeza abaixo do que o número em bytes brutos sugere.
Quatro ficheiros reais deste repositório mostram quanto a resposta depende do que está lá dentro. O globals.css encolheu 68,2 % em bruto e 70,2 % depois do brotli — espetacular, e explicado por inteiro pelo facto de 2 084 dos seus 3 393 bytes serem comentários, com os treze blocos de regras intactos dos dois lados. O app-shell.module.css, que é sobretudo declarações reais, deu 4,4 % em bruto e 5,8 % depois do brotli. Dois módulos TypeScript carregados de conteúdo deram 10,8 % e 6,5 % em bruto, mas apenas 3,4 % e 2,3 % depois do brotli, porque um ficheiro feito sobretudo de cadeias literais quase não tem nada que um minificador possa tocar. Regra prática: a minificação paga na proporção do que no seu ficheiro são comentários, indentação e identificadores locais longos, e não paga nada sobre dados.
Uma ordem de operações que funciona
Ligue primeiro a compressão, porque é a maior vitória isolada, não precisa de passo de build e não pode partir nada. Nestas amostras o brotli sozinho retirou 66,2 % dos bytes. Depois minifique com uma ferramenta baseada em analisador para cada linguagem, nas suas definições por omissão. A seguir, só se tiver uma razão medida, vá às opções agressivas — mangling de propriedades, remoção de brancos entre etiquetas — e trate cada uma como uma alteração que precisa de testes, porque cada uma é um sítio onde o contrato de preservação semântica foi deliberadamente afrouxado.
Dois hábitos valem mais do que qualquer definição de minificador. Retire comentários e código morto na origem em vez de confiar que o minificador dê por eles — o globals.css era 61 % comentários, e esse único facto explica toda a sua redução de 68 %. E verifique a saída, não a promessa: passe o pacote minificado pela sua bateria de testes e compare os tamanhos comprimidos dos dois lados em vez dos brutos, porque o número bruto é o que lisonjeia e o comprimido é o que os seus utilizadores realmente descarregam.
| Antes | Depois | Seguro? | Porquê |
|---|---|---|---|
| .card > footer | .card>footer | Sim | O > é inequívoco por si só; os espaços não carregam nada |
| .card .card-title | .card .card-title (inalterado) | Não pode mudar | O espaço é o combinador descendente; retirá-lo seleciona um elemento com ambas as classes |
| calc(100% - 2px) | calc(100% - 2px) (inalterado) | Não pode mudar | O Chrome reporta calc(100%-2px) como não suportado e descarta a declaração |
| content: « new » | content:« new » (espaços mantidos) | Não pode mudar | O conteúdo das cadeias é inserido tal e qual no documento |
| rgba(0, 0, 0, 0.08) | #00000014 | Sim | O hexadecimal de oito dígitos representa exatamente a mesma cor |
| margin: 0 0 8px 0 | margin:0 0 8px | Sim | A abreviatura espelha o segundo valor quando o quarto é omitido |
| return sozinho na sua linha | quebra de linha retirada por uma ferramenta de texto | Não | A função devolvia undefined antes e o objeto depois |
| o.userName e o[«userName»] | o mangling de propriedades renomeia só o primeiro | Não | O resultado medido passou de [ada, ada, 3] para [ada, undefined, undefined] |
Perguntas frequentes
- Se o meu servidor já envia gzip ou brotli, ainda é preciso minificar?
- Sim, mas espere um ganho muito menor do que os números em bruto sugerem, e ponha primeiro a compressão a funcionar. Medido na página de exemplo, o brotli sozinho levou 3 985 bytes de recursos em bruto a 1 346 — 66,2 % poupados sem qualquer passo de build. Minificar antes de comprimir chegou a 1 102 bytes, pelo que a contribuição marginal da minificação foi de 244 bytes, ou 18,1 % por cima. Vale a pena, e não custa nada por pedido depois de o seu build o fazer. Os dois sobrepõem-se porque atacam a mesma redundância: identificadores longos repetidos, sequências de indentação e texto de comentários são exatamente aquilo que um compressor de dicionário melhor elimina. Onde a minificação ganha de forma clara é naquilo que a compressão não pode fazer, por ser semântico e não textual — eliminação de código morto, dobragem de constantes, remoção de ramos inalcançáveis, encurtamento da sintaxe de cores e unidades. A ordem que importa: compressão ligada, depois minificar, depois medir os tamanhos comprimidos e não os brutos.
- O meu layout deslocou-se depois de ligar a minificação HTML. Porquê?
- Quase de certeza porque o minificador retirou brancos entre elementos de nível linha, que são conteúdo renderizado e não formatação. O processamento de brancos do CSS reduz uma série de espaços e quebras de linha em fluxo normal a um espaço — um, não zero — e esse espaço sobrevivente ocupa largura entre duas caixas em linha. Medido no Chrome sem interface a monospace de 16 px, dois spans com uma quebra de linha entre eles na fonte terminavam em x = 36,92, e o mesmo par sem qualquer branco em x = 27,28: 9,64 px de diferença, exatamente um espaço. Numa barra de navegação, numa lista de etiquetas ou numa fila de ligações em linha, essa diferença vê-se de imediato e parece um erro. Procure uma opção do género collapseWhitespace com um modo agressivo ou conservador, e prefira o conservador. Se não quiser intervalo entre dois elementos em linha, retire-o em CSS com um contentor flex ou grid, ou com font-size no pai, para que a marcação continue independente do layout.
- Alguma vez é seguro renomear propriedades de objeto?
- Só quando puder garantir que todo o acesso à propriedade é visível para o minificador, o que na prática significa adotar uma convenção de nomes e informar a ferramenta. O padrão comum é mangling apenas das propriedades que correspondem a um padrão, como um sublinhado final, para que os campos internos sejam renomeados e tudo o que é público fique intacto. O que o parte é qualquer acesso por cadeia. Demonstrado aqui: um módulo que devolvia [config.userName, o[«userName»], o[«retryCount»]] deu [«ada», «ada», 3] com minificação normal e [«ada», undefined, undefined] assim que o mangling de propriedades foi ativado — o acesso por ponto acompanhou a definição, as duas leituras por cadeia não. O mesmo modo de falha cobre o acesso por parênteses retos construído a partir de uma variável, as chaves vindas de JSON, os modelos de framework que ligam por nome e tudo o que seja percorrido com Object.keys. Como a quebra é silenciosa e só aparece no caminho de código que usa a cadeia, trate o mangling de propriedades como uma otimização que exige uma bateria completa de testes, e não como uma caixa a assinalar.
- Posso escrever um minificador com expressões regulares?
- Pode escrever algo que normalmente funciona, o que é o pior resultado possível, porque as falhas são raras e silenciosas. Um padrão que retira sequências de brancos não distingue um combinador descendente de uma indentação, não sabe que o espaço antes de um menos dentro de calc() é gramaticalmente obrigatório, não vê que uma quebra de linha antes de uma chaveta de fecho é o que faz um return devolver undefined, e não sabe que os carateres entre aspas são conteúdo. Cada uma dessas distinções exige tokenizar a entrada segundo a gramática real. O colapsador de brancos usado para a medição HTML deste artigo é deliberadamente ingénuo, e foi preciso talhar-lhe uma exceção explícita para pre e textarea antes de sequer produzir saída correta — e continuaria a não ser seguro numa página com scripts em linha que contenham sinais de menor dentro de cadeias. A resposta prática: use uma ferramenta baseada em analisador por linguagem e gaste o seu esforço na entrada — menos comentários enviados, nada de código morto, nomes locais mais curtos onde não prejudiquem a legibilidade.
- Porque é que minificar o meu ficheiro cheio de dados quase não ajudou?
- Porque um minificador só pode tocar na sintaxe, e um ficheiro de dados é quase todo conteúdo. As cadeias literais têm de sobreviver byte a byte, as chaves de objeto usadas em tempo de execução não podem ser renomeadas, e os números já são o mais curtos que vão ser. As medições deste artigo mostram o padrão com clareza: dois módulos TypeScript cheios de conteúdo deste repositório encolheram 10,8 % e 6,5 % em bruto, mas apenas 3,4 % e 2,3 % depois do brotli, porque o pouco que a minificação retirou era indentação e pontuação que o compressor ia espremer de qualquer forma. Compare com o globals.css, que encolheu 68,2 % — inteiramente porque 2 084 dos seus 3 393 bytes eram comentários. Se um ficheiro de dados é mesmo grande, a alavanca não é a minificação mas o formato e a entrega: ponha os dados atrás de uma API para que as páginas tragam só o que mostram, divida-os para que uma rota carregue a sua própria fatia, ou tire-os do pacote JavaScript para JSON que o navegador analisa mais depressa e guarda em cache à parte.
Artigos que podem interessar-lhe
Todos os guias →Ferramentas relacionadas
Fontes
- W3C — CSS Syntax Module Level 3 — tokenization rules that decide which whitespace is removable
- W3C — CSS Values and Units Module Level 3 — the calc() grammar requiring whitespace around + and −
- W3C — CSS Text Module Level 3 — white space processing and the white-space property
- W3C — Selectors Level 4 — the descendant combinator is whitespace
- WHATWG — HTML Standard — parsing, the pre element's leading newline, and raw text elements
- Ecma International / TC39 — ECMAScript Language Specification — Automatic Semicolon Insertion
Detetaste um erro neste artigo?