ngx-bootstrap Tooltip 动态内容(Dynamic Content)实现指南:从 Use-Case 规格到源码级绑定原理
ngx-bootstrap Tooltip 动态内容(Dynamic Content)实现指南:从 Use-Case 规格到源码级绑定原理
ngx-bootstrap 是 Angular 生态中基于 Bootstrap 的组件库(支持 Ivy 引擎),其 Tooltip 指令提供了丰富的内容注入方式。本文围绕仓库中 Tooltip 动态内容示例的 Use-Case 规格,完整讲解如何在 ngx-bootstrap 中实现"内容由组件数据驱动"的动态 Tooltip:从 demo 页面的 "Simple binding" 按钮入手,逐步拆解字符串绑定、TemplateRef 模板绑定与动态 HTML 三种方案,并深入 TooltipDirective 与 ComponentLoader 的底层实现,帮助读者掌握动态内容 Tooltip 的完整调用链与实战写法。
一、Use-Case 规格解读:动态内容 Tooltip 的行为契约
仓库 docs/spec/tooltip/tooltip.examples.dynamic-content.use-case.md 以用例(Use-Case)形式定义了 "Tooltip Dynamic content example" 的验收标准,这是理解该功能行为的最权威依据:
- Primary Actor(主要参与者):User(用户)
- Scope(适用范围):Ngx-bootstrap DEMO,兼容 Bootstrap 版本 3 与 4
- Goal(目标):向用户展示如何制作带动态内容的 Tooltip
其 Main success scenario(主成功场景) 逐条规定了交互链路:
- 用户打开 Tooltip 演示页面;
- 用户点击 "Dynamic content" 子菜单;
- 用户看到名为 "Simple binding" 的按钮;
- 当用户将鼠标悬停在该按钮上时,Tooltip 容器显示在按钮顶部,内容为来自组件的特定文本;
- 当用户将鼠标光标移离按钮时,Tooltip 容器消失;
- 组件源码应当使用**内容参数(content params)**编写。
规格同时给出了 Variations(变体):用户也可以直接滚动到 "Dynamic content" 子菜单,而不是点击进入。
这份规格直接对应 demo 源码:组件通过 content 属性持有内容参数,按钮通过 [tooltip]="content" 进行动态绑定——这正是"动态内容"与基础示例(直接在模板里写死字符串)的核心区别。
二、Demo 实现:组件属性驱动的 "Simple binding" 按钮
2.1 模板层:属性绑定替代字面量
在 libs/doc-pages/tooltip/src/lib/demos/dynamic/dynamic.html 中,按钮模板如下:
<button type="button" class="btn btn-info" [tooltip]="content">
Simple binding
</button>
这里使用方括号属性绑定语法 <a href="https://link.gitcode.com/i/73a74eae0570369bd248275f255cbafd" target="_blank">tooltip]="content" 将组件的 content 属性传给 TooltipDirective 的 tooltip 输入项,而不是像 [基础示例 basic.html 那样直接书写 tooltip="Vivamus sagittis..." 字面量。
2.2 组件层:内容参数承载数据
在 libs/doc-pages/tooltip/src/lib/demos/dynamic/dynamic.ts 中,组件定义简洁明了:
import { Component } from '@angular/core';
@Component({
// eslint-disable-next-line @angular-eslint/component-selector
selector: 'demo-tooltip-dynamic',
templateUrl: './dynamic.html',
standalone: false
})
export class DemoTooltipDynamicComponent {
content = 'Vivamus sagittis lacus vel augue laoreet rutrum faucibus.';
}
这正是规格第 6 条要求的"组件源码使用内容参数编写":content 属性即"内容参数",它与 Basic demo(空组件、内容写在模板字面量中)形成鲜明对比——动态方案的内容源从模板层移到了组件数据层,意味着它可以随时被计算、被接口返回、被用户输入所替换,无需修改模板。
2.3 路由与菜单挂载
该 demo 组件通过 tooltip-section.list.ts 注册到文档页:
{
title: 'Dynamic Content',
anchor: 'dynamic-content',
component: require('!!raw-loader!./demos/dynamic/dynamic.ts'),
html: require('!!raw-loader!./demos/dynamic/dynamic.html'),
description: `<p>Pass a string as tooltip content</p>`,
outlet: DemoTooltipDynamicComponent
},
它同时出现在 Overview(含源码展示)与 Examples 两个 Tab 中,这就是 Use-Case 中 "Dynamic content sub-menu" 的来源。文档页模块 tooltip.module.ts 将 DemoTooltipDynamicComponent 声明并导入 TooltipModule,保证 demo 可以实际运行。
三、源码级原理:动态内容在 Tooltip 指令中的流转
3.1 tooltip 输入:支持字符串与模板引用的 model
TooltipDirective 是动态内容的核心载体,其选择器为 [tooltip], [tooltipHtml],导出名 bs-tooltip,并声明为 standalone: true:
@Directive({
selector: '[tooltip], [tooltipHtml]',
exportAs: 'bs-tooltip',
standalone: true,
providers: [ComponentLoaderFactory, PositioningService]
})
export class TooltipDirective implements OnInit, OnDestroy {
/**
* Content to be displayed as tooltip.
*/
readonly tooltip = model<string | TemplateRef<unknown> | undefined>();
注意 tooltip 是 Angular 的 model() 信号,类型为 string | TemplateRef<unknown> | undefined。这意味着动态内容不仅可以是字符串,还可以是 ng-template 模板引用——这正是后续"自定义内容模板"与"动态 HTML"两个扩展示例的技术基础。指令同时提供 tooltipHtml 等已废弃输入(仅用于兼容旧写法,运行时通过 warnOnce 提示改用 tooltip)。
3.2 show():内容如何进入 Tooltip 容器
当用户触发显示(默认 hover/focus)时,show() 方法读取当前 tooltip() 值并交给 ComponentLoader 展示:
show(): void {
this._positionService.setOptions({ ... });
const tooltipValue = this.tooltip();
const isDisabledValue = this.isDisabled() || ...;
...
if (this.isOpen || isDisabledValue || this._delayTimeoutId || !tooltipValue) {
return;
}
const showTooltip = () => {
this._tooltip
.attach(TooltipContainerComponent)
.to(containerValue)
.position({ attachment: placementValue })
.show({
content: tooltipValue,
placement: placementValue,
containerClass: this.containerClass(),
id: `tooltip-${this.tooltipId}`
});
};
...
}
几个值得注意的源码细节:
- 空内容直接拦截:
!tooltipValue时直接 return,即[tooltip]="''"或未绑定时不会弹出空 Tooltip; - 延迟显示:若配置了
delay,会通过rxjs timer(delayValue)延后showTooltip(),并监听关闭类触发器取消未执行的延迟(见 tooltip.directive.ts); - 定位参数:
position({ attachment: placementValue })决定 Tooltip 出现在按钮的哪个方位(默认top,即规格中"shown on the top of button")。
3.3 ComponentLoader:字符串内容的渲染分支
内容真正被渲染是在 component-loader.class.ts 的 _getContentRef() 中。该方法按内容类型分派三种路径:
private _getContentRef(content, context?, initialState?): ContentRef {
if (!content) return new ContentRef([]);
if (content instanceof TemplateRef) {
// 路径一:TemplateRef —— 创建内嵌视图(EmbeddedView),支持上下文绑定
...
}
if (typeof content === 'function') {
// 路径二:组件类 —— 动态创建组件实例
...
}
const nodes = this._renderer
? [this._renderer.createText(`${content}`)]
: [];
return new ContentRef([nodes]);
}
对于本文主角——字符串动态内容——走的是最后一条分支:Renderer2.createText() 创建一个纯文本节点。也就是说,动态字符串内容不会被当作 HTML 解析,天然规避了 XSS 风险;如果确有渲染 HTML 的需求,必须显式走 TemplateRef + [innerHtml] 路径(见下文第五节)。
创建文本节点后,ComponentLoader.show() 会通过 createComponent() 实例化 TooltipContainerComponent,并把内容节点作为 projectableNodes 投影进容器模板的 <ng-content> 中:
this._componentRef = createComponent(this._compType, {
environmentInjector: this._environmentInjector,
elementInjector,
projectableNodes: this._contentRef.nodes
});
容器组件模板定义于 tooltip-container.component.ts:
<div class="tooltip-arrow arrow"></div>
<div class="tooltip-inner"><ng-content></ng-content></div>
即:动态文本最终被放置在 .tooltip-inner 内,配以箭头元素;宿主类(host class)会组合 show tooltip in tooltip-top bs-tooltip-top top 等定位与样式类,并根据 Bootstrap 版本(getBsVer() 检测,兼容 BS3/4/5)做 PlacementForBs5 映射——这正好呼应 Use-Case Scope 中"BS version 3&4"的兼容性说明。
3.4 显示与消失:默认触发器 hover focus
规格第 4、5 条要求"悬停显示、移开消失"。这一行为由 TooltipConfig 的默认值保证:
@Injectable({ providedIn: 'root' })
export class TooltipConfig {
/** sets disable adaptive position */
adaptivePosition = true;
/** tooltip placement, supported positions: 'top', 'bottom', 'left', 'right' */
placement = 'top';
/** array of event names which triggers tooltip opening */
triggers = 'hover focus';
/** a selector specifying the element the tooltip should be appended to. */
container?: string;
/** delay before showing the tooltip */
delay = 0;
}
triggers = 'hover focus' 表示同时监听鼠标悬停与键盘聚焦;placement = 'top' 即规格中"shown on the top of button"。在 ngOnInit() 中,指令将触发器注册到 ComponentLoader:
this._tooltip.listen({
triggers: this.triggers(),
show: () => this.show()
});
ComponentLoader.listen() 内部通过 listenToTriggersV2 绑定事件:hover 事件触发 show,对应的 mouseleave 触发 hide,从而完整实现"悬停出现、移开消失"的交互闭环。hide() 中还会先移除 in 类(配合淡出动画),并在 tooltipFadeDuration(默认 150ms)后真正销毁组件。
3.5 无障碍支持
show() 被调用后,onShown 事件会触发 setAriaDescribedBy(),为宿主元素动态设置 aria-describedby="tooltip-{id}"(隐藏时移除),见 tooltip.directive.ts。动态内容 Tooltip 因此天然支持屏幕阅读器,符合 WCAG 语义。
四、测试佐证:动态内容场景的行为验证
仓库单元测试 src/tooltip/testing/tooltip.directive.spec.ts 验证了与动态内容强相关的行为契约:
- 初始不显示:
tooltip should not be displayed until user does not any actions——断言页面加载时.tooltip-inner为null(对应规格第 3 条:先看到按钮,而非 Tooltip); - focus 触发显示:
tooltip should be displayed by focus event after 0 ms by default——focus()后立即出现.tooltip-inner; - mouseenter 触发显示:
tooltip should be displayed by mouseenter event——模拟mouseenter后出现内容; - 延迟显示:设置
delay = 1000后tick(1100)才出现 Tooltip; - 编程式 show/hide:通过
tooltip.show()/tooltip.hide()暴露的bs-tooltip引用手动控制。
测试模板中 tooltip="See? Now click away..." 与 [delay]="delay"、triggers="focus mouseenter" 等写法,展示了动态内容与触发器、延迟等配置组合使用的典型形态,可作为读者编写自己场景的参考模板。
五、动态内容的三种进阶形态
Use-Case 文档聚焦字符串动态内容,但 TooltipDirective 的 tooltip 输入类型决定了它同时支持模板引用。结合 demo 仓库,可在同一篇文章中厘清三种形态的适用边界:
5.1 字符串绑定(本文主体)
<button type="button" class="btn btn-info" [tooltip]="content">Simple binding</button>
内容为纯文本,走 createText 分支,安全、轻量,适合提示文案、接口返回的普通字符串。
5.2 TemplateRef 模板绑定(自定义内容模板)
<ng-template #tolTemplate>Just another: {{content}}</ng-template>
<button type="button" class="btn btn-warning" [tooltip]="tolTemplate">
TemplateRef binding
</button>
模板引用走 _getContentRef 的 TemplateRef 分支,通过 createEmbeddedView() 创建内嵌视图,支持插值表达式 {{content}}、结构指令等完整 Angular 模板能力,且随组件数据自动更新。
5.3 动态 HTML(需显式 innerHtml)
<ng-template #popTemplate>Here we go: <div [innerHtml]="html"></div></ng-template>
<button type="button" class="btn btn-success" [tooltip]="popTemplate">
Show me tooltip with html
</button>
html = `<span class="btn-block btn-danger well-sm">Never trust not sanitized HTML!!!</span>`;
动态 HTML 必须通过 ng-template + [innerHtml] 组合实现(Angular 的 innerHtml 绑定自带安全净化,并依赖 DomSanitizer 策略)。demo 中刻意使用"Never trust not sanitized HTML!!!"警示文案,提醒开发者不要直接拼接未净化的 HTML 字符串——因为纯字符串走 createText 分支不会解析 HTML,而 innerHtml 的净化规则仍需由使用者负责。三种方式的选型总结:
| 形态 | 绑定写法 | 底层分支 | 支持 HTML | 典型场景 |
|---|---|---|---|---|
| 字符串 | [tooltip]="content" |
createText |
否(纯文本) | 提示文案、接口字符串 |
| 模板引用 | [tooltip]="myTemplateRef" |
createEmbeddedView |
是(Angular 模板能力) | 结构化富内容、插值 |
| 动态 HTML | [tooltip]="tpl" + [innerHtml] |
模板分支 + innerHtml |
是(受净化策略约束) | 服务端返回的 HTML 片段 |
六、快速接入与运行
6.1 安装与模块引入
动态内容 Tooltip 是 TooltipModule 的常规能力。模块方式引入(见 docs/usage.md):
import { TooltipModule } from 'ngx-bootstrap/tooltip';
@NgModule({
imports: [TooltipModule, ...]
})
export class AppModule() {}
由于 TooltipDirective 本身是 standalone: true,也支持 Standalone 组件直接 imports: <a href="https://link.gitcode.com/i/5cd1065d6fffeebdef963fc25ac49f8f" target="_blank">TooltipModule]。TooltipModule 内部实现([tooltip.module.ts)仅导入并导出 CommonModule、TooltipDirective、TooltipContainerComponent,公共 API 由 public_api.ts 统一导出 TooltipContainerComponent、TooltipDirective、TooltipModule、TooltipConfig。
6.2 在组件中落地动态内容
将 Use-Case 规格落地到自有组件,只需三步:
- 在组件类中声明内容参数:
content = '...'; - 模板中使用属性绑定:
<button [tooltip]="content">Simple binding</button>; - 根据内容形态选择字符串 /
TemplateRef/ng-template + [innerHtml]三种方案之一。
如需调整默认行为,可通过全局 TooltipConfig(providedIn: 'root')或指令输入覆盖 placement(top/bottom/left/right)、triggers(如 'hover focus')、delay、container、adaptivePosition 等配置项。
七、小结
从 动态内容 Use-Case 规格 出发,本文完整还原了 ngx-bootstrap 动态内容 Tooltip 的"契约 → Demo → 源码 → 测试"全链路:[tooltip]="content" 属性绑定把内容参数注入 TooltipDirective 的 tooltip 信号,经 ComponentLoader 的 _getContentRef() 按类型分派渲染,最终由 TooltipContainerComponent 投影到 .tooltip-inner 中;默认 hover focus 触发器与 top 定位保证了"悬停顶部显示、移开消失"的交互闭环,单元测试与 aria-describedby 逻辑则分别验证了行为与无障碍语义。对于需要展示富内容或动态 HTML 的场景,可在此基础上无缝切换为 TemplateRef 与 [innerHtml] 组合,实现从纯文本到结构化内容的渐进式升级。