为什么飞书不用 Markdown?——一条消息背后的结构化设计哲学

发布于 2026年06月14日 12:09 #飞书#DevOps

为什么飞书不用 Markdown?——一条消息背后的结构化设计哲学 封面图
  • 飞书消息类型作为渲染器选择器,协议统一且类型分离简化客户端逻辑
  • 二维数组结构无注入点,安全模型天然防XSS,跨端渲染绝对一致
  • 多语言内嵌于消息结构,每条消息成为自洽的国际化数据单元
  • Post与卡片分离,阅读型用post,操作型用card,边界清晰
  • 日常使用可通过CLI转换Markdown,手写post JSON适用于严肃集成

Slack 用 mrkdwn。Telegram 用 MarkdownV2。Discord 也是 Markdown。

几乎所有主流 IM 的开发者 API,在表达富文本消息这件事上,都选了同一条路:给你一个字符串,你可以往里塞 Markdown 标记。

然后飞书说:我不。

它造了一套 JSON 结构化内容树。没有 Markdown 解析器,没有字符串模版,每条带格式的消息都是一棵小 JSON 树——content 是一个二维数组,数组里是行,行里是内联元素,元素带 tagstyle

说实话,第一次看到这玩意儿的时候,我的反应和大多数人一样:为什么造一个没人用的格式?用 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 和卡片的边界画得很清楚:

能力PostInteractive 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 字段,双标题会渲染冲突);本地图片路径 ![](./a.png) 不会自动上传,需要先 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 才是更稳的答案。

评论互动

© 2026 王若风的技术博客 · Powered by Astro