import tocbot from 'tocbot';
/**
* 文章阅读模块
*/
export class ArticleRead {
private readonly _fab = document.querySelector<HTMLElement>('.js-toc-fab');
private readonly _toc = document.querySelector<HTMLElement>('.js-toc');
private _toggleToc: ((event: Event) => void) | null = null;
private _outsideClickHandler: ((event: MouseEvent) => void) | null = null;
private _tocClickHandler: ((event: MouseEvent) => void) | null = null;
private _tocInitialized: boolean = false;
constructor() {
this.init();
}
public destroy(): void {
if (this._fab && this._toggleToc) {
this._fab.removeEventListener('click', this._toggleToc);
}
if (this._outsideClickHandler) {
document.removeEventListener('click', this._outsideClickHandler);
}
if (this._toc && this._tocClickHandler) {
this._toc.removeEventListener('click', this._tocClickHandler);
}
if (this._tocInitialized) {
tocbot.destroy();
this._tocInitialized = false;
}
this._toggleToc = null;
this._outsideClickHandler = null;
this._tocClickHandler = null;
}
private init() {
// 1. 图片简单预处理
document.querySelectorAll('.article-content img').forEach((img) => {
img.classList.add('img-fluid');
});
// 2. 目录生成(Tocbot)
this.generateToc();
this.initMobileEvents();
}
private initMobileEvents(): void {
const fab = this._fab;
const toc = this._toc;
if (!fab || !toc) {
return;
}
this._toggleToc = (event: Event) => {
event.preventDefault();
event.stopPropagation();
toc.classList.toggle('is-open-mobile');
};
fab.addEventListener('click', this._toggleToc);
// 点击外部时关闭
this._outsideClickHandler = (event: MouseEvent) => {
if (window.innerWidth > 1200) {
return;
}
const target = event.target as Node;
// 如果点击位置在TOC和FAB之外,则关闭
if (
!toc.contains(target) &&
!fab.contains(target) &&
toc.classList.contains('is-open-mobile')
) {
toc.classList.remove('is-open-mobile');
}
};
document.addEventListener('click', this._outsideClickHandler);
// 点击TOC内部链接时关闭
this._tocClickHandler = (event: MouseEvent) => {
const target = event.target as HTMLElement;
if (target.tagName === 'A' || target.closest('a')) {
// 如果是移动端视图
if (window.innerWidth <= 1200) {
toc.classList.remove('is-open-mobile');
}
}
};
toc.addEventListener('click', this._tocClickHandler);
}
private generateToc(): void {
const tocContainer = document.querySelectorAll('.js-toc');
const content = document.querySelectorAll(
'.article-content h1, .article-content h2, .article-content h3'
);
if (tocContainer.length === 0 || content.length === 0) {
tocContainer.forEach((container) => {
const containerElement = container as HTMLElement;
if (containerElement != null) {
containerElement.style.display = 'none';
}
});
return;
}
tocbot.init({
tocSelector: '.js-toc',
contentSelector: '.article-content',
headingSelector: 'h1, h2, h3',
hasInnerContainers: true,
scrollSmooth: true,
scrollSmoothDuration: 420,
// 适配固定头部
headingsOffset: 80,
scrollSmoothOffset: -80,
});
this._tocInitialized = true;
// 带动画显示目录
tocContainer.forEach((container) => container.classList.add('is-visible'));
}
}
⚠⚠ 以下内容为AI分析的结果,请根据实际情况进行判断。
ArticleRead.ts 分析报告
一、文件定位
ArticleRead.ts 是 Dpz.Core.Web 前端脚本中「文章阅读页」的页面级功能模块,职责有两件事:
- 给正文图片补 Bootstrap 的
img-fluid响应式类; - 基于第三方库 tocbot 生成文章目录(TOC),并处理移动端目录抽屉的开关交互。
它属于「核心壳 + 按需特性」架构下的一个 feature chunk,只在文章阅读页被动态加载。依赖声明可见 package.json(tocbot: ^4.36.4)。
二、生命周期与外部协作
该模块没有被 App.ts 静态引入,而是通过特性注册表挂载:
- features.ts 中注册了
article-read,匹配条件是页面存在.article-content或.js-toc;load()里await import('../ArticleRead.ts'),工厂函数new ArticleRead()并返回{ destroy }。 - FeatureLoader.ts 的
refreshFeatures()在 PJAX 跳转时先disposeFeatures()(逐个调用destroy)再按新 DOM 重新activateFeatures()。因此本文件的destroy()是配合 PJAX 无刷新导航所必需的清理逻辑,用于避免监听器累积和 tocbot 残留实例。
DOM 契约(由视图与样式提供,不是本文件定义):
- Read.cshtml:
<nav class="js-toc toc">、<button class="toc-fab js-toc-fab">、<div class="article-content markdown-body">(正文容器,标题选择器的根)。 - tocbot.css:
.toc默认display:none,.toc.is-visible才显示;≤1200px时为浮动抽屉,靠.toc.is-open-mobile打开,FAB 靠.toc.is-visible ~ .toc-fab显示。也就是说 JS 只负责加/去类,显隐规则全在 CSS。
另需注意:Views/Code/README.md 明确说明代码浏览页刻意避开
js-toc/js-toc-fab类名,因为本模块的选择器是全局的(document.querySelector),会抢走代码页的 TOC 容器与 FAB 事件(CodeExplorer.ts自己另用别的类名并自行调用tocbot)。
三、字段与状态
| 成员 | 含义 |
|---|---|
_fab / _toc | 构造时一次性 document.querySelector 抓取的 FAB 与 TOC 容器(只取第一个匹配元素) |
_toggleToc / _outsideClickHandler / _tocClickHandler | 保存的具名监听器引用,供 destroy() 精确移除(否则匿名函数无法解绑) |
_tocInitialized | 记录 tocbot 是否已初始化,决定 destroy() 是否需要 tocbot.destroy() |
构造函数直接调用 init(),即"实例化即生效",无显式 init 对外方法。
四、执行流程
flowchart TD
A["new ArticleRead()"] --> B["init()"]
B --> C["document.querySelectorAll('.article-content img')<br/>逐个 add('img-fluid')"]
C --> D["generateToc()"]
D --> E{"存在 .js-toc 容器<br/>且存在 h1/h2/h3 ?"}
E -->|否| F["对所有 .js-toc 设 display:none<br/>并 return(目录区彻底隐藏)"]
E -->|是| G["tocbot.init({tocSelector:'.js-toc',<br/>contentSelector:'.article-content', headingsOffset:80,<br/>scrollSmoothOffset:-80, ...})"]
G --> H["_tocInitialized = true"]
H --> I["容器 add('is-visible')(CSS 过渡淡入)"]
I --> J["initMobileEvents()"]
J --> K{"_fab 与 _toc 都存在 ?"}
K -->|否| L["return(无移动端交互)"]
K -->|是| M["绑定 3 个监听器:<br/>fab.click / document.click / toc.click"]
M --> N["destroy()"]
N --> O["移除 fab click / document click / toc click"]
O --> P{"_tocInitialized ?"}
P -->|是| Q["tocbot.destroy(); _tocInitialized=false"]
P -->|否| R["跳过"]
Q --> S["三个监听器字段置 null"]
R --> S
移动端三个监听器的分工:
- FAB 点击:
preventDefault + stopPropagation,切换is-open-mobile(也是阻止冒泡到 document 处理器、避免"开了立刻被关"的关键)。 - document 点击:仅当
window.innerWidth <= 1200才判定;若点击点在 TOC 和 FAB 之外且 TOC 当前是打开状态,则移除is-open-mobile,实现"点空白处收起"。 - TOC 内部点击:若点中的是
<a>或其后代,且处于移动端宽度,则关闭抽屉——点击目录跳转后自动收起。
generateToc() 中 headingsOffset: 80 与 scrollSmoothOffset: -80 是为固定头部做的偏移补偿,scrollSmooth 打开平滑滚动。
五、几点值得留意的实现细节(基于代码的观察)
- 选择器不对称:
_fab/_toc用querySelector(单元素),而generateToc()用querySelectorAll且对全部容器加is-visible/display:none。若页面存在多个.js-toc,tocbot 会渲染进所有匹配容器,但移动端开关事件只绑在第一个 TOC 与第一个 FAB 上。 - 无幂等保护:模块没有"已初始化则跳过"的判断。重复
new ArticleRead()会导致监听器与 tocbot 实例叠加;当前靠 FeatureLoader 的active集合 +disposeFeatures()保证不重复激活。 - 销毁不还原类名:
destroy()移除了监听器并调用tocbot.destroy(),但没有移除is-visible,也未清理加在图片上的img-fluid。由于 PJAX 会替换 DOM 节点,这通常不造成问题;但若同一 DOM 被复用,is-visible会残留。 init()未标注返回类型(private init()),而destroy()/initMobileEvents()等均有显式标注,属风格上的小不一致。
六、总结
一句话概括:这是文章详情页的"阅读体验"模块——用 tocbot 把 .article-content 的 h1/h2/h3 渲染成右侧目录,负责目录的显示时机与移动端抽屉式交互,并通过实现 destroy() 接入 FeatureLoader.ts 的 PJAX 生命周期,由 features.ts 按 .article-content / .js-toc 的存在与否动态加载。
AI 正在分析代码…
评论加载中...