Blog de transmisiones en vivo potenciadas: División de código

En nuestra transmisión en vivo más reciente, Supercharged, implementamos la división de código y la fragmentación basada en rutas. Con los módulos de HTTP/2 y ES6 nativos, estas técnicas se convertirán en esenciales para permitir una carga y almacenamiento en caché eficientes de los recursos de secuencias de comandos.

Otros trucos y sugerencias en este episodio

  • asyncFunction().catch() con error.stack: 9:55
  • Módulos y atributo nomodule en etiquetas <script>: 7:30
  • promisify() en Node 8: 17:20

Resumen

Cómo dividir el código a través de la fragmentación basada en rutas:

  1. Obtén una lista de tus puntos de entrada.
  2. Extrae las dependencias del módulo de todos estos puntos de entrada.
  3. Busca dependencias compartidas entre todos los puntos de entrada.
  4. Agrupa las dependencias compartidas.
  5. Vuelve a escribir los puntos de entrada.

División de código y fragmentación basada en rutas

La división de código y la fragmentación basada en rutas están estrechamente relacionadas y, a menudo, se usan de manera intercambiable. Esto generó confusión. Intentemos aclararlo:

  • División de código: Es el proceso de dividir tu código en varios paquetes. Si no envías un gran paquete con todo tu JavaScript al cliente, debes realizar una división del código. Una forma específica de dividir tu código es usar la fragmentación basada en rutas.
  • Fragmentación basada en rutas: La fragmentación basada en rutas crea paquetes relacionados con las rutas de tu app. Si analizamos las rutas y sus dependencias, podemos cambiar los módulos que se incluyen en cada paquete.

¿Por qué se usa la división de código?

Módulos individuales

Con los módulos ES6 nativos, cada módulo de JavaScript puede importar sus propias dependencias. Cuando el navegador reciba un módulo, todas las declaraciones import activarán recuperaciones adicionales a fin de obtener los módulos necesarios para ejecutar el código. Sin embargo, todos estos módulos pueden tener dependencias propias. El peligro es que el navegador termina con una cascada de recuperaciones que duran varias idas y vueltas antes de que se pueda ejecutar el código.

Paquetes

El uso de paquetes, que consiste en intercalar todos tus módulos en un solo paquete, garantizará que el navegador tenga todo el código que necesita después de 1 recorrido de ida y vuelta y pueda comenzar a ejecutar el código más rápido. Sin embargo, esto obliga al usuario a descargar una gran cantidad de código que no es necesario, por lo que se desperdiciaron ancho de banda y tiempo. Además, cada cambio en uno de nuestros módulos originales dará lugar a una modificación en el paquete, lo que invalidará cualquier versión del paquete almacenada en caché. Los usuarios deberán volver a descargar todo el archivo.

División de código

La división del código está en el medio. Estamos dispuestos a invertir ida y vuelta adicionales para obtener la eficiencia de la red descargando solo lo que necesitamos y una mejor eficiencia del almacenamiento en caché a través de la reducción mucho menor de la cantidad de módulos por paquete. Si se realiza correctamente la agrupación, la cantidad total de recorridos de ida y vuelta será mucho menor que con los módulos sueltos. Por último, podríamos usar mecanismos de precarga como link[rel=preload] para ahorrar tiempo adicional de tres rondas si es necesario.

Paso 1: Obtén una lista de tus puntos de entrada

Este es solo uno de muchos enfoques, pero en el episodio analizamos la sitemap.xml del sitio web para obtener los puntos de entrada a nuestro sitio web. Por lo general, se usa un archivo JSON dedicado que enumera todos los puntos de entrada.

Usa Babel para procesar JavaScript

Babel se usa comúnmente para "transpilar", es decir, consumir código JavaScript de vanguardia y convertirlo en una versión anterior de JavaScript para que más navegadores puedan ejecutar el código. El primer paso es analizar el nuevo JavaScript con un analizador (Babel usa babylon) que convierte el código en lo que se denomina "Árbol de sintaxis abstracta" (AST). Una vez que se genera el AST, una serie de complementos lo analiza y altera.

Haremos un uso intensivo de Babel para detectar (y manipular) las importaciones de un módulo de JavaScript. Es posible que tengas la tentación de recurrir a expresiones regulares, pero estas no son lo suficientemente potentes como para analizar correctamente un lenguaje y son difíciles de mantener. Utilizar herramientas probadas como Babel puede ahorrarte muchos dolores de cabeza.

Este es un ejemplo sencillo de ejecutar Babel con un complemento personalizado:

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

Un complemento puede proporcionar un objeto visitor. El visitante contiene una función para cualquier tipo de nodo que el complemento desee controlar. Cuando se encuentra un nodo de ese tipo mientras se recorre la AST, la función correspondiente en el objeto visitor se invocará con ese nodo como parámetro. En el ejemplo anterior, se llamará al método ImportDeclaration() para cada declaración import del archivo. Para tener una idea más clara de los tipos de nodos y el AST, consulta astexplorer.net.

Paso 2: Extrae las dependencias del módulo

Para compilar el árbol de dependencias de un módulo, lo analizaremos y crearemos una lista de todos los módulos que importa. También debemos analizar esas dependencias, ya que, a su vez, también pueden tener dependencias. Este es un caso clásico de recursividad.

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));
}

Paso 3: Busca dependencias compartidas entre todos los puntos de entrada

Como tenemos un conjunto de árboles de dependencias (un bosque de dependencias, por así decirlo), podemos encontrar las dependencias compartidas buscando nodos que aparecen en todos los árboles. Aplanaremos y anularemos el duplicado de nuestro bosque, y filtraremos solo los elementos que aparecen en todos los árboles.

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)));
}

Paso 4: Agrupa las dependencias compartidas

Para empaquetar nuestro conjunto de dependencias compartidas, podríamos concatenar todos los archivos del módulo. Surgen dos problemas cuando se usa ese enfoque: el primero es que el paquete seguirá conteniendo instrucciones import, que harán que el navegador intente recuperar recursos. El segundo problema es que las dependencias de las dependencias no se agruparon. Como ya lo hicimos antes, escribiremos otro complemento de Babel.

El código es bastante similar a nuestro primer complemento, pero, en lugar de solo extraer las importaciones, también las quitaremos y, luego, insertaremos una versión empaquetada del archivo 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');
}

Paso 5: Reescribe los puntos de entrada

Para el último paso, escribiremos otro complemento de Babel. Su trabajo es quitar todas las importaciones de módulos que se encuentran en el paquete compartido.

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);
}

Fin

Fue una experiencia increíble, ¿no? Recuerda que nuestro objetivo para este episodio fue explicar y desmitificar la división del código. El resultado funciona, pero es específico de nuestro sitio de demostración y fallará terriblemente en el caso genérico. Para la producción, te recomiendo que utilices herramientas establecidas como WebPack, RollUp, etcétera.

Puedes encontrar nuestro código en el repositorio de GitHub.

Hasta la próxima.