Cómo escribir componentes buenos, compostables y puros en Angular 2+

La mayoría de nosotros sabemos qué son los componentes inteligentes y tontos. Sabemos que debemos usar @Input () y @Output () tanto como sea posible. Pero cuando nuestro SPA crece lo suficiente, comienza a recordarnos cada vez más un espagueti típico y parece que ni siquiera podemos evitarlo.

La razón es que muy a menudo sabemos cuáles son los patrones buenos y malos en el desarrollo del código, pero, especialmente en el front-end, a menudo nos confundimos con lo que debemos y no debemos hacer. Los patrones que debemos seguir comienzan a volverse borrosos y terminamos usando atajos en nuestro código con más frecuencia de lo que no lo hacemos.

Uno de esos patrones es dividir sus componentes en "Smart" y "Dumb". Dice que debemos mantener toda la lógica de negocios y los efectos secundarios en los componentes inteligentes mientras hacemos que todos los demás componentes sean lo más "tontos" posible.

Esto funciona bien, pero a menos que apliquemos algunas reglas estrictas en nuestro proyecto, lo olvidaremos. Porque cuando el patrón no es una regla y es solo "una idea general" que tenemos en cuenta, eventualmente terminaremos omitiéndolo cada vez más.

Por ejemplo, comenzamos a tener nuestros componentes tontos, pero cuando nos sentimos hinchados con la necesidad de pasar y manejar todas las entradas y salidas en todas partes, nos volvemos perezosos y solo mutamos los datos de las entradas directamente.
(Esto hará que sea difícil adivinar qué componente está cambiando qué datos y cuándo).

En otras ocasiones, queremos "optimizar" el rendimiento de nuestros componentes, y en lugar de depender de las entradas y salidas, simplemente tomamos el "enfoque ng1", lo que significa que creamos un servicio de controlador único que cada componente se inyectará a sí mismo siempre que sea necesario. necesita cualquier cosa
(Esto dificultará razonar sobre qué componentes reciben qué datos).

Algunos casos más de tomar atajos como ese y comenzamos a notar que, extrañamente, el mecanismo de detección de cambios de Angular no aplica cambios en nuestros componentes (cuando normalmente debería hacerlo). Entonces agregamos algunas líneas this.cdRef.markForCheck () aquí y allá para que funcione.

Avancemos unos meses y no solo terminamos teniendo líneas aleatorias markForCheck () e inyecciones de varios servicios de singleton en todas partes, sino que tampoco sabemos qué componente depende de qué y qué datos se cambian y dónde. .

Llegamos a un punto en el que nuestra aplicación tiene cientos de componentes y decenas de múltiples servicios singleton, y todos dependen unos de otros. Nos damos cuenta de que ya no sabemos qué depende de qué. Terminamos con un laberinto insoluble de código front-end.

El proyecto se hincha tanto con dependencias directas y no directas entre nuestros componentes, que nos perdemos , molestos , hasta que finalmente nos damos por vencidos y simplemente volvemos a codificar backends / jQuery / ”cualquier cosa que fuera más fácil que la interfaz en 2018 " de nuevo…

‍ No es así como debería ser.

Si bien es cierto que el framework debería ayudarnos a escribir nuestra aplicación para que sea fácilmente escalable, la verdad es que el framework siempre había sido un andamiaje básico para nuestro código. Nunca podemos confiar solo en el marco para mantener nuestro código controlado por nosotros. Necesitamos hacerlo nosotros mismos.

Solución

Pensemos nuevamente en esta división de componentes "inteligente" y "tonta". La razón por la que nos perdemos en nuestra aplicación no es porque este patrón sea incorrecto, sino porque muy a menudo lo hacemos de manera imperfecta.

Mi plan de ingeniería para definir este patrón es el siguiente:

  1. Divide los componentes en inteligentes y tontos.
  2. Mantenga los componentes lo más tontos posible.
  3. Decide cuándo un componente debe ser inteligente en lugar de tonto.

Describamos cada uno de esos puntos uno por uno.

PD En caso de que prefiera navegar por las diapositivas que una publicación de blog, puede consultar las diapositivas de mi presentación que di en una reunión de ng-poznan con el mismo título.

1. Divide los componentes en inteligentes y tontos

Primero, definamos qué son realmente los componentes inteligentes y tontos.

  • Un componente tonto es un componente que funciona como una función pura.
    (Una función pura es una función que para argumentos de función dados, siempre producirá el mismo valor de retorno).
    Un componente tonto es así. Es un componente que para los datos recibidos (entradas), siempre se verá y se comportará igual, posiblemente también produciendo otros datos (eventos, a través de salidas).
Componente tonto
  • Un componente inteligente es un componente que se parece más a una función impura.
    (Una función impura es una función que toca "el mundo exterior": ya sea obteniendo datos de servicios externos o produciendo efectos secundarios).
    Un componente inteligente es así. No solo depende de sus entradas, sino también de algún tipo de datos externos ("el mundo exterior"), que no se pasan directamente a través de @Input (). También puede producir algunos efectos secundarios que no se emiten a través de la interfaz @Output ().
    Por ejemplo, un componente que obtiene datos de usuario actuales de un servicio singleton instanciado en otro lugar; desde una API externa; o de LocalStorage. Un componente que cambia el estado de un servicio externo; emite una llamada a la API; o cambia los datos almacenados en LocalStorage.
Componente inteligente

El componente tonto a veces también se llama "Puro", "Presentacional". El componente inteligente a veces se llama "Impuro", "Conectado", "Contenedor". En internet aparecen diferentes definiciones, según el autor que las describa o el marco en el que se centra, pero el concepto completo de sumergirse de esa manera suele ser similar.

Tenga en cuenta: ¡Smart vs Dumb no es Stateful vs Stateless!
La gente a menudo confunde esos términos, pero para mí, no tienen ninguna relación entre ellos. Ver:

  • un componente tonto no tiene dependencias externas y no causa efectos secundarios (pero aún puede o no tener un estado local).
  • un componente inteligente tiene dependencias externas o causa efectos secundarios (pero aún puede o no tener un estado local).
  • un componente sin estado no tiene un estado local (pero aún puede causar efectos secundarios).
  • un componente con estado tiene un estado local (pero no necesita tener dependencias ni causar efectos secundarios).

Puse un dibujo para ilustrarlo más claramente, cómo se pueden unir esas dos divisiones:

Matriz inteligente / tonta x con estado / sin estado

Lo interesante es que únicamente en esos dibujos ya podemos sacar algunas conclusiones serias:

  • El comportamiento del componente Dumb-Stateless es el más fácil de predecir y comprender.
  • El componente Dumb-Stateful también parece ser simple, porque aunque tiene algún estado local, es transparente para los demás.
    Desde la perspectiva de los demás, todo lo que hace este componente sigue siendo solo recibir entradas y emitir salidas.
  • Los componentes inteligentes parecen ser los más difíciles de comprender, porque además de tener entradas y salidas, también están conectados de alguna manera con el mundo exterior al obtener datos de él y / o causar efectos secundarios.

Ahora, mi punto principal de toda esta publicación de blog es este: los componentes inteligentes son el peor mal. Porque además de tener entradas y salidas claramente definidas, también requieren algún tipo de dependencias externas y producen algún tipo de efectos secundarios. Desafortunadamente, tales cosas siempre son difíciles de controlar en la programación.

2. Mantenga los componentes lo más tontos posible

Por eso, digo que debemos mantener tontos a la mayoría de nuestros componentes. ¡Por tonto, quiero decir realmente tonto! Quiero decir, un buen componente tonto:

  • no debe depender de servicios externos: si requiere algunos datos para funcionar, debe inyectarse a través de @Input ();
  • no debería producir ningún efecto secundario; si necesita emitir algo, debería emitirse con @Output () en su lugar;
  • no debe silenciar sus entradas, porque si lo hace, en realidad produce un efecto secundario que provoca un cambio en los datos del componente principal.
    Un niño nunca debe editar directamente los datos de los padres. Si necesita informar al padre que algo ha cambiado, él debe emitirlo como un evento, que el padre debe recoger y luego actuar adecuadamente sobre él.

Ejemplo de código:

3. Decide cuándo un componente debe ser inteligente en lugar de tonto

Este punto es difícil. Me hizo reescribir toda la publicación un par de veces;) La razón es porque la clara distinción de lo que debería ser inteligente y lo que no debería ser no es tan simple. Pero como odio dejar las decisiones de la informática al "gusto personal" del desarrollador, finalmente llegué a cuatro conclusiones:

3a. Si puede ser tonto, hazlo tonto

Piensa en cuál es el rol del componente.

¿Es fácil predecir y describir el papel en papel? En caso afirmativo, eso significa que no necesitamos hacerlo inteligente. Las entradas y salidas deberían ser fáciles de controlar su comportamiento.

Ejemplos:

  • un componente de control de formulario que recibe el valor actual y emite nuevos valores,
  • un componente de formulario que recibe el valor inicial del formulario y emite los nuevos valores del formulario.

Básicamente, si no hay ninguna razón para no hacer que el componente sea tonto, simplemente hazlo tonto.

3b. Si varios niños son igualmente inteligentes, hazlos tontos

Por ejemplo, si tiene una vista de búsqueda con múltiples filtros diferentes que se conectan al estado de la página de búsqueda, pero todos tienen el mismo rol: mostrar el valor del filtro actual y posiblemente cambiarlo, entonces ¿por qué repetir la misma lógica inteligente en todos ¿de ellos?

En cambio, podríamos tener un FiltersListComponent inteligente que se conecta al estado de la página de búsqueda y pasa los valores del filtro a los componentes del filtro Dumb debajo de él.

Luego, en lugar de tener diez componentes inteligentes, terminamos teniendo uno inteligente y diez tontos.

3c. Lo que no puede ser tonto, hazlo inteligente

Eventualmente llegaremos a un punto en el que no podremos mantener tontos a todos nuestros componentes. Al menos uno de ellos debe ser inteligente. Necesitamos mantener el estado de nuestra aplicación en algún lugar; necesitamos llamar a las API desde algún lugar, ¿verdad?

En la mayoría de los casos, la mejor opción es colocarlo en los componentes de la vista superior.

Tenga en cuenta que eso no significa que debamos poner la lógica directamente en el código del componente. Puede estar en un servicio separado como SearchPageControllerService. O puede estar en un Redux acciones, estado y reductor, si estamos usando una estructura de tipo redux.

Lo único que importa es que este componente inteligente será, de hecho, el único que tendrá acceso a esta dependencia externa y será el único que le emitirá eventos.

Todos los hijos de este componente inteligente serán tontos y responderán al mundo solo con sus entradas y salidas.

Esto hará que sea más fácil razonar sobre quién está cambiando qué y cuándo en su opinión y qué depende de qué.

Por ejemplo, en una página de búsqueda típica, solo un componente de SearchPage debe ser inteligente. Todos los demás componentes en su vista, como SearchPageResultsComponent, SearchPageFiltersComponent, SearchPageResultsItemComponent, etc., deben ser tontos.

Ejemplo de página de búsqueda

3d. Si el Smart se hace demasiado grande, divídalo en Smarts separados.

"Hacer que el componente de vista superior sea inteligente" es una regla muy buena, pero en algunas de las vistas, puede que no sea suficiente.

Por ejemplo, una página de bandeja de entrada principal de Gmail tiene las siguientes características:

  • enumerar y administrar hilos de correo electrónico recientes
  • enumerar y administrar carpetas disponibles
  • mostrar personas en línea de Hangout
  • permitir escribir rápidamente un nuevo mensaje

Si solo tuviéramos un componente inteligente en la página de Gmail, tendría que asumir todas sus responsabilidades. Bastante. De todos modos, hay muchas posibilidades de que termine siendo una gran parte de código muy complejo.

Por lo tanto, podríamos dividirlo en unos pocos componentes inteligentes que tienen sus propias responsabilidades:

  • ThreadsListView
  • FoldersListView
  • HangoutPeopleListView
  • NewMessageModalView

Cada una de esas vistas solo tendría niños tontos debajo. Entonces, aún será manejable.

Preguntas frecuentes

1. ¿Cómo y cuándo se me permite inyectar servicios / dependencias externas? ¿Puedo depender de ellos y cómo me comunico con ellos?

En general, nunca se le permite comunicarse con el mundo exterior a menos que sea un componente inteligente.

Como componente inteligente, usted es el intermediario entre las API y la lógica de negocios de su aplicación y los otros componentes tontos.

Solo un Componente inteligente debería poder comunicarse con API externas llamando a sus funciones y suscribiéndose a sus valores de retorno / promesas / observables.

Cada vez que algo nuevo proviene de la API, actualiza el estado de su Componente inteligente, lo que hace que el cambio se propague a sus hijos "tontos".

Lo mismo ocurre con la otra dirección. Cada vez que su hijo tonto quiera "cambiar" algo, p. quiere actualizar los resultados de búsqueda, solo emite un evento. El Componente inteligente lo recoge, vuelve a llamar a la API externa y solo entonces propaga el cambio a sus "hijos tontos" actualizando sus entradas.

En la práctica, significa que solo sus componentes inteligentes ubicados en la parte superior de su árbol de componentes se comunicarán con los servicios externos. Todos los demás ubicados en el fondo del árbol terminarán siendo tontos y se comunicarán con los demás solo a través de entradas y salidas.

Sin embargo, todavía puede haber casos en los que valga la pena conectar su Componente tonto con el mundo exterior directamente. Por ejemplo:

  • Al hacer clic en un botón en su ProductItemComponent, puede navegar instantáneamente la aplicación a una URL diferente, sin la necesidad de emitir y capturar el evento @Output () productItemClick.
  • Al representar su DateTimePickerComponent, puede obtener la zona horaria del usuario actual directamente de alguna variable global.
  • Al enumerar posibles usuarios en su UserSelectComponent, puede obtener la lista de todos los usuarios en línea directamente de un servicio API disponible en todo el mundo en su aplicación.

Esos tres ejemplos anteriores son aceptables para mí, porque incluso si tienen una conexión con "el mundo exterior", es solo obteniendo datos disponibles en todo el mundo y rara vez se cambian en su aplicación. Pero recuerda que todavía está rompiendo las reglas. Si surge una situación en la que queremos tener un comportamiento diferente en ProductItemComponent # productItemClick, queremos usar una zona horaria diferente para una instancia específica de DateTimePickerComponent, o usar una selección diferente de usuarios en UserSelectComponent, entonces deberíamos recurrir a IMO uso de la interfaz @Input () y @Output ().

2. ¿Cómo aplico un cambio a mis padres? Básicamente, ¿cómo manejo mi aplicación para cambiar algo?

Emite un evento y el padre lo capta y maneja el cambio internamente. En efecto, las entradas y salidas de los componentes forman una especie de ciclo:

  1. El niño B emite un evento @Output ();
  2. El padre A captura el evento @Output () y lo maneja ya sea por:
    a) despachar el evento hacia arriba, emitiendo otro @Output () o
    b) manejar el cambio internamente, actualizando su estado local (y opcionalmente diciéndole a Angular que vuelva a verificar sus enlaces propios y de los niños mediante ChangeDetectorRef # detectChanges ());
  3. El Niño B recibe los nuevos datos a través de @Input (), se llama a su devolución de llamada 'ngOnChanges () y su vista' y Angular vuelve a verificar automáticamente los enlaces de los niños.

PD Lea más sobre el mecanismo de Detección de cambio angular en mi artículo anterior: Dominando el rendimiento angular, parte 1 - dejando caer la magia del Detector de cambio).

3. ¿Puedo mutar mis entradas alguna vez?

No. La mutación de su entrada es una violación de una función pura: en realidad produce un efecto secundario que hace que los datos en su componente principal cambien sin pedirle a nadie que lo haga.

Si desea que se cambie su componente primario, simplemente debe emitir un evento con @Output () en su lugar y manejar el cambio en el componente primario.

4. Necesito obtener el clima actual de la ciudad en mi CityListItemComponent. ¿Puedo inyectar mi pequeño WeatherService en él y usarlo directamente como this.cityWeather = WeatherService.getWeatherForCity (this.city)?

No, creo que no deberías hacerlo. Un CityListItemComponent suena como un componente que está anidado profundamente y que debería ser tonto. Si lo hace dependiente de un servicio externo como WeatherService, ya no será tonto. Será más difícil predecir su comportamiento, porque una función impura siempre es más difícil de comprender que una pura.

En cambio, podría obtener, por ejemplo, el clima actual para todas las ciudades del componente que realmente obtiene las ciudades y luego pasarlo hacia abajo utilizando @Input ().

5. Necesito actualizar el clima actual para una ciudad determinada con un botón de actualización ubicado en mi CityListItemComponent. ¿Puedo inyectar mi pequeño WeatherService allí y llamar directamente a WeatherService.refreshWeatherForCity (this.city)?

No, creo que no deberías hacerlo. Si CityListItemComponent es un componente tonto, no debería producir ningún efecto secundario.

En su lugar, emita un evento @Output () weatherRefresh y adminístrelo en el ancestro inteligente que realmente maneja los datos del clima para toda la lista.
Esto se debe a que desea mantener simple el código de sus componentes. Dumb CityListItemComponent solo debe preocuparse por mostrar los detalles de la ciudad y emitir eventos de IU. No debería tener ninguna lógica para buscar el clima de la ciudad actual en alguna API.

Toda esta separación se trata de tener sus componentes SÓLIDOS. La cuestión de obtener y actualizar el clima para las ciudades en la página de la lista de ciudades debe estar en un solo lugar. Presumiblemente, el código de la lista o de la página de la lista (o su estado y acciones redux).

Pros y contras de seguir la división Smart / Dumb

Pros

  1. Puede predecir fácilmente el comportamiento del componente tonto.
    Es tan simple como su interfaz pública de entradas y salidas.
    (Que, por cierto, junto con TypeDefs typedefs, sirve como una impresionante documentación de su código).
  2. Puede probar fácilmente el comportamiento del componente tonto.
    Probar un componente tonto es tan simple como:
    1. Definir valores de entrada
    2. Instanciar componente
    3. Actúe sobre el componente (por ejemplo, haga clic en él)
    4. Afirma que se ha emitido un `@Output ()` específico.
    La prueba de un componente inteligente generalmente requiere mucho más que eso: eliminar dependencias externas, verificar los efectos secundarios, etc.
  3. Puede cambiar (bastante) fácilmente el comportamiento del componente tonto sin romper las cosas.
    Cada vez que cambie un componente tonto:
    - Asegúrese de que la interfaz anterior sigue funcionando (o busque y reemplace los usos antiguos de este componente, lo que puede hacer fácilmente, gracias a TypeScript)
    - El comportamiento principal de Component todavía funciona según lo previsto.
    No necesita asegurarse de que las dependencias externas rompan este componente, o si produce otros efectos secundarios que antes. Nunca lo hizo y nunca lo hará.
  4. La lógica principal de su aplicación está controlada solo por sus componentes inteligentes.
    Ya no necesita leer todo el repositorio de código, solo para ver quién obtiene qué y dónde se cambia qué y dónde.
    Ahora puede ver la mayor parte principalmente mirando la plantilla HTML de sus componentes inteligentes.
  5. Es más eficiente.
    Debido a que ahora sabe exactamente qué depende de qué, ya no necesita el NgZone ni el mecanismo mágico de detección de cambios que verifica los cambios de todo en todas partes. Puede omitir NgZone y usar ChangeDetectionStrategy.OnPush en todos sus componentes tontos.
  6. Te ayuda a evitar errores.
    Menos acoplamiento de su código;
    Dividiéndolo en trozos más pequeños, más sólidos y puros;
    Evitar los efectos secundarios
    Usar entradas y salidas escritas para transferir datos a través de la mayor parte de su aplicación;
    - todo eso disminuye la complejidad de su código y al mismo tiempo disminuye la posibilidad de que ocurran errores en su código.
  7. Bonificación: puede omitir usando ChangeDetectorRef # markForCheck () por completo.
    Simplemente no hay necesidad de usarlo en absoluto.
    No necesitamos informarle a Angular que se ha cambiado algo en el componente primario, porque como componente secundario, ya no lo estamos cambiando directamente.
    (Si lo hacemos, lo hacemos a través de `@Output ()` y es el padre el que maneja el cambio en sí mismo).

Contras

  1. No puede inyectar dependencias donde quiera.
    Siempre debe pensar con anticipación qué datos necesitarán sus componentes y cómo se los transmitirán.
  2. No puede mutar los datos pasados ​​a través de Entrada / Salida.
    En cambio, utiliza un estado local o deja el mantenimiento de datos a los padres inteligentes.

TL; DR:

  • Divide claramente tus componentes en componentes inteligentes y tontos.
  • Implemente bien sus componentes Dumb.
    Nunca mute los valores de @Input ().
    Evite depender de servicios externos: use @Input () en su lugar.
    Evite causar efectos secundarios: use @Output () en su lugar.
  • Evita usar atajos hacky en tu código. En la perspectiva a largo plazo, en realidad te obligan a pasar más tiempo en tu código y no son atajos en absoluto. También hacen que toda la aplicación sea más difícil de entender, cambiar y ampliar.

Hace unos meses, comenzamos a aplicar todas esas reglas en nuestra enorme aplicación Angular de recluta de una sola página y ya estamos viendo grandes ventajas.

¿Qué opinas de todas estas reglas? ¿Hace cumplir otras similares en sus equipos y proyectos? Tal vez no estás de acuerdo con algo? Háganos saber en los comentarios.

Además, siéntase libre de ver mi presentación, que recientemente di sobre el mismo tema. Tal vez las diapositivas funcionen mejor para usted que una publicación de blog.

-

PD ¡Contratación de reclutas! Si está buscando un trabajo en Angular + TypeScript o Elixir + Phoenix Framework, preferiblemente en Poznań, Polonia, consulte nuestras oportunidades profesionales. Podríamos ser la pareja perfecta para ti.