欢迎访问我的个人网站 浮生·迹 使用排版功能!!!
维护微信公众号的人,大概都经历过这样的过程:文章内容已经写完,接下来还要调整标题字号、段落间距、引用样式、代码块和分隔线。单独看每一步都不难,但一篇文章从头调到尾,时间很快就过去了。
第三方排版工具提供了很多模板,不过不少主题和功能需要会员。我的公众号更新频率不算固定,为偶尔使用的模板长期付费并不合适,于是最初只想给自己做一个简单的排版页面:左边写 Markdown,右边实时预览,选好主题后复制到微信公众号编辑器。
真正开始实现以后,我发现这件事并不只是"给 HTML 加一份 CSS"。微信公众号最终接收的是从剪贴板粘贴进去的富文本,普通网页中依赖的样式表、类名和交互效果,不一定能跟着内容一起过去。
因此,这个模块真正需要解决的是三个问题:
- 怎样把 Markdown 转换成结构可控的 HTML;
- 怎样让不同主题共享渲染能力,又保留各自的视觉差异;
- 怎样把带样式的富文本可靠地写入剪贴板。
下面记录一下「趣排版」目前的实现思路。
整体流程
项目使用 Vue 3 和 Vite。编辑区通过 v-model 保存 Markdown 内容,右侧预览则由一个计算属性实时生成:
js
const previewHtml = computed(() => {
return renderWechatMarkdown(content.value, themeKey.value)
})
完整的数据流可以概括为:
text
Markdown 原文
↓
markdown-it 解析
↓
自定义 Renderer 注入内联样式
↓
拼接文章外层和作者尾部
↓
以 text/html 写入剪贴板
↓
粘贴到微信公众号编辑器
这里没有直接把预览区域的 DOM 整块复制,而是先生成一份专门面向公众号的 HTML。这样可以把页面预览效果和最终复制内容分开处理,避免把按钮、动画或者编辑器自身的样式一并带过去。
使用 markdown-it 接管元素渲染
Markdown 解析使用的是 markdown-it。初始化时关闭原生 HTML,开启自动换行和链接识别:
js
const markdown = new MarkdownIt({
html: false,
breaks: true,
linkify: true,
highlight: renderCode
})
关闭原生 HTML,是为了避免用户输入的任意标签直接进入预览区域。文章需要的样式统一由渲染器产生,这样输出结构更容易控制。
markdown-it 默认会把标题渲染成普通的 <h1>、<h2>。如果只在页面中给它们添加 class,浏览器预览没有问题,但复制到其他编辑器后,这些 class 背后的样式未必还在。
我的处理方式是重写对应的 renderer rule,在生成标签时直接写入内联样式:
js
markdown.renderer.rules.heading_open = (tokens, index) => {
const tag = tokens[index].tag
if (tag === 'h2') {
return `<h2 style="
margin:32px 0 20px;
padding-left:16px;
border-left:4px solid ${theme.primaryColor};
color:${theme.h2Color};
font-size:${theme.h2FontSize};
">`
}
return `<${tag} style="color:${theme.h3Color};">`
}
目前标题、段落、引用、列表、链接、图片、表格、分隔线、行内代码和代码块都使用了自定义规则。这样做比维护一份外部 CSS 繁琐一些,但输出结果是相对独立的富文本,不依赖当前网站的样式环境。
列表的处理比标题更麻烦。有序列表、无序列表、嵌套列表和任务列表的结构不同,而四套主题对标记符号也有不同设计。因此渲染过程中会在 env 中记录当前列表类型、层级和序号,再由段落渲染规则决定插入圆点、数字或者其他标记。
用配置驱动四套主题
最开始做主题时,很容易走向复制四份渲染代码:每个主题各写一套标题、列表和引用样式。这种方式短期很快,但修改一个公共问题时,需要在四个地方重复处理。
现在的实现把主题拆成两部分:
- 设计变量:颜色、字号、间距、圆角、代码背景等;
- 行为标记:标题、列表、链接和表格采用哪一种渲染结构。
下面是经过简化的主题配置:
js
const formatterThemes = {
'polaris-code': {
name: '微光代码',
headingStyle: 'dev',
listStyle: 'dev',
tableStyle: 'dev',
primaryColor: '#7C3AED',
accentColor: '#0D9488',
fontSize: '16px',
lineHeight: '1.9',
codeBg: '#EEECF5'
}
}
渲染器读取这些配置,再决定标题使用缎带结构、左侧强调线、暖色标记还是编辑排版风格。这样既能复用 Markdown 解析和复制逻辑,又不会把主题限制成简单的"换颜色"。
目前有四套主题:
- 霁蓝流光:标题层级明显,适合知识分享和经验总结;
- 微光代码:突出代码块、列表和技术内容;
- 纸间絮语:颜色和间距更柔和,适合随笔与生活记录;
- 素纸铅字:减少装饰,适合长文和观点内容。
为了避免每次输入都重新创建 MarkdownIt 实例,渲染器会按主题进行缓存:
js
function getRenderer(themeKey) {
rendererCache[themeKey] ||= createRenderer(
getFormatterTheme(themeKey)
)
return rendererCache[themeKey]
}
切换主题时只需要取出对应渲染器重新渲染内容,主题定义和业务组件之间也保持了相对清晰的边界。
代码高亮为什么也要内联化
技术文章离不开代码块,因此项目接入了 highlight.js。为了避免引入所有语言,只从 core 版本中注册当前常用的 Bash、CSS、Java、JavaScript、JSON、Python、TypeScript 和 XML。
highlight.js 默认输出类似下面的结构:
html
<span class="hljs-keyword">const</span>
问题还是一样:如果 hljs-keyword 对应的样式没有一起复制,代码就只剩下结构,没有高亮颜色。
因此,高亮完成后还会把语法 class 转成内联颜色:
js
function inlineHighlightStyles(html) {
return html.replace(
/<span class="hljs-([^"]+)">/g,
(_match, className) => {
const color = syntaxColors[className]
return color
? `<span style="color:${color}">`
: '<span>'
}
)
}
如果指定的语言不存在或者高亮失败,则回退为经过转义的普通代码文本,至少保证内容能正常显示,不让一次高亮异常影响整篇文章。
把 HTML 和纯文本同时写入剪贴板
完成渲染以后,最关键的一步是复制。
现代浏览器支持通过 ClipboardItem 同时写入多种格式。我会把渲染后的结果作为 text/html 写入,同时保留 Markdown 原文作为 text/plain:
js
const clipboardItem = new ClipboardItem({
'text/html': new Blob([html], { type: 'text/html' }),
'text/plain': new Blob([plainText], { type: 'text/plain' })
})
await navigator.clipboard.write([clipboardItem])
当目标编辑器支持富文本时,它会优先读取 text/html,标题、颜色和间距就可以跟随内容一起粘贴;如果目标位置只接收纯文本,仍然能够拿到原始 Markdown。
考虑到部分浏览器对 Clipboard API 的支持和权限限制,项目还保留了一套降级逻辑:
- 创建一个移出屏幕的临时容器;
- 将生成的 HTML 放入容器;
- 使用
Range和Selection选中内容; - 调用
document.execCommand('copy'); - 复制完成后清除选区并移除临时节点。
execCommand 已经属于旧接口,但作为兼容兜底仍然有实际作用。主流程优先使用 Clipboard API,只有失败时才进入降级分支。
草稿保存和一些细节
排版工具最怕页面刷新后内容全部消失,因此编辑内容、主题和文章尾部发生变化时,会在 500 毫秒后自动保存草稿。连续输入会不断重置计时器,避免每敲一个字就执行一次保存。
当前默认使用 localStorage,同时保留了通过环境变量切换后端接口的能力。页面重新打开时,会恢复文章内容、选中主题以及尾部设置。
此外,还有几个实现中的小取舍:
- 图片不做本地拖拽上传,只接受线上地址,避免处理公众号素材权限和临时地址失效问题;
- 文章尾部独立解析,支持普通文本和 Markdown 图片语法;
- 移动端将编辑和预览拆成两个视图,避免双栏被压缩得无法使用;
- 表格外层增加横向滚动容器,尽量降低窄屏下的布局溢出;
- 预览区可以有动画效果,但复制输出只保留静态内联样式。
这些功能不复杂,却直接影响一个排版工具是否真的能用于日常写作。
最后
「趣排版」最初只是为了减少自己维护公众号时的重复排版工作,后来发现 Markdown 渲染、主题组织和富文本复制之间,还有不少值得整理的实现细节,于是把这次开发过程记录了下来。
目前四套主题已经可以直接使用,但微信公众号编辑器、浏览器和不同内容结构之间仍可能出现细节差异。如果你也做过类似的编辑器,或者在使用时遇到样式丢失、列表错位、代码块显示异常等问题,欢迎提出建议。
排版工具能做的事情其实很有限:让结构更清晰,让阅读更舒服,再把原本重复的格式调整尽量自动化。省下来的时间,最终还是应该回到内容本身。