为什么飞书不用 Markdown?——一条消息背后的结构化设计哲学
- 飞书消息类型作为渲染器选择器,协议统一且类型分离简化客户端逻辑
- 二维数组结构无注入点,安全模型天然防XSS,跨端渲染绝对一致
- 多语言内嵌于消息结构,每条消息成为自洽的国际化数据单元
- Post与卡片分离,阅读型用post,操作型用card,边界清晰
- 日常使用可通过CLI转换Markdown,手写post JSON适用于严肃集成
Slack 用 mrkdwn。Telegram 用 MarkdownV2。Discord 也是 Markdown。
几乎所有主流 IM 的开发者 API,在表达富文本消息这件事上,都选了同一条路:给你一个字符串,你可以往里塞 Markdown 标记。
然后飞书说:我不。
它造了一套 JSON 结构化内容树。没有 Markdown 解析器,没有字符串模版,每条带格式的消息都是一棵小 JSON 树——content 是一个二维数组,数组里是行,行里是内联元素,元素带 tag 和 style。
说实话,第一次看到这玩意儿的时候,我的反应和大多数人一样:为什么造一个没人用的格式?用 Markdown 不行吗?
然后发了上百条消息之后,我知道它为什么这样做了。
msg_type 不是一个标签,是渲染器的选择器
表面上看,飞书消息的结构长这样:
{
"msg_type": "post",
"content": "{...}"
}
大多数人的第一反应是:msg_type 就是个分类标签。但实际上它是渲染器的选择器——客户端看到 post,就启动富文本渲染器;看到 interactive,就启动卡片渲染器;看到 image,直接全屏铺图。
而 content 永远是字符串。
这才是关键。不管你是 text、post、image、interactive(卡片),content 字段永远是一个 JSON 字符串,但内部的 JSON 结构完全由 msg_type 决定。这个设计意味着两件事:
第一,协议层统一。 所有消息类型共用同一套接口——POST 到 /im/v1/messages,同一个 receive_id,同一套鉴权逻辑。新增消息类型不需要改协议,加一个新的 msg_type 值和新渲染器就行。
第二,类型之间互不嵌套。 Post 里不能塞卡片,卡片里不能直接引用 post。一条消息只做一件事。
这跟 Slack 的思路完全不同。Slack Block Kit 是“一个消息里可以组合 section、image、actions、context”——类型混合。飞书的选择是类型分离。
坦白讲,两种设计各有道理。但分离有一个巨大好处:客户端渲染逻辑简单。Post 渲染器不用处理按钮交互,卡片渲染器不用处理段落排版,Image 渲染器甚至不用管文字换行。一个渲染器一件事,代码量少,出 bug 的概率低很多。如果你写过跨端的富文本渲染,就知道“让一个渲染器同时处理文字和按钮”有多容易出对齐 bug。
二维数组就是飞书的 HTML——但没给 XSS 留门
来看看 post 的内容到底长什么样:
{
"zh_cn": {
"title": "日报 2026-06-14",
"content": [
[
{"tag": "text", "text": "今天完成了 "},
{"tag": "text", "text": "3 个", "style": ["bold"]},
{"tag": "text", "text": " 需求。"}
],
[
{"tag": "a", "text": "详情见 Wiki", "href": "https://wiki.internal/p/123"}
],
[
{"tag": "at", "user_id": "ou_xxx"}
]
]
}
}
外层数组 = 行(段落),内层数组 = 行内的内联元素(inline)。同一个内层数组里的元素在一条线上水平拼接,每开始一个新内层数组就是新起一行。
用 HTML 来类比,它等价于:
<p><span>今天完成了 </span><b>3 个</b><span> 需求。</span></p>
<p><a href="...">详情见 Wiki</a></p>
<p><span class="mention">@张三</span></p>
坦白讲,这个结构写起来比 Markdown 啰嗦多了。但它有一个 Markdown 永远做不到的事:没有注入点。
text 元素的 text 字段永远是字面文本——不会被解析成 HTML,不会执行 JavaScript,不会渲染成富文本。你往里塞 <script>alert(1)</script>,客户端会老老实实显示这串字符。
这就是 JSON 结构化内容树的安全模型:输入不是“可能包含注入的字符串”,而是“已经结构化的数据”。渲染器看到 {"tag":"text","text":"..."},它不需要做任何 sanitize——直接把 text 值丢到屏幕上。没有解析环节,就没有注入窗口。
Markdown 就不是这样了。Markdown 字符串要先解析成 AST,再做安全过滤,再渲染——这个链条里任何一个环节出错都可能产生 XSS。Discord 在这上面出过漏洞,Slack 也出过。
另一个好处是跨端渲染绝对一致。Markdown 在不同客户端渲染差异很大:同一段 ## Heading,Slack 可能是 18px bold,Discord 可能是 16px semibold,Telegram 可能加下划线。但飞书 post 在所有端用的是同一套原生渲染器。{"tag":"text","text":"重要","style":["bold"]} 在 iOS、Android、Web、桌面端呈现的效果完全一样。
表面上看,这是“控制力”的差别。真正关键是:数据带语义,渲染由客户端统一执行。Markdown 把语义和样式混一起丢给各端自己解释,post JSON 只传语义,样式由统一渲染器决定。
多语言不是后来打补丁,是消息的第一公民
{
"zh_cn": {"title": "日报", "content": [[{"tag":"text","text":"今日总结"}]]},
"en_us": {"title": "Daily Report", "content": [[{"tag":"text","text":"Today's summary"}]]}
}
一条消息可以同时携带多个 locale 版本。客户端按用户语言设置自动选择渲染哪个。
Slack 和 Discord 的 API 都只支持单一 text 字段。要发多语言消息,你得发两条——实际上没人这么干,大家都只发英文。飞书在协议设计阶段就把多语言嵌入了消息结构体内,不是通过 lang 参数区分,而是把 locale 键直接作为 JSON key。
这意味着什么?每一条消息本身就是自洽的国际化单元。 不需要依赖外部资源文件,不需要 CMS 做翻译管理,不需要发多条消息。消息就是一份数据,数据可以有多种视图。
95% 的场景里你只会填一个 zh_cn。但这个设计的存在说明了一个态度:消息是“数据”,不是“文本”。文本只有一个样子,数据可以有多种视角。
Post 干不了的事,让卡片去干
飞书 post 的富文本能力并不全。没有行内颜色,没有背景色,没有多列布局,没有按钮,没有表单,没有下拉框。
很多人觉得这是功能缺失。说实话,第一次用我也这么觉得。后来发现——这是故意的。
飞书的策略是:复杂交互走 msg_type=interactive(卡片消息)。卡片有自己的一整套元素体系:
column_set → column → div / markdown / hr / action(按钮/表单)
Post 和卡片的边界画得很清楚:
| 能力 | Post | Interactive Card |
|---|---|---|
| 标题 | ✅ title 字段 | ✅ header.title |
| 加粗/斜体/下划线 | ✅ style 数组 | ❌(用 markdown 标签) |
| 多列布局 | ❌ | ✅ column_set |
| 按钮/表单 | ❌ | ✅ action |
| 代码语法高亮 | ❌ | ✅ 卡片内 markdown 标签 |
| 动态更新 | ❌ | ✅ 同 message_id 更新 |
| 行内颜色 | ❌ | ❌(都不支持) |
Post 和卡片的关系,就是文档和交互界面的关系。文档给人读,交互界面给人操作。你把按钮塞进文档,文档就不像文档了;你把段落排版规则塞进卡片,卡片就太沉了。
一个很实用的判断标准:这条消息的本质是阅读还是操作?
- 日报、通知、报告 → post(阅读)
- 审批、投票、配置选择、订单状态 → card(操作)
- 列表 + 按钮 → card
- 长文 + 链接 → post
大部分时候,你不需要手写 post
聊了这么多设计,说点实的。
飞书官方没有提供 “Markdown 转 post” 的库,但 lark-cli 内置了一个转换器:
lark-cli im +messages-send --chat-id oc_xxx --markdown $'## 标题\n\n正文内容'
这一行会先跑一遍转换(规范化标题层级、处理链接、压空白行),再自动生成 post JSON,然后发送。省去手写二维数组的痛苦。
转换有些限制得知道:# 标题会被降级为 ####(因为 post 已经有外层 title 字段,双标题会渲染冲突);本地图片路径  不会自动上传,需要先 im images create 拿到 image_key 再引用。但日常 95% 的消息,这些限制不影响你。
一条决策链:
- 一句话几个字 →
--text,别折腾 - 带标题、列表、链接的日报/通知 →
--markdown,CLI 帮你翻 - 需要标题栏 + 精确加粗 + 多段落控制 →
--content手写 post JSON - 要按钮、分栏、表单、动态更新 →
msg_type=interactive
手写 post JSON 的场景其实很少——多半是机器人自动发日报、监控系统输出告警详情、CI 流水线发构建报告。但这些场景一旦需要,知道结构怎么配能省你好几个小时调试时间。
还有一个坑提前帮各位踩了:别往 Markdown 里塞 HTML。飞书不是 Discord,<font color="red"> 不会被吃掉并渲染——它会原样显示出来。因为 post 的 text 是字面文本,不是解析引擎。
飞书富文本消息的设计,用一个词概括:消息是数据,不是文档。
大多数 IM 把消息当作“一段 Markdown 文本”处理——输入自由,跨端一致靠 sanitizer 擦屁股。飞书把消息当作“结构化数据”——输入严格,输出一致,安全天然正确。
这个设计不讨巧。开发者的第一次体验一定是“怎么这么麻烦”,尤其刚从一个 Markdown 就搞定的平台切过来。但用久了会意识到:这不是“麻烦”,这是在协议层就把事做对的代价。
如果只是偶尔发两条通知,Markdown 够用。 如果需要做严肃的产品集成,post JSON 才是更稳的答案。
评论互动