返回首页
/markdown-content-code-walkthrough

MarkdownContent 代码细节解析笔记

创建于 2026/6/28 07:39:10
Markdown代码解析TypeScript渲染

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 的区别

someevery 都是 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() 是兜底,防止最后一段缓存丢失。
游客模式