Всем привет! 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:
js.Value // тип, представляющий значение JavaScript
func Global() Value // возвращает глобальный объект JavaScript, в браузере это `window`
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
Исходники на github
Интерактивное демо на lovefrontend.ru
Автор: Maxchagin