Turbopack 很火? 那么就从前端角度看 Rust
关于 Rust
rust 是一门强类型的、编译型的、内存安全的编程语言。最早版本的 Rust 原本是 Mozilla 基金会的一名叫 Graydon Hoare 的员工的私人项目,2009 年开始,Mozilla 开始赞助者们项目的发展,并于 2010 年,Rust 实现了自举 —— 使用 Rust 构建了 Rust 的编译器。
Mozilla 将 Rust 应用到构建新一代浏览器排版引擎 Servo 当中 ——Servo 的 CSS 引擎在 2017 年开始,集成到了 FireFox 当中去。
在过去的十年中,编程语言 Rust 一直是一项突破性的技术,Rust 始终站在独特的学术研究和行业实用性结合的挑战视角。但是如果说 Rust 的影响仅仅是技术性的,那就错过了精髓,正如社区在 2016 年讨论的系列博客一样:"Rust 不仅是编程语言或编译器"和"Rust 让一切触手可及",同年 Rust 宣布了其官方口号:“一种让每个人都能够构建可靠和高效软件的语言”。
2021 年 2 月 8 日,Rust 基金会宣布成立,其基金会董事成员有:AWS、Google、华为、微软、Mozilla 。Rust 基金会诞生自 Rust 核心团队,并且得到了五位全球行业领先公司的财务承诺,这标志着 Rust 向成熟化迈出了坚实的一步。
Rust 原本作为一种内存安全的语言,其初衷是代替 C++ 或者 C,来构建大型的底层项目,如操作系统、浏览器等,但是因为 Mozilla 的这一层关系,前端业界也注意到了这门语言,并将它应用在了其他领域,其生态也慢慢繁荣起来。
内存安全 ——Rust 的一大杀手锏
众所周知,当下主流的编程语言当中一般分为两类,一类是自动 GC 的,如 Golang、Java、JavaScript 等,另一类则是 C++ 和 C,用户需要手动管理内存。
大部分语言的内存模型都是大同小异的,但通常有以下两种方式:
开发者自己分配和销毁:比如 C、C++ 等,这种方式相当于把所有权力开放给开发者,管理不当容易内存泄漏。 编程语言提供自动垃圾回收机制:比如 JavaScript、Java、Python 等,这种方式会产生运行时开销,对性能可能产生影响(注意这里是“可能”,没有办法证明性能一定比开发者自己管理要差)。
Rust 则另辟蹊径采用所有权、借用、生命周期机制在编译期自动插入内存释放逻辑来实现内存管理,由于没有了垃圾回收产生的运行时开销,Rust 整体表现的速度惊人且内存利用率极高。
fn main() {
let a = String::from("hello rust");
let b = a; // 所有权被转移
println!("{}", a); // 编译失败!a 已经被释放,无法再使用
}
当代码被执行时,一个个变量所对应的值,就被依次入栈,当代码执行完某一个作用域时,变量对应的值也就跟着出栈,栈作为一个先进后出的结构非常符合编程语言的作用域 —— 最外层的作用域先声明、后结束。但是栈无法在中间插入值,因此栈当中只能存储一旦声明、占用空间就不会改变的值,比如 int、char,或者是固定长度的数组,而其他值,比如可变长度的数组 vector,可变长度的字符串 String,是无法被塞进栈当中的。
当编程语言需要一个预先不知道多大的空间时,就会向操作系统申请,操作系统开辟一块空间,并将这一块空间的内存地址 —— 指针返回给程序,于是编程语言就成功将这些数据存到了堆中,并将指针存到栈当中去 —— 因为指针的大小是固定的,32 位程序的指针一定是 32bit,64 位程序的指针也肯定是 64bit。
栈中的数据是不需要做内存管理的,随着代码执行,一个变量很容易被判断还有没有用 —— 只要这个变量的作用域结束,那么再也无法读取到这个变量的值,那么这个变量肯定没用了。只需要随着作用域的声明与结束,不断的入栈和出栈就足以管理栈的内存了,不需要程序员操心。
但是堆当中的数据就不行了,因为程序拿到的只是一个内存指针,实际的内存块不在栈当中,无法随着栈自动销毁。程序也不能在栈当中的内存指针变量销毁时,就将指针对应的空间自动清理 —— 因为可能有多个变量保存的指针都指向了同一个内存块,此时清理这个内存块,会导致意料之外的情况。
基于此,有的程序自带一套非常复杂的 GC 算法,比如通过引用计数,统计一个内存区块的指针到底保存在多少个变量当中,当引用计数归 0 时,就代表所有的指向此处的指针都被销毁了,此处内存块就可以被清理。而有的程序则需要手动管理内存空间,任何堆当中开辟的空间,都必须手动清理。
这两种办法各有优劣,前者导致程序必须带一个 runtime,runtime 当中存放 GC 算法,导致程序体积变大,而后者,则变得内存不安全,或者说,由于内存管理的责任到了程序员头上,程序员的水平极大程度上影响了代码安全性,忘记回收会导致程序占用的内存越来越大,回收错误会导致删掉不应该删的数据,除此以外还有通过指针修改数据的时候溢出到其他区块导致修改了不应修改的数据等等。
而 Rust 则采取了一种全新的内存管理方式。这个方式可以简单概括为:程序员和编译器达成某一种约定,程序员必须按照这个约定来写代码,而当程序员按照这个约定来写代码时,那么一个内存区块是否还在被使用,就变得非常清晰,清晰到不需要程序跑起来,就可以在编译阶段知道,那么编译器就可以将内存回收的代码,插入到代码的特定位置,来实现内存回收。换句话说,Rust 本质上是通过限制引用的使用,将那些【不好判断某块地址是否还在使用】的情况给规避了,剩余的情况,都是很好判断的情况,简单到不需要专业的程序员,只需要一个编译器,就能很好的判断了。
这样的一大好处是:
不需要 GC 算法和 runtime,本质上还是手动回收,只不过编译器把手动回收的代码插入进去了,程序员不需要自己写而已。只要编译可以通过,那么就一定是内存安全的。
在一项比较 REST API 性能的基准测试中(Rust 使用 Rocket,Node.js 使用 Restify),Rust 每秒处理 72,000 个请求,而 Node.js 为 8,000 个,空闲时使用的内存大约为 1MB,而 Node.js 为 19MB。在另一个测试中(Rust 使用 Nickel,Node.js 使用 Restana),Rust 对请求的平均响应速度比 Node.js 快近 100 倍。
具体数据可以参考 Web Frameworks Benchmark。
(一)实现原理
rust 的内存安全机制可以说是独创的,它有一套非常简单、便于理解的机制,叫做所有权系统,这里面会涉及到两个核心概念,所有权和借用。
(二)所有权
任何值,包括指针,都要绑定到一个变量,那么,我们就称这个变量拥有这个值的所有权,比如以下代码,变量 str 就拥有 “hello” 的所有权。
let str = "hello"
当 str 所在的作用域结束时,str 的值就会被清理,str 也不再有效。这个和几乎所有主流语言都是一致的,没有什么问题。也很好理解。
但是注意一下,Rust 本身区分了可变长度的字符串和不可变长度的字符串,上文是一个不可变长度的字符串,因为其长度不可变,可以保存在栈当中,于是下面这一段代码可以正确执行,就像其他几乎所有主流语言一样:
let str = "hello world";
但如果我们引入一个保存在堆里、长度可变的字符串,我们再来看看同样的代码:
fn main() {
此时,我们会惊讶地发现,代码报错了。为什么呢?
原因在于,第一段代码当中,str 这个变量的值,保存在栈里,str 这个变量所拥有的,是 hello world 这一串字符串本身。所以如果令 str2=str,那么相当于又创建了一个 str2 变量,它也拥有这么一串一模一样的字符串,这里发生的是 “内存拷贝”。两个变量各自拥有 hello world 这一个值的所有权,只不过两者的 hello world 不是同一个 hello world。
而第二段代码当中,我们拿到的 str,本质上只是一个指向到某一个内存区块的地址,而这个地址,当我们另 str2=str 的时候,实际上是将这一个地址的值赋值给 str2,如果是在其他语言当中,这么写极大概率是没问题的,但是 str 和 str2 会指向同一个内存地址,修改 str 的时候,str2 也变了。但是 rust 当中,同一个值只能被绑定到一个同一个变量,或者说,某一个变量对这一个值有所有权,就像一个东西同一时间只能属于同一个人一样!当令 str2=str 的时候 str 保存的地址值,就不再属于 str 了,它属于 str2,这叫做【所有权转移】。所以 str 失效了,我们使用一个失效的值,那么自然报错了。
以下这些情况都能导致所有权转移:
上文提到的赋值操作:
let str = String::from ("hello world"); let str2=str; //str 失去所有权!
将一个值传进另一个作用域,比如函数:
let str=String::from ("hello world"); some_func (str); // 此时 str 失效。
这样,我们就可以很简单的发现,对于同一个内存区块地址,它同时只能保存在一个变量里,这个变量如果出了作用域,导致这个变量读取不到了,那么这个内存地址就注定永远无法访问了,那么,这个内存区块,就可以被释放了。这个判断过程非常简单,完全可以放在静态检查阶段让编译器来实现。所以 rust 可以很简单的实现内存安全。
但,上述的写法是很反人类的,这确实解决了内存安全的问题,但是不好用。比如我需要将 str 传入一个方法做一些逻辑操作,做完操作之后我还希望我能读取到这个 str,比如类似于下面这段代码:
fn main() {
我们希望对 str 进行操作,后面添加三个感叹号然后打印出来,这段代码肯定是错误的,因为当 str 传入 add_str 方法时,就将所有权转移到了 add_str 方法内的变量 str_1 上,它不再具备所有权,所以就不能使用了,这种情况其实很常见,单纯的所有权机制让这个问题复杂化了,所以 rust 还有一个机制来解决下面的问题:【引用和借用】。
借用
虽然一个值只能有一个变量拥有其所有权,但是,就像人可以把自己的东西借给其他人用,借给不同的人用一样,变量也可以把自己拥有的值给借出去,上述代码稍作修改:
fn main() {
add_str 传入的不再是 mut str,而是 & mut str1,这就相当于从 mut str1 上借了这份数据来使用,但实际上的所有权仍在 str1 上,内存区块的回收条件,仍然是【str1 所在的作用域执行完毕,str1 保存的内存地址北出栈而销毁】。
这两种机制,所形成的本质是:对于一块内存的引用计数,变得异常简单,只要这个内存地址对应的变量在堆里,引用计数就是 1,否则就是 0,只有这两种情况。绝对不存在,多个变量都指向同一个内存地址的情况,这一下子就把引用计数 GC 算法的复杂度给大幅度降低了。降低到不需要一个复杂的运行时,静态检查阶段就可以得到所有需要 GC 的时机并进行 GC 了。
Rust 的其他特性
rust 是一个非常年轻编程语言,它拥有很多新语言常见的特性,在特性方面很类似于 Golang、ts 和高版本 C++ 的混合。比如说:
没有继承,只有组合,类似于 Golang。继承带来的子类型会带来数学上的不可判定性,即存在一种可能,可以构造出一段包含子类型的代码。无法对它进行类型推倒和类型检查,因为类型不可判定,表现在工程上,那就是编译器在类型推倒时陷入死递归,无法停止。同时,多层的继承也让代码变得难以维护,越来越多的新语言抛弃了继承。
大量高级的语言特性:模式匹配、没有 null 但是有 Option(任何可能报错、返回空指针的地方,都可以返回一个 Option 枚举,基于模式匹配来匹配成功和失败两种情况,null 不再对开发者暴露)、原生的异步编程支持等等。
Rust 在前端构建工具中的应用
高性能工具
在之前,前端领域如果希望做一个高性能的工具,那么唯一选择就是 gyp,使用 C++ 编写代码,通过 gyp 编译成 nodejs 可以调用的 API,saas-loader 等大家耳熟能详的库都是这样实现的。但更多的情况下,前端的大部分工具都是完全不在乎性能,直接用 js 写的,比如 Babel、ESLint、webpack 等等,有很大一部分原因在于 C++ 实在不太好入门,光是几十个版本的 C++ 特性,就足够让人花掉大量的时间来学习,学习完之后还要大量的开发经验才可以学会如何更好的做内存管理、避免内存泄露等问题。而 Rust 不一样,它足够年轻,没有几十个版本的标准、有和 npm 一样现代的包管理器,还有更关键的,不会内存泄露,这使得即便 rust 的历史不长,即便 C++ 也能写 Nodejs 扩展,但前端领域仍然出现了大量的 Rust 写的高性能工具。比如:
swc 一个 Rust 写的,封装出 Nodejs API 的,功能类似 Babel 的 JS polyfill 库,但在 Rust 加持之下,它的性能可以达到 Babel 的 40 倍。 Rome 也是基于 Rust 实现,其作者也是是 Babel 的作者 Sebastian。Rome 涵盖了编译、代码检测、格式化、打包、测试框架等工具。它旨在成为处理 JavaScript 源代码的综合性工具。 RSLint,一个 Rust 写的 JS 代码 lint 工具,旨在替代 ESLint。
随着前端愈发复杂,我们必定会逐渐追求性能更好的工具链,也许过几年我们就会看到使用 swc 和 Rome 正式版的项目跑在生产环境当中了。
Deno
Github((80.9K star):https://github.com/denoland/deno
Deno 是一个简单、现代且安全的 JavaScript 和 TypeScript 运行时,它使用 V8 并基于 Rust 构建。Deno 是由 Node.JS 之父 Ryan Dahl 创建,在 2018 JS Conf Berlin 上借演讲《Design Mistakes in Node》首次对外公开。
Deno 诞生之初就是为了解决 Node 不安全和糟糕包的管理等老生常谈的问题,其中不安全时常令 Node.JS 开发者感到头疼和愤怒,近期也刚刚发生 node-ipc 事件(node-ipc 在所有用户的桌面上都会创建一个文件来宣传作者的政治观点),影响到了众多开源项目包括 Vue CLI 等。
因此 Deno 很自然地拥有以下特性:
默认安全,除非特别启用它,否则使用 Deno 运行的程序没有文件、网络或环境访问权限
deno run --allow-read mod.ts
开箱即用地支持 TypeScript 仅编译单个可执行文件 拥抱 Web 生态标准,内置了 fetch、localStorage、location 等 API
localStorage.setItem("myDemo", "Deno App"); // 拥有 10M 的持久化存储限制
内置依赖检查器 deno info 和代码格式化工具 deno fmt 有一组经过审查的标准模块,可以与 Deno 一起使用:deno.land/std
目前已经有众多公司正在积极探索 Deno,包括 Amazon、Github、IBM、Vercel、Tencent、Microsoft 等头部技术公司。
SWC
Github(21K star):https://github.com/swc-project/swc
SWC 是一个可扩展的基于 Rust 的前端构建工具,目前核心功能相当于 Babel,包含以下这些模块:
模块 状态 作用 官方提供的基准测试数据如下:
name 1 core, sync 4 promises 100 promises
SWC 在单线程上比 Babel 快 20 倍,在四核上快 70 倍。
目前 SWC 已经被 Next.js、Parcel 和 Deno 等工具以及 Vercel、字节跳动、腾讯、Shopify 等公司广泛使用。
Parcel
Github(40k star):https://github.com/parcel-bundler/parcel
支持以 HTML 作为入口的零配置构建工具,Parcel 支持多种开箱即用的语言和文件类型,从 HTML、CSS 和 JavaScript 等 Web 技术到图像、字体、视频等资产。当您使用默认不包含的文件类型时,Parcel 将自动为您安装所有必要的插件和开发依赖项。Parcel 的 JavaScript 编译器和源映射是建立在 SWC 编译器之上的,在 SWC 之上,Parcel 实现了依赖项收集、捆绑、摇树优化、热重载等。
目前 Parcel 已经被广泛应用在微软、Atlassian、SourceGraph 等公司。
Rome
Github(17.2 star):https://github.com/rome/tools
Rome 是 Babel 作者做的基于 Node.js 的前端构建全家桶,包含但不限于 JavaScript、TypeScript、JSON、HTML、Markdown 和 CSS,在 2021 年 9 月 21 日 宣布计划使用 Rust 重构。
其它工具(WIP)
dprint:使用 Rust 编写,比 Prettier 快 30x 倍
postcss-rs:使用 Rust 编写,比 Postcss 快 20x 倍
Rust 在桌面应用开发中的应用
Tauri
Github(34.7 star):https://github.com/tauri-apps/tauri
在很长一段时间里,包括现在,Electron 都是最流行的跨平台桌面应用开发框架,目前在 Github 上有 101K 个 star,它允许你使用纯粹的前端技术(HTML、CSS、JS、Node.JS)来构建桌面应用,不过它也有两个比较明显的缺陷被人诟病:包体积太大和内存占用高,而造成这两个问题的根本原因是 Electron 是基于 Chromium 和 Node.JS 构建的。
Tauri 是 Electron 的代替品,现在 Tauri 试图去除 Chromium 转而使用 Rust 去和系统内置 Webview 进行绑定,简单来说就是在 Electon 时代你的应用永远使用 Chromium 内核,而在 Tauri 时代,你的应用在 Windows 上使用 Edge/Webview2,在 macOS 上使用 WebKit,在 Linux 上使用 WebKitGTK。基于 Rust 和 Webview 的好处很明显:包体积极小且内存占用极低。以下是官方提供的数据:
Detail Tauri Electron
看起来还不错的样子,不过别忘了如果你使用 Tauri 开发的话,后端(Electron 中叫主进程)目前只能使用 Rust,这将带来不小的学习成本,除此之外 Tauri 还有很长的路需要走,不过也算文艺复兴式的创新。
Rust 在 WebAssembly 中的应用
WebAssembly 是一种新的编码方式,具有紧凑的二进制格式,可以在现代的网络浏览器中以接近原生的性能运行,目前 Rust 和 WebAssembly 结合有两大主要用例:
整个 Web 应用都基于 Rust 开发:比如 Yew 等框架 在现存的 JavaScript 前端中使用 Rust
有了 WASM 之后,前端也在寻找一个最完美支持 WASM 的语言,目前来看,也很有可能是 Rust。对于 WASM 来说,带运行时的语言是不可接受的,因为带有运行时的语言,打包成 WASM 之后,不仅包含了我们自己写的业务代码,同时还有运行时的代码,这里面包含了 GC 等逻辑,这大大提高了包体积,并不利于用户体验,将带运行时的语言剔除之后,前端能选择的范围便不大了,C++、Rust 里面,Rust 的优势使得前端界更愿意选择 Rust。同时,Rust 在这方面,也提供了不错的支持,Rust 的官方编译器支持将 Rust 代码编译成 WASM 代码,再加上 wasm-pack 这种开箱即用的工具,使得前端是可以很快的构建 wasm 模块的。
Yew
Github(Github 20k star):https://github.com/yewstack/yew
Yew 是一个设计先进的 Rust 框架,目的是使用 WebAssembly 来创建多线程的前端应用,它有几个特点:
基于组件的框架,可以轻松地创建交互式 UI。拥有 React 或 Elm 等框架经验的开发人员在使用 Yew 时会感到得心应手。 高性能 ,前端开发者可以轻易地将工作分流至后端来减少 DOM API 的调用,从而达到异常出色的性能。(又是一个“文艺复兴式创新”... ) 支持与 JavaScript 交互 ,允许开发者使用 npm 包,并与现有的 JavaScript 应用程序结合。
wasm-bindgen
Github(5.0k star):https://github.com/rustwasm/wasm-bindgen
目前 WebAssembly 类型系统还很小,只有四种数字类型,如果要使用复杂类型(例如字符串、对象、数组、结构体),需要花点心思:
将字符串或对象转换为 WebAssembly 模块可以理解的东西 将 WebAssembly 模块的返回值转换为 JavaScript 可以理解的字符串或对象 但是每次转换它们(序列化为线性内存,并提供它们所在位置的引用)是一项枯燥的工作并且容易出错,幸运的是,Rust world 想出了 wasm-bindgen 来促进 WebAssembly 模块和 JavaScript 之间的高级交互,其使用方式也非常简单:
创建一个 Rust 项目
$ cargo new --lib hello_world Created library `hello_world` package
打开 Cargo.toml文件并添加 wasm-bindgen依赖项
[package]
name = "hello_world"
version = "0.1.0"
authors = ["Sendil Kumar <sendilkumarn@live.com>"]
edition = "2018"
[lib]
crate-type = ["cdylib"]
[dependencies]
wasm-bindgen = "0.2.56"
打开src/lib.rs文件并将内容替换为以下内容
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn hello_world() -> String {
"Hello World".to_string()
}
编译成 wasm 模块
cargo build --target=wasm32-unknown-unknown
安装 wasm-bindgen-cli,并将 wasm 文件转换成 JavaScript 文件
cargo install wasm-bindgen-cli
wasm-bindgen target/wasm32-unknown-unknown/debug/hello_world.wasm --out-dir .
# ls -lrta
# 76330 hello_world_bg.wasm
# 1218 hello_world.js
# 109 hello_world.d.ts
# 190 hello_world_bg.d.ts
然后你就可以使用 hello_world.js 文件了,它可以帮你加载 wasm 文件。
wasm-pack
Github(4.1k star):https://github.com/rustwasm/wasm-pack
这是一个可以直接将你的 Rust 代码打包成 npm 包的工具,用法十分简单,只有 4 个命令:
new:使用模板生成一个新的 Rust Wasm 项目 build: 从 rustwasm crate 生成一个 npm wasm pkg test:运行浏览器测试 pack 和 publish:创建压缩包,发布到镜像仓库 值得注意的是,WebAssembly 目前还并不是提高 Web 应用性能的万金油,就目前来说,在 WebAssembly 中使用 DOM API 仍然比从 JavaScript 中调用要慢。但只是暂时性问题的,WebAssembly Interface Types 计划将解决这个问题。如果你想要了解更多关于这方面的信息,可以查看 Mozilla 的这篇文章 。
有没有啥问题?
虽然我上文说了许多 Rust 的好,但我在学习 Rust 的时候却有些备受打击,很大的一个原因在于,Rust 过于特立独行了。
举一个很简单的例子,在一般的编程语言当中,声明变量和常量,要么有不同的声明方式,如 javascript 区分 let 和 const,go 区分 const 和 var,要么就是声明出来默认是变量,常量需要额外声明,比如 Java 声明的变量前面加 final 就会是常量,而 Rust 就很特殊,声明出来的默认是常量,变量反而需要额外声明,let a=1 得到的是常量,let mut a=1 才是变量。
上述提到的,Rust 比较特别的点非常多,虽然大部分都只是设计理念不同,没有高下优劣之分,但如此设计确实会给其他语言的开发者带来一部分心智负担。
从我的学习经验来看,Rust 本身的学习难度并不低,学习起来实际上未必就比 C++ 简单,社区内也有想学好 Rust 得先学习 C++,不然完全领会不到 Rust 优雅的说法。想学习 Rust 的同学,可能需要做好一些心理准备。
添加小妹微信入群可以获取前端面试,算法资料,不限于截图,每天不定时放松全网精品资料。
如果您不想加上方妹妹的微信,那么请务必关注 下方 「前端面试真题」 公众号,给自己一次进步的机会。只有多刷题,才可以拿到年薪 50w,60w的offer。关注吧,和我一起每天进步,每天刷题。