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

@fumadocs/local-content 深度解析:Fumadocs 本地内容源的缓存、重载与 Source API 设计 fumadocs/local-content 深度解析Fumadocs 本地内容源的缓存、重载与 Source API 设计【免费下载链接】fumadocsThe beautiful flexible React.js docs framework.项目地址: https://gitcode.com/GitHub_Trending/fu/fumadocsfumadocs/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文件扫描、picomatchglob 匹配、chokidar文件监听、wsWebSocket并以fumadocs-core^16.15.0和vite^7/^8为可选 peer 依赖。这个包本身并不关心你的内容是什么格式它只做两件事把文件变成 source 虚拟文件通过ContentIntegration让上层集成如fumadocs/local-md、fumadocs/local-html、fumadocs/obsidian定义哪些文件、如何解析管理解析结果的缓存与失效提供按文件、按全量的失效接口供开发期 watcher 调用。CHANGELOG 中 v0.1.1 Extract shared local content source logic tofumadocs/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 loaders 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一个WeakMapParsedFile, VirtualFile把解析结果复用为虚拟文件对象——只要底层解析对象未被失效每次重建 source 得到的虚拟文件就是同一个对象引用。export function createLocalSourcePage extends PageData, Meta extends MetaData( config: LocalSourceConfigPage, Meta, ): LocalSourcePage, Meta { const fileCache new WeakMapParsedFilePage, 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 的另一半——configureStatic与configure两个生命周期钩子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 一起更新的内容例如基于文件列表生成的路由索引、交叉引用写入对应 loaderconfigure则用于让 loader 在收到失效信号时自我刷新loader.invalidate()。这是从源被动提供文件到源主动参与 loader 生命周期的转变源不再只是数据提供者还能挂钩到它被挂载的那个 loader 上。两种缓存模式memory 与 customCHANGELOG 明确给出了cache字段的两种取值语义模式行为适用场景cache: memory默认files()只调用一次直到触发invalidate()文件列表基本固定、由 loader 统一管理的源cache: custom源自己管理缓存dynamicLoader()在get()时重跑files()但仅在文件列表按身份identity发生浅层变化时才重建文件内容变化频繁、希望在运行时感知新文件的源从源码结构看fumadocs/local-content自己的dynamicSource()走的就是cache: custom路线见 source.ts#L81-L89dynamicSource(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()的扫描逻辑是用tinyglobby按includeglob 列出文件逐文件查缓存未命中则调用integration.parse()解析单个文件解析出错会被捕获并console.error不会中断整个扫描——测试 source.test.ts#L170-L185 验证了抛错的guide.md会被跳过其余文件照常输出每批次最多 100 个文件并发解析Promise.all等待该批次完成后才进入下一批。const promises: Promisevoid[] []; 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 ContentIntegrationPage, Meta { /** glob patterns to scan, relative to the content directory */ include: string[]; parse: (file: SourceFile) PromiseParsedFilePage, Meta | undefined; }include扫描 glob相对内容目录createLocalSource的config.include可以覆盖它测试 source.test.ts#L139-L144 验证覆盖后只扫描*.jsonparse把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/vitepackages/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: true、followSymlinks: false并通过自定义ignored回调按各客户端注册的 glob 过滤非目标文件通过ws的WebSocketServer路径/ _fumadocs_local_md见 protocol.ts该路径与 env 变量名作为与已发布fumadocs/local-md配置的线缆契约保留旧命名向所有客户端广播change/error事件客户端dev/ws/connection.ts 的connectDevServer发watch-dir消息注册监听目录收到change后回调source.invalidateFile(event.absolutePath)close()会依次关闭所有客户端连接、wss与watcher并处理SIGINT/SIGTERM转发。配套的runDevServerClidev/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可覆盖。浏览器侧还有对应的DevClientdev/ws/react.tsuse 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 到挂载的 loaderCHANGELOG 的 Integrations 一节描述了 GraphQL 与 Sanity 集成的行为变化GraphQL cross-links are generated from the attached loader instead of abaseUrloption onstaticSource(). Local, OpenAPI, and AsyncAPIdynamicSource()usecache: customand reuse generated files by identity untilinvalidate().也就是说GraphQL 文档的交叉引用cross-links改为从它所挂载的 loader 生成而不再依赖staticSource()上的baseUrl配置本地、OpenAPI、AsyncAPI 三类dynamicSource()统一采用cache: custom按对象身份复用已生成文件直到invalidate()。Sanity now usescache: customwhen given asanityFetchfromnext-sanity/live, callinginvalidate()in draft mode is no longer needed.Sanity 集成的变化同样围绕缓存策略当传入来自next-sanity/live的sanityFetch时采用cache: custom草稿模式draft mode下不再需要手动调用invalidate()——live fetch 本身就能驱动内容更新。v0.1.2Obsidian 内容源 v1CHANGELOG 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.要点有三直接渲染 vault不再像旧方案那样先把笔记生成成中间文件或依赖 remark 插件而是通过静态/动态 Fumadocs source 直接渲染 Obsidian vault惰性内存编译结合上文的load: () file.read()模式可以推断编译被推迟到真正需要读取内容时进行配合按文件失效实现增量重编译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-contentv0.1.2Obsidian v1直接渲染 vault、惰性内存编译、本地热重载、URL 编码链接解析v0.2.0Source API 重设计configureStatic/configure挂钩 loadercache: memory/custom双模式GraphQL 改用挂载的 loader 生成交叉引用冷扫描分块读取v0.2.1简化缓存缓存实现进一步简化结合源码看即getFiles()每轮以nextCache整体替换cache的机制如果你要为自己的内容格式编写本地 source推荐直接复用本包实现一个ContentIntegration定义include与parse交给createLocalSource再按框架类型选择staticSourceSSG或dynamicSource运行时开发期用localContentPluginVite或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),仅供参考