用 Bun 自带 API 替换博客里的 gray-matter 和 marked

太阳作者太阳
原创内容采用 CC-4.0 协议发布,转载请注明出处

我的博客已经用 Bun 运行,这次又把 Markdown 处理里的两个依赖换掉了。上线后,内存占用下降了很多,启动也加快了。

翻了一下 Git,实际是分两次改的:9 月 4 日的 0c8845d4 移除了 marked;9 月 5 日的 13c131fd 再把 Blog 的 gray-matter 换成 Bun.YAML.parse()。随后,内容审计和文章验证脚本里的 gray-matter 也一并移除了。下面按文件里的前后代码写,较长函数只截取与替换有关的部分。

gray-matter:原来只用了 data 和 content

博客的内容入口在 apps/blog/instrumentation-node.ts。原来读入 Markdown 后,用 gray-matter 拆出元数据和正文:

import matter from 'gray-matter';

const { data, content } = matter(source);

文章格式固定:文件开头两条 --- 之间是 YAML,后面是正文。这里没有使用自定义解析引擎,也没有把元数据重新写回文件。

Bun.YAML.parse()负责解析 YAML,分隔 frontmatter 和正文则由下面这个函数完成。这是新增到内容入口里的实现:

function parseFrontmatter(source: string) {
  const normalizedSource = source
    .replace(/^\uFEFF/, '')
    .replace(/\r\n?/g, '\n');
  const match = normalizedSource.match(
    /^---[ \t]*\n([\s\S]*?)\n---[ \t]*(?:\n|$)/,
  );

  if (!match) {
    return {
      data: {} as Record<string, unknown>,
      content: normalizedSource,
    };
  }

  const parsed = Bun.YAML.parse(match[1] ?? '');
  const data =
    parsed && typeof parsed === 'object' && !Array.isArray(parsed)
      ? (parsed as Record<string, unknown>)
      : {};

  return {
    data,
    content: normalizedSource.slice(match[0].length),
  };
}

前两次 replace() 分别处理文件开头的 BOM 和 Windows 换行。正则只从文件开头识别 frontmatter,取到第一条结束分隔线后,用 slice() 留下正文。因此,正文自己的 --- 不会被拆掉。

没有完整首部时返回空元数据和全文;有首部但 YAML 写错时,解析异常直接向外抛出。这个函数只处理本博客的 YAML 首部格式,没有照搬 gray-matter 的全部能力。

两个调用点分别在元数据扫描和正文加载中,改动相同:

-const { data, content } = matter(source);
+const { data, content } = parseFrontmatter(source);

后面的字段转换继续保留。比如 getString() 仍接收字符串、Date 和数字,标签仍过滤成字符串数组;这次没有重写公开状态和日期展示规则。

marked:先看原来的渲染入口

apps/blog/lib/markdown.ts 原来导入了 Marked 的解析器和 token 类型:

import { lexer, Marked, type RendererObject, type Tokens } from 'marked';

渲染入口先准备 Shiki,再创建一个带自定义 renderer 的 Marked 实例:

const highlighter = await getHighlighter();
await loadFenceLanguages(highlighter, markdown);

const marked = new Marked({
  gfm: true,
  renderer: createMarkdownRenderer(highlighter, documentTitle),
});

return marked.parse(markdown);

自定义 renderer 主要处理标题、外链和代码块。替换后,入口变成下面这样,取自 0c8845d4

const highlighter = await getHighlighter();
await loadFenceLanguages(highlighter, markdown);
const headings = extractMarkdownHeadings(markdown);
const codeBlocks = extractMarkdownCodeBlocks(markdown);
const markdownLinks = extractMarkdownLinks(markdown);
const html = Bun.markdown.html(markdown);

return applyExternalLinkAttributes(
  applyHighlightedCode(
    applyHeadingMarkup(html, headings, documentTitle),
    codeBlocks,
    highlighter,
  ),
  markdownLinks,
);

Bun.markdown.render() 用来收集结构,Bun.markdown.html() 生成 HTML,再补标题 ID、高亮和外链属性。后续代码增加了 noHtmlBlocksnoHtmlSpans 参数,那是另外的改动,这里先按迁移时的实现讲。

Bun 自定义渲染文档说明,render() 未提供回调的元素会透传子内容,不会自动生成对应标签。因此这里没有只写三个回调就直接返回结果,段落、列表和表格仍交给 html() 输出。

标题:从 lexer token 改成 heading 回调

原来的目录遍历 lexer(markdown),从标题 token 里取 textdepth

for (const token of lexer(markdown)) {
  if (token.type !== 'heading') continue;

  const id = createHeadingId(token.text, usedIds);
  if (token.depth === 2 || token.depth === 3) {
    items.push({
      id,
      title: getHeadingTitle(token.text),
      depth: token.depth,
    });
  }
}

现在用 heading 回调收集,原来的标题 ID 生成函数没有换:

function extractMarkdownHeadings(markdown: string) {
  const usedIds = new Map<string, number>();
  const headings: MarkdownHeading[] = [];

  Bun.markdown.render(markdown, {
    heading(children, { level }) {
      const title = getHeadingTitle(children);
      headings.push({
        depth: level,
        id: createHeadingId(title, usedIds),
        title,
      });
      return '';
    },
  });

  return headings;
}

这次调用不使用 render() 的返回值,只取 headings。目录从中筛出二、三级标题;正文的 applyHeadingMarkup() 则按顺序取出对应项,为 HTML 标题补上属性:

return `<h${depth} id="${escapeHtmlAttribute(heading.id)}" class="scroll-mt-24">${children}</h${depth}>\n`;

与文章标题相同的首个 H1 仍然省略,因为页面已经单独展示标题。重复章节继续得到 getting-startedgetting-started-2 这样的 ID。目录里的文字和正文锚点都来自同一组标题,围栏代码中的 ## 也不会被当成章节。

代码块:Shiki 保留,换一个地方调用

原来 Marked 的 code 回调接收 Tokens.Code,从中取 textlang。现在先用 Bun 的回调收集代码:

function extractMarkdownCodeBlocks(markdown: string) {
  const blocks: MarkdownCodeBlock[] = [];

  Bun.markdown.render(markdown, {
    code(children, metadata) {
      blocks.push({
        text: children.endsWith('\n')
          ? children.slice(0, -1)
          : children,
        language: metadata?.language,
      });
      return '';
    },
  });

  return blocks;
}

这里把 children 末尾的一个换行去掉,语言名从 metadata.language 取得。Shiki 的正常渲染分支还是原来的配置:

const language = getFenceLanguage(block.language, block.text);

return highlighter.codeToHtml(block.text, {
  lang: language,
  themes: HIGHLIGHT_THEMES,
  defaultColor: false,
  cssVariablePrefix: '--shiki-',
});

失败分支继续用 text 语言重新渲染,pwshcaddyfileenv 等语言别名也保留。生成普通 HTML 后,代码块按顺序替换成高亮结果:

function applyHighlightedCode(
  html: string,
  blocks: MarkdownCodeBlock[],
  highlighter: BlogHighlighter,
) {
  let blockIndex = 0;

  return html.replace(
    /<pre><code(?: class="language-[^"]*")?[^>]*>[^]*?<\/code><\/pre>\n?/g,
    (fallback) => {
      const block = blocks[blockIndex++];
      return block
        ? renderHighlightedCode(highlighter, block)
        : fallback;
    },
  );
}

所以删掉 marked 后,Shiki 的浅色、深色主题仍在使用。变化是高亮不再发生于 Marked 的 renderer 内,而是在 Bun 输出 HTML 之后回填。

外链:保留生成的标签,追加两个属性

原来的 Marked link 回调使用 this.parser.parseInline(tokens) 生成链接文字,再手工拼接整个 <a>。现在通过 Bun 收集 Markdown 中的链接地址:

Bun.markdown.render(markdown, {
  link(children, { href }) {
    links.push(href);
    return children;
  },
});

applyExternalLinkAttributes() 取得站点地址,将 linkIndex 设为 0,再处理生成的 HTML:

return html.replace(
  /<a href="([^"]*)"([^>]*)>/g,
  (tag, encodedHref: string, attributes: string) => {
    const markdownHref = markdownLinks[linkIndex];
    if (
      markdownHref === undefined ||
      decodeHtmlAttribute(encodedHref) !== markdownHref
    ) {
      return tag;
    }

    linkIndex += 1;
    if (!isExternalHttpLink(markdownHref, siteUrl)) return tag;

    return `<a href="${encodedHref}"${attributes} target="_blank" rel="noopener nofollow">`;
  },
);

站内链接、相对路径、锚点和 mailto: 保持原样,外部 HTTP 链接追加新窗口和 rel 属性。Bun 已生成的其他属性通过 attributes 保留下来,不需要重新解析链接里的粗体或行内代码。

标题、代码块和链接的后处理都依赖 Bun 输出的标签形态及顺序。例如代码块正则匹配 &lt;pre&gt;&lt;code ...&gt;,后续升级如果改变了这种输出,就需要调整这里。

搜索文本和知识分块也一起改了

同一个提交还修改了 apps/blog/lib/search.ts。原来先调用 marked.lexer(),再递归处理 token 的 tokensitems、表头和单元格,拼出搜索用的纯文本。

现在直接通过回调决定每种内容怎样进入搜索文本。下面是实际回调中的一部分:

const rendered = Bun.markdown.render(markdown, {
  heading: (children, { level }) =>
    renderHeading?.(children, level) ?? `${children}\n`,
  paragraph: (children) => `${children}\n`,
  blockquote: (children) => `${children}\n`,
  code: (children) => `\n${preserveLiteral(children)}\n`,
  codespan: preserveLiteral,
  listItem: (children) => `${children}\n`,
  th: (children) => `${children} `,
  td: (children) => `${children} `,
  html: (children) => children,
  link: (children) => children,
  image: (children) => children,
});

这里的 preserveLiteral() 把代码存入数组,返回一个临时标记:

const LITERAL_MARKER_START = '\uE000literal:';
const LITERAL_MARKER_END = '\uE001';
const literals: string[] = [];

const preserveLiteral = (children: string) => {
  const index = literals.length;
  literals.push(
    children.endsWith('\n') ? children.slice(0, -1) : children,
  );
  return `${LITERAL_MARKER_START}${index}${LITERAL_MARKER_END}`;
};

接着清理普通正文中的 HTML 标签,再把标记还原成代码。这个顺序保留了代码示例里的 &lt;tag&gt; 等字面内容,否则统一去标签时会把它们一起删掉,读者就无法按完整报错或代码片段搜索。

知识分块的入口也从 marked.lexer() 改成了这个文件新增的 markdownToSearchSections()

-import { lexer } from 'marked';
-import { markdownToSearchText } from '@/lib/search';
+import { markdownToSearchSections } from '@/lib/search';

markdownToSearchSections() 在二、三级标题回调中插入章节标记,再切分文本。knowledge-chunks.ts 使用这些分段结果,配上目录中的标题和锚点,不再把每个 section 的 Markdown 重新交给 Marked 解析。代码围栏中的标题示例仍属于代码内容。

最后清理 content 脚本的依赖

Blog 改完后,content/scripts/audit-seo-metadata.tsverify-articles.ts 还在导入 gray-matter。我把前面的解析逻辑放进 content/scripts/frontmatter.ts,供两个脚本共用:

-import matter from "gray-matter";
+import { parseFrontmatter } from "./frontmatter";

摘要审计改为:

const { data, content } = parseFrontmatter(
  await Bun.file(filePath).text(),
);

文章验证继续保留原文,解析只用于取得元数据和可见正文:

const visible = raw.replace(/<!--[\s\S]*?-->/g, "");
const parsed = parseFrontmatter(visible);
return { file, raw, visible, body: parsed.content, meta: parsed.data };

raw 没有被归一化后的结果覆盖,作者注释与原文保护仍使用原始输入。测试夹具原来需要找到 gray-matter 的绝对路径并改写 import,现在只要把 frontmatter.ts 一起复制到临时仓库。

最后删除 content/package.json 中的依赖声明,更新 bun.lockgray-mattersection-matterstrip-bom-string 等不再需要的条目随之移除,实际应用和工具脚本已经不再导入它。

这轮部署后,我观察到内存明显降低、启动加快。没有保留同条件的前后测量,所以这里不写提升百分比,也不把整轮更新的收益全部算到这两个解析器上。