Разрабатывая Angular приложение, все мы рано или поздно сталкиваемся с проблемой SEO. Существуют различные пути решения, но если вы хотите высокую производительность, легкость обслуживания, отсутствие необходимости в платных NodeJS серверах, то вас вероятно заинтересует Angular Server Side Pre-rendering.Pre-rendering – это server side процесс, который подготовит SEO оптимизированные странички в виде готовых html файлов. Специальный скрипт открывает заранее подготовленные url страниц, ждет окончания загрузки и выполнения каждой из них, а затем полученную верстку сохраняет в html файлы, которые будут доступны по указанному пути.
Именно таким способом я реализовал SEO в своем проекте methodist.io. Поэтому далее я буду использовать его как пример.
Общая структура собранного проекта будет выглядеть так:
Установка зависимостей
Данный гайд рассчитан, что вы работаете с Angular v6+ проектом.
Установим angular universal. Он будет основой нашего пререндеринга.
ng g universal --client-project firestarter
После выполнения этой команды, проект будет настроен автоматически для работы с SSR. Имя проекта для Angular universal я указал app-server
.
Следующий шаг можно пропустить, если вы не используете module lazy loading.
npm i @nguniversal/module-map-ngfactory-loader --save
Обновим файл src/app/app.module.ts
import { BrowserTransferStateModule } from '@angular/platform-browser'; @NgModule({ imports: [ // ... BrowserModule.withServerTransition({ appId: 'firestarter' }), BrowserTransferStateModule ] })
Еще обновим src/app/app.server.module
import { ServerModule, ServerTransferStateModule } from '@angular/platform-server'; import { ModuleMapLoaderModule } from '@nguniversal/module-map-ngfactory-loader'; @NgModule({ imports: [ // ... ServerTransferStateModule ModuleMapLoaderModule // <-- нужен для lazy-loaded модулей ], })
Установка и настройка пререндеринга
Теперь будет происходить магия. Для начала установим webpack:
npm i -D webpack-cli fs-extra http-server ts-loader # если вы используете firebase npm i -D ws xmlhttprequest
Все эти пакеты нужны для того, чтобы скомпилированный сервер node.js при запуске загружал роуты из файла static.paths.ts
в корне проекта:
export const ROUTES = [ '/', '/profile', '/terms', '/privacy' ];
и генерировал страницы внутри папки dist
.
Создадим конфиг webpack.prerender.config.js
в корне проекта:
const path = require('path'); const webpack = require('webpack'); const APP_NAME = 'methodist'; //Имя вашего проекта module.exports = { entry: { prerender: './prerender.ts' }, resolve: { extensions: ['.js', '.ts'] }, mode: 'development', devtool: 'hidden-source-map', target: 'node', externals: [/(node_modules|main\..*\.js)/], output: { path: path.join(__dirname, `dist/${APP_NAME}`), filename: '[name].js' }, module: { rules: [ { test: /\.ts$/, loader: 'ts-loader' } ] }, plugins: [ new webpack.ContextReplacementPlugin( /(.+)?angular(\\|\/)core(.+)?/, path.join(__dirname, 'src'), // location of your src {} // a map of your routes ), new webpack.ContextReplacementPlugin( /(.+)?express(\\|\/)(.+)?/, path.join(__dirname, 'src'), {} ), new webpack.NormalModuleReplacementPlugin( /@swimlane(\\|\/)dragula(\\|\/)dragula.js/, path.join(__dirname, 'src/server-mocks/dragula/dragula.js') ), ] }
Ну и теперь самое важное, скрипт пререндеринга и сохранение полученных html:
import 'zone.js/dist/zone-node'; import 'reflect-metadata'; import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'; import { join } from 'path'; import { enableProdMode } from '@angular/core'; // С включенным prod mode сервер быстрее генерирует страницы enableProdMode(); import { provideModuleMap } from '@nguniversal/module-map-ngfactory-loader'; import { renderModuleFactory } from '@angular/platform-server'; import { ROUTES } from './static.paths'; (global as any).XMLHttpRequest = require('xmlhttprequest').XMLHttpRequest; const { AppServerModuleNgFactory, LAZY_MODULE_MAP } = require('./dist/methodist-server/main'); const BROWSER_FOLDER = join(process.cwd(), './dist/methodist'); //Путь к SPA билду // Укажем начальную страницу сбилдженного Angular приложения const index = readFileSync(join('./dist/methodist', 'index.html'), 'utf8'); let previousRender = Promise.resolve(); // Цикл по роутам страниц для пререндеринга ROUTES.forEach(route => { const fullPath = join(BROWSER_FOLDER, route); if (!existsSync(fullPath)) { mkdirSync(fullPath); } // Я записываю полученную верстку в seo.html файлы по нужным роутам previousRender = previousRender.then(_ => renderModuleFactory(AppServerModuleNgFactory, { document: index, url: route, extraProviders: [ provideModuleMap(LAZY_MODULE_MAP) ] })).then(html => { const sourceMapRe = /\/\*# sourceMappingURL=.*\*\//g; writeFileSync(join(fullPath, 'seo.html'), html.replace(sourceMapRe, '')); }); });
Если верстку сохранять в index.html, то необходимо будет выключить SPA функционал в firebase хостинге. Для меня такое решение не подходило, так как есть динамические страницы, для которых я не делаю pre-rendering, но их можно открыть по прямой ссылке.
Поэтому я решил, что должен быть в проекте стандартный spa.html в корне собранного проекта, который будет работать для всех роутов кроме тех, которые указаны для пререндеринга. А пути, которые должны открывать сгенерированные html файлы, я задам в настройках firebase хостинга.
Обратите внимание, что главный дефолтный html файл я называю не index.html, а spa.html. Так как если в корне firebase хостинга есть index.html с включенным SPA behavior, то открываться будет он, а не указанный роут в конфиге (у index.html самый высокий приоритет).
Вот как выглядит мой конфиг хостинга firebase.json
:
{ "hosting": { "public": "dist/methodist", "ignore": [ "firebase.json", "**/.*", "**/node_modules/**" ], "rewrites": [ { "source": "/", "destination": "/seo.html" }, { "source": "/profile", "destination": "/profile/seo.html" }, { "source": "/terms", "destination": "/terms/seo.html" }, { "source": "/privacy", "destination": "/privacy/seo.html" }, { "source": "**", "destination": "/spa.html" } ], "headers": [ { "source": "**/*.@(eot|otf|ttf|ttc|woff|font.css)", "headers": [ { "key": "Access-Control-Allow-Origin", "value": "*" } ] }, { "source": "**/*.@(js|css)", "headers": [ { "key": "Cache-Control", "value": "max-age=604800" } ] }, { "source": "**/*.@(jpg|jpeg|gif|png)", "headers": [ { "key": "Cache-Control", "value": "max-age=604800" } ] }, { "source": "404.html", "headers": [ { "key": "Cache-Control", "value": "max-age=300" } ] } ], "trailingSlash": false }, "functions": { "predeploy": [ "npm --prefix \"$RESOURCE_DIR\" run lint" ], "source": "functions" } }
Запуск pre-rendering процесса
Уже почти все готово, осталось автоматизировать:
- сборку приложения
- выполнение пререндеринга
- подготовку папки dist к деплою
Для этого мы обновим наш package.json
и добавим вот такие команды:
... "scripts": { ... "build": "ng build --prod --aot=true --source-map=false", "webpack:prerender": "webpack --config webpack.prerender.config.js", "build:prerender": "node dist/methodist/prerender.js", "serve:prerender": "http-server ./dist/methodist -o", "rename-index": "mv ./dist/methodist/index.html ./dist/methodist/spa.html", "build:all": "npm run build && ng run methodist:server && npm run webpack:prerender && npm run build:prerender && rm -f ./dist/methodist/prerender.js ./dist/methodist/prerender.js.map && npm run rename-index", ... }, ...
Теперь если прописать в консоли npm run build:all
, то должен собраться ваш проект и сервер для пререндеринга, затем выполнится сам пререндеринг.
При успешно выполненных командах вы получите SPA с SEO оптимизированными страницами, который полностью готов для деплоя на хостинг.
Заключение
SSR Pre-rendering – отличный подход, который позволит поисковикам и соц. сетям индексировать важные для вашего проекта страницы. При этом вам не понадобится оплачивать отдельный сервер с node.js для Angular Universal или использовать медленный rendertron. Реальный пример работы вы можете посмотреть на methodist.io.
Подписывайтесь
Для получения уведомлений о новых публикациях подписывайтесь на мой блог или страницы в соц. сетях: Twitter, Facebook.
О том, как я перешел с PhpStorm на VS Code