• 开往
  • 设置
  • 用户
  • 回到顶部
  • 收起
  • [cover]

    编译器魔法雏形

    • ? 阅读
    • 2429 字
    上一章下一章

    早在编写词条页模板的时期,我就时常困惑于这样一件事:如何在结构化的数据(JSON)中优雅地嵌入文本数据,而不至于在不能换行的字符串内手动编写 HTML,或是传入未经处理的 Markdown,将渲染压力转移到客户端?

    这是一项很常见的需求,尤其是当我们既需要一致的数据,又需要根据需求动态组织文本内容的时候。MediaWiki 便是采用了 WikiText 作为解决方案,提供了一整套的模板机制来渲染任何复杂的 HTML 结构。

    当然,我的技术并没有精进到足以手写一门模板语言,但实际的需求却已经摆在我面前了。尽管那时候我还欠缺能够撑起各个页面的内容,我还是开始了对这个课题的研究。

    前置灵感的迸发

    如果同时提起结构化数据和 Markdown,不难联想到在 Markdown 中存在这样一种语法,它允许我们为这篇文章编写一系列的元数据:

    yaml
    1
    2
    3
    4
    5
    6
    ---
    title: 编译器魔法雏形
    abbrlink: 7b3e03b
    date:
      published: 2024-08-25
    ---

    没错,这就是 Frontmatter,默认以 YAML 格式出现,经过适当的处理后就能够转换为供 JS 读取的 JSON 数据。在讨论可能性的话题之前,单纯从开发体验的角度看待这件事,不难发现只有在 Markdown 文件中,我才能够甚至是零代价地同时获得这两者的语法高亮。这是否意味着,我应该适当地转换思路,不直接编写晦涩难懂的 JSON 文件,而是采用一些预处理器来优化我的工作流程?

    虽然还没开始正式的代码编写,当这个念头在脑海中闪过的那一刻,我便意识到这个课题已然向前迈出了一大步。

    原创语法的考量

    那么,剩下的问题便是如何将 Markdown 嵌入结构化数据中了。假设我们有这样一组数据结构:

    ts
    1
    2
    3
    4
    5
    6
    7
    8
    interface Entry {
      title: string;
      summary: string;
      details: {
        title: string;
        content: string;
      }[];
    }

    标题自然使用纯文本便足以应对,而对于 summary 和各个 detailscontent 部分,我该如何在正文区域中分别编写各自的内容,还能够保证将它们嵌入到正确的位置当中呢?

    答案是显而易见的,插槽。

    假如我能够以一种特殊的语法,将每一块内容与指定目标的插槽名对应起来,那么事情便一下子轻松许多了。

    那时我还在使用 Marked 渲染文章内容,自然而然联想到了对 Marked 的扩展。不同于一些其他的 Markdown 渲染引擎,在 Marked 中能够声明式地编写自定义语法,而不需要手动处理词法分析——我认为这是它独一档的优势。

    于是,一种以双尖括号包裹内容的语法横空出世:

    markdown
    1
    2
    3
    << summary
    有些人只拥吻影子,于是只拥有幸福的幻影。
    <<

    大喝彩!现在只要将这种语法转换为一个特殊的 <slot> 标签,再分别拿到插槽名和序列化为 HTML 的内容就结束了。

    而对于插槽名如何对应到指定的字段,这里应当采用点表示法来实现映射。如果希望编写 details 下第一个对象的 content 内容,只需要用 details[0].content 来指定即可。

    此外,我们还可以编写一个简易的 Textmate 扩展,来使这种自定义语法获得高亮:

    json
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    {
      "begin": "^(<<)\\s+(.*)$",
      "end": "^(<<)\\s*$",
      "beginCaptures": {
        "1": { "name": "entity.name.tag" },
        "2": { "name": "source.js" }
      },
      "endCaptures": {
        "1": { "name": "entity.name.tag" }
      },
      "patterns": [{
        "include": "text.html.markdown"
      }]
    }

    太好了,不仅优雅地实现了 Markdown 注入,还有完善的语法高亮可用!开发体验++!

    然而,没过多久这种语法就被我弃用了。

    依赖迭代的困境

    为了更加精细地控制文章构建流程,我最终选择了 Remark 来作为我的 Markdown 解析器。

    正如前面所言,Marked 拥有声明式创建自定义语法扩展的能力,而这在以 AST 为核心的 Unified 工具链中是似乎行不通的。或许简单的行内语法能够通过正则匹配等方式来变通地实现,然而想要以不破坏 Markdown 哲学及其语义的方式,实现一套完备的自定义语法,我们将不得不深入底层的 Micromark 手动编写 Tokenizer。我在反复阅读了各种自定义语法扩展的源码后,意识到这无论如何都不是通过几行简洁的代码就能够达成的目标。

    由于缺乏编译原理的知识,手写分词器对我而言成了一项艰巨的挑战。即便是有着众多现成的源码作为参考,我还是没能顺利地坚持下来。

    那么,放弃自定义语法呢?

    既然我选择将特殊的 <slot> 标签作为中间产物,那么作为能力不足的妥协,直接编写这样的标签似乎也是可行的,只是没必要地多了一些冗余的字符而已。

    遗憾的是,Remark 并不支持渲染 HTML 中的字符串。

    再进一步,放弃在解析器上做手脚,用一些特殊标记来分隔内容块,采用完全后处理的方式获取数据呢?

    同样遗憾的是,我逐渐发现自己并不能接受这样让代码一步步陷入耦合的妥协。

    上层框架的救赎

    实际上,我早在那之前就接触了 MDC 语法,它允许我们将文本内容包裹在组件中,而不破坏它们的 Markdown 结构。可当时的我依然只是认为将内容套在特殊标签中的方式不符合我对简洁的追求,却遗忘了本该在这种场合大放异彩的那个功能

    mdc
    1
    2
    3
    4
    5
    6
    ::hero
    Default slot text
    
    #description
    This will be rendered inside the `description` slot.
    ::

    我承认我看到这里时是醍醐灌顶的。

    还有比这更简洁的插槽语法吗?

    仅仅通过 # 号后接一串字符,就能够轻松地将不同的内容区域注入到各自的位置中,它甚至比我原先的双尖括号语法还要简洁一万倍。也许此前的我还会因为不得不重复地编写组件围栏而感叹一句美中不足,但这种顾虑在被 MDC 呈现出来的可能性前已经不复存在了。

    既然我的正文区域中只会出现用于向 Frontmatter 注入 Markdown 的语法,那么不就意味着每个文件都会在开头和结尾出现同样的标识符吗?

    这种一致的数据冗余所预示着的破坏力是惊人的,它告诉我们真正的编译器魔法该登场了:

    ts
    1
    text = text.replace(/(?<=\n---$)/, "\n\n::slots") + "\n\n::";

    通过这种方式,每个文件都必须要添加组件语法的需求被完美消除了。这时候,再小小地借助 Textmate 的力量点亮 # 号后方的字符串,原本不得不手写分词器的地狱一下子升格到了调包就能解决的天堂。

    初尝蜜糖的所感

    最近从事了一些有关 Vue Language Tools 的工作,发现不少在单文件组件中看似魔法的功能都是通过十分巧妙的方式实现的。其中不乏对 Scoped Class 的捕获与追踪,对 Template Ref 的自动类型推断等等。尽管我所构建的这一套编译流程与之相差甚远,但还是从中获得了近似语法糖的开发体验。假如说有关这一课题的下一阶段任务该如何制定,我恐怕会毫不犹豫地选择基于 Volar 来为 Frontmatter 和插槽名提供类型支持吧。

    评论0

    60FPS