實現一個簡單的基於 WebAssembly 的圖片處理應用

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

本文做者:劉家隆html

寫在前邊

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

WebAssembly(wasm) 簡介

若是你真的瞭解了 WebAssembly, 能夠跳過這一節。

能夠先看兩個 wasm 比較經典的 demo:node

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

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

快速總結一下: WebAssembly(wasm) 是一個可移植、體積小、加載快而且兼容 Web 的全新格式,由 w3c 制定出的新的規範。目的是在一些場景下可以代替 JS 取得更接近原生的運算體驗,好比遊戲、圖片/視頻編輯、AR/VR。說人話,就是能夠體積更小、運行更快。github

wasm 有兩種表示格式,文本格式和二進制格式。二進制格式能夠在瀏覽器的 js 虛擬機中沙箱化運行,也能夠運行在其餘非瀏覽器環境中,好比常見的 node 環境中等;運行在 Web 上是 wasm 一開始設計的初衷,因此實如今瀏覽器上的運行方法很是簡單。web

經過一個簡單的例子實現快速編譯 wasm 文本,運行一個 wasm 二進制文件:算法

wasm 文本格式代碼:npm

(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 並導出提供給外部調用,方法體中執行了 import2json

文本格式自己沒法在瀏覽器中被執行,必須編譯爲二進制格式。能夠經過 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", &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/

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

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

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

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

本文發佈自 網易雲音樂前端團隊,可自由轉載,轉載請在標題標明轉載並在顯著位置保留出處。咱們一直在招人,若是你剛好準備換工做,又剛好喜歡雲音樂,那就 加入咱們
相關文章
相關標籤/搜索