一段订单金额计算可以同时运行在服务端 JVM 和前端 JavaScript 环境。复用它能够减少规则分叉,但相同的 Scala 源码并不意味着可以随意调用相同的 Java 类库,也不意味着两个运行环境的所有细节都一致。跨平台边界需要从编译、链接和实际运行三个阶段分别验证。

Scala.js 将 Scala 程序编译并链接为 JavaScript。它提供 Scala 标准库以及部分 Java API 的实现,使常见集合与纯业务逻辑能够复用。文件系统、线程、反射等能力则与目标平台有关,不能把 JVM 上的可用性当成 JavaScript 环境的承诺。

本篇冻结 Scala 3.3.7、Scala.js 1.20.1、Scala CLI 1.9.1;链接工具在 JDK 21 上运行,产物由本机 Node.js 22.22.2 执行。独立实验位于 examples/scala-lab/electives/E04,通过记录为 evidence/20261002-c/。这里没有运行浏览器 DOM、打包网站或访问真实服务。

先确定共享代码的职责

共享核心只接收订单行,返回金额或校验失败,不读取文件、不调用网络,也不依赖时区与系统时钟。输入价格使用整数分,数量使用整数,样例取值范围足够小,便于跨平台直接比较。

1
2
3
4
5
object Core:
def total(lines: List[(Int, Int)]): Either[String, Int] =
if lines.exists((price, quantity) => price < 0 || quantity <= 0)
then Left("invalid line")
else Right(lines.map((price, quantity) => price * quantity).sum)

主程序给出两条合法订单行:1250 分乘以 2,再加 500 分乘以 1,结果为 3000 分。第二个输入把数量设为零,要求返回 Left。同一份 Core.scala 与同一份 Main.scala 分别交给 JVM 后端和 Scala.js 后端,不复制两套算法。

1
2
assert(Core.total(List((1250, 2), (500, 1))) == Right(3000))
assert(Core.total(List((1250, 0))) == Left("invalid line"))

两边均通过断言,打印相同结果。这证明所选输入在两个目标上的行为一致。它没有穷举 Int 的所有取值,尤其没有证明乘法与求和不会溢出。实际金额模型仍应检查数值范围、货币种类和舍入规则。

共享核心还应避免隐藏的平台依赖。例如只看方法签名,def discount(code: String): Int 很像纯计算;若方法内部读取本地文件或调用全局时钟,迁移时仍然会遇到边界。判断可共享性,需要检查依赖链,不能只观察参数与返回类型是否属于 Scala 包。

这一设计使平台适配代码能够单独测试。JVM 端从 HTTP 请求或文件读取订单,JS 端从页面表单取得订单,二者把已经解码的数据送入 Core。共享的是明确的业务规则,平台入口仍负责各自的数据格式与异常处理。

编译通过与链接通过

Scala.js 构建会经历类型检查、生成中间表示、链接可达代码和运行产物。某个引用在编译期能够找到类型,不代表链接器具有目标实现。只有执行到完整链接,才会检查本次程序实际可达的类与方法是否齐全。

反例调用 JVM 文件 API:

1
2
3
4
5
object BadJava:
def main(args: Array[String]): Unit =
println(java.nio.file.Files.readString(
java.nio.file.Path.of("orders.csv")
))

实验把这个文件作为独立主程序构建到 JS。链接阶段报告所需 java.nio.file 类或方法不存在,记录器同时检查非零退出码和目标诊断。这与网络下载失败、Scala 语法错误不同,是平台实现边界的证据。

如果该调用放在不可达方法里,链接器可能不需要将其保留进最终产物。为了验证目标 API,反例必须让主入口直接调用它。仅仅在源文件中写下一个从未使用的函数,未必能触发所需的链接检查。

反例也不意味着 JavaScript 绝对不能读文件。Node.js 有自己的文件系统接口,浏览器则拥有另一组受权限与用户交互约束的接口。正确修复是把文件读取放到平台适配层,使用目标环境提供的能力,再把内容交给共享核心。

传递依赖同样可能引入不可用的 Java API。一个看似只做字符串处理的库,内部若调用反射或文件 API,最终链接仍会失败。选择跨平台依赖时要确认它发布了对应的 Scala.js 产物,不能只把 JVM 坐标加入构建。

[PATTERN] 跨平台验收包含类型检查、目标链接和目标运行。任一阶段成功都不能代替后续阶段。

JS 互操作是新的输入边界

Scala.js 能够直接调用 JavaScript API,但 JavaScript 动态值不自动满足 Scala 业务类型。实验使用 js.JSON.parse 解析固定输入,再读取 quantity 字段,确认实际运行在 JS 环境而非只进行 JVM 编译。

1
2
3
4
5
import scala.scalajs.js

val payload = js.JSON.parse("{\"quantity\":2}")
val quantity = payload.selectDynamic("quantity").asInstanceOf[Int]
assert(quantity == 2)

这里的强制转换只针对受实验控制的固定字符串,不是通用 JSON 校验器。若真实输入缺少字段、字段是字符串或数值超出范围,asInstanceOf[Int] 不会替应用建立完整的解码协议。生产入口应先验证形状、类型与范围,再创建共享核心使用的数据。

尤其不能因为表单控件限制了输入,就省略服务端验证。浏览器里的共享规则可以及时反馈错误,但调用方仍可以绕过页面,直接发送请求。共享代码减少规则实现的重复,授权与可信边界仍由服务端掌握。

另一项互操作边界是函数与集合表示。Scala 的 List 不是任意 JavaScript 数组,Scala 函数与 js.Function 也有各自的接口类型。跨边界需要显式的 facade、转换或导出约定。把对象暴露给 JS 之后,还要考虑 JavaScript 代码是否会修改其中的数据。

固定的 JSON 样例只证明字段读取路径可执行,没有测试恶意对象、原型链、循环结构或大整数。将互操作章节扩展为完整 SDK 时,应该把这些输入契约写进 API,而不是把所有动态值都留给调用方强制转换。

数值相同不等于所有语义相同

Scala.js 尽量保持 Scala 语言语义,但目标运行时仍会影响某些操作。官方语义文档列出数值装箱后的运行时类型检查、浮点字符串格式和反射等差异。本篇的共享核心避免依赖这些行为,只比较整数运算与明确的 Either 结果。

例如日志格式不能随意成为跨平台协议。如果测试把浮点数 toString 结果当成签名输入,目标差异可能影响结果。应当选定明确的序列化格式和数值编码,而非用调试打印充当协议。订单金额采用整数分,也必须说明单位,不能只传一个无语义的数字。

异常行为也受链接配置影响。开发模式与优化模式对某些错误检查的处理可能不同,不能只在一种构建模式下观察越界行为,就把结果写成所有 Scala.js 程序的不变规则。正确程序应遵守边界前置条件;需要依赖特定检查语义时,应显式固定配置并单独验收。

目标引擎也是版本矩阵的一部分。Node 上执行成功,不证明浏览器具有相同全局对象;现代浏览器支持的 ECMAScript 特性,也不一定被旧嵌入式 JS 引擎支持。模块格式、目标 ECMAScript 版本与宿主 API,应当与部署环境一起冻结。

本次运行使用 Scala CLI 默认开发链接模式,并通过 JVM 版 Scala.js 链接工具生成产物。原生链接工具在本机尝试中未生成 main.js,失败记录保留在 b;切换工具后重新运行了 JVM、JS、互操作和拒绝用例四项完整场景。这个工具差异没有被写成语言语义结论。

共享源码与共享二进制

JVM class 文件不能直接放进浏览器执行。共享源码通常由不同目标分别编译,发布依赖时也会出现目标平台后缀。Scala.js 的 _sjs1_3 坐标与 JVM 的 _3 坐标承担不同目标,即使版本字符串相同,也不是可互换文件。

当工程增长时,可以建立 shared、jvm、js 等源码范围。shared 放置领域模型和纯规则,目标目录放置 HTTP、文件、计时器或 JS facade。是否需要这种目录结构取决于实际共享规模,本实验只用三个小文件,让每次编译的输入清晰可见。

更重要的是防止平台代码倒流进共享层。若 Core 开始调用一个平台单例,目录名称仍叫 shared 也不会阻止依赖出现。为共享核心配置两个目标的构建检查,比约定“请不要写 Java API”更可靠。Java API 拒绝反例可以作为理解这条边界的最小案例。

前后端同时使用同一份源码,还需要管理发布时差。客户端可能长期缓存旧代码,服务端已升级新规则;“同仓库”不能保证用户当前运行的是同一版本。涉及价格、优惠或权限时,服务端仍必须返回权威计算结果,并为规则版本变化设计兼容策略。

平台边界适合放在输入与输出处

浏览器与服务器都可以调用相同的金额函数,但读取订单的方法通常不同:浏览器处理表单或网络响应,服务器读取数据库或请求体。共享核心接收经过校验的数据,把平台对象留在适配层,能够减少对 DOM、文件系统和线程接口的隐含依赖。输入验证也不能只留给前端,因为服务端接收到的数据未必来自受信任的页面。

测试共享代码时,应让两个目标读取相同的测试向量,包括正常金额、零数量、负价格与边界整数。若测试数据分别手写,某个平台漏掉反例,两个测试组都通过也不能说明覆盖范围一致。本实验直接复用 Core.scala 和主要入口,减少这种差异,但只覆盖其中列出的有限输入,不替代完整的数值性质测试。

互操作层则需要另设测试。动态对象可能缺少 quantity,也可能把字符串写进原本预期为数值的字段。静态核心无法检查尚未转换进来的外部对象;适配层必须先判定字段存在、类型和数值范围,再构造业务值。示例中的固定 JSON 只验证一次真实 JavaScript 调用,不应直接复制成不校验外部输入的生产解析器。

共享比例也不是越高越好。如果为了复用而给所有平台引入巨大抽象接口,维护成本可能超过节省的代码。先共享确定的领域计算,再依据真实重复抽取接口,通常更容易看清每个平台仍然需要承担的责任。

实测结果与练习

四个场景由独立进程执行,拒绝场景采用相反的退出码判定。

场景 实测结果 证据范围
JVM 共享核心 3000,非法数量返回 Left JVM 运行断言
Node 共享核心 同样结果 JS 链接与执行
js.JSON 互操作 quantity 为 2 固定 JSON 样例
java.nio.file 反例 链接拒绝 当前目标缺少实现

手算题:将价格改成 Int.MaxValue、数量改成 2,当前核心是否会返回“金额过大”的 Left?不会,当前输入校验没有检查乘法溢出。跨平台都得到某个相同结果,也不能让这个业务缺陷消失。

修改练习:在共享核心中增加受控范围校验,并为 JVM 与 JS 同时加入边界值测试。随后实现 JS 输入解码,把缺少 quantity 和 quantity 为字符串两种情况转为显式错误,确保强制转换不再承担输入校验责任。

可迁移规则 应保留的检查
纯核心缩小平台依赖 两个目标编译并运行同一源文件
链接检查可达实现 独立入口触发不可用 API
互操作保持输入边界 数据形状、类型与范围校验
共享不消除发布时差 服务端权威规则与版本策略

完整命令与本机工具配置见实验说明。

参考资料

顺序导航:系列入口:00 · 上一篇:E03 · 下一篇:E05。