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。






