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

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

Sugerencias y trucos diversos en este episodio

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

A modo de resumen

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

  1. Obtén una lista de tus puntos de entrada.
  2. Extrae las dependencias de los módulos de todos estos puntos de entrada.
  3. Busca dependencias compartidas entre todos los puntos de entrada.
  4. Agrupa las dependencias compartidas.
  5. Reescribe los puntos de entrada.

División de código en comparación con el 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 indistinta. Esto ha provocado cierta confusión. Intentemos aclarar esto:

  • División de código: La división de código es el proceso de dividir tu código en varios paquetes. Si no envías un paquete grande con todo tu código JavaScript al cliente, estás realizando la división de 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 tus rutas y sus dependencias, podemos cambiar qué módulos van en qué paquete.

¿Por qué se divide el código?

Módulos independientes

Con los módulos ES6 nativos, cada módulo de JavaScript puede importar sus propias dependencias. Cuando el navegador recibe un módulo, todas las instrucciones import activarán recuperaciones adicionales para obtener los módulos necesarios para ejecutar el código. Sin embargo, todos estos módulos pueden tener sus propias dependencias. El peligro es que el navegador termine con una cascada de recuperaciones que duran varios recorridos antes de que el código se pueda ejecutar por fin.

Agrupación

El empaquetado, que consiste en intercalar todos tus módulos en un solo paquete, se asegurará de que el navegador tenga todo el código que necesita después de 1 viaje de ida y vuelta y pueda comenzar a ejecutar el código más rápido. Sin embargo, esto obliga al usuario a descargar mucho código que no es necesario, por lo que se desperdician ancho de banda y tiempo. Además, cada cambio en uno de nuestros módulos originales generará un cambio en el paquete, lo que invalidará cualquier versión almacenada en caché del paquete. Los usuarios tendrán que volver a descargar todo el contenido.

División de código

La división de código es la opción intermedia. Estamos dispuestos a realizar más recorridos para obtener eficiencia de la red descargando solo lo que necesitamos y a mejorar la eficiencia del almacenamiento en caché reduciendo mucho la cantidad de módulos por paquete. Si el agrupamiento se realiza correctamente, la cantidad total de viajes será mucho menor que con los módulos sueltos. Por último, podríamos usar mecanismos de carga previa, como link[rel=preload], para ahorrar tiempos adicionales de trío redondo si fuera necesario.

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

Este es solo uno de muchos enfoques, pero en el episodio analizamos el 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

Por lo general, Babel se usa para la “transpilación”, que consiste en consumir código JavaScript de vanguardia y convertirlo en una versión anterior de JavaScript para que más navegadores puedan ejecutarlo. El primer paso aquí es analizar el nuevo código JavaScript con un analizador (Babel usa babylon) que convierte el código en un llamado “árbol de sintaxis abstracto” (AST). Una vez que se genera el AST, una serie de complementos lo analizan y modifican.

Usaremos mucho Babel para detectar (y manipular más adelante) las importaciones de un módulo de JavaScript. Es posible que sientas la tentación de recurrir a expresiones regulares, pero estas no son lo suficientemente potentes como para analizar un idioma de forma correcta y son difíciles de mantener. Usar herramientas probadas como Babel te ahorrará muchos dolores de cabeza.

Este es un ejemplo sencillo de cómo 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 plug-in quiera controlar. Cuando se encuentre un nodo de ese tipo mientras se recorre el AST, se invocará la función correspondiente en el objeto visitor con ese nodo como parámetro. En el ejemplo anterior, se llamará al método ImportDeclaration() para cada declaración import en el archivo. Para obtener más información sobre 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 tenemos que analizar esas dependencias, ya que, a su vez, también pueden tener dependencias. ¡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

Dado que tenemos un conjunto de árboles de dependencias (un bosque de dependencias, si quieres), podemos encontrar las dependencias compartidas buscando nodos que aparezcan en cada árbol. Aplanaremos y desduplicaremos nuestro bosque y filtraremos para conservar 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 agrupar nuestro conjunto de dependencias compartidas, solo 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 sentencias import, lo que hará 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 al de 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: Vuelve a escribir los puntos de entrada

En el último paso, escribiremos otro complemento de Babel. Su función 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 un viaje bastante largo, ¿no? Recuerda que nuestro objetivo en este episodio era explicar y desmitificar la división de código. El resultado funciona, pero es específico de nuestro sitio de demostración y falla miserablemente en el caso genérico. Para producción, te recomiendo que uses herramientas establecidas, como WebPack, RollUp, etcétera.

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

Hasta la próxima.