当团队里已经有一大套用 Swift 维护的算法、协议和业务规则,前端又不想用 JavaScript 再实现一遍时,最直接的思路就是把 Swift 编译成 WebAssembly(WASM),让同一份代码在浏览器里跑起来。网上关于 Swift 交叉编译到 WASM 的资料并不少,但大多要么只讲一个简单 Hello World,要么停留在概念阶段,真正能落地到“SwiftUI 风格页面 + 浏览器运行”的完整教程相对零散,新手很容易卡在工具链选择和 JavaScript 互操作上。
本文将围绕 Swift + WASM 的交叉编译全流程展开,重点解决三件事:第一,SwiftWasm 工具链怎么装、怎么选版本;第二,Swift 代码怎么通过 wasm32-wasi 目标编译成 WASM 模块;第三,SwiftUI 风格的 UI 能不能直接编译进浏览器,如果不能,社区方案 Tokamak 是怎么做的,以及一个可以运行的最小项目怎么搭。
1. 背景与核心概念
1.1 Wasm 是什么,为什么 Swift 要到 Wasm 上
WebAssembly 是一种可移植、体积紧凑、加载高效的字节码格式,几乎所有主流浏览器都原生支持。它不是一个编程语言,而是一个编译目标。开发者可以用 C、Rust、Go、Kotlin、Swift 等语言编写业务逻辑,然后通过工具链把它们编译成.wasm文件,交给浏览器里的虚拟机执行。
为什么要把 Swift 编译到 Wasm?核心诉求有这几个:
- 代码复用:一套算法、序列化逻辑、校验规则在 iOS、macOS、Web 端共用。
- 性能可控:对于计算密集任务,WASM 的执行效率远高于手写 JS 解释执行,并且接近原生速度。
- 多端一致:编译后的字节码与操作系统无关,业务逻辑不会因为平台差异出现细微行为偏差。
- 沙箱安全:WASM 默认运行在受限环境中,模块不能随意访问文件、网络或宿主内存。
从工程角度看,Swift 后端开发者可以继续用 Swift 写业务,前端通过加载 WASM 模块拿到同样的计算结果;iOS 开发者可以把核心模块抽成独立 Swift Package,Web 端直接交叉编译复用,而不是重新写一套 TypeScript。
1.2 SwiftUI 到 Wasm 的真实路径:Tokamak
这里需要先厘清一个容易误解的点:苹果官方 SwiftUI 框架目前并没有开放到 wasm32-wasi 目标。SwiftUI 是 Apple 平台框架,依赖 UIKit、AppKit、Core Animation 等底层能力,官方工具链在非苹果平台上并不提供这些组件实现。所以标题里的“including SwiftUI”,在实际工程中并不是直接 import SwiftUI 然后编译成 WASM,而是通过社区框架 Tokamak 实现 SwiftUI 风格的声明式 UI。
Tokamak 是一个可以在浏览器里跑 SwiftUI 风格代码的跨平台 UI 框架。它复刻了 SwiftUI 的核心设计:View 协议、@State 状态管理、VStack/HStack/Text/Button 等基础组件,然后将这些声明式描述映射到 DOM 操作。最终 Tokamak 应用会被编译成 WASM,在浏览器里执行渲染逻辑。
如果你是想把现有 SwiftUI 项目原封不动搬到浏览器,那目前还做不到;但如果你愿意按照 Tokamak 支持的组件子集去调整,那么 SwiftUI 风格的声明式语法、状态响应式和组件化思想都可以在 Web 端延续下来。
1.3 适用场景与不适用场景
先看适合的场景:
- 你有一个核心业务模块用 Swift 写成,希望 iOS 端和 Web 端共用,比如加密签名、序列化协议、复杂校验规则。
- 你想在 Web 端使用 Swift 的语言能力,但又不想把整个业务用 JS 重写。
- 你的团队对 Swift 更熟悉,希望通过 Tokamak 在 Web 端保持 SwiftUI 风格的组件组织方式。
- 你在做边缘计算或插件化架构,希望用 Swift 编译出可动态加载的 WASM 模块。
不适合的场景也很明显:
- 需要深度调用浏览器完整 API,例如复杂 WebGL 渲染、Service Worker、IndexedDB 高频操作。Swift 侧只能通过 JS 互操作间接访问,不是完全透明。
- 需要极小体积的包体。Swift 运行时本身会带来一定体积开销,简单场景下比纯 JS 方案大不少,需要做裁剪和体积治理。
- 需要高频双向数据交换。WASM 与 JS 之间有边界开销,如果每帧都在两个世界之间传递大对象,性能反而不如直接用 JS。
2. 环境准备与版本说明
2.1 工具链与版本选择
编译 Swift 到 WASM 主要依赖 SwiftWasm 项目提供的工具链。SwiftWasm 不是一个独立语言,而是对 Swift 编译器的扩展和移植,它基于官方 Swift 代码仓库构建,增加了wasm32-wasi目标支持,并配套维护了一套 WASI 运行时和 Swift 标准库实现。
关于版本要注意一点:SwiftWasm 工具链会跟随上游 Swift 版本迭代,建议使用较新的稳定版本,避免在 Swift 5.5 等过老版本上踩到标准库兼容问题。实际项目里,团队还需要保证以下两个条件一致:
- 本机 Swift 版本:用于本地编译调试。
- SwiftWasm 工具链版本:用于正式交叉编译到 WASM。
安装前,先确认本机是否存在 Swift:
swift --version如果本机安装的是 Xcode 自带的官方 Swift,那么它一般无法直接构建 WASM 目标。后面需要单独安装 SwiftWasm 的工具链,并在编译命令或路径切换上让项目使用 SwiftWasm 版本。
2.2 安装 SwiftWasm Toolchain 与 Carton
SwiftWasm 提供了面向 macOS 和 Linux 的预编译工具链。最稳妥的安装方式是到 SwiftWasm 官网或 GitHub Releases 页面下载对应平台的安装包,然后将其配置到系统 PATH 中。
macOS 上下载.pkg安装包后,工具链会默认安装到/Library/Developer/Toolchains目录。你可以用下面命令看到 SwiftWasm 工具链是否可用:
/Library/Developer/Toolchains/swift-wasm-<版本>/usr/bin/swiftc --version为了让终端直接使用 SwiftWasm 工具链,可以临时指定:
export TOOLCHAINS=org.swift.wasm或者直接把工具链路径加入 PATH:
export PATH=/Library/Developer/Toolchains/swift-wasm-<版本>/usr/bin:$PATHLinux 用户下载tar.xz后解压到固定目录,再更新 PATH 即可。这里不写死具体版本号,是因为 SwiftWasm 的 Release 会持续迭代,建议以官方安装说明为准。
接下来安装开发服务器 Carton。Carton 是 SwiftWasm 生态里的重要工具,它负责把 Swift 源码编译成 WASM、启动本地开发服务器、提供热更新预览,并最终打包成静态站点产物。安装方式可以走源码构建:
git clone https://github.com/swiftwasm/carton.git cd carton swift build -c release编译完成后,把.build/release/carton复制或软链到 PATH 目录下。你也可以从 Carton 的 Release 页面直接下载对应平台的可执行文件。安装后验证:
carton --version2.3 验证环境
安装完成后,通过下面两步确认交叉编译环境是否正确:
swiftc --version # 预期输出里能看到 wasm 相关的 target 支持信息 swiftc -target wasm32-unknown-wasi -print-target-info第二条命令会输出当前目标平台的 CPU、系统库路径、编译器路径等信息。如果 command not found 或者输出的是 x86_64-apple-macosx 之类的内容,说明当前使用的仍然不是 SwiftWasm 工具链。
这里需要补充说明:官方 Swift 的某些新版本已经试验性地支持wasm32-unknown-wasi目标,但 SwiftWasm 工具链仍然是目前最稳定、生态最完整的方案。下面的操作都以 SwiftWasm 工具链为前提。
3. 核心原理拆解:Swift 是如何编译到 Wasm 的
3.1 wasm32-wasi 目标
交叉编译时我们频繁看到wasm32-wasi这个 target 名称。它由两个部分组成:
wasm32:表示 32 位 WASM 线性内存模型。wasi:全称 WebAssembly System Interface,是 WASM 模块访问系统能力的一组标准接口。
WASI 相当于把 POSIX 系统调用抽象成了 WASM 可以导入的函数集合,比如文件读写、时钟、随机数、环境变量等。Swift 标准库在编译到这个 target 时,会把原本依赖 FileManager、Process 等系统能力的实现替换成 WASI 版本。这样一来,同一份 Swift 逻辑可以在浏览器、Node.js、边缘运行时等多类宿主中运行,只需要宿主提供对应的 WASI 导入实现。
但要注意,Swift 的 Foundation 框架在 WASI 环境下并不是所有能力都可用。比如网络请求、文件系统操作这类能力,如果宿主没有实现对应 WASI API,Swift 代码里即使写了URLSession,也无法按预期工作。因此在实际工程中,网络、文件这类 IO 操作一般仍由宿主注入或通过 JavaScript 互操作完成,Swift 侧聚焦在纯逻辑和数据处理部分。
3.2 Wasm 沙箱能力模型
WASM 之所以安全,是因为它从一开始就设计成“能力安全”模型。每个 WASM 实例拥有独立的线性内存,无法直接访问宿主进程的堆内存;模块内部只能调用自己定义和导入的函数,不能像原生程序那样随意发起系统调用。
对于 Swift 开发者来说,这种沙箱模型有两个直接影响:
- 默认权限极小:一个未导入任何 WASI 函数的 Swift WASM 模块,几乎什么系统资源都碰不了。
- 授权必须显式:在需要文件访问的场景,宿主只会向 WASM 模块开放指定路径的预打开文件描述符,而不是整个文件系统。
在浏览器中运行时,沙箱能力由浏览器自身保障。WASM 模块无法偷偷读取用户本机文件,也无法绕过浏览器安全策略发起跨域请求。这也是为什么很多滑块验证、前端加密、风控算法会把关键逻辑编译成 WASM——既利用性能,也能制造一定的逆向门槛。但必须要说清楚,WASM 沙箱并不等于绝对安全,模块内部算法仍然可能被逆向分析,不能把敏感密钥直接写进 WASM。
3.3 Swift 与 JavaScript 互操作
SwiftWasm 生态里,JavaScript 互操作主要依赖 JavaScriptKit 这个库。它提供了一组类型安全的包装,让 Swift 代码可以调用浏览器 API。
基本流程是:Swift 通过JSObject.global获取全局对象,然后像调用 Swift 方法一样调用document、window、console等 JavaScript 对象的方法。下面是一个在浏览器弹窗的小例子:
import JavaScriptKit let window = JSObject.global _ = window.alert("Hello from Swift + WASM")这段代码可以编译进 WASM 模块,加载到浏览器后,window.alert会被真正执行,弹出浏览器原生对话框。
反向互操作也很常见:JavaScript 调用 Swift 暴露的函数。SwiftWasm 支持通过@_cdecl导出符号,让 WASM 模块成为可以被 JS 直接调用的函数库。比如:
import Foundation @_cdecl("add") public func add(_ a: Int32, _ b: Int32) -> Int32 { return a + b }在 JavaScript 侧加载 WASM 后,可以通过 WebAssembly 实例的exports.add直接调用。不过实际项目中,如果只是单方向调用,建议优先使用 JavaScriptKit 的正向互操作;如果需要双向调用,则需要设计好数据交换的边界,尽量用字符串或二进制缓冲区传递结构化数据,避免逐个对象跨边界访问。
3.4 声明式 UI 为什么能跨平台
SwiftUI 的核心思想是“状态驱动视图”。开发者描述的是界面与状态的关系,而不是每一步 DOM 操作。比如下面这段代码:
struct CounterView: View { @State var count = 0 var body: some View { VStack { Text("count: \(count)") Button("+1") { count += 1 } } } }编译器会在每次count变化时重新计算 body,得到新的视图描述。原生的 SwiftUI 会把这个视图描述渲染成 UIKit/AppKit 控件;Tokamak 则把同样的视图描述翻译成 DOM 节点的创建、更新与删除。这就是声明式 UI 可以跨平台存在的根本原因——视图层只是渲染器的具体实现,而 SwiftUI 风格的 DSL 本身是平台无关的。
Tokamak 本质上就是一个“跑在 WASM 里的 SwiftUI 渲染器”。它保留 View、App、@State 等概念,再把渲染层替换成 DOM。因此,你在项目里写的 SwiftUI 风格代码从语法上很接近原生 SwiftUI,但底层其实并不是苹果官方框架。
4. 完整实战:SwiftUI 风格的 WebAssembly 应用
这一节将实现一个最简单的计数器应用,界面完全使用 SwiftUI 风格的声明式语法。项目启动后,浏览器里会出现一个带按钮的页面,点击按钮数字自动加一。
4.1 创建项目结构
先创建一个名为SwiftWasmDemo的目录,项目结构如下:
SwiftWasmDemo/ ├── Package.swift └── Sources/ └── SwiftWasmDemo/ ├── App.swift ├── ContentView.swift └── JSBridge.swift这里Sources/SwiftWasmDemo是 SwiftPM 的可执行模块目录,里面三个 Swift 文件各司其职:
App.swift:应用入口,声明根界面。ContentView.swift:页面主体,SwiftUI 风格组件。JSBridge.swift:Swift 与 JavaScript 互操作示例。
4.2 编写 Package.swift
Package.swift 是 SwiftPM 项目的核心配置文件。为了编译到 WASM,我们需要声明 TokamakDOM 这个产品依赖。
// swift-tools-version:5.9 import PackageDescription let package = Package( name: "SwiftWasmDemo", targets: [ .executableTarget( name: "SwiftWasmDemo", dependencies: [ .product(name: "TokamakDOM", package: "Tokamak") ] ) ], dependencies: [ .package(url: "https://github.com/TokamakUI/Tokamak.git", from: "0.11.0") ] )这里需要注意,版本号会随 Tokamak 仓库更新而变动。如果你 fetch 依赖时出现版本不存在或签名校验失败,可以把from: "0.11.0"替换成仓库 README 中推荐的最新版本号。SwiftPM 解析成功后,会把 Tokamak 及其底层依赖一起构建。
4.3 实现 SwiftUI 风格的页面
首先是应用入口App.swift。这里使用@main声明应用的启动点,WindowGroup表示一个窗口级别的视图容器:
// 文件路径:Sources/SwiftWasmDemo/App.swift import TokamakDOM @main struct SwiftWasmDemoApp: App { var body: some Scene { WindowGroup("Swift + WASM Demo") { ContentView() } } }然后编写页面主体ContentView.swift。这个文件里的代码和 SwiftUI 几乎一致,一个@State变量、一个VStack、一个Text、一个Button:
// 文件路径:Sources/SwiftWasmDemo/ContentView.swift import TokamakDOM struct ContentView: View { @State private var count = 0 var body: some View { VStack(spacing: 12) { Text("你好,Swift + WASM") .font(.title) Text("当前计数:\(count)") Button("点击 +1") { count += 1 } } .padding() } }这份代码并不是苹果官方 SwiftUI,而是 TokamakDOM 提供的 SwiftUI 兼容 API。@State的响应式行为由 Tokamak 内部状态系统管理,点击按钮后count变化,Text("当前计数:\(count)")会自动更新。
4.4 引入 JavaScriptBridge 示例
再写一个JSBridge.swift,演示 Swift 侧如何直接调用浏览器全局函数:
// 文件路径:Sources/SwiftWasmDemo/JSBridge.swift import JavaScriptKit func alertMessage(_ message: String) { let window = JSObject.global _ = window.alert(message) }然后在ContentView.swift的按钮闭包里调用它:
Button("点击 +1") { count += 1 alertMessage("当前计数:\(count)") }这样点击按钮后,浏览器会先弹出原生 alert 对话框,再更新页面上的计数文本。如果你在本地运行时发现JavaScriptKit模块无法导入,说明当前 Package 解析没有把 JavaScriptKit 作为直接依赖暴露给目标。最稳妥的做法是在Package.swift的 dependencies 中显式加入 JavaScriptKit 依赖,并将对应 product 加进 target。不过通常 TokamakDOM 会传递依赖 JavaScriptKit,所以大多数情况下上面的代码可以直接编译。
4.5 运行开发服务器
在项目根目录执行:
carton devCarton 会先执行swift build,把 Swift 源码编译成 WASM 模块,然后启动一个本地开发服务器,默认端口通常是http://localhost:8080。命令行中会出现编译进度,编译完成后,打开浏览器访问该地址,就能看到渲染后的页面。
carton dev的优势是支持文件监听,修改Sources目录下的 Swift 源码后,页面会自动重新编译并刷新,省去手动打包。
编译完成后,也可以只构建一次产物,不启动开发服务器:
carton build4.6 构建发布产物
开发验证通过后,执行 bundle 命令打包静态产物:
carton bundleCarton 会在build子目录下生成swiftwasm.js、SwiftWasmDemo.wasm、index.html等文件。这些文件就是最终的静态站点发布内容,可以放到 Nginx、GitHub Pages、对象存储或任何静态文件服务器上。
需要注意,不要把本地构建的index.html用双击方式直接打开。浏览器从file://协议加载.wasm文件时会出现 CORS 跨域限制,正确的做法是通过本地静态服务器访问。简单的方式是:
cd build python3 -m http.server 8080然后访问http://localhost:8080。
5. 常见问题与排查思路
5.1 高频报错与解决方案
我把 Swift 交叉编译到 WASM 过程中最常遇到的问题整理成一张表,方便你快速定位。
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
wasm32-wasitarget 无法识别 | 当前使用的是官方 Swift 工具链,而不是 SwiftWasm 工具链 | 用swiftc --version确认工具链版本,切换到 SwiftWasm 安装目录 |
TokamakDOM模块找不到 | Package.swift 中缺少 Tokamak 依赖,或版本解析失败 | 检查 package url 和 version,重新swift package resolve |
JavaScriptKit模块找不到 | 目标没有显式依赖 JavaScriptKit 产品 | 在 Package.swift 的依赖列表和 target dependencies 中补上 JavaScriptKit |
| 编译时出现 SwiftUI 报错 | 把 import SwiftUI 写进了代码 | 将 import 改为import TokamakDOM,并检查是否有 SwiftUI 独有 API |
| 浏览器访问时白屏 | .wasm文件加载失败,或 JS 运行时异常 | 打开浏览器控制台查看报错,确认通过 HTTP 服务访问而非 file 协议 |
| 页面弹出 alert 后又卡住 | JS 互操作调用到了不存在的全局对象 | 确认浏览器环境里确实存在对应 API,并 |