DOM Manipulation and Browser APIs
Working with the DOM from Go WebAssembly felt weird at first. I was used to Go’s clean, typed interfaces, and suddenly I was dealing with the dynamic, loosely-typed world of browser APIs. But after building several apps that heavily manipulate the DOM, I’ve learned patterns that make it manageable.
The key insight is treating DOM manipulation as a specialized interface layer, not the core of your application. Your Go code should handle computation and business logic, while using DOM manipulation to present results and handle user interactions.
Building a DOM Abstraction
Rather than scattering DOM calls throughout your code, I create an abstraction layer that provides a clean, Go-like interface:
type DOMManager struct {
document js.Value
}
func NewDOMManager() *DOMManager {
return &DOMManager{
document: js.Global().Get("document"),
}
}
func (dm *DOMManager) GetElement(id string) (js.Value, error) {
element := dm.document.Call("getElementById", id)
if element.IsNull() {
return js.Value{}, fmt.Errorf("element '%s' not found", id)
}
return element, nil
}
func (dm *DOMManager) SetText(id, text string) error {
element, err := dm.GetElement(id)
if err != nil {
return err
}
element.Set("textContent", text)
return nil
}
func (dm *DOMManager) SetHTML(id, html string) error {
element, err := dm.GetElement(id)
if err != nil {
return err
}
element.Set("innerHTML", html)
return nil
}
This abstraction hides the complexity of JavaScript calls and provides consistent error handling.
Event Handling Patterns
Event handling is where DOM manipulation becomes particularly important for interactive apps. I’ve developed patterns that make event handling reliable and easy to debug:
type EventHandler struct {
dom *DOMManager
callbacks map[string]js.Func
}
func NewEventHandler(dom *DOMManager) *EventHandler {
return &EventHandler{
dom: dom,
callbacks: make(map[string]js.Func),
}
}
func (eh *EventHandler) OnClick(elementId string, handler func()) error {
element, err := eh.dom.GetElement(elementId)
if err != nil {
return err
}
jsHandler := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
handler()
return nil
})
// Store reference to prevent garbage collection
eh.callbacks[elementId+"_click"] = jsHandler
element.Call("addEventListener", "click", jsHandler)
return nil
}
func (eh *EventHandler) OnInput(elementId string, handler func(string)) error {
element, err := eh.dom.GetElement(elementId)
if err != nil {
return err
}
jsHandler := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
value := element.Get("value").String()
handler(value)
return nil
})
eh.callbacks[elementId+"_input"] = jsHandler
element.Call("addEventListener", "input", jsHandler)
return nil
}
This pattern provides clean interfaces for common events while handling the JavaScript integration complexity.
Working with Forms
Forms are critical for most web apps, and handling them from WebAssembly requires understanding both DOM APIs and Go’s type system:
type FormManager struct {
dom *DOMManager
}
func (fm *FormManager) GetFormData(formId string) (map[string]string, error) {
form, err := fm.dom.GetElement(formId)
if err != nil {
return nil, err
}
data := make(map[string]string)
elements := form.Get("elements")
length := elements.Get("length").Int()
for i := 0; i < length; i++ {
element := elements.Index(i)
name := element.Get("name").String()
value := element.Get("value").String()
if name != "" {
data[name] = value
}
}
return data, nil
}
func (fm *FormManager) ValidateForm(formId string, rules map[string]func(string) error) []string {
data, err := fm.GetFormData(formId)
if err != nil {
return []string{err.Error()}
}
var errors []string
for field, rule := range rules {
if value, exists := data[field]; exists {
if err := rule(value); err != nil {
errors = append(errors, fmt.Sprintf("%s: %s", field, err.Error()))
}
}
}
return errors
}
This form handling approach provides type-safe validation while working with the dynamic nature of HTML forms.
Browser API Integration
Beyond DOM manipulation, WebAssembly apps often need to interact with browser APIs like localStorage, fetch, or geolocation:
type BrowserAPI struct {
window js.Value
}
func NewBrowserAPI() *BrowserAPI {
return &BrowserAPI{
window: js.Global().Get("window"),
}
}
func (api *BrowserAPI) LocalStorageSet(key, value string) {
localStorage := api.window.Get("localStorage")
localStorage.Call("setItem", key, value)
}
func (api *BrowserAPI) LocalStorageGet(key string) (string, bool) {
localStorage := api.window.Get("localStorage")
value := localStorage.Call("getItem", key)
if value.IsNull() {
return "", false
}
return value.String(), true
}
func (api *BrowserAPI) FetchJSON(url string, callback func(map[string]interface{}, error)) {
promise := js.Global().Call("fetch", url)
promise.Call("then", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
response := args[0]
return response.Call("json")
})).Call("then", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
// Convert JavaScript object to Go map
jsData := args[0]
goData := convertJSObjectToMap(jsData)
callback(goData, nil)
return nil
})).Call("catch", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
error := args[0]
callback(nil, fmt.Errorf("fetch error: %s", error.Get("message").String()))
return nil
}))
}
This provides clean Go interfaces to common browser APIs while handling the asynchronous nature of many browser operations.
File Handling
File handling in browsers requires special consideration because of security restrictions:
func (api *BrowserAPI) ReadFile(inputId string, callback func(string, []byte)) error {
input, err := api.dom.GetElement(inputId)
if err != nil {
return err
}
changeHandler := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
files := input.Get("files")
if files.Get("length").Int() > 0 {
file := files.Index(0)
filename := file.Get("name").String()
reader := js.Global().Get("FileReader").New()
reader.Set("onload", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
result := reader.Get("result")
// Convert ArrayBuffer to []byte
uint8Array := js.Global().Get("Uint8Array").New(result)
length := uint8Array.Get("length").Int()
data := make([]byte, length)
for i := 0; i < length; i++ {
data[i] = byte(uint8Array.Index(i).Int())
}
callback(filename, data)
return nil
}))
reader.Call("readAsArrayBuffer", file)
}
return nil
})
input.Call("addEventListener", "change", changeHandler)
return nil
}
This pattern handles file reading while converting between JavaScript and Go data types safely.
Performance Considerations
DOM manipulation can be expensive, especially when done frequently. I’ve learned techniques that maintain good performance:
- Batch DOM operations when possible
- Cache element references instead of looking them up repeatedly
- Use efficient selectors and avoid complex queries
- Minimize reflows and repaints by grouping style changes
The abstraction layers I’ve shown help with these optimizations by providing centralized points where you can implement performance improvements.
Common Pitfalls
I’ve made every DOM manipulation mistake possible. Here are the most common ones:
- Not checking if elements exist before manipulating them
- Forgetting to release js.Func objects causing memory leaks
- Making too many small DOM updates instead of batching them
- Not handling asynchronous operations properly
Testing DOM Code
Testing DOM manipulation code requires browser environments, but you can structure your code to make testing easier:
// Testable business logic
func CalculateTotal(items []Item) float64 {
total := 0.0
for _, item := range items {
total += item.Price * float64(item.Quantity)
}
return total
}
// DOM integration (harder to test)
func UpdateTotalDisplay(total float64) {
dom := NewDOMManager()
dom.SetText("total", fmt.Sprintf("$%.2f", total))
}
Keep your business logic separate from DOM manipulation, and you can test most of your code with standard Go tests.
Working with browser APIs from Go feels natural once you establish these patterns. The key is treating the DOM as just another interface to implement, not something fundamentally different from other Go code.
Performance optimization comes next - techniques for making your WebAssembly applications not just work, but work fast.