Angular 18 SSR获取服务器数据但无法填充HTML的问题
Angular 18 SSR: 无法在服务器端渲染CMS菜单数据问题
问题详情
我是Angular新手,正在使用Angular 18开发SSR项目,需要从CMS获取数据并填充到HTML模板中。已成功获取CMS响应数据,但将数据赋值给组件的menus变量后,变量在服务器端渲染时看似为空,HTML模板无法渲染数据。服务器端页面源码中没有对应数据,客户端还会额外发起请求尝试填充HTML。
相关代码
CMS数据获取服务
// cmsHttpResponse is defined @Injectable({providedIn: 'root',}) export class CmsService { httpOptions = { headers: new HttpHeaders({'Content-Type': 'application/json',}), observe: 'response', }; constructor(private http: HttpClient,) {} get<cmsHttpResponse>(endpoint: CMSEndPoint, params?: string, showLoader = false): Observable<cmsHttpResponse> { const url = endpoint.getUrl(params); return this.http .get<cmsHttpResponse>(endpoint.getUrl(params), { headers: new HttpHeaders({ 'Content-Type': 'application/json', Authorization: `Authorization token`, }), observe: 'response', }) .pipe( map((response) => { const cmsResponse = new cmsHttpResponse(); cmsResponse.responseData = response.body; cmsResponse.responseCode = response.status; switch (response.status) { case 200: cmsResponse.success = true; return cmsResponse; default: return throwError( () => new Error(`Unexpected status code ${response.status}`) ); } }), catchError(this.handleError<any>(endpoint.getUrl(params))) ); } private handleError<T>(url = '') { // function to handle error } }
组件代码
@Component({ selector: 'app-header-desktop', standalone: true, imports: [FormsModule, NgIconComponent, MatExpansionModule], templateUrl: './header-desktop.component.html', styleUrl: './header-desktop.component.scss', viewProviders: [provideIcons({ ionTriangle, ionClose, ionChevronDown })], }) export class HeaderDesktopComponent implements OnInit, OnDestroy { // booleans showInput = false; showSuggestions = false; panelOpenState = false; // data variables menus: Menu[] = []; // state variables for UI hoverIndex = -1; submenuClickIndex = -1; childmenuClickIndex = -1; menuClick = -1; // services private service = inject(CmsService); private siteApiservice = inject(SiteApiService); constructor( private router: Router, private renderer: Renderer2, @Inject(PLATFORM_ID) private platformId: Object ) { afterNextRender(() => { // unrelated to error, for scroll detection this.onWindowScroll(); }); } ngOnInit(): void { this.getMenu(); } ngOnDestroy(){ // unrelated to the error, for unsubscribing from a subscription this.suggestedDataSubscription?.unsubscribe(); } getMenu(): void { this.service .get<cmsHttpResponse>( CMSEndPoint.API_CMS_GET_NAVIGATION, 'query-string' ) .subscribe({ next:(response: cmsHttpResponse) => { if(response.success){ const data = response.responseData as MainMenu; // -------------- here printing data works, correct data is here -------- this.menus = data.data?.filter( (s) => s.attributes?.position == 'Header' && s.attributes.menu_name != 'Resources' ) ?? []; // --------------- here printing anything does not work, this.menus is not printing, data is not printing ------------------ } },error:(error) => { //error block } }); } }
HTML模板
<nav id="desktop-header" class="hidden md:flex md:flex-row nav-header-active fixed top-0 left-0 right-0 z-20 rounded-full m-4 p-3 items-center justify-between" > <div class="flex flex-row items-center relative"> <!-- show menus --> @if(!showInput) { <!-- (mouseleave)="hoverIndex=-1" to hide the submenu drop-down --> <div (mouseleave)="hoverIndex = -1" class="flex flex-row relative justify-center items-center gap-2 z-20 text-sm text-white overflow-visible" > <!-- iterate over each menu item --> @for (menu of menus; track $index) { <button (mouseenter)="hoverIndex = $index" class="menu-item font-barlow" > <div class="flex items-center"> <p class="pr-1" (click)="hoverIndex === $index ? (hoverIndex = -1) : (hoverIndex = $index)">{{ menu.attributes.menu_name }}</p> <ng-icon size="6" name="ionTriangle" class="dropdown-icon"></ng-icon> </div> @if(hoverIndex===$index){ <!-- for single menu with no child menu --> @if(typeofMenu(menu, "desktop")===menu_types.SINGLE_COLUMN_WITHOUT_CHILD){ <div class="dropdown-content grid grid-cols-1 gap-2 p-2 text-left text-nowrap rounded" > <!-- iterate over each submenu --> @for (submenu of menu.attributes.menus.data; track $index) { <a href="{{ submenu.attributes.url }}" class="flex flex-row items-center justify-start w-full p-2 gap-2" > {{ submenu.attributes.submenu }} </a> } </div> <!-- for double menu with no child menu --> } @else if(typeofMenu(menu, "desktop")===menu_types.DOUBLE_COLUMN_WITHOUT_CHILD){ <div class="dropdown-content grid grid-cols-2 gap-2 p-4 w-fit text-left rounded" > <!-- divide the grid into 2 columns, half submenus in each --> <div class="col-auto"> @for (submenu of menu.attributes.menus.data | slice : 0 : firstColumnMenuCount(menu.attributes.menus.data); track $index) { <a href="{{ submenu.attributes.url }}" class="p-2 flex flex-row items-center gap-2" > {{ submenu.attributes.submenu }} </a > } </div> <div class="col-auto"> @for (submenu of menu.attributes.menus.data | slice : firstColumnMenuCount(menu.attributes.menus.data) : menu.attributes.menus.data.length; track $index) { <a href="{{ submenu.attributes.url }}" class="p-2 flex flex-row items-center gap-2" > {{ submenu.attributes.submenu }}</a > } </div> </div> <!-- for double menu with child menu --> } @else { <!-- DOUBLE COLUMN MENU WITH CHILD --> <div class="dropdown-content grid grid-cols-2 gap-2 p-1 w-fit text-left rounded" > <!-- divide the grid into 2 columns, half submenus in each --> <div class="col-auto"> @for (submenu of menu.attributes.menus.data | slice : 0 : firstColumnMenuCount(menu.attributes.menus.data); track $index) { <mat-accordion> <mat-expansion-panel hideToggle class="bg-transparent mat-elevation-z0 hover:bg-transparent" > <mat-expansion-panel-header> <mat-panel-title class="text-nowrap"> {{ submenu.attributes.submenu }} </mat-panel-title> <mat-panel-description> @if(panelOpenState===true){ - }@else { + } </mat-panel-description> </mat-expansion-panel-header> <ul class="font-barlow list-item"> @for(child_menu of submenu.attributes.child_menu; track $index){ <li class="py-1 list-inside"> <a href="{{ child_menu.menu_url }}"> {{ child_menu.menu_name }} </a> </li> } </ul> </mat-expansion-panel> </mat-accordion> } </div> <div class="col-auto"> @for (submenu of menu.attributes.menus.data | slice : firstColumnMenuCount(menu.attributes.menus.data) : menu.attributes.menus.data.length; track $index) { <mat-accordion class="drop-shadow-none"> <mat-expansion-panel hideToggle class="bg-transparent mat-elevation-z0 hover:bg-transparent" > <mat-expansion-panel-header class="drop-shadow-none"> <mat-panel-title class="text-nowrap"> {{ submenu.attributes.submenu }} </mat-panel-title> <mat-panel-description> @if(panelOpenState===true){ - }@else { + } </mat-panel-description> </mat-expansion-panel-header> <ul class="flex flex-col marker:list-disc font-barlow"> @for(child_menu of submenu.attributes.child_menu; track $index){ <li class="py-1"> <a href="{{ child_menu.menu_url }}"> {{ child_menu.menu_name }} </a> </li> } </ul> </mat-expansion-panel> </mat-accordion> } </div> </div> } } </button> } </div> } </div> </nav>
问题原因
Angular SSR在服务器端渲染时,不会等待subscribe的异步回调执行完成就生成HTML并返回给客户端。虽然客户端最终能获取到数据并更新视图,但服务器端输出的源码中menus仍是初始空数组,导致客户端hydration时会重新发起请求,且SEO不友好。
解决方案
改用Observable配合async管道,让Angular服务器端等待数据加载完成后再渲染:
修改组件代码
@Component({ // ... 保持原有配置不变 }) export class HeaderDesktopComponent implements OnInit, OnDestroy { // ... 其他变量保持不变 // 将menus改为Observable类型 menus$: Observable<Menu[]> = of([]); // ... 构造函数、其他方法保持不变 getMenu(): void { this.menus$ = this.service .get<cmsHttpResponse>( CMSEndPoint.API_CMS_GET_NAVIGATION, 'query-string' ) .pipe( // 过滤成功的响应 filter(response => response.success), // 转换数据为需要的Menu数组 map(response => { const data = response.responseData as MainMenu; return data.data?.filter( s => s.attributes?.position === 'Header' && s.attributes.menu_name !== 'Resources' ) ?? []; }), // 错误时返回空数组 catchError(() => of([])) ); } }
修改HTML模板
将@for循环中的menus改为menus$ | async,并使用唯一标识作为track条件(避免依赖索引):
<!-- 替换原有循环部分 --> @for (menu of menus$ | async; track menu.attributes.menu_name) { <!-- 原有菜单内容保持不变 --> }
额外说明
async管道会自动订阅和取消订阅Observable,无需手动管理订阅,避免内存泄漏。- 服务器端会等待Observable完成后再渲染HTML,确保源码中包含菜单数据,避免客户端重复请求。
- 若需要更复杂的预加载逻辑,可使用Angular的
Resolver在路由激活前加载数据,确保组件渲染时数据已准备就绪。
内容的提问来源于stack exchange,提问作者Yashasav Prajapati
相关产品推荐
相关产品推荐

