Skip to content

Commit 4558bc2

Browse files
author
zhaoshiwei1
committed
refactor: enhance heading ID generation in MarkdownRenderer for improved stability and uniqueness, utilizing content hashing and indexing
1 parent 4dfe4c3 commit 4558bc2

File tree

1 file changed

+118
-31
lines changed

1 file changed

+118
-31
lines changed

src/components/markdown/markdown-renderer.tsx

Lines changed: 118 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -54,57 +54,62 @@ export default function MarkdownRenderer({
5454
const [desktopTocCollapsed, setDesktopTocCollapsed] = useState(false); // 桌面端目录是否折叠
5555
const [readingProgress, setReadingProgress] = useState(0); // 阅读进度
5656

57-
// 简单的 ID 生成器 - 使用时间戳和随机数确保唯一性
58-
const generateUniqueId = (text: string) => {
57+
// 生成稳定的唯一 ID - 基于内容哈希,确保服务端和客户端一致
58+
const generateStableUniqueId = (text: string, index: number) => {
5959
const baseId = text
6060
.toLowerCase()
6161
.replace(/[^\w\u4e00-\u9fa5\s-]/g, "")
6262
.replace(/\s+/g, "-");
63-
const uniqueSuffix =
64-
Date.now().toString(36) + Math.random().toString(36).substring(2, 5);
65-
return `${baseId}-${uniqueSuffix}`;
63+
64+
// 使用内容哈希 + 索引确保唯一性和稳定性
65+
const hash = text.split("").reduce((a, b) => {
66+
a = (a << 5) - a + b.charCodeAt(0);
67+
return a & a;
68+
}, 0);
69+
70+
return `${baseId}-${Math.abs(hash).toString(36)}-${index}`;
6671
};
6772

68-
// 使用 useMemo 优化性能,只在 content 变化时重新计算目录
69-
const toc = useMemo(() => {
73+
// 预生成所有标题及其稳定 ID,确保目录和标题渲染使用一致的 ID
74+
const { toc, headingIdMap } = useMemo(() => {
7075
// 首先移除代码块内容,避免将代码中的#号误解析为标题
71-
// 匹配 ``` 包裹的代码块(包括有语言标识的和无语言标识的)
7276
const codeBlockRegex = /```[\s\S]*?```/g;
7377
let contentWithoutCodeBlocks = safeContent.replace(codeBlockRegex, "");
7478

75-
// 同时移除行内代码,避免行内代码中的#号被解析为标题
76-
// 匹配 ` 包裹的行内代码
7779
const inlineCodeRegex = /`[^`]*`/g;
7880
contentWithoutCodeBlocks = contentWithoutCodeBlocks.replace(
7981
inlineCodeRegex,
8082
""
8183
);
8284

83-
// 正则表达式:匹配 Markdown 标题(# ## ### 等)
84-
// ^(#{1,6}) 匹配行首的1-6个#号
85-
// \s+ 匹配空格
86-
// (.+)$ 匹配标题文本直到行尾
87-
// gm 标志:g=全局匹配,m=多行模式
8885
const headingRegex = /^(#{1,6})\s+(.+)$/gm;
8986
const headings: TocItem[] = [];
87+
const idMap = new Map<string, string>();
88+
const textCounters = new Map<string, number>(); // 跟踪每个文本的出现次数
9089
let match;
90+
let globalCounter = 0;
9191

9292
// 循环匹配所有标题
9393
while ((match = headingRegex.exec(contentWithoutCodeBlocks)) !== null) {
94-
const level = match[1].length; // #号的数量就是标题级别
95-
const text = match[2].trim(); // 标题文本,去除首尾空格
94+
const level = match[1].length;
95+
const text = match[2].trim();
96+
97+
// 生成稳定的唯一 ID
98+
const id = generateStableUniqueId(text, globalCounter);
9699

97-
// 生成 URL 友好的 ID(与目录生成逻辑保持一致)
98-
const id = text
99-
.toLowerCase()
100-
.replace(/[^\w\u4e00-\u9fa5\s-]/g, "") // 保留字母、数字、中文、空格、连字符
101-
.replace(/\s+/g, "-"); // 空格转连字符
100+
// 为重复的文本创建唯一的键
101+
const currentCount = textCounters.get(text) || 0;
102+
const uniqueKey = currentCount === 0 ? text : `${text}___${currentCount}`;
103+
textCounters.set(text, currentCount + 1);
102104

103105
headings.push({ id, text, level });
106+
idMap.set(uniqueKey, id); // 使用唯一键建立映射
107+
108+
globalCounter++;
104109
}
105110

106-
return headings;
107-
}, [safeContent]); // 依赖数组:只有 content 变化时才重新计算
111+
return { toc: headings, headingIdMap: idMap };
112+
}, [safeContent]);
108113

109114
// 使用 useEffect 监听滚动事件,实现目录高亮功能和阅读进度
110115
useEffect(() => {
@@ -144,6 +149,14 @@ export default function MarkdownRenderer({
144149
return () => window.removeEventListener("scroll", handleScroll);
145150
}, []); // 空依赖数组:只在组件挂载和卸载时执行
146151

152+
// 重置标题计数器
153+
useEffect(() => {
154+
// 清理全局计数器
155+
if (typeof window !== "undefined") {
156+
(window as any).__headingCounters = {};
157+
}
158+
}, [safeContent]); // 当内容变化时重置
159+
147160
// 跳转到指定章节的函数
148161
const scrollToHeading = (id: string) => {
149162
// 根据 ID 查找对应的 DOM 元素
@@ -246,8 +259,22 @@ export default function MarkdownRenderer({
246259
// 自定义 h1 标题渲染 - 为每个标题添加唯一 ID 以支持锚点跳转
247260
h1: ({ children, ...props }) => {
248261
const text = children?.toString() || "";
249-
// 简单直接:每次都生成新的唯一 ID
250-
const id = generateUniqueId(text);
262+
// 创建或获取当前标题的计数器
263+
const currentCount =
264+
(window as any).__headingCounters?.[text] || 0;
265+
const uniqueKey =
266+
currentCount === 0 ? text : `${text}___${currentCount}`;
267+
268+
// 更新计数器
269+
if (!(window as any).__headingCounters) {
270+
(window as any).__headingCounters = {};
271+
}
272+
(window as any).__headingCounters[text] = currentCount + 1;
273+
274+
// 获取预生成的 ID
275+
const id =
276+
headingIdMap.get(uniqueKey) ||
277+
generateStableUniqueId(text, currentCount);
251278
return (
252279
<h1 id={id} className="scroll-mt-20" {...props}>
253280
{children}
@@ -257,7 +284,19 @@ export default function MarkdownRenderer({
257284
// h2-h6 标题渲染
258285
h2: ({ children, ...props }) => {
259286
const text = children?.toString() || "";
260-
const id = generateUniqueId(text);
287+
const currentCount =
288+
(window as any).__headingCounters?.[text] || 0;
289+
const uniqueKey =
290+
currentCount === 0 ? text : `${text}___${currentCount}`;
291+
292+
if (!(window as any).__headingCounters) {
293+
(window as any).__headingCounters = {};
294+
}
295+
(window as any).__headingCounters[text] = currentCount + 1;
296+
297+
const id =
298+
headingIdMap.get(uniqueKey) ||
299+
generateStableUniqueId(text, currentCount);
261300
return (
262301
<h2 id={id} className="scroll-mt-20" {...props}>
263302
{children}
@@ -266,7 +305,19 @@ export default function MarkdownRenderer({
266305
},
267306
h3: ({ children, ...props }) => {
268307
const text = children?.toString() || "";
269-
const id = generateUniqueId(text);
308+
const currentCount =
309+
(window as any).__headingCounters?.[text] || 0;
310+
const uniqueKey =
311+
currentCount === 0 ? text : `${text}___${currentCount}`;
312+
313+
if (!(window as any).__headingCounters) {
314+
(window as any).__headingCounters = {};
315+
}
316+
(window as any).__headingCounters[text] = currentCount + 1;
317+
318+
const id =
319+
headingIdMap.get(uniqueKey) ||
320+
generateStableUniqueId(text, currentCount);
270321
return (
271322
<h3 id={id} className="scroll-mt-20" {...props}>
272323
{children}
@@ -275,7 +326,19 @@ export default function MarkdownRenderer({
275326
},
276327
h4: ({ children, ...props }) => {
277328
const text = children?.toString() || "";
278-
const id = generateUniqueId(text);
329+
const currentCount =
330+
(window as any).__headingCounters?.[text] || 0;
331+
const uniqueKey =
332+
currentCount === 0 ? text : `${text}___${currentCount}`;
333+
334+
if (!(window as any).__headingCounters) {
335+
(window as any).__headingCounters = {};
336+
}
337+
(window as any).__headingCounters[text] = currentCount + 1;
338+
339+
const id =
340+
headingIdMap.get(uniqueKey) ||
341+
generateStableUniqueId(text, currentCount);
279342
return (
280343
<h4 id={id} className="scroll-mt-20" {...props}>
281344
{children}
@@ -284,7 +347,19 @@ export default function MarkdownRenderer({
284347
},
285348
h5: ({ children, ...props }) => {
286349
const text = children?.toString() || "";
287-
const id = generateUniqueId(text);
350+
const currentCount =
351+
(window as any).__headingCounters?.[text] || 0;
352+
const uniqueKey =
353+
currentCount === 0 ? text : `${text}___${currentCount}`;
354+
355+
if (!(window as any).__headingCounters) {
356+
(window as any).__headingCounters = {};
357+
}
358+
(window as any).__headingCounters[text] = currentCount + 1;
359+
360+
const id =
361+
headingIdMap.get(uniqueKey) ||
362+
generateStableUniqueId(text, currentCount);
288363
return (
289364
<h5 id={id} className="scroll-mt-20" {...props}>
290365
{children}
@@ -293,7 +368,19 @@ export default function MarkdownRenderer({
293368
},
294369
h6: ({ children, ...props }) => {
295370
const text = children?.toString() || "";
296-
const id = generateUniqueId(text);
371+
const currentCount =
372+
(window as any).__headingCounters?.[text] || 0;
373+
const uniqueKey =
374+
currentCount === 0 ? text : `${text}___${currentCount}`;
375+
376+
if (!(window as any).__headingCounters) {
377+
(window as any).__headingCounters = {};
378+
}
379+
(window as any).__headingCounters[text] = currentCount + 1;
380+
381+
const id =
382+
headingIdMap.get(uniqueKey) ||
383+
generateStableUniqueId(text, currentCount);
297384
return (
298385
<h6 id={id} className="scroll-mt-20" {...props}>
299386
{children}

0 commit comments

Comments
 (0)