Всем привет! 24 августа 2018 вышла версия Go 1.11 с экспериментальной поддержкой WebAssembly (Wasm). Технология интересная и у меня сразу возникло желание поэкспериментировать. Написать "Hello World" скучно (и он кстати есть в документации), тем более тренд прошедшего лета статьи из серии "Как сделать поиск пользователей по GitHub <вставить свой любимый JS-фреймворк>"
Итак, данная статья является продолжением цикла. Вот предыдущие главы:
Как сделать поиск пользователей по GitHub используя React + RxJS 6 + Recompose
Как сделать поиск пользователей по GitHub без React + RxJS 6 + Recompose
Как сделать поиск пользователей по GitHub, используя Vue
Как сделать поиск пользователей по Github используя Angular
Как сделать поиск пользователей по Github используя VanillaJS
Задача: необходимо реализовать поиск пользователей по GitHub с вводом логина и динамическим формированием HTML. Как показывают предыдущие публикации делается это элементарно, но в нашем случае мы не будем использовать JavaScript. Должно получиться вот это:

Внимание! Данная статья не призывает бросать JS и переписывать все web-приложения на Go, а только показывает возможности работы с JS API из WASM.
Установка
Прежде всего стоит обновиться до последней версии Go (на момент написания статьи 1.11)
Копируем два файла с поддержкой HTML & JS в свой проект
cp $(go env GOROOT)/misc/wasm/wasm_exec.{html,js} .
Hello World и базовую настройку HTTP сервера я пропущу, подробности можно прочитать на Go Wiki
Взаимодействие с DOM
Осуществляется через пакет syscall/js
В статье будут использоваться следующие функции и методы для управления JavaScript:
// тип, представляющий значение JavaScript js.Value // возвращает глобальный объект JavaScript, в браузере это `window` func Global() Value // вызывает метод объекта как функцию func (v Value) Call(m string, args ...interface{}) Value // возвращают значения полей объекта func (v Value) Get(p string) Value // устанавливает значения полей объекта func (v Value) Set(p string, x interface{})
Все м��тоды имеют аналоги в JS, и станут понятнее по ходу статьи.
Поле ввода
Для начала создадим поле, в которое пользователь будет вводить логин для поиска, это будет классический тег input с placeholder.
В дальнейшем нам потребуется создание еще одного тега, поэтому мы сразу напишем конструктор HTML-элементов.
type Element struct { tag string params map[string]string }
Структура HTML-элемента содержит название тега tag (например input, div и т.д) и дополнительные параметры params (например: placeholder, id и т.д)
Сам конструктор выглядит так:
func (el *Element) createEl() js.Value { e := js.Global().Get("document").Call("createElement", el.tag) for attr, value := range el.params { e.Set(attr, value) } return e }
e := js.Global().Get("document").Call("createElement", el.tag) это аналог var e = document.createElement(tag) в JS
e.Set(attr, value) аналог e.setAttribute(attr, value)
Данный метод только создает элементы, но не добавляет их на страницу.
Чтобы добавить элемент на страницу необходимо определить место его вставки. В нашем случае это div с id="box" (в wasm_exec.html строка <div id="box"></div>)
type Box struct { el js.Value } box := Box{ el: js.Global().Get("document").Call("getElementById", "box"), }
В box.el храниться ссылка на основной контейнер нашего приложения.
js.Global().Get("document").Call("getElementById", "box") в JS это document.getElementById('box')
Сам метод создания input-элемента:
func (b *Box) createInputBox() js.Value { // Конструктор el := Element{ tag: "input", params: map[string]string{ "placeholder": "GitHub username", }, } // Создание элемента input := el.createEl() // Вывод на страницу в div с id="box" b.el.Call("appendChild", input) return input }
Данный метод возвращает ссылку на созданный элемент
<input placeholder="GitHub username">
Контейнер вывода результатов
Полученные результаты необходимо выводить на страницу, давайте добавим div c id="search_result" по аналогии с input
func (b *Box) createResultBox() js.Value { el := Element{ tag: "div", params: map[string]string{ "id": "search_result", }, } div := el.createEl() b.el.Call("appendChild", div) return div }
Создается контейнер и возвращается ссылка на элемент
<div id="search_result"></div>
Настало время определить структуру для всего нашего Web-приложения
type App struct { inputBox js.Value resultBox js.Value } a := App{ inputBox: box.createInputBox(), resultBox: box.createResultBox(), }
inputBox и resultBox — ссылки на ранее созданные элементы <input placeholder="GitHub username"> и <div id="search_result"></div> соответственно
Отлично! Мы добавили два элемента на страницу. Теперь пользователь может вводить данные в input и смотреть на пустой div, уже неплохо, но пока наше приложение не интерактивно. Давайте исправим это.
Событие ввода
Нам необходимо отслеживать когда пользователь вводит логин в input и получать эти данные, для этого подписываемся на событие keyup, сделать это очень просто
func (a *App) userHandler() { a.input.Call("addEventListener", "keyup", js.NewCallback(func(args []js.Value) { e := args[0] user := e.Get("target").Get("value").String() println(user) })) }
e.Get("target").Get("value") — получение значения input, аналог event.target.value в JS, println(user) обычный console.log(user)
Таким образом мы консолим все действия пользователя по вводу логина в input.
Теперь у нас есть данные, с которыми мы можем формировать запрос к GitHub API
Запросы к GitHub API
Запрашивать мы будем информацию по зарегистрированным пользователям: get-запрос на https://api.github.com/users/:username
Но сперва определим структуру ответа GitHub API
type Search struct { Response Response Result Result } type Response struct { Status string }` `type Result struct { Login string `json:"login"` ID int `json:"id"` Message string `json:"message"` DocumentationURL string `json:"documentation_url"` AvatarURL string `json:"avatar_url"` Name string `json:"name"` PublicRepos int `json:"public_repos"` PublicGists int `json:"public_gists"` Followers int `json:"followers"` }
Response — содержит ответ сервера, для нашего приложения нужен только статус Status string — он потребуется для вывода на странице ошибки.
Result — тело ответа в сокращенном виде, только необходимые поля.
Сами запросы формируются через стандартный пакет net/http
func (a *App) getUserCard(user string) { resp, err := http.Get(ApiGitHub + "/users/" + user) if err != nil { log.Fatal(err) } defer resp.Body.Close() b, err := ioutil.ReadAll(resp.Body) if err != nil { log.Fatal(err) } var search Search json.Unmarshal(b, &search.Result) search.Response.Status = resp.Status a.search <- search }
Теперь, когда у нас есть метод получение информации о пользователе с GitHub API давайте модифицируем userHandler() и попутно расширим структуру Web-приложения App добавив туда канал chan Search для передачи данных из горутины getUserCard()
type App struct { inputBox js.Value resultBox js.Value search chan Search } a := App{ inputBox: box.createInputBox(), resultBox: box.createResultBox(), search: make(chan Search), } func (a *App) userHandler() { a.input.Call("addEventListener", "keyup", js.NewCallback(func(args []js.Value) { e := args[0] user := e.Get("target").Get("value").String() go a.getUserCard(user) })) }
Шаблонизатор
Прекрасно! Мы получили информацию о пользователе и у нас есть контейнер для вставки. Теперь нам нужен HTML-шаблон и разумеется какой-нибудь простой шаблонизатор. В нашем приложении будем использовать mustache — это популярный шаблонизатор с простой логикой.
Установка: go get github.com/cbroglie/mustache
Сам HTML-шаблон user.mustache находится в директории tmpl нашего приложения и выглядит следующим образом:
<div class="github-card user-card"> <div class="header User" /> <a class="avatar" href="https://github.com/{{Result.Login}}"> <img src="{{Result.AvatarURL}}&s=80" alt="{{Result.Name}}" /> </a> <div class="content"> <h1>{{Result.Name}}</h1> <ul class="status"> <li> <a href="https://github.com/{{Result.Login}}?tab=repositories"> <strong>{{Result.PublicRepos}}</strong>Repos </a> </li> <li> <a href="https://gist.github.com/{{Result.Login}}"> <strong>{{Result.PublicGists}}</strong>Gists </a> </li> <li> <a href="https://github.com/{{Result.Login}}/followers"> <strong>{{Result.Followers}}</strong>Followers </a> </li> </ul> </div> </div>
Все стили прописаны в web/style.css
Следующий шаг — получить шаблон в виде строки и прокинуть его в наше приложение. Для этого опять расширяем структуру App добавив туда нужные поля.
type App struct { inputBox js.Value resultBox js.Value userTMPL string errorTMPL string search chan Search } a := App{ inputBox: box.createInputBox(), resultBox: box.createResultBox(), userTMPL: getTMPL("user.mustache"), errorTMPL: getTMPL("error.mustache"), search: make(chan Search), }
userTMPL — шаблон вывода информации о пользователе user.mustache. errorTMPL — шаблон обработки ошибок error.mustache
Для получения шаблона из приложения используем обычный Get-запрос
func getTMPL(name string) string { resp, err := http.Get("tmpl/" + name) if err != nil { log.Fatal(err) } defer resp.Body.Close() b, err := ioutil.ReadAll(resp.Body) if err != nil { log.Fatal(err) } return string(b) }
Шаблон есть, данные есть теперь попробуем отрендерить HTML-представление
func (a *App) listResults() { var tmpl string for { search := <-a.search switch search.Result.ID { case 0: // TMPL for Error page tmpl = a.errorTMPL default: tmpl = a.userTMPL } data, _ := mustache.Render(tmpl, search) // Output the resultBox to a page a.resultBox.Set("innerHTML", data) } }
Это горутина, которая ожидает данные из канала <-a.search и рендерит HTML. Условно считаем, что если в данных из GitHub API есть ID-пользователя search.Result.ID то результат корректный, в противном случае возвращаем страницу ошибки.
data, _ := mustache.Render(tmpl, search) — рендерит готовый HTML, а a.resultBox.Set("innerHTML", data) выводит HTML на страницу
Debounce
Работает! Но есть одна проблема — если посмотреть в консоль мы увидим, что на каждое нажатие клавиши отправляется запрос к GitHub API, при таком раскладе мы быстро упремся в лимиты.

Решение — Debounce. Это функция, которая откладывает вызов другой функции на заданное время. То есть когда пользователь нажимает кнопку мы должны отложить запрос к GitHub API на X миллисекунд, при этом если срабатывает еще одно событие нажатие кнопки — запрос откладывается еще на X миллисекунд.
Debounce в Go реализуется с помощью каналов. Рабочий вариант взял из статьи debounce function for golang
func debounce(interval time.Duration, input chan string, cb func(arg string)) { var item string timer := time.NewTimer(interval) for { select { case item = <-input: timer.Reset(interval) case <-timer.C: if item != "" { cb(item) } } } }
Перепишем метод (a *App) userHandler() с учетом Debounce:
func (a *App) userHandler() { spammyChan := make(chan string, 10) go debounce(1000*time.Millisecond, spammyChan, func(arg string) { // Get Data with github api go a.getUserCard(arg) }) a.inputBox.Call("addEventListener", "keyup", js.NewCallback(func(args []js.Value) { e := args[0] user := e.Get("target").Get("value").String() spammyChan <- user println(user) })) }
Событие keyup срабатывает всегда, но запросы отправляются только через 1000ms после завершения последнего события.
Полируем
И напоследок немного улучшим наш UX, добавив индикатор загрузки "Loading..." и очистку контейнера в случае пустого input
func (a *App) loadingResults() { a.resultBox.Set("innerHTML", "<b>Loading...</b>") } func (a *App) clearResults() { a.resultBox.Set("innerHTML", "") }
Конечный вариант метода (a *App) userHandler() выглядит так:
func (a *App) userHandler() { spammyChan := make(chan string, 10) go debounce(1000*time.Millisecond, spammyChan, func(arg string) { // Get Data with github api go a.getUserCard(arg) }) a.inputBox.Call("addEventListener", "keyup", js.NewCallback(func(args []js.Value) { // Placeholder "Loading..." a.loadingResults() e := args[0] // Get the value of an element user := e.Get("target").Get("value").String() // Clear the results block if user == "" { a.clearResults() } spammyChan <- user println(user) })) }
Готово! Теперь у нас есть полноценный поиск пользователей по GitHub без единой строчки на JS. По моему это круто.
Вывод
Написать Web-приложение работающее с DOM на wasm возможно, но стоит ли это делать — вопрос. Во-первых, пока не понятно как тестировать код, во-вторых, в некоторых браузерах работает не стабильно (например в Хроме падало с ошибкой через раз, в FF с этим лучше), в-третьих вся работа с DOM осуществляется через JS API, что должно сказываться на производительности (правда замеры не делал, поэтому все субъективно)
Кстати большинство примеров это работа с графикой в canvas и выволнение тяжелых вычислений, скорее всего wasm проектировался именно для этих задач. Хотя… время покажет.
Сборка и запуск
Клонируем репозиторий
cd work_dir git clone https://github.com/maxchagin/gowasm-example ./gowasm-example cd gowasm-example
Сборка
GOARCH=wasm GOOS=js go build -o web/test.wasm main.go
Запуск сервера
go run server.go
Просмотр
http://localhost:8080/web/wasm_exec.html
