寶貝,帶上WebAssembly,換個姿勢來優(yōu)化你的前端應(yīng)用
前言
說起,「前端性能優(yōu)化」,大家可能第一時間就會從網(wǎng)絡(luò)/資源加載/壓縮資源等角度考慮。
正如下面所展示的一樣。
圖片
圖片
圖片
上面所列的措施,是我們常規(guī)優(yōu)化方案。針對上面的內(nèi)容我們有機會來講講該如何做。
而今天呢,我們和大家嘮嘮利用WebAssembly來優(yōu)化前端渲染鏈路或者針對關(guān)鍵節(jié)點進行調(diào)優(yōu)處理。
我們能所學(xué)到的知識點
- WebAssembly是個啥?
- 項目初始化&配置
- Rust項目初始化
- 處理耗時任務(wù)
- 圖像處理
- 優(yōu)化音視頻
- 優(yōu)化游戲體驗
1. WebAssembly是個啥?
WebAssembly是一種「二進制指令格式」,旨在在瀏覽器中高效執(zhí)行。
- 它「作為JavaScript的補充」,允許我們用Rust、C++和C等語言編寫性能關(guān)鍵代碼,并在瀏覽器中運行。
- 通過將代碼編譯成Wasm,它變得「平臺無關(guān)」,并且可以以接近本地的速度運行。
- Rust是一種以安全性和性能著稱的系統(tǒng)編程語言,由于其強大的保證和與Wasm的無縫集成,已經(jīng)在WebAssembly生態(tài)系統(tǒng)中獲得了廣泛的關(guān)注。WebAssembly為網(wǎng)絡(luò)開發(fā)開辟了新的可能性,在一些復(fù)雜任務(wù)如游戲引擎、圖像處理等方面有著顯著的性能提升。
WebAssembly 的優(yōu)勢
WebAssembly的一個最具說服力的特點是其在「計算密集型任務(wù)」中的性能提升。例如,在對龐大數(shù)據(jù)集進行復(fù)雜的統(tǒng)計計算時,WebAssembly 可能比常規(guī)的 JavaScript 快得多。這是因為 WebAssembly 的高度優(yōu)化設(shè)計使得代碼執(zhí)行速度遠遠超過 JavaScript。
WebAssembly 的另一個優(yōu)點是其「可移植性」??缙脚_應(yīng)用程序的開發(fā)變得非常簡單,因為可以從多種語言生成 WebAssembly 代碼,并在任何平臺上執(zhí)行。
最后,「安全性」也是 WebAssembly 架構(gòu)中的一個重要考慮因素。由于 WebAssembly 提供了沙箱執(zhí)行環(huán)境,代碼無法訪問敏感數(shù)據(jù)或運行惡意代碼。
下面是了解和學(xué)習(xí)WebAssembly的RoadMap。
圖片
2. 項目初始化&配置
進入正題之前,我們還是和之前一樣,使用我們自己的腳手架-f_cli_f[1]構(gòu)建一個以Vite為打包工具的前端項目。
在本地合適的目錄下執(zhí)行如下代碼:
npx f_cli_f create wasm_preformance
然后,我們在pages中新建如下的目錄結(jié)構(gòu):
圖片
其中wasm存放的是我們已經(jīng)構(gòu)建好的wasm的資源。
配置Web Worker
由于我們在項目中會用到Web Worker,所以我們還需要對其做一定的配置。
而今天,我們再介紹另外一種更加優(yōu)雅的方式 - Comlink[2]。
Comlink是一個由Google Chrome Labs開發(fā)的輕量級庫,它旨在簡化Web Worker與主線程之間的通信,讓我們能夠充分利用多線程處理的威力,提升前端應(yīng)用性能。
由于,我們是用Vite搭建的前端項目,所以我們還需要在項目中借助vite-plugin-comlink[3]。
我們可以通過如下代碼安裝對應(yīng)的依賴。
yarn add -D vite-plugin-comlink
yarn add comlink
然后,將對應(yīng)的庫配置到vite.config.js中。
import { comlink } from "vite-plugin-comlink";
export default {
plugins: [comlink()],
worker: {
plugins: () => [comlink()],
},
};
這里有一點需要額外注意,comlink要放置在plugins第一個位置。
針對TypeScript項目,我們還需要在vite-env.d.ts中新增/// <reference types="vite-plugin-comlink/client" />。
然后我們就可以用優(yōu)雅的方式來使用WebWorker了。
圖片
可以看到,使用了comlink后,我們在使用多線程能力時,不需要寫那么多模板代碼,而是通過Promise來接收從子線程返回的數(shù)據(jù)。
配置WebAssembly
如果看過我們之前的文章(Rust 賦能前端 -- 寫一個 File 轉(zhuǎn) Img 的功能)就對這塊不會陌生。
在Vite項目中使用WebAssembly我們需要配置vite-plugin-wasm[4]和vite-plugin-top-level-await[5]
然后,也是需要在vite.config.js的plugin和worker中進行相關(guān)處理。這里就不展開說明了。之前的文章有過解釋。
3. Rust項目初始化
在講項目頁面結(jié)構(gòu)時說過,我們在組件目錄中特意有一個wasm目錄用于存放編譯好的wasm信息。
我們選擇wasm代碼和前端項目分離的方式,也就是我們會重新啟動一個Rust項目。
通過如下代碼在合適的文件目錄下執(zhí)行。
cargo new --lib rust_comformation2web
然后,因為我們想要把Rust編譯成wasm并且還需要操作對應(yīng)的dom等。所以,我們需要按照對應(yīng)的crate。
安裝依賴
所以,我們來更新對應(yīng)的Cargo.toml。
[package]
name = "rust_comformation2web"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
wasm-bindgen = "0.2.92"
console_error_panic_hook = "0.1.7"
js-sys = "0.3.69"
[dependencies.web-sys]
version = "0.3.69"
features = [
'Document',
'TextMetrics',
'CanvasRenderingContext2d',
'HtmlCanvasElement',
'Window'
]
然后,我們就可以在src/lib.rs寫我們對應(yīng)的代碼了。
如果對自己的代碼質(zhì)量不是很放心,并且又不想寫Test模塊了,我們將Rust所在的文件目錄,構(gòu)建成一個Node項目(通過npm init),并配合對應(yīng)的打包軟件(Webpack)來直接驗證wasm的效果。
圖片
對應(yīng)的webpack.config.js的配置如下:
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const webpack = require('webpack');
const WasmPackPlugin = require("@wasm-tool/wasm-pack-plugin");
module.exports = {
entry: './index.js',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'index.js',
},
plugins: [
new HtmlWebpackPlugin({
template: 'index.html'
}),
new WasmPackPlugin({
crateDirectory: path.resolve(__dirname, ".")
}),
// 讓這個示例在不包含`TextEncoder`或`TextDecoder`的Edge瀏覽器中正常工作。
new webpack.ProvidePlugin({
TextDecoder: ['text-encoding', 'TextDecoder'],
TextEncoder: ['text-encoding', 'TextEncoder']
})
],
mode: 'development',
experiments: {
asyncWebAssembly: true
}
};
然后,我們在package.json新增兩個命令
"scripts": {
"build": "webpack",
"serve": "webpack serve"
},
我們就可以通過yarn serve查看效果亦或者yarn build執(zhí)行對應(yīng)的rust打包。
能夠?qū)崿F(xiàn)這一切的功勞都是-@wasm-tool/wasm-pack-plugin[6]所賜予的。
編譯處理
但是呢,我們對Rust編譯處理不使用之前的yarn build,而是使用cargo自己的構(gòu)建工具 - wasm-pack[7]。
wasm-pack build --target web --release
如果一切都正常的話,對應(yīng)的wasm就會被打包到pkg文件夾下面了。
然后,我們就可以將所有文件復(fù)制到Vite項目中的wasm/xx目錄下。
最后,我們就可以在React組件中通過。
import init, { fib } from './wasm/xx';
引入對應(yīng)的wasm函數(shù)了。
前面鋪墊了那么多,其實為了更好的講下面的內(nèi)容,我們先把一些和邏輯代碼不相關(guān)的配置內(nèi)容提前介紹了,這樣我們就可以將更過的注意力放在代碼實現(xiàn)上了。
4. 處理耗時任務(wù)
先說結(jié)果
圖片
當執(zhí)行一個處理耗時任務(wù)時,WebAssembly/JS WebWorker/JS主線程三者的執(zhí)行時間是由低到高排列的。
WebAssembly < JS WebWorker<JS主線程。
針對上面的我們有幾點需要注意:
- JS WebWorker針對JS主線程優(yōu)化率不是很高,(有時候worker執(zhí)行時間甚至比JS主線程長)。
- WebAssembly通過至極的內(nèi)存優(yōu)化,還可以將優(yōu)化率提高到50%以上。
聽我解釋
我們都知道JS是單進程的,所以我們在處理一些處理耗時任務(wù)就會很吃力。當然,我們也可以借助Web Worker來開啟新的子線程來緩解主線程的計算壓力。但是,在一些計算量特別大的功能面前,一切的計算都是收效甚微的。
其實,將一些處理耗時任務(wù)放置到Web Worker中只是不想讓耗時任務(wù)過多的占用主線程資源,從而讓頁面沒有卡頓的感覺。這就是大家所熟悉的瀏覽器在 1 秒鐘內(nèi)完成 60 次圖像的繪制,用戶才會感覺頁面順暢。
為了在前端環(huán)境模擬處理耗時任務(wù),我們采用在前端環(huán)境中執(zhí)行一個fibonacci的計算過程。
在WasmPerformance的index.tsx中有如下的頁面操作。
圖片
也就是說,我們在JS主線程/JS WebWorker/WebAssembly中分別執(zhí)行一個耗時的fibonacci。
我們在tool.ts中構(gòu)建了一個最簡單的fibonacci函數(shù)。
function fibJS(n: number): number {
if (n < 2) {
return n;
}
return fibJS(n - 1) + fibJS(n - 2);
}
對應(yīng)的頁面代碼如下:
圖片
從上面我們看到幾個關(guān)鍵的點:
我們用state來維護計算的結(jié)果和時間。
const [calculateInfo, setCalculateInfo] = useState<CalculateInfo>({
js: { result: 0, executionTime: 0 },
wasm: { result: 0, executionTime: 0 },
webworker: { result: 0, executionTime: 0 },
});
然后,我們在handleCalculate中執(zhí)行不同的操作邏輯。
圖片
其中measureExecutionTime是我們在tool定義的用于檢測指定函數(shù)被執(zhí)行時的所用時間的函數(shù)。
function measureExecutionTime<T extends (...args: any[]) => any>(
fn: T
): (...args: Parameters<T>) => { result: ReturnType<T>; executionTime: number } {
return function (...args: Parameters<T>): { result: ReturnType<T>; executionTime: number } {
const start = performance.now();
const result = fn.apply(this, args);
const end = performance.now();
const executionTime = end - start;
return { result, executionTime };
};
}
還有,我們在handleCalculate在接收到type為3時,是觸發(fā)了一個wasm版本的fibonacci函數(shù)。
由于,對應(yīng)的Rust代碼如下:
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn fib(n: usize) -> usize {
match n {
0 => 0,
1 => 1,
_ => fib(n - 1) + fib(n - 2),
}
}
而上面的Rust代碼會通過wasm-pack build --target web --release進行打包處理,并且打包后的相關(guān)內(nèi)容被復(fù)制到了前端項目中wasm/calculate。
然后在組件中通過import init, { fib } from './wasm/calculate';方式來導(dǎo)入。
5. 圖像處理
先說結(jié)果
圖片
圖片
我們寫了兩個示例:
- 將指定文本信息繪制到圖片上
- 將特定圖形繪制到圖片上
無論是哪種情況,我們可以得出一個比較明顯的情況。
在圖像處理的部分功能點上,WebAssembly的性能遠高于JS。
因為,我們這里沒做WebAssembly的內(nèi)存優(yōu)化,當處理數(shù)據(jù)「超級大」時,由于數(shù)據(jù)傳輸?shù)膯栴},反而WebAssembly的執(zhí)行時間會比JS長。但是呢,這塊不在我們的討論范圍內(nèi)。后期有機會寫相關(guān)的文章。
下面,我們就按照上面的示例來分別講講它們的代碼實現(xiàn)。有些代碼的邏輯其實很簡單,我們已經(jīng)有對應(yīng)的注釋,所以也不會用多余的篇幅解釋。
繪制文本到圖片上
對應(yīng)的頁面結(jié)構(gòu)如下:
圖片
我們還是用了一個state來維護狀態(tài)信息。
const [drawInfo, setDrawInfo] = useState<DrawInfo>({
js: { url: '', executionTime: 0 },
wasm: { url: '', executionTime: 0 },
js_circle: { url: '', executionTime: 0 },
wasm_circle: { url: '', executionTime: 0 },
});
然后在handleDraw中處理事件邏輯。
圖片
其中drawTextToCanvas是利用JS來繪制文本到Canvas,而drawTextToCanvasWasm是利用wasm處理相關(guān)邏輯。
JS 版本的drawText
圖片
該函數(shù)定義在tool.ts中,然后就是接收一個String類型的數(shù)據(jù),并將其渲染到Canvas中。
Rust 版本的drawText
圖片
然后,別忘記在頭部引入對應(yīng)的crate。
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use web_sys::{CanvasRenderingContext2d, HtmlCanvasElement};
extern crate console_error_panic_hook;
use std::panic;
其實這塊的邏輯,和之前我們講的Rust 賦能前端 -- 寫一個 File 轉(zhuǎn) Img 的功能的核心功能是類似的。
該函數(shù)通過wasm-pack編譯到pkg中,然后我們復(fù)制對應(yīng)的文件到React項目的wasm/draw中。
然后我們通過如下代碼:
import init4Draw, {
draw_text_to_canvas as drawTextToCanvasWasm,
draw_circle_to_canvas as drawCircleToCanvasWasm,
} from './wasm/draw';
進行函數(shù)的導(dǎo)入。
繪制圖形到圖片上
對應(yīng)的頁面結(jié)構(gòu)和事件回調(diào)和之前是類似的,我們就省略了這部分的解釋。
JS 版本的drawCircle
該部分也是定義在tool.ts中:
圖片
Rust 版本的drawCircle
此函數(shù)的處理過程和drawText是一樣的。
利用Photon操作圖形
針對圖片操作,不單單只有繪制文本/繪制圖案,其實我們還可以做類似(裁剪/新增水印/圖片翻轉(zhuǎn)等)。
我們可以借助一些成熟的WebAssembly來做上述的操作。這里呢,給大家推薦一個庫Photon[9]。
Photon 是一個高性能的圖像處理庫,用 Rust 編寫并可編譯為 WebAssembly,既可以在本地使用 Web 也可以在 Web 上使用。
這是它能做相關(guān)功能:
圖片
6. 優(yōu)化音視頻
寫到這里呢,我們就不在羅列相關(guān)代碼了。所以,我們給出一些針對音視頻的優(yōu)化的解決方案。
在這里我們介紹一種wasm庫-ffmpeg.wasm[10]。
ffmpeg.wasm 是 FFmpeg[11] 的針對 WebAssembly / JavaScript 端口,支持在瀏覽器中錄制、轉(zhuǎn)換和流式傳輸視頻和音頻。它利用 Emscripten 來轉(zhuǎn)譯 FFmpeg 源代碼和許多庫得到。
具體的功能和庫如下:
圖片
7. 優(yōu)化游戲體驗
得益于WebAssembly極致的內(nèi)存管理,然后其二進制特性,WebAssembly 提供接近本地執(zhí)行速度的性能,使得復(fù)雜的游戲邏輯和高幀率的圖形渲染可以在瀏覽器中高效運行。
還得之前我們寫過Game = Rust + WebAssembly + 瀏覽器。
圖片
還有,如果我們想要更多的效果,我們可以選擇使用bevy[12] - 一款基于Rust的數(shù)據(jù)驅(qū)動的游戲引擎。
然后我們還在itch.io[13]查看哪些游戲是用Rust寫的。
圖片