
把 Swift 交叉编译到 WASM听起来像是把 iOS 生态的核心语言搬进浏览器。实际做起来难点不在编译器本身而在于 Swift 标准库、系统框架和 UI 层在 WebAssembly 平台上支持到什么程度。特别是项目里带上 SwiftUI 后最直接的问题不是怎么写View而是 SwiftWasm 工具链根本找不到 SwiftUI 这个模块。这篇文章从工具链和最小可运行例子开始讲清楚 Swift 编译到 WASM 的真实路径以及 SwiftUI 风格界面如何在浏览器端落地的替代方案。读者最后可以自己跑通一个 Swift 命令行程序到.wasm的编译再通过 Carton 和 JavaScriptKit 让它在浏览器里输出页面最后用 Tokamak 写出 SwiftUI 风格的声明式界面。1. Swift 到 WASM 的现状能交叉编译什么不能交叉编译什么1.1 SwiftWasm 项目要解决什么问题SwiftWasm 是社区维护的一套 Swift 工具链目标是把 Swift 语言编译到wasm32-unknown-wasi三项目标。这里的关键不是“Swift 本身能不能编译成 Wasm”而是编译成功后Swift 标准库、Foundation 子集以及运行时能不能在 WASI 接口上正常工作。在常规的 Swift 开发中我们使用 Apple 官方工具链面向x86_64-apple-macosx、arm64-apple-ios等目标。这些目标背后有完整的系统框架调用链。交叉编译到 WASM 时编译器要把代码生成、标准库链接、运行时初始化都换成面向wasm32-unknown-wasi的一套产物。SwiftWasm 工具链做的事情就是把 Swift 编译器、Swift 标准库、WASI sysroot 以及必要的 Foundation 实现组装到一起让swift build --triple wasm32-unknown-wasi这条命令变得可用。可以这么理解SwiftWasm 不是一门新语言而是 Swift 的一个新编译目标。你写的 Swift 纯逻辑代码例如数据解析、算法、加密散列、状态计算只要没有依赖 UIKit、SwiftUI、CoreBluetooth 这类系统框架就有很大概率能编译到 WASM。反过来凡是依赖系统能力的功能就要看 SwiftWasm 的移植程度和宿主环境是否支持。1.2 SwiftUI 为什么不能直接出现在 WASM 目标里很多人看到“Cross compiling Swift (including SwiftUI) to WASM”时会以为只要切换 targetSwiftUI 的View就能跟着编译成 Wasm 在浏览器里渲染。这个理解是不成立的。SwiftUI 是 Apple 平台上的声明式 UI 框架它依赖大量操作系统级能力比如主线程事件循环、Core Animation 渲染、系统字体、无障碍访问和 UIKit 桥接。SwiftWasm 的wasm32-unknown-wasi目标不提供这些框架。实际执行import SwiftUI时工具链会报module SwiftUI not found因为对应的模块根本不在这个 sysroot 里。因此本文讨论的 “SwiftUI 到 WASM”准确说法是“用 SwiftUI 风格的声明式 API 在浏览器里写界面”。这个领域的代表方案是 Tokamak。Tokamak 实现了View、Text、Button、VStack、HStack、State这类 SwiftUI 风格接口底层渲染到 DOM 或静态 HTML。它让开发者保留 SwiftUI 的写法和状态管理思路但并不能把 Apple SwiftUI 的二进制直接搬到浏览器。1.3 这篇文章围绕哪条技术主线展开这篇文章的技术主线是先准备 SwiftWasm 交叉编译环境然后从一个不依赖任何 UI 的 Swift 命令行程序开始编译出最小 Wasm 模块用 WASI 运行时验证结果。随后引入 Carton 和 JavaScriptKit让 Swift 代码在浏览器中操作 DOM。最后再讨论 Tokamak 如何承担 “SwiftUI 风格 UI” 的角色以及生产环境需要关注的体积、性能和调试问题。这条路线的好处是每一层都能独立验证。命令行程序能跑通说明工具链本身正确浏览器能渲染 DOM说明宿主桥接通了Tokamak 能显示按钮并更新状态说明声明式 UI 链路完整。后续即使遇到问题也能快速判断是工具链、WASI、JavaScript 桥接还是 UI 层的问题。2. 环境准备SwiftWasm 工具链、Carton 和 WASI 运行时2.1 普通 Swift 工具链与 SwiftWasm 工具链的区别安装 Swift 后终端里执行的swift命令来自 Apple 官方工具链或 Swift.org 官方 builds。这个工具链并不包含 Wasm target。直接执行swift build --triple wasm32-unknown-wasi通常会收到类似unsupported option --triple或 target 无法识别的错误。SwiftWasm 工具链是基于 Swift 源码改造的独立发布它在标准 Swift 编译器上加入了 Wasm 后端、WASI sysroot、Swift 运行时和基础 Foundation 移植。使用它时swift命令本身仍然是 Swift 编译器只是支持的 target 列表发生了变化。常见环境选择有三种环境适用场景注意点macOS 安装 SwiftWasm toolchain本地开发、调试、配合 Xcode 编辑器需要手动切换 PATH 或设置TOOLCHAINSLinux Docker 镜像CI、复现环境、隔离实验每次进入容器需要挂载源码目录系统已装 Swift 工具链编译 Carton 等宿主工具不能用来编译 Wasm target这里建议把“写 Swift 代码”和“交叉编译到 Wasm”分开理解。写代码时使用你熟悉的编辑器编译时确保当前终端能访问 SwiftWasm 的swift。2.2 在 macOS 或 Docker 中准备 SwiftWasmmacOS 上的常见做法是从 SwiftWasm 官方仓库的 Release 页面下载对应平台的 toolchain 安装包。安装完成后工具链通常会被放到 Xcode 的可选工具链目录中。启动终端后可以通过export TOOLCHAINSswiftwasm或把工具链路径放到PATH最前面来激活具体变量名要以对应版本发布说明为准。切到 SwiftWasm 工具链后先检查版本swift --version正常输出里会包含 SwiftWasm 或 wasm target 相关字样。如果swift --version仍然显示普通 Swift说明 PATH 没切过来。Linux 上更稳定的方式是使用 SwiftWasm 的 Docker 镜像。先拉取镜像docker pull swiftwasm/swift:latest在项目目录下启动容器并编译docker run --rm -v $(pwd):/src -w /src swiftwasm/swift:latest swift build --triple wasm32-unknown-wasi为了避免每次敲很长的docker run可以在项目里放一个swiftwasm.sh脚本#!/usr/bin/env bash set -euo pipefail docker run --rm \ -v $(pwd):/src \ -w /src \ swiftwasm/swift:latest \ $然后通过./swiftwasm.sh swift --version使用 SwiftWasm 工具链。2.3 安装 Carton 与 wasmtimeCarton 是 SwiftWasm 生态里的 Web 开发工具负责把 SwiftPM 项目编译成浏览器可加载的 Wasm 产物同时提供本地开发服务器和静态资源打包。建议从 GitHub 源码构建这样能确保它适配本机已安装的 Swift 工具链。git clone https://github.com/swiftwasm/carton.git cd carton swift build -c release export PATH$PWD/.build/release:$PATH carton --version这里用系统 Swift 编译 Carton 即可不需要切到 SwiftWasm 工具链。Carton 在启动开发服务器时自己会调用 SwiftWasm 的swift build --triple wasm32-unknown-wasi。wasmtime 是验证 Wasm 模块运行的轻量级 WASI 运行时。安装命令参考官方脚本curl https://wasmtime.dev/install.sh -sSf | bash安装完成后wasmtime --version2.4 环境检查清单准备阶段最容易出错的不是代码而是当前执行命令时用的工具链不对。建议按以下清单逐项确认工具用途检查命令SwiftWasm toolchain生成wasm32-unknown-wasi目标swift build --triple wasm32-unknown-wasi能正常识别Carton浏览器开发服务器和打包carton --versionwasmtime本地运行 Wasm 模块wasmtime --versionwabt可选查看 Wasm 模块导入导出wasm-objdump --version如果swift --version显示的是普通 SDK后续所有 Wasm 编译步骤都会失败。建议在单个终端会话中完成工具链切换和编译避免多个版本互相干扰。3. 第一个可复现例子把 Swift 可执行文件编译成 wasm32-unknown-wasi3.1 创建 SwiftPM 可执行项目先用 SwiftPM 初始化一个可执行项目mkdir HelloWasm cd HelloWasm swift package init --type executable项目结构如下HelloWasm/ ├── Package.swift └── Sources/ └── HelloWasm/ └── main.swift修改Package.swift保留最简配置// swift-tools-version:5.7 import PackageDescription let package Package( name: HelloWasm, targets: [ .executableTarget( name: HelloWasm ) ] )这里不需要声明platforms。Wasm 的wasm32-unknown-wasi目标并不在 Swift 官方支持的 Apple 平台列表里添加平台限制反而可能带来不必要的兼容问题。3.2 编写入口代码参数、计算与标准输出把Sources/HelloWasm/main.swift改成下面的内容import Foundation func compute(_ a: Int, _ b: Int) - Int { a * b a b } let args CommandLine.arguments print(Hello from Swift WASM) print(arguments: \(args)) print(compute(3, 4) \(compute(3, 4)))这里故意加入了函数计算和命令行参数访问。这样既能验证 Swift 基础语法编译到 Wasm 后是否正常也能验证 WASI 的命令行参数传递链路。需要注意Wasm 里的CommandLine.arguments来自 WASI 的args_get接口而不是操作系统层面的进程参数。这意味着运行时是否能看到参数取决于 host 是否实现了对应 WASI 函数。3.3 用 SwiftWasm 交叉编译并检查产物编译命令swift build --triple wasm32-unknown-wasi编译成功后产物位于.build/debug/HelloWasm.wasm。确认文件存在ls -lh .build/debug/HelloWasm.wasm如果需要更小的产物可以加 release 优化swift build -c release --triple wasm32-unknown-wasi此时产物在.build/release/HelloWasm.wasm。--triple指定的是 target triple格式拆开来看是部分值含义architecturewasm3232 位 WebAssemblyvendorunknown没有具体厂商os/abiwasi使用 WASI 系统接口这个 triple 决定了编译器选择哪套标准库、哪套 sysroot以及最终运行时依赖哪些系统接口。3.4 用 wasmtime 运行 Wasm 模块运行wasmtime .build/debug/HelloWasm.wasm hello world预期输出Hello from Swift WASM arguments: [hello, world] compute(3, 4) 19wasmtime 是 WASI host它实现了模块需要的wasi_snapshot_preview1接口Swift 标准库的print才能最终写到终端。如果这里输出为空或报错说明 WASI 接口不完整而不是 Swift 代码本身有问题。可以再用 wabt 工具查看 Wasm 模块结构wasm-objdump -x .build/debug/HelloWasm.wasm | head -60输出中能看到模块导入的 WASI 函数以及导出的_start入口。_start是 WASI 命令约定入口类似传统程序的main。3.5 WASI 与函数导出这一层到底发生了什么Wasm 模块默认无法直接写终端、读文件或访问网络。它只能使用输入外部传入的导入函数。WASI 定义了一组标准的导入函数例如fd_write用于写输出args_get用于读取参数。wasmtime 在模块实例化时把这些函数注入到 wasm 运行时形成“宿主提供能力”的模型。沙箱机制也体现在这里。模块无法直接访问宿主进程的任意内存只能访问自己的线性内存。调用宿主函数时Swift 需要把数据拷贝到 Wasm 线性内存再把指针和长度传给宿主函数。这个边界是 WebAssembly 安全设计的基础也是跨语言调用产生性能损耗的来源。4. 在浏览器中运行Carton 开发服务器与 JavaScriptKit 桥接4.1 为什么浏览器里不能直接复用 wasmtime 的 WASI 假设wasmtime 能运行刚才的 module是因为它实现了wasi_snapshot_preview1。但浏览器默认不提供这套 WASI 接口。浏览器虽然能实例化 Wasm但不会自动给你fd_write、args_get等系统能力。浏览器的宿主环境是 JavaScript 和 DOM不是通用操作系统接口。所以要让 Swift 代码在浏览器里跑起来有两种路径使用 Carton 内置的 WASI polyfill把print输出转发到浏览器 console。通过 JavaScriptKit显式调用浏览器的 DOM API把内容写入页面。第一种方式适合快速验证但只能看到 console 日志。真正做界面需要第二种。4.2 用 Carton 组织 Web 应用目录创建另一个项目mkdir BrowserWasm cd BrowserWasm项目根目录需要有一个index.html。Carton 会把它作为浏览器入口并自动注入加载 Wasm 的脚本。!DOCTYPE html html head meta charsetutf-8 titleSwift on Browser/title /head body div idapp/div /body /html创建Package.swift// swift-tools-version:5.7 import PackageDescription let package Package( name: BrowserWasm, dependencies: [ .package(url: https://github.com/swiftwasm/JavaScriptKit.git, from: 0.18.0) ], targets: [ .executableTarget( name: BrowserWasm, dependencies: [ .product(name: JavaScriptKit, package: JavaScriptKit) ] ) ] )JavaScriptKit 是连接 Swift 和 JavaScript 的桥接库。它把 JavaScript 的全局对象、函数和属性包装成 Swift 可调用的类型从而让 Swift 代码安全地操作浏览器 API。接着创建Sources/BrowserWasm/main.swift。4.3 用 JavaScriptKit 操作 DOM 的最小示例import JavaScriptKit let document JSObject.global.document let paragraph document.createElement(p) paragraph.innerText Hello from Swift JavaScriptKit _ document.body.appendChild(paragraph)这段代码的作用是通过JSObject.global拿到浏览器globalThis然后访问document。调用 DOM 的createElement(p)创建一个段落节点。设置innerText属性。把段落挂到document.body上。运行开发服务器carton dev默认情况下 Carton 会启动一个本地服务器并打开浏览器页面。正常结果是在页面中看到一行Hello from Swift JavaScriptKit如果浏览器控制台报错优先检查index.html的 body 是否存在以及 JavaScriptKit 版本与 SwiftWasm 工具链的匹配关系。这里要注意JavaScriptKit 的 API 在不同版本里会有命名调整。例如某些版本使用JSObject.global.foo某些版本对属性访问做了更强的类型约束。上面的示例用于说明核心思路真实项目里要按实际依赖版本调整。4.4 WebAssembly 沙箱原理线性内存、导入函数与安全边界浏览器里运行 Wasm 时页面中的 JavaScript 和 Wasm 模块共享线程但 Wasm 模块拥有独立的线性内存。线性内存是一块连续可访问的字节区域默认情况下无法越过边界访问 JavaScript 数据。JavaScript 引擎负责把 Wasm 指令翻译成平台机器码但翻译后的代码仍然受线性内存和函数签名的限制。宿主能力通过导入函数进入模块。当 Swift 代码调用 JavaScriptKit 的createElement时底层实际上是一个从 Swift 到 Wasm 导出函数再跨越到 JavaScript 的导入函数调用。JavaScript 函数计算完后返回值再转成合适类型回到 Swift。这个模型解决了一个安全问题模块不能直接操作系统文件、进程或网络除非宿主显式导入这些能力。但要注意沙箱隔离不等于绝对安全。一旦模块被授予了 DOM 访问权它就能操纵页面内容被授予网络请求能力它就能发起请求。所以安全边界取决于 host 暴露了什么而不是 Wasm 本身。5. SwiftUI 到 WASM 的落地路线Tokamak 与声明式 UI5.1 Tokamak 是 SwiftUI 风格而不是 Apple SwiftUITokamak 是 SwiftWasm 生态中的 UI 库。它借鉴了 SwiftUI 的声明式写法提供与 SwiftUI 高度相似的 API 名称和状态管理方式但底层由 DOM 渲染。开发者可以用View、VStack、Text、Button、State写出看起来很像 SwiftUI 的页面。它和 Apple SwiftUI 的主要差异如下对比项Apple SwiftUITokamak运行平台iOS / macOS / tvOS / watchOS浏览器 DOM / 静态 HTML模块来源Apple SDKSwift 包渲染后端Core Animation 等原生渲染DOM / HTMLAPI 完整度全面覆盖常用组件但有差异编译目标Apple tripleswasm32-unknown-wasi所以Tokamak 是“用 SwiftUI 风格写 Web”不是“把 SwiftUI 编译到 Web”。项目里不能直接写import SwiftUI而是要写import TokamakDOM。5.2 搭一个 Tokamak 计数器工程创建项目目录TokamakWasm/ ├── index.html ├── Package.swift └── Sources/ └── TokamakWasm/ └── MyApp.swiftindex.html保留一个空 mount 点!DOCTYPE html html head meta charsetutf-8 titleTokamak on Wasm/title /head body div idapp/div /body /htmlPackage.swift加入 Tokamak 依赖// swift-tools-version:5.7 import PackageDescription let package Package( name: TokamakWasm, dependencies: [ .package(url: https://github.com/TokamakUI/Tokamak.git, from: 0.11.0) ], targets: [ .executableTarget( name: TokamakWasm, dependencies: [ .product(name: TokamakDOM, package: Tokamak) ] ) ] )这里的版本号用于示例说明。实际使用时要前往 Tokamak 仓库查看当前 release以仓库实际版本为准。MyApp.swiftimport TokamakDOM main struct TokamakWasmApp: App { var body: some Scene { WindowGroup(Tokamak Example) { ContentView() } } } struct ContentView: View { State private var count 0 var body: some View { VStack { Text(Count: \(count)) Button(Increment) { count 1 } } } }运行carton dev页面显示计数和按钮。点击按钮数值会更新。这个交互闭环验证了状态管理、事件处理和 DOM 更新都正常工作。5.3 State 更新与 DOM 变更声明式 UI 的渲染思路State在 Tokamak 里并不等于 SwiftUI 编译器魔法而是通过属性包装器在值变化时触发相关视图重新计算。当count变化后渲染器会重新执行body生成新的视图树并与之前的视图树做 diff然后把最小 DOM 变更应用到页面。这种方式和 React 的思路类似。好处是开发者不需要手动管理 DOM 节点的增删改只需要描述“当前状态应该显示什么”。代价是每次状态变化都可能产生视图树计算因此在大型页面上要注意控制 body 的计算量避免在body里做高成本操作。5.4 业务逻辑与 UI 层分离的工程方式如果目标是同时支持 iOS 原生 SwiftUI 和 Web 端 Tokamak不要