tMarkdown 最初用一个扁平块列表加两个深度字段来表达文档。它跑得很快,但每遇到一种新的嵌套组合,就要打一个补丁。
扁平列表的天花板
扁平模型的问题不在于性能,而在于语义。引用里套列表、列表里再套引用,这些在 Markdown 里完全合法的结构,一旦被拍平成“块 + depth”,编辑器就必须在每次回车、缩进、删除时推断边界。推断逻辑会随着组合数量指数增长,最后没有人能说清所有分支。
更矛盾的是,解析层其实早就有一棵完整的树。Rust 侧的 comrak 会产出标准 AST,我们却用 walk 把它重新拍平,再在 Dart 侧猜结构。信息在进入编辑器之前就被丢掉了。
块树模型
新的模型很直接:文档是一棵树,容器块持有 children。引用块是容器,列表项也是容器;叶子块保存文本或代码。连续列表项的分组由相邻关系隐式表达,不再维护额外的 depth。
class EditableBlock {
final BlockId id;
final EditableBlockType type;
final String text;
final List<EditableBlock> children; // 容器块
}
渐进式迁移
重构没有推倒重来。第一步只把树作为新能力加入:Rust 输出嵌套 JSON,Dart 递归建树,旧的扁平路径继续运行,测试保持全绿。第二步让渲染切到树,再把编辑操作逐个迁移。第三步才删除旧字段。
这样做的好处是每一步都可以独立发布与回滚。对于个人项目,能随时停下来而不留下半成品,比一次性追求“架构纯洁”重要得多。
性能与编辑语义
树带来表达能力,也带来渲染成本。虚拟列表需要把树先序展开成扁平项,只渲染视口附近的部分;大文件的打开路径仍然走 Rust,避免在 Dart 侧做重复解析。
真正困难的部分是编辑语义:在容器边界按回车应该发生什么?在空列表项上退格应该退出容器还是升级层级?这些问题没有通用答案,只能逐条写成测试,再用真实文稿反复验证。
经验: 如果解析器已经产出树,不要在中间层把它拍平。保留结构,把复杂度留在它本该存在的地方。
