Angular Universal и NX или как подружить SSR с monorepo в Angular
Обновление: 14 февраля 2020: Данная статья актуальна, но в Angular 9 немного изменился механизм сборки SSR. Подробнее в статье — Angular 9, Universal и Nx. Новые правила сборки SSR приложения.
Обновление: 25 марта 2021: Новая статья по созданию монорепозитория Nx — Banx. Создание Nx workspace для Angular, входящая в цикл статьей по разработке enterprise приложения на Angular.
Одним из лучших подходов для использования monorepo в Angular является Nx. В данной статье мы рассмотрим как создать и настроить SSR приложение на основе Nx.
Немного о возможностях NX, который позволяет:
— создавать несколько имплементаций приложений;
— создавать библиотеки, которые могут быть использованы как в Angular, так и в других JavaScript/Typescript проектах;
— реализована поддержка создания backend
с помощью ExpressJS
/NestJS
;
— есть возможность разрабатывать UI
с использованием React
или web components, не используя Angular.
С полным списком возможностей можно ознакомиться на официальном сайте проекта Nx.
Создание workspace
Создадим workspace и назовем его medium-stories, со следующими опциями:
— What to create in the new workspace: empty
— CLI to power the Nx workspace: Angular CLI
— Which stylesheet format would you like to use: SASS(.scss)
yarn create nx-workspace medium-stories
Перейдем в проект:
cd medium-stories
Опционально: Инициализируем git flow и создадим новую feature
git flow init
git flow feature start add-universal
Подключение Angular
Добавим поддержку Angular в Nx, но сначала укажем yarn как package
manager
по-умолчанию, добавив в файл angular.json
опцию
"cli": {
...
"packageManager": "yarn",
...
}
Теперь добавим angular, со следующими опциями:
— Which Unit Test Runner would you like to use for the application: Jest
— Which E2E Test Runner would you like to use: Cypress
ng add @nrwl/angular
Закоммитим изменения
git add .
git commit -m "Add angular"
Создадим angular приложение. Базовый проект будет называться frontend-base
, на основе которого будут создаваться остальные приложения.
ng g @nrwl/angular:application frontend/base
Проверим работоспособность сгенерированного приложения:
ng serve
Для остановки dev
сервера используем стандартную команду отменыctrl + с
Закоммитим изменения:
git add .
git commit -m "Generate angular base app"
Добавление Universal
Для добавления universal запустим команду, где для clientProject
укажем название нашего frontend
проекта.
ng add @nguniversal/express-engine --clientProject frontend-base
Добавим зависимости, чтобы убрать сообщения о ошибках и уведомлениях
yarn add -D webpack webpack-sources @types/express
Отредактируем часть сгенерированных и обновленных файлов, так как при добавлении universal команда ng предназначена для обычного angular-cli, а не для Nx и поэтому имеет немного отличную структуру.
Сначала перенесем файлы server.ts
и webpack.server.config.js
в корневую папку angular приложения — apps/frontend/base
Так как у нас есть 2 платформы запуска angular приложения (browser
and server platforms
), то переименуем webpack
конфиг в — webpack.ssr.config.js
, так как этот конфиг является оберткой над angular приложением и формально служит конфигом для expressjs
.
В файле angular.json
, для проекта frontend-base
В разделе architect/build
поставим outputPath
как браузерную версию
"outputPath": "dist/apps/frontend/base/browser",
В разделе architect/server
Поставим следующие пути
"outputPath": "dist/apps/frontend/base/server"
и добавим опцию — bundleDependencies
"bundleDependencies": "none",
В прод версии установим ее в “all
”
"bundleDependencies": "all"
Далее отредактируем файл apps/frontend/base/webpack.ssr.config.js
Добавим после импорта webpack
ряд констант
const distFolder = path.join(process.cwd(), 'dist');
const appsFolder = path.join(process.cwd(), 'apps');
const appName = 'frontend/base';
В entry
изменим точку входа на:
server: path.join(appsFolder, appName, 'server.ts')
В externals
изменим путь до нашего проекта:
'../../../dist/apps/frontend/base/server/main': 'require("./server/main")'
В output
изменим путь:
path: path.join(distFolder, 'apps', appName),
В plugins
поставим соответствующие пути:
path.join(distFolder, 'apps', appName),
В итоге, файл конфига будет выглядеть следующим образом:
// Work around for https://github.com/angular/angular-cli/issues/7200
const path = require('path');
const webpack = require('webpack');
const distFolder = path.join(process.cwd(), 'dist');
const appsFolder = path.join(process.cwd(), 'apps');
const appName = 'frontend/base';
module.exports = {
mode: 'none',
entry: {
// This is our Express server for Dynamic universal
server: path.join(appsFolder, appName, 'server.ts')
},
externals: {
'../../../dist/apps/frontend/base/server/main': 'require("./server/main")'
},
target: 'node',
resolve: { extensions: ['.ts', '.js'] },
optimization: {
minimize: false
},
output: {
// Puts the output at the root of the dist folder
path: path.join(distFolder, 'apps', appName),
filename: '[name].js'
},
module: {
noParse: /polyfills-.*\.js/,
rules: [
{ test: /\.ts$/, loader: 'ts-loader' },
{
// Mark files inside `@angular/core` as using SystemJS style dynamic imports.
// Removing this will cause deprecation warnings to appear.
test: /(\\|\/)@angular(\\|\/)core(\\|\/).+\.js$/,
parser: { system: true },
},
]
},
plugins: [
new webpack.ContextReplacementPlugin(
// fixes WARNING Critical dependency: the request of a dependency is an expression
/(.+)?angular(\\|\/)core(.+)?/,
path.join(distFolder, 'apps', appName), // location of your src
{} // a map of your routes
),
new webpack.ContextReplacementPlugin(
// fixes WARNING Critical dependency: the request of a dependency is an expression
/(.+)?express(\\|\/)(.+)?/,
path.join(distFolder, 'apps', appName),
{}
)
]
};
Далее отредактируем файл apps/frontend/site/server.ts
.
Добавим константы для путей к нашему приложению, изменив DIST_FOLDER
:
const DIST_FOLDER = join(process.cwd(), 'dist', 'apps');
const appName = 'frontend/base';
const appBrowser = join(DIST_FOLDER, appName, 'browser');
Заменим везде DIST_FOLDER
на корректные пути.
Изменим путь для require, заметим, что путь должен быть строкой и здесь мы не используем авто подстановку значений, так как иначе сборщик (webpack
) не сможет корректно подставить пути:
require('../../../dist/apps/frontend/base/server/main');
Исправим пути для шаблонов:
app.set('views', appBrowser);
Для статики:
// Serve static files from /browser
app.get(
'*.*',
express.static(appBrowser, {
maxAge: '1y'
})
);
В рендоре шаблонов:
...
res.render(join(appBrowser, 'index.html'), {
...
Опционально: Импортируем токены для REQUEST
, RESPONSE
(понадобиться в следующих статьях).
import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens';
и добавим в DI
, переопределив стандартный рендер:
app.get('*', (req, res) => {
res.render(join(appBrowser, 'index.html'), {
req,
res,
providers: [
{
provide: REQUEST,
useValue: req
},
{
provide: RESPONSE,
useValue: res
}
]
});
});
В итоге файл server.ts
будет выглядеть:
/**
* *** NOTE ON IMPORTING FROM ANGULAR AND NGUNIVERSAL IN THIS FILE ***
*
* If your application uses third-party dependencies, you'll need to
* either use Webpack or the Angular CLI's `bundleDependencies` feature
* in order to adequately package them for use on the server without a
* node_modules directory.
*
* However, due to the nature of the CLI's `bundleDependencies`, importing
* Angular in this file will create a different instance of Angular than
* the version in the compiled application code. This leads to unavoidable
* conflicts. Therefore, please do not explicitly import from @angular or
* @nguniversal in this file. You can export any needed resources
* from your application's main.server.ts file, as seen below with the
* import for `ngExpressEngine`.
*/
import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens';
import 'zone.js/dist/zone-node';
import * as express from 'express';
import { join } from 'path';
// Express server
const app = express();
const PORT = process.env.PORT || 4000;
const DIST_FOLDER = join(process.cwd(), 'dist', 'apps');
const appName = 'frontend/base';
const appBrowser = join(DIST_FOLDER, appName, 'browser');
// * NOTE :: leave this as require() since this file is built Dynamically from webpack
const {
AppServerModuleNgFactory,
LAZY_MODULE_MAP,
ngExpressEngine,
provideModuleMap
} = require('../../../dist/apps/frontend/base/server/main');
// Our Universal express-engine (found @ https://github.com/angular/universal/tree/master/modules/express-engine)
app.engine(
'html',
ngExpressEngine({
bootstrap: AppServerModuleNgFactory,
providers: [provideModuleMap(LAZY_MODULE_MAP)]
})
);
app.set('view engine', 'html');
app.set('views', appBrowser);
// Example Express Rest API endpoints
// app.get('/api/**', (req, res) => { });
// Serve static files from /browser
app.get(
'*.*',
express.static(appBrowser, {
maxAge: '1y'
})
);
// All regular routes use the Universal engine
app.get('*', (req, res) => {
res.render(join(appBrowser, 'index.html'), {
req,
res,
providers: [
{
provide: REQUEST,
useValue: req
},
{
provide: RESPONSE,
useValue: res
}
]
});
});
// Start up the Node server
app.listen(PORT, () => {
console.log(`Node Express server listening on http://localhost:${PORT}`);
});
И в конце поменяем файлы связанные не посредственно с Angular.
Сначала переименуем файл apps/frontend/base/src/main.ts
:
main.browser.ts
В angular.json
поправим main
файл:
"main": "apps/frontend/base/src/main.browser.ts",
В файле apps/frontend/base/src/app/app.module.ts
оставим лишь только общие данные и уберем bootstrap
:
import { HttpClientModule } from '@angular/common/http';
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { BrowserModule, BrowserTransferStateModule } from '@angular/platform-browser';
import { AppComponent } from './app.component';
@NgModule({
imports: [
BrowserModule.withServerTransition({ appId: 'medium-stories' }),
HttpClientModule,
RouterModule.forRoot([], { initialNavigation: 'enabled' }),
BrowserTransferStateModule
],
declarations: [AppComponent]
})
export class AppModule {}
Создадим файл apps/frontend/site/src/app/app.browser.module.ts
с содержимым:
import { NgModule } from '@angular/core';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';import { AppModule } from './app.module';
import { AppComponent } from './app.component';@NgModule({
imports: [
AppModule,
BrowserAnimationsModule,
],
bootstrap: [AppComponent]
})
export class AppBrowserModule {}
Файл apps/frontend/base/src/app/app.server.module.ts
приведем к виду:
import { NgModule } from '@angular/core';
import { ServerModule, ServerTransferStateModule } from '@angular/platform-server';
import { ModuleMapLoaderModule } from '@nguniversal/module-map-ngfactory-loader';
import { AppComponent } from './app.component';
import { AppModule } from './app.module';
@NgModule({
imports: [
AppModule,
ServerModule,
ModuleMapLoaderModule,
ServerTransferStateModule
],
bootstrap: [AppComponent]
})
export class AppServerModule {}
И теперь в apps/frontend/base/src/main.browser.ts
изменим базовый модуль на браузерный:
import { enableProdMode } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { AppBrowserModule } from './app/app.browser.module';
import { environment } from './environments/environment';
if (environment.production) {
enableProdMode();
}
document.addEventListener('DOMContentLoaded', () => {
platformBrowserDynamic()
.bootstrapModule(AppBrowserModule)
.catch(err => console.error(err));
});
Тестирование сборки
С настройкой Universal
закончили, теперь проверим, что все платформы работают.
Поправим npm
скрипты в package.json
:
"compile:server": "webpack --config apps/frontend/base/webpack.ssr.config.js --progress --colors",
"serve:ssr": "node dist/apps/frontend/base/server.js",
"build:ssr": "yarn run build:client-and-server-bundles && yarn run compile:server",
"build:client-and-server-bundles": "ng build --prod && ng run frontend-base:server:production"
Сначала запустим и посмотрим браузерную версию:
ng serve
Теперь соберем SSR
версию:
yarn run build:ssr
Запустим команду:
yarn run serve:ssr
Как можно убедиться, с сервера вернулась отрисованная страница уже с контентом.
Закончим feature и закоммитим.
git flow feature finish
Исходники
Все исходники находятся на github
, в репозитории https://github.com/Fafnur/medium-stories.
Для того, чтобы посмотреть состояние проекта на момент написания статьи, нужно выбрать соответствующий tag — base.
git checkout base
Следующие статьи: