MarkdownContent 代码细节解析笔记
这篇整理的是 components/MarkdownContent.tsx 里几个 Markdown 预处理函数的代码细节。重点不是完整业务背景,而是把一些容易卡住的 JavaScript / TypeScript 语法、正则写法和函数运行机制拆开说明。
content.split(/\r?\n/)
代码:
const lines = content.split(/\r?\n/);
这里做的第一件事是把一整段字符串按换行拆成数组。
比如原始内容是:
const content = " # 标题\n 正文\n - 列表";
执行后会得到:
[
" # 标题",
" 正文",
" - 列表"
]
所以后面才能继续使用数组方法:
lines.filter(...)
nonEmptyLines.every(...)
lines.map(...)
正则 /\r?\n/ 是为了兼容不同系统的换行:
\n:Unix / macOS / Linux 常见换行。\r\n:Windows 常见换行。\r?:表示\r可有可无。
所以它可以同时处理 \n 和 \r\n。
line.trim()
代码:
line.trim()
trim() 不是转字符串。它的作用是去掉字符串开头和结尾的空白字符。
比如:
" hello ".trim()
结果是:
"hello"
它会去掉两边的:
- 空格
- tab
- 换行符
- 回车符
但不会去掉中间的空格:
" hello world ".trim()
结果是:
"hello world"
在代码里常见用法是:
line.trim().length > 0
意思是:去掉前后空白后,判断这一行还有没有实际内容。
比如:
" ".trim().length
结果是 0,说明这一行本质上是空行。
every 的作用
代码:
nonEmptyLines.every((line) => /^(?: {4}|\t)/.test(line))
every 是数组方法,意思是:数组里的每一项都满足条件时,才返回 true。
比如:
const lines = [
" # 标题",
" 正文",
" - 列表"
];
lines.every((line) => line.startsWith(" "));
结果是 true,因为每一行都以 4 个空格开头。
如果是:
const lines = [
" # 标题",
"正常正文"
];
lines.every((line) => line.startsWith(" "));
结果是 false,因为第二行不满足。
在 normalizeAccidentalDocumentIndent 里用 every,是因为只有当所有非空行都以 4 个空格或 tab 开头时,才能认为整篇内容是被整体误缩进了。
如果只要有一行满足就处理,会很危险,因为正常 Markdown 里也可能只有局部内容缩进。
some 和 every 的区别
some 和 every 都是 ES5 就有的数组方法,不是 ES6 才有。
区别是:
some
表示只要有一个满足条件,就返回 true。
every
表示必须全部满足条件,才返回 true。
例子:
const nums = [1, 2, 3];
nums.some((n) => n > 2); // true
nums.every((n) => n > 2); // false
在“整篇是否误缩进”这个场景里,应该用 every,因为判断条件必须覆盖所有非空行。
这一段判断的含义
代码:
if (
nonEmptyLines.length === 0 ||
!nonEmptyLines.every((line) => /^(?: {4}|\t)/.test(line))
) {
return content;
}
可以翻译成:
如果没有任何非空行
或者
不是每一条非空行都以 4 个空格或 tab 开头
那就不要处理,直接返回原内容
这里的 ! 是否定。
nonEmptyLines.every(...)
表示全部满足。
!nonEmptyLines.every(...)
表示不是全部满足。
也就是说,只有当所有非空行都满足缩进条件时,代码才会继续往下走,统一去掉一层缩进。
正则 /^(?: {4}|\t)/
代码:
/^(?: {4}|\t)/
这个正则用来判断一行开头是否是:
- 4 个空格
- 或 1 个 tab
拆开看:
^
表示从字符串开头开始匹配。
{4}
表示连续 4 个空格。
\t
表示 tab。
|
表示或者。
(?: ... )
表示非捕获分组。
所以:
/^(?: {4}|\t)/
整体意思是:
从行首开始,匹配 4 个空格或 1 个 tab
正则里的 ?: 是什么
很多人第一次看到这个会以为 ?: 是三元表达式里的判断,其实不是。
在正则里:
(?:A|B)
表示非捕获分组。
它的作用是:
把 A|B 这一段括起来,让它作为一个整体参与匹配,但不要保存这个分组结果
普通分组:
/(A|B)/
会捕获匹配结果。
非捕获分组:
/(?:A|B)/
只负责分组,不额外捕获。
在当前代码里,我们只是想表达“4 个空格或者 tab”,不需要拿到分组内容,所以用 (?: ... ) 更合适。
isFenceLine
代码:
function isFenceLine(line: string): boolean {
return /^\s*(?:```|~~~)/.test(line);
}
这个函数判断某一行是不是 Markdown 代码块的开始或结束标记。
正则:
/^\s*(?:```|~~~)/
拆开:
^
从行首开始。
\s*
允许前面有任意数量的空白字符。
(?:```|~~~)
匹配三个反引号或者三个波浪线。
所以这些都会返回 true:
isFenceLine("```");
isFenceLine("```ts");
isFenceLine("~~~");
isFenceLine(" ```");
这些会返回 false:
isFenceLine("hello");
isFenceLine("abc ```");
因为 abc ``` 里的代码块标记不是从行首开始的。
isDiagramLine
代码:
function isDiagramLine(line: string): boolean {
const trimmedLine = line.trim();
if (!trimmedLine) {
return false;
}
const boxDrawingMatches = line.match(/[┌┐└┘├┤┬┴┼│─━┃╭╮╰╯]/g);
if (boxDrawingMatches && boxDrawingMatches.length >= 2) {
return true;
}
return /[▶◀▼▲①②③④⑤⑥⑦⑧⑨⑩❗]/.test(line) && /\s{2,}/.test(line);
}
这个函数用来判断某一行是否像文本图形、流程图或框线图。
第一步:
const trimmedLine = line.trim();
if (!trimmedLine) {
return false;
}
空行不算图形行。
第二步:
const boxDrawingMatches = line.match(/[┌┐└┘├┤┬┴┼│─━┃╭╮╰╯]/g);
匹配常见框线字符,比如:
┌ ┐ └ ┘ │ ─ ╭ ╮
这里的 g 表示全局匹配,会找出一行里所有符合条件的字符。
第三步:
if (boxDrawingMatches && boxDrawingMatches.length >= 2) {
return true;
}
如果一行里至少出现 2 个框线字符,就认为它很可能是图形文本的一部分。
最后一步:
return /[▶◀▼▲①②③④⑤⑥⑦⑧⑨⑩❗]/.test(line) && /\s{2,}/.test(line);
如果没有明显框线,再判断它是否同时满足:
- 包含箭头、序号、提示符号。
- 存在连续两个及以上空白字符。
这样可以识别一些不是框线图、但依赖空格排版的流程说明。
flushDiagramBuffer 为什么写在函数内部
代码:
function protectDiagramBlocks(content: string): string {
const protectedLines: string[] = [];
let diagramBuffer: string[] = [];
function flushDiagramBuffer() {
...
}
}
flushDiagramBuffer 写在 protectDiagramBlocks 内部,是因为它只服务于这个函数,而且它需要直接读写外层函数里的临时变量:
protectedLines
diagramBuffer
比如:
protectedLines.push("```text", ...diagramBuffer, "```");
diagramBuffer = [];
这种内部函数访问外部变量的能力叫闭包。
如果把它写到外面,就需要把这些状态都通过参数传进去,代码会更绕:
function flushDiagramBuffer(
diagramBuffer: string[],
protectedLines: string[]
) {
...
}
而且当前逻辑里还会重新赋值:
diagramBuffer = [];
写在内部函数里可以直接修改当前这次执行过程里的缓存变量。
所以这里放内部是合理的:
- 表达它是
protectDiagramBlocks的私有工具。 - 避免污染文件顶层作用域。
- 可以直接访问当前函数的临时状态。
- 减少参数传递。
flushDiagramBuffer 的运行机制
核心代码:
function flushDiagramBuffer() {
if (diagramBuffer.length === 0) {
return;
}
if (diagramBuffer.filter(isDiagramLine).length >= 2) {
protectedLines.push("```text", ...diagramBuffer, "```");
} else {
protectedLines.push(...diagramBuffer);
}
diagramBuffer = [];
}
它的作用是把前面临时收集的“疑似图形文本行”统一处理掉。
第一步:
if (diagramBuffer.length === 0) {
return;
}
如果缓存里没有内容,就什么都不做。
第二步:
if (diagramBuffer.filter(isDiagramLine).length >= 2) {
protectedLines.push("```text", ...diagramBuffer, "```");
}
如果缓存里至少有 2 行被识别为图形行,就认为它是一整块图形文本,于是自动包成 text 代码块。
结果类似:
```text
```text
┌──────┐
│ 内容 │
└──────┘
```
```
第三步:
protectedLines.push(...diagramBuffer);
如果不够构成图形块,就不要包代码块,直接原样写回结果。
第四步:
diagramBuffer = [];
处理完后清空缓存,后面继续收集新的内容。
为什么循环结束后还要 flush 一次
代码:
for (const line of lines) {
...
}
flushDiagramBuffer();
这不是第一次调用,而是最后兜底调用。
循环过程中,遇到普通行或代码块边界时,前面的图形缓存会被处理掉。
但如果文章最后刚好以图形文本结尾,比如:
正文
┌───┐
│ A │
└───┘
循环走到最后一行时,图形内容还在:
diagramBuffer
里面。
因为后面已经没有下一行了,所以不会再遇到普通行来触发 flushDiagramBuffer()。
如果没有最后这次调用,最后一段缓存就不会进入 protectedLines,最终内容可能丢失。
所以最后的:
flushDiagramBuffer();
作用是兜底处理文件末尾残留的缓存。
protectDiagramBlocks 的整体流程
可以把它理解成一个扫描器:
逐行扫描 Markdown
↓
如果遇到原有 ``` 或 ~~~ 代码块,就跳过内部内容
↓
如果遇到图形行,就先放入 diagramBuffer
↓
如果遇到普通行,就把前面的 diagramBuffer 统一处理
↓
最后再 flush 一次,防止末尾缓存丢失
它的关键点是:不是看到一行像图形就立刻包代码块,而是先收集连续区域,等区域结束后再判断这段是否足够像图形块。
这样比单行判断更稳,不容易误伤普通文本。
小结
这几个函数背后的思路是:
先把字符串按行拆成数组
↓
过滤空行或识别特殊行
↓
用 every 做保守判断
↓
用正则识别缩进、代码块边界和图形字符
↓
用 buffer 暂存连续图形区域
↓
在合适的边界统一 flush
其中比较重要的几个点:
split(/\r?\n/)把字符串转成按行数组。trim()是去掉首尾空白,不是转字符串。every表示全部满足,适合做保守判断。some表示至少一个满足。(?: ... )是正则里的非捕获分组,不是条件判断。flushDiagramBuffer是内部闭包函数,用来处理当前扫描过程中的临时缓存。- 循环结束后的最后一次
flushDiagramBuffer()是兜底,防止最后一段缓存丢失。