圖片來源:https://rustwasm.github.io/本文作者:劉家隆
寫在前邊
本文希望通過 Rust 敲一敲 WebAssembly 的大門。作為一篇入門文章,期望能夠幫你瞭解 WebAssembly 以及構建一個簡單的 WebAssembly 應用。在不考慮IE的情況,目前大部分主流的瀏覽器已經支援 WebAssembly,尤其在移動端,主流的UC、X5核心、Safari等都已支援。讀完本文,希望能夠幫助你將 WebAssembly 應用在生產環境中。
WebAssembly(wasm) 簡介
如果你真的瞭解了 WebAssembly, 可以跳過這一節。可以先看兩個 wasm 比較經典的 demo:
快速總結一下: WebAssembly(wasm) 是一個可移植、體積小、載入快並且相容 Web 的全新格式,由 w3c 制定出的新的規範。目的是在一些場景下能夠代替 JS 取得更接近原生的運算體驗,比如遊戲、圖片/視訊編輯、AR/VR。說人話,就是可以體積更小、執行更快。
wasm 有兩種表示格式,文字格式和二進位制格式。二進位制格式可以在瀏覽器的 js 虛擬機器中沙箱化執行,也可以執行在其他非瀏覽器環境中,比如常見的 node 環境中等;執行在 Web 上是 wasm 一開始設計的初衷,所以實現在瀏覽器上的執行方法非常簡單。
通過一個簡單的例子實現快速編譯 wasm 文字,執行一個 wasm 二進位制檔案:
wasm 文字格式程式碼:
(module
(import "js" "import1" (func $i1)) // 從 js 環境中匯入方法1
(import "js" "import2" (func $i2)) // 從 js 環境中匯入方法2
(func $main (call $i1)) // 呼叫方法1
(start $main)
(func (export "f") (call $i2)) // 將自己內部的方法 f 匯出,提供給 js,當 js 呼叫,則會執行方法2
)
上述內容看個大概即可,參閱程式碼中註釋大致瞭解主要功能語法即可。主要功能就是從 js 環境中匯入兩個方法 import1
和 import2
; 同時自身定義一個方法 f
並匯出提供給外部呼叫,方法體中執行了 import2
。
文字格式本身無法在瀏覽器中被執行,必須編譯為二進位制格式。可以通過 wabt 將文字格式編譯為二進位制,注意文字格式本身不支援註釋的寫法,編譯的時候需要將其去除。這裡使用 wat2wasm 線上工具快速編譯,將編譯結果下載就是執行需要的 wasm 二進位制檔案。
有了二進位制檔案,剩下的就是在瀏覽器中進行呼叫執行。
// 定義 importObj 物件賦給 wasm 呼叫
var importObj = {js: {
import1: () => console.log("hello,"), // 對應 wasm 的方法1
import2: () => console.log("world!") // 對應 wams 的方法2
}};
// demo.wasm 檔案就是剛剛下載的二進位制檔案
fetch('demo.wasm').then(response =>
response.arrayBuffer() // wasm 的記憶體 buffer
).then(buffer =>
/**
* 例項化,返回一個例項 WASM.module 和一個 WASM.instance,
* module 是一個無狀態的 帶有 Ast.module 佔位的物件;
* 其中instance就是將 module 和 ES 相關標準融合,可以最終在 JS 環境中呼叫匯出的方法
*/
WebAssembly.instantiate(buffer, importObj)
).then(({module, instance}) =>
instance.exports.f() // 執行 wasm 中的方法 f
);
大概簡述一下功能執行流程:
- 在 js 中定義一個
importObj
物件,傳遞給 wasm 環境,提供方法import1
import2
被 wasm 引用; - 通過 fetch 獲取二進位制檔案流並獲取到記憶體 buffer;
- 通過瀏覽器全域性物件 WebAssembly 從記憶體 buffer 中進行例項化,即
WebAssembly.instantiate(buffer, importObj)
,此時會執行 wasm 的main
方法,從而會呼叫import1
,控制檯輸出 hello; - 例項化之後返回 wasm 例項,通過此例項可以呼叫 wasm 內的方法,從而實現了雙向連線,執行
instance.exports.f()
會呼叫 wasm 中的方法f
,f
會再呼叫 js 環境中的import2
,控制檯輸出 world。
細品這段實現,是不是就可以達到 wasm 內呼叫 js,從而間接實現在 wasm 環境中執行瀏覽器相關操作呢?這個下文再展開。
通過直接編寫文字格式實現 wasm 顯然不是我們想要的,那麼有沒有“說人話的”實現方式呢,目前支援比較好的主要包括 C、 C++、Rust、 Lua 等。
頗有特點的Rust
如果你瞭解 Rust,這一節也可以跳過了。A language empowering everyone to build reliable and efficient software. ——from rust-lang
Rust 被評為 2019 最受歡迎的語言。
截圖自 https://insights.stackoverflo...
Rust 正式誕生於 15 年,距今僅僅不到五年的時間,但是目前已覆蓋各大公司,國外有 Amazon、Google、Facebook、Dropbox 等巨頭,國內有阿里巴巴、今日頭條、知乎、Bilibili 等公司。那是什麼讓如此年輕的語言成長這麼快?
- Rust 關注安全、併發與效能,為了達成這一目標,Rust 語言遵循記憶體安全、零成本抽象和實用性三大設計哲學
- 藉助 LLVM 實現跨平臺執行。
- Rust 沒有執行時 gc,並且大部分情況不用擔心記憶體洩漏的問題。
- ...
你內心 OS 學不動了?別急,先簡單領略一下 Rust 的魅力,或許你會被他迷住。
下邊看似很簡單的問題,你能否答對?一共三行程式碼,語法本身沒有問題,猜列印的結果是啥?
fn main() {
let s1 = String::from("hello word"); // 定義一個字串物件
let s2 = s1; // 賦值
println!("{}", s1); // log輸出
}
<details>
<summary>思考一會 點選檢視答案</summary>
報錯!變數 s1 不存在了。
</details>
這其實是 Rust 中一個比較重要的特性——所有權。當將 s1
賦值給 s2
之後,s1
的所有權便不存在了,可以理解為 s1
已經被銷燬。通過這種特性,實現記憶體的管理被前置,程式碼編寫過程中實現記憶體的控制,同時,藉助靜態檢查,可以保證大部分編譯正確的程式可以正常執行,提高記憶體安全之外,也提高了程式的健壯性,提高開發人員的掌控能力。
所有權只是 Rust 的眾多特性之一,圍繞自身的三大哲學(安全、併發與效能)其有很多優秀的思想,也預示著其上手成本還是比較高的,感興趣的可以深入瞭解一下。之前 Rust 成立過 CLI、網路、WASM、嵌入式四大工作組,預示著 Rust 希望發力的四大方向。截止目前已經在很多領域有比較完善的實現,例如在服務端方向有 actix-web、web 前端方向有 yew、wasm 方面有 wasm-pack 等。總之,Rust 是一門可以拓寬能力邊界的非常有意思的語言,儘管入門陡峭,也建議去了解一下,或許你會深深的愛上它。
除 wasm 外的其他方向(cli、server等),筆者還是喜歡 go,因為簡單,^_^逃...
行了,扯了這麼多,Rust 為何適合 wasm:
- 沒有執行時 GC,不需要 JIT,可以保證效能
- 沒有垃圾回收程式碼,通過程式碼優化可以保證 wasm 的體積更小
- 支援力度高(官方介入),目前而言相比其他語言生態完善,保證開發的低成本
Rust -> wasm
Rust編譯目標
rustc 本身是一個跨平臺的編譯器,其編譯的目標有很多,具體可以通過 rustup target list
檢視,和編譯 wasm 相關的主要有三個:
- wasm32-wasi:主要是用來實現跨平臺,通過 wasm 執行時實行跨平臺模組通用,無特殊 web 屬性
- wasm32-unknown-emscripten:首先需要了解 emscripten,藉助 LLVM 輕鬆支援 rust 編譯。目標產物通過 emscripten 提供標準庫支援,保證目標產物可以完整執行,從而實現一個獨立跨平臺應用。
- wasm32-unknown-unknown:主角出場,實現 rust 到 wasm 的純粹編譯,不需要藉助龐大的 C 庫,因而產物體積更加小。通過記憶體分配器(wee_alloc)實現堆分配,從而可以使用我們想要的多種資料結構,例如 Map,List 等。利用 wasm-bindgen、web-sys/js-sys 實現與 js、ECMAScript、Web API 的互動。該目標鏈目前也是處於官方維護中。
或許有人對 wasm32-unknown-unknown 的命名感覺有些奇怪,這裡大概解釋一下:wasm32 代表地址寬度為 32 位,後續可能也會有 wasm64 誕生,第一個 unknow 代表可以從任何平臺進行編譯,第二個 unknown 表示可以適配任何平臺。
wasm-pack
以上各個工具鏈看著複雜,官方開發支援的 wasm-pack 工具可以遮蔽這一切細節,基於 wasm32-unknown-unknown 工具鏈可快速實現 Rust -> wasm -> npm 包的編譯打包,從而實現在 web 上的快速呼叫,窺探 wasm-npm 包這頭“大象”只需要如下幾步:
- 使用 rustup 安裝rust
- 安裝 wasm-pack
- wasm-pack new hello-wasm.
- cd hello-wasm
- 執行 wasm-pack build.
- pkg 目錄下產物就是可以被正常呼叫的 node_module 了
一個真例項子看一下 wasm 執行優勢
路指好了,準備出發!接下來可以愉快的利用 rust 編寫 wasm 了,是不是手癢了;下邊通過實現一個 MD5 加密方法來對比一下 wasm 和 js 的執行速度。
首先修改 Cargo.toml,新增依賴包
[dependencies]
wasm-bindgen = "0.2"
md5 = "0.7.0"
Cargo 是 Rust 的包管理器,用於 Rust 包的釋出、下載、編譯等,可以按需索取你需要的包。其中 md5 就是一會要進行 md5 加密的演算法包,wasm-bindgen 是幫助 wasm 和 js 進行互動的工具包,抹平實現細節,方便兩個記憶體空間進行通訊。
編寫實現(src/lib.rs)
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn digest(str: &str) -> String {
let digest = md5::compute(str);
let res = format!("{:x}", digest);
return res;
}
藉助 wasm_bindgen 可以快速將方法匯出給 js 進行呼叫,從而不需要關心記憶體通訊的細節。最終通過 wasm-pack build 構建出包(在目錄 pkg 下),可以直接在 web 進行引用了,產物主要包含以下幾部分
├── package.json
├── README.md
├── *.ts
├── index_bg.wasm:生成 wasm 檔案,被index.js進行呼叫
├── index.js:這個就是最終被 ECMAScript 專案引用的模組檔案,裡邊包含我們定義的方法以及一些自動生成的膠水函式,利用 TextEncoder 實現記憶體之間的資料通訊。
js 呼叫
import * as wasm from "./pkg";
wasm.digest('xxx');
構建出的 wasm pkg 包引入 web 專案中,使用 webpack@4 進行打包編譯,甚至不需要任何其他的外掛便可支援。
速度對比
針對一個大約 22 萬字元長度的字串進行 md5 加密,粗略的速度對比:
加密1次時間(ms) | 加密100次時間(ms) | 演算法依賴包 | |
---|---|---|---|
js版本md5 | ~57 | ~1300 | https://www.npmjs.com/package... |
wasm版本md5 | ~5 | ~150 | https://crates.io/crates/md5 |
從資料層面來看,wasm 的效能優勢顯而易見。但同時也發現在 100 次的時候,效能資料差值雖然擴大,但是比值卻相比一次加密縮小。原因是在多次加密的時候,js 和 wasm 的通訊成本的佔比逐漸增高,導致加密時間沒有按比例增長,也說明 wasm 實際加密運算的時間比結果更小。這其實也表明了了 wasm 在 web 上的應用場景:重計算、輕互動,例如音視訊/影像處理、遊戲、加密。但在將來,這也會得到相應的改善,藉助 interface-type 可實現更高效的值傳遞,未來的前端框架或許會真正迎來一場變革。
利用 wasm 實現一個完整 Web 應用
藉助 wasm-bindgen
,js-sys
和web-sys
crates,我們甚至可以極小的依賴 js,完成一個完整的 web 應用。以下是一個本地彩色 png 圖片轉換為黑白圖片的 web-wasm 應用。
效果圖:
線上體驗:點我
大致功能是通過 js 讀取檔案,利用 wasm 進行圖片黑白處理,通過 wasm 直接建立 dom 並進行圖片渲染。
1. 利用 js 實現一個簡單的檔案讀取:
// html
<div>
<input type="file" id="files" style="display: none" onchange="fileImport();">
<input type="button" id="fileImport" value="選擇一張彩色的png圖片">
</div>
// js
$("#fileImport").click(function () {
$("#files").click();
})
window.fileImport = function() {
//獲取讀取我檔案的 File 物件
var selectedFile = document.getElementById('files').files[0];
var reader = new FileReader(); // 這是核心, 讀取操作就是由它完成.
reader.readAsArrayBuffer(selectedFile); // 讀取檔案的內容,也可以讀取檔案的URL
reader.onload = function () {
var uint8Array = new Uint8Array(this.result);
wasm.grayscale(uint8Array);
}
}
這裡獲取到的檔案是一個 js 物件,最終拿到的檔案資訊需要藉助記憶體傳遞給 wasm , 而檔案物件無法直接傳遞給 wasm 空間。我們可以通過 FileReader 將圖片檔案轉換為一個 8 位無符號的陣列來實現資料的傳遞。到此,js 空間內的使命完成了,最後只需要呼叫 wasm.grayscale
方法,將資料傳遞給 wasm 即可。
2. wasm 獲取資料並重組
fn load_image_from_array(_array: &[u8]) -> DynamicImage {
let img = match image::load_from_memory_with_format(_array, ImageFormat::Png) {
Ok(img) => img,
Err(error) => {
panic!("{:?}", error)
}
};
return img;
}
#[wasm_bindgen]
pub fn grayscale(_array: &[u8]) -> Result<(), JsValue> {
let mut img = load_image_from_array(_array);
img = img.grayscale();
let base64_str = get_image_as_base64(img);
return append_img(base64_str);
}
wasm 空間拿到傳遞過來的陣列,需要重組為圖片檔案物件,利用現成的輪子 image crate 可以快速實現從一個無符號陣列轉換為一個圖片物件(load_image_from_array
),並進行影像的黑白處理(img.grayscale()
)。處理過後的物件需要最終再返回瀏覽器 <img />
標籤可識別的內容資訊,提供給前端進行預覽,這裡選擇 base64 字串。
3. wasm 內生成 base64 圖片格式
fn get_image_as_base64(_img: DynamicImage) -> String {
// 建立一個記憶體空間
let mut c = Cursor::new(Vec::new());
match _img.write_to(&mut c, ImageFormat::Png) {
Ok(c) => c,
Err(error) => {
panic!(
"There was a problem writing the resulting buffer: {:?}",
error
)
}
};
c.seek(SeekFrom::Start(0)).unwrap();
let mut out = Vec::new();
// 從記憶體讀取資料
c.read_to_end(&mut out).unwrap();
// 解碼
let stt = encode(&mut out);
let together = format!("{}{}", "data:image/png;base64,", stt);
return together;
}
在 wasm 空間內將 DynamicImage 物件再轉換為一個基礎值,從而再次實現值得傳遞;藉助 Rust Cursor,對 DynamicImage 物件資訊進行讀寫,Rust Cursor 有點類似前端的 Reader/Writer,通過一個快取區實現資訊讀寫,從而拿到記憶體空間內的圖片儲存資訊,獲得的資訊經過 base64 解碼即可拿到原始字串資訊,拿到的字串拼接格式資訊 data:image/png;base64
組成完整的圖片資源字元創,便可以直接返回給前端進行預覽渲染了。
以上已經完成了圖片處理的所有流程了,獲取到的 base64 可以直接交還給 js 進行建立 dom 預覽了。但是!我有沒有可能不使用 js 進行操作,在 wasm 內直接完成這步操作呢?
4. wasm 內建立 dom 並渲染圖片
wasm 本身並不能直接操作 dom,必須經過 js 完成 dom 的操作。但是依然可以實現在 wasm 內載入 js 模組間接操作 dom。web_sys 便實現了這步操作,並基本完成所有的介面實現,藉助 web_sys 甚至可以很方便的實現一個純 wasm 的前端框架,比如 yew。
圖片引自:https://hacks.mozilla.org/201...
pub fn append_img(image_src: String) -> Result<(), JsValue> {
let window = web_sys::window().expect("no global `window` exists");
let document = window.document().expect("should have a document on window");
let body = document.body().expect("document should have a body");
let val = document.create_element("img")?;
val.set_attribute("src", &image_src)?;
val.set_attribute("style", "height: 200px")?;
body.append_child(&val)?;
Ok(())
}
操作的流程和直接使用 js 操作 dom 基本一致,其實也都是間接呼叫了 js 端方法。在實際應用中,還是要儘量避免多次的通訊帶來額外的效能損耗。
一個簡單的圖片黑白處理應用完成了,完整的程式碼:點我。其他的功能可以按照類似的方式進行擴充,比如壓縮、裁剪等。
寫在最後
本文簡述了從 Rust 到 wasm,再到 web based wasm 的流程。希望讀完本文,能夠幫你在實際業務開發中開拓解決問題的思路,探索出更多更實用的場景。由於作者水平有限,歡迎批評指正。
資料參考
https://rustwasm.github.io/wa...
https://github.com/WebAssembl...
https://hacks.mozilla.org/201...
本文釋出自 網易雲音樂前端團隊,可自由轉載,轉載請在標題標明轉載並在顯著位置保留出處。我們一直在招人,如果你恰好準備換工作,又恰好喜歡雲音樂,那就 加入我們!