Angular 16集成NGUniversal后多项功能失效,求排查
问题列表
执行npm run dev:ssr后出现以下功能异常:
- Angular动画全部失效,终端报错:
@pageAnimation has failed due to:
NG03014:query(":enter, :leave")returned zero elements. (Usequery(":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

