[Перевод] 22 совета Angular-разработчику. Часть 1

Автор статьи, первую часть перевода которой мы публикуем, говорит, что он уже около двух лет работает над крупномасштабным Angular-приложением в Trade Me. В течение последних нескольких лет команда разработчиков приложения постоянно занимается совершенствованием проекта — как в плане качества кода, так и в том, что касается производительности.

7bb9dc3bca3b7f924e2835f11f666917.jpg


В этой серии материалов речь пойдёт о подходах к разработке, используемые командой Trade Me, которые выражены в виде более чем двух десятков рекомендаций, касающихся таких технологий, как Angular, TypeScript, RxJS и @ngrx/store. Кроме того, определённое внимание здесь будет уделено универсальным техникам программирования, которые направлены на то, чтобы сделать код приложений чище и аккуратнее.

1. О trackBy


Применяя ngFor для обхода массивов в шаблонах, используйте эту конструкцию с функцией trackBy, которая возвращает уникальный идентификатор для каждого элемента.

▍Пояснения


Когда массив изменяется, Angular выполняет повторный рендеринг всего дерева DOM. Но если вы используете trackBy, система будет знать о том, какой элемент изменился и внесёт в DOM изменения, касающиеся только этого конкретного элемента. Подробности об этом можно почитать здесь.

▍До

  • {{ item }}

  • ▍После

    // в шаблоне
    
  • {{ item }}
  • // в компоненте trackByFn(index, item) { return item.id; // уникальный id, соответствующий элементу }


    2. Ключевые слова const и let


    Если вы собираетесь объявить переменную, значение которой менять не планируется — используйте ключевое слово const.

    ▍Пояснения


    Уместное использование ключевых слов let и const проясняет намерение, касающееся применения объявленных с их помощью сущностей. Кроме того, такой подход облегчает распознавание проблем, вызванных случайной перезаписью значений констант. В такой ситуации выдаётся ошибка компиляции. Кроме того, это улучшает читабельность кода.

    ▍До

    let car = 'ludicrous car';
    let myCar = `My ${car}`;
    let yourCar = `Your ${car};
    if (iHaveMoreThanOneCar) {
       myCar = `${myCar}s`;
    }
    if (youHaveMoreThanOneCar) {
       yourCar = `${youCar}s`;
    }
    


    ▍После

    // значение car не перезаписывается, поэтому мы можем сделать car константой
    const car = 'ludicrous car';
    let myCar = `My ${car}`;
    let yourCar = `Your ${car};
    if (iHaveMoreThanOneCar) {
       myCar = `${myCar}s`;
    }
    if (youHaveMoreThanOneCar) {
       yourCar = `${youCar}s`;
    }
    


    3. Конвейеризуемые операторы


    При работе с RxJS используйте конвейризуемые операторы.

    ▍Пояснения


    Конвейеризуемые операторы поддерживают алгоритм tree-shake, то есть — при их импорте в проект будет включён лишь код, который планируется выполнять. Это, кроме того, упрощает идентификацию неиспользуемых операторов в файлах.

    Обратите внимание на то, что эта рекомендация актуальна для Angular версии 5.5 и выше.

    ▍До

    import 'rxjs/add/operator/map';
    import 'rxjs/add/operator/take';
    iAmAnObservable
        .map(value => value.item)
        .take(1);
    


    ▍После

    import { map, take } from 'rxjs/operators';
    iAmAnObservable
        .pipe(
           map(value => value.item),
           take(1)
         );
    


    4. Изоляция исправлений API


    Не все API совершенно стабильны и лишены ошибок. Поэтому иногда требуется вводить в код некую логику, направленную на исправление проблем API. Вместо того чтобы размещать эту логику в компонентах, где используются исправляемые API, лучше будет где-то её изолировать, например — в сервисе, и обращаться из компонента уже не к проблемному API, а к соответствующему сервису.

    ▍Пояснения


    Предложенный подход позволяет держать исправления «ближе» к API, то есть, настолько близко к тому коду, из которого выполняются сетевые запросы, насколько это возможно. В результате уменьшается объём кода приложения, взаимодействующего с проблемными API. Кроме того, так оказывается, что все исправления находятся в одном месте, в результате с ними легче будет работать. Если вам приходится исправлять ошибки в API, то гораздо проще делать это в каком-то одном файле, чем разбрасывать эти исправления по всему приложению. Это облегчает не только создание исправлений, но и поиск соответствующего кода в проекте, и его поддержку.

    Кроме того, можно создавать собственные теги, наподобие API_FIX (что напоминает тег TODO), и помечать ими исправления. Это упрощает поиск таких исправлений.

    5. Подписка в шаблоне


    Избегайте подписки на наблюдаемые объекты (observables) из компонентов. Вместо этого оформляйте подписки на них в шаблонах.

    ▍Пояснения


    Асинхронные конвейеризуемые операторы автоматически отписывают сами себя, что упрощает код, исключая необходимость в ручном управлении подписками. Это, кроме того, уменьшает риск того, что разработчик забудет отписаться от подписки в компоненте, что может привести к утечкам памяти. Снизить вероятность возникновения утечек памяти можно и с использованием правил линтера, направленных на выявление наблюдаемых объектов, от которых не отписались.

    Кроме того, применение такого подхода приводит к тому, что компоненты перестают быть компонентами с состоянием, что может приводить к ошибкам когда данные меняются вне подписки.

    ▍До

    // шаблон
    

    {{ textToDisplay }}

    // компонент iAmAnObservable .pipe( map(value => value.item), takeUntil(this._destroyed$) ) .subscribe(item => this.textToDisplay = item);


    ▍После

    // шаблон
    

    {{ textToDisplay$ | async }}

    // компонент this.textToDisplay$ = iAmAnObservable .pipe( map(value => value.item) );


    6. Удаление подписок


    Подписываясь на наблюдаемые объекты всегда следите за тем, чтобы подписки на них правильно удалялись, используя операторы наподобие take, takeUntil и так далее.

    ▍Пояснения


    Если не отписаться от наблюдаемого объекта — это приведёт к утечкам памяти, так как поток наблюдаемого объекта останется открытым, что возможно даже после разрушения компонента или после того, как пользователь перейдёт на другую страницу приложения.

    Ещё лучше будет создать правило линтера для обнаружения наблюдаемых объектов с действующей подпиской на них.

    ▍До

    iAmAnObservable
        .pipe(
           map(value => value.item)     
         )
        .subscribe(item => this.textToDisplay = item);
    
    


    ▍После


    Воспользуйтесь оператором takeUntil в том случае, если вы хотите наблюдать за изменениями какого-то объекта до тех пор, пока другой наблюдаемый объект не сгенерирует некое значение:

    private destroyed$ = new Subject();
    public ngOnInit (): void {
        iAmAnObservable
        .pipe(
           map(value => value.item)
          // Мы хотим прослушивать iAmAnObservable до разрушения компонента
           takeUntil(this._destroyed$)
         )
        .subscribe(item => this.textToDisplay = item);
    }
    public ngOnDestroy (): void {
        this._destroyed$.next();
    }
    


    Использование чего-то наподобие this — это паттерн, применяемый для управления удалением подписок на множество наблюдаемых объектов в компоненте.

    Используйте take если вам нужно лишь первое значение, выдаваемое наблюдаемым объектом:

    iAmAnObservable
        .pipe(
           map(value => value.item),
           take(1),
           takeUntil(this._destroyed$)
        )
        .subscribe(item => this.textToDisplay = item);
    


    Обратите внимание на то, что здесь мы используем takeUntil с take. Это делается для того, чтобы избежать утечек памяти, вызванных тем, что подписка не привела к получению значения до разрушения компонента. Если бы здесь не использовалась функция takeUntil, подписка существовала бы до получения первого значения, но так как компонент был бы уже уничтожен, это значение никогда не было бы получено, что привело бы к утечке памяти.

    7. Использование подходящих операторов


    Используя сглаживающие операторы с наблюдаемыми объектами, применяйте те из них, которые соответствуют особенностям решаемой задачи.

    • Используйте switchMap когда вам нужно игнорировать предыдущее диспетчеризованное действие при поступлении нового действия.
    • Используйте mergeMap в том случае, если нужно параллельно обрабатывать все диспетчеризованные действия.
    • Используйте concatMap тогда, когда действия нужно обрабатывать одно за другим, в порядке их поступления.
    • Используйте exhaustMap в ситуациях, когда, в процессе обработки ранее поступивших действий, вам нужно игнорировать новые.


    Подробности об этом можно почитать здесь.

    ▍Пояснения


    Использование, если есть такая возможность, одного оператора, вместо того, чтобы достигать того же эффекта за счёт объединения в цепочку нескольких операторов, приводит к сокращению объёма кода приложения, который нужно отправить пользователю. Использование неудачно выбранного оператора может привести к неправильному поведению программы, так как разные операторы по-разному обрабатывают наблюдаемые объекты.

    8. Ленивая загрузка


    Тогда, когда это возможно, попытайтесь организовать ленивую загрузку модулей Angular-приложения. Данная техника сводится к тому, что нечто загружается только в том случае, если оно используется. Например — компонент загружается только тогда, когда его нужно вывести на экран.

    ▍Пояснения


    Ленивая загрузка уменьшает размер материалов приложения, которые приходится загружать пользователю. Это может улучшить скорость загрузки приложения за счёт того, что неиспользуемые модули не передаются с сервера клиентам.

    ▍До

    // app.routing.ts
    { path: 'not-lazy-loaded', component: NotLazyLoadedComponent }
    


    ▍После

    // app.routing.ts
    { 
      path: 'lazy-load',
      loadChildren: 'lazy-load.module#LazyLoadModule' 
    }
    // lazy-load.module.ts
    import { NgModule } from '@angular/core';
    import { CommonModule } from '@angular/common';
    import { RouterModule } from '@angular/router';
    import { LazyLoadComponent }   from './lazy-load.component';
    @NgModule({
      imports: [
        CommonModule,
        RouterModule.forChild([
             { 
                 path: '',
                 component: LazyLoadComponent 
             }
        ])
      ],
      declarations: [
        LazyLoadComponent
      ]
    })
    export class LazyModule {}
    


    9. О подписках внутри других подписок


    Иногда вам, для выполнения некоего действия, могут понадобиться данные из нескольких наблюдаемых объектов. В подобной ситуации избегайте создания подписок на такие объекты внутри блоков subscribe других наблюдаемых объектов. Вместо этого применяйте подходящие операторы для объединения команд в цепочки. Среди таких операторов можно отметить withLatestFrom и combineLatest. Рассмотрим примеры, после чего прокомментируем их.

    ▍До

    firstObservable$.pipe(
       take(1)
    )
    .subscribe(firstValue => {
        secondObservable$.pipe(
            take(1)
        )
        .subscribe(secondValue => {
            console.log(`Combined values are: ${firstValue} & ${secondValue}`);
        });
    });
    


    ▍После

    firstObservable$.pipe(
        withLatestFrom(secondObservable$),
        first()
    )
    .subscribe(([firstValue, secondValue]) => {
        console.log(`Combined values are: ${firstValue} & ${secondValue}`);
    });
    


    ▍Пояснения


    Если говорить о читабельности, о сложности кода, или о признаках плохого кода, то, когда в программе возможности RxJS в полной мере не используются, это говорит о том, что разработчик недостаточно хорошо знаком с API RxJS. Если затронуть тему производительности, то окажется, что если наблюдаемому объекту нужно некоторое время на инициализацию, то будет осуществлена подписка на firstObservable, потом система будет ждать завершения операции, и только после этого начнётся работа со вторым наблюдаемым объектом. Если эти объекты представляют собой сетевые запросы, то выглядеть это будет как синхронное выполнение запросов.

    10. О типизации


    Всегда старайтесь объявлять переменные или константы с типом, отличающимся от any.

    ▍Пояснения


    При объявлении в TypeScript переменной или константы без указания типа тип будет выведен на основании назначаемого ей значения. Это может привести к проблемам. Рассмотрим классический пример поведения системы в подобной ситуации:

    const x = 1;
    const y = 'a';
    const z = x + y;
    console.log(`Value of z is: ${z}`
    // Вывод
    Value of z is 1a
    


    Предполагается, что y здесь является числом, но наша программа об этом не знает, поэтому она выводит нечто такое, что выглядит неправильно, но никаких сообщений об ошибках не выдаёт. Подобных проблем можно избежать, назначая переменным и константам подходящие типы.

    Перепишем вышеприведённый пример:

    const x: number = 1;
    const y: number = 'a';
    const z: number = x + y;
    // Тут появится ошибка компиляции:
    Type '"a"' is not assignable to type 'number'.
    const y:number
    


    Это помогает избежать ошибок, связанных с типами данных.

    Ещё одно преимущество систематического подхода к типизации заключается в том, что это упрощает рефакторинг и снижает вероятность ошибок в ходе этого процесса.

    Рассмотрим пример:

    public ngOnInit (): void {
        let myFlashObject = {
            name: 'My cool name',
            age: 'My cool age',
            loc: 'My cool location'
        }
        this.processObject(myFlashObject);
    }
    public processObject(myObject: any): void {
        console.log(`Name: ${myObject.name}`);
        console.log(`Age: ${myObject.age}`);
        console.log(`Location: ${myObject.loc}`);
    }
    // Вывод
    Name: My cool name
    Age: My cool age
    Location: My cool location
    


    Предположим, что мы хотели поменять, в объекте myFlashObject, имя свойства loc на location и допустили ошибку в ходе редактирования кода:

    public ngOnInit (): void {
        let myFlashObject = {
            name: 'My cool name',
            age: 'My cool age',
            location: 'My cool location'
        }
        this.processObject(myFlashObject);
    }
    public processObject(myObject: any): void {
        console.log(`Name: ${myObject.name}`);
        console.log(`Age: ${myObject.age}`);
        console.log(`Location: ${myObject.loc}`);
    }
    // Вывод
    Name: My cool name
    Age: My cool age
    Location: undefined
    


    Если при создании объекта myFlashObject не используется типизация, то в нашем случае система полагает, что значением свойства loc объекта myFlashObject является undefined. Она не задумывается о том, что loc может представлять собой недопустимое имя свойства.

    Если при описании объекта myFlashObject применяется типизация, то в подобной ситуации мы увидим, при компиляции кода, замечательное сообщение об ошибке:

    type FlashObject = {
        name: string,
        age: string,
        location: string
    }
    public ngOnInit (): void {
        let myFlashObject: FlashObject = {
            name: 'My cool name',
            age: 'My cool age',
            // Ошибка компиляции
            Type '{ name: string; age: string; loc: string; }' is not assignable to type 'FlashObjectType'.
            Object literal may only specify known properties, and 'loc' does not exist in type 'FlashObjectType'.
            loc: 'My cool location'
        }
        this.processObject(myFlashObject);
    }
    public processObject(myObject: FlashObject): void {
        console.log(`Name: ${myObject.name}`);
        console.log(`Age: ${myObject.age}`)
        // Ошибка компиляции
        Property 'loc' does not exist on type 'FlashObjectType'.
        console.log(`Location: ${myObject.loc}`);
    }
    


    Если вы начинаете работу над новым проектом, полезно будет задать, в файле tsconfig.json, опцию strict:true для того, чтобы включить строгую проверку типов.

    11. Об использовании линтера


    У tslint имеются различные стандартные правила наподобие no-any, no-magic-numbers, no-console. Линтер можно настраивать, редактируя файл tslint.json для того, чтобы организовать проверку кода по определённым правилам.

    ▍Пояснения


    Использование линтера для проверки кода означает, что, если в коде встречается что-то такое, что запрещено правилами, вы получите сообщение об ошибке. Это способствует единообразию кода проекта, улучшает его читабельность. Здесь можно ознакомиться с другими правилами tslint.

    Надо отметить, что некоторые правила включают в себя и средства исправления того, что в соответствии с ними считается недопустимым. При необходимости можно создавать собственные правила. Если вам это интересно — взгляните на данный материал, в котором идёт речь о создании собственных правил для линтера с использованием TSQuery.

    ▍До

    public ngOnInit (): void {
        console.log('I am a naughty console log message');
        console.warn('I am a naughty console warning message');
        console.error('I am a naughty console error message');
    }
    // Вывод. Никаких сообщений об ошибках, в консоль выводится следующее:
    I am a naughty console message
    I am a naughty console warning message
    I am a naughty console error message
    


    ▍После

    // tslint.json
    {
        "rules": {
            .......
            "no-console": [
                 true,
                 "log",    // команда console.log запрещена
                 "warn"    // команда console.warn запрещена
            ]
       }
    }
    // ..component.ts
    public ngOnInit (): void {
        console.log('I am a naughty console log message');
        console.warn('I am a naughty console warning message');
        console.error('I am a naughty console error message');
    }
    // Вывод. Линтер выводит ошибки для команд console.log and console.warn и не сообщает об ошибках применительно к console.error, так как эта команда не упомянута в настройках
    Calls to 'console.log' are not allowed.
    Calls to 'console.warn' are not allowed.
    


    Итоги


    Сегодня мы рассмотрели 11 рекомендаций, которые, надеемся, пригодятся Angular-разработчикам. В следующий раз ждите ещё 11 советов.

    Уважаемые читатели! Пользуетесь ли вы фреймворком Angular для разработки веб-проектов?

    1ba550d25e8846ce8805de564da6aa63.png

    © Habrahabr.ru