You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.22 13:20:53