Современное front-end-приложение на Angular должно включать в себя следующие характеристики:
- Возможность использования типизированного JS — Typescript
- Обеспечение удобства и производительности разработки с помощью HMR (hot module replacement);
- Модульность приложений и возможность отложенной загрузки модулей (Lazy Loading);
- AoT — режим (ahead-of-time), повышающий производительность приложения.
Существует много вариантов сборки, решающих эти задачи (angular cli, A2 seed и т. д.). Обычно они имеют сложную структуру, плохо настраиваются/расширяются и представляют собой монолит, который невозможно изменить.
В статье я расскажу, как совместить Angular 2+ с webpack и разобраться со всеми этапами сборки/разработки.
Вы удивитесь, как это просто.
Финальное приложение.
Постараюсь по максимуму осветить тонкие моменты. Итак, поехали.
1) Создаем проект
Создаем папку с проектом, чтобы никто не догадался, назовем ее angular-project.
(Использую Webstorm, однако можете проделывать то же самое в вашем редакторе)

2) Окружение
Устанавливаем node.js (npm в комплекте по умолчанию).
Создаем package.json, разумеется, количество подключаемых на проект модулей потенциально стремится к бесконечности, однако я оставлю только необходимые, на мой взгляд, для полноценной разработки. Модулей много, постараюсь обосновать, зачем они нужны.
{ "name": "angular-project", "version": "1.0.0", "description": "angular scaffolding", "author": "maxim1006", "license": "MIT", "dependencies": { //блок необходимых для Angular модулей "@angular/animations": "^4.3.6", "@angular/common": "^4.3.6", "@angular/compiler": "^4.3.6", "@angular/compiler-cli": "^4.3.6", "@angular/core": "^4.3.6", "@angular/forms": "^4.3.6", "@angular/http": "^4.3.6", "@angular/platform-browser": "^4.3.6", "@angular/platform-browser-dynamic": "^4.3.6", "@angular/router": "^4.3.6", //модули для hmr "@angularclass/hmr": "^2.1.1", "@angularclass/hmr-loader": "^3.0.2", //polyfills для es5 "core-js": "^2.5.0", //модуль для работы декораторов в браузере "reflect-metadata": "^0.1.8", //модуль для работы с реактивным программированием "rxjs": "^5.4.3", //типизация и доп. возможности для js "typescript": "2.3.4", //зоны в js, очень интересно, обязательно почитайте "zone.js": "^0.8.17" }, "devDependencies": { //для сборки AoT (Ahead-of-Time Compilation) angular "@ngtools/webpack": "^1.6.2", //поддержка типизации, чтобы не ругался typescript "@types/es6-shim": "^0.31.35", "@types/jasmine": "^2.5.54", "@types/node": "^7.0.43", //routing в приложении "angular-router-loader": "^0.6.0", //так как на выходе получится бандл со встроенными темплейтами "angular2-template-loader": "^0.6.2", //чтобы не писать префиксы в css "autoprefixer": "^6.3.7", //для оптимизации работы typescript в webpack "awesome-typescript-loader": "^3.2.3", //если вдруг надо скопировать папку/файл "copy-webpack-plugin": "^4.0.1", //для работы с css "css-loader": "^0.28.5", "css-to-string-loader": "^0.1.2", //es6 polyfills "es6-shim": "^0.35.1", //для мобильной разработки "hammerjs": "^2.0.8", //чтобы webpack работал с html "html-webpack-plugin": "^2.29.0", //препроцессор для более удобной работы со стилями "less": "^2.7.2", "less-loader": "^4.0.3", //по завершению сборки сможем вызвать коллбек "on-build-webpack": "^0.1.0", //вставляет результат работы webpack на страничку "raw-loader": "^0.5.1", //для работы со стилями "postcss-loader": "^1.3.3", "style-loader": "^0.17.0", //линтер "tslint": "^5.7.0", //если надо что-нибудь удалить "rimraf": "^2.6.1", //чтобы вставить картинки в css в виде base64 "url-loader": "^0.5.8", //webpack "webpack": "^3.5.5", //и его встроенный express сервер "webpack-dev-server": "^2.7.1" }, //когда введем в терминале эти команды с помощью npm run __command__ (например npm run serve)выполняться соответствующие команды) "scripts": { //Запускаем сервер. При каждом сохранении в вашем редакторе при работе с файлами проекта страничка будет перезагружаться, и вы будете видеть результат. Расскажем подробнее о команде. Для начала запускаем веб-сервер с данными настройками. Если мы хотим видеть в консоли, что с ним происходит (что бандлится и т. д.), используем (флаг --profile); если хотим, чтобы при сохранении в редакторе webpack автоматически обновлял результат, используем (--watch); ну а если хотим видеть проценты компиляции, можем опционально использовать (флаг –-progress). "serve": "webpack-dev-server --config ./webpack.config.js --profile --watch --progress", //то же, что и serve, но без перезагрузки страницы "hmr": "webpack-dev-server --config ./webpack.config.js --profile --watch --progress", //создаем prod папочку с нашим проектом "prod": "npm run aot", //посмотреть как наш проект выглядит в prod, мало ли что "prodServer": "webpack-dev-server --config ./webpack.config.js --open", //очищаем ./dist на всякий случай "clean": "rimraf ./dist", //нужно, чтобы в webpack.js понять, что мы делаем aot. Делать это необязательно, но для наглядности нужно. "aot": "webpack", //тесты для приложения "test": "karma start" } }
3) Установка модулей
Через терминал заходим в папку, где лежит package.json, и вводим команду npm i.
4) Установка глобальных модулей
Так как мы используем команды rimraf, webpack и webpack-dev-server в терминале, то придется объяснить их вашему ПК с помощью команды npm i rimraf webpack webpack-dev-server -g
После этих манипуляций наш проект пополнился папкой node_modules.

5) README.md
Создаем README.md, куда кроме ссылки на эту статью можно добавить особенности разработки вашего проекта.
6) Линтер
Создаем tslint.json, тут не буду останавливаться, так как нет серебряной пули.
{ "rules": { "no-unused-variable": true, "curly": true, "no-console": [ true, "log", "error", "debug", "info" ], "no-debugger": true, "no-duplicate-variable": true, "no-eval": true, "no-invalid-this": true, "no-shadowed-variable": true, "no-unsafe-finally": true, "no-var-keyword": true, "triple-equals": [ true, "allow-null-check", "allow-undefined-check" ], "semicolon": [ true, "always", "ignore-interfaces" ], "variable-name": [ true, "ban-keywords", "check-format", "allow-leading-underscore" ] } }
7) PostCss
Создадим postcss.config.js, чтобы не писать префиксы к стилям
module.exports = { plugins: [ require('autoprefixer')({ browsers: [ 'last 2 versions' ], cascade: true }) ] };
Дальше пойдут немного более сложные манипуляции, пожалуйста, сфокусируйтесь.
8) Настройка Typescript tsconfig.json
Так как разработка A2+, на мой взгляд, невозможна без typescript, его надо настроить. Настройки обычные, однако если будут вопросы, спрашивайте в комментариях.
{ //Настраиваем компилятор typescript "compilerOptions": { "target": "es5", "module": "es2015", "declaration": false, "moduleResolution": "node", "sourceMap": true, "emitDecoratorMetadata": true, "experimentalDecorators": true, "removeComments": false, "noImplicitAny": false, "suppressImplicitAnyIndexErrors": true, "skipLibCheck": true, "lib": ["es6", "dom"], "outDir": "./dist/", "typeRoots": [ "./node_modules/@types/" ] }, "compileOnSave": false, "buildOnSave": false, //наше приложение будет лежать в папке ./src "include": [ "./src/**/*" ], //запрещаем typescript обращать внимание на: "exclude": [ "node_modules/*", "dist/*", "dist-serve/*", "node/*", "**/*.spec.ts" ], //настраиваем loader для webpack "awesomeTypescriptLoaderOptions": { "forkChecker": true, "useWebpackText": true, "useCache": true }, //нужно для AoT "angularCompilerOptions": { "genDir": ".", "skipMetadataEmit" : true } }
9) Настройка Webpack
Самое сложное – дать понять webpack, что мы от него хотим. Для этого создаем webpack.conf.js, без паники, постараюсь все объяснить
"use strict"; //это node модули и webpack плагины, которые понадобятся нам в разработке const path = require('path'); const fs = require('fs'); const webpack = require('webpack'); const WebpackOnBuildPlugin = require('on-build-webpack'); const CopyWebpackPlugin = require('copy-webpack-plugin'); const AotPlugin = require('@ngtools/webpack').AotPlugin; //помните, в package.json были команды serve, hmr, prod и т. д.? так вот, текущую команду (например, если вы введете npm run serve, то команда будет называться ‘serve’) можно получить и обработать вот так: const ENV = process.env.npm_lifecycle_event ? process.env.npm_lifecycle_event : ''; const isStatic = ENV === 'serve'; const isHmr = ENV === 'hmr'; const isProd = ENV === 'prod'; const isTest = ENV === 'test'; const isAot = ENV.includes('aot'); const isProdServer = ENV.includes('prodServer'); //в зависимости от команды, мы будем объяснять webpack что делать //обычно из webpack.conf.js экспортируется функция, возвращающая объект с настройками module.exports = function makeWebpackConfig() { console.log(`You are in ${ENV} mode`); //напомнить что мы запустили let config = {}; //главный объект с настройками //если вдруг кто-то выполнит команду npm run prodServer, не выполнив предварительно npm run prod, кидаем напоминалку if (isProdServer) { if (!fs.existsSync('./dist')) { throw "Can't find ./dist, please use 'npm run prod' to get it."; } } //подключаем sourcemaps if (isHmr || isStatic) { config.devtool = 'inline-source-map'; } else { config.devtool = 'source-map'; } //обозначаем главный файл, который будет создавать webpack. Этот файл доступен в index.html по пути “./ng-app.js” config.entry = { 'ng-app': './src/app/ng-main.ts' }; //специально для AoT режима нужно создать другой файл с другим наполнением, так надо… if (isAot) { config.entry['ng-app'] = './src/app/ng-main-aot.ts'; } // Имя файла, который создаст webpack будет 'ng-app’, так как задали filename: '[name].js', также когда запустим prod сборку, результирующий бандл попадет в папку './dist', это указали с помощью path: root('./dist') config.output = isTest ? {} : { path: root('./dist'), //root – всего лишь функция, для создания правильных путей относительно папки, в которой находится webpack.config.js filename: '[name].js' }; //в свойстве entry при настройке webpack обязательно нужно задать какой-нибудь файл, иначе возникнет ошибка, но в режиме prodServer нам нужно лишь посмотреть на нашу prod сборку. По этой причине и создаем поддельный файл, чтобы сервер ни на что, кроме статики, не отвлекался. Можно в корень проекта, рядом с webpack.conf.js, положить пустой файл webpack-prod-server.js, чтобы в логи сервера не попадала ошибка, что этого файла нет, хотя и без него сервер будет работать. if (isProdServer) { config.entry = { 'server': './webpack-prod-server.js' }; config.output = {}; } //указываем расширения файлов, с которыми webpack будет работать config.resolve = { extensions: ['.ts', '.js', '.json', '.html', '.less', '.svg'] }; //определяем так называемые loaders: если будут вопросы по ним, отвечу в комментариях. Если коротко, тут готовый пример для превращения ts в js, html вставляем в js бандл, less компилируем в css и вставляем в js бандл, картинки до 10 кб в base64 и вставляем в js бандл. config.module = { rules: [ { test: /\.ts$/, use: isAot ? [{loader: '@ngtools/webpack'}] : [ { loader: 'awesome-typescript-loader?' }, { loader: 'angular2-template-loader' }, { loader: 'angular-router-loader' } ].concat(isHmr ? '@angularclass/hmr-loader?pretty=' + !isProd + '&prod=' + isProd : []), exclude: [/\.(spec|e2e|d)\.ts$/] }, { test: /\.html$/, loader: 'raw-loader', exclude: [/node_modules\/(?!(ng2-.+))/, root('src/index.html')] }, { test: /\.(png|jpe?g|gif|svg|woff|woff2|ttf|eot|ico)(\?v=[0-9]\.[0-9]\.[0-9])?$/, loader: "url-loader?name=[name].[ext]&limit=10000&useRelativePath=true" }, { test: /\.less$/, use: [ {loader: "css-to-string-loader"}, {loader: "css-loader"}, {loader: "postcss-loader"}, {loader: "less-loader"} ] } ] }; //если работаем не в режиме тестирования, то подключаем webpack плагины if (!isTest) { config.plugins = [ //не останавливать webpack warcher при ошибках new webpack.NoEmitOnErrorsPlugin(), //передать текущий режим в наши .ts файлы, как их получить в .ts файлах увидите чуть позже new webpack.DefinePlugin({ 'process.env': { 'STATIC': isStatic, 'HMR': isHmr, 'PROD': isProd, 'AOT': isAot } }), //сделать что-то по окончании сборки new WebpackOnBuildPlugin((stats) => { console.log('build is done'); }) ] //если работаем в режиме hmr, то подключить плагин для hmr .concat(isHmr ? new webpack.HotModuleReplacementPlugin() : []); } //если вы вызовете команду ‘npm run prod’, то запустите процесс создания prod сборки с AoT if (isAot) { config.plugins = [ //нужно для AoT режима new AotPlugin({ tsConfigPath: './tsconfig.json', entryModule: root('src/app/app.module.ts#AppModule') }), //Оптимизируем полученный бандл new webpack.optimize.UglifyJsPlugin({ compress: { warnings: false, screw_ie8: true, conditionals: true, unused: true, comparisons: true, sequences: true, dead_code: true, evaluate: true, if_return: true, join_vars: true }, output: { comments: false }, sourceMap: true }), //Копируем нужные нам файлы в ./dist папку (js бандл туда положит сам webpack, а мы перенесем то, что нам понадобится дополнительно) new CopyWebpackPlugin([ {from: 'index.html', context: './src'}, {from: 'assets/themes/base/fonts/**/*', context: './src'}, {from: 'assets/themes/base/images/other-images/**/*', context: './src'}, ]), new WebpackOnBuildPlugin((stats) => { console.log('build in aot is done'); }) ]; } //Ну и наконец настроим наш webpack-dev-server config.devServer = { contentBase: isProdServer ? "./dist" : "./src",//корневая папка сервера, в prod режиме в ./dist, в режиме разработки в ./src headers: { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS", "Access-Control-Allow-Headers": "X-Requested-With, content-type, Authorization" }, //стандартные заголовки для rest запросов historyApiFallback: true, //включаем HTML5 history api, очень удобно 1ой строкой compress: true,//включаем gzip quiet: false, //ничего лишнего нам выводить в логи не нужно inline: isHmr || isStatic || isProdServer, //inline mode hot: isHmr, //включаем hmr, если в hmr режиме stats: "minimal", port: 9000, //модное окно смерти при ошибке от Webpack overlay: { errors: true }, //Опции для webpack warcher watchOptions: { aggregateTimeout: 50, ignored: /node_modules/ } }; return config; }; //делаем правильный путь от текущей директории function root(__path = '.') { return path.join(__dirname, __path); }
10) Структура src
Сейчас наш проект выглядит так, кроме папки src

Создаем структуру в папке src:

Пара комментариев: в папке app будет лежать наше angular приложение, в папке assets вспомогательные файлы, index.html просто кладем в src. В assets поддержим темизацию и разобьем папки для удобной работы со шрифтами, картинками, стилями.
В нашей компании мы используем БЭМ методологию, немного переработанную и более оптимальную, на наш взгляд. base.less – агрегирующий .less файл для base темы:
// Common @import "themes/base/styles/common/normalize"; @import "themes/base/styles/common/colors"; @import "themes/base/styles/common/common"; @import "themes/base/styles/common/fonts"; @import "themes/base/styles/common/vars"; // Blocks // (please, add new blocks in alphabetical order) @import "themes/base/styles/blocks/app-component";
Заметим, что, на наш взгляд, следует разносить функциональную и стилевую части приложения: это решает ряд проблем как сборки, так и поддержки проекта. Если использовать БЭМ и парадигму один блок – один less файл, то проблем у подхода не обнаруживается. Однако есть куча альтернатив. Более подробно покопаться в assets можно в приложении, к этому посту. Вопросы задавайте в комментариях к статье.
11) index.hml
index.html – стал безумно прост в A2+ приложениях
<!DOCTYPE html> <html> <head> <base href="/"> //нужно для A2+ routing <meta charset="utf-8"> <title>Landing</title> <meta name="viewport" content="width=device-width, initial-scale=1"> <link rel="icon" type="image/x-icon" href="/img/favicon.ico"> </head> <body> <app-component>Loading...</app-component> <script type="text/javascript" src="./ng-app.js"></script> </body> </html>
12) Angular app
Вздохнем поглубже, все сложное мы уже сделали, теперь остался сам фреймворк)
Создадим структуру в папке app:

На первый взгляд – ребус.
Однако если вы прошли хотя бы Angular 2+ Tutorial, то все это вам уже знакомо. Для остальных же краткие комментарии: все приложение разбито на модули, фреймворк даже предоставляет такую сущность – module. Есть главный модуль – app.module.ts, есть дополнительные модули, расширяющие функционал приложения. Большая часть приложений будет иметь home, lazy и shared модули. Названия модулей, разумеется, опциональны, однако при соблюдении правил наименования у вас не возникнет проблем с расширяемостью приложения.
Про сам фреймворк говорить много не будем, есть отличная документация. Лучше сосредоточимся на тонких моментах:
ng-main.ts
С него все начинается
import './ng-polyfills'; //чтобы работало в ie 9+ import … //в настройках webpack мы прокидывали переменные, тут их ловим if (process.env.STATIC) { //console.log("******************You are in Dev mode******************"); platformBrowserDynamic().bootstrapModule(AppModule).then(():any => {}); } else if (process.env.HMR) { //нужно для hmr в Angular //console.log("******************You are in HMR mode******************"); bootloader(main); } export function main() { return platformBrowserDynamic() .bootstrapModule(AppModule) }
ng-main-aot.ts для AoT
Для AoT (Ahead-of-Time Compilation) режима создаем другой главный файл ng-main-aot.ts, так нужно…
import … console.log("******************You are in prod mode******************"); enableProdMode(); platformBrowser() .bootstrapModuleFactory(<any>AppModuleNgFactory) .catch(error=>console.log(error));
HMR, стили, hammerjs
HMR, стили нашего приложения (на всякий случай оставил пример подключения картинок) и настройки hammerjs для мобильной разработки подключаем в app.module.ts таким образом:
require("style-loader!../assets/base.less"); //так подключаем стили через webpack import … //настраиваем hammer.js export class MyHammerConfig extends HammerGestureConfig { overrides = <any>{ 'swipe': {velocity: 0.4, threshold: 20} } } @NgModule({ declarations: [ AppComponent, ], imports: [ BrowserModule, HomeModule, NgRoutingModule ], providers: [ ], bootstrap: [ AppComponent ] }) export class AppModule { constructor(public appRef: ApplicationRef) {} hmrOnInit(store) { if (!store || !store.state) return; if ('restoreInputValues' in store) { store.restoreInputValues(); } this.appRef.tick(); delete store.state; delete store.restoreInputValues; } hmrOnDestroy(store) { let cmpLocation = this.appRef.components.map(cmp => cmp.location.nativeElement); store.disposeOldHosts = createNewHosts(cmpLocation); store.state = {data: 'yolo'}; store.restoreInputValues = createInputTransfer(); removeNgStyles(); } hmrAfterDestroy(store) { store.disposeOldHosts(); delete store.disposeOldHosts; } }
Lazy loading
Lazy loading модулей подключаем в ng-routing.module.ts
import … const routes: Routes = [ {path: '', redirectTo: '/home', pathMatch: 'full'}, {path: 'home', component: HomeComponent}, //так подключаем lazy модули, отдельные .js файлы webpack для них создаст сам {path: 'lazy', loadChildren: './modules/lazy/lazy.module#LazyModule'}, {path: '**', component: PageNotFoundComponent}, ]; @NgModule({ imports: [ RouterModule.forRoot(routes) ], exports: [ RouterModule ] }) export class NgRoutingModule { }
После подключения lazy модуля в роутере необходимо в модуле, который мы хотим загружать отложено, сделать (на примере lazy.module.ts) следующее:
import … const routes: Routes = [ {path: '', component: LazyComponent}, ]; @NgModule({ imports: [SharedModule, RouterModule.forChild(routes)], exports: [LazyComponent], declarations: [LazyComponent] }) export class LazyModule {}
Хм… ну вот в принципе и все. Покопаться в app папке можно в приложении к данному посту.
Для разработки с перезагрузкой странички на каждое изменение кода в редакторе, пишем в терминале, находясь папке с package.json: npm run serve
То же, но без перезагрузки странички: npm run hmr
Делаем prod сборку с AoT: npm run prod
Запускаем статический сервер, чтобы посмотреть prod: npm run prodServer
Почистить ./dist папку: npm run clean
Всего несколько шагов и у нас работают: webpack сборка с Angular 4, AoT, HMR, Lazy loading. Все, включая шаблоны и стили, аккуратно кладется в бандл и оптимизируется.
Разумеется, эту конфигурацию можно расширять, улучшать, менять, однако на мой взгляд, ее вполне достаточно, чтобы смело начать разрабатывать с Angular 2+.
P.S.
Небольшая реклама АoT: отличный boost к производительности вашего SPA приложения на Angular.

Спасибо за внимание.

