В мире веб-разработки технологии постоянно эволюционируют, стремясь найти оптимальный баланс между производительностью, удобством пользователя и эффективностью разработки. Одной из ключевых технологий, прошедших значительный путь развития, является Server-Side Rendering (SSR) или рендеринг на стороне сервера. В этой эволюции фреймворк Angular сыграл значительную роль.
От статических страниц к динамическому контенту
Изначально веб состоял из статических HTML-страниц, где весь контент формировался на сервере. С ростом потребности в динамическом контенте появились серверные технологии вроде PHP, ASP, и JSP, позволяющие генерировать HTML на лету.
Эра клиентского рендеринга и подъем Angular
Революция в веб-разработке произошла с появлением мощных JavaScript-фреймворков, и Angular (изначально AngularJS) был одним из пионеров в этой области. Выпущенный Google в 2010 году, AngularJS предложил новый подход к созданию динамических веб-приложений с использованием клиентского рендеринга (CSR).
Angular (версия 2+), выпущенный в 2016 году, продолжил эту традицию, предоставив разработчикам мощный инструментарий для создания сложных одностраничных приложений (SPA). Это позволило создавать более интерактивные и отзывчивые приложения, но также принесло новые вызовы:
- Медленная начальная загрузка из-за большого размера JavaScript-бандла.
- Проблемы с SEO, так как поисковые роботы не всегда могли корректно индексировать динамический контент.
- Увеличение времени до первого значимого отображения (First Meaningful Paint).
Возрождение SSR и роль Angular Universal
Осознав ограничения чисто клиентского подхода, сообщество Angular обратилось к идее серверного рендеринга, но уже на новом уровне. Так появился Angular Universal — официальное SSR-решение для Angular.
Angular Universal позволяет рендерить приложения Angular на сервере, что решает ключевые проблемы CSR:
- Улучшает начальную загрузку страницы, отправляя предварительно отрендеренный HTML.
- Повышает SEO, предоставляя поисковым роботам полностью сформированный контент.
- Улучшает производительность на мобильных устройствах и в сетях с медленным соединением.
Практика
На словах все звучит круто, давайте попробуем это проверить.
Для начала надо посмотреть что отдает приложение без SSR. Для этого надо создать "пустой" проект.
Тут и далее, код можно вставлять целиком в консоль
# проверяем, что у нас установлен Angular CLI
npm install -g @angular/cli
# создаем наш демо проект
ng new no-ssr-demo
У нас могут попросить выбрать необходимые параметры, везде жмем enter
В консоле мы должны увидеть вот такой вывод
✔ Packages installed successfully.
Successfully initialized git.
Если все успешно, то переходим в проект и запускаем его
cd no-ssr-demo/
npm start
Должен получиться вот такой вывод
Но давайте посмотрим, что получает браузер при первом обращении к нашему сайту, для этого будем использовать curl
. Если вы из под винды, просто перейдите по адресу view-source:http://localhost:4200/
. Результат будет аналогичный
# Важно! ваш сайт может быть запушен на другом порту, если это так, просто замените 4200 на ваш порт
curl localhost:4200
Какой же мы получили вывод?
<!doctype html>
<html lang="en">
<head>
<script type="module" src="/@vite/client"></script>
<meta charset="utf-8">
<title>NoSsrDemo</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<link rel="stylesheet" href="styles.css"></head>
<body>
<app-root></app-root>
<script src="polyfills.js" type="module"></script><script src="main.js" type="module"></script></body>
</html>
А теперь перейдем в браузер и сравним html
Наш тег app-root
силами Angular превратился в прекрасного лебедя, которого, к сожалению, бездушный робот не увидит. Но давайте это исправим, для примера сделаем небольшой блог, который можно будет писать используя разметку markdown
Создание простого блога из Markdown с помощью Angular и SSR
Если вы следовали шагам выше, это можно пропустить
npm install -g @angular/cli
Создаем наш блог. На вопрос ? Do you want to enable Server-Side Rendering (SSR) and Static Site Generation (SSG/Prerendering)? (y/N)
нажмите yes
или y
ng new my-blog --routing --style=scss
cd my-blog
после успешного вывода, необходимо поставить библиотеку marked
, которая будет парсить наш mardown
npm install marked
Первым делом необходимо создать отображаемые ресурсы и настроить сервер
// папка с нашими файлами .md
const articlesFolder = resolve(serverDistFolder, '../../../articles');
Ниже реализуем API
для их получения
server.get('/api/articles', async (req, res) => {
try {
const files = await fs.readdir(articlesFolder);
const articlePromises = files
.filter((file) => file.endsWith('.md'))
.map(async (file) => {
const filePath = path.join(articlesFolder, file);
try {
const fileData = await fs.readFile(filePath, 'utf8');
return {
fileName: file.replace('.md', ''),
content: fileData.split('\n').slice(0, 150),
};
} catch (err) {
console.error(`Error reading file ${file}:`, err);
return null;
}
});
const articles = await Promise.all(articlePromises);
res.json(articles.filter((article) => article !== null));
} catch (err) {
console.error('Error scanning directory:', err);
res.status(500).json({ error: 'Unable to scan directory' });
}
});
server.get('/api/articles/:name', (req, res) => {
const fileName = req.params.name + '.md';
const filePath = path.join(articlesFolder, fileName);
fs.readFile(filePath, 'utf8')
.then((data) => res.send(data))
.catch(() => res.status(404).send('Article not found'));
});
В корне проекта создайте папку articles
в которую положите 1-2 файла с расширением .md
После запуска команды npm run build && npm run serve:ssr:my-blog
сервер начнет работу (ТУТ ВНИМАТЕЛЬНО) После каждого исправления команду надо перезапускать, так что если хотим разрабатывать фронт и не писать каждый раз билд, то надо запустить ее и отдельно написать npm run start
, чтобы запросы не падали, надо включить server.use(cors());
(ну и библиотеку установить соотвествено)
С грязной работой покончено, теперь у нас есть сервер, который отдает содержимое одной конкретной статьи, для всех вернет небольшое содержимое (первые 150 символов) и название файла
Пойдем писать фронт и первым делом добавим новые компоненты и поправим импорты (не забыть перейти в папку с src/app
ну и папки под каждый создать)
ng g c ArticleComponent
ng g c HomeComponent
#резолверы
ng g r ArticleResolver
ng g r arcticlesResolver
export const routes: Routes = [
{
path: 'article/:slug',
component: ArticleComponent,
resolve: {
content: ArticleResolver,
},
},
{
path: '',
component: HomeComponent,
resolve: {
content: arcticlesResolver
},
},
];
ArticleComponent
import { AsyncPipe, NgIf } from '@angular/common';
import { Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ActivatedRoute } from '@angular/router';
import { map } from 'rxjs';
@Component({
selector: 'app-article',
standalone: true,
imports: [AsyncPipe, NgIf],
template: `
<div [innerHTML]="content()"></div>
`
})
export class ArticleComponent {
readonly #route = inject(ActivatedRoute);
content = toSignal(this.#route.data.pipe(map(({ content }) => content)));
}
ArticleResolver
import { inject, Injectable } from '@angular/core';
import { SafeHtml } from '@angular/platform-browser';
import { ActivatedRouteSnapshot, Resolve } from '@angular/router';
import { Observable } from 'rxjs';
import { MarkdownService } from '../../services/markdown.service';
@Injectable({ providedIn: 'root'})
export class ArticleResolver implements Resolve<SafeHtml> {
readonly #markdownService = inject(MarkdownService);
resolve(route: ActivatedRouteSnapshot): Observable<SafeHtml> {
const article = route.paramMap.get('slug') ?? '';
return this.#markdownService.getMarkdownFromServer(article);
}
}
HomeComponent
import { HttpClient } from '@angular/common/http';
import { Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ActivatedRoute, ResolveFn, RouterModule } from '@angular/router';
import { map } from 'rxjs';
import { ArticlePreviewComponent } from '../article-preview/article-preview.component';
interface Article {
fileName: string;
content: string;
}
export const arcticlesResolver: ResolveFn<Article[]> = () => {
const http = inject(HttpClient);
return http.get<Article[]>('http://localhost:4000/api/articles');
};
@Component({
selector: 'app-home',
standalone: true,
imports: [RouterModule, ArticlePreviewComponent],
styleUrl: './home.component.scss',
template: `
<div><h1>Список доступных статей:</h1></div>
@for (item of articles(); track item) {
<app-article-preview [name]="item.fileName" [content]="item.content" />
}
`,
})
export class HomeComponent {
readonly #route = inject(ActivatedRoute);
articles = toSignal(
this.#route.data.pipe(map(({ content }) => content as Article[]))
);
}
ArticlePreviewComponent
import { AsyncPipe, DatePipe } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
inject,
input,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { RouterLink } from '@angular/router';
import { MarkdownService } from '../../services/markdown.service';
@Component({
selector: 'app-article-preview',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterLink, DatePipe, AsyncPipe],
template: `
<article [routerLink]="['/article', name()]">
<h2>{{ name() }}</h2>
<p>{{ rendered() }}</p>
<!-- TO-DO поправить на дату создания файла -->
<footer>Опубликовано {{ currentDate | date : 'medium' }}</footer>
</article>
`,
styles: `
:host {
cursor: pointer;
}
`,
})
export class ArticlePreviewComponent {
readonly #markdownService = inject(MarkdownService);
name = input<string>();
content = input<string>();
currentDate = new Date();
rendered = toSignal(
this.#markdownService.parseMarkdown(this.content() ?? '')
);
}
MarkdownService
import { HttpClient } from '@angular/common/http';
import { inject, Injectable } from '@angular/core';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { marked } from 'marked';
import { from, Observable, of } from 'rxjs';
import { map, switchMap } from 'rxjs/operators';
@Injectable({
providedIn: 'root',
})
export class MarkdownService {
readonly #http = inject(HttpClient);
readonly #sanitizer = inject(DomSanitizer);
getMarkdownFromServer(file: string): Observable<SafeHtml> {
return this.#http
.get(`http://localhost:4000/api/articles/${file}`, {
responseType: 'text',
})
.pipe(
switchMap((res) => {
const parsed = marked.parse(res);
return typeof parsed === 'string' ? of(parsed) : from(parsed);
}),
map((html) => this.#sanitizer.bypassSecurityTrustHtml(html)),
);
}
parseMarkdown(content: string): Observable<SafeHtml> {
return of(content).pipe(
map((markdown) => {
console.log(markdown);
return marked.parse(markdown);
}),
map((html) => this.#sanitizer.bypassSecurityTrustHtml(html as string))
);
}
}
На этом моменте все должно работать, код вот тут https://github.com/alaev-dev/my-blog
, что мы получили
реальный настоящий html
, который с небольшими скриптами js
Top comments (0)