Actualización de la arquitectura de Herramientas para desarrolladores: Migración a módulos de JavaScript

Tim van der Lippe
Tim van der Lippe

Como sabes, Herramientas para desarrolladores de Chrome es una aplicación web escrita en HTML, CSS y JavaScript. A lo largo de los años, Herramientas para desarrolladores se ha vuelto más inteligente, con más funciones y con más conocimientos sobre la plataforma web en general. Si bien DevTools se expandió con el paso de los años, su arquitectura se asemeja en gran medida a la arquitectura original cuando aún formaba parte de WebKit.

Esta entrada forma parte de una serie de entradas de blog que describen los cambios que estamos haciendo en la arquitectura de Herramientas para desarrolladores y cómo se crea. Explicaremos el funcionamiento histórico de Herramientas para desarrolladores, cuáles fueron los beneficios y las limitaciones, y lo que hicimos para mitigar esas limitaciones. Por lo tanto, analicemos en detalle los sistemas de módulos, cómo cargar código y cómo terminamos usando módulos de JavaScript.

En el principio, no había nada

Si bien el panorama actual del frontend tiene una variedad de sistemas de módulos con herramientas integradas a su alrededor, así como el formato de módulos de JavaScript ahora estandarizado, ninguno de estos existía cuando se creó DevTools por primera vez. Herramientas para desarrolladores se basa en código que inicialmente se envió en WebKit hace más de 12 años.

La primera mención de un sistema de módulos en DevTools data de 2012: la introducción de una lista de módulos con una lista asociada de fuentes. Esto formaba parte de la infraestructura de Python que se usaba en ese momento para compilar y crear DevTools. Un cambio posterior extrajo todos los módulos en un archivo frontend_modules.json independiente (compromiso) en 2013 y, luego, en archivos module.json independientes (compromiso) en 2014.

Archivo module.json de ejemplo:

{
  "dependencies": [
    "common"
  ],
  "scripts": [
    "StylePane.js",
    "ElementsPanel.js"
  ]
}

Desde 2014, el patrón module.json se usa en DevTools para especificar sus módulos y archivos de origen. Mientras tanto, el ecosistema web evolucionó rápidamente y se crearon varios formatos de módulos, incluidos UMD, CommonJS y los módulos de JavaScript, finalmente estandarizados. Sin embargo, DevTools se quedó con el formato module.json.

Si bien DevTools seguía funcionando, el uso de un sistema de módulos único y no estandarizado tenía algunas desventajas:

  1. El formato module.json requería herramientas de compilación personalizadas, similares a los agrupadores modernos.
  2. No había integración con IDE, lo que requería herramientas personalizadas para generar archivos que los IDE modernos pudieran entender (la secuencia de comandos original para generar archivos jsconfig.json para VS Code).
  3. Las funciones, las clases y los objetos se colocaron en el alcance global para que sea posible compartir entre módulos.
  4. Los archivos dependían del orden, lo que significa que el orden en el que se enumeraban los sources era importante. No había garantía de que se cargara el código en el que confías, más allá de que una persona lo haya verificado.

En resumen, cuando evaluamos el estado actual del sistema de módulos en DevTools y los otros formatos de módulos (más utilizados), concluimos que el patrón module.json creaba más problemas de los que solucionaba y que era hora de planificar nuestro alejamiento de él.

Los beneficios de los estándares

De los sistemas de módulos existentes, elegimos los módulos de JavaScript como los que migraremos. En el momento de esa decisión, los módulos de JavaScript aún se enviaban detrás de una marca en Node.js y una gran cantidad de paquetes disponibles en NPM no tenían un paquete de módulos de JavaScript que pudiéramos usar. A pesar de esto, concluimos que los módulos de JavaScript eran la mejor opción.

El beneficio principal de los módulos de JavaScript es que es el formato de módulo estandarizado para JavaScript. Cuando enumeramos las desventajas de module.json (consulta más arriba), nos dimos cuenta de que casi todas se relacionaban con el uso de un formato de módulo único y no estandarizado.

Elegir un formato de módulo que no esté estandarizado significa que debemos invertir tiempo en crear integraciones con las herramientas de compilación y las herramientas que usaron nuestros mantenedores.

Estas integraciones solían ser frágiles y carecían de compatibilidad con las funciones, por lo que requerían tiempo de mantenimiento adicional, lo que a veces provocaba errores sutiles que, finalmente, se enviaban a los usuarios.

Dado que los módulos de JavaScript eran el estándar, esto significaba que los IDEs como VS Code, los verificadores de tipos como Closure Compiler/TypeScript y las herramientas de compilación como Rollup/minifiers podrían comprender el código fuente que escribimos. Además, cuando un nuevo encargado se una al equipo de DevTools, no tendrá que dedicar tiempo a aprender un formato module.json propietario, mientras que es probable que ya esté familiarizado con los módulos de JavaScript.

Por supuesto, cuando se creó DevTools inicialmente, no existía ninguno de los beneficios anteriores. Se necesitaron años de trabajo en grupos de estándares, implementaciones del entorno de ejecución y desarrolladores que usaban módulos de JavaScript para proporcionar comentarios y llegar al punto en el que se encuentran ahora. Sin embargo, cuando los módulos de JavaScript estuvieron disponibles, tuvimos que tomar una decisión: seguir manteniendo nuestro propio formato o invertir en migrar al nuevo.

El costo del nuevo y reluciente

Si bien los módulos de JavaScript tenían muchos beneficios que nos gustaría usar, nos quedamos en el mundo no estándar de module.json. Para aprovechar los beneficios de los módulos de JavaScript, tuvimos que invertir significativamente en la limpieza de la deuda técnica y realizar una migración que podría dañar las funciones y generar errores de regresión.

En este punto, no se trataba de la pregunta "¿Queremos usar módulos de JavaScript?", sino de "¿Cuánto cuesta poder usar módulos de JavaScript?". Aquí, tuvimos que equilibrar el riesgo de generar errores en nuestros usuarios con regresiones, el costo de que los ingenieros dediquen (una gran cantidad de) tiempo a la migración y el peor estado temporal en el que trabajaríamos.

Ese último punto resultó ser muy importante. Si bien, en teoría, podríamos acceder a los módulos de JavaScript, durante una migración terminaríamos con un código que tendría que tener en cuenta tanto los módulos module.json como los de JavaScript. Esto no solo era técnicamente difícil de lograr, sino que también significaba que todos los ingenieros que trabajaban en Herramientas para desarrolladores necesitaban saber cómo trabajar en este entorno. Tendrían que preguntarse constantemente: "Para esta parte de la base de código, ¿se trata de módulos module.json o JavaScript, y cómo hago los cambios?".

Adelanto: El costo oculto de guiar a nuestros colegas encargados de mantener una migración fue mayor de lo que esperábamos.

Después del análisis de costos, concluimos que aún valía la pena migrar a los módulos de JavaScript. Por lo tanto, nuestros objetivos principales fueron los siguientes:

  1. Asegúrate de que el uso de los módulos de JavaScript aproveche los beneficios en la medida de lo posible.
  2. Asegúrate de que la integración con el sistema existente basado en module.json sea segura y no genere un impacto negativo para los usuarios (errores de regresión, frustración del usuario).
  3. Guía a todos los encargados del mantenimiento de DevTools a través de la migración, principalmente con controles y balances integrados para evitar errores accidentales.

Hojas de cálculo, transformaciones y deuda técnica

Si bien el objetivo era claro, las limitaciones que impone el formato module.json resultaron difíciles de solucionar. Se necesitaron varias iteraciones, prototipos y cambios arquitectónicos antes de desarrollar una solución que nos resultara adecuada. Escribimos un documento de diseño con la estrategia de migración que elegimos. En el documento de diseño, también se indica nuestra estimación de tiempo inicial: de 2 a 4 semanas.

Alerta de spoiler: La parte más intensiva de la migración llevó 4 meses y, de principio a fin, 7 meses.

Sin embargo, el plan inicial resistió el paso del tiempo: le enseñaríamos al entorno de ejecución de DevTools a cargar todos los archivos enumerados en el array scripts en el archivo module.json de la forma anterior, mientras que todos los archivos enumerados en el array modules con la importación dinámica de módulos de JavaScript. Cualquier archivo que resida en el array modules podrá usar importaciones o exportaciones de ES.

Además, realizaríamos la migración en 2 fases (con el tiempo, dividimos la última fase en 2 subfases, como se muestra a continuación): las fases export y import. El estado de qué módulo sería en qué fase se hizo el seguimiento en una hoja de cálculo grande:

Hoja de cálculo de migración de módulos de JavaScript

Un fragmento de la hoja de progreso está disponible públicamente aquí.

export-fase

La primera fase sería agregar sentencias export para todos los símbolos que se suponía que se compartirían entre módulos o archivos. La transformación se automatizaría mediante la ejecución de una secuencia de comandos por carpeta. Dado el siguiente símbolo existiría en el mundo module.json:

Module.File1.exported = function() {
  console.log('exported');
  Module.File1.localFunctionInFile();
};
Module.File1.localFunctionInFile = function() {
  console.log('Local');
};

(Aquí, Module es el nombre del módulo y File1 es el nombre del archivo. En nuestro árbol de origen, sería front_end/module/file1.js).

Esto se transformaría en lo siguiente:

export function exported() {
  console.log('exported');
  Module.File1.localFunctionInFile();
}
export function localFunctionInFile() {
  console.log('Local');
}

/** Legacy export object */
Module.File1 = {
  exported,
  localFunctionInFile,
};

Inicialmente, nuestro plan era reescribir las importaciones del mismo archivo durante esta fase. Por ejemplo, en el ejemplo anterior, volveríamos a escribir Module.File1.localFunctionInFile como localFunctionInFile. Sin embargo, nos dimos cuenta de que sería más fácil automatizar y más seguro aplicar si separamos estas dos transformaciones. Por lo tanto, "migrar todos los símbolos en el mismo archivo" se convertiría en la segunda subfase de la fase import.

Dado que agregar la palabra clave export a un archivo lo transforma de una "secuencia de comandos" a un "módulo", se tuvo que actualizar gran parte de la infraestructura de DevTools según corresponda. Esto incluía el entorno de ejecución (con importación dinámica), pero también herramientas como ESLint para ejecutarse en modo de módulo.

Mientras trabajábamos en estos problemas, descubrimos que nuestras pruebas se ejecutaban en modo "sloppy". Dado que los módulos de JavaScript implican que los archivos se ejecutan en modo "use strict", esto también afectaría nuestras pruebas. Al final, una cantidad no trivial de pruebas dependía de esta negligencia, incluida una prueba que usaba una sentencia with 😱.

Al final, actualizar la primera carpeta para incluir sentencias export tardó alrededor de una semana y varios intentos con relands.

Fase import

Después de que todos los símbolos se exportaron con sentencias export y permanecieron en el alcance global (heredado), tuvimos que actualizar todas las referencias a símbolos de varios archivos para usar importaciones de ES. El objetivo final sería quitar todos los "objetos de exportación heredados" y limpiar el alcance global. La transformación se automatizaría mediante la ejecución de una secuencia de comandos por carpeta.

Por ejemplo, para los siguientes símbolos que existen en el mundo module.json:

Module.File1.exported();
AnotherModule.AnotherFile.alsoExported();
SameModule.AnotherFile.moduleScoped();

Se transformarían en lo siguiente:

import * as Module from '../module/Module.js';
import * as AnotherModule from '../another_module/AnotherModule.js';

import {moduleScoped} from './AnotherFile.js';

Module.File1.exported();
AnotherModule.AnotherFile.alsoExported();
moduleScoped();

Sin embargo, este enfoque tenía algunas salvedades:

  1. No todos los símbolos se nombraron como Module.File.symbolName. Algunos símbolos solo se nombraron Module.File o, incluso, Module.CompletelyDifferentName. Esta incoherencia significó que tuvimos que crear una asignación interna del objeto global anterior al objeto importado nuevo.
  2. A veces, habría conflictos entre los nombres de moduleScoped. En un lugar más prominente, usamos un patrón para declarar ciertos tipos de Events, donde cada símbolo solo se llamaba Events. Esto significaba que, si estabas escuchando varios tipos de eventos declarados en diferentes archivos, se produciría un conflicto de nombres en la sentencia import para esos Events.
  3. Al final, había dependencias circulares entre los archivos. Esto estaba bien en un contexto de alcance global, ya que el uso del símbolo era después de que se cargaba todo el código. Sin embargo, si necesitas un import, la dependencia circular se hará explícita. Esto no es un problema de inmediato, a menos que tengas llamadas a funciones con efectos secundarios en el código de alcance global, que también tenía DevTools. En resumen, se requirió una cirugía y una refactorización para que la transformación fuera segura.

Un mundo nuevo con módulos de JavaScript

En febrero de 2020, 6 meses después del inicio en septiembre de 2019, se realizaron las últimas limpiezas en la carpeta ui/. Esto marcó el final no oficial de la migración. Después de que todo se calmó, marcamos oficialmente la migración como finalizada el 5 de marzo de 2020. 🎉

Ahora, todos los módulos de DevTools usan módulos de JavaScript para compartir código. Aún colocamos algunos símbolos en el alcance global (en los archivos module-legacy.js) para nuestras pruebas heredadas o para integrarlos con otras partes de la arquitectura de DevTools. Se quitarán con el tiempo, pero no los consideramos un impedimento para el desarrollo futuro. También tenemos una guía de estilo para el uso de los módulos de JavaScript.

Estadísticas

Las estimaciones conservadoras de la cantidad de CL (siglas en inglés de lista de cambios, el término que se usa en Gerrit para representar un cambio, similar a una solicitud de extracción de GitHub) involucradas en esta migración son de alrededor de 250 CL, que en su mayoría realizan 2 ingenieros. No tenemos estadísticas definitivas sobre el tamaño de los cambios realizados, pero una estimación conservadora de las líneas modificadas (calculada como la suma de la diferencia absoluta entre las inserciones y las eliminaciones de cada CL) es de aproximadamente 30,000 (alrededor del 20% de todo el código del frontend de DevTools).

El primer archivo que usa export se envió en Chrome 79, que se lanzó a la versión estable en diciembre de 2019. El último cambio para migrar a import se envió en Chrome 83, que se lanzó en la versión estable en mayo de 2020.

Sabemos de una regresión que se envió a la versión estable de Chrome y que se introdujo como parte de esta migración. El autocompletado de fragmentos en el menú de comandos no funcionaba debido a una exportación de default innecesaria. Tuvimos muchas otras regresiones, pero nuestros paquetes de pruebas automatizadas y usuarios de Chrome Canary informaron sobre estas regresiones y las corregimos antes de que pudieran llegar a los usuarios estables de Chrome.

Puedes ver el recorrido completo (no todas las CL están asociadas a este error, pero la mayoría de ellas) registrado en crbug.com/1006759.

Qué aprendimos

  1. Las decisiones que se tomaron en el pasado pueden tener un impacto duradero en tu proyecto. Aunque los módulos de JavaScript (y otros formatos de módulos) estuvieron disponibles durante bastante tiempo, DevTools no estaba en condiciones de justificar la migración. Decidir cuándo migrar y cuándo no es difícil y se basa en suposiciones fundamentadas.
  2. Nuestras estimaciones de tiempo iniciales se basaban en semanas en lugar de meses. Esto se debe en gran medida a que encontramos más problemas inesperados de los que anticipamos en nuestro análisis de costos inicial. Si bien el plan de migración era sólido, la deuda técnica era (más a menudo de lo que nos hubiera gustado) el obstáculo.
  3. La migración de módulos de JavaScript incluyó una gran cantidad de limpiezas de deuda técnica (aparentemente no relacionadas). La migración a un formato de módulo moderno y estandarizado nos permitió realinear nuestras prácticas recomendadas de programación con el desarrollo web actual. Por ejemplo, pudimos reemplazar nuestro empaquetador de Python personalizado por una configuración mínima de Rollup.
  4. A pesar del gran impacto en nuestra base de código (alrededor del 20% del código cambió), se informaron muy pocas regresiones. Si bien tuvimos muchos problemas para migrar los primeros archivos, después de un tiempo, logramos tener un flujo de trabajo sólido y parcialmente automatizado. Esto significó que el impacto negativo para nuestros usuarios estables fue mínimo en esta migración.
  5. Enseñar las complejidades de una migración en particular a otros encargados de mantenimiento es difícil y, a veces, imposible. Las migraciones de esta escala son difíciles de seguir y requieren mucho conocimiento del dominio. Transferir ese conocimiento del dominio a otras personas que trabajan en la misma base de código no es conveniente en sí para el trabajo que están haciendo. Saber qué compartir y qué detalles no es un arte, pero es necesario. Por lo tanto, es fundamental reducir la cantidad de migraciones grandes o, al menos, no realizarlas al mismo tiempo.

Descarga los canales de vista previa

Considera usar Chrome Canary, Dev o Beta como tu navegador de desarrollo predeterminado. Estos canales de versión preliminar te brindan acceso a las funciones más recientes de DevTools, te permiten probar las APIs de plataformas web de vanguardia y te ayudan a encontrar problemas en tu sitio antes que tus usuarios.

Comunícate con el equipo de Herramientas para desarrolladores de Chrome

Usa las siguientes opciones para hablar sobre las funciones nuevas, las actualizaciones o cualquier otro tema relacionado con DevTools.