Modern Angular es un marco poderoso con muchas características, junto con el cual a primera vista surgen conceptos y mecanismos complejos. Esto es especialmente notable para aquellos que acaban de comenzar a trabajar tanto en el front-end en principio, como con Angular en particular.
También enfrenté el mismo problema cuando llegué a Tinkoff al puesto de Desarrollador Frontend Junior hace aproximadamente dos años y me sumergí en el mundo de Angular. Por lo tanto, les ofrezco una breve historia sobre cinco cosas, cuya comprensión facilitaría en gran medida mi trabajo al principio.

Inyección de dependencia (DI)
Al principio entré en el componente y vi que había algunos argumentos en el constructor de la clase. Hice un pequeño análisis del trabajo de los métodos de clase, y quedó claro que se trata de algunas dependencias externas. ¿Pero cómo entraron en clase? ¿Dónde se llamaba el constructor?
Sugiero inmediatamente entender un ejemplo, pero para esto necesitamos una clase. Si en JavaScript "normal" OOP está presente con ciertos "hacks", entonces junto con ES6 hay una sintaxis "real". Angular usa TypeScript de inmediato, en el que la sintaxis es casi la misma. Por lo tanto, propongo usarlo más.
Imagine que hay una clase JokerService
en nuestra aplicación que gestiona chistes. El método getJokes()
devuelve una lista de chistes. Supongamos que lo usamos en tres lugares. ¿Cómo hacer bromas en tres lugares diferentes del código? Hay varias formas:
- Crea una instancia de la clase en cada lugar. Pero, ¿por qué necesitamos obstruir la memoria y crear tantos servicios idénticos? ¿Y si hay 100 asientos?
- Haga que el método sea estático y recupere datos utilizando JokerService.getJokes ().
- Implemente uno de los patrones de diseño. Si necesitamos que el servicio sea uno para toda la aplicación, entonces será Singleton. Pero para esto necesitas escribir una nueva lógica en la clase.
Entonces, tenemos tres opciones bastante funcionales. El primero no nos conviene, en este caso es ineficaz. No queremos crear copias adicionales, ya que serán completamente idénticas. Quedan dos opciones.
Vamos a complicar la tarea para comprender qué método nos conviene más. Supongamos que, en tercer lugar, necesitamos por alguna razón crear nuestro propio servicio con ciertos parámetros. Este puede ser un autor específico, la duración de la broma, el idioma y más. ¿Qué haremos entonces?
En el caso del método estático, deberá pasar la configuración con cada llamada, ya que la clase es común a todos los lugares. Es decir, en cada llamada a getJokes()
pasaremos todos los parámetros únicos de este lugar. Por supuesto, es mejor pasarlos cuando getJokes()
instancia y luego simplemente llamar al método getJokes()
.
Resulta que la segunda opción tampoco nos conviene: nos hará duplicar mucho código en cada lugar. Solo queda Singleton, que nuevamente necesitará actualizar la lógica, pero con variaciones. Pero, ¿cómo entender qué opción necesitamos?
Si creía que simplemente podía crear un objeto y usar la clave para tomar el servicio deseado, puedo felicitarlo: acaba de darse cuenta de cómo funciona la inyección de dependencia en general. Pero vamos un poco más profundo.
Para asegurarse de que se necesita un mecanismo para ayudarnos a obtener las instancias necesarias, imagine que JokerService necesita otros dos servicios más, uno de los cuales es opcional, y el segundo debería dar un resultado especial en un lugar determinado. No es dificil.
Inyección de dependencia en angular
Como dice la documentación , DI es un patrón de diseño importante para una aplicación. Angular tiene su propio marco de dependencia, que se utiliza en Angular para aumentar la eficiencia y la modularidad.
En términos generales, la inyección de dependencias es un mecanismo poderoso en el que una clase recibe las dependencias necesarias de algún lugar externo, en lugar de crear instancias por sí misma.
Deje que la sintaxis y los archivos con la extensión html
no lo confundan. Cada componente en Angular es un objeto JavaScript normal, una instancia de una clase. En términos generales: cuando inserta un componente en una plantilla, se crea una instancia de la clase de componente. En consecuencia, en este momento, puede pasar las dependencias necesarias al constructor. Ahora considere un ejemplo:
@Component({ selector: 'jokes', template: './jokes.template.html', }) export class JokesComponent { private jokes: Observable<IJoke[]>; constructor(private jokerService: JokerService) { this.jokes = this.jokerService.getJokes(); } }
En el constructor de componentes, simplemente indicamos que necesitamos un JokerService
. No lo creamos nosotros mismos. Si hay cinco componentes más que lo usan, todos se referirán a la misma instancia. Todo esto nos permite ahorrar tiempo, eliminar repeticiones y escribir aplicaciones muy productivas.
Proveedores
Y ahora propongo tratar el caso cuando necesite obtener diferentes instancias del servicio. Primero, eche un vistazo al servicio en sí:
@Injectable({ providedIn: 'root', // , «» }) export class JokerService { getJokes(): Observable<IJoke[]> { // } }
Cuando el servicio es uno para toda la aplicación, esta opción será suficiente. ¿Pero qué JokerService
si tenemos, digamos, dos implementaciones de JokerService
? ¿O es solo por alguna razón que un componente específico necesita su propia instancia de servicio? La respuesta es simple: provider
.
Por conveniencia, llamaré a provider
proveedor , y se comprobará el proceso de sustitución de un valor en una clase. Por lo tanto, podemos proporcionar servicio de diferentes maneras y en diferentes lugares. Comencemos con el último. Hay tres opciones disponibles:
- En toda la aplicación, especifique
provideIn: 'root'
en el decorador de servicios. - En el módulo, especifique el proveedor en el decorador de servicios como
provideIn: JokesModule
o en el decorador del módulo @NgModule providers: [JokerService]
. - En el componente: especifique el proveedor en el decorador del componente, como en el módulo.
El lugar se elige según sus necesidades. Descubrimos el lugar, pasemos al mecanismo en sí. Si simplemente especificamos provideIn: root
en el servicio, esto sería equivalente a la siguiente entrada en el módulo:
@NgModule({ // ... providers: [{provide: JokerService, useClass: JokerService}], }) //
Esto se puede leer de la siguiente manera: "Si JokerService
solicita un JokerService
, proporcione una instancia de la clase JokerService»
Desde aquí puede obtener una instancia específica de varias maneras:
Por token: debe especificar un InjectionToken
y obtener un servicio en él. Tenga en cuenta que en los ejemplos a continuación en provide
puede pasar el mismo token:
const JOKER_SERVICE_TOKEN = new InjectionToken<string>('JokerService'); // ... [{provide: JOKER_SERVICE_TOKEN, useClass: JokerService}];
Por clase: puede reemplazar la clase. Por ejemplo, pediremos JokerService
y daremos - JokerHappyService
:
[{provide: JokerService, useClass: JokerHappyService}];
Por valor: puede devolver inmediatamente la instancia deseada:
[{provide: JokerService, useValue: jokerService}];
Por fábrica: puede reemplazar la clase con una fábrica que creará la instancia deseada cuando se acceda a ella:
[{provide: JokerService, useFactory: jokerServiceFactory}];
Eso es todo Es decir, para resolver el ejemplo con una instancia especial, puede usar cualquiera de los métodos anteriores. Elija el más adecuado para sus necesidades.
Por cierto, DI funciona no solo para los servicios, sino en general para cualquier entidad que obtenga en el constructor de componentes. Este es un mecanismo muy poderoso que debe utilizarse en todo su potencial.
Un pequeño resumen
Para una comprensión completa, propongo considerar el mecanismo simplificado de Inyección de dependencias en Angular en pasos utilizando el ejemplo de servicio:
- Al inicializar la aplicación, el servicio tiene un token. Si no lo especificamos específicamente en el proveedor, entonces este es JokerService.
- Cuando se solicita un servicio en un componente, el mecanismo DI verifica si existe el token transferido.
- Si el token no existe, DI lanzará un error. En nuestro caso, el token existe y el JokerService se encuentra en él.
- Cuando se crea el componente, se pasa una instancia de JokerService al constructor como argumento.
Detección de cambio
A menudo escuchamos, como argumento para usar marcos, algo así como “El marco hará todo por usted, más rápido y más eficientemente. No necesitas pensar en nada. Solo administra los datos. Quizás esto sea cierto con una aplicación muy simple. Pero si tiene que trabajar con la entrada del usuario y operar constantemente con datos, solo necesita saber cómo funciona el proceso de detección de cambios y representación.
En Angular, la detección de cambios es responsable de verificar los cambios. Como resultado de varias operaciones: cambiar el valor de una propiedad de clase, completar una operación asincrónica, responder a una solicitud HTTP, etc., el proceso de verificación comienza en todo el árbol de componentes.
Dado que el objetivo principal del proceso es comprender cómo volver a representar un componente, la esencia es verificar los datos utilizados en las plantillas. Si son diferentes, la plantilla se marca como "cambiada" y se volverá a dibujar.
Zone.js
Comprender cómo Angular realiza un seguimiento de las propiedades de clase y las operaciones sincrónicas es bastante simple. Pero, ¿cómo rastrea asíncrono? La biblioteca Zone.js, creada por uno de los desarrolladores de Angular, es responsable de esto.
Aquí está lo que es. Una zona en sí misma es un "contexto de ejecución", para decirlo sin rodeos, el lugar y el estado en el que se ejecuta el código. Una vez que se completa la operación asincrónica, la función de devolución de llamada se ejecuta en la misma zona donde se registró. Entonces Angular descubre dónde ocurrió el cambio y qué verificar.
Zone.js reemplaza con sus implementaciones casi todas las funciones y métodos asincrónicos nativos. Por lo tanto, puede rastrear cuándo se llamará la callback
una función asincrónica. Es decir, Zone le dice a Angular cuándo y dónde comenzar el proceso de validación de cambio.
Estrategias de detección de cambios
Descubrimos cómo Angular monitorea un componente y ejecuta la verificación de cambios. Ahora imagine que tiene una gran aplicación con docenas de componentes. Y por cada clic, cada operación asincrónica, cada solicitud ejecutada con éxito, se inicia una verificación en todo el árbol de componentes. Lo más probable es que dicha aplicación tenga serios problemas de rendimiento.
Los desarrolladores angulares pensaron en esto y nos dieron la oportunidad de establecer una estrategia de detección de cambios, cuya elección correcta puede aumentar significativamente la productividad.
Hay dos opciones para elegir:
- Predeterminado: como su nombre indica, esta es la estrategia predeterminada cuando se inicia un CD para cada acción.
- OnPush es una estrategia en la que se lanza un CD en solo unos pocos casos:
- si el valor de
@Input()
ha cambiado; - si ha ocurrido un evento dentro del componente o sus descendientes;
- si la verificación se inició manualmente;
- si llega un nuevo evento a Async Pipe.
Basado en mi propia experiencia de desarrollo en Angular, así como en la experiencia de mis colegas, puedo decir con certeza que siempre es mejor especificar la estrategia OnPush
, a menos que el default
realmente necesario. Esto le dará varias ventajas:
- Una comprensión clara de cómo funciona el proceso de CD.
- Trabajo
@Input()
con las propiedades @Input()
. - Ganancia de rendimiento.
Al igual que otros marcos populares, Angular utiliza un flujo de datos aguas abajo. El componente acepta parámetros de entrada que están marcados con el decorador @Input()
. Considere un ejemplo:
interface IJoke { author: string; text: string; } @Component({ selector: 'joke', template: './joke.template.html', }) export class JokeComponent { @Input() joke: IJoke; }
Supongamos que hay un componente descrito anteriormente que muestra el texto de la broma y el autor. El problema con esta escritura es que puede mutar accidental o específicamente el objeto transferido. Por ejemplo, sobrescribir texto o autor.
setAuthorNameOnly() { const name = this.joke.author.split(' ')[0]; this.joke.author = name; }
Noto de inmediato que este es un mal ejemplo, pero muestra claramente lo que podría suceder. Para protegerse contra tales errores, debe hacer que los parámetros de entrada sean de solo lectura. Gracias a esto, comprenderá cómo trabajar correctamente con los datos y crear un CD. Basado en esto, la mejor manera de escribir una clase se verá así:
@Component({ selector: 'joke', template: './joke.template.html', changeDetection: ChangeDetectionStrategy.OnPush, }) export class JokeComponent { @Input() readonly joke: IJoke; @Output() updateName = new EventEmitter<string>(); setAuthorNameOnly() { const name = this.joke.author.split(' ')[0]; this.updateName.emit(name); } }
El enfoque descrito no es una regla, sino solo una recomendación. Hay muchas situaciones en las que este enfoque será inconveniente e ineficaz. Con el tiempo, aprenderá a comprender en qué caso puede rechazar el método propuesto para trabajar con entradas.
Rxjs
Por supuesto, podría estar equivocado, pero parece que ReactiveX y la programación reactiva en general son una nueva tendencia. Angular sucumbió a esta tendencia (o tal vez la creó) y usa RxJS por defecto. La lógica básica de todo el marco se ejecuta en esta biblioteca, por lo que es muy importante comprender los principios de la programación reactiva.
Pero, ¿qué es RxJS? Combina tres ideas que revelaré en un lenguaje bastante simple con algunas omisiones:
- El patrón "Observador" es una entidad que produce eventos, y hay un oyente que recibe información sobre estos eventos.
- El patrón iterador : le permite obtener acceso secuencial a los elementos de un objeto sin revelar su estructura interna.
- La programación funcional con colecciones es un patrón en el que la lógica se convierte en componentes pequeños y muy simples, cada uno de los cuales resuelve un solo problema.
La combinación de estos patrones nos permite describir de manera muy simple algoritmos que son complejos a primera vista, por ejemplo:
private loadUnreadJokes() { this.showLoader(); // fromEvent(document, 'load') .pipe( switchMap( () => this.http .get('/api/v1/jokes') // .pipe(map((jokes: any[]) => jokes.filter(joke => joke.unread))), // ), ) .subscribe( (jokes: any[]) => (this.jokes = jokes), // error => { /* */ }, () => this.hideLoader(), // ); }
Solo 18 líneas con toda la hermosa sangría. Ahora intente reescribir este ejemplo en Vanilla o al menos jQuery. Casi el 100% de esto le llevará al menos el doble de espacio y no será tan expresivo. Aquí puedes seguir la línea con los ojos y leer el código como un libro.
Observable
Comprender que cualquier dato se puede representar como una secuencia no llega de inmediato. Por lo tanto, propongo pasar a una analogía simple. Imagine que una secuencia es una matriz de datos ordenados por tiempo. Por ejemplo, en esta realización:
const observable = []; let counter = 0; const intervalId = setInterval(() => { observable.push(counter++); }, 1000); setTimeout(() => { clearInterval(intervalId); }, 6000);
Consideraremos que el último valor de la matriz es relevante. Cada segundo se agregará un número a la matriz. ¿Cómo podemos descubrir en otra parte de la aplicación que se ha agregado un elemento a la matriz? En una situación normal, llamaríamos a algún tipo de callback
de callback
y actualizaríamos el valor de la matriz, y luego simplemente tomaríamos el último elemento.
Gracias a la programación reactiva, no es necesario no solo escribir mucha lógica nueva, sino también pensar en actualizar la información. Esto se puede comparar con un simple oyente:
document.addEventListener('click', event => {});
Puede poner una gran cantidad de EventListener
en toda la aplicación, y funcionarán, a menos que, por supuesto, usted se encargue de lo contrario a propósito.
La programación reactiva también funciona. En un lugar, simplemente creamos un flujo de datos y periódicamente colocamos nuevos valores allí, y en otro, nos suscribimos a este flujo y simplemente escuchamos estos valores. Es decir, siempre aprendemos sobre la actualización y podemos manejarla.
Ahora veamos un ejemplo real:
export class JokesListComponent implements OnInit { jokes$: Observable<IJoke>; authors$ = new Subject<string[]>(); unread$ = new Subject<number>(); constructor(private jokerService: JokerService) {} ngOnInit() { // , subscribe() this.jokes$ = this.jokerService.getJokes(); this.jokes$.subscribe(jokes => { this.authors$.next(jokes.map(joke => joke.author)); this.unread$.next(jokes.filter(joke => joke.unread).length); }); } }
Gracias a esta lógica, al cambiar los datos en jokes
, actualizamos automáticamente los datos sobre el número de chistes no leídos y la lista de autores. Si tiene un par de componentes más, uno de los cuales recopila estadísticas sobre el número de chistes leídos por un autor, y el segundo calcula la duración promedio de los chistes, entonces las ventajas se vuelven obvias.
Banco de pruebas
Tarde o temprano, el desarrollador comprende que si el proyecto no es MVP, entonces debe escribir pruebas. Y cuantas más pruebas se escriban, cuanto más clara y detallada sea su descripción, más fácil, más rápido y más confiable será hacer cambios e implementar nuevas funcionalidades.
Angular probablemente previó esto y nos dio una poderosa herramienta de prueba. Muchos desarrolladores al principio intentan dominar algún tipo de tecnología "desde el principio" sin entrar en la documentación. Hice lo mismo, por eso me di cuenta bastante tarde de todas las capacidades de prueba disponibles "listas para usar".
Puede probar cualquier cosa en Angular, pero si solo necesita crear instancias y comenzar a llamar a métodos para probar una clase o servicio regular, la situación con el componente es completamente diferente.
Como ya descubrimos, gracias a las dependencias DI se toman fuera del componente. Por un lado, esto complica un poco todo el sistema, por otro lado, nos brinda grandes oportunidades para configurar pruebas y verificar muchos casos. Propongo entender el ejemplo de un componente:
@Component({ selector: 'app-joker', template: '<some-dependency></some-dependency>', styleUrls: ['./joker.component.less'], }) export class JokerComponent { constructor( private jokesService: JokesService, @Inject(PARTY_TOKEN) private partyService: PartyService, @Optional() private sleepService: SleepService, ) {} makeNewFriend(): IFriend { if (this.sleepService && this.sleepService.isSleeping) { this.sleepService.wakeUp(); } const joke = this.jokesService.generateNewJoke(); this.partyService.goToParty('Pacha'); this.partyService.toSay(joke.text); const laughingPeople = this.partyService.getPeopleByReaction('laughing'); const girl = laughingPeople.find(human => human.sex === 'female'); const friend = this.partyService.makeFriend(girl); return friend; } }
Entonces, en el ejemplo actual hay tres servicios. Uno se importa de la manera habitual, uno por token y otro servicio es opcional. ¿Cómo configuramos el módulo de prueba? Enseguida mostraré la vista terminada:
beforeEach(async(() => { TestBed.configureTestingModule({ imports: [SomeDependencyModule], declarations: [JokerComponent], // , providers: [{provide: PARTY_TOKEN, useClass: PartyService}], }).compileComponents(); fixture = TestBed.createComponent(JokerComponent); component = fixture.componentInstance; fixture.detectChanges(); // , }));
TestBed
nos permite hacer una simulación completa del módulo requerido. Puede conectar cualquier servicio, reemplazar módulos, obtener instancias de clases de un componente y mucho más. Ahora que tenemos el módulo ya configurado, pasemos a las posibilidades.
Se pueden evitar dependencias innecesarias
Una aplicación angular consta de módulos, que pueden incluir otros módulos, servicios, directivas y más. En la prueba, necesitamos, de hecho, recrear la operación del módulo. Si en nuestro ejemplo usamos <some-dependency></some-dependency>
en la plantilla, esto significa que también debemos importar SomeDependencyModule
en la prueba. ¿Y si hay adicciones allí? Entonces, también necesitan ser importados.
Si la aplicación es compleja, habrá muchas dependencias de este tipo. La importación de todas las dependencias conducirá al hecho de que en cada prueba se ubicará la aplicación completa y se llamará a todos los métodos. Quizás esto no nos convenga.
Hay al menos una forma de deshacerse de las dependencias necesarias: simplemente reescriba la plantilla. Suponga que tiene pruebas de captura de pantalla o pruebas de integración y no hay necesidad de probar la apariencia del componente. Entonces es suficiente simplemente verificar los métodos. En este caso, puede escribir la configuración de la siguiente manera:
TestBed.configureTestingModule({ declarations: [JokerComponent], providers: [{provide: PARTY_TOKEN, useClass: PartyService}], }) .overrideTemplate(JokerComponent, '') // , .compileComponents();
, . , . , . , , , , . — .
Injection Token , . . , , .
ts-mockito
, , . Angular « ».
// export class MockPartyService extends PartyService { meetFriend(): IFriend { return {} as IFriend; } goToParty() {} toSay(some: string) { console.log(some); } } // ... TestBed.configureTestingModule({ declarations: [JokerComponent, MockComponent], providers: [{provide: PARTY_TOKEN, useClass: MockPartyService}], // }).compileComponents();
Eso es todo .
. , — , — . , :
— . , . — .
Resumen
Angular, . , , «».
, Angular - . HTTP-, , lazy-loading . Angular .