圖片來源: https://rustwasm.github.io/

本文作者: 劉家隆

寫在前邊

本文希望通過 Rust 敲一敲 WebAssembly 的大門。作爲一篇入門文章,期望能夠幫你瞭解 WebAssembly 以及構建一個簡單的 WebAssembly 應用。在不考慮IE的情況,目前大部分主流的瀏覽器已經支持 WebAssembly,尤其在移動端,主流的UC、X5內核、Safari等都已支持。讀完本文,希望能夠幫助你將 WebAssembly 應用在生產環境中。

WebAssembly(wasm) 簡介

如果你真的瞭解了 WebAssembly, 可以跳過這一節。

可以先看兩個 wasm 比較經典的 demo:

http://webassembly.org.cn/dem...

http://wasm.continuation-labs...

快速總結一下: 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 環境中導入兩個方法 import1import2 ; 同時自身定義一個方法 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 中的方法 ff 會再調用 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 包這頭“大象”只需要如下幾步:

  1. 使用 rustup 安裝rust
  2. 安裝 wasm-pack
  3. wasm-pack new hello-wasm.
  4. cd hello-wasm
  5. 運行 wasm-pack build.
  6. 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-sysweb-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", ℑ_src)?;
    val.set_attribute("style", "height: 200px")?;
    body.append_child(&val)?;
    Ok(())
}

操作的流程和直接使用 js 操作 dom 基本一致,其實也都是間接調用了 js 端方法。在實際應用中,還是要儘量避免多次的通信帶來額外的性能損耗。

一個簡單的圖片黑白處理應用完成了,完整的代碼: 點我 。其他的功能可以按照類似的方式進行拓展,比如壓縮、裁剪等。

寫在最後

本文簡述了從 Rust 到 wasm,再到 web based wasm 的流程。希望讀完本文,能夠幫你在實際業務開發中開拓解決問題的思路,探索出更多更實用的場景。由於作者水平有限,歡迎批評指正。

資料參考

https://rustwasm.github.io/

https://rustwasm.github.io/wa...

https://github.com/WebAssembl...

https://yew.rs/docs/v/zh_cn/

https://hacks.mozilla.org/201...

本文發佈自 網易雲音樂前端團隊 ,可自由轉載,轉載請在標題標明轉載並在顯著位置保留出處。我們一直在招人,如果你恰好準備換工作,又恰好喜歡雲音樂,那就 加入我們

相關文章