Angular Universal и NX или как подружить SSR с monorepo в Angular

Aleksandr Serenko
F.A.F.N.U.R
Published in
9 min readAug 1, 2019
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
create nx-workspace medium-stories

Перейдем в проект:

cd medium-stories

Опционально: Инициализируем git flow и создадим новую feature

git flow init
git flow init
git flow feature start add-universal
git flow new feature

Подключение Angular

Добавим поддержку Angular в Nx, но сначала укажем yarn как package manager по-умолчанию, добавив в файл angular.json опцию

"cli": {
...
"packageManager": "yarn",
...
}
angular cli package manager options

Теперь добавим 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
добавление nrwl/angular
добавление nrwl/angular
config angular app
выбор options

Закоммитим изменения

git add .
git commit -m "Add angular"
git commit

Создадим angular приложение. Базовый проект будет называться frontend-base, на основе которого будут создаваться остальные приложения.

ng g @nrwl/angular:application frontend/base
generate new angular app

Проверим работоспособность сгенерированного приложения:

ng serve
ng serve
run angular application with Nx

Для остановки dev сервера используем стандартную команду отмены
ctrl + с

Закоммитим изменения:

git add .
git commit -m "Generate angular base app"
git commit changes for new application

Добавление Universal

Для добавления universal запустим команду, где для clientProject укажем название нашего frontend проекта.

ng add @nguniversal/express-engine --clientProject frontend-base
ng add angular universal to project

Добавим зависимости, чтобы убрать сообщения о ошибках и уведомлениях

yarn add -D webpack webpack-sources @types/express

Отредактируем часть сгенерированных и обновленных файлов, так как при добавлении universal команда ng предназначена для обычного angular-cli, а не для Nx и поэтому имеет немного отличную структуру.

Сначала перенесем файлы server.ts и webpack.server.config.js

rename generated files ng for universal

в корневую папку angular приложения — apps/frontend/base

moved files for universal

Так как у нас есть 2 платформы запуска angular приложения (browser and server platforms), то переименуем webpack конфиг в — webpack.ssr.config.js, так как этот конфиг является оберткой над angular приложением и формально служит конфигом для expressjs.

rename file webpack sever

В файле angular.json, для проекта frontend-base

В разделе architect/build

change paths for browser version angular

поставим outputPath как браузерную версию

"outputPath": "dist/apps/frontend/base/browser",

В разделе architect/server

changed paths for server version angular

Поставим следующие пути

"outputPath": "dist/apps/frontend/base/server"

и добавим опцию — bundleDependencies

"bundleDependencies": "none",

В прод версии установим ее в “all

"bundleDependencies": "all"
added options for server build

Далее отредактируем файл 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
rename main file for start application

В angular.json поправим main файл:

"main": "apps/frontend/base/src/main.browser.ts",
changed path for main file on angular application

В файле 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
run browser version angular
result run angular application with source code first page

Теперь соберем SSR версию:

yarn run build:ssr
build ssr version angular application

Запустим команду:

yarn run serve:ssr
run ssr version angular
result ssr version for angular application

Как можно убедиться, с сервера вернулась отрисованная страница уже с контентом.

Закончим feature и закоммитим.

git flow feature finish
git flow feature finish

Исходники

Все исходники находятся на github, в репозитории https://github.com/Fafnur/medium-stories.

Для того, чтобы посмотреть состояние проекта на момент написания статьи, нужно выбрать соответствующий tag — base.

git checkout base

Следующие статьи:

  1. Статья о настройке Prettier, tslint и eslint в Angular
  2. Статья про LocalStorage, SessionStorage в Angular Universal
  3. Статья о локализации в Angular с помощью ngx-translate
  4. Статья про Redux в Angular с помощью Ngrx или создание Store в Angular

--

--

Aleksandr Serenko
F.A.F.N.U.R

Senior Front-end Developer, Angular evangelist, Nx apologist, NodeJS warlock