В статье я хотел поделиться уже работающим в продакшене вариантом начала постепенной миграции «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) в качестве программы на обозримое будущее были приняты такие пункты:
- Старый функционал до поры до времени просто не трогаем
- Новый функционал сразу пишем в виде компонент (стили, шаблоны и тесты храним там же, где код конкретного компонента)
- Пишем совсем не стесняя себя в использовании фич из ES6/ES7
- Пишем на Typescript
- Старый функционал переделываем на новый лад по мере поступления по нему задач достаточно крупных для того, чтобы провести рефакторинг.
Теперь нужно определиться с обвязкой.
Браузеры на данном этапе развития не поддерживает 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» часть приложения) с указанных в конфиге адресов, а часть, за которую теперь отвечает вебпак, держать в памяти и очень быстро перекомпилировать.
Еще немного хинтов
- Запуск сборки вебпака можно легко добавить в ваш основной процесс сборки (например в какой-нибудь 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(); }); }); // ....
- Если вам кажется что вебпак работает медленно — самое время оптимизировать билд. CommonChunks плагин, разбиение проекта на много разных логических бандлов, тюнинг настроек кэша, использование dev-server'а в конце концов.
- Также, если ваш фронтенд неотчуждим от бэкенда и кажется что по этой причине использование webpack-dev-server'а невозможно, просто знайте, что одной из стандартных компонент webpack-dev-server'а является node-http-proxy и соответсвтенно в пару движений в сторону изменения конфига вы можете настроить прокси, который ваши запросы к нему будет перенаправлять… ну например на ваш staging сервер. Или еще куда-нибудь.
- Babel — очень мощный инструмент, который остался почти совсем за рамками данной статьи. Он включает в себя огромное количество плагинов, которые вы можете использовать на своем проекте.
- Для облегчения работы людям, не учавствующим в разработке фроненда, или для упрощения контейнеризации, или для еще чего, можно создать простые хелперы для запуска всех нужных приложений. Это позволит не ставить их глобально.
./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
- Пишите тесты и документацию.
- Курение убивет.
Конец
Ну вот. В общем итоговая (на сегодняшний день) конструкция выглядит как-то вот так, это результаты где-то наверное месяца весьма непоследовательно чтения различных посвященных этой теме статей, большая часть из которых несколько устарела (например как избиваться от reference path и относительных путей импортов в них не было написано) и десятков различных экспериментов (это не первая и не вторая итерация, все по большей части в свободное от работы — на которой надо пилить фичи — и личной жизни время, конечно же). Надеюсь кому-нибудь этот экскурс будет полезен. Также жду критики и предложений по улучшению всего, что я тут понагородил (ведь на самом деле я не совсем frontend developer и наверняка многое упустил из виду). Спасибо.
Автор: Gugic