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

Angular 16集成NGUniversal后多项功能失效,求排查

Angular 16 + Universal SSR 功能失效问题排查与修复

问题列表

执行npm run dev:ssr后出现以下功能异常:

  • Angular动画全部失效,终端报错:

    @pageAnimation has failed due to:
    NG03014: query(":enter, :leave") returned zero elements. (Use query(":enter, :leave", { optional: true }) if you wish to allow this.)
    页面过渡动画、滚动导航栏、汉堡菜单侧边栏滑入均无法工作。

  • 全站routerLink点击无响应,仅能通过地址栏输入路径跳转。
  • 按钮功能与*ngIf视图切换失效:控制面板/仪表盘按钮点击无反应,视图无法切换。
  • 自定义可调整大小<iframe>组件无法嵌入内容。
  • ngx-highlightjs代码片段组件接收不到@Input传递的代码,仅显示空黑框。
  • ResizeObserver无法更新尺寸变量:监听iframe尺寸变化的逻辑失效。

已知背景:问题源于服务器端缺少浏览器环境对象,尝试过注入PLATFORM_ID、使用afterRender()(无输出)无效,项目采用独立组件模式,无app.module文件。

相关配置文件

main.ts

import { provideAnimations } from '@angular/platform-browser/animations';
import { ApplicationConfig } from '@angular/core';
import { provideRouter, withInMemoryScrolling } from '@angular/router';
import { SITEROUTES } from './app/routes';
import { HIGHLIGHT_OPTIONS, HighlightOptions } from 'ngx-highlightjs';
import { provideClientHydration } from '@angular/platform-browser';

export const appConfig: ApplicationConfig = {
  providers: [
    provideClientHydration(),
    provideRouter(SITEROUTES, withInMemoryScrolling({scrollPositionRestoration: 'enabled'})),
    provideAnimations(),
    {
      provide: HIGHLIGHT_OPTIONS,
      useValue: <HighlightOptions>{
        coreLibraryLoader: () => import('highlight.js/lib/core'),
        lineNumbersLoader: () => import('ngx-highlightjs/line-numbers'),
        languages: {
          xml: () => import('highlight.js/lib/languages/xml'),
          css: () => import('highlight.js/lib/languages/css')
        },
        themePath: '../node_modules/highlight.js/styles/a11y-dark.css'
      }
    }
  ]
};

main.server.ts

import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
import { config } from './app/app.config.server';

const bootstrap = () => bootstrapApplication(AppComponent, config);

export default bootstrap;

app.config.server.ts

import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
import { provideServerRendering } from '@angular/platform-server';
import {appConfig} from '../main';

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

export const config = mergeApplicationConfig(appConfig, serverConfig);

server.ts

import 'zone.js/node';

import { APP_BASE_HREF } from '@angular/common';
import { ngExpressEngine } from '@nguniversal/express-engine';
import * as express from 'express';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import bootstrap from './src/main.server';

// The Express app is exported so that it can be used by serverless Functions.
export function app(): express.Express {
  const server = express();
  const distFolder = join(process.cwd(), 'dist/srcry-documentation/browser');
  const indexHtml = existsSync(join(distFolder, 'index.original.html')) ? 'index.original.html' : 'index';

  // Our Universal express-engine (found @ https://github.com/angular/universal/tree/main/modules/express-engine)
  server.engine('html', ngExpressEngine({
    bootstrap
  }));

  server.set('view engine', 'html');
  server.set('views', distFolder);

  // Example Express Rest API endpoints
  // server.get('/api/**', (req, res) => { });
  // Serve static files from /browser
  server.get('*.*', express.static(distFolder, {
    maxAge: '1y'
  }));

  // All regular routes use the Universal engine
  server.get('*', (req, res) => {
    res.render(indexHtml, { req, providers: [{ provide: APP_BASE_HREF, useValue: req.baseUrl }] });
  });

  return server;
}

function run(): void {
  const port = process.env['PORT'] || 4000;

  // Start up the Node server
  const server = app();
  server.listen(port, () => {
    console.log(`Node Express server listening on http://localhost:${port}`);
  });
}

// Webpack will replace 'require' with '__webpack_require__'
// '__non_webpack_require__' is a proxy to Node 'require'
// The below code is to ensure that the server is run only when not requiring the bundle.
declare const __non_webpack_require__: NodeRequire;
const mainModule = __non_webpack_require__.main;
const moduleFilename = mainModule && mainModule.filename || '';
if (moduleFilename === __filename || moduleFilename.includes('iisnode')) {
  run();
}

export default bootstrap;

tsconfig.server.json

/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
  "extends": "./tsconfig.app.json",
  "compilerOptions": {
    "outDir": "./out-tsc/server",
    "types": [
      "node"
    ]
  },
  "files": [
    "src/main.server.ts",
    "server.ts"
  ]
}

修复方案

1. 配置层面修复:服务器端禁用动画

服务器端无浏览器动画环境,需替换provideAnimations为provideNoopAnimations,修改app.config.server.ts:

import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
import { provideServerRendering } from '@angular/platform-server';
import { provideNoopAnimations } from '@angular/platform-browser/animations';
import { appConfig } from '../main';

const serverConfig: ApplicationConfig = {
  providers: [
    provideServerRendering(),
    provideNoopAnimations() // 覆盖浏览器端动画提供器,服务器端不执行动画
  ]
};

export const config = mergeApplicationConfig(appConfig, serverConfig);

2. 动画代码修复:添加可选查询配置

在所有使用query(':enter, :leave')的动画定义中,添加{ optional: true }参数,避免服务器端报错:

query(':enter, :leave', [
  // 你的动画逻辑
], { optional: true })

3. 客户端逻辑隔离:使用isPlatformBrowser与afterNextRender

所有依赖浏览器环境的逻辑(事件绑定、DOM操作、API调用)必须限制在客户端执行:

路由与按钮事件修复

在组件中注入PLATFORM_ID,判断环境后执行逻辑:

import { Component, Inject, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';

@Component({...})
export class YourComponent {
  constructor(@Inject(PLATFORM_ID) private platformId: Object) {}

  handleButtonClick() {
    if (isPlatformBrowser(this.platformId)) {
      // 你的按钮逻辑,比如更新*ngIf绑定的变量
    }
  }
}

在AppComponent中用afterNextRender确保客户端路由初始化:

import { Component, Inject, PLATFORM_ID, afterNextRender } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';

@Component({...})
export class AppComponent {
  constructor(@Inject(PLATFORM_ID) private platformId: Object) {
    afterNextRender(() => {
      if (isPlatformBrowser(this.platformId)) {
        // 客户端路由初始化,确保routerLink事件绑定正常
      }
    });
  }
}

iframe组件修复

仅在浏览器环境渲染iframe:

<ng-container *ngIf="isBrowser">
  <iframe #resizeFrame [src]="iframeSrc"></iframe>
</ng-container>

组件中定义isBrowser:

isBrowser = isPlatformBrowser(this.platformId);

ngx-highlightjs修复

在代码片段组件中,用afterNextRender和ngOnChanges触发客户端高亮:

import { Component, Input, Inject, PLATFORM_ID, afterNextRender, OnChanges } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { HighlightService } from 'ngx-highlightjs';

@Component({...})
export class CodeSnippetComponent implements OnChanges {
  @Input() code: string = '';

  constructor(
    @Inject(PLATFORM_ID) private platformId: Object,
    private highlightService: HighlightService
  ) {
    afterNextRender(() => {
      this.triggerHighlight();
    });
  }

  ngOnChanges() {
    this.triggerHighlight();
  }

  private triggerHighlight() {
    if (isPlatformBrowser(this.platformId)) {
      setTimeout(() => {
        this.highlightService.highlightAll();
      });
    }
  }
}

ResizeObserver修复

仅在浏览器环境初始化监听:

import { Component, ViewChild, ElementRef, Inject, PLATFORM_ID, AfterViewInit } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';

@Component({...})
export class ResizableIframeComponent implements AfterViewInit {
  @ViewChild('resizeFrame') iframeElement!: ElementRef;
  iframeSize = { width: 0, height: 0 };

  constructor(@Inject(PLATFORM_ID) private platformId: Object) {}

  ngAfterViewInit() {
    if (isPlatformBrowser(this.platformId)) {
      const observer = new ResizeObserver(entries => {
        const entry = entries[0];
        this.iframeSize = {
          width: entry.contentRect.width,
          height: entry.contentRect.height
        };
      });
      observer.observe(this.iframeElement.nativeElement);
    }
  }
}

4. 验证dist路径正确性

检查server.ts中distFolder路径是否与实际打包目录一致:

const distFolder = join(process.cwd(), 'dist/srcry-documentation/browser');

确保打包后browser目录存在于该路径下。

内容的提问来源于stack exchange,提问作者Optiq

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.01 21:24:55