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 sabrás, Herramientas para desarrolladores de Chrome es una aplicación web escrita con HTML, CSS y JavaScript. A lo largo de los años, Herramientas para desarrolladores se ha vuelto más ricas en funciones, más inteligentes y con más conocimientos sobre la plataforma web más amplia. Si bien Herramientas para desarrolladores se expandió con los años, su arquitectura se parece en gran medida a la arquitectura original de cuando aún formaba parte de WebKit.

Esta entrada forma parte de una serie de entradas de blog en las que se describen los cambios que hacemos en la arquitectura de Herramientas para desarrolladores y cómo se compila. Explicaremos el funcionamiento histórico de Herramientas para desarrolladores, los beneficios y las limitaciones, y lo que hicimos para mitigarlas. Por lo tanto, profundicemos en los sistemas de módulos, cómo cargar código y cómo usamos los módulos de JavaScript.

Al principio, no había nada

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

La primera mención de un sistema de módulos en Herramientas para desarrolladores tiene su origen en 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 Herramientas para desarrolladores. Con un cambio de seguimiento, se extrajeron todos los módulos en un archivo frontend_modules.json independiente (confirmación) en 2013 y, luego, en archivos module.json separados (confirmación) en 2014.

Un archivo module.json de ejemplo:

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

Desde 2014, el patrón module.json se usa en Herramientas para desarrolladores 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, finalmente, los módulos de JavaScript estandarizados. Sin embargo, Herramientas para desarrolladores mantuvo el formato module.json.

Si bien Herramientas para desarrolladores seguía trabajando, hubo algunas desventajas al usar un sistema de módulos único y no estandarizado:

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

En general, cuando evaluamos el estado actual del sistema de módulos en Herramientas para desarrolladores y los otros formatos de módulo (más utilizados), concluimos que el patrón module.json generaba más problemas de los que resolvía y que era momento de planificar un cambio.

Los beneficios de los estándares

Entre los sistemas de módulos existentes, elegimos los módulos de JavaScript como los que se deben migrar. Al momento de esa decisión, los módulos de JavaScript aún se estaban enviando con un parámetro 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 ello, concluimos que los módulos de JavaScript eran la mejor opción.

El beneficio principal de los módulos de JavaScript es que son el formato de módulo estandarizado para JavaScript. Cuando enumeramos las desventajas de module.json (consulta la sección anterior), nos dimos cuenta de que casi todas estaban relacionadas 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 tenemos que dedicar tiempo a crear integraciones con las herramientas de compilación y las herramientas que usaron nuestros encargados de mantenimiento.

Con frecuencia, estas integraciones eran frágiles y carecían de compatibilidad para las funciones, lo que requería tiempo de mantenimiento adicional, lo que en ocasiones generaba errores sutiles que, finalmente, se enviarían a los usuarios.

Dado que los módulos de JavaScript eran el estándar, significaba que los IDE como VS Code, los verificadores de tipo (como Closure Compiler/TypeScript) y las herramientas de compilación como Rollup/minifiers podían comprender el código fuente que escribimos. Además, cuando un encargado de mantenimiento nuevo se uniera al equipo de Herramientas para desarrolladores, no tendría que perder tiempo aprendiendo un formato module.json de propiedad, mientras que (probablemente) ya estaría familiarizado con los módulos de JavaScript.

Por supuesto, cuando se creó Herramientas para desarrolladores, ninguno de los beneficios anteriores existía. Llevaron años de trabajo en grupos de estándares, implementaciones de entornos de ejecución y desarrolladores usando módulos de JavaScript que proporcionaron comentarios para llegar al punto en el que se encuentran ahora. Sin embargo, cuando los módulos de JavaScript están disponibles, tuvimos que tomar una decisión: seguir manteniendo nuestro propio formato o invertir en migrar al nuevo.

El costo de las nuevas

Si bien los módulos de JavaScript tienen muchos beneficios que nos gustaría usar, seguimos en el mundo no estándar de module.json. Aprovechar los beneficios de los módulos de JavaScript significa que tuvimos que invertir significativamente en eliminar la deuda técnica y llevar a cabo una migración que podía potencialmente romper funciones e introducir errores de regresión.

En este punto, no era una pregunta de “¿Queremos usar módulos de JavaScript?”, sino una pregunta de “¿qué tan costoso es poder usar módulos de JavaScript?”. Aquí, tuvimos que equilibrar el riesgo de dañar a nuestros usuarios con regresiones, el costo de que los ingenieros dedicaran una gran cantidad de tiempo a migrar y el peor estado temporal en el que trabajaríamos.

Ese último dato resultó ser muy importante. Aunque, en teoría, podríamos llegar a los módulos de JavaScript, durante una migración, obtendremos código que tendría que tener en cuenta tanto los módulos module.json como los de JavaScript. Esto no solo fue 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. Deberían preguntarse continuamente: “¿Para esta parte de la base de código es module.json o módulos de JavaScript y cómo hago los cambios?”.

Vista rápida: El costo oculto de guiar a otros encargados de mantenimiento durante una migración fue mayor de lo que esperábamos.

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

  1. Asegúrate de que el uso de módulos de JavaScript aproveche al máximo los beneficios posibles.
  2. Asegúrate de que la integración con el sistema existente basado en module.json sea segura y no produzca un impacto negativo para el usuario (errores de regresión o frustración del usuario).
  3. Guía a todos los encargados de mantenimiento de Herramientas para desarrolladores durante la migración, principalmente con controles y balances integrados a fin de evitar errores accidentales.

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

Si bien el objetivo era claro, las limitaciones impuestas por el formato de module.json resultaron ser difíciles de solucionar. Hicimos varias iteraciones, prototipos y cambios arquitectónicos antes de desarrollar una solución con la que nos pusiéramos cómodo. Escribimos un documento de diseño con la estrategia de migración que obtuvimos. El documento de diseño también incluía nuestra estimación de tiempo inicial: 2 a 4 semanas.

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

Sin embargo, el plan inicial resistió el tiempo: le enseñaríamos al entorno de ejecución de Herramientas para desarrolladores a cargar todos los archivos enumerados en el array scripts del archivo module.json con el método anterior, mientras que todos los archivos incluidos en el array modules con importación dinámica de módulos de JavaScript. Cualquier archivo que residiera en el array modules podría usar importaciones y exportaciones de ES.

Además, realizaremos 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 estaría en qué fase se realizó seguimiento en una hoja de cálculo grande:

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

Aquí encontrará un fragmento de la hoja de progreso disponible para el público.

export fases

La primera fase sería agregar sentencias export para todos los símbolos que se suponía que se compartí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 fuentes, sería front_end/module/file1.js).

Esto se transformará de la siguiente manera:

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

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

Al principio, nuestro plan era volver a escribir las importaciones del mismo archivo también durante esta fase. En el ejemplo anterior, volveríamos a escribir Module.File1.localFunctionInFile en localFunctionInFile. Sin embargo, nos dimos cuenta de que sería más fácil de 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á en la segunda subfase de la fase import.

Dado que agregar la palabra clave export a un archivo transforma el archivo de una “secuencia de comandos” a un “módulo”, se tuvo que actualizar gran parte de la infraestructura de Herramientas para desarrolladores en consecuencia. Esto incluyó el entorno de ejecución (con importación dinámica), pero también herramientas como ESLint para ejecutarse en modo de módulo.

Un descubrimiento que descubrimos mientras trabajábamos con estos problemas es que nuestras pruebas se estaban ejecutando en modo “descuidado”. 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 parecer, una cantidad considerable de pruebas se basaban en este desorden, incluida una prueba que utilizaba una sentencia with Experiment.

Al final, la actualización de la primera carpeta para incluir las sentencias export tardó alrededor de una semana y varios intentos con redireccionamientos.

import fases

Después de exportar todos los símbolos mediante sentencias export y permanecer en el alcance global (heredado), tuvimos que actualizar todas las referencias a símbolos de archivos cruzados para usar las 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:

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 tuvo algunas advertencias:

  1. No todos los símbolos tienen el nombre Module.File.symbolName. Algunos símbolos se llamaron únicamente Module.File o incluso Module.CompletelyDifferentName. Esta incoherencia significó que teníamos que crear una asignación interna desde el objeto global anterior hasta el nuevo objeto importado.
  2. A veces, habrían conflictos entre los nombres moduleScoped. De manera más destacada, usamos un patrón para declarar ciertos tipos de Events, en el que cada símbolo se nombraba solo como Events. Esto significaba que, si escuchabas varios tipos de eventos declarados en diferentes archivos, se producía un conflicto de nombres en la sentencia import de esos Events.
  3. Al parecer, había dependencias circulares entre los archivos. Esto era correcto en un contexto de alcance global, ya que el uso del símbolo se producía 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 de efectos secundarios en tu código de alcance global, que también tenía Herramientas para desarrolladores. En general, requirió un poco de cirugía y refactorización para que la transformación fuera segura.

Un mundo completamente nuevo con módulos de JavaScript

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

Ahora, todos los módulos en Herramientas para desarrolladores 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 Herramientas para desarrolladores. Estas se eliminarán con el paso del tiempo, pero no las consideramos un obstáculo para futuros desarrollos. También tenemos una guía de estilo para nuestro uso de los módulos de JavaScript.

Estadísticas

Las estimaciones conservadoras para la cantidad de CL (abreviatura de lista de cambios, el término usado en Gerrit que representa un cambio similar a una solicitud de extracción de GitHub) involucrados en esta migración son de alrededor de 250 CL, en su mayoría realizadas por 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 (que se calcula como la suma de la diferencia absoluta entre las inserciones y eliminaciones para cada CL) es de aproximadamente 30,000 (alrededor del 20% de todo el código de frontend de Herramientas para desarrolladores).

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

Estamos al tanto de una regresión que se lanzó 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 dejó de funcionar debido a una exportación irrelevante de default. Tuvimos muchas otras regresiones, pero nuestros paquetes de pruebas automatizadas y usuarios de Chrome Canary los informaron, y los corregimos antes de que pudieran llegar a los usuarios estables de Chrome.

Puedes ver el recorrido completo (no todos los CL están adjuntos a este error, pero la mayoría sí está) registrado en crbug.com/1006759.

Qué aprendimos

  1. Las decisiones tomadas en el pasado pueden tener un impacto duradero en tu proyecto. A pesar de que los módulos de JavaScript (y otros formatos de módulo) estaban disponibles por un tiempo, Herramientas para desarrolladores no estaba en posición 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 fueron en semanas en lugar de meses. Esto se debe en gran parte al hecho de que encontramos más problemas inesperados de los que esperábamos en nuestro análisis de costos inicial. Aunque el plan de migración era sólido, la deuda técnica era el obstáculo (con mayor frecuencia de la que nos gustaría).
  3. La migración de módulos de JavaScript incluyó una gran cantidad de limpiezas de deudas técnicas (aparentemente no relacionadas). La migración a un formato moderno de módulos estandarizados nos permitió volver a alinear nuestras prácticas recomendadas de programación con el desarrollo web moderno. Por ejemplo, pudimos reemplazar nuestro agrupador de Python personalizado por una configuración de Rollup mínima.
  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 varios problemas con la migración de los primeros archivos, después de un tiempo, tuvimos un flujo de trabajo sólido y parcialmente automatizado. Esto significa que el impacto negativo para los usuarios estables fue mínimo durante esta migración.
  5. Enseñar las particularidades 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 para el trabajo que realizan. Saber qué compartir y qué detalles no compartir 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 Canary, Dev o Beta de Chrome como tu navegador de desarrollo predeterminado. Estos canales de vista previa te brindan acceso a las funciones más recientes de Herramientas para desarrolladores, prueba APIs de plataformas web de vanguardia y encuentra problemas en tu sitio antes que tus usuarios.

Cómo comunicarse con el equipo de Herramientas para desarrolladores de Chrome

Usa las siguientes opciones para analizar las nuevas funciones y los cambios en la publicación, o cualquier otra cosa relacionada con Herramientas para desarrolladores.

  • Envíanos tus sugerencias o comentarios a través de crbug.com.
  • Informa un problema en Herramientas para desarrolladores mediante Más opciones   Más   > Ayuda > Informar problemas con Herramientas para desarrolladores en Herramientas para desarrolladores.
  • Envía un tweet a @ChromeDevTools.
  • Deja comentarios en los videos de YouTube de las Novedades de las Herramientas para desarrolladores o en las sugerencias de Herramientas para desarrolladores (videos de YouTube).