#一篇文章从 D1 到用户浏览器
#一篇文章从 D1 到用户浏览器,是怎么显示出来的
这套博客/知识库系统里,后台编辑器用的是 Milkdown,但用户打开文章页时,正文并不是由 Milkdown 渲染出来的。
Milkdown 只负责后台写作:让管理员用所见即所得的方式编辑 Markdown,并把最终 Markdown 原文保存到数据库。真正的阅读页渲染链路,是前台页面请求后端接口,后端从 D1 查询 Markdown,再用 marked 渲染成 HTML,最后由 Vue 页面把 HTML 放到文章正文区域。
#整体链路
用户访问一篇文章时,入口通常是:
/topic/:id
完整过程可以概括成:
浏览器访问 /topic/:id
-> app/pages/topic/[id].vue
-> 请求 /api/topic/:id
-> server/api/topic/[id].get.ts
-> repository.getDocument(id)
-> D1 查询 md_documents
-> buildDocumentArticleView()
-> renderMarkdown(document.content)
-> marked 把 Markdown 转成 HTML
-> API 返回 ArticlePageView JSON
-> ArticleFrame.vue 使用 v-html 显示正文
#后台编辑器只负责生成 Markdown
后台编辑器组件是:
app/components/admin/MilkdownEditor.client.vue
它使用的是:
import { Crepe } from '@milkdown/crepe'
这个组件的职责是编辑 Markdown。用户在后台看到的是 Milkdown/Crepe 的富文本编辑界面,但保存时系统拿到的是 Markdown 字符串。
比如编辑器里有这样的内容:
# 心理实现

最终保存到 D1 的核心字段是 md_documents.content,也就是 Markdown 原文。
所以 Milkdown 在这里不是前台文章渲染器,而是后台 Markdown 编辑器。
#前台页面入口
文章页的前台入口文件是:
app/pages/topic/[id].vue
这个页面根据路由参数拿到文章 ID:
const documentId = computed(() => String(route.params.id || ''))
然后请求后端接口:
const { data, error } = await useFetch(() => `/api/topic/${documentId.value}`)
也就是说,前台页面本身不直接查 D1,也不直接解析 Markdown。它只负责请求 /api/topic/:id,拿到后端组装好的文章视图数据。
#后端接口查询文章
文章接口文件是:
server/api/topic/[id].get.ts
它的职责包括:
从 URL 里读取文章 ID。
通过 repository 查询文章。
判断文章是否存在、是否已发布。
计算访问权限。
组装文章页需要的完整视图数据。
核心查询入口是:
const document = await repository.getDocument(id)
如果文章不存在,或者不是 published 状态,就返回 404:
if (!document || document.status !== 'published') {
return jsonError('NOT_FOUND', '文章不存在。', 404)
}
通过这个接口,阅读页只会拿到已经发布、并且当前请求有权限访问的文章。
#Repository 从 D1 读取 md_documents
仓储逻辑在:
src/lib/repository.ts
读取单篇文章的方法是:
async getDocument(id: string): Promise<MarkdownDocument | null>
里面查询的是 D1 表:
SELECT * FROM md_documents WHERE id = ? AND deleted_at IS NULL
也就是说,文章正文来自 md_documents.content 字段。这个字段存的是 Markdown,不是 HTML。
这一步只负责拿数据,不负责把 Markdown 转成 HTML。
#文章视图组装
接口拿到文章后,会调用:
buildDocumentArticleView({
siteName: getSiteName(runtime),
document,
nav: await repository.listNav('top'),
access,
})
这个函数在:
src/lib/view.ts
它负责把数据库里的文章对象转换成前台页面需要的 ArticlePageView。
这里最关键的一行是:
const rendered = access.allowed ? renderMarkdown(document.content) : { html: '', toc: [] };
也就是说:
如果当前请求有权限阅读,就把
document.content渲染成 HTML。如果没有权限,正文 HTML 为空,只返回权限提示。
这个函数最终会返回:
html: rendered.html,
toc: rendered.toc,
html 是文章正文 HTML,toc 是文章目录。
#Markdown 渲染发生在 renderMarkdown
真正把 Markdown 转成 HTML 的文件是:
src/lib/markdown.ts
导出的函数是:
export const renderMarkdown = (markdown: string): { html: string; toc: TocItem[] }
这里使用的渲染库是 marked:
import { Marked } from 'marked';
核心返回值是:
html: marked.parse(normalizeHtmlBreaks(markdown)) as string
所以前台文章页的正文 HTML,是由 marked 根据 Markdown 原文生成的。
这个文件还做了几件重要的安全和展示处理:
标题会生成锚点 ID,并进入文章目录。
链接会过滤协议,只允许安全的 URL。
图片只允许
http和https。原始 HTML 默认会被转义,避免直接执行不可信 HTML。
<br>、<br/>、<br />会先被转换成换行,避免在正文里显示成文本标签。
所以,阅读页的渲染规则主要看 src/lib/markdown.ts,不是看 Milkdown。
#API 返回的是 JSON,不是完整 HTML 页面
server/api/topic/[id].get.ts 最终返回的是 JSON:
return json(buildDocumentArticleView(...))
返回的数据结构里包含文章页面需要的内容,例如:
{
siteName,
document,
html,
toc,
tree,
breadcrumbs,
nav,
access,
seo
}
其中 html 字段就是后端渲染好的正文 HTML。
这意味着后端接口不是直接返回完整网页,而是返回文章页视图数据。Nuxt 页面拿到这些数据后,再交给 Vue 组件显示。
#最终显示在 ArticleFrame
文章阅读布局组件是:
app/components/ArticleFrame.vue
它负责显示顶部导航、面包屑、左侧清单树、右侧文章目录,以及中间正文。
正文显示的关键代码是:
<div v-html="view.html" />
这里的 view.html 来自 /api/topic/:id 返回的数据,也就是 renderMarkdown(document.content) 的结果。
所以用户最终看到的文章正文,是这样的来源:
D1 md_documents.content
-> src/lib/repository.ts
-> src/lib/view.ts
-> src/lib/markdown.ts
-> server/api/topic/[id].get.ts
-> app/pages/topic/[id].vue
-> app/components/ArticleFrame.vue
#Milkdown 和阅读页的关系
可以把两条链路分开看。
后台写作链路:
MilkdownEditor.client.vue
-> 管理员编辑 Markdown
-> 保存到 md_documents.content
前台阅读链路:
md_documents.content
-> marked 渲染
-> ArticleFrame.vue 显示
Milkdown 不参与第二条链路。
这也是为什么后台编辑器里看起来正常,不代表前台文章页一定正常。前台文章页真正依赖的是 src/lib/markdown.ts 里的 Markdown 渲染规则,以及 ArticleFrame.vue 的显示方式。
#为什么 <br> 会显示出来
如果数据库里的 Markdown 包含:
<br />
而渲染器又把原始 HTML 当作普通文本转义,那么用户看到的就可能是字面量:
<br />
这不是 Milkdown 的问题,而是前台 Markdown 渲染规则的问题。
当前处理方式是在 src/lib/markdown.ts 里,渲染前先把常见的 HTML 换行标签转成 Markdown 能理解的换行:
const normalizeHtmlBreaks = (value: string): string =>
value.replace(/<br\s*\/?>/gi, '\n');
然后再交给 marked:
marked.parse(normalizeHtmlBreaks(markdown))
这样既能避免 <br> 显示成正文文字,又不需要放开所有原始 HTML。
#总结
这套系统的文章正文显示过程,本质上是:
Markdown 入库,后端渲染,前台插入显示。
Milkdown 只在后台编辑时出现。用户阅读文章时,真正决定正文展示效果的是:
server/api/topic/[id].get.ts:文章接口入口。src/lib/repository.ts:从 D1 读取md_documents。src/lib/view.ts:组装文章页视图。src/lib/markdown.ts:把 Markdown 渲染成 HTML。app/pages/topic/[id].vue:请求文章接口。app/components/ArticleFrame.vue:把 HTML 放到页面正文区域。
理解这条链路后,排查文章显示问题就会清楚很多:编辑器问题看 Milkdown,阅读页渲染问题看 renderMarkdown() 和 ArticleFrame.vue。