Blog com transmissão ao vivo sobrecarregada: divisão de código

Na nossa transmissão ao vivo Supercharged mais recente, implementamos a divisão de código e o agrupamento baseado em rotas. Com o HTTP/2 e os módulos ES6 nativos, essas técnicas vão se tornar essenciais para permitir o carregamento e o armazenamento em cache eficientes de recursos de script.

Dicas e truques diversos neste episódio

  • asyncFunction().catch() com error.stack: 9h55
  • Módulos e atributo nomodule em tags <script>: 7:30
  • promisify() no nó 8: 17:20

Texto longo, leia o resumo

Como fazer a divisão de código usando o agrupamento baseado em rota:

  1. Acesse uma lista dos seus pontos de entrada.
  2. Extraia as dependências de módulo de todos esses pontos de entrada.
  3. Encontre dependências compartilhadas entre todos os pontos de entrada.
  4. Agrupe as dependências compartilhadas.
  5. Reescreva os pontos de entrada.

Divisão de código x fragmentação baseada em rotas

A divisão de código e o agrupamento de rotas estão intimamente relacionados e são usados de forma intercambiável. Isso causou alguma confusão. Vamos esclarecer isso:

  • Divisão do código: é o processo de dividir o código em vários pacotes. Se você não enviar um grande pacote com todo o JavaScript para o cliente, estará fazendo a divisão do código. Uma maneira específica de dividir seu código é usar o agrupamento baseado em rotas.
  • Fragmentação baseada em rota: cria pacotes relacionados às rotas do app. Ao analisar suas rotas e as dependências delas, podemos mudar quais módulos vão para qual pacote.

Por que fazer a divisão do código?

Módulos soltos

Com os módulos ES6 nativos, cada módulo JavaScript pode importar as próprias dependências. Quando o navegador recebe um módulo, todas as instruções import acionam outras transferências para acessar os módulos necessários para executar o código. No entanto, todos esses módulos podem ter dependências próprios. O perigo é que o navegador acabe com uma cascata de buscas que dura várias viagens de ida e volta antes que o código possa ser finalmente executado.

Agrupamento

O agrupamento, que é a inserção de todos os módulos em um único pacote, garante que o navegador tenha todo o código necessário após uma ida e volta e possa começar a executar o código mais rapidamente. Isso, no entanto, força o usuário a fazer o download de muito código que não é necessário, então a largura de banda e o tempo foram desperdiçados. Além disso, cada mudança em um dos nossos módulos originais vai resultar em uma mudança no pacote, invalidando qualquer versão em cache do pacote. Os usuários precisarão fazer o download de tudo de novo.

Divisão de código

O particionamento do código é o meio-termo. Estamos dispostos a investir em mais viagens de ida e volta para conseguir eficiência de rede, fazendo o download apenas do que precisamos, e melhorando a eficiência do armazenamento em cache, reduzindo muito o número de módulos por pacote. Se o agrupamento for feito corretamente, o número total de viagens de ida e volta será muito menor do que com módulos soltos. Por fim, podemos usar mecanismos de pré-carregamento, como link[rel=preload], para economizar mais tempo no round trip, se necessário.

Etapa 1: acessar uma lista dos pontos de entrada

Essa é apenas uma das muitas abordagens, mas no episódio analisamos o sitemap.xml do site para encontrar os pontos de entrada. Normalmente, é usado um arquivo JSON dedicado que lista todos os pontos de entrada.

Como usar o Babel para processar JavaScript

O Babel é comumente usado para "transpilação": consumir código JavaScript de ponta e transformá-lo em uma versão mais antiga de JavaScript para que mais navegadores possam executar o código. A primeira etapa é analisar o novo JavaScript com um analisador (o Babel usa o babylon) que transforma o código em uma chamada "árvore de sintaxe abstrata" (AST, na sigla em inglês). Depois que o AST é gerado, uma série de plug-ins analisa e modifica o AST.

Vamos usar bastante o Babel para detectar (e manipular mais tarde) as importações de um módulo JavaScript. Você pode querer recorrer a expressões regulares, mas elas não são poderosas o suficiente para analisar corretamente uma linguagem e são difíceis de manter. Confiar em ferramentas testadas e comprovadas, como o Babel, vai evitar muitas dores de cabeça.

Confira um exemplo simples de execução do Babel com um plug-in personalizado:

const plugin = {
  visitor: {
    ImportDeclaration(decl) {
      /* ... */
    }
  }
}
const {code} = babel.transform(inputCode, {plugins: [plugin]});

Um plug-in pode fornecer um objeto visitor. O visitante contém uma função para qualquer tipo de nó que o plug-in queira processar. Quando um nó desse tipo é encontrado durante a travessia do AST, a função correspondente no objeto visitor é invocada com esse nó como parâmetro. No exemplo acima, o método ImportDeclaration() será chamado para cada declaração import no arquivo. Para ter uma ideia dos tipos de nó e da AST, acesse astexplorer.net.

Etapa 2: extrair as dependências do módulo

Para criar a árvore de dependências de um módulo, vamos analisar esse módulo e criar uma lista de todos os módulos importados. Também precisamos analisar essas dependências, já que elas também podem ter dependências. Um caso clássico de recursão.

async function buildDependencyTree(file) {
  let code = await readFile(file);
  code = code.toString('utf-8');

  // `dep` will collect all dependencies of `file`
  let dep = [];
  const plugin = {
    visitor: {
      ImportDeclaration(decl) {
        const importedFile = decl.node.source.value;
        // Recursion: Push an array of the dependency’s dependencies onto the list
        dep.push((async function() {
          return await buildDependencyTree(`./app/${importedFile}`);
        })());
        // Push the dependency itself onto the list
        dep.push(importedFile);
      }
    }
  }
  // Run the plugin
  babel.transform(code, {plugins: [plugin]});
  // Wait for all promises to resolve and then flatten the array
  return flatten(await Promise.all(dep));
}

Etapa 3: encontrar dependências compartilhadas entre todos os pontos de entrada

Como temos um conjunto de árvores de dependência, uma floresta de dependências, podemos encontrar as dependências compartilhadas procurando nós que aparecem em todas as árvores. Vamos achatar e eliminar a duplicação da nossa floresta e filtrar para manter apenas os elementos que aparecem em todas as árvores.

function findCommonDeps(depTrees) {
  const depSet = new Set();
  // Flatten
  depTrees.forEach(depTree => {
    depTree.forEach(dep => depSet.add(dep));
  });
  // Filter
  return Array.from(depSet)
    .filter(dep => depTrees.every(depTree => depTree.includes(dep)));
}

Etapa 4: agrupar dependências compartilhadas

Para agrupar nosso conjunto de dependências compartilhadas, podemos concatenar todos os arquivos de módulo. Dois problemas surgem ao usar essa abordagem: o primeiro é que o pacote ainda vai conter instruções import, que vão fazer com que o navegador tente buscar recursos. O segundo problema é que as dependências das dependências não foram agrupadas. Como já fizemos isso antes, vamos escrever outro plug-in do Babel.

O código é bastante semelhante ao nosso primeiro plug-in, mas, em vez de extrair as importações, também vamos removê-las e inserir uma versão agrupada do arquivo importado:

async function bundle(oldCode) {
  // `newCode` will be filled with code fragments that eventually form the bundle.
  let newCode = [];
  const plugin = {
    visitor: {
      ImportDeclaration(decl) {
        const importedFile = decl.node.source.value;
        newCode.push((async function() {
          // Bundle the imported file and add it to the output.
          return await bundle(await readFile(`./app/${importedFile}`));
        })());
        // Remove the import declaration from the AST.
        decl.remove();
      }
    }
  };
  // Save the stringified, transformed AST. This code is the same as `oldCode`
  // but without any import statements.
  const {code} = babel.transform(oldCode, {plugins: [plugin]});
  newCode.push(code);
  // `newCode` contains all the bundled dependencies as well as the
  // import-less version of the code itself. Concatenate to generate the code
  // for the bundle.
  return flatten(await Promise.all(newCode)).join('\n');
}

Etapa 5: reescrever pontos de entrada

Na última etapa, vamos criar outro plug-in do Babel. A tarefa dele é remover todas as importações de módulos que estão no pacote compartilhado.

async function rewrite(section, sharedBundle) {
  let oldCode = await readFile(`./app/static/${section}.js`);
  oldCode = oldCode.toString('utf-8');
  const plugin = {
    visitor: {
      ImportDeclaration(decl) {
        const importedFile = decl.node.source.value;
        // If this import statement imports a file that is in the shared bundle, remove it.
        if(sharedBundle.includes(importedFile))
          decl.remove();
      }
    }
  };
  let {code} = babel.transform(oldCode, {plugins: [plugin]});
  // Prepend an import statement for the shared bundle.
  code = `import '/static/_shared.js';\n${code}`;
  await writeFile(`./app/static/_${section}.js`, code);
}

Fim

Foi uma longa jornada, não é mesmo? Lembre-se de que nosso objetivo neste episódio era explicar e desmistificar a divisão de código. O resultado funciona, mas é específico para nosso site de demonstração e vai falhar horrivelmente no caso genérico. Para produção, recomendo usar ferramentas estabelecidas, como WebPack, RollUp etc.

Encontre nosso código no repositório do GitHub.

Espero vocês no próximo curso!