一个人做个人网站(五):Markdown 编辑器——把写作体验做成一种享受
前四篇的铺垫之后,这一篇聊个"小而美"的话题:后台的 Markdown 写作编辑器。
说它小,是因为它不涉及架构,本质就是接入一个第三方组件;说它美,是因为写作是我在这个后台里使用频率最高的动作——文章一篇篇写,代码一年也改不了几次。工具的体验会被时间放大:快捷键不顺手、插张图要七步操作、手机上完全不能写,每一个小摩擦都会乘以"使用次数"。所以这个组件值得认真挑、认真接。
这一篇讲三件事:为什么选 md-editor-v3、怎么把它接得"像这个站原生的一部分"、以及编辑器预览和线上渲染如何保持一致。
一、选型:不自己造,但要能被深度定制
自研一个 Markdown 编辑器是个深坑:CodeMirror 封装、语法快捷键、撤销栈、拖拽上传、移动端适配……每一项都够写一个月。我的原则很明确:通用基础设施坚决不自己造,把精力留给定制和集成。
对比了几类方案后选了 md-editor-v3(6.5.x),理由是:
- Vue 3 原生,
<script setup>直接用,不需要 React 包一层; - 工具栏可裁剪、可排序、可插自定义按钮,不是一个只能开关的黑盒;
- 图片上传暴露
onUploadImg(files, callback)钩子,能对接我自己的 OSS 直传; - 通过暴露的 ref 提供
insert()、focus()等命令式 API,可以做"在光标处插入网络图片"这类自定义动作; - 可选关闭 mermaid、katex、prettier、highlight 等重功能,不把用不到的几十 KB 打进包里。
它只在后台路由里被引用,而后台页面本身是路由懒加载的,所以这个组件不会增加访客端首屏的任何体积。
二、接入:组件本身只有十行,配置才是主体
编辑器在文章编辑页里的模板部分非常克制:
<MdEditor
ref="editorRef"
v-model="form.content_md"
class="post-md-editor"
language="zh-CN"
:toolbars="toolbars"
:floating-toolbars="floatingToolbars"
:on-upload-img="handleUploadImg"
:preview="!isMobile"
:no-mermaid="true"
:no-katex="true"
:no-prettier="true"
:no-highlight="true"
:no-img-zoom-in="true"
:show-code-row-number="false"
>
<template #defToolbars>
<NormalToolbar title="图片链接(粘贴 URL)" @onClick="insertImageUrl">
<el-icon class="md-editor-icon"><Picture /></el-icon>
</NormalToolbar>
</template>
</MdEditor>
正文内容就是表单的一个字段,v-model 双向绑到 form.content_md,保存时和标题、摘要、标签一起提交,没有任何特殊状态。真正花心思的是下面几处配置。
工具栏:按写作习惯重排,而不是用默认全集
默认工具栏按钮很多,我按"这个站实际写什么"做了裁剪和排序,用数组声明,组件按顺序渲染:
// '-' 是分隔符,'=' 把后续按钮推到右侧,
// 数字 0 对应 #defToolbars 插槽里的自定义按钮
const toolbars = [
'revoke', 'next', '-',
'bold', 'italic', 'strikeThrough', 'title', 'quote',
'unorderedList', 'orderedList', 'codeRow', 'link', 'image', 0, 'table',
'=', 'preview', 'previewOnly',
]
// 选中文字时浮出的格式条:只留最常用的行内格式
const floatingToolbars = [
'bold', 'italic', 'strikeThrough', 'title', '-',
'quote', 'unorderedList', 'orderedList', 'codeRow', 'link',
]
三个容易忽略的点:
strikeThrough必须出现。文章里偶尔用删除线写俏皮话,但前提是后端渲染器也认识~~——第 4 篇讲过,后端为此专门启用了pymdownx.tilde,前后端语法集合要对齐;- 浮动工具条是体验关键。手机上选中一段文字再去顶部找按钮很痛苦,浮出条让"选中即排版"在移动端也成立;
0这个魔法数字是自定义按钮的插槽位,配合下面的网络图片功能使用。
自定义按钮:在光标处插入网络图片
除了上传本地图片,我还保留了"粘贴图片 URL"的需求(引用网图、贴个流程图外链时很方便)。组件没有这个内置按钮,就通过 #defToolbars 插槽加一个,图标直接复用 Element Plus 的 Picture,视觉上和后台其他地方统一:
async function insertImageUrl() {
let url
try {
const res = await ElMessageBox.prompt('粘贴图片的网络地址,将在光标处插入 Markdown 图片', '插入图片链接', {
inputPattern: /^https?:\/\/\S+$/i,
inputErrorMessage: '请输入合法的 http(s) 图片地址',
})
url = res.value.trim()
} catch (e) {
return // 用户取消
}
editorRef.value?.insert((selectedText) => ({
targetValue: ``,
select: !!selectedText,
}))
editorRef.value?.focus()
}
这里的精髓是 insert() 的回调参数 selectedText——如果用户选中了一段文字再点按钮,这段文字会自动变成图片的 alt 文本;select: true 让插入后保持选中状态,方便继续调整。用完主动 focus() 把光标还给编辑区,否则弹窗关闭后焦点丢失,下一次敲键盘不知道落在哪。
三、图片上传:三种入口,一条通道
写作时插图有三种自然手势:点工具栏选文件、Ctrl+V 直接粘贴截图、把文件拖进编辑区。md-editor-v3 把这三种情况统一收进 onUploadImg(files, callback),我要做的只是在里面对接站点统一的上传函数——这个函数全站只有一份,封面、相册、视频上传都走它,编辑器不是特例:
async function handleUploadImg(files, callback) {
const imgs = files.filter((f) => f.type.startsWith('image/'))
if (imgs.length === 0) {
ElMessage.warning('只支持插入图片文件')
callback([])
return
}
const loading = ElLoading.service({ target: '.post-md-editor', text: '图片上传中…' })
const urls = []
try {
for (const file of imgs) {
try {
const data = await uploadFile(file, { kind: 'image' })
urls.push(data.url)
} catch (err) {
ElMessage.error(`「${file.name}」上传失败:${err?.message || '请重试'}`)
}
}
if (urls.length > 0) ElMessage.success(`已插入 ${urls.length} 张图片`)
} finally {
loading.close()
callback(urls) // 全流程只调用一次
}
}
这个函数有几条来自实战的规矩:
- 先过滤 MIME。编辑器不限制拖进来的文件类型,把 PDF 或 exe 传给 OSS 再报错就太晚了;
callback全流程只调用一次,且放在finally里。多张图串行上传,组件内部在等这个回调来插入 Markdown 并关闭 loading 状态;漏调一次,编辑器会永远转圈;调多次,图片会重复插入;- 部分失败不阻断。五张图里一张失败,其余四张正常插入,失败的那张给明确提示——写作的思路不能被一次网络抖动打断;
- 上传逻辑零特化。
uploadFile内部会先探测后端是否开启了 OSS 直传:开了就走 STS 直传(图片还会在 Canvas 里按 EXIF 摆正、压缩到长边 1920、顺带生成 500px 缩略图),没开就回退服务器中转。编辑器完全不感知这套分支,第 6 篇会把直传全链路拆开讲。
loading 遮罩用 target: '.post-md-editor' 只罩住编辑器,而不是全屏——传图时我还能去改标题、填摘要。
四、编辑器预览 ≠ 线上效果:一条必须守住的边界
这是接入 Markdown 编辑器最容易踩的概念坑:编辑器右侧的实时预览是组件用 JavaScript 渲染的,而访客最终看到的文章页是后端 Python-Markdown 渲染后入库的 HTML(第 4 篇的双存储策略)。它们是两套独立的渲染器,不做约束就一定会漂移。
我的处理原则是"承认差异、收敛差异、提供权威通道":
- 承认:编辑器预览定位为"写作反馈",不承诺与线上像素级一致;
- 收敛:两边语法集合对齐。编辑器原生支持的删除线、表格、围栏代码块,后端扩展(
extra/sane_lists/codehilite/toc/pymdownx.tilde)全部启用;反过来编辑器里关掉 mermaid/katex 这类后端不渲染的功能,不制造"写的时候有、发出来没了"的落差; - 关闭编辑器自带的代码高亮(
:no-highlight="true")。线上代码块的配色以后端 codehilite 生成的 class 体系 + 站点自己的.highlight主题 CSS 为准,不让两套高亮主题打架,也省掉一份 highlight.js 的包体; - 提供权威通道:后端有个登录后可调的
/api/admin/markdown/preview接口,任何需要"和线上完全一致"的场景(比如拿不准复杂表格语法时)都可以请求后端即时渲染。
文章目录也是同一思路的延伸:后端 toc 扩展给每个标题生成稳定的 id 并随 HTML 入库,访客页的 PostView 直接从渲染好的 DOM 里读 h2/h3 的 id 和文本生成目录、用 IntersectionObserver 做滚动高亮——目录结构来自最终 HTML,而不是前端再解析一遍 Markdown,从根上杜绝两边标题不一致。
五、移动端与视觉:让组件"住进"米白色的站点
第三方编辑器默认是中性灰白配色,直接放进行政后台会有明显的"拼贴感"。因为样式都是普通 CSS 类,用 :deep() 穿透覆盖即可:
.post-md-editor {
height: 560px;
border-radius: 10px;
overflow: hidden;
z-index: 0; /* 防止工具条下拉层盖住 Element 弹层 */
}
.post-md-editor :deep(.md-editor-toolbar-wrapper) { background: #fafaf7; }
.post-md-editor :deep(.cm-editor),
.post-md-editor :deep(.md-editor-preview-wrapper) { background: #fff; }
移动端做了两个实打实的适配决策:
- 小屏直接关掉分屏预览(
:preview="!isMobile")。5 英寸屏上左右分栏两边都只有 200px,毫无可用性;手机上专注写作,预览靠"仅预览"模式切换。isMobile来自全站统一的useMobile()composable,断点口径和其他页面一致; - 保存按钮吸底。编辑页很长,手机上滚到正文底部再滚回顶部点保存是灾难。用 fixed 定位把操作条固定在视口底部,并加了
env(safe-area-inset-bottom)适配全面屏底部横条,配半透明毛玻璃背景:
.sticky-actions {
position: fixed; left: 0; right: 0; bottom: 0; z-index: 200;
padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
background: rgba(255, 254, 251, 0.96);
backdrop-filter: blur(8px);
}
页面底部同时留出 72px 的占位空间,避免最后的正文被吸底条永久遮住。
六、踩过的坑
callback的调用时机是第一大坑。 最初我在每张图传完就调一次,结果编辑器按"批次结束"处理,后几张图的 Markdown 被吞。记住它是"整批文件处理完毕"的信号,恰好一次。- 自定义按钮图标要加组件库自己的 class(
md-editor-icon),否则尺寸、悬停色和内置按钮对不齐,会看到一个明显偏大的"外来图标"。 - placeholder 里的换行要用
HTML 实体,直接在属性字符串里敲真换行,模板解析后提示语格式会乱。 - 编辑器容器要给
z-index: 0并注意层叠上下文,否则它工具条的下拉菜单可能压在 Element 的弹窗/吸底条上面;反过来吸底条我设了 200 确保永远可点。 - 编辑器高度必须显式给(桌面 560px、手机 460px),组件不会随内容无限撑高——自适应高度在长文时会让页面滚动和编辑器内部滚动互相打架,固定高度 + 内部滚动是更可控的选择。
- 别引入 MdEditor 再在前台用。访客侧只展示 HTML,不需要编辑器;组件还有轻量的
MdPreview可供需要"JS 渲染 Markdown 展示"的场景,按需选择,别把编辑器整套打进访客包。
七、小结
这个模块代码量很小,但它体现了我做"集成型功能"的完整方法论:
- 通用轮子坚决用成熟方案,选型的标准是"可裁剪、有钩子、有命令式 API",即可被深度集成;
- 组件只负责交互,业务能力全部接到站点自己的基础设施上——上传走统一
uploadFile,渲染对齐后端扩展,配置走既有表单; - 承认两套渲染器的存在,用语法对齐 + 关闭落差功能 + 后端预览接口收敛差异,而不是假装它们一致;
- 体验细节按"使用次数"加权投入:浮动格式条、三通道插图、吸底保存,每个都不大,但高频动作值得每个都顺。
写作工具不抢内容的戏,但它让"开始写"这件事没有阻力。对一个内容站来说,这可能是最重要的工程投资。
下一篇是整个系列最硬核的一篇:OSS 直传全攻略——STS 临时凭证怎么发、RAM 角色怎么配、浏览器怎么直传和分片续传、300MB 视频为什么不该经过 ECS、以及 Nginx 内网回源如何剥掉强制下载头——第 6 篇:OSS 直传全攻略,让文件流绕开你的服务器。
系列导航:① 开篇与总体架构 → ② Flask 后端骨架 → ③ 登录与安全 → ④ 博客内容模块 → ⑤ Markdown 编辑器(本文)→ ⑥ OSS 直传全攻略 → ⑦ SPA 的 SEO 与微信分享 → ⑧ 打卡热力图与小游戏 → ⑨ AI 网页宠物与收官


