Organización del desarrollo de aplicaciones React a gran escala

Esta publicación se basa en una serie sobre la modernización de la interfaz de jQuery con React. Para comprender mejor las razones por las que se escribió este artículo, se recomienda echar un vistazo al primer artículo de esta serie. Es muy fácil en estos días organizar el desarrollo de una pequeña aplicación React, o empezar desde cero. Especialmente cuando se usa create-react-app . Es muy probable que algunos proyectos solo necesiten unas pocas dependencias (por ejemplo, para administrar el estado de la aplicación e internacionalizar el proyecto) y una carpeta que contenga al menos un directorio







srccomponents... Creo que esta es la estructura con la que comienzan la mayoría de los proyectos de React. Sin embargo, normalmente, a medida que crece el número de dependencias del proyecto, los programadores se enfrentan a un aumento en el número de componentes, reductores y otros mecanismos reutilizables incluidos en su estructura. A veces todo se vuelve muy incómodo y difícil de manejar. ¿Qué hacer, por ejemplo, si ya no está claro por qué se necesitan ciertas dependencias y cómo encajan? O, ¿qué pasa si el proyecto ha acumulado tantos componentes que se vuelve difícil encontrar el adecuado entre ellos? ¿Qué hacer si un programador necesita encontrar un componente cuyo nombre se ha olvidado?



Estos son solo algunos ejemplos de las preguntas a las que tuvimos que encontrar respuestas al reelaborar la interfaz en Karify . Sabíamos que la cantidad de dependencias y componentes del proyecto algún día podría salirse de control. Esto significaba que teníamos que planificar todo para que, a medida que el proyecto creciera, pudiéramos seguir trabajando en él con confianza. Esta planificación incluyó acordar la estructura de archivos y carpetas y la calidad del código. Esto incluyó una descripción de la arquitectura general del proyecto. Y lo más importante, era necesario hacerlo para que todo esto pudiera ser percibido fácilmente por los nuevos programadores que se acercan al proyecto, para que ellos, para ser incluidos en el trabajo, no tuvieran que estudiar el proyecto demasiado tiempo, entendiendo todas sus dependencias y el estilo de su código.



En el momento de escribir este artículo, tenemos alrededor de 1200 archivos JavaScript en nuestro proyecto. 350 de ellos son componentes. El código está probado en un 80%. Dado que seguimos adhiriendo a los acuerdos que hemos establecido y trabajamos en el marco de la arquitectura del proyecto previamente creada, decidimos que sería bueno compartir todo esto con el público en general. Así apareció este artículo. Aquí hablaremos sobre cómo organizar el desarrollo de una aplicación React a gran escala y qué lecciones aprendimos de la experiencia de trabajar en ella.



¿Cómo organizo archivos y carpetas?



Solo encontramos una manera de organizar convenientemente nuestros materiales de interfaz de React después de pasar por varias etapas del proyecto. Inicialmente, íbamos a alojar los materiales del proyecto en el mismo repositorio donde se almacenaba el código frontend basado en jQuery. Sin embargo, debido a los requisitos para la estructura de carpetas impuestos al proyecto por el marco de backend que estamos usando, esta opción no funcionó para nosotros. A continuación, pensamos en mover el código de la interfaz a un repositorio separado. Al principio, este enfoque funcionó bien, pero con el tiempo comenzamos a pensar en crear otras partes del cliente del proyecto, por ejemplo, una interfaz basada en React Native. Esto nos hizo pensar en la biblioteca de componentes. Como resultado, dividimos el nuevo repositorio en dos repositorios separados. Uno era para una biblioteca de componentes y el otro era para la nueva interfaz de React.Aunque al principio pensamos que esta idea fue exitosa, su implementación condujo a una seria complicación del procedimiento de revisión del código. La relación entre los cambios en nuestros dos repositorios no está clara. Como resultado, decidimos cambiar nuevamente para almacenar el código en un solo repositorio, pero ahora era un repositorio mono.



Nos decidimos por el repositorio mono porque queríamos introducir una separación entre la biblioteca de componentes y la aplicación frontend en el proyecto. La diferencia entre nuestro repositorio mono y otros repositorios similares es que no necesitamos publicar paquetes dentro de nuestro repositorio. En nuestro caso, los paquetes eran solo un medio para garantizar la modularidad del desarrollo y una herramienta para la separación de preocupaciones. Es especialmente útil tener diferentes paquetes para diferentes variantes de tu aplicación, ya que esto te permite definir diferentes dependencias para cada una y aplicar diferentes scripts con cada una.



Configuramos nuestro repositorio mono usando espacios de trabajo de yarn usando la siguiente configuración en el archivo raíz package.json:



"workspaces": [
    "app/*",
    "lib/*",
    "tool/*"
]


Ahora, algunos de ustedes se estarán preguntando por qué simplemente no usamos las carpetas del paquete, haciendo lo mismo que en otros monorrepositorios. Esto se debe principalmente al hecho de que queríamos separar la aplicación y la biblioteca de componentes. Además, sabíamos que necesitábamos crear algunas de nuestras propias herramientas. Como resultado, llegamos a la estructura de carpetas anterior. Así es como se reproducen estas carpetas en el proyecto:



  • app: todos los paquetes de esta carpeta están relacionados con aplicaciones frontend como Karify frontend y algunas otras interfaces internas. Nuestros materiales del libro de cuentos también se almacenan aquí .
  • lib: -, , . , , . , , typography, media primitive.
  • tool: , , Node.js. , , , . , , webpack, , ( « »).


Todos nuestros paquetes, independientemente de la carpeta en la que estén almacenados, tienen una subcarpeta srcy, opcionalmente, una carpeta bin. Las carpetas del srcpaquete, almacenadas en directorios appy lib, pueden contener algunas de las siguientes subcarpetas:



  • actions: Contiene funciones para crear acciones cuyos valores de retorno se pueden pasar a funciones de despacho desde reduxo useReducer.
  • components: contiene carpetas de componentes con su código, traducciones, pruebas unitarias, instantáneas, historiales (si corresponde a un componente específico).
  • constants: esta carpeta almacena valores que no se modifican en diferentes entornos. Las utilidades también se almacenan aquí.
  • fetch: aquí es donde se almacenan las definiciones de tipo para procesar los datos recibidos de nuestra API, así como las correspondientes acciones asíncronas utilizadas para recibir dichos datos.
  • helpers: , .
  • reducers: , redux useReducer.
  • routes: , react-router history.
  • selectors: , redux-, , API.


Esta estructura de carpetas nos permite escribir código verdaderamente modular, ya que crea un sistema claro para dividir las responsabilidades entre los diversos conceptos definidos por nuestras dependencias. Esto nos ayuda a buscar en el repositorio variables, funciones y componentes y, además, sin importar si la persona que los busca conoce o no su existencia. Además, nos ayuda a mantener la mínima cantidad de contenido en carpetas independientes, lo que, a su vez, facilita el trabajo con ellos.



Cuando comenzamos a aplicar esta estructura de carpetas, nos enfrentamos al desafío de garantizar una aplicación coherente de dicha estructura. Al trabajar con diferentes paquetes, el desarrollador puede querer crear diferentes carpetas en las carpetas de estos paquetes, organizar los archivos en estas carpetas de diferentes formas. Si bien no siempre es malo, un enfoque tan desorganizado daría lugar a confusión. Para ayudarnos a aplicar sistemáticamente la estructura anterior, hemos creado lo que se puede llamar un "linter del sistema de archivos". Hablaremos de esto ahora.



¿Cómo se asegura de que se aplique la guía de estilo?



Nos esforzamos por lograr uniformidad en la estructura de archivos y carpetas de nuestro proyecto. Queríamos lograr lo mismo para el código. En ese momento, ya teníamos una experiencia exitosa al resolver un problema similar en la versión jQuery del proyecto, pero teníamos mucho que mejorar, especialmente cuando se trata de CSS. Como resultado, decidimos crear una guía de estilo desde cero y asegurarnos de usarla con un linter. Las reglas que no se podían hacer cumplir con un linter se controlaron durante la revisión del código.



La configuración de un linter en un repositorio mono se realiza de la misma manera que en cualquier otro repositorio. Esto es bueno, ya que le permite verificar todo el repositorio en una ejecución de linter. Si no está familiarizado con linters, le recomiendo que eche un vistazo a ESLint y Stylelint . Los usamos exactamente.



El linter de JavaScript ha demostrado ser particularmente útil en las siguientes situaciones:



  • Garantizar el uso de componentes creados teniendo en cuenta la accesibilidad del contenido, en lugar de sus contrapartes HTML. Al crear la guía de estilo, introdujimos varias reglas con respecto a la accesibilidad de enlaces, botones, imágenes e íconos. Luego, necesitábamos hacer cumplir estas reglas en el código y asegurarnos de que, en el futuro, no las olvidaríamos. Hicimos esto con el reaccionan / prohibir elementos gobiernan desde eslint-plugin-reaccionar .


A continuación, se muestra un ejemplo de cómo se ve:



'react/forbid-elements': [
    'error',
    {
        forbid: [
            {
                element: 'img',
                message: 'Use "<Image>" instead. This is important for accessibility reasons.',
            },
        ],
    },
],






Además de agregar JavaScript y CSS, también tenemos nuestro propio "linter de sistema de archivos". Es él quien asegura un uso uniforme de la estructura de carpetas que hemos elegido. Dado que esta es una herramienta que creamos nosotros mismos, si decidimos cambiar a una estructura de carpetas diferente, siempre podemos cambiarla en consecuencia. A continuación, se muestran ejemplos de las reglas que controlamos cuando trabajamos con archivos y carpetas:



  • Comprobación de la estructura de carpetas de los componentes: asegurarse de que siempre haya un archivo index.tsy un .tsx.file con el mismo nombre que la carpeta.
  • Validación de archivos package.json: Asegurarse de que haya un archivo de este tipo por paquete y que la propiedad privateesté configurada truepara evitar la publicación accidental del paquete.


¿Qué tipo de sistema debería elegir?



Hoy en día, la respuesta a la pregunta del título de esta sección probablemente sea bastante sencilla para muchos. Solo necesita usar TypeScript . En algunos casos, independientemente del tamaño del proyecto, la implementación de TypeScript puede ralentizar el desarrollo. Pero creemos que este es un precio razonable a pagar por mejorar la calidad y el rigor del código.



Desafortunadamente, en el momento en que comenzamos a trabajar en el proyecto, el sistema de tipos de accesorios todavía se usaba mucho.... Al comienzo de nuestro trabajo, esto fue suficiente para nosotros, pero a medida que el proyecto creció, comenzamos a perder la capacidad de declarar tipos para entidades que no son componentes. Hemos visto que esto nos ayudará a mejorar, por ejemplo, reductores y selectores. Pero la introducción de un sistema de escritura diferente en el proyecto requeriría una refactorización importante del código necesario para escribir el código base completo.



Al final, todavía equipamos nuestro proyecto con soporte de tipos, pero cometimos el error de probar Flow primero.... Nos pareció que Flow era más fácil de integrar en el proyecto. Aunque ese era el caso, regularmente teníamos todo tipo de problemas con Flow. Este sistema no se integró muy bien con nuestro IDE, a veces por alguna razón desconocida no detectaba algunos errores, y crear tipos genéricos era una verdadera pesadilla. Por estas razones, terminamos migrando todo a TypeScript. Si supiéramos entonces lo que sabemos ahora, elegiríamos inmediatamente TypeScript.



Debido a la dirección en la que se ha desarrollado TypeScript en los últimos años, esta transición fue bastante fácil para nosotros. La transición de TSLint a ESLint fue especialmente útil para nosotros .



¿Cómo pruebo el código?



Cuando comenzamos a trabajar en el proyecto, no teníamos muy claro qué herramientas de prueba elegir. Si estuviera pensando en ello ahora, diría que, para las pruebas unitarias y de integración, es mejor usar jest y cypress, respectivamente . Estas herramientas están bien documentadas y son fáciles de usar. La única lástima es que cypress no es compatible con la API Fetch , lo malo es que la API de esta herramienta no está diseñada para usar la construcción async / await . Nosotros, después de comenzar a usar ciprés, no entendimos esto de inmediato. Pero me gustaría esperar que la situación mejore en un futuro próximo.



Al principio, fue difícil para nosotros encontrar la mejor manera de escribir pruebas unitarias. Con el tiempo, hemos probado enfoques como la prueba de instantáneas , el renderizador de prueba , el renderizador superficial . Probamos Testing Library . Terminamos con una representación poco profunda, utilizada para probar la salida del componente y usamos la representación de prueba para probar la lógica interna de los componentes.



Creemos que la biblioteca de pruebas es una buena solución para proyectos pequeños. Pero el hecho de que este sistema se base en la renderización DOM tiene un gran impacto en el rendimiento de las pruebas comparativas. Además, creemos que las críticasLas pruebas de instantáneas utilizando la representación de superficies son irrelevantes cuando se trata de componentes muy "profundos". Para nosotros, las instantáneas resultaron ser muy útiles para verificar todas las opciones posibles para generar componentes. Sin embargo, el código del componente no debe ser demasiado complicado; debe esforzarse para que sea conveniente de leer. Esto se puede toJSONlograr reduciendo el tamaño de los componentes y definiendo un método para las entradas de los componentes que no están relacionados con la instantánea.



Para no olvidarnos de las pruebas unitarias, configuramos el umbral de cobertura del código mediante pruebas... Con broma, esto es muy fácil de hacer y no hay mucho en qué pensar. Basta con establecer un indicador de la cobertura global del código mediante pruebas. Entonces, al comienzo del trabajo, establecimos esta cifra en 60%. Con el tiempo, a medida que crecía la cobertura de prueba de nuestro código base, la aumentamos al 80%. Estamos satisfechos con este indicador, ya que no creemos que sea necesario esforzarse por lograr una cobertura de código del 100% con pruebas. Alcanzar este nivel de cobertura de código con pruebas no nos parece realista.



¿Cómo simplificar la creación de nuevos proyectos?



Por lo general, el inicio de los trabajos en la aplicación Reaccionar es muy simple: ReactDOM.render(<App />, document.getElementById(‘#root’));. Pero en el caso de que necesite admitir SSR (Representación del lado del servidor), esta tarea se vuelve más complicada. Además, si las dependencias de su aplicación incluyen algo más que React, es posible que su código de cliente y servidor necesite usar parámetros diferentes. Por ejemplo, utilizamos react-intl para la internacionalización, react-redux para la gestión del estado global , react-router para el enrutamiento y redux-saga para la gestión de acciones asincrónicas . Estas dependencias necesitan algunos ajustes. El proceso de configuración de estas dependencias puede ser complejo.



Nuestra solución a este problema se basó en los patrones de diseño " Estrategia " y " Fábrica abstracta ". Solíamos crear dos clases diferentes (dos estrategias diferentes): una para la configuración del cliente y otra para la configuración del servidor. Ambas clases recibieron parámetros de la aplicación creada, que incluían el nombre, logo, reductores, rutas, el idioma predeterminado, sagas (para redux-saga), etc. Se pueden tomar reductores, rutas y sagas desde diferentes paquetes de nuestro mono-repositorio. Esta configuración se utiliza para crear la tienda redux, el middleware de sagas y el objeto de historial del enrutador. También se utiliza para cargar traducciones y renderizar la aplicación. Por ejemplo, aquí están las firmas de las estrategias de cliente y servidor:



type BootstrapConfiguration = {
  logo: string,
  name: string,
  reducers: ReducersMapObject,
  routes: Route[],
  sagas: Saga[],
};
class AbstractBootstrap {
  configuration: BootstrapConfiguration;
  intl: IntlShape;
  store: Store;
  rootSaga: Task;
abstract public run(): void;
  abstract public render<T>(): T;
  abstract protected createIntl(): IntlShape;
  abstract protected createRootSaga(): Task;
  abstract protected createStore(): Store;
}
//   
class WebBootstrap extends AbstractBootstrap {
  constructor(config: BootstrapConfiguration);
  public render<ReactNode>(): ReactNode;
}
//   
class ServerBootstrap extends AbstractBootstrap {
  constructor(config: BootstrapConfiguration);
  public render<string>(): string;
}


Encontramos útil esta separación de estrategias, ya que existen algunas diferencias en la configuración de almacenamiento, sagas, objetos de internacionalización e historial, dependiendo del entorno en el que se ejecuta el código. Por ejemplo, se crea una tienda redux en el cliente usando datos precargados desde el servidor y usando la extensión redux-devtools- . Nada de esto es necesario en el servidor. Otro ejemplo es un objeto de internacionalización que, en el cliente, obtiene el idioma actual de navigator.languages y en el servidor del encabezado HTTP Accept-Language .



Es importante señalar que tomamos esta decisión hace mucho tiempo. Si bien las clases todavía se usaban ampliamente en las aplicaciones de React, no existían herramientas simples para hacer renderizado de aplicaciones del lado del servidor. Con el tiempo, la biblioteca Reaccionar dio un paso hacia un estilo funcional y proyectos como Next.js apareció . Teniendo esto en cuenta, si está buscando una solución a un problema similar, le recomendamos que investigue las tecnologías actuales. Esto, muy posiblemente, nos permitirá encontrar algo que será más simple y más funcional que lo que estamos usando.



¿Cómo mantener la calidad de su código en un nivel alto?



Linters, pruebas, verificación de tipos: todo esto tiene un efecto beneficioso sobre la calidad del código. Pero un programador puede olvidarse fácilmente de ejecutar las comprobaciones adecuadas antes de incluir código en una rama master. Lo mejor que puede hacer es que dichos controles se ejecuten automáticamente. Algunas personas prefieren hacer esto en cada confirmación usando ganchos de Git., que no le permite comprometerse hasta que el código haya pasado todas las comprobaciones. Pero creemos que con este enfoque, el sistema interfiere demasiado con el trabajo del programador. Después de todo, por ejemplo, el trabajo en una determinada rama puede llevar varios días, y todos estos días no se reconocerá como adecuado para enviar al repositorio. Por lo tanto, verificamos las confirmaciones utilizando el sistema de integración continua. Solo se comprueba el código de las ramas que están asociadas con las solicitudes de combinación. Esto nos permite evitar la ejecución de comprobaciones que están garantizadas para fallar, ya que la mayoría de las veces solicitamos incluir los resultados de nuestro trabajo en el código principal del proyecto cuando estamos seguros de que estos resultados pueden pasar todas las comprobaciones.



El flujo de validación automática de código comienza con la instalación de dependencias. A esto le sigue la verificación de tipos, la ejecución de linters, la ejecución de pruebas unitarias, la creación de una aplicación y la ejecución de pruebas de cypress. Casi todas estas tareas se realizan en paralelo. Si ocurre un error en cualquiera de estos pasos, todo el proceso de pago fallará y la rama correspondiente no se puede incluir en el código principal del proyecto. A continuación se muestra un ejemplo de un sistema de revisión de código que funciona.





Verificación automática de código La



principal dificultad que encontramos al configurar este sistema fue acelerar la ejecución de las verificaciones. Esta tarea sigue siendo relevante. Realizamos muchas optimizaciones y ahora todas estas comprobaciones son estables en unos 20 minutos. Quizás este indicador se pueda mejorar paralelizando la ejecución de algunas pruebas de cipreses, pero por ahora nos conviene.



Salir



Organizar el desarrollo de una aplicación React a gran escala no es una tarea fácil. Para solucionarlo, un programador necesita tomar muchas decisiones, es necesario configurar muchas herramientas. Al mismo tiempo, no existe una única respuesta correcta a la pregunta de cómo desarrollar dichas aplicaciones.



Nuestro sistema nos conviene hasta ahora. Esperamos que contarlo ayude a otros programadores que se enfrentan a las mismas tareas que enfrentamos nosotros. Si decide seguir nuestro ejemplo, primero asegúrese de que lo que se discutió aquí sea adecuado para usted y su empresa. Lo más importante es luchar por el minimalismo. No complique demasiado las aplicaciones y los kits de herramientas utilizados para crearlos.



¿Cómo abordaría la tarea de organizar el desarrollo de un proyecto React a gran escala?






All Articles