iT邦幫忙

2021 iThome 鐵人賽

DAY 23
2
Modern Web

今晚,我想來點 Web 前端效能優化大補帖!系列 第 23

Day23 X WebAssembly

也許你早就聽過 WebAssembly 這個詞,傳說中它可以讓 C, C++, Rust 等系統語言的程式碼在瀏覽器上執行,解決 JS 的效能已經快要逼近極限的問題,並可以做一些以前對於瀏覽器來說可能相對吃效能的任務。

有些傳言甚至說 WebAssembly 未來會取代 JavaScript,然而事情真的是這樣嗎?JS 開發者要失業了嗎?我需要趕快去學 Rust 或 C++ 以保住飯碗嗎? WebAssembly 又到底是什麼?是怎麼運作的?今天我們就來了解一下 WebAssembly 的基礎觀念吧!

(本篇只會介紹 WebAssembly 最基本的概念與它對於前端開發者而言帶來的可能性,不會有太深入的介紹,如果原本抱有許多期待的讀者只能說聲抱歉了,鐵人賽果然不簡單,寫太多我會斷賽的 ? 不過我有預計未來了解深入一點後在 Medium 再撰寫更深入探討的文章,如果不想錯過的讀者可以追蹤我的 medium 喔!)

直譯式語言 vs 編譯式語言

JavaScript 是一門直譯語言,在執行時會一行一行動態地將程式碼直譯為機器碼並且執行,這種語言通常會是動態語言,並且在型別與程式上會較有彈性,身為前端開發者,應該對這些特性都不陌生了。而像是 C, C++ 則是屬於編譯語言,編譯語言在執行前會先透過編譯器(compiler)將程式碼編譯成電腦看的懂的機器碼(Machine Code),最後再執行,這種語言通常會是靜態的語言且具有型別檢查的機制與高性能。

所以不論 JavaScript 經過優化後變得再怎麼快,都會受限於直譯語言的特性,沒辦法達到像是編譯語言那樣的性能。

既然編譯語言那麼快,為什麼不直接在瀏覽器上跑 C++ 之類的語言呢?主要原因有兩個:

  • 瀏覽器需要透過網路抓取編譯後的檔案,因為編譯後的檔案通常都很大,因此傳輸會需要花費不少時間。
  • 如果是先傳過來再編譯,等程式編譯完也會需要一定的時間。

Let's Welcome WebAssembly !

既然知道 JavaScript 因為是直譯語言的關係,就算要優化也會達到一個天花板並遇到瓶頸,但是要直接在瀏覽器跑編譯語言又會遇到限制,那該如何是好呢?Let's Welcome WebAssembly!

首先來看看 MDN 官方文件是怎麼介紹它的:

WebAssembly 是一種新的低階程式語言,可在今日的網頁瀏覽器中被執行 —— 它是低階的類組合語言,具有嚴謹的二進位格式,能以接近原生應用程式的效能執行,並提供如 C/C++/Rust 等語言一個構建目標,使它們能在 Web 上被執行。他也被設計為可與 JavaScript 共存,允許兩者一同工作。

WebAssembly 已經成為了 W3C 的標準,在各家瀏覽器廠商間建立了一制性的規範,簡單來說它可以讓編譯語言寫的程式碼透過編譯器編譯成二進位制的 wasm 檔,再放入到 JS Engine 準備好的 wasm compiler 當中解碼並編譯為機器碼,從而得以被瀏覽器執行。

曾經看到很多人說「WebAssembly 未來會取代 JavaScript」,但是事實是

WebAssembly 的目的不是取代 JavaScript!
WebAssembly 的目的不是取代 JavaScript!
WebAssembly 的目的不是取代 JavaScript!

而更像是與 JavaScript 一起合作的角色,補足一些在 JS 裡不易達成的耗性能操作。

WebAssembly 的適用場景

目前主要應用在對性能要求較高的應用中,例如:

  • AR
  • VR
  • 遊戲開發
  • 數學計算
  • IOT
  • 區塊鏈
  • Edge Computing
  • 圖片與影像的編輯操作

如果想知道有哪些應用實際有使用 WebAssembly 的讀者十分建議閱讀這篇文章,另外也十分推薦前陣子一位大大在前端社群分享利用 WebAssembly module 實作 QRcode 掃描的心得

Web 開發者的好選擇 - AssemblyScript

我們首先來看看 .wasm 檔案的內容格式是長什麼樣子

(module
  (import "console" "log" (func $log (param i32 i32)))
  (import "js" "mem" (memory 1))
  (data (i32.const 0) "Hi")
  (func (export "writeHi")
    i32.const 0  ;; pass offset 0 to log
    i32.const 2  ;; pass length 2 to log
    call $log))

雖然說我們可以直接撰寫這樣的語法,但畢竟它的可讀性並不高,也比較難上手,所以一般在開發 WebAssembly 時還是比較常透過 C, C++, Rust 等程式語言來開發,再透過 Emscripten 編譯成 WebAssembly。

但是對前端開發者來說,這意味著我們需要再額外去學習一種新的程式語言(學習新的語言也未嘗不好,筆者就有在額外花時間學習 Rust,雖然一開始會覺得跟 JS 的世界很不一樣有點不舒服,但的確開拓了一些視野與未來的可能性。)

其實對於前端開發者來說,還有一個更適合我們的選擇 - AssemblyScript

簡單來說它可以讓我們以 TypeScript 的語法來開發 WebAssembly 程式,不過兩者間還是有一些不同,例如在 AssemblyScript 中數字的型別是使用如 i32, u32 等特定整數或浮點數的型別,而不是在 TypeScript 中使用的 number 型別。因為 AssemblyScript 只允許 TypeScript 的有限功能子集,因此如果熟悉 TypeScript 的開發者應該可以迅速上手 AssemblyScript。

AssemblyScript 寫起來是以下這樣

export function fib(n: i32): i32 {
  var a = 0, b = 1
  if (n > 0) {
    while (--n) {
      let t = a + b
      a = b
      b = t
    }
    return b
  }
  return a
}

是不是跟 TypeScript 很像呢!?

AssemblyScript Simple Demo

既然都提到 AssemblyScript 了,就不免俗的玩玩看屬於 AssemblyScript 的 Hello World Demo 吧!

首先建立一個空的資料夾,並在終端機裡面執行

npm init -y
npm install --save @assemblyscript/loader
npm install --save-dev assemblyscript
npx asinit .

asinit 這個指令會幫我們在當前目錄新建一個 AssemblyScript 的專案,我們接著就來看看這個指令幫我們建立了哪些檔案與它們的用途為何

  ./assembly
  放置我們寫的 AssemblyScript Source Code

  ./assembly/tsconfig.json
  一些配合 AssemblyScript 開發的 TypeScript Setting

  ./assembly/index.ts
  Compile 成 wasm 時的 entry file

  ./build
  存放 compile 後的 WebAssembly 檔案的地方

  ./build/.gitignore
  排除一些 compile 後的 wasm 檔案被上傳到 Version Control Service  

  ./index.js
  Loading the WebAssembly module and exporting its exports.

  ./package.json
  你們懂的!!!

可以發現在 ./assembly/index.js 預設已經提供了一個簡單的函式

export function add(a: i32, b: i32): i32 {
  return a + b;
}

接著我們就不做修改,直接編譯它吧

npm run asbuild

回到 build 可以發現一些檔案被生成了

接下來需要在要使用 WebAssembly Module 的地方進行 instantiation,WebAssembly Module 可以在 Node.js 也能在瀏覽器環境使用,差別在於 Node.js 可以使用 fs 的方式引入 wasm file,在 browser 則需要透過 network request 的方式載入。基本上 WebAssembly Module instantiation 有三種方式可以選擇:

  • WebAssembly.Instance – Synchronous instantiation
  • WebAssembly.instantiate – Asynchronous instantiation
  • WebAssembly.instantiateStreaming – Asynchronous streaming instantiation

要注意 instantiateStreaming 目前在 Node.js 與 Safari browser 是尚未支援的(不過可以透過 polyfill 解決)

WebAssembly.instantiateStreaming(fetch('/build/optimized.wasm'), {})
  .then(wasmModule => {
    const exports = wasmModule.instance.exports;
    const mem = new Uint32Array(exports.memory.buffer);
  });

載入後就可以對 wasm module 作後續的操作囉!
使用方式大概像下面這樣子,礙於篇幅就請有興趣的讀者自行研究囉~

// AssemblyScript

export function fibonacci(n: i32): i32 {
	let i: i32 = 1;
	let j: i32 = 0
	let k: i32;
	let t: i32;

	for (k = 1; k <= Math.abs(n); k++) {
	   t = i + j;
	   i = j;
	   j = t;
	}
	if (n < 0 && n % 2 === 0) {
		j = -j;
	}
	return j;
}


// Browser Side

const n = 1000;
const result = wasmModule.fibonacci(n);

Cache Wasm Module

在 web client side 使用 WebAssembly 的 module 時可以搭配快取將 compile 過後的 WebAssembly module 存起來,如此一來就不需要每次都得重新 download 與 compile,可以加快網站的性能。

然而一般我們比較熟悉的 browser storage 例如 localStorage 與 sessionStorage 是有大小限制的,WebAssembly module 很有可能超過這個限制,因此 MDN 官方是推薦使用一個你應該聽過,卻也應該沒有用過的 Browser Database - IndexedDB 來快取 compile 過後的 WebAssembly Module。

對於使用 IndexedDB 快取 wasm module 有興趣的讀者可以閱讀這篇 MDN 的文章,因為該篇文章已經寫的十分完整,我就不在這裡贅述。

(另外也推薦讀者們使用 localForage 這個 library,可以讓使用 browser storage 更為輕鬆方便。)

WebAssembly X Web Workers

經過昨天的文章後各位讀者應該知道我們是有機會透過把一些任務丟到 Web Workers 中來實現平行處理的,而 WebAssembly 就是其中一種可能性。

把 WebAssembly 放到 Web Workers 來執行的好處是可以把 fetching, compiling and initialising 一個 WebAssembly module 的工作抽離 Main Thread,如果這個 WebAssembly 的 module 負責的是複雜運算與處理的操作,這麼做可以使頁面效能提升不少。至於為什麼要將一些任務從 Main Thread 抽離與這麼做可能的優缺點都在上一篇文章說明過,如果不清楚的讀者記得回頭到上一篇回憶一下。

不過 WebAssembly 搭配 Web Workers 也是有缺點的,Main Thread 與 Workers 間的資料溝通是一個耗性能的操作,而且消耗的性能還會隨著資料的大小而成長。再者 WebAssembly 搭配 Web Workers 也會使程式碼變得更加複雜,也讓程式與 wasm module 的互動變成非同步的形式(因為 message 的傳遞、Event Listener 與 Callback 等原因)。

所以說 WebAssembly 與 Web Workers 並不是一個絕配組合,實際上適不適合搭配使用還是得經過謹慎評估喔!有興趣的讀者可以參考這篇文章

Demo Time : React X WASM

要自己寫 WebAssembly 並不容易,但至少我們可以學會怎麼使用別人寫好的 WebAssembly 工具。

FFmpeg 是一個開放原始碼的自由軟體,可以執行音訊和視訊多種格式的錄影、轉檔、串流功能,今天想要 demo 的是透過 FFmpeg 將影片轉檔成 gif 的功能。

(注意!這個 wasm module 因為使用到 sharedArrayBuffer,目前的瀏覽器預設都是關閉的,如果要能夠順利使用這個 module,必須設定 cross-origin isolated。不過我覺得就算跑不起來也沒關係,因為這是一個簡單的範例,目的是讓各位讀者了解使用 wasm module 並沒有想像中那麼困難。)

在過去,要達成這樣的功能一般來說會需要透過 client 與 server 溝通來達成,也就是前端將使用者上傳的影片丟到後端,後端伺服器跑 FFmpeg 進行轉檔後再吐回給前端。

這種方式主要會有兩個問題:

  • 沒有效率
  • offline 時沒辦法 work

幸好有了 WebAssembly 的出現,讓我們有機會可以把這些操作拉到瀏覽器端來做。

首先使用 create-react-app 建立一個簡單的 react 專案

npx create-react-app wasm-react-demo

接著下載 FFmpeg 的 WebAssembly Module

npm i @ffmpeg/ffmpeg @ffmpeg/core

再來看看 React Code 的部分

其實實作上並不難,建立 FFmpeg 的 instance 後就可以載入 wasm 的模組,比較值得一提的是可以看到 convertToGif 這個 function 中 ffmpegInstance 有跑一個 FS 的函數,這是因為 WebAssembly 會自己管理一個 in memory 的 file system ,所以可以執行像伺服器端 readFile, writeFile 等操作。

如果想要優化這個範例,也可以試著嘗試剛剛提過的搭配 Web Workers 或是 IndexedDB Cache 的方式,這就留給有興趣的讀者親自嘗試啦!

Demo Source Code: https://github.com/kylemocode/it-ironman-2021/tree/master/react-wasm-demo

(以上的 demo 建議讀者可以去看看這部 Youtube 影片

本日小結

WebAssembly 的本意不是取代 JavaScript,儘管現在越來越多 WebAssembly 的框架出現,標榜著可以不用寫 JavaScript 就能達到以前的功能,並且有著更好的效能,但不能忽略的是 WebAssembly 也有一些難題例如不容易 debug、檔案大小過大...等等,且還是需要找到適合使用的時機,不然效能未必會比用 JS 寫還要來得好(例如這篇文章就透過實驗點出 WebAssembly 或是編譯語言在某些狀況下未必會比 JS 還要來得快)。

所以如果要給此時此刻的 WebAssembly 一個結論的話,我會認為它在短時間內不會取代 JavaScript,而更像是一種合作互補的關係。有了它 Web 開發又多了更多的可能性,而前端開發也是如此。我相信未來它的發展會越來越健全,身為前端開發者千萬不能錯過這波技術潮流,現在就開始關注它吧!

References & 圖片來源

https://tigercosmos.xyz/post/2020/08/js/webassembly-intro/

https://www.youtube.com/watch?v=-OTc0Ki7Sv0&t=9s

https://developer.mozilla.org/zh-TW/docs/WebAssembly

https://www.sitepen.com/blog/getting-started-with-assemblyscript


上一篇
Day22 X Web Workers
下一篇
Day24 X Web Rendering Architectures
系列文
今晚,我想來點 Web 前端效能優化大補帖!30

尚未有邦友留言

立即登入留言