可能是世界上最簡單的用 Go 來寫 WebAssembly 的教程

騰訊技術工程發表於2020-06-30

原標題:The world’s easiest introduction to WebAssembly? 原文連結:The world’s easiest introduction to WebAssembly - freeCodeCamp.org - Medium  作者:Martin Olsansky (olso)

可能是世界上最簡單的用 Go 來寫 WebAssembly 的教程

一個與貓咪互動的 Canvas 手機遊戲,這個專案完全由 Golang 編寫。圖裡這隻小貓正在體驗我編寫的小遊戲
  • 你認為 WebAssembly (WASM) 只用於影像處理、複雜的數學計算或者 Web 上的小小應用嗎?
  • 你是否經常將 WASM 與 Web Workers 和 Service Workers 的概念混淆?
  • 你對 WASM 不感興趣,是因為你認為現在的 Web 應用程式在未來 10 年裡依舊是 JavaScript 主導?
  • 你是否想過用 JS 以外的語言做 Web 前端開發?

如果你不想細讀,你可以看下我做的 demo  頁面或者直接看下 ? go-wasm-cat-game-on-canvas-with-docker 這個專案,我會講的簡潔一些,儘量不浪費你的時間。以下是我這個專案的一些關鍵的程式碼解析。

故事開始了 ?

我們的目標是給貓 ? 做一個簡單的小遊戲:做一個小紅點在手機上不停的移動,整個過程還有 HiFi 音樂 ?還有震動。整個專案我們會用  Golang (Go)這門語言來實現,包括 DOM 操作、邏輯還有相關的狀態。

而且,由於貓咪不會使用滑鼠,我們還需要給貓爪 ? 做一些點選觸控的互動。

說一下我的理解!

把 WASM 想象成一個 通用虛擬機器(UVM, Universal Virtual Machine) 或者一個沙箱,你只需編寫一次任何程式碼,它便可以在任何地方執行。

WASM 是一個編譯目標,而不是一種語言。就像你要同時針對 Windows,Mac OS 和 Linux 進行編譯一樣!

我不認為 WASM 會廢棄 JS,你可以有其他選擇而不用付出任何代價。

想象一下使用 Go,Swift,Rust,Ruby,C ++,OCaml 或者其他語言的開發人員。現在,他們可以使用自己喜歡的語言來建立互動式,聯網,快速,具有離線功能的網站和Web 應用。

你是否曾經參與過類似「一個專案是用一個程式碼倉庫管理還是多個程式碼倉庫管理?」問題的討論?

好吧,不管你有沒有,你現在也要想一下現在這個專案打算用一門語言實現還是多門語言實現了。

當大家可以使用相同的技術棧時,一切都會變得更加容易,尤其是團隊之間的溝通。

你可以依舊使用 React 或者 Vue,但你現在開始也可以不用使用 JS 來開發了。

WASM 跟 Service Workers 還有 Web Workers 有什麼區別?

Service Workers 還有 Web Workers 允許應用在後臺執行,也可以做到離線執行和快取。它們模仿執行緒,無法訪問DOM,並且不能共享資料(僅能透過訊息傳遞),只能在單獨的上下文中執行。咦,其實我們甚至可以在其中執行 WASM 而不是 JS。對我來說,它們只提供一些具有特殊特權的抽象層,沒有人說這些層必須執行 JS。

Service Workers 還有 Web Workers 是瀏覽器上的功能,不是 JS 的專有功能。

設定開發環境 ?

我們將使用 WASM,Go,JS 和 Docker(這個是可選的)? 來進行開發。

如果您不瞭解Go,但瞭解 JS,請 點選這裡學習 Go,然後再回來繼續閱讀。讓我們從 Go WASM Wiki 開始。

你可以使用安裝在電腦本地的  go 版本,在這裡我使用 Docker 的 golang:1.12-rc 映象。只需在此處為 go 編譯器設定兩個 WASM 標誌。在main.go 中建立一個簡單的 hello world 進行測試。

$ GOOS=js GOARCH=wasm go build -o game.wasm main.go
build_go:
 docker run --rm \
 -v `pwd`/src:/game \
 --env GOOS=js --env GOARCH=wasm \
 golang:1.12-rc \
 /bin/bash -c "go build -o /game/game.wasm /game/main.go; cp /usr/local/go/misc/wasm/wasm_exec.js /game/wasm_exec.js"

現在,讓我們利用好 Go 團隊提供的 wasm_exec.js  程式碼。程式碼裡的全域性變數 Go 對 WASM 進行了初始化操作,我們不必自己從頭開始做好任何 DOM 的實現。等我們編譯好 wasm 檔案後,它會獲取 .wasm 檔案並執行我們的遊戲。

總而言之,它應該看起來像這樣:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width,initial-scale=1.0" />
    <style>body{height:100%;width:100%;padding:0;margin:0;background-color:#000000;color:#FFFFFF;font-family:Arial,Helvetica,sans-serif}</style>
    <script type="text/javascript" src="./wasm_exec.js"></script>
    <script type="text/javascript">
      async function run(fileUrl) {
        try {
          const file = await fetch(fileUrl);
          const buffer = await file.arrayBuffer();
          const go = new Go();
          const { instance } = await WebAssembly.instantiate(buffer, go.importObject);
          go.run(instance);
        } catch (err) {
          console.error(err);
        }
      }
      setTimeout(() => run("./game.wasm"));
    </script>
  </head>
  <body></body>
</html>

放碼過來!(當然是 Go 的碼)

要渲染我們的這個小遊戲,<canvas> 這個標籤應該足夠了。我們可以直接從 Go 程式碼建立 DOM 結構和元素!這個 syscall/js 檔案 (包含在標準 Go 庫中)為我們處理了與 DOM 互動的方法。

main() 方法

我敢打賭,你很久沒見過 main() 方法了?。

package main

import (
 // https://github.com/golang/go/tree/master/src/syscall/js
 "syscall/js"
)

var (
 // js.Value 可以是任意的 JS 物件、型別或者建構函式
 window, doc, body, canvas, laserCtx, beep js.Value
 windowSize struct{ w, h float64 }
)

func main() {
 setup()
}

func setup() {
 window = js.Global()
 doc = window.Get("document")
 body = doc.Get("body")

 windowSize.h = window.Get("innerHeight").Float()
 windowSize.w = window.Get("innerWidth").Float()

 canvas = doc.Call("createElement", "canvas")
 canvas.Set("height", windowSize.h)
 canvas.Set("width", windowSize.w)
 body.Call("appendChild", canvas)

 // 這個是小紅點 ? Canvas 物件
 laserCtx = canvas.Call("getContext", "2d")
 laserCtx.Set("fillStyle", "red")

 // http://www.iandevlin.com/blog/2012/09/html5/html5-media-and-data-uri/
 beep = window.Get("Audio").New("data:audio/mp3;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU2LjI1LjEwMQAAAAAAAAAAAAAA/+NAwAAAAAAAAAAAAFhpbmcAAAAPAAAAAwAAA3YAlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaW8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw////////////////////////////////////////////AAAAAExhdmYAAAAAAAAAAAAAAAAAAAAAACQAAAAAAAAAAAN2UrY2LgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP/jYMQAEvgiwl9DAAAAO1ALSi19XgYG7wIAAAJOD5R0HygIAmD5+sEHLB94gBAEP8vKAgGP/BwMf+D4Pgh/DAPg+D5//y4f///8QBhMQBgEAfB8HwfAgIAgAHAGCFAj1fYUCZyIbThYFExkefOCo8Y7JxiQ0mGVaHKwwGCtGCUkY9OCugoFQwDKqmHQiUCxRAKOh4MjJFAnTkq6QqFGavRpYUCmMxpZnGXJa0xiJcTGZb1gJjwOJDJgoUJG5QQuDAsypiumkp5TUjrOobR2liwoGBf/X1nChmipnKVtSmMNQDGitG1fT/JhR+gYdCvy36lTrxCVV8Paaz1otLndT2fZuOMp3VpatmVR3LePP/8bSQpmhQZECqWsFeJxoepX9dbfHS13/////aysppUblm//8t7p2Ez7xKD/42DE4E5z9pr/nNkRw6bhdiCAZVVSktxunhxhH//4xF+bn4//6//3jEvylMM2K9XmWSn3ah1L2MqVIjmNlJtpQux1n3ajA0ZnFSu5EpX////uGatn///////1r/pYabq0mKT//TRyTEFNRTMuOTkuNaqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq/+MQxNIAAANIAcAAAKqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqg==")
}

看起來是不是很像 JS 程式碼?

是的,這就是與 DOM 互動所需的全部內容!現在只需要幾個 get 方法還有呼叫函式即可。

可能是世界上最簡單的用 Go 來寫 WebAssembly 的教程
awsl? 它就在那!

在這一點上,我問自己:在某種程度上,我仍然在寫 JS … 這怎麼算是升級?因為我們還不能直接訪問 DOM,所以我們必須(透過 JS)呼叫 DOM 來做任何事情。想象一下如何用 JSX / React 來抽象化它。

實際上,已經可以做到了,請期待我的下篇文章 ?。

「渲染」還有事件處理

直接使用 syscall / js 庫,這個寫法看起來有點像 ES5 的回撥。但我們能夠監聽 DOM 事件,而且那些靜態型別看起來很乾淨!

func main() {
 setup()

  // 在編譯時宣告渲染器
 var renderer js.Func
 // 沒有錯,看起來很像 JS 的回撥 ?
 renderer = js.FuncOf(func(this js.Value, args []js.Value) interface{} {
  updateGame()
  // 實現 60FPS 的動畫
  window.Call("requestAnimationFrame", renderer)
  return nil
 })
 window.Call("requestAnimationFrame", renderer)

 // 讓我們處理下 滑鼠/手勢 點選事件
 var mouseEventHandler js.Func = js.FuncOf(func(this js.Value, args []js.Value) interface{} {
  updatePlayer(args[0])
  return nil
 })
 window.Call("addEventListener", "pointerdown", mouseEventHandler)
}

func updatePlayer(event js.Value) {}
func updateGame() {}

日誌記錄、音訊播放以及「非同步」執行

在 Go 中,有一個慣例是把所有的函式都寫成同步的方式,由呼叫者決定函式的執行是否是非同步的。非同步執行函式非常簡單,只要在前面加上go 就行了!它使用自己的上下文建立一個執行緒,你仍然可以將父級上下文繫結給它,不要擔心哈。

func updatePlayer(event js.Value) {
 mouseX := event.Get("clientX").Float()
 mouseY := event.Get("clientY").Float()
 
  // `go` 關鍵字是主要用來實現執行緒、非同步、並行的功能
 // TODO 與 Web Workers 的區別
 // TODO 與 Service Workers 的區別
 // https://gobyexample.com/goroutines
 go log("mouseEvent", "x", mouseX, "y", mouseY)

 // 下一個關鍵點
 if isLaserCaught(mouseX, mouseY, gs.laserX, gs.laserY) {
  go playSound()
 }
}

// 不要以為我用了什麼黑魔法,這裡直接使用了 HTML5 的 API
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLAudioElement#Basic_usage
func playSound() {
 beep.Call("play")
 window.Get("navigator").Call("vibrate", 300)
}

// 這裡主要用了 JS 的解構賦值語法
// 這裡的 `...interface{}` 有點像 TS 的 `any` 語法
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/rest_parameters#Description
func log(args ...interface{}) {
 window.Get("console").Call("log", args...)
}

讓遊戲一直跑下去!

該程式碼建立一個非緩衝通道,並嘗試從該通道接收資料。因為沒有人向它傳送任何東西,它本質上是一個永久的阻塞操作,允許我們永遠執行我們的程式。

func main() {
 // https://stackoverflow.com/a/47262117
 // 建立空通道
 runGameForever := make(chan bool)

 setup()

 // 嘗試從空通道接收
 // 由於沒有人向它傳送任何資料,它本質上是一個永久阻塞操作
  // 我們有一個 daeomon / service / background 程式
  // 在 WASM 裡,我們的遊戲會一直執行 ?
 <-runGameForever
}

更新遊戲狀態並移動小紅點

這裡沒有狀態管理,只有一個簡單的宣告型別的結構體,它不允許在內部傳遞任何不正確的值。

import (
 "math"
)

type gameState struct{ laserX, laserY, directionX, directionY, laserSize float64 }

var (
 // gs 處於最高範圍,小於這個範圍小紅點 ? 都能都能訪問
 gs = gameState{laserSize: 35, directionX: 3.7, directionY: -3.7, laserX: 40, laserY: 40}
)

func updateGame() {
 // 邊界判斷
 if gs.laserX+gs.directionX > windowSize.w-gs.laserSize || gs.laserX+gs.directionX < gs.laserSize {
  gs.directionX = -gs.directionX
 }
 if gs.laserY+gs.directionY > windowSize.h-gs.laserSize || gs.laserY+gs.directionY < gs.laserSize {
  gs.directionY = -gs.directionY
 }

 // 移動小紅點 ?
 gs.laserX += gs.directionX
 gs.laserY += gs.directionY

 // 清除畫布
 laserCtx.Call("clearRect", 0, 0, windowSize.w, windowSize.h)

 //畫一個小紅點 ?
 laserCtx.Call("beginPath")
 laserCtx.Call("arc", gs.laserX, gs.laserY, gs.laserSize, 0, math.Pi*2, false)
 laserCtx.Call("fill")
 laserCtx.Call("closePath")
}

// 判斷點選的點是不是在小紅點 ? 內部
func isLaserCaught(mouseX, mouseY, laserX, laserY float64) bool {
 // 直接這樣返回是不行的
 // return laserCtx.Call("isPointInPath", mouseX, mouseY).Bool()
 
 // 所以這裡我透過勾股定理 ? 來實現
 // 同時我給 laserSize 屬性的值加上 15,讓貓爪更容易點選  ?
 return (math.Pow(mouseX-laserX, 2) + math.Pow(mouseY-laserY, 2)) < math.Pow(gs.laserSize+15, 2)
}

總結

事實上,WASM 仍然被認為是一個 [MVP](https://hacks.mozilla.org/2018/10/webassembly -post- MVP -future/) (MAP),你可以不用編寫一行 JS,就能建立一個像這樣的遊戲。驚不驚訝!CanIUse 上 WASM 的支援已經是一片綠色了,沒有人可以阻止你去建立基於 WASM 的網站和應用。

你可以組合所有你想要的語言,像是把 JS 轉成 WASM。最後,它們都將編譯成 WASM 位元組碼。如果你需要在他們之間分享任何東西,也沒問題,因為它們可以共享原始記憶體。

我擔心的是,在最近的新聞中,我們關注到 微軟正在開發 Chromium 瀏覽器 還有 Firefox市場份額低於9%。這使谷歌在 WASM 上有了致命的切換能力。如果他們不願意配合,大眾可能永遠不會知道有這個特性。

可能是世界上最簡單的用 Go 來寫 WebAssembly 的教程
你看這隻貓玩的多開心 ?

現在都有誰在用 WASM?

你必須得承認,我的專案已經在用了。這個專案僅僅是畫了一個全屏的畫布,這裡有一些更高階的例子,它們關注於語義 Web awesome-wasm#web-frameworks-libraries

同時,也有相當多的專案已經上了 WASM 的車了。我對 Spotify、Twitch 和 Figma 和 EWASM 更感興趣。

Web3 時代的 WASM

現在,如果你想在手機上使用以太坊錢包(Ethereum wallet),你必須從應用商店下載一個類似於 Status.im 的移動端錢包 App,並且信任所有商家。

如果有一個先進的 Web App,可以執行 geth (Go Ethereum 客戶端),並且能在 WebRTC 上光速同步,這會怎麼樣?它可以使用 Service Worker 來更新它的 WASM 程式碼並在後臺執行,可以託管在 IPFS/Dat 上。

一些有用的關於 WASM 的文章、資源還有學習資料 ?

感謝 twifkak 在 Android Chrome 上對 Go 的最佳化!

相關文章