跳转到内容

面向 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 正好能做到。

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 上大多数需求都有很多选择;这里列的是流行、维护得好的那些。

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。

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 刻意不支持前瞻和反向引用。见 正则表达式
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 变更。见 游戏开发

下面是一个 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 就能保持完好。这就是所有权在起作用,你会经常见到它。

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.14
6.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 === 1) return { name: "Ada", email: "[email protected]" };
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 {
1 => Some(User { name: "Ada".to_string(), email: Some("[email protected]".to_string()) }),
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
找到了 Grace
▶ 动手试一试

Rust 里没有“cannot read properties of undefined”,因为根本没有 undefined 可以读。编译器干脆不让你在 Option<String> 上调用 .to_uppercase()。你也不用再记 null 和 undefined 的区别:只有一个 None。辅助方法可以在 Rust Option 这一页学,简短写法在 if let / while let 这一页。

在 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)。

你天天在用的数组方法,在 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),而不是在迭代器上;见 排序与查找。

下面是一段异步 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 转过来的人都会遇到同样的几个坎。提前知道会轻松不少。

把值传给函数会移动它。 在 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 here
10 | 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,或者用 indexmap crate。
  • 没有异常。 错误就是值,你会经常写 ?。学习阶段调用 .unwrap() 没问题,但它就相当于 Rust 版的 TypeScript !:“相信我,值肯定在”。如果你错了,程序就会 panic。
  • 编译时间。 没有 node app.js 那样的即时重载。依赖很多 crate 的项目,第一次构建要花点时间;之后的构建是增量的,会快一些。改代码的时候用 cargo check,它会跳过代码生成。

你不必按顺序读完整个网站。作为 JavaScript 或 TypeScript 开发者,下面这条路线会跳过你已经会的东西,把时间花在 Rust 不一样的地方:

  1. Rust 快速上手和 Rust 工具与环境配置:安装 Rust,配好 VS Code 和 rust-analyzer,运行 cargo new。
  2. Rust 变量和 Rust 数据类型:认真读一读,各种数字类型对你来说是新东西。
  3. Rust 所有权,然后是 Rust 借用,再然后是 Rust slice(切片):对你来说最重要的几页,慢慢来。
  4. Rust 字符串:String 与 &str。
  5. Rust 结构体和 Rust 枚举,然后是 Rust match 和 Rust 模式匹配:你的 interface 和可辨识联合。
  6. Rust Option 和 Rust Result,然后是自定义错误类型。
  7. Rust 闭包和 Rust 迭代器:你的数组方法。
  8. Rust vector(Vec)、Rust HashMap 和 HashSet 与 BTreeMap。
  9. Rust trait、Rust 泛型和常用 trait。
  10. Rust 模块、Cargo 与 crate 和 Rust 测试。
  11. Rust 生命周期和 Rust 智能指针。
  12. async/await 和深入 async。
  13. 用 Serde 处理 JSON,然后动手做 Web API 项目,或者试试 Rust 与 WebAssembly。

两道题都会给你一段 JavaScript 或 TypeScript 代码,请把它移植到 Rust。

下面这段 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: 3
and: 2
bat: 1
cat: 1
hat: 1
▶ 动手试一试

entry(word).or_insert(0) 一步就完成了 counts.get(word) ?? 0 和 set 两件事:它给你一个指向计数的可变引用,如果计数还不存在,就先用 0 创建它。这里的排序是必需的,不只是锦上添花:没有它,HashMap 每次运行打印出来的顺序都可能不一样。这段文本特意保留了英文,因为要按字母顺序给单词排序。

下面这个 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