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 學習之路系列文章。

Webpack(Node.js)

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

https://magiclen.org/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

然後別忘了將工作目錄也跟著移動到剛才建立出來的應用程式專案的根目錄中,指令如下:

cd hello-webassembly

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

wee_alloc

wee_alloc(The Wasm-Enabled, Elfin Allocator)是專門用在WebAssembly的分配器,通常可以使Rust編譯出來的.wasm檔案更小,但效能會比預設的分配器慢一些。預設並不會被啟用,我們可以在Cargo.tomlfeatures區塊裡的default項目中,加上wee_alloc來使它預設能被啟用。

wasm_bindgen

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

...

#[wasm_bindgen]
extern {
    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.wasm:看副檔名就知道這個就是WebAssembly的二進制檔。
  • hello_webassembly.js:這個就是前面提到過的膠水代碼。
  • hello_webassembly.d.ts:這個是hello_webassembly.js的TypeScript定義檔,可以使這個膠水代碼用於TypeScript中。
  • hello_webassembly_bg.d.ts:這個是hello_webassembly_bg.wasm的TypeScript定義檔,可以使這個WASM模組用於TypeScript中。
  • package.json:這個JavaScript套件的設定檔。
  • README.md:這個就是Cargo程式專案根目錄README.mdwasm-pack只是把它複製過來而已。

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

我們可以在Cargo程式專案目錄中,新增一個www目錄,然後在這個目錄中再新增以下這些檔案,來將pkg目錄中的檔案加進Webpack中。

{
  "name": "hello-webassembly",
  "version": "0.0.0",
  "scripts": {
    "build": "webpack --mode production",
    "dev": "webpack --mode development",
    "start": "webpack-dev-server"
  },
  "devDependencies": {
    "@babel/core": "^7.4.5",
    "@babel/plugin-syntax-dynamic-import": "^7.2.0",
    "@babel/preset-env": "^7.4.5",
    "@babel/register": "^7.4.4",
    "babel-loader": "^8.0.6",
    "clean-webpack-plugin": "^3.0.0",
    "html-webpack-plugin": "^3.2.0",
    "terser-webpack-plugin": "^1.3.0",
    "webpack": "^4.35.0",
    "webpack-cli": "^3.3.5",
    "webpack-dev-server": "^3.7.2"
  }
}

注意這邊必須要使用webpack-dev-server套件,因為網頁瀏覽器要載入.wasm檔案時,其必須要有HTTP的Content-Type標頭欄位,且值必須要是application/wasm。如果直接用檔案的通訊協定來開啟,就不會有HTTP標頭了,也就會使得網頁瀏覽器無法正常載入.wasm檔案。而使用webpack-dev-server套件的話就可以在系統中建立出輕量的HTTP伺服器,讓.wasm檔案可以擁有正確的Content-Type標頭欄位。

另外,要引用wasm-pack產生出來的膠水代碼,必須要使用非同步的import(或稱動態import)──也就是import函數;而非我們比較習慣使用的同步的import──也就是import關鍵字。為了讓Babel能夠支援import函數,就需要使用plugin-syntax-dynamic-import這個外掛。

import('../../pkg/hello_webassembly')
    .catch(e => console.error("Error importing `hello_webassembly.js`:", e))
    .then(module => module.greet());
<!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>
const {CleanWebpackPlugin} = require('clean-webpack-plugin');
const TerserPlugin = require('terser-webpack-plugin');

const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
    output: {
        filename: './js/bundle.min.js'
    },
    plugins: [
        new CleanWebpackPlugin(),
        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: /\.js$/,
                use: {
                    loader: 'babel-loader',
                    options: {
                        presets: ['@babel/preset-env'],
                        plugins: [
                            "@babel/plugin-syntax-dynamic-import"
                        ]
                    }
                }
            }
        ]
    },
    optimization: {
        minimizer: [
            new TerserPlugin(
                {
                    cache: true,
                    parallel: true,
                    terserOptions: {
                        output: {
                            comments: false,
                        }
                    }
                }
            )
        ]
    }
};

將以上檔案添加好後,就可以執行npm install指令來安裝套件。接著再執行npm run start指令來啟動HTTP伺服器,然後用瀏覽器開啟http://localhost:8080,正常情況下會成功在網頁瀏覽器看到Hello, world!對話框。

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

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

module.exports = {
    ...

    output: {
        ...

        webassemblyModuleFilename: './wasm/game-of-life.wasm'

        ...
    }

    ...
};

總結

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

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

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