‹ 返回博客

个人网站(五):Markdown 编辑器——把写作体验做成一种享受

2026-09-20 · 约 9 分钟读完 · #编程

一个人做个人网站(五):Markdown 编辑器——把写作体验做成一种享受

前四篇的铺垫之后,这一篇聊个"小而美"的话题:后台的 Markdown 写作编辑器。

说它小,是因为它不涉及架构,本质就是接入一个第三方组件;说它美,是因为写作是我在这个后台里使用频率最高的动作——文章一篇篇写,代码一年也改不了几次。工具的体验会被时间放大:快捷键不顺手、插张图要七步操作、手机上完全不能写,每一个小摩擦都会乘以"使用次数"。所以这个组件值得认真挑、认真接。

这一篇讲三件事:为什么选 md-editor-v3、怎么把它接得"像这个站原生的一部分"、以及编辑器预览和线上渲染如何保持一致。

一、选型:不自己造,但要能被深度定制

自研一个 Markdown 编辑器是个深坑:CodeMirror 封装、语法快捷键、撤销栈、拖拽上传、移动端适配……每一项都够写一个月。我的原则很明确:通用基础设施坚决不自己造,把精力留给定制和集成

对比了几类方案后选了 md-editor-v3(6.5.x),理由是:

它只在后台路由里被引用,而后台页面本身是路由懒加载的,所以这个组件不会增加访客端首屏的任何体积。

二、接入:组件本身只有十行,配置才是主体

编辑器在文章编辑页里的模板部分非常克制:

<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',
]

三个容易忽略的点:

  1. strikeThrough 必须出现。文章里偶尔用删除线写俏皮话,但前提是后端渲染器也认识 ~~——第 4 篇讲过,后端为此专门启用了 pymdownx.tilde,前后端语法集合要对齐;
  2. 浮动工具条是体验关键。手机上选中一段文字再去顶部找按钮很痛苦,浮出条让"选中即排版"在移动端也成立;
  3. 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: `![${selectedText || ''}](${url})`,
    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)        // 全流程只调用一次
  }
}

这个函数有几条来自实战的规矩:

  1. 先过滤 MIME。编辑器不限制拖进来的文件类型,把 PDF 或 exe 传给 OSS 再报错就太晚了;
  2. callback 全流程只调用一次,且放在 finally。多张图串行上传,组件内部在等这个回调来插入 Markdown 并关闭 loading 状态;漏调一次,编辑器会永远转圈;调多次,图片会重复插入;
  3. 部分失败不阻断。五张图里一张失败,其余四张正常插入,失败的那张给明确提示——写作的思路不能被一次网络抖动打断;
  4. 上传逻辑零特化uploadFile 内部会先探测后端是否开启了 OSS 直传:开了就走 STS 直传(图片还会在 Canvas 里按 EXIF 摆正、压缩到长边 1920、顺带生成 500px 缩略图),没开就回退服务器中转。编辑器完全不感知这套分支,第 6 篇会把直传全链路拆开讲。

loading 遮罩用 target: '.post-md-editor' 只罩住编辑器,而不是全屏——传图时我还能去改标题、填摘要。

四、编辑器预览 ≠ 线上效果:一条必须守住的边界

这是接入 Markdown 编辑器最容易踩的概念坑:编辑器右侧的实时预览是组件用 JavaScript 渲染的,而访客最终看到的文章页是后端 Python-Markdown 渲染后入库的 HTML(第 4 篇的双存储策略)。它们是两套独立的渲染器,不做约束就一定会漂移。

我的处理原则是"承认差异、收敛差异、提供权威通道":

文章目录也是同一思路的延伸:后端 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; }

移动端做了两个实打实的适配决策:

  1. 小屏直接关掉分屏预览:preview="!isMobile")。5 英寸屏上左右分栏两边都只有 200px,毫无可用性;手机上专注写作,预览靠"仅预览"模式切换。isMobile 来自全站统一的 useMobile() composable,断点口径和其他页面一致;
  2. 保存按钮吸底。编辑页很长,手机上滚到正文底部再滚回顶部点保存是灾难。用 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 的占位空间,避免最后的正文被吸底条永久遮住。

六、踩过的坑

  1. callback 的调用时机是第一大坑。 最初我在每张图传完就调一次,结果编辑器按"批次结束"处理,后几张图的 Markdown 被吞。记住它是"整批文件处理完毕"的信号,恰好一次。
  2. 自定义按钮图标要加组件库自己的 classmd-editor-icon),否则尺寸、悬停色和内置按钮对不齐,会看到一个明显偏大的"外来图标"。
  3. placeholder 里的换行要用 &#10; HTML 实体,直接在属性字符串里敲真换行,模板解析后提示语格式会乱。
  4. 编辑器容器要给 z-index: 0 并注意层叠上下文,否则它工具条的下拉菜单可能压在 Element 的弹窗/吸底条上面;反过来吸底条我设了 200 确保永远可点。
  5. 编辑器高度必须显式给(桌面 560px、手机 460px),组件不会随内容无限撑高——自适应高度在长文时会让页面滚动和编辑器内部滚动互相打架,固定高度 + 内部滚动是更可控的选择。
  6. 别引入 MdEditor 再在前台用。访客侧只展示 HTML,不需要编辑器;组件还有轻量的 MdPreview 可供需要"JS 渲染 Markdown 展示"的场景,按需选择,别把编辑器整套打进访客包。

七、小结

这个模块代码量很小,但它体现了我做"集成型功能"的完整方法论:

写作工具不抢内容的戏,但它让"开始写"这件事没有阻力。对一个内容站来说,这可能是最重要的工程投资。

下一篇是整个系列最硬核的一篇:OSS 直传全攻略——STS 临时凭证怎么发、RAM 角色怎么配、浏览器怎么直传和分片续传、300MB 视频为什么不该经过 ECS、以及 Nginx 内网回源如何剥掉强制下载头——第 6 篇:OSS 直传全攻略,让文件流绕开你的服务器

系列导航:① 开篇与总体架构 → ② Flask 后端骨架 → ③ 登录与安全 → ④ 博客内容模块 → ⑤ Markdown 编辑器(本文)→ ⑥ OSS 直传全攻略 → ⑦ SPA 的 SEO 与微信分享 → ⑧ 打卡热力图与小游戏 → ⑨ AI 网页宠物与收官