WebAssembly 和 JavaScript 該怎麼選?

大家好,我是 ConardLi

JavaScript 已經長久以來並且目前依然是瀏覽器運行時的主流開發語言,然而近年來,WebAssembly 的誕生爲我們提供了一個全新的選擇。這就引出了一個值得我們探索的問題:在瀏覽器運行環境中,哪個語言的性能更優越,JavaScript 還是 WebAssembly?

筆者最近在工作中正好面臨了這樣的選擇,我需要在瀏覽器運行時動態插入一些策略,用於在用戶的瀏覽器運行時實現一些安全功能,例如網站請求的 CSRF 防護,網站存儲數據的加解密等等,那麼這種動態的運行時策略到底該使用 JavaScript 還是 WebAssembly 呢,爲了找到答案,我做了一些驗證,本文將詳細對比兩者在各項性能指標上的表現。

基礎概念

測試代碼

JavaScript

我們首先添加一個用於測試密集 CPU 計算的 cycle 函數,其他按照安全策略格式增加 20 個其他的函數(用於測試體積)。

window.StrategySet = {
    cycle: {
        key: 'cycle',
        name: '循環計算測試',
        expression: function (n) {
            let result = 0;
            for (let i = 0; i < n; i++) {
                result += i;
            }
            return result;
        }
    },
    detectTextHttp: {
        key: 'detectTextHttp',
        name: '檢測網頁明文傳輸請求',
        expression: function (event) {
            const { payload } = event;
            if (payload.url.startsWith('http://')) {
                console.log({ action: "REPORT_ONLY", reason: "HTTP REQUEST" }, event);
            } else {
                console.log({ action: "PASS"}, event);
            }
        }
    }
    // 剩餘 20 個函數 ...
}

window.Strategys = StrategyGroup = {
    NETWORK_RESOURCE_REQUEST: ['detectTextHttp'],
    PAGE_INITIALIZED: ['fibonacci'],
    NETWORK_XHR_REQUEST: [],
    API_LOCALSTORAGE_GET: ['cycle'],
    API_CLIPBOARD_READ: [],
}

WebAssembly(Rust)

JS 實現完全一樣的邏輯:添加一個用於測試密集 CPU 計算的 cycle 函數,其他按照相同的格式增加 20 個函數。

#[no_mangle]
pub fn cycle(n: u64) -> u64 {
    let mut result = 0;
    for i in 0..n {
        result += i;
    }
    result
}

struct DetectTextHttp {
    key: &'static str,
    name: &'static str,
    expression: Box<dyn Fn(Event)>,
}

struct Event {
    payload: Payload,
}

struct Payload {
    url: String,
}

impl DetectTextHttp {
    fn new(key: &'static str, name: &'static str, expr: Box<dyn Fn(Event)>) -> DetectTextHttp {
        DetectTextHttp {
            key: key,
            name: name,
            expression: expr,
        }
    }
}

fn main() {
    let detect_text_http = DetectTextHttp::new(
        "detectTextHttp",
        "檢測網頁明文傳輸請求",
        Box::new(|event: Event| {
            if event.payload.url.starts_with("http://") {
                println!("{{ action: \"REPORT_ONLY\", reason: \"HTTP REQUEST\" }}, {:?}, event");
            } else {
                println!("{{ action: \"PASS\" }}, event");
            }
        }),
    );
    
    // 剩餘 20 個檢測規則 ...
}

資源體積

JavaScript

JavaScript 在未經過任何壓縮的情況下,代碼體積爲 1.8KB

WebAssembly(Rust)

Rust 源代碼行數爲 259 行,使用 cargo build --target wasm32-unknown-unknown 打包爲 wasm 代碼,最終網頁中的加載的體積爲 1.7MB

但這個是未經過任何優化和壓縮的代碼,我們使用 Rust 編譯參數對產物的編譯體積進行優化後:

   [profile.release]
   opt-level = 'z'  # 代碼大小最小化
   lto = true       # 啓用鏈接時優化,可以減少代碼體積
   panic = 'abort'  # 拋棄默認的包含堆棧展開的恐慌處理器

最終壓縮後的代碼大小爲 4.6KB:

所以,在同樣的代碼情況下,瀏覽器中可執行的代碼文件體積上 JavaScript 更勝一籌。

代碼初始化

因爲是需要動態執行的策略,代碼需要有一個動態拉取的過程,而不能直接打包在業務代碼內部。

我們先添加一個測試的 HTML

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta >
    <title>WebAssembly 測試</title>
    <!-- 基礎 SDK -->
    <script id="" src="./basic.js"></script>
    <!-- 一個外部 CDN JS -->
    <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
    <!-- 一個內聯的 Script 腳本 -->
    <script>
        // 調用 localStorage API,觸發 localStorage Hook
        localStorage.setItem('name''ConardLi');

        // 調用 fetch API ,觸發 fetch Hook
        fetch('https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js');

    </script>
</head>

<body>
    <img src="https://pic0.sucaisucai.com/11/50/11050520_2.jpg">
</body>

</html>

然後我們在基礎 SDK 中添加一些運行時的 Hook:

function initHook() {
    const observer = new PerformanceObserver((list) ={
        for (const entry of list.getEntries()) {
            log('[Hook] PerformanceObserver:', entry.name, window.Strategys);
        }
    });
    observer.observe({ entryTypes: ['resource'] });

    var originalFetch = window.fetch;
    window.fetch = function () {
        log('[Hook] Fetch:', arguments, window.Strategys);
        return originalFetch.apply(this, arguments);
    };

    var originalSetItem = localStorage.setItem;
    localStorage.setItem = function (key, value) {
        log('[Hook] localStorage.setItem:' + key, window.Strategys);
    };
}

JavaScript

策略拉取邏輯:

async function initStrategy() {
    log('[initStrategy] start download')
    document.write(`
        <script 
            src="https://lf3-static.bytednsdoc.com/obj/eden-cn/kyhuvjeh7pxpozps/snoopy/security-strategy4.js" 
            onload="console.log('動態策略已經加載並執行完畢!', performance.now() - window.time)">
        </script>`
    );
    log('[initStrategy] end download', performance.now() - window.time)
}

可見拉取、解析策略共花費的時間爲 34ms,且後續同步執行的 JavaScript Hook 都可以拿到策略:

WebAssembly(Rust)

策略拉取邏輯(執行 WebAssembly 前還需要進行 ArrayBuffer 的轉換、實例創建等流程,均爲異步動作):

async function initStrategy() {
    log('[initStrategy] start download')
    const response = await fetch('https://lf3-static.bytednsdoc.com/obj/eden-cn/kyhuvjeh7pxpozps/snoopy/security-strategy1.wasm');
    log('[initStrategy] end download', performance.now() - window.time)
    const buffer = await response.arrayBuffer();
    log('[initStrategy] to arrayBuffer', performance.now() - window.time)
    const module = await WebAssembly.instantiate(buffer);
    log('[initStrategy] WebAssembly.instantiate', performance.now() - window.time)
    const cycle = module.instance.exports.cycle;
    window.Strategys = cycle;
}

從開始拉取 WebAssembly 模塊到最終可執行策略共消耗 528ms 。然後使用進行編譯體積優化後的模塊進行測試:

整個過程均爲異步,在這段時間頁面上下載並解析的 JS 還是會繼續執行的,在這期間 Hook 點位上拿不到策略。

長任務測試

爲了讓這段異步下載的過程更加直觀,在業務代碼中模擬一個純 CPU 計算的長任務:

    <script>
        // 模擬一個長任務,用於體現策略拉取的異步動作
        console.log('[業務] start cpu', performance.now() - window.time);
        for (let i = 0; i < 999999999; i++) {
        }
        console.log('[業務] end cpu', performance.now() - window.time);
    </script>

可見 WebAssembly 模塊實例化一定在業務長任務執行完後執行:

JavaScript 則會先解析好策略後再開始執行後續的 Script 邏輯:

代碼執行

JavaScript

測試代碼,調用 cycle 函數:

log('[initStrategy] 策略計算性能測試 init', performance.now() - window.time);

const result = window.StrategySet[window.Strategys['API_LOCALSTORAGE_GET'][0]].expression(999999999);

log('[initStrategy] 策略計算性能測試 end', result, performance.now() - window.time);

WebAssembly(Rust)

測試代碼,調用 cycle 函數:

async function initStrategy() {
    log('[initStrategy] start download')
    const response = await fetch('https://lf3-static.bytednsdoc.com/obj/eden-cn/kyhuvjeh7pxpozps/snoopy/security-strategy1.wasm');
    log('[initStrategy] end download', performance.now() - window.time)
    const buffer = await response.arrayBuffer();
    log('[initStrategy] to arrayBuffer', performance.now() - window.time)
    const module = await WebAssembly.instantiate(buffer);
    log('[initStrategy] WebAssembly.instantiate', performance.now() - window.time)
    const cycle = module.instance.exports.cycle;
    window.Strategys = cycle;
    console.time('策略計算性能測試')
    const result = cycle(999999999n);
    log('[initStrategy] 策略計算性能測試', result, performance.now() - window.time);
    console.timeEnd('策略計算性能測試')
}

最終結論

  • JavaScript:首屏加載快、可同步加載、計算性能差:需要在業務首屏渲染前執行的策略、計算邏輯簡單的策略,優先考慮使用 JavaScript 執行,例如 CSRF 防護、API 調用鑑權等策略。
  • WebAssembly:首屏初始化慢、只能異步加載、計算性能好:可以在業務首屏渲染完成後異步執行的策略,計算邏輯非常複雜、有密集 CPU 計算的策略,考慮使用 WebAssembly 模塊執行,例如需要給業務圖片在前端增加水印,需要對圖片數據進行重寫等策略。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/1VZxzb-SbnPyvruJUBzhQA