初識WebAssembly

myskies發表於2023-01-11

近期線上收實驗報告的時候,學生們上傳的圖片亂七八糟的。後期提示使用 掃描王 等軟體處理後再上傳效果好了很多。但無疑這給學生了帶來了相應的繁瑣。於是:如何在WEB能快速的處理圖片,並實時的顯示效果成為了新的需求。

首先,我們可以點選demo感受一下它的魅力。

而處理圖片往往都在後端執行,直接在 WEB 處理則需要一個叫WebAssembly的知識,簡單來說就是瀏覽器允許執行二進位制的檔案,而這個二進位制的檔案則是各種原後端語言透過編譯器編譯出來的。

所以可以用C++來寫一個圖片處理程式,並使用WebAssembly把它應用到瀏覽器中便成了解決方案。

本文在macos下,演示如何把Hello world執行在瀏覽器中。

Emscripten

要想把C++原始碼編譯成瀏覽器可以執行的 WebAssembly , 則需要一些編譯器,而Emscripten則屬於其中的一個。

docker(推薦)

docker無疑是最簡單的安裝方式,官方image提供了多個版本供我們選擇。

image.png

我們在使用前僅僅需要下載相應的image即可,比如我們下載最新的版本:

 % docker  pull emscripten/emsdk

然後我們進行資料夾對映,並執行容器中的emcc命令即可,比如:

docker run --rm -v $(pwd):/src -u $(id -u):$(id -g) \
  emscripten/emsdk emcc helloworld.cpp -o helloworld.js

則表示將當前路徑下的helloworld.cpp編譯成helloworld.js

macos

安裝 Emscripten 需要從github下載相當的程式碼,並執行相應的操作,

環境要求:

  1. macOS 10.14 Mojave及以上
  2. 安裝 Xcode Command Line Tools
  3. 安裝git
  4. 安裝cmake

命令如下:

# 下載程式碼
$ git clone https://github.com/emscripten-core/emsdk.git --depth=1
# 進入下載的資料夾
$ cd emsdk
# 執行安裝命令,由於這個操作會從網下下載相應的第三方安裝包,所以這可能需要一個比較友好的網路
$ ./emsdk install latest
# 源活我們剛剛安裝的 latest 版本
$ ./emsdk activate latest
# 源活環境變數,每啟動一新的shell,都要執行一次
$ source ./emsdk_env.sh

驗證:

建立以下檔案:

#include <stdio.h>

int main() {
    printf("Hello World\n");
    return 0;
}

驗證

我們新建hello.c檔案

#include <stdio.h>

int main() {
    printf("Hello World\n");
    return 0;
}

然後執行emcc hello.c -o hello.html

panjie@panjies-Mac-Pro src $ emcc hello.c -o hello.html
shared:INFO: (Emscripten: Running sanity checks)
cache:INFO: generating system asset: symbol_lists/ed436b369ffc02205671a0a9df422f9da2cf641b.txt... (this will be cached in "/Users/panjie/github/emscripten-core/emsdk/upstream/emscripten/cache/symbol_lists/ed436b369ffc02205671a0a9df422f9da2cf641b.txt" for subsequent builds)
cache:INFO:  - ok

然後我們就得到了 一個 html 檔案,一個js檔案以及一個wasm檔案:

panjie@panjies-Mac-Pro src % ls
hello.c        hello.html    hello.js    hello.wasm

接著我們起一個http-server,並在瀏覽器中檢視效果:

panjie@panjies-Mac-Pro src % http-server
Starting up http-server, serving ./
Available on:
  http://127.0.0.1:8081
  http://192.168.0.242:8081
Hit CTRL-C to stop the server

image.png

如果你是用的docker,則可以如下執行:

panjie@panjies-Mac-Pro src % docker run --rm -v $(pwd):/src -u $(id -u):$(id -g) emscripten/emsdk emcc hello.c -o hello.html      
cache:INFO: generating system asset: symbol_lists/ed436b369ffc02205671a0a9df422f9da2cf641b.txt... (this will be cached in "/emsdk/upstream/emscripten/cache/symbol_lists/ed436b369ffc02205671a0a9df422f9da2cf641b.txt" for subsequent builds)
cache:INFO:  - ok

最後的效果是一致的。

分析

.c檔案編譯後生成了3個新的檔案,html檔案用於展示頁面並且呼叫js檔案,js檔案則充當獲取二進位制檔案,裝載二進位制檔案,呼叫二進位制檔案並獲取返回值的目的,而wasm則是瀏覽器直接執行的二進位制檔案。該檔案由c語言編譯而來,可以兼顧功能與效率,重要的是原本一些只能支援在應用程式中的功能,可以被移植到瀏覽器中來了。

編譯至指定模板

Emscripten的github原始碼中,為我們提供了自定義的html模板,下面我們將 HelloWorld輸入到這個自定義的模板中。

首先我們在當前資料夾中建立子資料夾html_template,並將位於Emscripten的github原始碼資料夾中的 /upstream/emscripten/src/shell_minimal.html複製到html_template資料夾。

panjie@panjies-Mac-Pro src % tree
.
├── hello.c
├── hello.html
├── hello.js
├── hello.wasm
└── html_template
    └── shell_minimal.html

1 directory, 5 files

接著我們執行如下命令:

panjie@panjies-Mac-Pro src %  docker run --rm -v $(pwd):/src -u $(id -u):$(id -g) emscripten/emsdk emcc  -o hello1.html hello.c -O3 --shell-file html_template/shell_minimal.html
cache:INFO: generating system asset: symbol_lists/812dbbffa7488aec7a503446fae422688638f439.txt... (this will be cached in "/emsdk/upstream/emscripten/cache/symbol_lists/812dbbffa7488aec7a503446fae422688638f439.txt" for subsequent builds)
cache:INFO:  - ok

注意,上面的命令中O3不是03.

此時,便會使用模模板html_template/shell_minimal.html來生成hello1.html,使用http-server起個服務後檢視結果如下:

image.png

如果我們將當前的網路模擬成慢速3G:

image.png

則會發下如下啟動過程:

先下載
image.png

再準備
image.png

最後才是呈現結果
image.png

請求的時序如下:

image.png

如果我們開啟快取,那麼整體請求將會友好的多:

image.png

自定義模板

學習 DEMO

透過 shell_minimal.html 模板的學習,我們簡單的把關鍵的資訊拿出來學習一下。首先是 CSS 樣式部分,該部門主要用於控制頁面顯示,我們暫時略過。

image.png

上圖這個html基本上可以分為兩個部分,第一部分是影像UI輸出,第三部分是sheel控制檯輸出。比如我們的hello.c,並沒有輸出任何影像,而是直接列印了 Hello World,所以上述影像就輸出了一個黑框框。

在模板中,用於輸出影像的標籤是canvas:

<canvas class="emscripten" id="canvas" oncontextmenu="event.preventDefault()" tabindex=-1></canvas>

而用於輸出shell資訊的是textarea

<textarea class="emscripten" id="output" rows="8"></textarea>

而以下的script程式碼的作用起的是銜接的作用:載入.c,執行.c,輸出.c的結果。

    <script type='text/javascript'>
      // 獲取三個dom,分別用於顯示 狀態、進度,以及 loading時轉圈圈
      var statusElement = document.getElementById('status');
      var progressElement = document.getElementById('progress');
      var spinnerElement = document.getElementById('spinner');

      // 訂義一個物件,該物件的各個屬性方法都是WebAssembly規定好的
      var Module = {
        // 執行前執行的
        preRun: [],
        // 執行後執行的
        postRun: [],
        // 輸出結果
        print: (function() {
          // 獲取用於輸出結果的 textarea DOM
          var element = document.getElementById('output');
          if (element) element.value = ''; // 清空textarea中的內容
          // 返回function供hello.js呼叫,hello.js會在執行hello.c後呼叫該函式,並把執行hello.c結果做為參考text傳入
          return function(text) {
            // arguments的上下文位於hello.js中,欄位意思看是 呼叫引數. 如果呼叫的引數大於1,則重寫 text 的為arguments陣列
            if (arguments.length > 1) text = Array.prototype.slice.call(arguments).join(' ');
            // 下面這幾行就給出的示例,主要是用於替換html中的關鍵字,比如 < 使用 &lt; 來替換
            //text = text.replace(/&/g, "&amp;");
            //text = text.replace(/</g, "&lt;");
            //text = text.replace(/>/g, "&gt;");
            //text = text.replace('\n', '<br>', 'g');
            console.log(text);
            // 向 textarea 中輸出內容,並調焦點設定為textarea的底部
            if (element) {
              element.value += text + "\n";
              element.scrollTop = element.scrollHeight; // focus on bottom
            }
          };
        })(),
        // 輸出影像
        canvas: (function() {
          // 獲取 canvas
          var canvas = document.getElementById('canvas');

          // 瀏覽器對影像的渲染基於webgl,所以做一個預設的初始化選項,
          // 為了保證程式的健壯性,我們應該在webgl上下文丟失時,提醒使用者重新重新整理介面
          // 原文如下:
          // As a default initial behavior, pop up an alert when webgl context is lost. To make your
          // application robust, you may want to override this behavior before shipping!
          // See http://www.khronos.org/registry/webgl/specs/latest/1.0/#5.15.2
          canvas.addEventListener("webglcontextlost", function(e) { alert('WebGL context lost. You will need to reload the page.'); e.preventDefault(); }, false);

          // 將這個 canvas 返回給hello.js,hello.js則會將影像輸出到這個 canvas 上
          return canvas;
        })(),
        // 設定狀態(比如開始下載、下載的百分比,準備完畢),這個應該不是WebAssembly的官方介面,而是自定義的
        setStatus: function(text) {
          // 設定個最後更新時間
          if (!Module.setStatus.last) Module.setStatus.last = { time: Date.now(), text: '' };
          // 如果更新內容與最後的更新內容相同,則什麼也不做
          if (text === Module.setStatus.last.text) return;
          // 判斷傳入的是否為下載的百分比(進度)
          var m = text.match(/([^(]+)\((\d+(\.\d+)?)\/(\d+)\)/);
          var now = Date.now();
          // 如果傳入的是下載百分比,而且距離上次傳入的時間小於30ms,則什麼也不做。
          if (m && now - Module.setStatus.last.time < 30) return; // if this is a progress update, skip it if too soon
          // 設定最後的時間及最後的文字
          Module.setStatus.last.time = now;
          Module.setStatus.last.text = text;

          if (m) {
            // 傳入的是下載百分比,則格式化
            text = m[1];
            progressElement.value = parseInt(m[2])*100;
            progressElement.max = parseInt(m[4])*100;
            progressElement.hidden = false;
            spinnerElement.hidden = false;
          } else {
            // 不是百分比,則清空進度值
            progressElement.value = null;
            progressElement.max = null;
            progressElement.hidden = true;

            // 當text為空時,隱藏掉spinner
            if (!text) spinnerElement.hidden = true;
          }
          // 最後設定狀態元素的內容
          statusElement.innerHTML = text;
        },
        // 總依賴數
        totalDependencies: 0,
        // 監視執行依賴項,該方法會被間隔呼叫,用於通知當前載入的進度
        // @param left 剩餘依賴項
        monitorRunDependencies: function(left) {
          // 未載入完畢,則設定狀態為 Preparing... (已載入數/未載入數);否則顯示 All downloads complete
          this.totalDependencies = Math.max(this.totalDependencies, left);
          Module.setStatus(left ? 'Preparing... (' + (this.totalDependencies-left) + '/' + this.totalDependencies + ')' : 'All downloads complete.');
        }
      };

      // 設定起始狀態
      Module.setStatus('Downloading...');
      // 設定下異常的回撥
      window.onerror = function() {
        Module.setStatus('Exception thrown, see JavaScript console');
        spinnerElement.style.display = 'none';
        Module.setStatus = function(text) {
          if (text) console.error('[post-exception status] ' + text);
        };
      };
    </script>

模板最後存在的{{{ SCRIPT }}}則用於替換為 js 檔案的引用。如此,我們先宣告瞭符合 WebAssembly 介面的物件 Module,然後引入了 js 檔案,而js檔案則會應用這個剛剛宣告的Module。這樣一來,WebAssembly的 JS 檔案更與當前頁面結合起來了。

自定義模板

為了驗證前面的假設,我們下面來如下自定義模板並命名為sample.html,同是樣存到html_template資料夾中:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>WebAssembly</title>
</head>
<body>
    <div style="margin-left: auto; margin-right: auto; width: 400px; margin-top:10em; text-align: center;">
        <p id="output">這裡是輸出的內容</p>
    </div>
    <script type="text/javascript">
      // 訂義一個物件,該物件的各個屬性方法都是WebAssembly規定好的
      var Module = {
        preRun: [],
        postRun: [],
        // 輸出結果
        print: (function() {
          // 獲取用於輸出結果的 textarea DOM
          var element = document.getElementById('output');
          if (element) element.innerHTML = '';
          return function(text) {
            if (element) {
              element.innerHTML += text;
            }
          };
        })(),
        totalDependencies: 0,
        monitorRunDependencies: function(left) {
            console.log(left);
        }
      };

      // 設定下異常的回撥
      window.onerror = function() {
        alert('error');
      };
    </script>
    {{{ SCRIPT }}}
</body>
</html>

然後我們執行以下命令來將hello.c渲染進來:

docker run --rm -v $(pwd):/src -u $(id -u):$(id -g) emscripten/emsdk emcc  -o sample.html hello.c -O3 --shell-file html_template/sample.html

最後我們將得到以sample打頭的js html 以及 wasm 檔案,執行http-server並檢視:

image.png

函式

最後我們再看看如何呼叫.cpp檔案中的函式,我們簡單用c++語言寫個求開平方,並把它應用html頁面中。官方文件指出呼叫 C 語言中function最簡單的方法便是使用 ccall以及cwrap.

ccall() 使用指定的引數來呼叫一個編譯後的 C 函式,而cwrap()則是把 C 中的函式包裹成js的函式,然後再像呼叫普通的js函式一樣來進行呼叫。所以如果我們只想呼叫一次,那麼用ccall就好了,如果我們想多次呼叫,則建立使用cwarp來封裝一下。

建立一個 sqrt.cpp 檔案並加入以下程式碼:

#include <math.h>

// 相容 C++
extern "C" {
    int int_sqrt(int x) {
      return sqrt(x);
    }
}

接下來我們將其編譯為 js wasm檔案,需要注意的是:

  1. 本次我們是先編譯,然後再寫模板,所以我們把目標檔案設定為sqrt.js,而非sqrt.html
  2. 我們需要指定編譯的方法,並以_打頭
  3. 我們需要指定ccall、cwarp
docker run --rm -v $(pwd):/src -u $(id -u):$(id -g) emscripten/emsdk emcc sqrt.cpp -o sqrt.js -sEXPORTED_FUNCTIONS=_int_sqrt -sEXPORTED_RUNTIME_METHODS=ccall,cwrap

最終將生成 js 及 wasm 兩種型別的檔案。

最後,我們寫個html程式碼來嘗試呼叫一下:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>加法器</title>
</head>
<body style="margin-left:auto; margin-right:auto; width: 800px;">
    <div>請輸入:<input type="number" id="num"/></div>
    <div>結果:<p id="output"></p></div>
    <button onclick="sqrt()">運算</button>

    <script type="text/javascript">
        // 訂義一個物件,該物件的各個屬性方法都是WebAssembly規定好的
        var Module = {};

        // 設定下異常的回撥
        window.onerror = function() {
            alert('error');
        };

        // 獲取輸入、輸出
        var num = document.getElementById("num");
        var output = document.getElementById("output");

        // 定義開平方方法
        var sqrt = function() {
            // 呼叫c++中的int_sqrt方法
            const result = Module.ccall(
                "int_sqrt",     // 方法名
                "number",       // 返回值型別
                ["number"],     // 引數型別,這是個陣列,因為可能是多引數
                [+num.value]    // 引數值,也是個陣列,因為可能是多引數
              );
            // 最後將結果給html元素
            output.innerHTML = result;
        }
    </script>
    <!-- 手動引用js檔案 -->
    <script async src=sqrt.js></script>
</body>
</html>

image.png

最後我們再測試下 cwrap :

<script type="text/javascript">
        let intSqrt;
        // 訂義一個物件,該物件的各個屬性方法都是WebAssembly規定好的
        var Module = {
            monitorRunDependencies: function(left) {
                // 載入完畢後初始化iniSqrt方法
                if (left === 0 && !intSqrt) {
                    intSqrt = Module.cwrap('int_sqrt', 'number', ['number'])
                }
            }
        };

        // 設定下異常的回撥
        window.onerror = function() {
            alert('error');
        };

        // 獲取輸入、輸出
        var num = document.getElementById("num");
        var output = document.getElementById("output");

        // 定義開平方方法
        var sqrt = function() {
            // 直接呼叫intSqrt方法
            output.innerHTML = intSqrt ? intSqrt(+num.value) : '';
        }
    </script>

最終實現效果相同。

總結

WebAssembly是個變革性的東西,有人說它將引領下一代WEB開發,它的出現使得原本僅能安裝客戶端才能實現在功能,當下可以直接在 WEB 端來使用了。同時由於其編譯為2進位制的特性,在保證了效能的同時能提供了足夠的安全性。