Let's write frontend in Go

Let's write frontend in Go

I wrote a CLI to quickly bootstrap a simple Go + WebAssembly application. It contains a dev server, auto-build and glue code. It also uses tinygo under the hood to produce the smallest binary possible. Go check it out on GitHub!

Intro

Recently, Go 1.12 released. Go now has a great WebAssembly support, even a special UMD script for better DX.

So, if we have WebAssembly, it means that we can access DOM. Go even has a special package for that – syscall/js. It basically just gets / sets global objects or calls functions. So, to avoid writing ugly Call() & Get("document") code, we'll be using godom, a library with predefined shortened functions. It is mostly used for GopherJS (Go to JS compiler), but has WebAssembly support as well.

Requirements

  • go 1.12+
  • Firefox 68 (was used during writing the article) / Chrome 69 / any other browser that supports WebAssembly

Let's code!

Glue

Let's create glue.js and index.html to make WebAssembly work. Even though WASM is magic that runs natively it still needs some glue to load itself.

index.html

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>Go frontend app</title>
<script src="wasm_exec.js"></script>
<script src="glue.js"></script>
</head>
<body>
<div id="app"></div>
</body>
</html>

Here we attached two scripts and added a target container.

Don't forget to copy wasm_exec script!

cp $(go env GOROOT)/misc/wasm/wasm_exec.js

glue.js

const go = new Go()
WebAssembly.instantiateStreaming(
fetch('app.wasm', {
headers: {
'Content-Type': 'application/wasm',
},
}),
go.importObject
).then((result) => go.run(result.instance))

In our glue code, we created an instance of a special Go class for better error messages and some setup especially for Go, launched a WebAssembly stream using fetch (e.g. wasm module loads asynchronously), created a new WebAssembly instance, and ran it.

Our frontend setup is done. Let's do some stuff with Go.

Go Setup

Init go module:

go mod init

Install godom (wasm package):

go get -u -v github.com/siongui/godom/wasm

Go Code

At first let's write a simple Hello World markup:

package main
import (
dom "github.com/siongui/godom/wasm"
)
func main() {
app := dom.Document.GetElementById("app")
app.SetInnerHTML(`
<div>
<h1>Hello World</h1>
<p>This page is built with Go, GoDOM and WebAssembly</p>
</div>
`)
}

As you can see here, we selected our #app div defined in an HTML file. Then we modify a property called "InnerHTML". godom doesn't have a function for every DOM interaction but you can still use syscall/js "Call" for such cases.

Compile Go to WebAssembly

We need to compile our go code to wasm to make it work. To compile to wasm, we should set system architecture to WASM, and OS to JS. Those is certainly no real OS called "JS" or processor architecture "WASM". Go uses them to detect that we want to compile it to WebAssembly instead of building a native executable.

In the terminal run this command:

ARCH=WASM OS=js go build -o app.wasm index.go

Now we have a compiled file. It is called "app.wasm".

The last step is running a server to see the result. There are a few quick options that you can use:

goexec 'http.ListenAndServe(":8080", http.FileServer(http.Dir(".")))'
  • Using Python:
python -m http.server -p 8080
  • Using Node.js and serve module:
serve -p 8080

We see hello world!

Hello World

This page is built with Go, GoDOM and WebAssembly

Interactive DOM

It is already very fun to write webpages with innerHTML but let's make some more complex stuff. We will attach a handler to a button and also add content with JS.

First, we import syscall/js because godom doesn't have some functions we need:

package main
import (
dom "github.com/siongui/godom/wasm"
"syscall/js"
)

Then we take our container (#app), create two elements – <span> and <button>.

app := dom.Document.GetElementById("app")
button := dom.Document.CreateElement("button")
text := dom.Document.CreateElement("span")

Fine, let's add some text to our button.

// Set button text
button.Set("textContent", "Click on me")

Now here all the magic happens. We're gonna make a callback handler for click event. syscall/js has a special wrapper for JavaScript functions js.FuncOf and Func is an interface for FuncOf.

// Callback for click event
var cb js.Func
cb = js.FuncOf(func(this js.Value, args []js.Value) interface{} {
text.Set("textContent", "Button was clicked")
return nil
})
// Add event listener to a button
button.Call("addEventListener", "click", cb)

We use an array for JS arguments because in JS we pass them as a simple list of things for functions:

// Small example
const args = (...arguments) => console.log(arguments)
args(0, 1, 2)

The last thing we need to do is to append our elements to container:

app.Call("appendChild", text)
app.Call("appendChild", button)

But there is one small problem...

wasm_exec.js:378 Uncaught Error: bad callback: Go program has already exited
at global.Go._resolveCallbackPromise (wasm_exec.js:378)
at wasm_exec.js:394
at <anonymous>:1:1

Go program is already finished and it skips our callbacks. Let's fix it.

We don't want Go to exit our program so we have to use channels. We need to put this in the beginning of our main function:

// Create a channel that doesn't let our Go program exit
c := make(chan bool)

And in the end we put this:

<-c

Now our Go program doesn't exit even with callbacks!

Let's try, it works!

Conclusion

Although, all the API calls for now are made through JS, the fact that we can now pick other language than JavaScript is impressive. Probably, in the future, WebAssembly will get it's own DOM API.