content_format 落地与双编辑模式
【这是一篇写在此项目开发期的文章】 我的后台一直有富文本和 Markdown 两个编辑模式,但文章自己并不记录是用哪种模式写的,于是前台靠正则去猜、后台重新打开时总是回到富文本。这篇记录我给文章加上 content_format 字段、在前后台和两套后端把它接起来、写脚本回填存量文章的过程,以及切换模式丢格式、预览和前台对不上这些坑。

一篇文章两种内容格式
主要问题:文章没记录自己是用哪种格式写的
我的后台编辑器一直有两个模式:富文本(Quill)和 Markdown。但这个设计有个问题 ,也就是文章自己不知道它是用哪种模式写的。
于是就只能猜。前台在 utils/markdown.ts 里猜:
export const looksLikeMarkdown = (content?: string): boolean => {
if (!content) return false;
// 先剥离 HTML 标签,避免富文本 <p> 前缀干扰 Markdown 语法检测
const text = content.replace(/<[^>]+>/g, "");
if (!text.trim()) return false;
// 正向检测 Markdown 块级语法(标题/列表/引用/代码块/表格/分隔线/任务列表)
return /(^|\n)\s{0,4}(#{1,6}\s|[-*+]\s|\d+\.\s|>\s|```|\|(?=.*\|)|---($|\n)|\[[ x]\]\s)/m.test(text);
};
但是这样会出现两类错误:
- 富文本文章里只要有一行以
-或1.开头,就会被判成 Markdown,然后交给 markdown-it 渲染。而 markdown-it 是关掉html选项的,这些标签会被转义成文本,<h2>、<ul>这类东西就直接显示在页面上了。 - Markdown 文章如果只用了行内语法(整篇没有标题、列表、代码块),也不会被识别,
**、反引号就原样显示。
后台猜得更直接。编辑模式只存在于当前这次会话里,fetchArticle 拿回文章后只填内容,editorMode 一直是默认的 richtext:
// 编辑模式:richtext(富文本,默认) | markdown(Markdown)
const editorMode = ref<'richtext' | 'markdown'>('richtext')
当我用 Markdown 写完一篇文章,下次点进来编辑,它已经变成富文本模式了,输入框里显示的是一整段 Markdown 源码。如果这时候再切回 Markdown 模式并提交,内容里还会混着之前富文本留下的 <p> 标签,前台再靠正则一猜,结果就更难说。
所以还是应该让文章显式声明自己的内容格式,前台和后台都不用再猜。
解决方案:给文章加一个 content_format 字段
数据库层面
格式目前只有两种,没必要上枚举或者 MIME 类型,一个短字符串最省事(myblog-1.1.sql):
`content` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '文章内容',
`content_format` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT 'html' COMMENT '内容格式(html/markdown)',
`cover_image` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '封面图',
后端:写入口要归一,读接口要统一字段名
两套后端要改的地方一样:读的时候把 content_format 带出来并统一成 contentFormat,写的时候从请求里取 contentFormat 存进去。
Express 这边在 models/Article.js 的 formatArticle 里做映射,查询语句补上列名:
const formatArticle = (row) => ({
id: row.id,
title: row.title,
summary: row.summary,
content: row.content,
contentFormat: row.content_format || "html",
coverImage: row.cover_image,
// ...
});
controllers/articleController.js 的 create / update 从 body 里读 contentFormat。这里我做了一次归一,只要不是 markdown,一律按 html 存:
const articleData = {
title,
content,
contentFormat: contentFormat === "markdown" ? "markdown" : "html",
// ...
};
Spring Boot 那边是 entity/Article 和 dto/ArticleDTO 各加一个 contentFormat 字段加 getter/setter,ArticleService 的 create / update 加参数,ArticleController 从请求体里读。
两套后端都过一遍之后,我发现它们的严格程度其实不太一样。Spring Boot 这边是 contentFormat != null ? contentFormat : "html",只要不是 null 就直接存,不会去校验值。前端传的是 'html' | 'markdown' 两个固定值,所以跑起来没问题,但两边的宽容度确实不同。日后想起来会修一下吧。
前台:按字段分支,缺省时回退到旧逻辑
前台改动的核心是 renderArticleContent 多接一个参数,按声明的格式分支:
export const renderArticleContent = (
content?: string,
contentFormat?: string,
): string => {
if (!content) return "";
let html: string;
if (contentFormat === "markdown") {
html = md.render(content);
} else if (contentFormat === "html") {
html = content;
} else {
// 兼容未标记格式的存量文章(按内容自动识别)
html = looksLikeMarkdown(content)
? md.render(unwrapRichText(content))
: content;
}
return normalizeContentUrls(html);
};
关于第三个判断分支,content_format 为 NULL 或空串时,还是走原来那套自动识别,这样即使迁移脚本还没跑、或者以后有文章从别的地方导入进来,页面也不会直接崩,最多是回到以前那种偶尔猜错的状态。老的 looksLikeMarkdown 和 unwrapRichText 因此也没删掉,退成了兜底。
调用处就是文章详情页多传一个值:
const renderContent = computed(() =>
renderArticleContent(article.value?.content || "", article.value?.contentFormat),
);
后台:编辑模式由文章决定
后台要改的是三处:载入文章、提交、以及模式切换。
载入时按文章声明的格式决定进哪个模式:
contentFormat: data.contentFormat === 'markdown' ? 'markdown' : 'html',
// ...
const fmt = data.contentFormat === 'markdown' ? 'markdown' : 'richtext'
editorMode.value = fmt
prevEditorMode.value = fmt
提交时反过来,把当前模式写回去。这里用了一个 watch 让 form.contentFormat 始终跟 editorMode 一致,避免在提交的地方再判断一次:
// editorMode: richtext/markdown;content_format 入库值为 html/markdown
watch(editorMode, (mode) => {
form.contentFormat = mode === 'richtext' ? 'html' : 'markdown'
})
api/index.ts 里的类型声明也跟着补了 contentFormat: 'html' | 'markdown',不然 TypeScript 会报这个字段不存在。
迁移脚本:判定逻辑必须和前台那套一致
存量文章需要一个回填脚本(scripts/addContentFormat.js)。它做两件事:列不存在就 ALTER,然后给没标格式的文章按内容判定写一个值。
脚本里的判定逻辑是照着前台 looksLikeMarkdown 原样复刻的。
/** 复刻 frontend looksLikeMarkdown 的判定:内容是否含 Markdown 块级语法 */
const looksLikeMarkdown = (content) => {
if (!content) return false;
const text = content.replace(/<[^>]+>/g, "");
if (!text.trim()) return false;
return /(^|\n)\s{0,4}(#{1,6}\s|[-*+]\s|\d+\.\s|>\s|```|\|(?=.*\|)|---($|\n)|\[[ x]\]\s)/m.test(text);
};
这里回填等于把「前台以前猜的结果」固化成「文章声明的结果」。如果脚本的判定和前台不一致,那迁移之后这些文章的渲染方式就会变,页面看起来像是被改坏了。所以需要让两边用同一套规则。
脚本做成幂等的,回填只挑「没标过格式」的行,所以重复执行不会覆盖已经写好的值:
const [rows] = await conn.query(
`SELECT id, content FROM article
WHERE content_format IS NULL OR content_format = ''`,
);
踩到的几个坑
切换模式是有损的,所以我加了二次确认
两种格式在文本层面差别很大,转换不可能完全无损:
- 富文本转 Markdown 用
turndown,标题、列表、代码块大部分能对上,但一些带内联样式的排版会退化。 - Markdown 转富文本用 markdown-it 渲染成 HTML 给 Quill,而 Quill 只注册了工具栏上那几种格式(1~3 级标题、粗体、列表、引用、代码块、链接、图片),表格、任务列表、四级以上标题这些进去就会被丢掉或降级。
所以我没做成「切过去就自动转」,而是弹一个确认框,让用户自己决定要不要转:
const isToMarkdown = fromMode === 'richtext' && newMode === 'markdown'
const message = isToMarkdown
? '当前为富文本内容,切换到 Markdown 将尝试把 HTML 转换为 Markdown 语法(可能存在格式损耗)。是否转换?'
: '当前为 Markdown 内容,切换到富文本将尝试渲染为 HTML(可能存在格式损耗)。是否转换?'
取消的时候要回到原来的模式。这里有一点需要注意:el-radio-group 上的 @change 触发时,v-model 绑的 editorMode 已经被改成新值了,所以我在外面另存了一个 prevEditorMode 记录切换前的模式,取消时用它回退,转换方向也靠它判断。
const editorMode = ref<'richtext' | 'markdown'>('richtext')
// 记录切换前的模式,用于取消时回退与转换方向判断
const prevEditorMode = ref<'richtext' | 'markdown'>('richtext')
// 防止确认对话框未决时重复触发
const modeChangePending = ref(false)
那个 modeChangePending 是防重复的。确认框还没点的时候,如果用户又动了一次单选按钮,handleModeChange 会被再调一次,两个对话框叠在一起,取消的先后顺序就乱了。所以我用这个标记挡住第二次触发,并把它强制拨回原模式。
草稿恢复也得记住格式
后台的「写文章」页面会把表单存到 localStorage,进这个页面时会自动恢复上一次的草稿。这个草稿里原本没有格式信息,恢复出来的 Markdown 草稿会进富文本模式,又变回一开始那个问题。
改法是在恢复时读一下草稿里的 contentFormat,同时把 editorMode 和 prevEditorMode 都设过去:
const isMarkdown = saved.contentFormat === 'markdown'
// ...
editorMode.value = isMarkdown ? 'markdown' : 'richtext'
prevEditorMode.value = isMarkdown ? 'markdown' : 'richtext'
后台预览和前台渲染对不上
上面那些改完之后功能是通了,但我打开 Markdown 模式的预览一看,却发现代码块和前台长得完全不一样:前台有语言标签、行号、复制按钮,预览里就是一个灰底 <pre>。
原因是两边走的是不同的渲染方式。前台的 markdown-it 没有配 highlight,代码块的增强是页面挂载之后在 DOM 上做的,动态引入 highlight.js,把 <pre> 包成 .code-block,再补上头部和复制按钮。后台预览是 v-html 直接渲染出来的,没有这个后处理环节。
我的处理是让预览直接走 markdown-it 的 highlight,覆盖掉 fence 渲染规则,一次生成完整结构:
// 覆盖默认 fence 渲染:只对代码块使用 highlight(fence 是 ``` 围栏代码块)
const defaultFence = md.renderer.rules.fence
md.renderer.rules.fence = (tokens, idx, options, env, self) => {
const token = tokens[idx]
if (!token || typeof token.content !== 'string') {
return defaultFence ? defaultFence(tokens, idx, options, env, self) : self.renderToken(tokens, idx, options)
}
const info = token.info ? token.info.trim().split(/\s+/g)[0] : ''
const highlighted = highlightCode(token.content, info)
if (highlighted.startsWith('<')) {
return highlighted + '\n'
}
// 回退:交给默认渲染器
return defaultFence ? defaultFence(tokens, idx, options, env, self) : self.renderToken(tokens, idx, options)
}
这里有两个地方要说一下。一个是 startsWith('<') 那个判断,我的 highlightCode 返回的就是一段完整的 HTML,正常情况都会走这个分支,它算是个兜底。另一个是语言不认识时不能抛错,hljs.highlight 用 ignoreIllegals 兜住,catch 里退到 highlightAuto,否则一个冷门语言标注就能让预览全白。
预览区的样式还有个小问题:内容是通过 v-html 注入的,scoped 样式选不中里面生成的元素,代码块的行号列和代码区就排不到一条线上。这部分得用 :deep() 写。
另外,代码块上的「复制」按钮也是 v-html 注入的,绑不了 Vue 事件,我用事件委托处理的:onMounted 时在 document 上注册 click,onBeforeUnmount 时移除,点到的元素 closest('.code-copy') 就复制它 data-code 里的源码。
其他修复的问题:目录和头部不吸顶
同一轮里我还修了一个一直在的布局问题。文章页的侧边目录和顶部导航都用 position: sticky,但它们在长文章里一直不生效,滚动过去就跟着走了。
最后定位到 assets/css/base/_reset.scss 里防横向滚动那两行:
html {
overflow-x: hidden;
}
body {
overflow-x: hidden;
}
overflow-x: hidden 会让元素成为一个滚动容器,position: sticky 的参照就变成了这个容器,于是吸顶失效。改成 clip 就正常了,它只裁剪、不产生滚动容器,横向溢出同样挡得住:
html {
overflow-x: clip;
}
另外目录里的长标题原来用的是 white-space: nowrap 加省略号,标题一长就被截断。我改成允许换行完整显示,并给 .toc li 和 .toc button 补了 min-width: 0。grid 子项的 min-width 默认是 auto,长标题会把卡片撑破,必须显式置 0 再配 overflow-wrap: anywhere。
待解决的地方
content_format 只解决了「文章自己知道格式」,但没有解决「同一篇文章里两种格式混用」。我现在如果往 Markdown 文章里粘一段带内联样式的 HTML,markdown-it 是关掉 html 的,那段标签会被转义出来,只能手动转成 Markdown 再贴。要不要给 Markdown 模式开一部分可信 HTML,我还没确定下来。
Spring Boot 那边写入口不做值校验这一点,我也还没对齐成 Express 的写法。两边的接口是一起被前端调用的,前端不会传出第三个值,实际风险不大,但两套后端行为不一致这件事本身还是需要处理的。



