公众号排版工具如何实现样式的复制粘贴?
本文基于开源项目 bm.md 的实现原理撰写。bm.md 是一个支持微信公众号、知乎、掘金等多平台的 Markdown 排版工具,技术栈包括 React 19、TanStack Router、Vite 7、Zustand 等。项目架构详见 architecture.md。
作为一个技术写作者,你可能用过 Markdown Nice、135编辑器这类排版工具。写好 Markdown,选个主题,点击复制,粘贴到微信公众号编辑器——样式完美保留。
这背后是怎么实现的?为什么有些样式能复制过去,有些却丢失了?
本文将揭开公众号排版工具的技术原理。
先理解问题:为什么普通复制不行?
试试看:在浏览器里打开任意网页,选中一段带样式的文字,复制粘贴到微信公众号编辑器。
你会发现:大部分样式都丢失了。
这是因为微信公众号编辑器会对粘贴内容进行"清洗":
flowchart TB
A["你复制的内容"] --> B["微信安全过滤器"]
B --> C["移除 script、style 标签"]
C --> D["移除 position、transform 等属性"]
D --> E["移除 id 属性和事件"]
E --> F["只保留'安全'的内联样式"]
这个过滤机制是为了安全(防止 XSS 攻击)和一致性(保证文章在不同设备上显示一致)。
所以,排版工具的核心挑战是:如何在这些限制下保留样式?
核心技术:CSS 内联化
答案是 CSS 内联化 (CSS Inlining)。
什么是 CSS 内联化?
普通网页的样式是这样写的:
<style>
h1 { color: blue; font-size: 24px; }
p { line-height: 1.6; }
</style>
<h1>标题</h1>
<p>正文内容</p>
<style> 标签会被微信过滤掉,样式就丢失了。
内联化后变成这样:
<h1 style="color: blue; font-size: 24px;">标题</h1>
<p style="line-height: 1.6;">正文内容</p>
每个元素都带着自己的样式,不依赖 <style> 标签,微信就无法剥离。
如何实现内联化?
手动转换不现实。排版工具使用专门的库来完成这项工作,比如 juice:
import juice from 'juice'
const css = `
h1 { color: blue; font-size: 24px; }
p { line-height: 1.6; }
`
const html = `
<h1>标题</h1>
<p>正文内容</p>
`
// 魔法发生在这里
const result = juice.inlineContent(html, css)
// 输出:
// <h1 style="color: blue; font-size: 24px;">标题</h1>
// <p style="line-height: 1.6;">正文内容</p>
juice 会解析 CSS 选择器,找到匹配的 HTML 元素,把样式规则写入 style 属性。
完整流程:从 Markdown 到剪贴板
以 bm.md 的架构为例,一个完整的公众号排版工具是这样工作的:
flowchart TB
subgraph Input["输入层"]
MD["Markdown 文本"]
end
subgraph Processing["处理层 (Web Worker)"]
Parse["解析为 AST<br/>remark-parse"]
GFM["GFM 扩展<br/>表格、任务列表"]
Math["数学公式<br/>remark-math"]
ToHTML["转换为 HTML<br/>remark-rehype"]
Highlight["代码高亮<br/>rehype-highlight"]
end
subgraph Adaptation["平台适配层"]
WeChat["微信适配"]
Zhihu["知乎适配"]
Juejin["掘金适配"]
end
subgraph Output["输出层"]
Theme["加载主题 CSS"]
Inline["CSS 内联化<br/>juice"]
Clipboard["写入剪贴板<br/>text/html"]
end
MD --> Parse
Parse --> GFM
GFM --> Math
Math --> ToHTML
ToHTML --> Highlight
Highlight --> WeChat
Highlight --> Zhihu
Highlight --> Juejin
WeChat --> Theme
Zhihu --> Theme
Juejin --> Theme
Theme --> Inline
Inline --> Clipboard
关键代码示例
async function copyForWechat(markdown: string, themeCss: string) {
// 1. Markdown → HTML (在 Web Worker 中执行)
const processor = unified()
.use(remarkParse)
.use(remarkGfm) // 支持表格、任务列表等
.use(remarkMath) // 数学公式支持
.use(remarkRehype)
.use(rehypeHighlight) // 代码高亮
.use(rehypeWechatAdapter) // 微信适配
.use(rehypeStringify)
const html = (await processor.process(markdown)).toString()
// 2. CSS 内联化
const wrapped = `<section id="bm-md">${html}</section>`
const inlined = juice.inlineContent(wrapped, themeCss)
// 3. 复制到剪贴板
const blob = new Blob([inlined], { type: 'text/html' })
await navigator.clipboard.write([
new ClipboardItem({ 'text/html': blob })
])
}
架构亮点:Web Worker + oRPC
bm.md 的一个设计亮点是将 Markdown 渲染放在 Web Worker 中执行,使用 oRPC 实现类型安全的通信。
为什么用 Web Worker?
Markdown 处理涉及大量字符串操作和 AST 转换,长文档可能导致主线程卡顿。Web Worker 将计算密集型任务移到后台线程,保持 UI 流畅。
oRPC 通信
// Worker 端暴露的方法
const procedures = {
render: async (markdown: string, theme: string) => {...},
parse: async (html: string) => {...},
extract: async (markdown: string) => {...},
lint: async (markdown: string) => {...}
}
// 主线程调用
const html = await worker.render(markdown, 'elegant')
oRPC 提供了类型推断,调用 Worker 方法就像调用普通函数一样,但实际是跨线程通信。
微信的特殊限制与应对
微信公众号编辑器有一些独特的"怪癖",排版工具需要专门处理。
问题 1:代码块空格被吃掉
微信会把连续空格合并成一个,导致代码缩进全乱了:
def hello():
print("world") # 原本 4 个空格
粘贴后变成:
def hello():
print("world") # 只剩 1 个空格
解决方案:用 \u00A0 (non-breaking space) 替换普通空格:
// 处理代码块中的空格
text = text.replace(/^( +)/gm, (spaces) =>
'\u00A0'.repeat(spaces.length)
)
\u00A0 是不可断空格,微信不会合并它们。
问题 2:换行符失效
代码块里的 \n 换行可能不生效。
解决方案:换成 <br> 标签:
// \n → <br>
codeContent = codeContent.split('\n').join('<br>')
问题 3:任务列表的复选框
Markdown 的任务列表渲染成 <input type="checkbox">,但微信不支持表单元素。
- [x] 已完成
- [ ] 未完成
解决方案:用 Unicode 字符替代:
// <input type="checkbox" checked> → ☑
// <input type="checkbox"> → ☐
const symbol = isChecked ? '☑' : '☐'
问题 4:外部链接警告
点击微信文章里的外部链接,会弹出"即将离开微信"的安全警告,体验不好。
解决方案:把链接转成文末脚注:
原文:详见 [官方文档](https://example.com)
转换后:详见 官方文档[1]
---
参考链接
1. 官方文档: https://example.com
这样读者既能看到链接,又不会被警告打断阅读。
哪些样式能用,哪些不能用?
这是最常见的问题。简单总结:
能用
| 属性 | 说明 |
|---|---|
color, background-color | 颜色没问题 |
font-size, font-weight | 字体大小、粗细 |
margin, padding | 间距,用 px 单位 |
border, border-radius | 边框、圆角 |
text-align | 文本对齐 |
line-height | 行高 |
box-shadow | 阴影 |
不能用
| 属性 | 原因 |
|---|---|
position: absolute/fixed | 安全考虑,全部失效 |
transform, animation | 会被过滤 |
:hover 等伪类 | 没有 <style> 标签就无法定义 |
@media 媒体查询 | 会被移除 |
百分比单位 % | 不稳定,用 px 更可靠 |
经验法则
如果一个 CSS 效果需要 JavaScript 或
<style>标签才能实现,那它在微信里大概率不行。
多平台支持
bm.md 不仅支持微信,还支持知乎、掘金等平台。每个平台有不同的限制:
| 平台 | 主要限制 |
|---|---|
| 微信公众号 | 最严格,过滤最多属性 |
| 知乎 | 支持更多 CSS,但有代码块限制 |
| 掘金 | 相对宽松,支持大部分样式 |
架构上通过平台适配器(Adapter)模式处理差异,复用核心处理逻辑。
存储与状态管理
本地存储
bm.md 使用 IndexedDB 存储文档内容,支持多文件编辑。如果 IndexedDB 不可用,自动回退到内存存储。
状态管理
使用 Zustand 管理 4 个独立的 Store:
| Store | 职责 |
|---|---|
| Files Store | 文件列表、当前文件 |
| Editor Store | 编辑器设置(字体、缩进等) |
| Preview Store | 预览偏好(主题、平台) |
| Command Store | 命令面板状态 |
图片存储
支持多种云存储后端:
- Cloudflare R2
- AWS S3
- MinIO
- 默认图床服务
通过环境变量配置自动选择。
MCP 集成
bm.md 实现了 Model Context Protocol (MCP) 服务器,将 Markdown 处理能力暴露给 AI 模型:
// MCP Tools
- render: Markdown → HTML
- parse: HTML → Markdown
- extract: Markdown → Plain Text
- lint: Markdown 格式修复
这意味着你可以在 Claude 等 AI 工具中直接调用 bm.md 的渲染能力。
技术选型参考
如果你想自己实现一个排版工具:
核心依赖
pnpm add unified remark-parse remark-gfm remark-rehype
pnpm add rehype-stringify rehype-highlight
pnpm add juice
可选依赖
pnpm add remark-math rehype-katex # 数学公式
pnpm add rehype-sanitize # XSS 防护
推荐架构
src/
├── components/
│ ├── Editor.tsx # CodeMirror 编辑器
│ ├── Preview.tsx # 预览区
│ └── CopyButton.tsx
├── lib/
│ ├── markdown/
│ │ ├── processor.ts # unified 处理管道
│ │ ├── render.ts # Markdown → 内联 HTML
│ │ └── adapters/
│ │ ├── wechat.ts # 微信适配
│ │ ├── zhihu.ts # 知乎适配
│ │ └── juejin.ts # 掘金适配
│ └── storage/
│ └── indexeddb.ts # 本地存储
├── stores/ # Zustand stores
├── themes/
│ ├── default.css
│ └── elegant.css
└── workers/
└── markdown.worker.ts # Web Worker
总结
公众号排版工具的核心原理:
- Markdown → HTML:使用 unified 生态处理
- CSS 内联化:用 juice 把样式写入 style 属性
- 平台适配:处理微信的特殊限制(空格、换行、链接等)
- Web Worker:避免主线程卡顿
- 剪贴板操作:使用
text/htmlMIME 类型写入
记住微信的限制:只有内联样式生效,没有动画,没有定位,没有伪类。在这些约束下做创意设计,才是排版工具的价值所在。
参考资料
- bm.md GitHub - 开源项目
- bm.md 架构文档
- juice - CSS 内联化库
- unified 生态
- oRPC - 类型安全 RPC