Angular11中将ng-template内容克隆到document.body的最佳实践是什么
问题原因
你之前两个方案失效的原因很明确:
- 原生
cloneNode只会克隆DOM结构,不会保留Angular的事件绑定、数据绑定上下文,所以克隆出来的元素交互全部失效 @ViewChild只能查询当前组件模板内的元素,body属于全局DOM节点,不在当前组件的查询范围内,所以拿不到对应的ViewContainerRef
推荐解决方案
以下两种方案都可以实现把ng-template挂载到document.body,同时完整保留原组件的所有绑定逻辑,变量、函数都可以正常使用。
方案1:使用Angular CDK Portal(官方推荐,优先选择)
Portal是Angular官方提供的动态内容挂载工具,专门用于把内容渲染到当前组件模板外的任意DOM节点,自动处理变更检测、生命周期管理,不需要手动维护绑定逻辑。
- 先安装和你Angular 11版本匹配的
@angular/cdk,然后在对应模块导入PortalModule - 组件代码修改如下:
import { TemplatePortal, DomPortalOutlet } from '@angular/cdk/portal'; import { ApplicationRef, ComponentFactoryResolver, Injector, ViewChild, TemplateRef, HostListener, ElementRef } from '@angular/core'; @Component({ // 你的组件配置 }) export class YourComponent { @ViewChild('clone') template: TemplateRef<any>; @ViewChild('optionBox') optionBox: ElementRef; showOptionBox = true; // 你的原有控制显示的变量 private bodyPortalOutlet: DomPortalOutlet; private embeddedViewPortal: TemplatePortal; isSticky = false; constructor( private viewContainerRef: ViewContainerRef, private componentFactoryResolver: ComponentFactoryResolver, private applicationRef: ApplicationRef, private injector: Injector ) {} ngAfterViewInit(): void { // 初始化绑定到body的Portal出口 this.bodyPortalOutlet = new DomPortalOutlet( document.body, this.componentFactoryResolver, this.applicationRef, this.injector ); // 基于你的ng-template创建Portal,上下文绑定当前组件,所有原有逻辑都生效 this.embeddedViewPortal = new TemplatePortal( this.template, this.viewContainerRef, this ); } @HostListener('body:scroll', ['$event']) onScroll(): void { if (!this.showOptionBox || !this.optionBox?.nativeElement) return; // getBoundingClientRect的top直接是相对于视口的距离,不需要额外加scrollY const positionTop = this.optionBox.nativeElement.getBoundingClientRect().top; if (positionTop < 60 && !this.isSticky) { this.isSticky = true; // 把模板挂载到body this.bodyPortalOutlet.attach(this.embeddedViewPortal); // 给吸顶元素加样式 const stickyEl = this.bodyPortalOutlet.outletElement.lastElementChild as HTMLElement; stickyEl.style.position = 'fixed'; stickyEl.style.top = '90px'; stickyEl.style.left = `${this.optionBox.nativeElement.getBoundingClientRect().left}px`; stickyEl.style.zIndex = '10000'; // 这里记得给原位置的optionBox加隐藏样式,避免两个元素同时显示 } else if (positionTop > 65 && this.isSticky) { this.isSticky = false; // 卸载吸顶元素 this.bodyPortalOutlet.detach(); } } ngOnDestroy(): void { // 组件销毁时清理portal,避免内存泄漏 if (this.bodyPortalOutlet) { this.bodyPortalOutlet.dispose(); } } }
方案2:手动实现(不需要引入CDK依赖)
如果不想引入CDK,也可以手动创建嵌入视图,手动挂载到Angular的变更检测树,再插入到body中:
import { ApplicationRef, EmbeddedViewRef, ViewChild, TemplateRef, HostListener, ElementRef } from '@angular/core'; @Component({ // 你的组件配置 }) export class YourComponent { @ViewChild('clone') template: TemplateRef<any>; @ViewChild('optionBox') optionBox: ElementRef; showOptionBox = true; private embeddedView: EmbeddedViewRef<any>; isSticky = false; constructor(private applicationRef: ApplicationRef) {} @HostListener('body:scroll', ['$event']) onScroll(): void { if (!this.showOptionBox || !this.optionBox?.nativeElement) return; const positionTop = this.optionBox.nativeElement.getBoundingClientRect().top; if (positionTop < 60 && !this.isSticky) { this.isSticky = true; // 创建嵌入视图,上下文绑定当前组件 this.embeddedView = this.template.createEmbeddedView(this); // 把视图挂载到应用的变更检测树,保证绑定正常更新 this.applicationRef.attachView(this.embeddedView); // 把视图根节点插入到body this.embeddedView.rootNodes.forEach(node => { document.body.appendChild(node); }); // 加吸顶样式 const stickyEl = this.embeddedView.rootNodes[0] as HTMLElement; stickyEl.style.position = 'fixed'; stickyEl.style.top = '90px'; stickyEl.style.left = `${this.optionBox.nativeElement.getBoundingClientRect().left}px`; stickyEl.style.zIndex = '10000'; // 同样记得隐藏原位置的元素 } else if (positionTop > 65 && this.isSticky) { this.isSticky = false; // 清理视图 if (this.embeddedView) { this.applicationRef.detachView(this.embeddedView); this.embeddedView.destroy(); this.embeddedView = null; } } } ngOnDestroy(): void { // 组件销毁时清理视图 if (this.embeddedView) { this.applicationRef.detachView(this.embeddedView); this.embeddedView.destroy(); } } }
注意事项
- 吸顶状态激活时要给原位置的元素加
display: none隐藏,避免页面上同时出现两个相同的元素 - 可以给滚动事件加10~20ms的防抖,避免滚动过程中频繁触发逻辑导致性能问题
- 增加窗口
resize事件监听,吸顶状态下窗口尺寸变化时重新计算left值,避免横向位置错位
内容的提问来源于stack exchange,提问作者netdjw
相关产品推荐
相关产品推荐

