DEV Community

Konstantin Alaev
Konstantin Alaev

Posted on

Universal Server Side Rendering (USSR) в Angular

В мире веб-разработки технологии постоянно эволюционируют, стремясь найти оптимальный баланс между производительностью, удобством пользователя и эффективностью разработки. Одной из ключевых технологий, прошедших значительный путь развития, является Server-Side Rendering (SSR) или рендеринг на стороне сервера. В этой эволюции фреймворк Angular сыграл значительную роль.

От статических страниц к динамическому контенту

Изначально веб состоял из статических HTML-страниц, где весь контент формировался на сервере. С ростом потребности в динамическом контенте появились серверные технологии вроде PHP, ASP, и JSP, позволяющие генерировать HTML на лету.

Эра клиентского рендеринга и подъем Angular

Революция в веб-разработке произошла с появлением мощных JavaScript-фреймворков, и Angular (изначально AngularJS) был одним из пионеров в этой области. Выпущенный Google в 2010 году, AngularJS предложил новый подход к созданию динамических веб-приложений с использованием клиентского рендеринга (CSR).

Angular (версия 2+), выпущенный в 2016 году, продолжил эту традицию, предоставив разработчикам мощный инструментарий для создания сложных одностраничных приложений (SPA). Это позволило создавать более интерактивные и отзывчивые приложения, но также принесло новые вызовы:

  1. Медленная начальная загрузка из-за большого размера JavaScript-бандла.
  2. Проблемы с SEO, так как поисковые роботы не всегда могли корректно индексировать динамический контент.
  3. Увеличение времени до первого значимого отображения (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 fullscreen mode Exit fullscreen mode

У нас могут попросить выбрать необходимые параметры, везде жмем enter

В консоле мы должны увидеть вот такой вывод

✔ Packages installed successfully.
    Successfully initialized git.
Enter fullscreen mode Exit fullscreen mode

Если все успешно, то переходим в проект и запускаем его

cd no-ssr-demo/
npm start
Enter fullscreen mode Exit fullscreen mode

Должен получиться вот такой вывод

Image description

Но давайте посмотрим, что получает браузер при первом обращении к нашему сайту, для этого будем использовать curl. Если вы из под винды, просто перейдите по адресу view-source:http://localhost:4200/. Результат будет аналогичный

# Важно! ваш сайт может быть запушен на другом порту, если это так, просто замените 4200 на ваш порт
curl localhost:4200
Enter fullscreen mode Exit fullscreen mode

Какой же мы получили вывод?

<!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>
Enter fullscreen mode Exit fullscreen mode

А теперь перейдем в браузер и сравним html

Image description

Наш тег app-root силами Angular превратился в прекрасного лебедя, которого, к сожалению, бездушный робот не увидит. Но давайте это исправим, для примера сделаем небольшой блог, который можно будет писать используя разметку markdown

Создание простого блога из Markdown с помощью Angular и SSR

Если вы следовали шагам выше, это можно пропустить

npm install -g @angular/cli
Enter fullscreen mode Exit fullscreen mode

Создаем наш блог. На вопрос ? 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
Enter fullscreen mode Exit fullscreen mode

после успешного вывода, необходимо поставить библиотеку marked, которая будет парсить наш mardown

npm install marked
Enter fullscreen mode Exit fullscreen mode

Первым делом необходимо создать отображаемые ресурсы и настроить сервер

// папка с нашими файлами .md
const articlesFolder = resolve(serverDistFolder, '../../../articles');
Enter fullscreen mode Exit fullscreen mode

Ниже реализуем 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'));
  });
Enter fullscreen mode Exit fullscreen mode

В корне проекта создайте папку 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
Enter fullscreen mode Exit fullscreen mode
export const routes: Routes = [
  {
    path: 'article/:slug',
    component: ArticleComponent,
    resolve: {
      content: ArticleResolver,
    },
  },
  {
    path: '',
    component: HomeComponent,
    resolve: { 
      content: arcticlesResolver
     },
  },
];
Enter fullscreen mode Exit fullscreen mode

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)));
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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[]))
  );
}
Enter fullscreen mode Exit fullscreen mode

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() ?? '')
  );
}

Enter fullscreen mode Exit fullscreen mode

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))
    );
  }
}

Enter fullscreen mode Exit fullscreen mode

На этом моменте все должно работать, код вот тут https://github.com/alaev-dev/my-blog, что мы получили

Image description

реальный настоящий html, который с небольшими скриптами js

Top comments (0)