问题

一个自定义 DSL 的 LSP 服务(Go 实现)需要为编辑器提供语法高亮。高亮依赖一份"词表":哪些标识符是内置元素 哪些是指令 哪些是内置函数。

这份词表在后端 schema 包里已经有了——它同时服务于补全 hover 诊断。问题是前端 Monaco Editor 的 Monarch tokenizer 也需要同样一份列表来做着色。

两边各写一份,迟早不同步。

两条路线

运行时拉取:后端暴露 API,前端 mount 时请求,拿到 JSON 后注册 Monarch 规则。

构建时生成:跑一个 Go 程序读 schema 包的导出切片,输出 .ts 文件到前端目录,前端直接 import。

做了简单的评估之后选了后者。

为什么不走运行时

Monaco 的 Monarch tokenizer 不支持注册后更新词表。调用 monaco.languages.setMonarchTokensProvider 之后,传入的 keywords 数组就固化了——没有 updateTokensProvider API。

这意味着要走运行时,就得:

  1. 在 registerBuiLanguage() 之前等后端 API 返回

  2. 如果 API 失败,编辑器要么不着色要么用一份 hardcode 的降级词表

  3. 编辑器打开的时序变成异步链:mount → 等 WebSocket → 请求 schema → 注册语言 → 可用

对于一份在发布周期内几乎不变的静态数据,引入这条异步链没有收益。词表跟着构建走,编辑器打开就能用,不存在时序问题。

下面这张图说明两种路线的数据流差异:

构建时生成怎么做

在 Taskfile(类似 Makefile 的任务编排工具)里加一个 generate:schema 任务,声明源文件和产物:

generate:schema:
  sources:
    - "internal/lsp/schema/template/*.go"
    - "internal/lsp/schema/imports/*.go"
    - "internal/lsp/schema/codegen/main.go"
  generates:
    - "frontend/src/services/lsp/monaco/generated/schema-tokens.ts"
  cmds:
    - go run ./internal/lsp/schema/codegen

sources 和 generates 让 Task 有增量判断能力——schema 文件没变就跳过。

生成器本身是一个独立的 main 包,导入 schema 包 读它的导出变量 按模板写 TypeScript:

package main

import (
    "foundation/internal/lsp/schema/template"
    "foundation/internal/lsp/schema/imports"
)

func main() {
    // 读 template.Elements 的 Tag 字段 → 输出 builtinElements 数组
    // 读 imports.ModuleNames → 输出 moduleNames 数组
    // ...
}

产物是一个带 // Code generated ... DO NOT EDIT. 头的 .ts 文件,git tracked。前端 tokenizer 直接 import:

import { builtinElements } from '../generated/schema-tokens';

Go 的 codegen 习惯

Go 生态对 codegen 的接受度非常高——go generate 本身就是语言工具链的一部分。标准库里 stringer、enumer 用了十几年;protobuf gRPC 的 .pb.go 文件也是生成的。

Go 社区形成了几条不成文的共识:

生成的文件 git 提交。不依赖 CI 环境有没有装生成器 不要求每个开发者本地先跑 generate 才能编译。go build 对着已有的 .go / .ts 文件直接过。

生成器是独立的 main 包。放在 cmd/ 或 internal/.../codegen/ 下,不会被主程序编译进去。go run ./path/to/codegen 一行就跑。

产物文件头部标注来源。// Code generated by X. DO NOT EDIT. 是 Go 官方约定,go generate 检测到这行会跳过。

增量由外部工具管理。Go 本身不管 codegen 的 up-to-date 检查;Task / Make / Bazel 这些工具靠 sources / generates 声明来做。

这些习惯的共同指向是:codegen 是一个构建步骤,不是运行时行为。生成完就是普通源文件,不比手写的特殊。

与运行时方案的对比

维度

构建时生成

运行时拉取

首屏延迟

零(静态 import)

取决于 API 响应

离线可用

是

否(无后端则降级)

类型安全

as const 推导字面量类型

JSON 需要手动断言

调试

直接看文件 git blame

需要抓请求看响应

额外依赖

无

需要处理异步 错误 降级

更新粒度

跟发布周期走

理论上可热更新

最后一行是运行时方案唯一的优势——热更新。但对 Monaco tokenizer 不适用(不支持),对我们的场景也不需要(词表随版本发布 不需要不重启就生效)。

前端消费方式

生成的 .ts 文件导出 as const 数组,类型会被 TypeScript 推导为字面量联合。Monarch tokenizer 的 cases 机制支持 @tokenName 引用顶层词表:

export const templateTokenizer = {
  tokens: { builtinElements: [...builtinElements] },
  tokenizer: {
    template: [
      [/(<\/?)([A-Z][\w-]*)/, ['delimiter.html', {
        cases: { '@builtinElements': 'tag', '@default': 'type' }
      }]],
    ]
  }
};

内置元素着色为 tag,工作区用户组件着色为 type。增减一个内置元素只需改后端 schema 重跑 codegen,前端不动一行代码。

什么时候该用运行时

不是所有场景都该走 codegen。当数据满足以下任一条件时,运行时拉取更合适:

  • 数据因用户而异(比如用户自定义的组件列表)

  • 数据高频变化且需要不重启就生效

  • 消费方支持动态更新(比如补全列表——LSP 每次请求都能返回最新结果)

这也是为什么补全和 hover 走 LSP 运行时通信,而高亮走 codegen——前者需要动态感知工作区状态 后者只需要语言规范里的静态词表。

两层各管各的,互不干扰。