Начало перевода «legacy» Angular JS проекта на Angular 1.5 Components – ES6 и TypeScript

в 21:00, , рубрики: angular 1.5, angular components, AngularJS, ecmascript 6, ES6, javascript, TypeScript

В статье я хотел поделиться уже работающим в продакшене вариантом начала постепенной миграции «legacy» Angular JS проекта на все хорошее, что дал нам Angular 1.5 и связку ES6/TypeScript.

Итак дано: стандартный проект, разработка которого началась еще на бородатом Angular 1.2 (человеком, далеким от мира фронтенда), представленный в более или менее стандартном виде — отдельно по директориям сгруппированы модули с роутами, сервисы, директивы и невероятно жирные контроллеры, функционал из которых потихоньку выделяестся в отдельные директивы. Адский поток фич к реализации, полное отсутствие моделей, доступ к объектам и их модификации — как бог на душу положит.

Также в проекте уже присутствует более или менее налаженный и прописанный процесс сборки/минификации и деплоя всего этого добра при помощи gulp, CI и прочее.

Задача — не уйти в себя на поддержке проекта в таком виде, в каком он есть, начать писать хороший, поддерживаемый код, научиться чему-то новому.

Вводная

Как раз подоспел Angular 1.5, представивший «компоненты» и после некоторого количества прочитанных мануалов по различным смежным темам (включая миграцию 1.3 -> 1.4, 1.4 -> 1.5, 1.x -> 2) в качестве программы на обозримое будущее были приняты такие пункты:

  1. Старый функционал до поры до времени просто не трогаем
  2. Новый функционал сразу пишем в виде компонент (стили, шаблоны и тесты храним там же, где код конкретного компонента)
  3. Пишем совсем не стесняя себя в использовании фич из ES6/ES7
  4. Пишем на Typescript
  5. Старый функционал переделываем на новый лад по мере поступления по нему задач достаточно крупных для того, чтобы провести рефакторинг.

Теперь нужно определиться с обвязкой.

Браузеры на данном этапе развития не поддерживает ES6 imports, а значит чтобы использовать их (а мне хотелось больше «нейтива»), нужно собирать проект под браузер одним из «сборщиков». После некоторых изысканий выбор пал на webpack — его идеология отлично сочетается с идеологией компонент и позволяет прямо из кода компонента подключать необходимые шаблоны и стили.

Спустя пару месяцев он был обновлен со стабильной 1.x до 2 (beta). Версия 2 имеет несколько очень важных нововведений — в первую очередь это нативная поддержка ES6 Imports (в том числе и постепенная подгрузка частей кода по мере появления нужды клиента в этом коде).

Для вебпака нам нужно будет несколько «лоадеров» — это эдакие middleware, дающие вебпаку понять каким именно образом добавлять в сборку тот или иной файл. У меня этот набор относительно скромный:

  • ts-loader, замененный впоследствии на awesome-typescript-loader (т.к. atl у удалось заставить поддерживать baseUrl из настроек typescript 2.0, ради которого, в основном, и был осуществлен переход на typescript 2.0)
  • ng-annotate-loader, замененный в последствии на плагин для babel babel-plugin-angularjs-annotate по причине неподдержки первым ES6 imports — ультраполезная вещь, имплементация модуля ng-annotate, позволяющего изрядно визуально очистить код angular-комплектующих, отказавшись от minification-proof dependency injection и вместо
    .factory('serviceId', ['depService', function(depService) {
      // ...
    }])

    начать писать более или менее человечное:

    .factory('serviceId', function(depService) {
      /*@ngInject*/
      // ...
    })
    

  • less-loader для компиляции less во время загрузки (в 'legacy' части проекта у нас основная масса стилей написана на less, и я не видел повода не продолжать использовать less, тем более у нас уже куча своих миксинов и переменных)
  • style-loader — загрузка css, получающегося на выходе less
  • raw-loader — в моем случае загрузка html прямо в js

Еще с вебпаком почти в комплекте поставляется webpack-dev-server, который позволяет очень сильно ускорить перекомпиляцию при изменениях — это локальный сервер, который раздает обычную статику из данной директории, а код, собираемый webpack-ом держит, пересобирает и раздает прямо из памяти.

Так же нам понадобится собственно компилятор TypeScript. Он спустя те же пару месяцев вместе с webpack был обновлен до беты 2.0, в основном из-за того, что 2.0 позволяет задавать базовый url для всех импортов, избавиться наконец от засилия относительных путей внутри файлов и заменить немного удручающее:

import IConversation from "../../../interfaces/IConversation";
import Conversation from "../../../models/Conversation";
import Interaction from "../../../models/Interaction";
import NotificationService from "../../../helpers/NotificationService";

На вполне энтерпрайзное:

import IConversation from "interfaces/IConversation";
import Conversation from "models/Conversation";
import Interaction from "models/Interaction";
import NotificationService from "helpers/NotificationService";

Еще нам вполне вероятно понадобится транспайлер (это как компилятор, только для компиляции ES6 в ES5) для того чтобы наш самый современный код нормально работал у самых обыкновенных пользователей сервиса. Самый популярный сейчас — это Babel JS. Конечно можно в роли траспайлера использовать непосредственно компилятор TypeScript, но он некоторые вещи делает хуже babel'a (async/await, например, typescript не транспайлит, насколько мне известно), поэтому я решил компилировать TypeScript в ES6 и потом с помощью Babel и пресета es2015-webpack (это специальный пресет для webpack 2, он не превращает ES6 Imports в CommonJS, т.к. webpack теперь умеет собирать ES6 Imports сам по себе).

Еще нам понадобится TypeScript Definition Manager (бывший tsd. Многие статьи рекомендуют ставить tsd, но tsd уже deprecated и сам по себе просит использовать вместо него проект typings).

Итак, давайте уже приступим.

Установка и настройка окружения

В первую очередь установим все вышеописанное:

npm install --save-dev webpack@2.1.0-beta.20 typescript@2 less-loader raw-loader style-loader typescript typings webpack webpack-dev-server babel-runtime babel-preset-es2015-webpack babel-polyfill  babel-plugin-angularjs-annotate babel-loader babel-core awesome-typescript-loader

Вероятно typescript, webpack и typings придется установить еще и глобально для того, чтобы удобно работать.

Также нам нужно будет установить все нужные для нашей комфортной работы с typescript definition'ы:

typings install angular --source=dt --global --save

А возможно и

typings install jquery --source=dt --global --save

И все такое, что там у вас еще используется.

Результатом выполнения этих команд будет созданный в текущей директории файл typings.json, который впоследствии восстановит все ваши typings'ы по вызову команды

typings install

Т.е. это эдакий аналог lock-файла или package.json для definition manager'а. Этот файл надо добавить в репозиторий. Также появится папка typings с собственно скачанными definition'ами для использования в typescript. (ее можно добавить в .gitignore и сделать вызов typings install частью сборки проекта)

Далее давайте уже начнем писать для всего этого конфиги.

Конфигурации

./declarations.d.ts
Используется для того же самого для чего используются definition'ы из typings, но содержит те интерфейсы, которых не нашлось в репозиториях typings manager'а. У меня там например

declare function require(name: string): any; // used by webpack
declare let antlr4: any;
declare let rangy: any;

Конечно с any — это я поленился, по идее там надо полностью описать интерфейс и тогда у вас появится корректный автокомплит по этим объектам в вашей IDE и, что самое главное, проверки корректности использования методов/свойств этих объектов на этапе компиляции. Это для меня todo, так сказать.

require здесь обязателен, иначе ваш код для вебпака просто не будет собираться.

./tsconfig.json
Это конфигурация компилятора typescript

{
  "compilerOptions": {
    "target": "ES6",
    "sourceMap": true, // for debug
    "experimentalDecorators": true, // decorators support, see ts reference
    "baseUrl": "./path/to/your/app" // url that will be 'root' for imports
  },
  "files": [
    "declarations.d.ts", // declarations file from previous point
    "typings/index.d.ts" // declarations, downloaded by definition manager
  ]
}

Объявленный здесь массив files позволяет нам не писать в каждом файле ужасающий

/// <reference path="..." />

Ну и как видно в этом конфиге отсутствует outfile и любые значащие файлы проекта. Просто потому что мы отдаем эти вопросы на откуп webpack.

./webpack.config.js
Собственно конфигурация webpack.

'use strict';

var path = require('path');
var webpack = require('webpack');
var TsConfigPathsPlugin = require('awesome-typescript-loader').TsConfigPathsPlugin; // plugin to work with typescript base path. Skip it if you don't need this.

var babelSettings =  {
    plugins: [['angularjs-annotate', {'explicitOnly' : true}]],  //explicitOnly here to disallow auto-annotating of each function. Skip it if you need automatioc anotation
    presets: ['es2015-webpack']
};

module.exports = {
    module: {
        loaders: [
            {
                test: /.tsx?$/,
                loader: 'babel-loader?' + JSON.stringify(babelSettings) + '!awesome-typescript-loader',
            },
            {test: /.html$/, loader: 'raw'},
            {test: /.less$/, loader: 'style!css?sourceMap!less?sourceMap'}
        ]
    },
    entry: {
        components: './path/to/your/app/components/components.ts'
        // entry1: './path/to/your/app/components/entry1/entry1.component.ts'
        // entry1: './path/to/your/app/components/entry2/entry2.component.ts'
        // models: './path/to/your/app/models/models.bundle.ts'
        // ...whatever you want
    },
    resolve: {
        extensions: ['.ts', '.js', '.html', '.css', '.less'],
        alias: {
            // lessWebApp: path.join(__dirname, '/path/to/your/app/less') - whatever you want to be used in your code
        },
        plugins: [
            new TsConfigPathsPlugin()
        ]
    },
    devtool: 'source-map',
    output: {
        path: path.join(__dirname, 'path/to/your/build/js/bundles'),
        publicPath: '/js/bundles',
        filename: '[name].bundle.js'
    },
    plugins: [
        // new webpack.optimize.CommonsChunkPlugin({ name: 'common', filename: 'common.bundle.js' }) - use this to move out common chunks to one separate chunk
    ],
    devServer: {
        contentBase: path.join(__dirname, 'path/to/your/build/'),
        publicPath: '/js/bundles/'
    }
};

Итак…

Перейдем непосредственно к

Тут стоит описать еще несколько моментов. В нашей стандартной структуре директорий приложения, среди всех этих directives/controllers/modules мы создали новую — components (а также models, helpers, etc...), в которой собственно и будут жить компоненты. Базовым файлом в этой директории является файл components.ts, в него импортятся наши компоненты из поддиректорий. Этот файл мы и используем в качестве entry-point для webpack. Выглядит он как-то так:

./path/to/your/app/components/components.ts

// component-based modules with their own routes
import Module1 from "components/module1/module1";
import Module2 from "components/module2/module2";
// ts helpers and services
import AnnotateHelper from "services/AutoMarkupService";
import ParserHelper from "helpers/ParserHelper";
// ....
// not organized in modules components
import AgentAvatarComponent from "components/agent/agent_avatar/agentAvatar.component";
import StaticInfoComponent from "components/shared/static_info/staticInfo.component";
// ....

let componentModule = angular.module('api.components', [
    Module1.name, Module2.name // ....
]);

componentModule
    .component(AgentAvatarComponent.name, AgentAvatarComponent)
    .component(StaticInfoComponent.name, StaticInfoComponent)
    // ....
    .factory('ParserService', () => ParserHelper) // for static helpers
    // ....
    .factory('autoMarkupService', AutoMarkupService.getInstance); // for helpers that handles something inside

export default componentModule;

Надо добавить что точек входа может быть (и должно быть) больше одной, иначе у вас просто с ростом проекта все начнет ужасно долго компилироваться. Выше в конфиге вебпака можно увидеть как устанавливается несколько entry-points. Ну и да, они не должны пересекаться по импортам. Если есть что-то общее (например модели) — то это общее так же нужно выделять в отдельный бандл и не забывать про оптимизацию, common chunks и возможность организовать ленивую загрузку скриптов.

Вполне понятно что тот же ParserHelper из этого примера — просто класс со статическими методами — может быть импортирован в ts-файл напрямую, без использования ангуляровского DI (что зачастую приятно), но здесь он регистрируется как фабрика для обеспечения обратной совместимости с legacy-частью приложения. Т.е. это один из уже переписанных на ts сервисов. А вот в AutoMarkupService мы уже хотим хранить какое-то состояние, или может быть нам просто нужен там стандартный ангуляровский DI. И потому для его регистрации в ангуляре используем нехитрый паттерн с getInstance:

./path/to/your/app/services/AutoMarkupService.ts

import IHttpService = angular.IHttpService;
import IPromise = angular.IPromise;
import Model from "models/Model";

export default class AutoMarkupService {

    private static instance: AutoMarkupService = null;

    public static getInstance($http, legacyUrlConfig) {
        /*@ngInject*/
        if (!AutoMarkupService.instance) {
            AutoMarkupService.instance = new AutoMarkupService($http, legacyUrlConfig);
        }
        return AutoMarkupService.instance;
    }

    constructor(private $http: IHttpService, private legacyUrlConfig: {modelUrl: string}) {
        // do something
    }

    public doSomething(): IPromise<Model> {
        return this.$http.get(this.legacyUrlConfig.modelUrl);
    }
}

По-хорошему это все надо переделать на какой-то базовый класс или сразу на декоратор.

Теперь что касается самих компонентов:

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

./path/to/your/app/helpers/decorators.ts

// ....
export const Component = function(options: ng.IComponentOptions): Function {
    return (controller: Function) => {
        return angular.extend(options, {controller});
    };
};
// ....

А теперь внимательно смотрим на то, что можно сделать со всем тем, что мы уже понастраивали

./path/to/your/app/components/shared/static_info/staticInfo.component.ts

import {Component} from "helpers/decorators";
require('./staticInfo.style.less');

@Component({
    bindings: {
        message: "@"
    },
    template: require('./staicInfo.template.html'),
    controllerAs: 'vm'
})
export default class StaticInfoComponent {

    public message: string;
    /**
    * here you can put any angular DI and it will work
    */
    constructor() {
        // this.message -> undefined
    }

    /**
    * function that will be called right after constructor(),
    * but in constructor() you will not have any bindings applied and here - will be
    */
    $onInit() {
        // this.message -> already binded and working.
    }
}

Заметьте что тут через require мы подключаем шаблон и стиль. Этот require — для webpack, после сборки вместо require в этом месте будут собственно итоговый css и html в текстовом виде. Ну или (в зависимости от настроек webpack) они будут где-то в других файлах, но к моменту вызова этой функции — уже точно будут загружены.

Так же важный момент насчет $onInit — пока вы транспайлите в es2015 он фактически не нужен. В es2015 еще нет классов и все это транспайлится в объект и к моменту вызова constructor все биндинги уже переданы. Но стоит только поменять пресет на es2016 или вовсе выкинуть Babel (для простоты отладки, например), как у вас все перестанет работать. $onInit — это в общем стандартный ангуляровский callback.

Как все это собрать и заставить работать

После всех подготовительных этапов осталось только в корневой директории (там, где у нас лежат все наши package.json, tsconfig.json, webpack.config.js и прочее) запустить

webpack

В директорию, указанную конфиге webpack по результатам работы соберется .js файл, который нужно наравне со всеми прочими включить в вашу .html-страницу (или добавить к вашей сборке специальную автоматику, которая будет этот файл собирать и минимизировать наравне со всеми прочими).

Команда

webpack -w

Запустит webpack в режиме watcher'а и будет пересобирать все при каждом изменении в ts или связанных с ними html и less.

Команда

webpack-dev-server -w

Запустит webpack-dev-server, который будет отдавать обычную статику (в нашем случае это «legacy» часть приложения) с указанных в конфиге адресов, а часть, за которую теперь отвечает вебпак, держать в памяти и очень быстро перекомпилировать.

Еще немного хинтов

  1. Запуск сборки вебпака можно легко добавить в ваш основной процесс сборки (например в какой-нибудь gulp build). У нас это выглядит как-то так:

    ./gulp/tasks/scripts.js

    // ....
    
    // use webpack.config.js to build modules
    gulp.task('webpack', "executes build of ts/es6 part of application", function (cb) {
    
        if (shared.state.isSkipWebpack) {
            console.log('Skipping webpack task during watch. Please use internal webpack watch');
            return cb();
        }
        let config = require('../../webpack.config');
    
        webpack(config, function (err, stats) {
            if (err) {
                console.log('webpack', err);
            }
            console.log('[webpack]', stats.toString({
                chunks: false,
                errorDetails: true
            }));
            cb();
        });
    });
    
    // ....
    
  2. Если вам кажется что вебпак работает медленно — самое время оптимизировать билд. CommonChunks плагин, разбиение проекта на много разных логических бандлов, тюнинг настроек кэша, использование dev-server'а в конце концов.
  3. Также, если ваш фронтенд неотчуждим от бэкенда и кажется что по этой причине использование webpack-dev-server'а невозможно, просто знайте, что одной из стандартных компонент webpack-dev-server'а является node-http-proxy и соответсвтенно в пару движений в сторону изменения конфига вы можете настроить прокси, который ваши запросы к нему будет перенаправлять… ну например на ваш staging сервер. Или еще куда-нибудь.
  4. Babel — очень мощный инструмент, который остался почти совсем за рамками данной статьи. Он включает в себя огромное количество плагинов, которые вы можете использовать на своем проекте.
  5. Для облегчения работы людям, не учавствующим в разработке фроненда, или для упрощения контейнеризации, или для еще чего, можно создать простые хелперы для запуска всех нужных приложений. Это позволит не ставить их глобально.

    ./runners/typings

            #!/bin/sh
            "node/node" "node_modules/typings/dist/bin.js" "$@"
            

    ./runners/webpack

            #!/bin/sh
            "node/node" "node_modules/webpack/bin/webpack.js" "$@"
            

    etc. (да, кстати, нода у нас нашим сборщиком также ставится локально в директорию проекта, рядом с node_modules)

    И запускать пакеты, установленные в node_modules, а не глобально. Это очень полезно если вы, например, собираете проект какой-то системой сборки, у вас там есть какой-то npm и вот чтобы не ставить глобально остальное, можно в этой системе сборки вызывать нужные команды таким вот образом:

    ./runners/typings install

  6. Пишите тесты и документацию.
  7. Курение убивет.

Конец

Ну вот. В общем итоговая (на сегодняшний день) конструкция выглядит как-то вот так, это результаты где-то наверное месяца весьма непоследовательно чтения различных посвященных этой теме статей, большая часть из которых несколько устарела (например как избиваться от reference path и относительных путей импортов в них не было написано) и десятков различных экспериментов (это не первая и не вторая итерация, все по большей части в свободное от работы — на которой надо пилить фичи — и личной жизни время, конечно же). Надеюсь кому-нибудь этот экскурс будет полезен. Также жду критики и предложений по улучшению всего, что я тут понагородил (ведь на самом деле я не совсем frontend developer и наверняка многое упустил из виду). Спасибо.

Автор: Gugic

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js