一位写了近二十年 macOS/iOS 原生代码的开发者,最近得出一个让 Apple 生态开发者不太舒服的结论:在复杂文本处理场景下,Electron 等 Web 技术反而比 Apple 原生框架更可靠。Artem Loenko 把这段从信心满满到被迫转向的经历写在了博客上——他原本要在纯 Swift/SwiftUI 应用里实现一个支持 Markdown 的富文本编辑器,结果一路踩坑,最终发现原生文本栈的短板不是靠经验就能绕过去的。
这个结论看似违背直觉,但背后的问题非常具体,值得每个做过富文本功能的人认真看看。
原生文本栈的硬伤在哪里
Apple 的文本处理体系并不算薄弱——NSAttributedString、UITextView、TextKit、TextKit 2,名字一长串。但当你真正要处理"复杂"文本时,这些框架的短板会集中暴露:
属性模型的碎片化。 NSAttributedString 的 attribute dictionary 是扁平的键值结构,没有嵌套能力。一段文本同时是粗体、链接、代码行内标记,你只能把所有属性平铺在同一个 range 上。属性之间有冲突时(比如一段既是标题又是链接),没有优先级机制,靠开发者手动拼逻辑。
布局与渲染耦合过深。 TextKit 的布局路径和渲染路径缠绕在一起,想单独控制排版细节(比如代码块的等宽字体切换、嵌套列表的缩进层级),往往要深入 override layout manager 的内部方法,改动牵一发动全身。
SwiftUI 的文本能力更薄。 SwiftUI 的 Text 视图目前只支持基础属性拼接,TextField 和 TextEditor 对富文本的支持几乎为零。想在 SwiftUI 里做 Markdown 渲染,要么退回 UIKit representable,要么自己从头写渲染管线。
Loenko 的经历正是这些问题的叠加:Markdown 解析后的 AST 是嵌套树结构,但要把这棵树映射到 NSAttributedString 的扁平属性模型,每一步都在做"把树压平"的暴力转换,而且压平之后丢失的结构信息,在后续编辑操作中还得再从扁平模型里猜回来。
Web 技术为何在这个场景下胜出
HTML + CSS 的文本模型天然就是嵌套的。一个 Markdown AST 映射到 HTML 几乎是零损耗:
<p>这是一段<strong>粗体</strong>文本,包含<a href="...">链接</a>和<code>行内代码</code>。</p>
<strong>、<a>、<code> 可以自由嵌套,CSS 处理冲突属性有明确的 cascade 规则。浏览器引擎的布局算法(Blink/Gecko)对复杂排版的处理能力,经过二十多年迭代,远超 TextKit 在移动端的表现。
Electron 把这套能力打包进桌面应用,代价是内存和包体积,但换来的是:
- DOM 的嵌套属性模型直接对应 Markdown AST,不需要树→扁平→树的来回转换
- CSS 的排版能力覆盖等宽切换、嵌套缩进、行间距微调等原生框架要大量 hack 的场景
- 成熟的编辑器生态(CodeMirror、ProseMirror、Slate)直接可用,不用从零造轮子
这不是"Web 万能"的泛泛之谈,而是文本处理这个特定领域里,DOM 模型与 Markdown AST 的结构同构性带来的真实优势。
实际对比:同一个 Markdown 渲染任务
下面用两段可运行的代码展示同一个任务——把一段含粗体、链接、行内代码的 Markdown 渲染为可交互的富文本——在原生和 Web 两条路径上的实现差异。
Swift 原生路径:手动拼 NSAttributedString
import UIKit
// 假设 Markdown 已解析为以下节点列表
// (实际项目中你需要自己写或引入一个 Markdown parser)
struct MDNode {
let type: String // "text", "bold", "link", "code"
let content: String
let href: String? // 仅 link 类型有值
}
let nodes: [MDNode] = [
MDNode(type: "text", content: "这是"),
MDNode(type: "bold", content: "粗体"),
MDNode(type: "text", content: "文本,包含"),
MDNode(type: "link", content: "链接", href: "https://example.com"),
MDNode(type: "text", content: "和"),
MDNode(type: "code", content: "行内代码"),
MDNode(type: "text", content: "。")
]
func renderNodes(_ nodes: [MDNode]) -> NSAttributedString {
let result = NSMutableAttributedString()
for node in nodes {
let segment = NSMutableAttributedString(string: node.content)
switch node.type {
case "bold":
segment.addAttribute(.font, value: UIFont.boldSystemFont(ofSize: 16),
range: NSRange(location: 0, length: segment.length))
case "link":
segment.addAttribute(.link, value: node.href ?? "",
range: NSRange(location: 0, length: segment.length))
segment.addAttribute(.foregroundColor, value: UIColor.systemBlue,
range: NSRange(location: 0, length: segment.length))
case "code":
segment.addAttribute(.font, value: UIFont(name: "Menlo", size: 14) ?? UIFont.systemFont(ofSize: 14),
range: NSRange(location: 0, length: segment.length))
segment.addAttribute(.backgroundColor, value: UIColor.systemGray6,
range: NSRange(location: 0, length: segment.length))
default:
segment.addAttribute(.font, value: UIFont.systemFont(ofSize: 16),
range: NSRange(location: 0, length: segment.length))
}
result.append(segment)
}
return result
}
// 使用:赋值给 UITextView 的 attributedText
let textView = UITextView()
textView.attributedText = renderNodes(nodes)
textView.isEditable = false
textView.isSelectable = true
注意几个问题:属性是平铺追加的,如果某个节点同时是粗体+链接,你要在同一段 NSMutableAttributedString 上叠加多个 attribute,管理 range 的重叠和冲突全靠手动;代码块的背景色是逐字符加的,换行后的整行背景需要额外用 exclusion rect 或 background attribute hack;编辑时反向解析(从 NSAttributedString 回推 Markdown AST)完全没有现成方案。
Electron / Web 路径:Markdown AST → HTML
// 最小 Electron 示例:Markdown 渲染 + 可交互富文本
// 项目结构:package.json + main.js + index.html
// package.json
// {
// "name": "md-editor-demo",
// "version": "1.0.0",
// "main": "main.js",
// "dependencies": { "marked": "^12.0.0" }
// }
// main.js
const { app, BrowserWindow } = require('electron');
const path = require('path');
app.whenReady().then(() => {
const win = new BrowserWindow({
width: 800, height: 600,
webPreferences: { nodeIntegration: true, contextIsolation: false }
});
win.loadFile('index.html');
});
// index.html
const { marked } = require('marked');
const mdSource = `这是**粗体**文本,包含[链接](https://example.com)和\`行内代码\`。`;
// marked 直接把 Markdown 转成嵌套 HTML
const htmlOutput = marked.parse(mdSource);
document.getElementById('editor').innerHTML = htmlOutput;
// CSS 处理所有排版细节——等宽、背景、间距,零 JS 逻辑
const style = document.createElement('style');
style.textContent = `
code {
font-family: 'Menlo', 'Consolas', monospace;
font-size: 14px;
background: #f0f0f0;
padding: 2px 4px;
border-radius: 3px;
}
a { color: #0066cc; }
strong { font-weight: 600; }
`;
document.head.appendChild(style);
运行方式:npm install && electron .
关键差异:marked.parse() 输出的 HTML 是嵌套结构,<strong> 和 <a> 可以自然共存于同一段文本内;CSS 以声明式方式处理所有排版,不需要逐字符计算 range;如果需要编辑能力,接入 CodeMirror 或 ProseMirror 后,编辑操作直接在 DOM 树上进行,AST ↔ 视图的双向映射由编辑器框架维护。
选择时的真实考量
Loenko 的反思不是"原生不行,Web 赢了"的二元结论,而是针对特定场景的务实判断。做选择时可以参考以下清单:
| 考量维度 | 原生路径更适合 | Web/Electron 路径更适合 |
|---|---|---|
| 文本复杂度 | 纯展示、属性单一(粗体/颜色) | Markdown/富文本编辑、嵌套属性多 |
| 编辑交互 | 只读或简单选区操作 | 实时编辑、AST 双向同步 |
| 排版需求 | 基础排版,无代码块/表格 | 代码块、嵌套列表、表格、混合字体 |
| 包体积敏感度 | 移动端必须小 | 桌面端可接受 ~150MB |
| 内存敏感度 | 后台常驻、多窗口 | 单窗口、短期使用 |
| 团队技能 | 深度 Swift/UIKit 经验 | 前端/Web 编辑器经验 |
几个务实的折中方案也值得考虑:
- 混合架构:核心编辑区用 WebView/Electron 渲染文本,外壳壳和导航栏保持原生,VS Code 和 Slack 都是这个模式
- WKWebView 内嵌:iOS/macOS 应用中用
WKWebView加载本地 HTML + JS 编辑器库,避免 Electron 的包体积代价,同时获得 DOM 的文本处理能力 - 原生渲染 + Web 编辑:渲染用原生(只读场景足够),编辑态切换到 Web 视图,编辑完成后再同步回原生模型
写在最后
Loenko 二十年的原生经验没有白费——正是深度使用原生框架之后,他才真正看清了文本栈的结构性短板。这不是能力问题,是模型问题:NSAttributedString 的扁平属性字典和 TextKit 的布局-渲染耦合,在嵌套文本场景下就是先天不足。DOM 的嵌套结构和 CSS 的声明式排版在这个特定领域是更对的抽象。
下次接到"做个 Markdown 编辑器"的需求时,先问一句:文本嵌套深度到哪一级?如果答案超过两层,认真评估 Web 技术可能比硬扛原生更省时间——这不是妥协,是对问题结构的诚实回应。