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

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

Dicas e truques variados neste episódio

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

Texto longo, leia o resumo

Como dividir o código por agrupamento com base em rota:

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

Divisão de código x agrupamento com base em rota

A divisão de código e o agrupamento com base em rotas estão intimamente relacionados e muitas vezes são usados de maneira intercambiável. Isso causou alguma confusão. Vamos tentar esclarecer isso:

  • Divisão de código: é o processo de dividir seu código em vários pacotes. Se não estiver enviando um pacote grande com todo o JavaScript para o cliente, você está dividindo o código. Uma maneira específica de dividir seu código é usar o agrupamento com base em rota.
  • Agrupamento baseado em rotas: o agrupamento com base em rota cria pacotes relacionados às rotas do app. Ao analisar suas rotas e suas dependências, podemos alterar os módulos que entram em cada pacote.

Por que dividir o 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 receber um módulo, todas as instruções import acionarão outras buscas para conseguir os módulos necessários para executar o código. No entanto, todos esses módulos podem ter dependências próprias. O perigo é que o navegador acaba com uma cascata de buscas que duram várias viagens de ida e volta antes que o código possa finalmente ser executado.

Agrupamento

O agrupamento, que integra todos os seus 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. No entanto, isso força o usuário a fazer o download de uma grande quantidade de código que não é necessário, de modo que largura de banda e tempo são desperdiçados. Além disso, todas as mudanças em um dos nossos módulos originais resultarão em uma mudança no pacote, invalidando qualquer versão armazenada em cache dele. Os usuários terão que fazer o download do conteúdo inteiro novamente.

Divisão de código

A divisão de código é o meio termo. Estamos dispostos a investir mais viagens de ida e volta para ter eficiência de rede fazendo o download apenas do que precisamos e melhorar a eficiência do armazenamento em cache ao reduzir muito o número de módulos por pacote. Se o agrupamento for feito corretamente, o número total de idas e voltas será muito menor do que com módulos soltos. Por fim, podemos usar mecanismos de pré-carregamento (link em inglês), como link[rel=preload], para economizar mais tempos de trio de rodadas, se necessário.

Etapa 1: receber uma lista dos pontos de entrada

Essa é apenas uma das muitas abordagens, mas no episódio analisamos o sitemap.xml do site para ter 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 do JavaScript para que mais navegadores possam executar o código. A primeira etapa é analisar o novo JavaScript com um analisador (o Babylon usa babylon) que transforma o código na chamada "Árvore de sintaxe abstrata" (AST, na sigla em inglês). Uma vez que o AST foi gerado, uma série de plug-ins analisa e prejudica o AST.

Vamos fazer uso intenso do Babel para detectar (e depois manipular) as importações de um módulo JavaScript. Você pode ficar tentado a 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 aprovadas como o Babel evitará muitas dores de cabeça.

Veja 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 ao atravessar o AST, a função correspondente no objeto visitor é invocada com esse nó como um parâmetro. No exemplo acima, o método ImportDeclaration() será chamado para cada declaração import no arquivo. Para entender melhor os tipos de nó e o 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 analisá-lo e criar uma lista de todos os módulos importados. Também precisamos analisar essas dependências, porque 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, ou seja, uma floresta de dependências, podemos encontrar as dependências compartilhadas procurando os nós que aparecem em todas as árvores. Vamos nivelar 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 o conjunto de dependências compartilhadas, basta concatenar todos os arquivos do módulo. Há dois problemas ao usar essa abordagem: o primeiro é que o pacote ainda vai conter instruções import, que farão o navegador tentar buscar recursos. O segundo problema é que as dependências das dependências não foram agrupadas. Como já fizemos isso antes, vamos criar outro plug-in babel.

O código é bastante semelhante ao nosso primeiro plug-in, mas, em vez de apenas extrair as importações, também as removeremos e inseriremos 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 os pontos de entrada

Na última etapa, criaremos mais um plug-in do Babel. A função 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 um passeio e tanto, não? Lembre-se de que nosso objetivo para este 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 terá uma falha grave no caso genérico. Para produção, recomendo usar ferramentas conhecidas, como WebPack, RollUp etc.

Nosso código está disponível no repositório do GitHub (link em inglês).

Espero vocês no próximo curso!