WebAssembly(簡稱Wasm),是一個安全、可移植(無硬體和作業系統相依)的低階語言(類似組合語言),由W3C制定標準,並由世界四大瀏覽器Mozilla Firefox、Microsoft Edge、Google Chrome和Apple Safari的提供商共同開發,能用來製作高效能且體積小的程式,通常是應用在Web上,但也不限於此。WebAssembly並不是利用新語法來編譯出優化過的JavaScript語法,而是一個嶄新的、可以在一次編譯後直接在各個主流網頁瀏覽器上執行的程式語言。效能敏感的程式若使用Web­Assembly來開發,可以讓它的效能比原先用JavaScript開發還要好上幾倍,甚至是數十倍。



既然Web­Assembly這麼厲害,那它會取代掉JavaScript嗎?

儘管JavaScript引擎再怎麼強大,JavaScript程式的效能始終無法和C/C++、Rust等程式語言針對CPU架構和作業系統製作出來的原生程式相提並論,因此WebAssembly的出現主要就是為了解決這個問題,讓程式就算在網頁瀏覽器上運行,也還是可以保有接近原生程式的效能。不過WebAssembly也並不是想要取代JavaScript,雖然WebAssembly的程式效能很好、體積也小,但在某些場合下,JavaScript也不見得會比它慢,而且JavaScript的程式碼通常會比WebAssembly原始碼編譯出來的二進制檔還要小很多,不用花費太多下載時間。

現階段在網頁瀏覽器或是Node.js上,WebAssembly編譯好的二進制檔會透過膠水代碼(glue code),以模組的形式載入到JavaScript中,其實就有點像是在Node.js使用C/C++的附加程式(Add-on)啦!因此或許Web­Assembly最終會取代JavaScript,但那大概也是十年後的事了。

Web­Assembly的程式碼

Web­Assembly的程式碼檔案使用的副檔名為.wat

以下面這個JavaScript來舉例:

function addTwo(a, b) {
    return a + b;
}

可以改用Web­Assembly來完成相同的功能,如下:

(module
  (func $addTwo (param i32 i32) (result i32)
    get_local 0
    get_local 1
    i32.add)
  (export "addTwo" (func $addTwo)))

Web­Assembly的程式碼需要經過編譯才可以被網頁瀏覽器或是Node.js使用,編譯出來的二進制檔使用的副檔名為.wasm。這種檔案現階段需要透過JavaScript的膠水代碼來載入到JavaScript中才能被執行,這些膠水代碼是由協助建置Web­Assembly專案的工具自動產生,用來處理JavaScript層和Web­Assembly層的轉換。這個部份會在之後的章節做更詳細的介紹。

上面的addTwo範例可以在這個網頁中實際運行看看,網頁中也提供了其它的範例,都可以開來玩玩看。

我沒有學過組合語言,覺得Web­Assembly的語法好難

對於沒有學過組合語言的人來說,Web­Assembly類似組合語言的語法結構可能會讓他們有些困擾,不知道要從哪裡下手。

其實不會組合語言也沒關係,許多高階程式語言都可以用來開發Web­Assembly的程式,其中就包括C、C++、Rust、Go、JavaScript和Python。而在本系列文章中,主要就會用Rust程式語言來開發Web­Assembly的程式。

Rust + WebAssembly的開發環境

使用Rust程式語言來開發Web­Assembly程式,並不只是單單引用某個或是某幾個crates.io上的套件來用就好了,還需要安裝JavaScript相關的工具,以及用來建置、測試及發佈Web­Assembly程式專案的工具。

Rust程式語言

如果您還不熟悉Rust程式語言,甚至是連Rust開發環境都還沒有的話,請先參考筆者撰寫的《Rust學習之路》系列文章

TypeScript程式語言

雖然不是必要,但本系列的教學會撰寫TypeScript程式來編譯出JavaScript程式。因為TypeScript要比JavaScript容易維護多了,最好儘可能地使用TypeScript。如果您還不熟悉TypeScript程式語言,甚至是連TypeScript開發環境都還沒有的話,請先參考筆者撰寫的《TypeScript學習之路》系列文章

Webpack(Node.js)

本系列的教學還需要用到Webpack來打包Web­Assembly的TypeScript/JavaScript程式,有關於Webpack的開發環境設定和基礎教學,可以參考這兩篇文章:

wasm-pack

wasm-pack是Rust生態圈提供的Web­Assembly開發工具。可以使用以下指令來安裝:

cargo install wasm-pack

cargo-generate

cargo-generate可以快速地從遠端的Git倉庫(repository)中下載Cargo程式專案,當作是新的Cargo程式專案的模板。可以使用以下指令來安裝:

cargo install cargo-generate

加入編譯目標(target)

要使Rust程式能夠被編譯為.wasm檔案,必須將編譯目標設為wasm32-unknown-unknown

以下指令可以加入wasm32-unknown-unknown目標:

rustup target add wasm32-unknown-unknown

Hello World

學習一個程式語言,依照慣例,第一支程式都會是「Hello World」。現在,我們就來從無到有,一步步地從建立新的Cargo程式專案開始,一直到能夠在網頁瀏覽器上執行WebAssembly程式,並用對話框秀出Hello, world!這幾個字吧!

建立專案目錄

首先,執行以下指令,用cargo-generate建立出名叫hello-webassembly的應用程式專案。

cargo generate --git https://github.com/rustwasm/wasm-pack-template.git -n hello-webassembly

rust-webassembly-introduction

若您有興趣的話可以自行看一下wasm-pack-template這個模板產生出來的程式碼,底下會繼續介紹程式的實作流程,就不多說明了。

wasm_bindgen

先用編輯器開啟src/lib.rs,應該會看到有兩個部份的程式被加上#[wasm_bindgen]屬性。如下:

...

#[wasm_bindgen]
extern "C" {
    fn alert(s: &str);
}

#[wasm_bindgen]
pub fn greet() {
    alert("Hello, hello-webassembly!");
}

...

若在extern關鍵字所組成的程式區塊加上#[wasm_bindgen]屬性,表示要將這個區塊裡面定義的函數連結到JavaScript提供的函數。

如果在有使用pub來定義的函數加上#[wasm_bindgen]屬性,表示要暴露(export)這個函數到JavaScript中。

Rust程式實作

為了能在對話框秀出Hello, world!,我們要將程式修改如下:

...

#[wasm_bindgen]
extern {
    fn alert(s: &str);
}

#[wasm_bindgen]
pub fn greet() {
    alert("Hello, world!");
}

...

建置專案

寫完Rust程式後,就可以執行以下指令將它編譯成.wasm檔案了!

wasm-pack build

rust-webassembly-introduction

編譯後產生出來的東西會是一個完整的JavaScript套件,檔案會被放在Cargo程式專案根目錄的pkg目錄中,列表如下:

  • hello_webassembly_bg.js:這個就是前面提到過的膠水代碼。
  • hello_webassembly_bg.wasm:看副檔名就知道這個就是WebAssembly的二進制檔。
  • hello_webassembly_bg.wasm.d.ts:TypeScript定義檔,可以使WebAssembly的二進制檔(WASM模組)被用於TypeScript中。
  • hello_webassembly.js:用來引用膠水代碼和定義其它JS相關的東西,我們主要會引用這個檔案來做東西。
  • hello_webassembly.d.ts:這個是hello_webassembly.js的TypeScript定義檔,可以使膠水代碼用於TypeScript中。
  • package.json:這個JavaScript套件的設定檔。
  • README.md:這個就是Cargo程式專案根目錄README.mdwasm-pack只是把它複製過來而已。

要怎麼把這些檔案套用在HTML網頁?

我們可以在Cargo程式專案目錄中,新增一個www目錄,然後在這個目錄中參考以下連結的文章來建立TypeScript函式庫專案:

在TypeScript專案中執行以下指令來安裝必要套件:

pnpm i -D webpack webpack-cli babel-loader @babel/core @babel/preset-env @babel/preset-typescript @babel/register terser-webpack-plugin html-webpack-plugin

修改TypeScript程式碼,如下:

export { greet } from "../../pkg/hello_webassembly.js";

新增www/src/index.ts檔案,內容如下:

import { greet } from "./lib.js";

greet();

新增www/views/index.html檔案,內容如下:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8"/>
    <title>Greeting</title>
</head>
<body>
    You should see an alert message.

    <script src="./js/bundle.min.js"></script>
</body>
</html>

新增www/webpack.config.ts檔案,即Webpack的設定檔,內容如下:

import HtmlWebpackPlugin from "html-webpack-plugin";
import TerserPlugin from "terser-webpack-plugin";
import { Configuration } from "webpack";

const config: Configuration = {
    entry: "./src/index.ts",
    output: {
        clean: true,
        filename: "./js/bundle.min.js",
    },
    plugins: [
        new HtmlWebpackPlugin({
            template: "./views/index.html",
            filename: "./index.html",
            minify: {
                collapseBooleanAttributes: true,
                collapseWhitespace: true,
                removeAttributeQuotes: true,
                removeComments: true,
                removeEmptyAttributes: true,
                removeRedundantAttributes: true,
                removeScriptTypeAttributes: true,
                removeStyleLinkTypeAttributes: true,
                minifyCSS: true,
                minifyJS: true,
                sortAttributes: true,
                useShortDoctype: true,
            },
            inject: false,
        }),
    ],
    module: {
        rules: [
            {
                test: /\.ts$/i,
                use: [
                    {
                        loader: "babel-loader",
                        options: { presets: ["@babel/preset-env", "@babel/preset-typescript"] },
                    },
                ],
            },
            {
                test: /\.js$/i,
                use: [
                    {
                        loader: "babel-loader",
                        options: { presets: ["@babel/preset-env"] },
                    },
                ],
            },
        ],
    },
    resolve: { extensionAlias: { ".js": [".ts", ".js"] } },
    optimization: {
        minimizer: [
            new TerserPlugin({
                extractComments: false,
                terserOptions: { format: { comments: false } },
            }),
        ],
    },
    experiments: { asyncWebAssembly: true },
};

export default config;

修改www/.eslintrc檔案,將www/webpack.config.ts加進import/no-extraneous-dependencies規則的devDependencies的例外中。

www/package.json中添加腳本:

{
    ...

    "scripts": {
        ...

        "build:webpack": "webpack --mode production",

        ...
    }
  
    ...
}

然後就可以在www目錄中執行npm run build:webpack指令來打包專案。接著再使用VSCode的Live Server開啟Webpack輸出的index.html,正常情況下會成功在網頁瀏覽器看到Hello, world!對話框。

這邊要注意的是,在Webpack設定檔中要將實驗性的WebAssembly功能開啟。Webpack本身就有支援.wasm檔案的打包,所以可不必替.wasm檔案撰寫載入器規則。Webpack在輸出.wasm檔案時,會將檔名被替換成雜湊值。

如果要更變.wasm檔案的輸出檔名,或是輸出路徑的話,可以在Webpack設定的output欄位再加上webassemblyModuleFilename欄位,這個欄位的值預設為./[modulehash].module.wasm,把它指定成我們要的路徑和檔名即可。例如:

module.exports = {
    ...

    output: {
        ...

        webassemblyModuleFilename: "./wasm/game-of-life.wasm",

        ...
    }

    ...
};

總結

在這個章節中,我們稍微瞭解了Web­Assembly,以及Web­Assembly和JavaScript的關聯。並且也學會了從無到有,用Rust程式語言和Webpack來製作出應用了Web­Assembly的網頁。

在接下來的章節中,會以更多例子來介紹用Rust程式語言開發Web­Assembly程式的方式。

下一章:將高效能的Rust函式庫套用在網頁上