惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

P
Privacy International News Feed
The Register - Security
The Register - Security
Microsoft Azure Blog
Microsoft Azure Blog
P
Proofpoint News Feed
M
MIT News - Artificial intelligence
Recorded Future
Recorded Future
H
Hackread – Cybersecurity News, Data Breaches, AI and More
F
Fortinet All Blogs
G
Google Developers Blog
Engineering at Meta
Engineering at Meta
B
Blog
aimingoo的专栏
aimingoo的专栏
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
N
Netflix TechBlog - Medium
Martin Fowler
Martin Fowler
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
MyScale Blog
MyScale Blog
L
LangChain Blog
T
The Blog of Author Tim Ferriss
U
Unit 42
Blog — PlanetScale
Blog — PlanetScale
C
Check Point Blog
Vercel News
Vercel News
Microsoft Security Blog
Microsoft Security Blog
D
DataBreaches.Net
Recent Announcements
Recent Announcements
云风的 BLOG
云风的 BLOG
Stack Overflow Blog
Stack Overflow Blog
博客园 - 聂微东
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
博客园 - 司徒正美
月光博客
月光博客
Jina AI
Jina AI
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
WordPress大学
WordPress大学
酷 壳 – CoolShell
酷 壳 – CoolShell
博客园 - Franky
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
Hugging Face - Blog
Hugging Face - Blog
Last Week in AI
Last Week in AI
The Last Watchdog
The Last Watchdog
P
Privacy & Cybersecurity Law Blog
有赞技术团队
有赞技术团队
G
GRAHAM CLULEY
腾讯CDC
Cyberwarzone
Cyberwarzone
爱范儿
爱范儿
I
Intezer
SecWiki News
SecWiki News

姓王者的博客

Linux用户Secure Boot自主维护指南 | 姓王者的博客 MAD Bugs 已经开始——关于信息安全的军备竞赛 | 姓王者的博客 解决钉钉Dingtalk无法在Linux新版内核上启动问题-修复可执行栈错误 | 姓王者的博客 突发:GitHub 正遭受大规模 Issue 赌博广告轰炸 | 姓王者的博客 Ubuntu26.04-beta体验:坚毅浣熊! | 姓王者的博客 fakeclaw装作龙虾发贴吧 | 姓王者的博客 找回12年前的QQ记忆 | 姓王者的博客 在Linux上玩Flash网页游戏-洛克王国 | 姓王者的博客 Copilot将使用交互数据来训练 | 姓王者的博客 重要通知-请更新我的GPG公钥 | 姓王者的博客 为了自由Android | 姓王者的博客 GPL"2,3"事 | 姓王者的博客 短文-对VitePlus的一点🤏小贡献 | 姓王者的博客 Bing收录没了?亲测有效的快速恢复指南 | 姓王者的博客 解决桌面设备二维码快速识别的工具-ClipQR | 姓王者的博客 解决 Nautilus 自定义终端插件安装依赖问题 | 姓王者的博客 OpenClaw 该熄火了 | 姓王者的博客 Vite8 - 统一的基建开始 | 姓王者的博客 Astro 6 推出啦 | 姓王者的博客 ubuntu的openvpn异常暂停推送更新 | 姓王者的博客 Ubuntu 24.04 安装 Win10 虚拟机 | 姓王者的博客 ESA-后记:热爱阿里云 | 姓王者的博客 Moonbit 0.8.0 重大发布,我也要改一下我的包 | 姓王者的博客 ESA Pages 边缘开发大赛获奖 | 姓王者的博客 Astro: 优化katex,mermaid和灯箱使用 | 姓王者的博客 从edgeone迁移到esa | 姓王者的博客 出租人类:AI时代的荒诞与真实 | 姓王者的博客 Moonbit License Checker 开发使用 | 姓王者的博客 Stalux Astro博客主题自荐 | 姓王者的博客 把Hexo永久链接迁移到Astro | 姓王者的博客 再见👋 LeanCloud | 姓王者的博客 2025年终总结 | 姓王者的博客 许可合规-fancybox | 姓王者的博客 博客主题的软著下来了 | 姓王者的博客 友链图谱 - 汇聚千丝万缕的联系 | 姓王者的博客 chen-er 专为Chen式ER图打造的npm包 | 姓王者的博客 为什么我推荐你使用GPG来加密你的邮件 | 姓王者的博客 2025第三方客户端登录东北大学邮箱 | 姓王者的博客 好久没更新了,过去与未来 | 姓王者的博客 1024 重要的日子 | 姓王者的博客 再也不见Windows10 | 姓王者的博客 偷梁换柱,解决Ubuntu24.04安装Packet Tracer缺失依赖问题 | 姓王者的博客 中秋-来试试Moonbit吧 | 姓王者的博客 Obsidian使用体验 | 姓王者的博客 猪猪侠·一只老猪的逆袭 | 姓王者的博客 国庆日纪念 | 姓王者的博客 GNU 42周年,AI时代的自由精神 | 姓王者的博客 解决Linux上启动游戏总是默认English的情况 | 姓王者的博客 7x24:运维使命 | 姓王者的博客 Tauri2.x实现系统菜单导航Vue路由 | 姓王者的博客 计算机图形学-基本图形生成算法 | 姓王者的博客 数据库原理-关系数据 | 姓王者的博客 数据库原理-设计技巧 | 姓王者的博客 数据库原理E-R模型 | 姓王者的博客 旧忆 - 我曾玩过的游戏 | 姓王者的博客 再谈自由软件 | 姓王者的博客 可能解决Tauri多窗口应用阻塞问题 | 姓王者的博客 Xingwangzhe! Z-Library We miss you and we need your help | 姓王者的博客 计算机组成原理第二章 - 定点数与浮点数 | 姓王者的博客 计算机组成原理第一章 | 姓王者的博客 不小心写死循环窗口弹出了 | 姓王者的博客 美化Grub界面 | 姓王者的博客 计算机图形学-图形的表示与数据结构 | 姓王者的博客 计算机图形学绪论 | 姓王者的博客 为什么说,大学教育与社会脱节 | 姓王者的博客 VSCode Remote 远程连接服务器记录 | 姓王者的博客 解决Tauri2.x拖拽事件问题 | 姓王者的博客 新学期第一课《计算机图形学》报告 | 姓王者的博客 Tauri在GNOME46+上通知无效的临时解决方法 | 姓王者的博客 窃文者:未经授权转载我文章 | 姓王者的博客 GPG公钥分享文化 | 姓王者的博客 解决在ubuntu上,打包vscode插件问题 | 姓王者的博客 伪造squaremap的玩家显示 | 姓王者的博客 爆,沉浸式翻译泄露敏感信息 | 姓王者的博客 读书:《Free as in Freedom》——若为自由故 | 姓王者的博客 首页文章列表懒加载优化 | 姓王者的博客 Ubuntu 24.04 安装 Vivado 2018.3 | 姓王者的博客 腾讯Edgeone免费版体验 | 姓王者的博客 在 Ubuntu 上实现 Thetis FIDO U2F 密钥登录 | 姓王者的博客 Thetis物理密钥,为什么我们应该使用物理密钥 | 姓王者的博客 高考生过来看!教你精准转换录取位次! | 姓王者的博客 ubuntu无法访问windows磁盘问题 | 姓王者的博客 收信有感,防范钓鱼邮件 | 姓王者的博客 自由不止软件-记录一次zlib上传书籍 | 姓王者的博客 时隔两年,通关夺命邮差2 | 姓王者的博客 博客一周年了,竟然坚持了下来 | 姓王者的博客 Minecraft大电影:不建不散! | 姓王者的博客 是时候了解docker了! | 姓王者的博客 编译原理:LL(1)文法 | 姓王者的博客 编译原理:文法转换 | 姓王者的博客 离散数学:子群的陪集及拉格朗日定理 | 姓王者的博客 离散数学:半群,独异点 | 姓王者的博客 《人工智能生成合成内容标识办法》与个人博客--我们应该做什么? | 姓王者的博客 通识学习:形式语言与自动机,布尔代数与数进制 | 姓王者的博客 离散数学:代数系统(一) | 姓王者的博客 Webmapview:一个我的世界内置网页地图浏览Fabric模组 | 姓王者的博客 海岛机器人农场试玩 | 姓王者的博客 正则表达式学习 | 姓王者的博客 抓取个人博客文章目录到github主页 | 姓王者的博客 制作github贪吃蛇贡献图 | 姓王者的博客
Astro 5.17构建性能优化实践:从18s到13s | 姓王者的博客
作者:xingwangzhe · 2026-02-02 · via 姓王者的博客

🕒 阅读时间:4 分钟 📝 字数:1206 👀 阅读量: Loading...

前言

在维护个人博客主题 Stalux 的过程中,随着文章数量增长到 150+ 篇,我发现构建时间逐渐变得非常的慢,对于调试来说很不方便(因为当前astro依赖的vite版本build和dev结果是不一样的)。每次 astro build 都要等待 18 秒以上,这不仅影响了开发体验,也让 CI/CD 流程变得拖沓。

本文将详细介绍如何通过 Astro 5.17 引入的 retainBody 选项,配合自定义 Remark 插件,在构建阶段提前提取文章元数据,从而将构建时间从 18s+ 优化到 13s+


问题分析为什么构建这么慢?

原始方案的问题

在优化前,我的 content.config.ts 使用了标准的 glob loader:

import { defineCollection } from "astro:content";

import { glob } from "astro/loaders";

import { z } from "astro/zod";

const posts = defineCollection({

loader: glob({

pattern: ["*.{md,mdx}"],

base: "stalux/posts/",

generateId: ({ data }) => String(data["abbrlink"]),

// 注意:这里没有 retainBody 选项,默认为 true

}),

schema: z.object({

title: z.string(),

abbrlink: z.string(),

date: z.date(),

// ... 其他字段

desc: z.string().optional(),

minutesRead: z.string().optional(),

wordCount: z.number().optional(),

}),

});

同时在文章列表页,我通过以下方式获取文章信息:

---

import { getCollection } from "astro:content";

import { convertMarkdownToText } from "@/utils/markdown";

const posts = await getCollection("posts");

// 为每篇文章计算描述、阅读时间和字数

const postsWithMeta = await Promise.all(

posts.map(async (post) => {

// ⚠️ 问题1:需要访问 post.body 获取原始内容

const plainText = convertMarkdownToText(post.body || "");

// ⚠️ 问题2:每次都要重新计算

const wordCount = plainText.length;

const minutesRead = Math.ceil(wordCount / 500) + " 分钟";

const desc = plainText.slice(0, 125) + "...";

return { ...post, wordCount, minutesRead, desc };

})

);

---

性能瓶颈

这个方案存在三个严重问题:

问题影响
重复存储post.body 保存了完整的原始 Markdown 文本,这部分数据在最终 HTML 中并不需要
重复计算每次构建时,150+ 篇文章的字数、阅读时间都要重新计算
数据传输大量的 body 内容被序列化到构建数据中,增加了 I/O 开销

通过分析,我发现 post.body 占用了存储空间的 60% 以上,而这些数据仅用于生成描述和统计信息。


解决方案:Astro 5.17 retainBody + Remark 插件

第一步:升级 Astro 到 5.17+

Astro 5.17 引入了 retainBody 选项,允许我们在 glob() loader 中禁用原始内容的保存:

Remark 插件可以在 Markdown 解析阶段直接操作 AST(抽象语法树),这是提取信息的关键时机——此时内容已经被解析,但还未生成 HTML。

创建 src/utils/remark-post-body.ts:,此处灵感来源于官方文档添加阅读时间

import getReadingTime from "reading-time";

import { toString } from "mdast-util-to-string";

export function remarkPostBody() {

return function (tree: unknown, { data }: { data: any }) {

// 将 AST 转换为纯文本

const textOnPage = toString(tree);

// 计算阅读时间

const readingTime = getReadingTime(textOnPage);

// 直接将数据写入 frontmatter

// 这些数据会被自动保存到文章元数据中

data.astro.frontmatter.wordCount = textOnPage.length;

data.astro.frontmatter.desc = textOnPage.slice(0, 125) + "...";

data.astro.frontmatter.minutesRead = readingTime.text;

};

}

  1. mdast-util-to-string:将 Markdown AST 转换为纯文本,去除所有格式标记
  2. reading-time:基于文本长度智能计算阅读时间
  3. data.astro.frontmatter:修改 frontmatter,注意,这不是直接修改,为了保持zod格式校验,astro使用remarkPluginFrontmatter,这是一个虚拟的 frontmatter,只有在渲染时才会生效。

第三步:配置 Markdown 插件

astro.config.mjs 中注册 Remark 插件:

import { remarkPostBody } from "./src/utils/remark-post-body.js";

import remarkToc from "remark-toc";

import remarkMath from "remark-math";

export default defineConfig({

markdown: {

// 确保 remarkPostBody 最先执行

remarkPlugins: [remarkPostBody, [remarkToc, { heading: "toc", maxDepth: 7 }], remarkMath],

rehypePlugins: [[rehypeKatex, { strict: false }], rehypePhotoswipe],

smartypants: true,

gfm: true,

},

});

第四步:启用 retainBody: false

修改 content.config.ts,在需要优化的集合中禁用 body 保留:

import { defineCollection } from "astro:content";

import { glob, file } from "astro/loaders";

import { z } from "astro/zod";

const posts = defineCollection({

loader: glob({

pattern: ["*.{md,mdx}"],

base: "stalux/posts/",

generateId: ({ data }) => String(data["abbrlink"]),

// ✨ 关键配置:不保留原始 body

retainBody: false,

}),

schema: z.object({

title: z.string(),

abbrlink: z.string().or(z.number().transform((num) => num.toString())),

date: z.preprocess((v) => (typeof v === "string" ? new Date(v) : v), z.date()),

updated: z.preprocess(

(v) => (v == null ? undefined : typeof v === "string" ? new Date(v) : v),

z.date().optional(),

),

draft: z.boolean().optional().default(false),

tags: z.preprocess(

(val) => (typeof val === "string" ? [val] : val),

z.array(z.string()).optional(),

),

categories: z.preprocess(

(val) => (typeof val === "string" ? [val] : val),

z.array(z.string()).optional(),

),

// ✨ 这些字段现在由 remark 插件自动填充

desc: z.string().optional(),

minutesRead: z.string().optional(),

wordCount: z.number().optional(),

cc: z.string().optional().default("CC-BY-NC-SA-4.0"),

}),

});

// 关于页面同样优化

const about = defineCollection({

loader: glob({

base: "stalux/about",

pattern: "**/*.{md,mdx}",

retainBody: false,

}),

schema: z.object({

title: z.string(),

description: z.string(),

}),

});

export const collections = { posts, about, config };

第五步:简化组件代码

现在文章列表页可以直接使用预计算的元数据:

---

import { getCollection } from "astro:content";

// 直接获取,无需额外处理

const posts = await getCollection("posts");

// ✨ 无需再计算 wordCount、minutesRead、desc

// 这些数据已经从 frontmatter 中直接可用

---

<ul>

{posts.map((post) => (

<li>

<h2>{post.data.title}</h2>

<!-- 直接使用预计算的数据 -->

<p class="desc">{post.data.desc}</p>

<span class="meta">

{post.data.wordCount} 字 · {post.data.minutesRead}

</span>

</li>

))}

</ul>

高级应用:站点总字数统计

对于需要汇总统计的场景(如站点总字数),我们需要在渲染时获取生成虚拟的 remarkPluginFrontmatter

import { getCollection, render } from "astro:content";

/**

* 获取所有文章的总字数

* 注意:wordCount 是通过 remark 插件动态生成的虚拟 frontmatter,

* 需要通过 render() 函数获取

*/

export async function getTotalWordCount(): Promise<number> {

try {

const posts = await getCollection("posts");

let totalWords = 0;

for (const post of posts) {

// render() 会返回 remarkPluginFrontmatter

const { remarkPluginFrontmatter } = await render(post);

totalWords += remarkPluginFrontmatter.wordCount || 0;

}

return totalWords;

} catch (error) {

console.error("计算文章总字数时出错:", error);

return 0;

}

}

/**

* 格式化字数显示

*/

export function formatWordCount(count: number): string {

if (count >= 10000) {

return `${(count / 10000).toFixed(1)}万`;

} else if (count >= 1000) {

return `${(count / 1000).toFixed(1)}k`;

}

return count.toString();

}

使用方式:

---

import { getTotalWordCount, formatWordCount } from "@/utils/word-count-utils";

const totalWords = await getTotalWordCount();

const formattedCount = formatWordCount(totalWords);

---

<footer>

<p>本站累计 {formattedCount} 字</p>

</footer>


效果对比

构建时间对比

指标优化前优化后提升
构建时间18.2s13.1s-28%

注意事项

1. 何时需要 retainBody: true

根据官方博客的建议,以下场景仍然需要保留 body

  • RSS 生成:需要输出原始 Markdown
  • 全文搜索:需要索引原始文本
  • 内容导出:提供 Markdown 下载功能

对于这些场景,可以单独创建一个保留 body 的集合,或者通过文件系统直接读取。

3. 兼容 render() 函数

retainBody: false 时,render() 函数仍然可用,它会从原始文件重新解析内容:

import { getEntry, render } from "astro:content";

const post = await getEntry("posts", "cbab25fa");

const { Content, remarkPluginFrontmatter } = await render(post);

// Content: 可渲染的 Astro 组件

// remarkPluginFrontmatter: 包含 wordCount、desc 等动态数据


总结

通过 Astro 5.17 的 retainBody 选项和自定义 Remark 插件,实现了去除不必要保存的内容,减少不必要的I/O,这个优化方案特别适合内容密集型网站。如果你的 Astro 项目构建时间随着文章增长而变慢,强烈建议尝试这个方案。