@fumadocs/local-content 深度解析:Fumadocs 本地内容源的缓存、重载与 Source API 设计
2026/9/15 15:09:34 网站建设 项目流程

@fumadocs/local-content 深度解析:Fumadocs 本地内容源的缓存、重载与 Source API 设计

【免费下载链接】fumadocsThe beautiful & flexible React.js docs framework.项目地址: https://gitcode.com/GitHub_Trending/fu/fumadocs

@fumadocs/local-content是 Fumadocs 文档框架中面向"本地内容源"的共享构建块包:它把文件系统里的 Markdown、JSON 等内容扫描、解析成 Fumadocs source 可直接消费的虚拟文件,并提供开发期热重载能力。本文以本仓库packages/local-content/CHANGELOG.md的演进脉络为主线,结合 packages/local-content/src 的源码实现与 packages/local-content/test/source.test.ts 的测试用例,逐层剖析其 DynamicSource 缓存策略、静态/动态 loader 的挂钩机制、冷扫描的分块读取优化,以及 Vite 插件与独立 WebSocket 开发服务器两套热重载方案,帮助你理解乃至二次开发自己的本地内容源。

包定位:本地内容源的"共享构建块"

在 Fumadocs 生态中,@fumadocs/local-content承担的是一个高度复用的抽象层。从 packages/local-content/package.json 的描述看,它是 "Shared building blocks for local content sources in Fumadocs",其依赖极简——tinyglobby(文件扫描)、picomatch(glob 匹配)、chokidar(文件监听)、ws(WebSocket),并以fumadocs-core(^16.15.0)和vite(^7/^8)为可选 peer 依赖。

这个包本身并不关心你的内容是什么格式,它只做两件事:

  1. 把文件变成 source 虚拟文件:通过ContentIntegration让上层集成(如@fumadocs/local-md@fumadocs/local-html@fumadocs/obsidian)定义"哪些文件、如何解析";
  2. 管理解析结果的缓存与失效:提供按文件、按全量的失效接口,供开发期 watcher 调用。

CHANGELOG 中 v0.1.1 "Extract shared local content source logic to@fumadocs/local-content" 正是这个定位的直接证据:早期各本地源集成各自实现一遍扫描与缓存逻辑,v0.1.1 开始将其抽取到本包统一维护,v0.2.x 则在此基础上完成了一次 source API 的重新设计。

v0.2.0 核心:Source API 重新设计

CHANGELOG 对 v0.2.0 的定义是 "Redesign source API",其关键变化是:

Content sources can hook into the static loader they are attached to, and dynamic sources can opt out of the loader's in-memory file cache.

结合 packages/local-content/src/source.ts 的实现,可以看到这套 API 的实际形态。

createLocalSource:一个源,两种出口

createLocalSource接收LocalSourceConfigdir内容根目录、include覆盖 glob、integration解析器),返回的LocalSource同时提供两种消费方式:

  • staticSource(options)一次性扫描并返回StaticSource,适合构建期(SSG)预渲染;
  • dynamicSource(options):返回DynamicSource,适合服务端运行时按需读取。

二者的文件来源统一收敛在内部createFiles()里:它调用 storage 的getFiles()拿到底层ParsedFile,再通过fileCache(一个WeakMap<ParsedFile, VirtualFile>)把"解析结果"复用为"虚拟文件对象"——只要底层解析对象未被失效,每次重建 source 得到的虚拟文件就是同一个对象引用。

export function createLocalSource<Page extends PageData, Meta extends MetaData>( config: LocalSourceConfig<Page, Meta>, ): LocalSource<Page, Meta> { const fileCache = new WeakMap<ParsedFile<Page, Meta>, LocalVirtualFile>(); const storage = createStorage(config); async function createFiles({ baseDir }: SourceOptions = {}) { return (await storage.getFiles()).map(({ file, parsed }) => { let v = fileCache.get(parsed); if (!v) { v = { type: parsed.type, path: baseDir ? path.join(baseDir, file) : file, absolutePath: path.resolve(config.dir, file), data: parsed.data, } as LocalVirtualFile; fileCache.set(parsed, v); } return v; }); } // ... }

这段代码有几个值得注意的细节:

  • 虚拟文件路径baseDir存在时,虚拟路径会拼上baseDir前缀(测试 source.test.ts#L70-L75 验证了baseDir: 'docs'时所有路径都以docs/开头);
  • 对象身份复用:只要parsed对象还在缓存里,重复构建得到的虚拟文件就是同一个对象,这为上层"按身份对比文件列表"的优化(见下文cache: 'custom')提供了基础;
  • 绝对路径absolutePath始终基于config.dir解析,供 watcher 按文件失效使用。

configureStatic / configure:挂载到 loader 的钩子

CHANGELOG 给出的示例展示了新 API 的另一半——configureStaticconfigure两个生命周期钩子:

export function createMySource(): DynamicSource { return { cache: 'custom', async files() { return loadFiles(); }, configureStatic({ loader, source }) { // `loader` is the created static loader // `source` is the record key when using named sources }, configure(loader, { source }) { loader.invalidate(); }, }; }
  • configureStatic在源被挂载到loader()时执行,并且每当dynamicLoader()构建新的静态 loader 时会再次执行——这意味着动态源可以趁机把"需要随 loader 一起更新的内容"(例如基于文件列表生成的路由索引、交叉引用)写入对应 loader;
  • configure则用于让 loader 在收到失效信号时自我刷新(loader.invalidate())。

这是从"源被动提供文件"到"源主动参与 loader 生命周期"的转变:源不再只是数据提供者,还能挂钩到它被挂载的那个 loader 上。

两种缓存模式:memory 与 custom

CHANGELOG 明确给出了cache字段的两种取值语义:

模式行为适用场景
cache: 'memory'(默认)files()只调用一次,直到触发invalidate()文件列表基本固定、由 loader 统一管理的源
cache: 'custom'源自己管理缓存;dynamicLoader()get()时重跑files(),但仅在文件列表按身份(identity)发生浅层变化时才重建文件内容变化频繁、希望在运行时感知新文件的源

从源码结构看,@fumadocs/local-content自己的dynamicSource()走的就是cache: 'custom'路线(见 source.ts#L81-L89):

dynamicSource(options) { return { cache: 'custom', files: () => createFiles(options), invalidate() { storage.clearCache(); }, }; }

测试 source.test.ts#L92-L112 完整验证了这一行为:首次files()会解析全部 3 个文件;第二次调用由于缓存命中,parse不再执行(parsed数组保持为空)且返回的guide.md虚拟文件与第一次是同一个对象;调用invalidate()后第三次files()又重新解析全部文件。这正是"按身份判断是否需要重建"的落地形态——文件没变,就复用对象;变了,才重新解析。

按文件级失效:invalidateFile 与 invalidateAll

除全量invalidate()外,LocalSource还暴露了两个粒度不同的失效接口:

  • invalidateFile(file):删除单个文件的解析缓存,由 watcher 在对应文件变更时调用;
  • invalidateAll():清空全部缓存。

测试 source.test.ts#L114-L137 分别验证:只失效guide.md时,下一次扫描只有guide.md被重新 parse;而invalidateAll()会让所有文件重新 parse。这套按文件粒度的失效机制,是开发期热重载"改一个文件只重编译一个文件"的前提。

冷扫描优化:分块读取而不是并发全开

CHANGELOG v0.2.0 中的一项性能优化值得单独说明:

getFiles()awaits each chunk before starting the next, instead of starting the entire tree concurrently.

在 packages/local-content/src/storage.ts 中可以看到CHUNK_SIZE = 100的常量。getFiles()的扫描逻辑是:

  1. tinyglobbyincludeglob 列出文件;
  2. 逐文件查缓存,未命中则调用integration.parse()解析(单个文件解析出错会被捕获并console.error,不会中断整个扫描——测试 source.test.ts#L170-L185 验证了抛错的guide.md会被跳过,其余文件照常输出);
  3. 每批次最多 100 个文件并发解析,Promise.all等待该批次完成后才进入下一批。
const promises: Promise<void>[] = []; for (let i = 0; i < Math.min(CHUNK_SIZE, files.length); i++) { promises.push(next()); } await Promise.all(promises);

这种"滑动窗口式"的分块策略,在保留一定并行度的同时,避免了对海量内容目录一次性并发启动整棵解析树(例如每个 MDX 文件都要经过编译管线)造成的资源峰值;同时每次getFiles()结束后用nextCache整体替换cache,保证同一轮扫描内一致性,下一轮再复用。

解析契约:ContentIntegration

扫描与解析之间通过 packages/local-content/src/integration.ts 中定义的ContentIntegration解耦:

export interface ContentIntegration<Page, Meta> { /** glob patterns to scan, relative to the content directory */ include: string[]; parse: (file: SourceFile) => Promise<ParsedFile<Page, Meta> | undefined>; }
  • include:扫描 glob,相对内容目录;createLocalSourceconfig.include可以覆盖它(测试 source.test.ts#L139-L144 验证覆盖后只扫描*.json);
  • parse:把SourceFile(含相对路径、绝对路径、read()读文件)解析为{ type: 'page', data } | { type: 'meta', data },返回undefined表示跳过该文件。

注释里还有一条重要约定:"Called again after the file is invalidated, so anything expensive on the returned data (such as compiling) should be memoized per call."——即解析函数是会被重复调用的,昂贵的编译工作应该在返回的 data 里做记忆化。测试 source.test.ts#L24-L44 给出的示例集成正是把load: () => file.read()这种惰性读取放进 page data,而不是在 parse 阶段就读文件。

开发期热重载:Vite 插件与独立 WebSocket 服务器

CHANGELOG v0.1.2 提到 "local content hot reload"(本地内容热重载),v0.2.0 又强调内容在运行时读取而非编译导入。@fumadocs/local-content为此提供了两套 watcher 适配器,二者都建立在WatchableSource契约之上(dir内容目录 +include扫描模式 +invalidateFile失效单文件)。

方案一:Vite 插件(dev/vite

packages/local-content/src/dev/vite/index.ts 提供watchWithVite(source)localContentPlugin()

  • localContentPluginapply: 'serve',仅在开发服务器启用。由于内容目录位于模块图之外(内容在运行时读取、并非 import 进来的模块),Vite 默认不会监听它们,插件在configureServer里把所有已注册源的dir加入server.watcher
  • 文件发生add/change/unlink时,按源目录 + picomatch 匹配,命中的源调用invalidateFile(absolutePath)reload选项(默认true)决定是否向浏览器发送full-reload全量刷新;
  • watchWithVite:把源注册进一个进程级 registrySymbol.for('fumadocs.local-content.vite-registry'))。注释解释得很清楚:Vite 插件运行在 config graph、源运行在 SSR graph,二者各自持有本模块的不同实例,因此必须借助全局 Symbol 共享状态。注册后若服务器已启动,还会立即把源目录补进 watcher。

方案二:独立 WebSocket 开发服务器(dev/ws

当框架跑多个 worker(例如多进程渲染)时,Vite 方案就不够了——每个 worker 需要共享同一个 watcher。于是 packages/local-content/src/dev/ws/watcher.ts 提供了startDevServer

  • chokidar监听文件,ignoreInitial: truefollowSymlinks: false,并通过自定义ignored回调按各客户端注册的 glob 过滤非目标文件;
  • 通过wsWebSocketServer(路径/ _fumadocs_local_md,见 protocol.ts,该路径与 env 变量名作为与已发布@fumadocs/local-md配置的线缆契约保留旧命名)向所有客户端广播change/error事件;
  • 客户端(dev/ws/connection.ts 的connectDevServer)发watch-dir消息注册监听目录,收到change后回调source.invalidateFile(event.absolutePath)
  • close()会依次关闭所有客户端连接、wsswatcher,并处理SIGINT/SIGTERM转发。

配套的runDevServerCli(dev/ws/server.ts)允许集成方在自己的 CLI 里暴露形如<name> dev [-p port] -- <command...>的子命令:先启动 dev server、把 URL 写入环境变量(FD_LOCAL_MD_DEV_SERVER_URL/NEXT_PUBLIC_FD_LOCAL_MD_DEV_SERVER_URL/VITE_FD_LOCAL_MD_DEV_SERVER_URL三者都会写入,且必须硬编码以便打包器内联),再以子进程方式运行next dev等命令。默认端口 8000,-p可覆盖。

浏览器侧还有对应的DevClient(dev/ws/react.ts,'use client'):从环境变量读取 URL 建立 WebSocket,收到change事件后调用router.refresh()刷新页面——这就是"改 Markdown 文件、浏览器立即更新"的最后一环。值得一提的是@fumadocs/local-html直接复用了这两套适配器(见 packages/local-html/src/dev/vite.ts 与 packages/local-html/src/dev/ws.ts 的 re-export),印证了本包"共享构建块"的定位。

v0.2.0 集成变化:从 baseUrl 到挂载的 loader

CHANGELOG 的 "Integrations" 一节描述了 GraphQL 与 Sanity 集成的行为变化:

GraphQL cross-links are generated from the attached loader instead of abaseUrloption onstaticSource(). Local, OpenAPI, and AsyncAPIdynamicSource()usecache: 'custom'and reuse generated files by identity untilinvalidate().

也就是说,GraphQL 文档的交叉引用(cross-links)改为从它所挂载的 loader 生成,而不再依赖staticSource()上的baseUrl配置;本地、OpenAPI、AsyncAPI 三类dynamicSource()统一采用cache: 'custom',按对象身份复用已生成文件直到invalidate()

Sanity now usescache: 'custom'when given asanityFetchfromnext-sanity/live, callinginvalidate()in draft mode is no longer needed.

Sanity 集成的变化同样围绕缓存策略:当传入来自next-sanity/livesanityFetch时采用cache: 'custom',草稿模式(draft mode)下不再需要手动调用invalidate()——live fetch 本身就能驱动内容更新。

v0.1.2:Obsidian 内容源 v1

CHANGELOG v0.1.2 记录了 Obsidian 集成(@fumadocs/obsidian)的 v1 发布:

Render Obsidian vaults directly through static or dynamic Fumadocs sources, with lazy in-memory compilation and local content hot reload. Remove the old generated-file and remark-plugin integrations.

要点有三:

  1. 直接渲染 vault:不再像旧方案那样先把笔记生成成中间文件或依赖 remark 插件,而是通过静态/动态 Fumadocs source 直接渲染 Obsidian vault;
  2. 惰性内存编译:结合上文的load: () => file.read()模式,可以推断编译被推迟到真正需要读取内容时进行,配合按文件失效实现增量重编译;
  3. URL 编码相对链接解析:Obsidian 笔记中的相对文件链接常含 URL 编码(如%20空格),v0.1.2 将其"解析到对应的解码后源路径"("Resolve URL-encoded relative file links against their decoded source paths"),保证 wiki 链接能正确落到磁盘文件上。

仓库中的 examples/obsidian 提供了完整的可运行示例,包含app/lib/public/(内有测试用的*.md笔记与*.png附件),可以直接对照本文的 API 说明查看lib中如何组装 Obsidian source 与热重载适配器。

版本演进一览与适用建议

版本主题关键变化
v0.1.1抽取共享逻辑把本地内容源的公共扫描/缓存逻辑提取到@fumadocs/local-content
v0.1.2Obsidian v1直接渲染 vault、惰性内存编译、本地热重载、URL 编码链接解析
v0.2.0Source API 重设计configureStatic/configure挂钩 loader;cache: 'memory'/'custom'双模式;GraphQL 改用挂载的 loader 生成交叉引用;冷扫描分块读取
v0.2.1简化缓存缓存实现进一步简化(结合源码看,即getFiles()每轮以nextCache整体替换cache的机制)

如果你要为自己的内容格式编写本地 source,推荐直接复用本包:实现一个ContentIntegration(定义includeparse)交给createLocalSource,再按框架类型选择staticSource(SSG)或dynamicSource(运行时),开发期用localContentPlugin(Vite)或watchWithDevServer/startDevServer(多 worker 或非 Vite 框架)接入热重载。缓存策略上,若文件列表稳定、变化主要由 loader 管理,用默认memory即可;若需要运行时感知新增/删除文件,应选择custom并自行管理invalidate()时机——@fumadocs/local-md@fumadocs/local-html@fumadocs/obsidian等本地集成正是这套 API 的最佳实践样本。

【免费下载链接】fumadocsThe beautiful & flexible React.js docs framework.项目地址: https://gitcode.com/GitHub_Trending/fu/fumadocs

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询