面向 JavaScript 和 TypeScript 开发者的 Rust
如果你写 JavaScript 或 TypeScript,你懂的 Rust 其实比你以为的多。Rust 有用起来很像箭头函数的闭包、async/await、名字很眼熟的迭代器方法(map、filter、find)、解构,还有一个命令行工具(Cargo)干的就是 npm 干的活。如果你用 TypeScript,你也早就习惯了一个在代码运行前帮你抓错的编译器。Rust 的编译器做的是同一件事,只是更严格。
那真正新的是什么?三样东西。Rust 没有垃圾回收器:每个值都只有一个所有者,编译器会在程序运行之前检查所有权和借用。Rust 没有 undefined 也没有 null:一个可能缺失的值,类型是 Option。Rust 还没有异常:可能失败的函数会返回一个 Result。你的大部分学习时间都会花在这三点上。
JavaScript 开发者为什么要学 Rust?通常是因为下面某个原因:你想要一个打包成单个文件就能发布的高性能 Web 后端或命令行工具;你想用 WebAssembly 给浏览器里的性能热点提速;你想写一个原生的 Node.js 插件;或者你只是对自己天天在用的工具感到好奇。SWC、Biome、Oxc、Rolldown、Rspack、Turbopack、Lightning CSS 和 Deno 都是用 Rust 写的。
这一页把你在 JavaScript 和 TypeScript 里学到的东西一一对应到 Rust,好让你把时间花在真正新的部分上。如果你想看 Rust 和 C++、Go、Python 的整体对比,请看 Rust 与 C++、Go、Python 对比。
语言概念:从 JavaScript 和 TypeScript 到 Rust
Section titled “语言概念:从 JavaScript 和 TypeScript 到 Rust”把这张表当成一本“短语手册”来用。最后一列链接到本站讲解 Rust 这一边的课程。
| JavaScript / TypeScript | Rust | 延伸学习 |
|---|---|---|
let 和 const |
let(不能修改)和 let mut(可以修改)。JS 里 const 对象的内部仍然可以改;Rust 里 let 绑定的值不行 |
Rust 变量 |
number |
由你选择大小:i32、i64、u32、usize、f64……整数和浮点数是不同的类型 |
Rust 数据类型 |
bigint |
i128 / u128,任意大小就用 num-bigint crate |
Rust 数据类型 |
string |
String(拥有所有权、可增长)和 &str(借用的文本视图) |
Rust 字符串 |
boolean |
bool |
Rust 布尔值 |
| 模板字符串 | format!("Hi {name}") 和 println!("Hi {name}") |
Rust 输出 |
any、unknown |
没有 any。用泛型、枚举,形状未知的 JSON 就用 serde_json::Value |
Rust 泛型、用 Serde 处理 JSON |
描述对象形状的 interface 或 type |
struct |
Rust 结构体 |
描述方法的 interface |
trait |
Rust trait |
| 结构化类型(形状相同就是同一类型) | 名义类型:两个字段完全相同的结构体,仍然是不同的类型 | Rust 结构体 |
| 联合类型和可辨识联合 | 每个变体都能携带自己数据的 enum |
Rust 枚举 |
"small"、"large" 这样的字符串字面量类型 |
只有简单变体的 enum:enum Size { Small, Large } |
Rust 枚举 |
undefined、null、可选属性 x?: T |
Option<T>:要么是 Some(value),要么是 None |
Rust Option |
?. 和 ?? |
?(在返回 Option 的函数里)、.map(...)、.and_then(...)、.unwrap_or(...) |
Rust Option |
非空断言 x! |
.unwrap() 或 .expect("原因"),值是 None 时会让程序停下来 |
Rust panic |
throw、try/catch、被拒绝的 Promise |
Result<T, E> 加 ? 运算符 |
Rust Result |
class MyError extends Error |
自己的错误枚举(常配合 thiserror);应用程序里用 anyhow |
自定义错误类型、anyhow 与 thiserror |
带字段和方法的 class |
用 struct 存数据,再用 impl 块写方法 |
Rust 结构体 |
extends(继承) |
没有继承。用组合(结构体里套结构体)加带默认方法的 trait | Rust trait、设计模式 |
class Dog implements Animal |
impl Animal for Dog |
Rust trait |
| 装着不同类、但实现了同一接口的对象的数组 | Vec<Box<dyn Animal>>(trait 对象) |
trait 对象(dyn) |
T extends HasId 这样的泛型 |
带 trait 约束的泛型:T: HasId |
Rust 泛型 |
| 箭头函数和回调 | 闭包,由 Fn、FnMut、FnOnce 这几个 trait 描述 |
Rust 闭包、Fn、FnMut 与 FnOnce |
this |
显式的 self、&self 或 &mut self 参数。this 到底指向谁,不会再有意外 |
Rust 结构体 |
解构 const { x, y } = point |
模式:let Point { x, y } = point;(match 和函数参数里也能用) |
Rust 模式匹配 |
展开 { ...user, admin: true } |
结构体更新语法:User { admin: true, ..user } |
Rust 结构体 |
switch,或者根据 typeof / kind 写的一串 if |
match(编译器会检查你是否覆盖了所有情况) |
Rust match |
=== |
==,比较的是值(通过 PartialEq trait)。永远不会做隐式类型转换 |
常用 trait |
for...of |
for item in &items |
Rust for 循环 |
map、filter、reduce、find、some、every |
迭代器方法:map、filter、fold、find、any、all,最后 collect |
Rust 迭代器 |
生成器(function*)和 Symbol.iterator |
实现 Iterator trait |
自定义迭代器 |
Promise<T> |
一个 Output = T 的 Future。和 Promise 不同,在被 await 之前它什么都不做 |
async/await |
async / await |
async fn 和 .await,再加上 tokio 这样的运行时来执行它们 |
async/await、深入 async |
Promise.all |
tokio::join!,或者 futures crate 里的 join_all |
深入 async |
setTimeout |
异步代码里用 tokio::time::sleep,其他情况用 std::thread::sleep |
async/await |
Web Worker、worker_threads |
真正的线程:std::thread::spawn,并行循环用 rayon |
Rust 并发 |
ES 模块:import、export |
模块:mod、use 和 pub |
Rust 模块 |
JSON.parse / JSON.stringify |
serde_json 配合 #[derive(Serialize, Deserialize)] |
用 Serde 处理 JSON |
console.log |
println!,快速调试用 dbg!。派生了 Debug 的类型用 {:?} 打印 |
Rust 输出 |
readonly、as const、Object.freeze |
不需要:除非你写了 mut,否则一切都是只读的 |
Rust 变量 |
| 装饰器 | #[derive(Debug)] 这样的属性,以及宏 |
Rust 宏 |
Number("abc") 得到 NaN |
"abc".parse::<i32>() 返回一个必须处理的 Err |
Rust 类型转换、From 与 Into 转换 |
| 垃圾回收器 | 所有权和借用,在编译时检查 | Rust 所有权、Rust 借用 |
| JavaScript | Rust | 延伸学习 |
|---|---|---|
Array |
Vec<T>(所有元素类型相同) |
Rust vector(Vec) |
固定长度的数组,或者 TS 元组 [string, number] |
固定长度用 [T; N],类型混合用元组 (String, i32) |
Rust 数组、Rust 元组 |
Map,或者当字典用的对象(Record<string, T>) |
HashMap<K, V> |
Rust HashMap |
| 键有序的 map | BTreeMap<K, V> |
Rust HashMap |
Set |
HashSet<T>(要有序就用 BTreeSet<T>) |
HashSet 与 BTreeMap |
当队列用的数组(push + shift) |
VecDeque<T>(push_back / pop_front,两头都快) |
VecDeque 与 BinaryHeap |
当栈用的数组(push + pop) |
用 push / pop 的 Vec<T> |
Rust vector(Vec) |
Uint8Array、Buffer |
Vec<u8>,或者字节 slice &[u8] |
Rust slice(切片) |
WeakMap、WeakRef |
Weak<T>(来自 Rc 或 Arc) |
Weak 与 Cow |
有一点要注意:JavaScript 的 Map、Set 和普通对象都记得插入顺序。Rust 的 HashMap 和 HashSet 不记得。需要有序就用 BTreeMap;需要保持插入顺序,indexmap crate 正好能做到。
工具链:从 npm 到 cargo
Section titled “工具链:从 npm 到 cargo”Cargo 会让你有回家的感觉。它集包管理器、构建工具、测试运行器和脚本运行器于一身,而且每个 Rust 项目的用法都一样。
| JavaScript | Rust | 说明 |
|---|---|---|
node + npm(或 pnpm、yarn) |
cargo |
构建、运行、测试、添加包、发布。见 Cargo 与 crate |
package.json |
Cargo.toml |
|
package-lock.json |
Cargo.lock |
自动生成 |
node_modules/ |
没有每个项目一份的目录。下载的东西统一缓存在 ~/.cargo 里,构建产物放在 target/ 里 |
|
| npmjs.com | crates.io | 每个已发布 crate 的 API 文档都在 docs.rs(英文)上 |
npm install zod |
cargo add serde |
|
npm init |
cargo new my_app(库用 cargo new --lib) |
|
npm run dev / build / test |
cargo run / cargo build / cargo test |
加上 --release 得到优化构建;debug 构建慢得多 |
package.json 里的 "scripts" |
没有 scripts 这一节。在 .cargo/config.toml 里写 cargo 别名,或者用 just 这样的任务运行器 |
|
npx some-tool |
cargo install some-tool,然后运行它 |
cargo binstall 会下载预编译好的二进制文件,不用自己编译 |
nvm 和 .nvmrc |
rustup 和一个 rust-toolchain.toml 文件 |
见 Rust 工具与环境配置 |
| npm / pnpm workspace(monorepo) | Cargo 工作空间 | 顶层的一个 Cargo.toml 列出所有成员 crate |
tsc |
rustc,由 cargo 替你调用 |
|
tsc --noEmit |
cargo check |
只检查类型不构建,快得多 |
| ESLint | cargo clippy |
数百条实用的 lint,官方出品 |
| Prettier | cargo fmt |
所有人共用一套标准风格 |
nodemon、tsx watch |
cargo watch 或 bacon |
用 cargo install 安装 |
| Jest / Vitest / Mocha | 内置的 #[test] 和 cargo test(cargo nextest 是一个流行的、更快的测试运行器) |
见 Rust 测试 |
| Jest 快照 | insta |
见 测试进阶 |
jest.mock() |
mockall,或者手写一个实现了你的 trait 的假对象 |
|
| fast-check | proptest |
基于属性的测试 |
| c8 / Istanbul(代码覆盖率) | cargo llvm-cov |
|
| JSDoc / TypeDoc | 用 Markdown 写的 /// 文档注释加 cargo doc |
文档里的代码示例会作为测试运行。见 Rust 文档 |
| VS Code + TypeScript 语言服务 | VS Code + rust-analyzer,或者 JetBrains RustRover | rust-analyzer 提供补全、跳转到定义和行内报错 |
npm publish |
cargo publish |
下面是一个小小的 Cargo.toml,你可以拿它和 package.json 比一比:
[package]name = "orders-api"version = "0.1.0"edition = "2021"
[dependencies]axum = "0.8"serde = { version = "1", features = ["derive"] }serde_json = "1"tokio = { version = "1", features = ["full"] }像 "1" 这样的版本号,意思和 package.json 里的 "^1" 一样:任何兼容的 1.x 版本。feature 是 crate 里可以按需打开的可选部分;见 Cargo feature 与 cfg。
库和框架:“那我们用什么来代替……?”
Section titled “库和框架:“那我们用什么来代替……?””这是每个 JavaScript 开发者最先问的问题:“我们用 Express 和 Prisma,Rust 里对应的是什么?”下面是最接近的替代品。和 npm 一样,crates.io 上大多数需求都有很多选择;这里列的是流行、维护得好的那些。
Web 与网络
Section titled “Web 与网络”| JavaScript | Rust | 说明 |
|---|---|---|
| Express、Fastify、Koa | axum(我们在项目:用 Axum 写 Web API 里做了一个)、actix-web、Rocket | axum 和 actix-web 在生产环境中用得很广 |
| Express 中间件 | tower layer 和 tower-http crate(CORS、压缩、追踪、超时) |
axum 直接使用 tower |
fetch、axios |
reqwest |
默认异步,也可以选用阻塞式客户端。见 网络编程 |
ws、Socket.IO |
axum 里的 WebSocket(ws feature)、tokio-tungstenite,或者 socketioxide(一个 Socket.IO 服务端) |
|
| Apollo Server(GraphQL) | async-graphql |
|
gRPC(@grpc/grpc-js) |
tonic(protobuf 用 prost) |
|
| Swagger / OpenAPI 生成器 | utoipa |
|
jsonwebtoken |
jsonwebtoken |
名字一样,思路也一样 |
bcrypt |
argon2 或 bcrypt |
| JavaScript | Rust | 说明 |
|---|---|---|
| Prisma、TypeORM | SeaORM(异步,支持实体和迁移)或 Diesel(查询在编译时做类型检查) | SeaORM 感觉最接近 Prisma 和 TypeORM |
Knex、pg、postgres.js |
sqlx:你直接写 SQL,query! 宏会在编译时拿你的真实数据库来检查它 |
异步,非常流行,不是 ORM |
better-sqlite3 |
rusqlite,或者配合 SQLite 使用 sqlx |
|
| Mongoose、MongoDB 驱动 | mongodb(官方驱动) |
没有 Mongoose 那样的 schema;你用的是 serde 结构体 |
| ioredis | redis |
数据库这一页用能跑的代码演示了 sqlx、Diesel 和 SeaORM。
日常基础组件
Section titled “日常基础组件”| JavaScript | Rust | 说明 |
|---|---|---|
JSON.parse / JSON.stringify |
serde + serde_json |
事实标准;还支持 TOML、YAML 等格式。见 用 Serde 处理 JSON |
| Zod、Yup | 结构体的类型加上 serde 就能完成大部分工作;“至少 8 个字符”这类规则用 validator 或 garde |
JSON 和结构体对不上时,解析会失败并返回错误 |
| Lodash | 大多用标准库的迭代器就够了;unique、sorted_by、chunk_by 这类额外功能用 itertools |
|
| date-fns、Day.js、Luxon | chrono 或 jiff |
见 日期与时间 |
| dotenv | dotenvy |
见 环境变量与进程 |
| Commander、yargs | clap |
见 项目:用 clap 写命令行工具 |
| chalk | colored 或 owo-colors |
|
| winston、pino | tracing(+ tracing-subscriber),或 log + env_logger |
见 日志 |
uuid |
uuid |
|
EventEmitter |
信道(std::sync::mpsc,有很多监听者时用 tokio::sync::broadcast) |
见 信道(channel) |
| BullMQ(任务队列) | 进程内任务用 tokio::spawn;持久化任务队列用 apalis |
|
| 正则表达式 | regex |
刻意不支持前瞻和反向引用。见 正则表达式 |
前端、桌面与 Node.js
Section titled “前端、桌面与 Node.js”| JavaScript | Rust | 说明 |
|---|---|---|
| 给浏览器里的重计算代码提速 | 用 wasm-bindgen 和 wasm-pack 把 Rust 编译成 WebAssembly |
见 Rust 与 WebAssembly 和下面那一节 |
| React、Vue、Svelte | Leptos、Yew、Dioxus(都编译成 WebAssembly) |
都还没到 1.0;API 仍在变化 |
| Electron | Tauri:你的 Web 前端 + Rust 后端,打出来的应用小得多 |
Tauri 2 已经稳定。见 GUI 应用 |
| 原生 Node.js 插件(C++ 加 node-gyp) | napi + napi-derive(napi-rs),或者 neon |
把预编译好的二进制文件发布到 npm;不需要 C++ |
| Phaser、Three.js 游戏 | Bevy、macroquad |
Bevy 还没到 1.0,每次发布都会有破坏性的 API 变更。见 游戏开发 |
对照看:interface 变成结构体
Section titled “对照看:interface 变成结构体”下面是一个 TypeScript 对象,带一个 interface、一个类似方法的函数,以及一次展开复制:
interface User { name: string; age: number; admin: boolean;}
const greeting = (u: User) => `你好,我是 ${u.name}(${u.age} 岁)`;
const ada: User = { name: "Ada", age: 36, admin: false };const promoted = { ...ada, admin: true };
console.log(greeting(ada));console.log(promoted);在 Rust 里,对象的形状变成一个结构体,函数变成 impl 块里的一个方法:
#[derive(Debug, Clone)]struct User { name: String, age: u32, admin: bool,}
impl User { // `&self` 的意思是“这个方法会读取用户,但不会修改它”。 fn greeting(&self) -> String { format!("你好,我是 {}({} 岁)", self.name, self.age) }}
fn main() { let ada = User { name: "Ada".to_string(), age: 36, admin: false };
// 相当于 JavaScript 里的 { ...ada, admin: true }。 let promoted = User { admin: true, ..ada.clone() };
println!("{}", ada.greeting()); println!("{:?}", promoted);}输出:
你好,我是 Ada(36 岁)User { name: "Ada", age: 36, admin: true }有几处不一样。每个字段都有确切的类型:age 是 u32(一个不能为负的整数),而不是笼统的 number。有了 #[derive(Debug)],你才能用 {:?} 打印整个结构体,就像 console.log(obj)。那为什么要 .clone()?因为展开写法 ..ada 会把 name 字符串从 ada 里移动出来,之后我们就不能再用 ada 了。先克隆一份,ada 就能保持完好。这就是所有权在起作用,你会经常见到它。
对照看:可辨识联合变成枚举
Section titled “对照看:可辨识联合变成枚举”TypeScript 开发者很喜欢可辨识联合:用一个 kind 字段说明当前是哪种形状,再用 switch 收窄类型:
type Shape = | { kind: "circle"; radius: number } | { kind: "rect"; width: number; height: number };
function area(s: Shape): number { switch (s.kind) { case "circle": return Math.PI * s.radius ** 2; case "rect": return s.width * s.height; }}
const shapes: Shape[] = [ { kind: "circle", radius: 1 }, { kind: "rect", width: 2, height: 3 },];for (const s of shapes) console.log(area(s).toFixed(2));Rust 把这个功能直接内置在语言里,而且它是 Rust 最好用的特性之一。每个枚举变体都携带自己的数据,再由 match 把它拆开:
enum Shape { Circle { radius: f64 }, Rect { width: f64, height: f64 },}
fn area(shape: &Shape) -> f64 { match shape { Shape::Circle { radius } => std::f64::consts::PI * radius * radius, Shape::Rect { width, height } => width * height, }}
fn main() { let shapes = vec![ Shape::Circle { radius: 1.0 }, Shape::Rect { width: 2.0, height: 3.0 }, ];
for shape in &shapes { println!("{:.2}", area(shape)); }}输出:
3.146.00不需要 kind 字符串:变体本身就是标签。而且 match 必须覆盖所有变体。如果以后你加了一个 Triangle,每个忘了处理它的 match 都会变成编译错误。这很像 TypeScript 里用 never 做穷尽检查的技巧,只不过它永远开着。更多内容请看 Rust 模式匹配。
对照看:undefined 和 null 变成 Option
Section titled “对照看:undefined 和 null 变成 Option”在 TypeScript 里,可选的值会以 undefined、null 或可选属性的形式出现,你要用 ?. 和 ?? 来处理它们:
interface User { name: string; email?: string;}
function findUser(id: number): User | undefined { if (id === 2) return { name: "Grace" }; return undefined;}
for (const id of [1, 2, 3]) { console.log(`${id}:${findUser(id)?.email?.toUpperCase() ?? "没有邮箱"}`);}在 Rust 里,一个可能缺失的值的类型是 Option<T>。它要么是 Some(value),要么是 None;在处理好 None 的情况之前,你没法把它当普通值来用:
struct User { name: String, email: Option<String>,}
fn find_user(id: u32) -> Option<User> { match id { 2 => Some(User { name: "Grace".to_string(), email: None }), _ => None, }}
// 在返回 Option 的函数里,`?` 的作用就像 `?.`:// 如果值是 None,就在这里停下,返回 None。fn email_domain(id: u32) -> Option<String> { let email = find_user(id)?.email?; let at = email.find('@')?; Some(email[at + 1..].to_string())}
fn main() { for id in [1, 2, 3] { // 相当于 findUser(id)?.email?.toUpperCase() ?? "没有邮箱" let email = find_user(id) .and_then(|user| user.email) .map(|email| email.to_uppercase()) .unwrap_or("没有邮箱".to_string()); println!("{id}:{email}"); }
println!("{:?}", email_domain(1)); println!("{:?}", email_domain(2));
if let Some(user) = find_user(2) { println!("找到了 {}", user.name); }}输出:
2:没有邮箱3:没有邮箱Some("example.com")None找到了 GraceRust 里没有“cannot read properties of undefined”,因为根本没有 undefined 可以读。编译器干脆不让你在 Option<String> 上调用 .to_uppercase()。你也不用再记 null 和 undefined 的区别:只有一个 None。辅助方法可以在 Rust Option 这一页学,简短写法在 if let / while let 这一页。
对照看:try/catch 变成 Result 和 ?
Section titled “对照看:try/catch 变成 Result 和 ?”在 JavaScript 里,函数抛出错误,你在调用栈上层的某个地方把它捕获。函数签名上看不出它可能抛错:
function parseAge(input) { const age = Number(input); if (!Number.isInteger(age)) throw new Error(`“${input}”不是整数`); if (age > 150) throw new Error(`年龄 ${age} 太大了`); return age;}
function totalAge(a, b) { return parseAge(a) + parseAge(b);}
for (const [a, b] of [["36", "41"], ["36", "abc"], ["36", "200"]]) { try { console.log(`总和:${totalAge(a, b)}`); } catch (e) { console.log(`错误:${e.message}`); }}在 Rust 里,可能失败的函数会返回一个 Result:要么是 Ok(value),要么是 Err(error)。? 运算符的意思是“如果这一步失败了,就立刻把错误返回给我的调用者”。它起的作用就是让异常向上冒泡,只不过你在代码里看得见:
fn parse_age(input: &str) -> Result<u32, String> { let age: u32 = input .parse() .map_err(|_| format!("“{input}”不是整数"))?;
if age > 150 { return Err(format!("年龄 {age} 太大了")); } Ok(age)}
fn total_age(a: &str, b: &str) -> Result<u32, String> { // 每个 `?` 都会把错误交给我们的调用者,就像异常那样。 let first = parse_age(a)?; let second = parse_age(b)?; Ok(first + second)}
fn main() { for (a, b) in [("36", "41"), ("36", "abc"), ("36", "200")] { match total_age(a, b) { Ok(total) => println!("总和:{total}"), Err(e) => println!("错误:{e}"), } }}输出:
总和:77错误:“abc”不是整数错误:年龄 200 太大了签名 -> Result<u32, String> 告诉每个调用者“这里可能失败”,编译器也不允许他们视而不见。异步代码也是一样:在 JavaScript 里你会得到一个被拒绝的 Promise,而 Rust 的异步函数会返回一个 Result,你写 .await? 就行。想定义自己的错误类型,请看 自定义错误类型。在应用程序里,anyhow crate 能给你一种接近“捕获任何错误”的体验(见 anyhow 与 thiserror)。
对照看:数组方法变成迭代器
Section titled “对照看:数组方法变成迭代器”你天天在用的数组方法,在 Rust 里都有直接的对应:
const orders = [ { customer: "Ada", total: 120, paid: true }, { customer: "Grace", total: 80, paid: false }, { customer: "Linus", total: 200, paid: true },];
const paidNames = orders.filter((o) => o.paid).map((o) => o.customer);const revenue = orders.filter((o) => o.paid).reduce((sum, o) => sum + o.total, 0);const firstBig = orders.find((o) => o.total > 150);
console.log(paidNames.join(", "));console.log(`收入:${revenue}`);console.log(`第一笔大订单:${firstBig?.customer ?? "没有"}`);console.log(`有没有未付款的?${orders.some((o) => !o.paid)}`);Rust 版本用的是迭代器和闭包。闭包的写法是 |o| o.paid,而不是 (o) => o.paid:
struct Order { customer: String, total: u32, paid: bool,}
fn main() { let orders = vec![ Order { customer: "Ada".to_string(), total: 120, paid: true }, Order { customer: "Grace".to_string(), total: 80, paid: false }, Order { customer: "Linus".to_string(), total: 200, paid: true }, ];
// filter + map,然后 collect() 把结果变成一个 Vec。 let paid_names: Vec<&str> = orders .iter() .filter(|o| o.paid) .map(|o| o.customer.as_str()) .collect();
// 带初始值的 reduce 叫作 fold。 let revenue = orders .iter() .filter(|o| o.paid) .fold(0, |sum, o| sum + o.total);
// find 返回一个 Option,因为可能一个都找不到。 let first_big = orders.iter().find(|o| o.total > 150);
println!("{}", paid_names.join(", ")); println!("收入:{revenue}"); match first_big { Some(o) => println!("第一笔大订单:{}", o.customer), None => println!("第一笔大订单:没有"), } // some() 在这里叫 any()。 println!("有没有未付款的?{}", orders.iter().any(|o| !o.paid));}输出:
Ada, Linus收入:320第一笔大订单:Linus有没有未付款的?true和 JavaScript 有一个很大的区别。在 JS 里,每个数组方法都会立刻构建出一个全新的数组。Rust 的迭代器是惰性的:filter 和 map 自己什么都不做,等你调用 collect()、sum()、fold() 或者写 for 循环时,所有工作才在一趟遍历里完成。中间不会创建任何临时数组。如果只是求和,你也可以写成 .map(|o| o.total).sum::<u32>()。排序方法在 Vec 上(sort、sort_by),而不是在迭代器上;见 排序与查找。
对照看:Promise 变成 future + tokio
Section titled “对照看:Promise 变成 future + tokio”下面是一段异步 JavaScript。注意“正在获取 1”是什么时候打印出来的:
async function fetchUser(id) { console.log(`正在获取 ${id}`); await new Promise((resolve) => setTimeout(resolve, 100)); return `用户 ${id}`;}
const promise = fetchUser(1); // 立刻打印“正在获取 1”console.log("promise 已创建");console.log(await promise);
const [a, b] = await Promise.all([fetchUser(2), fetchUser(3)]);console.log(`${a},${b}`);在 JavaScript 里,调用一个 async 函数会让它立刻开始运行。Rust 不一样:调用 async fn 只会创建一个 future,也就是对一份工作的描述。在你 .await 它之前,什么都不会运行。而且 Rust 没有内置的事件循环,所以你要加一个运行时:tokio(英文)是最流行的那个。
use std::time::Duration;
async fn fetch_user(id: u32) -> String { println!("正在获取 {id}"); tokio::time::sleep(Duration::from_millis(100)).await; format!("用户 {id}")}
#[tokio::main]async fn main() { // 创建 future 并不会启动它。这时什么都还没打印。 let future = fetch_user(1); println!("future 已创建"); println!("{}", future.await);
// 相当于 Promise.all:两个同时运行,并等它们都完成。 let (a, b) = tokio::join!(fetch_user(2), fetch_user(3)); println!("{a},{b}");}输出:
future 已创建正在获取 1用户 1正在获取 2正在获取 3用户 2,用户 3看出顺序了吗?在 JavaScript 里,“正在获取 1”会出现在“promise 已创建”之前。在 Rust 里,它出现在“future 已创建”之后,因为 future 要到 .await 时才开始运行。#[tokio::main] 负责搭好运行时(也就是你的事件循环),并在上面运行 main。tokio::join! 会同时等待两个 future,就像 Promise.all;tokio::spawn 会在后台启动一个任务,这是最接近“调用 async 函数却不 await 它”的写法。async/await 和深入 async 这两课会讲解细节。
在 JavaScript 项目里用 Rust:WebAssembly 和 Node 插件
Section titled “在 JavaScript 项目里用 Rust:WebAssembly 和 Node 插件”开始用 Rust 并不需要重写任何东西。把它混进 JavaScript 项目,常见的有两种方式:
浏览器里的 WebAssembly。 用 #[wasm_bindgen] 标记 Rust 函数,用 wasm-pack 构建,然后像普通 ES 模块一样导入结果。wasm-pack 甚至会帮你生成 .d.ts 文件,让 TypeScript 知道你的函数类型:
use wasm_bindgen::prelude::*;
#[wasm_bindgen]pub fn slugify(title: &str) -> String { title .to_lowercase() .split_whitespace() .collect::<Vec<_>>() .join("-")}import init, { slugify } from "./pkg/my_lib.js";
await init(); // 下载并启动 .wasm 文件console.log(slugify("Hello Rust World")); // "hello-rust-world"Rust 与 WebAssembly 这一课会带你走完整个配置过程,并解释哪些值跨越边界的开销很小。一个小建议:Wasm 适合重计算(图像处理、解析、模拟),不适合那种要调用几千次的小函数,因为 JS 和 Wasm 之间的每次调用都有一点开销。
原生 Node.js 插件。 用 napi-rs(英文;即 napi 和 napi-derive 这两个 crate),你只要给 Rust 函数标上 #[napi],它就变成一个能在 Node 里 require 或 import 的函数。很多流行的 npm 包就是这样发布预编译好的 Rust 二进制文件的,用户根本不需要 Rust 编译器。
JavaScript 开发者容易踩的坑
Section titled “JavaScript 开发者容易踩的坑”从 JavaScript 转过来的人都会遇到同样的几个坎。提前知道会轻松不少。
把值传给函数会移动它。 在 JavaScript 里,把数组传给函数传的是引用,之后你还能继续用这个数组。在 Rust 里,按值传递 Vec 或 String 会把所有权移动进函数,原来的变量就不能再用了:
fn print_all(names: Vec<String>) { for name in names { println!("{name}"); }}
fn main() { let names = vec![String::from("Ada"), String::from("Grace")]; print_all(names); println!("{} names", names.len());}error[E0382]: borrow of moved value: `names` --> src/main.rs:10:26 | 8 | let names = vec![String::from("Ada"), String::from("Grace")]; | ----- move occurs because `names` has type `Vec<String>`, which does not implement the `Copy` trait 9 | print_all(names); | ----- value moved here10 | println!("{} names", names.len()); | ^^^^^ value borrowed here after move编译器说的是:names 的值在第 9 行已经移动进了 print_all,之后又被借用了。通常的解决办法是改成借用:让函数接收 names: &[String],调用时写 print_all(&names)。请先读 Rust 所有权和 Rust 借用;它们讲的就是这条规则,你每天都会遇到它。
数字有类型,而且不能混用。 JavaScript 只有一种 number。在 Rust 里,3 是整数,2.5 是浮点数,不做转换就不能把它们放在一起算:
fn main() { let items = 3; let price = 2.5; let total = items * price; println!("{total}");}error[E0277]: cannot multiply `{integer}` by `{float}` --> src/main.rs:4:23 |4 | let total = items * price; | ^ no implementation for `{integer} * {float}`编译器说的是:不能用整数({integer})去乘浮点数({float})。写成 items as f64 * price,或者一开始就把 items 写成浮点数(3.0)。还要注意,整数除法会丢掉余数:7 / 2 是 3,不是 3.5。见 Rust 数据类型和 Rust 类型转换。
还有几个你会碰到的坑:
- future 是惰性的,而且需要运行时。 就像上面看到的,不
.await就什么都不会发生,而且你得有 tokio(或别的运行时)才能运行异步代码。忘了写.await,你得到的是一条编译器警告,而不是一个在跑的任务。 - 不能用下标访问字符串。
name[0]编译不过,因为 Rust 的String是 UTF-8 编码的,一个字符可能占好几个字节(一个汉字就占 3 个字节)。用name.chars().next()或.chars().nth(i)。另外,尽早弄清String和&str的区别:函数参数通常用&str,结构体字段通常存String。见 Rust 字符串。 - 没有结构化类型,也没有
any。 在 TypeScript 里,任何字段对得上的对象都能当作某个 interface 来用。在 Rust 里,接收User的函数就只接受User。共享的行为用 trait 来表达。形状未知的 JSON 就用serde_json::Value。 - 共享、可变的对象图很难写。 在 JavaScript 里,把一个对象传来传去、让大家都去改它,或者让对象指回它的父对象,都很正常。借用检查器同一时间只允许多个读者或者一个写者,绝不允许两者兼有。那样的写法需要用智能指针(
Rc<RefCell<T>>、Arc<Mutex<T>>),或者更好的办法是换一种设计。做好心理准备,头几周会有点受挫。刚开始觉得别扭很正常,之后会轻松很多。 HashMap没有顺序。 JavaScript 的Map和对象会保持插入顺序,所以代码常常在不知不觉中依赖它。Rust 的HashMap不会。打印前先排序,或者用BTreeMap,或者用indexmapcrate。- 没有异常。 错误就是值,你会经常写
?。学习阶段调用.unwrap()没问题,但它就相当于 Rust 版的 TypeScript!:“相信我,值肯定在”。如果你错了,程序就会 panic。 - 编译时间。 没有
node app.js那样的即时重载。依赖很多 crate 的项目,第一次构建要花点时间;之后的构建是增量的,会快一些。改代码的时候用cargo check,它会跳过代码生成。
JavaScript 开发者的学习路线
Section titled “JavaScript 开发者的学习路线”你不必按顺序读完整个网站。作为 JavaScript 或 TypeScript 开发者,下面这条路线会跳过你已经会的东西,把时间花在 Rust 不一样的地方:
- Rust 快速上手和 Rust 工具与环境配置:安装 Rust,配好 VS Code 和 rust-analyzer,运行
cargo new。 - Rust 变量和 Rust 数据类型:认真读一读,各种数字类型对你来说是新东西。
- Rust 所有权,然后是 Rust 借用,再然后是 Rust slice(切片):对你来说最重要的几页,慢慢来。
- Rust 字符串:
String与&str。 - Rust 结构体和 Rust 枚举,然后是 Rust match 和 Rust 模式匹配:你的 interface 和可辨识联合。
- Rust Option 和 Rust Result,然后是自定义错误类型。
- Rust 闭包和 Rust 迭代器:你的数组方法。
- Rust vector(Vec)、Rust HashMap 和 HashSet 与 BTreeMap。
- Rust trait、Rust 泛型和常用 trait。
- Rust 模块、Cargo 与 crate 和 Rust 测试。
- Rust 生命周期和 Rust 智能指针。
- async/await 和深入 async。
- 用 Serde 处理 JSON,然后动手做 Web API 项目,或者试试 Rust 与 WebAssembly。
两道题都会给你一段 JavaScript 或 TypeScript 代码,请把它移植到 Rust。
1. 用 Map 统计单词
Section titled “1. 用 Map 统计单词”下面这段 JavaScript 统计每个单词出现的次数,然后按出现次数从多到少打印(次数相同的按字母顺序)。请把它移植到 Rust。提示:用一个 HashMap<&str, u32> 和 *counts.entry(word).or_insert(0) += 1。排序时,先把 map 收集成一个由 (word, count) 对组成的 Vec,再用 sort_by。
const text = "the cat and the hat and the bat";
const counts = new Map();for (const word of text.split(" ")) { counts.set(word, (counts.get(word) ?? 0) + 1);}
const sorted = [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));for (const [word, n] of sorted) console.log(`${word}: ${n}`);查看答案
use std::collections::HashMap;
fn main() { let text = "the cat and the hat and the bat";
let mut counts: HashMap<&str, u32> = HashMap::new(); for word in text.split(' ') { *counts.entry(word).or_insert(0) += 1; }
// 相当于 [...counts]:把 map 变成一个由 (word, count) 对组成的列表。 let mut sorted: Vec<(&str, u32)> = counts.into_iter().collect(); // 出现次数多的排前面;次数相同的按字母顺序排。 sorted.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
for (word, n) in sorted { println!("{word}: {n}"); }}输出:
the: 3and: 2bat: 1cat: 1hat: 1entry(word).or_insert(0) 一步就完成了 counts.get(word) ?? 0 和 set 两件事:它给你一个指向计数的可变引用,如果计数还不存在,就先用 0 创建它。这里的排序是必需的,不只是锦上添花:没有它,HashMap 每次运行打印出来的顺序都可能不一样。这段文本特意保留了英文,因为要按字母顺序给单词排序。
2. 移植带默认值的可选设置
Section titled “2. 移植带默认值的可选设置”下面这个 TypeScript 函数接收一个所有字段都是可选的选项对象,并用 ?? 填上默认值。请把它移植到 Rust。提示:写一个字段都是 Option 的结构体,再加上 #[derive(Default)]。这样 ServerOptions { port: Some(3000), ..Default::default() } 就相当于传入 { port: 3000 }。bool 的默认值是 false。
interface ServerOptions { host?: string; port?: number; verbose?: boolean;}
function describe(opts: ServerOptions): string { const host = opts.host ?? "localhost"; const port = opts.port ?? 8080; const mode = opts.verbose ? "详细" : "安静"; return `${host}:${port}(${mode}模式)`;}
console.log(describe({}));console.log(describe({ port: 3000 }));console.log(describe({ host: "example.com", verbose: true }));查看答案
#[derive(Default)]struct ServerOptions { host: Option<String>, port: Option<u16>, verbose: bool,}
fn describe(opts: &ServerOptions) -> String { let host = opts.host.as_deref().unwrap_or("localhost"); let port = opts.port.unwrap_or(8080); let mode = if opts.verbose { "详细" } else { "安静" }; format!("{host}:{port}({mode}模式)")}
fn main() { println!("{}", describe(&ServerOptions::default())); println!("{}", describe(&ServerOptions { port: Some(3000), ..Default::default() })); println!( "{}", describe(&ServerOptions { host: Some("example.com".to_string()), verbose: true, ..Default::default() }) );}输出:
localhost:8080(安静模式)localhost:3000(安静模式)example.com:8080(详细模式)unwrap_or 就是你的 ??。as_deref() 把 Option<String> 变成 Option<&str>,这样它就能退回到普通文本 "localhost",而不用复制任何东西。..Default::default() 是 Rust 里“我没提到的字段都用默认值”的说法,正好对应 JavaScript 里省略某个属性的效果。端口用 u16,还意味着 70000 这样的端口根本不可能被误存进去。
- JavaScript 和 TypeScript 的很多东西都能直接带过来:闭包、async/await、解构,以及
map、filter、find这类数组风格的方法,在 Rust 里都有很接近的对应物。 - 最大的转变是:用所有权代替垃圾回收器,用
Option代替undefined和null,用Result加?代替throw和try/catch。 - TypeScript 的 interface 变成结构体(表示数据)或 trait(表示行为),可辨识联合变成配合
match使用的枚举。 - Rust 的 future 是惰性的,需要 tokio 这样的运行时;
tokio::join!扮演Promise.all的角色。 - Cargo 取代了 npm、
package.json和你的那些脚本;clippy 和 rustfmt 取代了 ESLint 和 Prettier;测试是内置的。 - 想找 Express 就看 axum 或 actix-web,想找
fetch就看 reqwest,想找 Prisma 就看 SeaORM 或 Diesel,想找 Electron 就看 Tauri。你也可以通过 WebAssembly 或 napi-rs 把 Rust 带进 JS 项目。
小测验
1. Rust 用什么来代替 undefined 和 null?
2. 在 JavaScript 里,调用 async 函数会让它立刻开始运行。在 Rust 里,调用一个 async fn 却不 .await,会怎样?
3. 你的 TypeScript 代码用了一个带 kind 字段的可辨识联合。Rust 里最接近的特性是什么?
4. 你们团队的后端用的是 Express。哪个 Rust crate 最接近?
得分: 0 / 4
- 《Rust 程序设计语言》简体中文版:官方教程。对 JavaScript 开发者来说,第 4 章(所有权)、第 6 章(枚举和模式匹配)和第 9 章(错误处理)最重要。
- Tokio 教程(英文):用 tokio 写异步 Rust 的官方指南,从第一个任务讲到信道和 I/O。
- The wasm-bindgen Guide(英文):Rust 和 JavaScript 在 WebAssembly 里如何互相通信,附有大量示例。
- 从 Rust 编译为 WebAssembly(MDN):MDN Web 文档里的分步教程,还讲了怎么发布到 npm。
- napi-rs(英文):用 Rust 写 Node.js 插件的文档。