angular-ssr

angular-ssr

熱門

使用 @angular/ssr 在 Angular v20+ 中實作伺服器端渲染與水合。適用於 SSR 設定、水合策略、預渲染靜態頁面及處理僅限瀏覽器的 API。觸發條件包括 SSR 配置、修正水合不一致、預渲染路由或讓程式碼相容 SSR。

596星標
70分支
更新於 2026/3/23
SKILL.md
唯讀
名稱
angular-ssr
描述

使用 @angular/ssr 在 Angular v20+ 中實作伺服器端渲染與水合。適用於 SSR 設定、水合策略、預渲染靜態頁面及處理僅限瀏覽器的 API。觸發條件包括 SSR 配置、修正水合不一致、預渲染路由或讓程式碼相容 SSR。

Angular SSR

在 Angular v20+ 中實作伺服器端渲染、水合與預渲染。

設定

為現有專案加入 SSR

ng add @angular/ssr

這會加入:

  • @angular/ssr 套件
  • server.ts - Express 伺服器
  • src/main.server.ts - 伺服器啟動檔
  • src/app/app.config.server.ts - 伺服器提供者
  • 更新 angular.json 加入 SSR 設定

專案結構

src/
├── app/
│   ├── app.config.ts          # 瀏覽器設定
│   ├── app.config.server.ts   # 伺服器設定
│   └── app.routes.ts
├── main.ts                     # 瀏覽器啟動檔
├── main.server.ts              # 伺服器啟動檔
server.ts                       # Express 伺服器

設定

app.config.server.ts

import { ApplicationConfig, mergeApplicationConfig } from '@angular/core';
import { provideServerRendering } from '@angular/platform-server';
import { provideServerRoutesConfig } from '@angular/ssr';
import { appConfig } from './app.config';
import { serverRoutes } from './app.routes.server';

const serverConfig: ApplicationConfig = {
  providers: [
    provideServerRendering(),
    provideServerRoutesConfig(serverRoutes),
  ],
};

export const config = mergeApplicationConfig(appConfig, serverConfig);

伺服器路由設定

// app.routes.server.ts
import { RenderMode, ServerRoute } from '@angular/ssr';

export const serverRoutes: ServerRoute[] = [
  {
    path: '',
    renderMode: RenderMode.Prerender, // 建置時靜態產生
  },
  {
    path: 'products',
    renderMode: RenderMode.Prerender,
  },
  {
    path: 'products/:id',
    renderMode: RenderMode.Server, // 動態 SSR
  },
  {
    path: 'dashboard',
    renderMode: RenderMode.Client, // 僅客戶端 (SPA)
  },
  {
    path: '**',
    renderMode: RenderMode.Server,
  },
];

渲染模式

模式 說明 使用情境
RenderMode.Prerender 建置時產生靜態 HTML 行銷頁面、部落格
RenderMode.Server 每次請求動態 SSR 使用者特定內容
RenderMode.Client 僅客戶端 (SPA) 需驗證的儀表板

水合

預設水合

水合預設透過 provideClientHydration() 啟用:

// app.config.ts
import { provideClientHydration } from '@angular/platform-browser';

export const appConfig: ApplicationConfig = {
  providers: [
    provideClientHydration(),
    // ...
  ],
};

增量水合

延遲特定元件的水合:

@Component({
  template: `
    <!-- 可見時水合 -->
    @defer (hydrate on viewport) {
      <app-comments [postId]="postId" />
    } @placeholder {
      <div class="comments-placeholder">載入留言中...</div>
    }
    
    <!-- 互動時水合 -->
    @defer (hydrate on interaction) {
      <app-interactive-chart [data]="chartData" />
    }
    
    <!-- 閒置時水合 -->
    @defer (hydrate on idle) {
      <app-recommendations />
    }
    
    <!-- 永不水合 (僅靜態) -->
    @defer (hydrate never) {
      <app-static-footer />
    }
  `,
})
export class Post {
  postId = input.required<string>();
  chartData = input.required<ChartData>();
}

水合觸發條件

觸發條件 說明
hydrate on viewport 當元素進入可視區域
hydrate on interaction 點擊、聚焦或輸入時
hydrate on idle 瀏覽器閒置時
hydrate on immediate 載入後立即執行
hydrate on timer(ms) 指定延遲後執行
hydrate when condition 當表達式為 true 時
hydrate never 永不水合 (靜態)

事件重播

在水合完成前擷取使用者事件:

import { provideClientHydration, withEventReplay } from '@angular/platform-browser';

export const appConfig: ApplicationConfig = {
  providers: [
    provideClientHydration(withEventReplay()),
  ],
};

僅限瀏覽器的程式碼

平台偵測

import { PLATFORM_ID, inject } from '@angular/core';
import { isPlatformBrowser, isPlatformServer } from '@angular/common';

@Component({...})
export class My {
  private platformId = inject(PLATFORM_ID);
  
  ngOnInit() {
    if (isPlatformBrowser(this.platformId)) {
      // 僅瀏覽器程式碼
      window.addEventListener('scroll', this.onScroll);
    }
  }
}

afterNextRender / afterRender

在渲染後僅於瀏覽器中執行程式碼:

import { afterNextRender, afterRender } from '@angular/core';

@Component({...})
export class Chart {
  constructor() {
    // 首次渲染後執行一次 (僅瀏覽器)
    afterNextRender(() => {
      this.initChart();
    });
    
    // 每次渲染後執行 (僅瀏覽器)
    afterRender(() => {
      this.updateChart();
    });
  }
  
  private initChart() {
    // 此處可安全使用 DOM API
    const canvas = document.getElementById('chart');
    new Chart(canvas, this.config);
  }
}

安全注入瀏覽器 API

// tokens.ts
import { InjectionToken, PLATFORM_ID, inject } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';

export const WINDOW = new InjectionToken<Window | null>('Window', {
  providedIn: 'root',
  factory: () => {
    const platformId = inject(PLATFORM_ID);
    return isPlatformBrowser(platformId) ? window : null;
  },
});

export const LOCAL_STORAGE = new InjectionToken<Storage | null>('LocalStorage', {
  providedIn: 'root',
  factory: () => {
    const platformId = inject(PLATFORM_ID);
    return isPlatformBrowser(platformId) ? localStorage : null;
  },
});

// 使用方式
@Injectable({ providedIn: 'root' })
export class Storage {
  private storage = inject(LOCAL_STORAGE);
  
  get(key: string): string | null {
    return this.storage?.getItem(key) ?? null;
  }
  
  set(key: string, value: string): void {
    this.storage?.setItem(key, value);
  }
}

預渲染

靜態路由

// app.routes.server.ts
export const serverRoutes: ServerRoute[] = [
  { path: '', renderMode: RenderMode.Prerender },
  { path: 'about', renderMode: RenderMode.Prerender },
  { path: 'contact', renderMode: RenderMode.Prerender },
  { path: 'blog', renderMode: RenderMode.Prerender },
];

使用 getPrerenderParams 的動態路由

// app.routes.server.ts
import { RenderMode, ServerRoute, PrerenderFallback } from '@angular/ssr';

export const serverRoutes: ServerRoute[] = [
  {
    path: 'products/:id',
    renderMode: RenderMode.Prerender,
    async getPrerenderParams() {
      // 取得要預渲染的產品 ID
      const response = await fetch('https://api.example.com/products');
      const products = await response.json();
      return products.map((p: Product) => ({ id: p.id }));
    },
    fallback: PrerenderFallback.Server, // 未預渲染的路由使用 SSR
  },
  {
    path: 'blog/:slug',
    renderMode: RenderMode.Prerender,
    async getPrerenderParams() {
      const posts = await fetchBlogPosts();
      return posts.map(post => ({ slug: post.slug }));
    },
    fallback: PrerenderFallback.Client, // 未預渲染的路由使用 SPA
  },
];

預渲染後備選項

後備選項 說明
PrerenderFallback.Server 未預渲染的路由使用 SSR
PrerenderFallback.Client 客戶端渲染
PrerenderFallback.None 未預渲染的路回傳 404

HTTP 快取

TransferState

自動將 HTTP 回應從伺服器傳輸到客戶端:

import { provideClientHydration, withHttpTransferCacheOptions } from '@angular/platform-browser';

export const appConfig: ApplicationConfig = {
  providers: [
    provideClientHydration(
      withHttpTransferCacheOptions({
        includePostRequests: true,
        includeRequestsWithAuthHeaders: false,
        filter: (req) => !req.url.includes('/api/realtime'),
      })
    ),
  ],
};

手動 TransferState

import { TransferState, makeStateKey } from '@angular/core';

const PRODUCTS_KEY = makeStateKey<Product[]>('products');

@Injectable({ providedIn: 'root' })
export class Product {
  private http = inject(HttpClient);
  private transferState = inject(TransferState);
  private platformId = inject(PLATFORM_ID);
  
  getProducts(): Observable<Product[]> {
    // 檢查資料是否已從伺服器傳輸
    if (this.transferState.hasKey(PRODUCTS_KEY)) {
      const products = this.transferState.get(PRODUCTS_KEY, []);
      this.transferState.remove(PRODUCTS_KEY);
      return of(products);
    }
    
    return this.http.get<Product[]>('/api/products').pipe(
      tap(products => {
        // 在伺服器上儲存以傳輸
        if (isPlatformServer(this.platformId)) {
          this.transferState.set(PRODUCTS_KEY, products);
        }
      })
    );
  }
}

建置與部署

建置指令

# 使用 SSR 建置
ng build

# 輸出結構
dist/
├── my-app/
│   ├── browser/      # 客戶端資源
│   └── server/       # 伺服器套件

執行 SSR 伺服器

# 開發模式
npm run serve:ssr:my-app

# 正式環境
node dist/my-app/server/server.mjs

部署到 Node.js 主機

// server.ts (自動產生)
import { APP_BASE_HREF } from '@angular/common';
import { CommonEngine } from '@angular/ssr/node';
import express from 'express';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import bootstrap from './src/main.server';

const serverDistFolder = dirname(fileURLToPath(import.meta.url));
const browserDistFolder = resolve(serverDistFolder, '../browser');
const indexHtml = join(serverDistFolder, 'index.server.html');

const app = express();
const commonEngine = new CommonEngine();

app.get('*', express.static(browserDistFolder, { maxAge: '1y', index: false }));

app.get('*', (req, res, next) => {
  commonEngine
    .render({
      bootstrap,
      documentFilePath: indexHtml,
      url: req.originalUrl,
      publicPath: browserDistFolder,
      providers: [{ provide: APP_BASE_HREF, useValue: req.baseUrl }],
    })
    .then((html) => res.send(html))
    .catch((err) => next(err));
});

app.listen(4000, () => {
  console.log('伺服器監聽於 http://localhost:4000');
});

如需進階模式,請參閱 references/ssr-patterns.md