Всем привет! 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