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只收录那些**"问题清晰、方案尚不明确"**的设计课题——它实质上是一份面向编译器设计者的开放问题清单,每个条目都代表一个值得深入研究的优化方向。
目前文档中记录着三个主题:
- Bundling compiled JavaScript is non-obvious(打包编译后的 JavaScript 不够直观)
- Cannot shift repeated work to compile or initialise time(无法将重复性工作转移到编译期或初始化期)
- 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/Error、toList、prepend、isEqual、CustomType、位数组系列函数等),最后在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类以及toList、prepend、isEqual等核心函数。
由于每个模块最终都要引用 prelude(或其派生出的模块),打包器必须能够正确解析这类跨模块的 ESM 依赖关系。
为什么"打包"成为痛点
对于一个要发布到浏览器或 Node.js 生产环境的 Gleam 项目,开发者需要把几十上百个.mjs文件合并成少量 bundle。痛点在于:
- Gleam 编译器本身不负责打包——从 compiler-cli 的源码结构看,CLI 只负责编译、运行与发布,产物就是分散的 ESM 文件;
- 输出文件包含多种附加引用——sourcemap、
.d.mts类型声明引用、按需生成的 prelude 导入,打包配置需要理解并正确处理这些引用,否则很容易出现"打包后类型声明丢失"或"sourcemap 失效"的问题; - 运行时是"预编译共享库"——prelude.mjs 中
Empty、List$Empty$const这类单例常量的语义依赖模块共享,打包器的 tree-shaking 或作用域隔离如果处理不当,可能破坏单例标识语义。
正因如此,文档把"打包编译后的 JavaScript"列为需要改进的体验问题。目前仓库中的做法是:项目本身依赖外部打包工具(如 esbuild、rollup、webpack 等通用 JS 打包器),并通过 gleam.toml 的[javascript]配置段声明运行环境,见下节。
相关配置:gleam.toml 的 [javascript] 段
与 JavaScript 打包、运行直接相关的配置项定义在 compiler-core/src/config.rs 的JavaScriptConfig中:
| 配置项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
typescript_declarations | bool | false | 是否生成.d.mtsTypeScript 类型声明文件 |
source_maps | bool | false | 是否生成.mjs.map源码映射文件 |
runtime | node/deno/bun/browser等 | node | 目标 JavaScript 运行时 |
deno | 对象 | — | Deno 运行时的权限配置(allow_net、allow_read、allow_env、allow_run、allow_write、allow_ffi、allow_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 编译器目前缺少一种机制,让这类"结果确定、反复使用"的计算提前到编译期(由编译器完成)或初始化期(模块加载时完成一次),从而避免运行时重复开销。
为什么难以实现:从语言与编译器结构推断
结合仓库结构可以从三个层面理解这个限制:
- 缺乏编译期求值/宏机制:Gleam 是一门无宏(macro-free)语言,
compiler-core/src/ast中只有常量(constant.rs)、类型(typed.rs/untyped.rs)等常规 AST 节点,没有面向用户的编译期执行通道。用户无法编写"在编译时运行一次"的代码; - 常量折叠能力有限:虽然存在 compiler-core/src/ast/constant.rs 这样的常量抽象,但
constant只支持字面量与纯数据构造,不包含"正则编译"这类需要调用运行时函数的操作; - 模块初始化顺序依赖:即使把计算推迟到初始化期,JavaScript 后端中 prelude 导入、单例常量(如
List$Empty$const)的求值顺序与模块加载顺序(javascript.rs 中 imports 先于 statements 输出)也决定了"初始化期执行"必须在依赖就绪之后,这进一步提高了方案设计难度。
当前可行的权宜做法
在语言层支持之前,仓库中可以看到几种缓解手段:
- 在模块顶层定义常量:Gleam 顶层
const会被编译为模块级的常量定义(module_constant),其求值发生在模块初始化阶段而非每次调用,天然具备"初始化期执行一次"的效果; - 使用 FFI 封装有状态的预计算对象:例如在
test/external_only_javascript与test/external_only_erlang这类测试目录中展示的用法,通过@external将昂贵的准备步骤放入模块级的 JS 代码中执行一次,再暴露为 Gleam 函数。
值得期待的演进方向
文档把它列为"无现成方案"的痛点,暗示社区未来可能在以下方向探索:
- 增加惰性初始化/单例语义,让
regex.compile这类调用在模块首次使用时只执行一次; - 引入编译期常量求值扩展,使纯函数的常量折叠能力更强;
- 在标准库层面提供预编译缓存惯用法。
以上方向均属推测,仓库当前并未实现,读者应避免将其当作既有功能。
痛点三:JavaScript 自定义类型表示可以更快
当前表示方式:每个变体一个 class
这是三个痛点中源码证据最充分的一条。Gleam 的 JavaScript 后端将每个自定义类型的每个构造器(variant)编译为一个继承CustomType的 JavaScript class。核心生成逻辑在variant_definition、variant_class_definition、variant_constructor_constant等函数中(javascript.rs),可以总结为以下产物:
- class 定义:如
class Wibble extends CustomType { ... },构造函数把各字段赋值到this上(带标签的字段用this.label,无标签的用this[$index]); - 无字段变体的单例常量:
Type$Variant$const,保证同一变体的所有值共享同一引用,从而可以用引用相等做快速比较(javascript.rs); - 构造器函数:
export const Type$Variant = (arg1, arg2) => new Variant(arg1, arg2); - 类型判断函数:
export const Type$isVariant = (value) => value instanceof Variant; - 字段访问函数:为每个字段生成
Type$Variant$label或Type$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与生成器代码可以推断出若干开销来源:
- class 实例体积:每个带字段的变体都是一个完整 class 实例,对象头、原型链查找与字段赋值都有固定开销;
- 对象属性访问 vs 数组索引:带标签字段通过
this.label访问,属性名查找(尤其是动态属性)通常比数组索引慢;尽管生成器为无标签字段生成了this[$index]索引访问,但标签字段仍走属性路径(见variant_class_definition中参数与构造体的分支逻辑,javascript.rs); - 类型判断与字段访问的函数包装:每个变体都生成
Type$isVariant、Type$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_javascript、project_javascript、javascript_prelude等项目级测试(如test/javascript_prelude/Makefile与main.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 后端与语言能力层面的演进方向。
对于想要深入研究或贡献的读者,建议的阅读路径是:
- 通读 docs/annoyances.md,理解三个开放问题的语境;
- 对照 compiler-core/src/javascript.rs 与 compiler-core/templates/prelude.mjs,建立"Gleam 源码 → JS 产物"的映射;
- 查看 compiler-core/src/javascript/tests 下的快照测试,观察当前生成代码的具体形态;
- 若关注 Erlang 对照,可阅读 compiler-core/src/erlang.rs 中自定义类型的编译逻辑;
- 最终以 issue 跟踪器与 CHANGELOG.md 为准,确认各痛点的解决进度,避免把本文描述的方向当作已实现功能。
【免费下载链接】gleam⭐️ A friendly language for building type-safe, scalable systems!项目地址: https://gitcode.com/GitHub_Trending/gl/gleam
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考