В статье я хотел поделиться уже работающим в продакшене вариантом начала постепенной миграции «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 и наверняка многое упустил из виду). Спасибо.
Поделиться с друзьями
-->

Комментарии (11)


  1. Alex_Crack
    10.08.2016 09:48

    и после некоторого количества прочитанных мануалов по различным смежным темам (включая миграцию 1.3 -> 1.4, 1.4 -> 1.5, 1.x -> 2)


    Все же, интересно было бы узнать, почему не решились на переход -> 2.x? А фактически попробовали строить angular2-style для версии 1.5.
    Вон ребята из Wrike уже используют в продакшене.


    1. Gugic
      10.08.2016 10:11
      +1

      Потому что у них «30+ фронтенд разработчиков» и у них, судя по тому, что я увидел в статье, совсем нет проблем со взять и переписать все по новой («Всё начиналось с Dojo, потом Ext.js. Мы писали на Polymer 0.5, и, когда он стал deprecated (с выходом версии 1.0), перед нами встал вопрос — что же выбрать?» ©). Этот вариант слабо совместим с требованиями (нашего) бизнеса.

      А у нас всего два фронтенд-девелопера, при этом поток запрашиваемых и реализуемых фич не остановить.

      В этом решении я аккуратно обновил версию основной библиотеки, починил все, что вылезло при обновлении, убрал какие-то deprecated вещи в небольшом количестве, настроил обвязку и в «legacy» части проекта в общем ничего более не менял, она как работала, так и работает. А весь новый функционал уже разрабатывается в приближенном к angular 2 стиле.

      Есть еще изощренные варианты с параллельным существованием двух версий angular в проекте, но наш клиентский код итак уже распух сверх всяких мер.

      Angular 1.5 — это хороший шаг для начала затяжной миграции. Будут следующие релизы, которые добавят еще больше совместимости между версиями. Функционал потихоньку переписывается согласно рекомендациям из upgrade гайда и я тешу себя надеждой что в каком-то будущем мы осуществим переход.


    1. indestructable
      10.08.2016 11:24
      +2

      А в чем преимущество 2.0? Вторая версия это же, по сути, не обновление, а новый продукт.


      1. kaljan
        10.08.2016 15:03

        если перейти на angular 1.5, в дальнейшем перейти на angular 2.0 в разы проще


      1. indestructable
        10.08.2016 18:41

        Отвечу сам себе: один из немногих явных плюсов — это нормальная работа с модульностью. В Ангуляр 2 нужно явно указывать компоненты/директивы, используемые в шаблоне, а в первом их нужно регистрировать в модуле, что проигрывает по явности и наглядности зависимости. (Однако, все еще нужно помнить, какой селектор использует компонент).


  1. wips
    11.08.2016 11:05

    Подскажите, плз, какой модуль/лоадер парсит/транспайлит аннотацию @Component?

    @Component({ ... })
    export default class StaticInfoComponent { ... }
    

    И правильно ли я понимаю, что команда Angular не предлагает использовать такие аннотации для 1.5.* проектов? Это такой себе v2-like стайл?


    1. Gugic
      11.08.2016 12:02

      Это typescript decorators.

      Правильно понимаете, но он не просто так сложился, так получилось и удобнее и чище. Начинал я с отдельного класса для компонент, имплементирующего angular.IComponentOptions, в который надо было импортировать отдельный «component view controller»


      1. wips
        11.08.2016 12:49

        Понятно, спасибо. То, что это ts-декораторы я понял. Просто я предполагал, что это не самописный декоратор, а какой-то набор готовых декораторов из npm эмулирующий Angular2 аннотации/декораторы.


        1. Gugic
          11.08.2016 13:05

          Мой декоратор был полностью приведен в середине статьи. А так-то есть из чего выбрать, просто я пока до них не добрался.


  1. Arta
    14.08.2016 02:20
    +1

    Учитывая переход на TypeScript лучшим вариантом имхо будет вот эта небольшая либа — ng-metadata. Она добавляет кучку декораторов и интерфейсов, совпадающих с ng2, и позволит избавиться от кучи проблем ng1-typescript, даст код практически целиком совпадающий по написанию с ng2 и позволит в дальнейшем очень просто на него перейти.
    Текущий проект уже был на ng1 1.5, но при разрастании оказалось что поддерживать чистый js в ng1 окружении очень тяжело, наметился постепенный переход на TypeScript. С ng-metadata код тех же компонентов/директив/сервисов стал приятней чем был на ng1 что без typescript, что с ним.
    Либа актуально обновляется, в скором времени введут поддержку @NgModule() из RC5 что позволит перевести на это и код инициализации всех частей приложения и максимально приблизит к ng2.


    1. Valery4
      14.08.2016 12:00
      +1

      Спасибо мил человек, а то ng-forward совсем зачах. Я на нём делал демо проект год назад. А сейчас для продакшена не стал, там больше чем пол года новых коммитов не было.