Gleam 编译器已知痛点清单深度解析:JavaScript 打包、编译期工作分配与自定义类型表示优化
2026/9/13 21:02:52 网站建设 项目流程

Gleam 编译器已知痛点清单深度解析:JavaScript 打包、编译期工作分配与自定义类型表示优化

【免费下载链接】gleam⭐️ A friendly language for building type-safe, scalable systems!项目地址: https://gitcode.com/GitHub_Trending/gl/gleam

本篇技术指南聚焦 Gleam 编译仓库内的 docs/annoyances.md 这份"已知痛点清单",逐条剖析其中记录的三个悬而未决的技术问题:编译后 JavaScript 的打包不够直观、重复工作无法迁移到编译期或初始化期、以及 JavaScript 后端自定义类型的运行时表示仍有性能优化空间。通过对照 compiler-core/src/javascript.rs、compiler-core/templates/prelude.mjs 与 compiler-core/src/config.rs 等源码实现,读者可以理解这些问题产生的底层原因、当前编译器的真实处理方式,以及未来可能的设计方向。

文档定位:一份编译器维护者的"优化路线图"

在 Gleam 仓库中,docs/annoyances.md是一份特殊的技术文档:它既不是用户手册,也不是变更日志,而是编译器维护者亲手记录的、在编写 Gleam 代码过程中遇到并希望未来解决的设计性痛点清单。文档开篇明确说明:

This document contains a list of issues and annoyances that we have writing Gleam code today, so that we can devise solutions to them in future.

(本文档记录了我们在编写 Gleam 代码时遇到的各类问题与不便之处,以便未来为它们设计解决方案。)

同时文档也划定了边界:已经存在已知解决方案、但尚未实现的痛点,不会记录在这份文档里,而是跟踪在 Gleam 的 issue 跟踪器中。这意味着docs/annoyances.md只收录那些**"问题清晰、方案尚不明确"**的设计课题——它实质上是一份面向编译器设计者的开放问题清单,每个条目都代表一个值得深入研究的优化方向。

目前文档中记录着三个主题:

  1. Bundling compiled JavaScript is non-obvious(打包编译后的 JavaScript 不够直观)
  2. Cannot shift repeated work to compile or initialise time(无法将重复性工作转移到编译期或初始化期)
  3. JavaScript custom type representation could be faster(JavaScript 自定义类型表示可以更快)

下文将逐条展开,并结合当前仓库的源码实现说明每个问题的具体背景。

痛点一:打包编译后的 JavaScript 不够直观

现状:每个 Gleam 模块编译为一个独立 ESM 模块

Gleam 的 JavaScript 后端(位于 compiler-core/src/javascript.rs)采用逐模块编译的策略:每个.gleam源文件被编译成独立的.mjs文件,模块之间通过 ES Module(import/export)互相引用。从Generator::compile的组装顺序(javascript.rs)可以清晰看到每个输出文件的完整结构:

  • sourcemap 引用sourcemap_reference):当开启源码映射时,输出文件末尾会附带//# sourceMappingURL=<module>.mjs.map注释;
  • TypeScript 声明引用type_reference):当开启 TypeScript 声明生成时,会附带对./<module>.d.mts的引用;
  • 导入语句imports.into_doc):由collect_imports收集模块自身的 import、外部函数 FFI 导入,以及按需注册的 prelude 函数;
  • 模块定义definitions):依次输出自定义类型、常量、函数;
  • echo 定义echo_definition):当代码中使用echo调试表达式时,注入 echo.mjs 模板。

其中最关键的是按需导入 prelude机制:Generator内部维护一个UsageTracker,在生成表达式代码时记录哪些 prelude 功能被用到(如Ok/ErrortoListprependisEqualCustomType、位数组系列函数等),最后在compile收尾阶段通过register_prelude_usage为实际用到的功能生成精确的导入语句(javascript.rs)。这保证了每个.mjs文件只导入自己真正依赖的运行时函数。

源码级证据:prelude 是唯一的公共运行时

所有的 JavaScript 运行时支撑代码集中在一个文件中:compiler-core/templates/prelude.mjs,它通过include_str!直接嵌入编译器二进制(见 javascript.rs 的pub const PRELUDE: &str = include_str!("../templates/prelude.mjs"))。这个文件定义了:

  • CustomType基类(含withFields方法,用于记录更新);
  • List类及Empty/NonEmpty子类,并实现了Symbol.iterator迭代器、toArray()atLeastLength()countLength()等高效辅助方法;
  • BitArray类(支持非字节对齐的位偏移bitOffset、位切片、整数/浮点读写等);
  • UtfCodepoint类以及toListprependisEqual等核心函数。

由于每个模块最终都要引用 prelude(或其派生出的模块),打包器必须能够正确解析这类跨模块的 ESM 依赖关系。

为什么"打包"成为痛点

对于一个要发布到浏览器或 Node.js 生产环境的 Gleam 项目,开发者需要把几十上百个.mjs文件合并成少量 bundle。痛点在于:

  1. Gleam 编译器本身不负责打包——从 compiler-cli 的源码结构看,CLI 只负责编译、运行与发布,产物就是分散的 ESM 文件;
  2. 输出文件包含多种附加引用——sourcemap、.d.mts类型声明引用、按需生成的 prelude 导入,打包配置需要理解并正确处理这些引用,否则很容易出现"打包后类型声明丢失"或"sourcemap 失效"的问题;
  3. 运行时是"预编译共享库"——prelude.mjs 中EmptyList$Empty$const这类单例常量的语义依赖模块共享,打包器的 tree-shaking 或作用域隔离如果处理不当,可能破坏单例标识语义。

正因如此,文档把"打包编译后的 JavaScript"列为需要改进的体验问题。目前仓库中的做法是:项目本身依赖外部打包工具(如 esbuild、rollup、webpack 等通用 JS 打包器),并通过 gleam.toml 的[javascript]配置段声明运行环境,见下节。

相关配置:gleam.toml 的 [javascript] 段

与 JavaScript 打包、运行直接相关的配置项定义在 compiler-core/src/config.rs 的JavaScriptConfig中:

配置项类型默认值说明
typescript_declarationsboolfalse是否生成.d.mtsTypeScript 类型声明文件
source_mapsboolfalse是否生成.mjs.map源码映射文件
runtimenode/deno/bun/browsernode目标 JavaScript 运行时
deno对象Deno 运行时的权限配置(allow_netallow_readallow_envallow_runallow_writeallow_ffiallow_all等)

default_javascript_runtime函数将默认运行时设定为Runtime::NodeJs(config.rs)。一个典型的配置片段如下(示例见 config.rs):

[javascript] typescript_declarations = true runtime = "node" [javascript.deno] allow_net = true

typescript_declarations = true时,编译器还会生成prelude.d.mts(同样通过include_str!嵌入,见 javascript.rs),为 prelude 提供类型声明支撑。

可行的优化方向

从文档的记录方式看,这一痛点的理想解法是让"编译产物 → 可部署 bundle"的路径更直接,例如:

  • 编译器直接内置打包能力,或提供一键式打包命令,避免用户自行拼装 ESM 依赖链;
  • 在输出层面减少对打包器的隐式要求(如将 prelude 内联进每个模块,消除共享模块依赖);
  • 输出更规范的清单(manifest)说明模块间依赖关系,方便外部打包工具消费。

需要注意的是,这些都属于推断方向,当前仓库尚未实现,真正的进展以 issue 跟踪器与 CHANGELOG.md 为准。

痛点二:无法将重复工作转移到编译期或初始化期

问题描述

文档第二条痛点的原文只有一句示例说明:"For example, regex compilation"(例如正则表达式的编译)。

含义是:在 JavaScript 后端,诸如regex.compile(...)这类代价较高的操作,如果每次都写在函数体内,那么每次调用都会重新执行;而 Gleam 编译器目前缺少一种机制,让这类"结果确定、反复使用"的计算提前到编译期(由编译器完成)或初始化期(模块加载时完成一次),从而避免运行时重复开销。

为什么难以实现:从语言与编译器结构推断

结合仓库结构可以从三个层面理解这个限制:

  1. 缺乏编译期求值/宏机制:Gleam 是一门无宏(macro-free)语言,compiler-core/src/ast中只有常量(constant.rs)、类型(typed.rs/untyped.rs)等常规 AST 节点,没有面向用户的编译期执行通道。用户无法编写"在编译时运行一次"的代码;
  2. 常量折叠能力有限:虽然存在 compiler-core/src/ast/constant.rs 这样的常量抽象,但constant只支持字面量与纯数据构造,不包含"正则编译"这类需要调用运行时函数的操作;
  3. 模块初始化顺序依赖:即使把计算推迟到初始化期,JavaScript 后端中 prelude 导入、单例常量(如List$Empty$const)的求值顺序与模块加载顺序(javascript.rs 中 imports 先于 statements 输出)也决定了"初始化期执行"必须在依赖就绪之后,这进一步提高了方案设计难度。

当前可行的权宜做法

在语言层支持之前,仓库中可以看到几种缓解手段:

  • 在模块顶层定义常量:Gleam 顶层const会被编译为模块级的常量定义(module_constant),其求值发生在模块初始化阶段而非每次调用,天然具备"初始化期执行一次"的效果;
  • 使用 FFI 封装有状态的预计算对象:例如在test/external_only_javascripttest/external_only_erlang这类测试目录中展示的用法,通过@external将昂贵的准备步骤放入模块级的 JS 代码中执行一次,再暴露为 Gleam 函数。

值得期待的演进方向

文档把它列为"无现成方案"的痛点,暗示社区未来可能在以下方向探索:

  • 增加惰性初始化/单例语义,让regex.compile这类调用在模块首次使用时只执行一次;
  • 引入编译期常量求值扩展,使纯函数的常量折叠能力更强;
  • 在标准库层面提供预编译缓存惯用法。

以上方向均属推测,仓库当前并未实现,读者应避免将其当作既有功能。

痛点三:JavaScript 自定义类型表示可以更快

当前表示方式:每个变体一个 class

这是三个痛点中源码证据最充分的一条。Gleam 的 JavaScript 后端将每个自定义类型的每个构造器(variant)编译为一个继承CustomType的 JavaScript class。核心生成逻辑在variant_definitionvariant_class_definitionvariant_constructor_constant等函数中(javascript.rs),可以总结为以下产物:

  1. class 定义:如class Wibble extends CustomType { ... },构造函数把各字段赋值到this上(带标签的字段用this.label,无标签的用this[$index]);
  2. 无字段变体的单例常量Type$Variant$const,保证同一变体的所有值共享同一引用,从而可以用引用相等做快速比较(javascript.rs);
  3. 构造器函数export const Type$Variant = (arg1, arg2) => new Variant(arg1, arg2)
  4. 类型判断函数export const Type$isVariant = (value) => value instanceof Variant
  5. 字段访问函数:为每个字段生成Type$Variant$labelType$Variant$index取值函数,并利用TypedCustomType的 accessors 生成跨变体共享字段的 getter(shared_custom_type_fields)。

prelude 中的基类CustomType.withFields负责记录更新(record update):它枚举实例自身属性,用传入的字段覆盖后构造新实例(见 prelude.mjs)。

已经存在的性能优化

值得说明的是,当前实现并非毫无优化。从源码可以确认两项已有优化:

  • instanceof替代isEqual:在模式匹配场景,代码生成器会优先输出value instanceof Variant而非isEqual(value, new Variant()),因为前者无需构造新对象即可完成判断(compiler-core/src/javascript/expression.rs 有明确注释);
  • 无字段变体单例化variant_constructor_constant的注释指出,单例常量让同一变体的所有值共享底层引用,从而支持更高效的比较。

为什么还可以更快:从代码结构推断

文档中"Would would be optimal?"这句反问(原文如此)表明:维护者知道有优化空间,但尚未确定什么才是最优表示。从prelude.mjs与生成器代码可以推断出若干开销来源:

  1. class 实例体积:每个带字段的变体都是一个完整 class 实例,对象头、原型链查找与字段赋值都有固定开销;
  2. 对象属性访问 vs 数组索引:带标签字段通过this.label访问,属性名查找(尤其是动态属性)通常比数组索引慢;尽管生成器为无标签字段生成了this[$index]索引访问,但标签字段仍走属性路径(见variant_class_definition中参数与构造体的分支逻辑,javascript.rs);
  3. 类型判断与字段访问的函数包装:每个变体都生成Type$isVariantType$Variant$field等导出函数,调用链比直接访问多一层。

对照:Erlang 后端的表示方式

作为参照,Erlang 后端采用了完全不同的表示:无字段构造器编译为原子(atom),带字段构造器编译为以原子为标签的元组,如{wibble, Field1, Field2}(见 compiler-core/src/erlang.rs 中constructor_atom = to_snake_case(&constructor.name)与"constructor with no fields becomes a regular atom"的注释)。元组+原子在 Erlang 虚拟机上是高度优化的表示,访问与比较都非常廉价。

由此可以推断,JavaScript 后端的理想方向可能是寻找类似"紧凑且可快速比较"的表示(例如基于数组、带整数标签的对象或使用Symbol等),但正如文档所述,"最优方案"尚未确定。

这些痛点与代码库的呼应

docs/annoyances.md虽短,但三个主题都贯穿于仓库的各处实现与测试中,可作为继续研究的路标:

  • JavaScript 后端测试:compiler-core/src/javascript/tests 下包含 prelude、布尔值、位数组等大量快照测试(snapshot 测试),例如tests/prelude.rs验证 prelude 导入与Ok/Error的限定名行为,任何表示层面的改动都会在此体现;
  • 自定义类型表示测试:JavaScript 测试目录中的快照文件直接展示了 class、单例常量与instanceof判断的生成结果,是观察"当前表示"最直观的入口;
  • 打包与运行相关集成测试:仓库中的 test/ 目录包含external_only_javascriptproject_javascriptjavascript_prelude等项目级测试(如test/javascript_prelude/Makefilemain.mjs),验证编译产物在真实 JS 运行时下的行为;
  • 配置解析测试:compiler-core/src/config.rs 对应的快照测试(如gleam_core__config__*系列)锁定了[javascript]配置段的 JSON/TOML 序列化行为;
  • 性能基准:benchmark/ 下的基准项目(如benchmark/list)为List等核心数据结构提供基准测试,可用于量化表示方案改动带来的性能变化。

结语:如何跟进这些痛点

docs/annoyances.md是一份"活文档":每当维护者在日常编码中遇到设计层面的不便,就可能追加新条目;一旦某个痛点有了明确方案并实现,它就会从这份清单中移除,转而以功能的形式出现在 CHANGELOG.md 与各版本的变更记录(changelog/)中。因此,跟踪这份文档的变化,等同于跟踪 Gleam 编译器在 JavaScript 后端与语言能力层面的演进方向。

对于想要深入研究或贡献的读者,建议的阅读路径是:

  1. 通读 docs/annoyances.md,理解三个开放问题的语境;
  2. 对照 compiler-core/src/javascript.rs 与 compiler-core/templates/prelude.mjs,建立"Gleam 源码 → JS 产物"的映射;
  3. 查看 compiler-core/src/javascript/tests 下的快照测试,观察当前生成代码的具体形态;
  4. 若关注 Erlang 对照,可阅读 compiler-core/src/erlang.rs 中自定义类型的编译逻辑;
  5. 最终以 issue 跟踪器与 CHANGELOG.md 为准,确认各痛点的解决进度,避免把本文描述的方向当作已实现功能。

【免费下载链接】gleam⭐️ A friendly language for building type-safe, scalable systems!项目地址: https://gitcode.com/GitHub_Trending/gl/gleam

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询