Goだけで作るフロントエンド


Go のシンタックスだけで HTML とスタイルと JS を構築する「Vecty」というフロントエンド開発キットの紹介

先日 Umeda.go#3 にて登壇して Vecty を紹介しました。資料はこちらです。

Vecty とは?

リポジトリ: https://github.com/gopherjs/vecty

GopherJSむけの React-like な frontend development kit です。

GopherJS について

Vecty の主な機能は

  • GopherJS の Go 記述を JS へのトランスパイル機能を利用します
  • Go のシンタックスのみで HTML とスタイルとイベントハンドリングを記述
  • HTML 記述ツリーをコンポーネントとして定義する支援機能
  • コンポーネントツリーを初期レンダリングする機能
  • 仮想 DOM のようにコンポーネントツリーを差分レンダリングする機能
  • コンポーネントプロパティを定義する機能

これだけです。

Vecty によるフロントの開発

下記のようなファイル構成を GOPATH 配下に作成しておき、(仮にパッケージ名が「app」とします)

  • app/ パッケージルート
    • main.go app.js のソースコード
    • index.html (オプショナル)

以下の開発サーバーを起動

gopherjs serve ./app # <- ファイルパスまたはパッケージパス

ブラウザにて「 http://localhost:8080/ 」を開くと、

gopherjs の開発サーバーは以下の特例を除き、指定したパスまたはパッケージをルートフォルダとしたスタティックファイルサーバーの様に振る舞います。

特例

  • app.js に対するリクエストを受けると app パッケージをビルドした結果を返す。
  • app.js.map に対するリクエストがあれば app パッケージをビルドした際に生成される map ファイルを返す。
  • /に対するリクエストを受けると app/index.html を返そうとする。
  • もし index.html が存在しない場合は app.js を script タグで読み込むだけの最小限の HTML を返す。

map ファイルはソースの URL と各行が生成 js のどの位置かを記録したファイルです。これと Go のソースコードがブラウザに配信されていることで、 Go のソースコードによるデバッグが可能になってます。

デバッグ

Vecty コンポーネント

Vecty コンポーネントの条件は以下の2つを満たす構造体定義です。

  • vecty.Core を埋め込む
  • Render メソッドを持つ

Render メソッドが返すのはコンポーネントまたは HTML 定義です。コンポーネントをネストした定義も使えますし、複数並べたものをまとめたものを記述することができます。最終的なツリーの枝の先は HTML 定義でなければなりません。

type MyComponent struct {
    vecty.Core
}
func (c *MyComponent) Render() vecty.ComponentOrHTML {
    return elem.Body(
        elem.Heading1(vecty.Text("Title")),
        elem.Paragraph(vecty.Text("hello world")),
    )
}

Vecty のレンダリング

  • vecty.RenderBody(c vecty.Component)
  • vecty.Rerender(c vecty.Component)

前者は初期レンダリング用で Body エレメントが  親のトップでなければなりません。後者は差分レンダリング用で RenderBody に渡したものかその子コンポーネントでなければなりません。

Vecty 付属のサンプル

GopherJS は現時点(2018-02-25)でまだ go1.10 対応はマージされていません。go1.9.4 を使うか、 gopherjs のブランチを go1.10 ブランチに切り替えて使うなどしてください。

https://medium.com/gopherjs/gopherjs-1-10-1-is-released-2ff9002a6712 go1.10-1 版でましたー。

準備

go get github.com/gopherjs/gopherjs
go get github.com/gopherjs/vecty

以下のサーバーを起動して http://localhost:8080 にアクセス。

gopherjs serve github.com/gopherjs/vecty/example

現在2種類のサンプルの動作が確認できます。 markdown サンプルは追加で以下のパッケージが必要です。

go get github.com/microcosm-cc/bluemonday
go get github.com/russross/blackfriday

作ってみたサンプル

ミニマムなサンプル

main.go

package main

import (
  "github.com/gopherjs/vecty"
  "github.com/gopherjs/vecty/elem"
)

type TopView struct {
  vecty.Core
}

func (c *TopView) Render() vecty.ComponentOrHTML {
  vecty.SetTitle("TopView")
  return elem.Body(
    elem.Heading1(vecty.Text("Top")),
    elem.Button(vecty.Text("click")),
  )
}

func main() {
  top := &TopView{}
  vecty.RenderBody(top)
}

プロパティを利用したサンプル

package main

import (
  "fmt"
  "time"

  "github.com/gopherjs/vecty"
  "github.com/gopherjs/vecty/elem"
  "github.com/gopherjs/vecty/event"
)

type TopView struct {
  vecty.Core
  Heading string `vecty:"prop"`
}

func (c *TopView) Render() vecty.ComponentOrHTML {
  vecty.SetTitle("TopView")
  return elem.Body(
    elem.Heading1(vecty.Text(c.Heading)),
    elem.Button(
      vecty.Markup(
        event.Click(func(ev *vecty.Event) {
          now := time.Now().Format(time.RFC3339Nano)
          c.Heading = fmt.Sprintf("Top: %s", now)
          vecty.Rerender(c)
        }),
      ),
      vecty.Text("click"),
    ),
  )
}

func main() {
  top := &TopView{Heading: "Top"}
  vecty.RenderBody(top)
}

ミソは以下の流れです

  • ボタンのクリック
  • Hedding プロパティの書き換え
  • vecty.Rerender(c)にて差分描画更新

ルーターを利用するサンプル

ルーターを導入してみます。

今のところおすすめは Vecty 用というわけではありませんが、

https://github.com/go-humble/router が使いやすいです。

main.go

package main

import (
  "github.com/go-humble/router"
  "github.com/gopherjs/vecty"
  "github.com/gopherjs/vecty/elem"
  "github.com/gopherjs/vecty/event"
  "github.com/gopherjs/vecty/prop"
)

type NormalView struct {
  vecty.Core
  Title string `vecty:"prop"`
  Next  string `vecty:"prop"`
}

func (c *NormalView) Render() vecty.ComponentOrHTML {
  vecty.SetTitle(c.Title)
  return elem.Body(
    elem.Heading1(vecty.Text(c.Title)),
    elem.Anchor(
      vecty.Markup(prop.Href("#/"+c.Next)),
      vecty.Text("link:"+c.Next),
    ),
    elem.Button(
      vecty.Markup(
        event.Click(func(ev *vecty.Event) {
          c.Title = c.Title + ":click"
          Refresh()
        }),
      ),
      vecty.Text("click"),
    ),
  )
}

var currentView vecty.Component

func Refresh() {
  vecty.Rerender(currentView)
}

func main() {
  r := router.New()
  r.ForceHashURL = true
  r.HandleFunc("/", func(ctx *router.Context) {
    currentView = &NormalView{Title: "top", Next: "next1"}
    vecty.RenderBody(currentView)
  })
  r.HandleFunc("/next1", func(ctx *router.Context) {
    currentView = &NormalView{Title: "next1", Next: "next2"}
    vecty.RenderBody(currentView)
  })
  r.HandleFunc("/next2", func(ctx *router.Context) {
    currentView = &NormalView{Title: "next2", Next: ""}
    vecty.RenderBody(currentView)
  })
  r.Start()
}

r.ForceHashURL = trueによりロケーションのハッシュ部分に SPA 用のパスがつくようになります。

例: http://localhost:8080/#/next2

この状態でブラウザヒストリもきちんと構築されるので前のページに戻ったりブックマークを残したりしても期待どおりに動きます。

r.Start()で現在のロケーションハッシュをもとに HundleFunc のいずれかが発火します。ロケーションハッシュがない場合は”#/“相当に遷移します。

アンカータグの href に”#/next2”を書いておくと、クリックにて”/next2”の HandleFunc を発火して画面遷移することができます。任意のタイミングで画面遷移させたい場合はr.Navigate("/next2")を呼ぶと同じ挙動になります。

スタイルシートを追加する方法

スタイルシートを読み込む機能は Vecty に備わっています。

bootstrap4 のスタイルシートを読み込む例

vecty.AddStylesheet("https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css")

QR コードコンポーネントを作ってみる

components/qrcode.go

package components

import (
  "bytes"
  "log"

  "github.com/gopherjs/vecty"
  "github.com/gopherjs/vecty/elem"

  "github.com/aaronarduino/goqrsvg"
  "github.com/ajstarks/svgo"
  "github.com/boombuler/barcode/qr"
)

// QRCode ...
type QRCode struct {
  vecty.Core
  CellSize int    `vecty:"prop"`
  Text     string `vecty:"prop"`
}

// Render ...
func (c *QRCode) Render() vecty.ComponentOrHTML {
  sz := 8
  if c.CellSize > 0 {
    sz = c.CellSize
  }
  code, err := qr.Encode(c.Text, qr.M, qr.Auto)
  if err != nil {
    log.Println(err)
    return nil
  }
  buff := bytes.NewBuffer(nil)
  g := svg.New(buff)
  qs := goqrsvg.NewQrSVG(code, sz)
  qs.StartQrSVG(g)
  qs.WriteQrSVG(g)
  g.End()
  return elem.Div(
    vecty.Markup(vecty.UnsafeHTML(buff.String())),
  )
}

このコンポーネントの驚くべきところは

  • QR コードの JS ライブラリは使っていない。
  • GopherJS を考慮していない Pure-Go のライブラリを使っただけ。

Vecty で SPA

リッチな SPA を構築するにあたり Vecty に足りない部分があります。

  • ルーターやビューという概念
  • コンポーネントセット
  • Flux 相当

ルーターやビューという概念

ルーターは https://github.com/go-humble/router を利用することで OK です。ビューという概念はコンポーネントに条件と機能追加することで実現できます。

  • ルーターの1パスに1ビューがマッピングされることを想定
  • Body エレメントを根にもつコンポーネント
  • Setup メソッドサポート(オプショナル)
  • Teardown メソッドサポート(オプショナル)

ビューを定形で扱うために github.com/go-humble/router と vecty のレンダラーをラップしたパッケージを起こしました。

ここを参考

このサンプルの「router」と「dispatcher」パッケージはそのまま他のアプリの実装に使えるので使い方とかまとまったら独立したパッケージにします。

コンポーネントセット

定番の Bootstrap4 の極プリミティブなコンポーネントセットを作ってライブラリにしました。

https://github.com/nobonobo/bootstrap4

使い方

import bs4 "github.com/nobonobo/bootstrap4"

...

func (c *MyComponent) Render() vecty.ComponentOrHTML {
    return &bs4.ButtonLinks{
        Size:     bs4.SizeLarge,
        Href:     "#/sample",
        Children: vecty.Text("Sample"),
    }
}

func main() {
    vecty.AddStylesheet("https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css")
    ...
}

Flux 相当

Vecty 付属のサンプル「todomvc」が Flux っぽいことを実践しています。この事例をアレンジしたサンプルを作ってみました。

  • actions: 振る舞いごとに渡すパラメータ定義
  • dispatcher: ビューとロジックを疎結合にするための仲介
  • handler: 振る舞いの実装群。唯一ストアを書き換える役割を持つ
  • store: アプリのフロントが持つデータを保持する。書き換えられるとカレントビューを Rerender する。
  • views: ビューコンポーネントの定義群。ビューはルーターの URL と1対1で対応する。
  • components: コンポーネントの定義群。
  • models: データの型定義群を入れておくところ。

これらの要素を 1 方向に回す

足りないものを補って ChatApp を作ってみた

Site: https://chatapp.irieda.com

コード: https://github.com/nobonobo/vecty-chatapp

  • リポジトリルート
    • app/ フロントエンド実装ルート
    • backend/ バックエンド実装
    • main.go HTTP サーバー実装

これはバックエンドと一緒にサーブするサーバー実装も含まれています。フロントのサンプルとしては $GOPATH/github.com/nobonobo/vecty-chatapp/app/ 配下だけを参考にしてください。

Flux + Router 相当の実装

実装例が app/ フォルダの下にあります。

大きい SPA アプリを作る際は以下のようなパッケージツリーが推奨です。

  • actions/ アクション定義置き場
  • assets/ 静的ファイル群置き場
    • app.css
  • components/ コンポーネント定義置き場
  • dispatcher/ ディスパッチャー実装
  • handlers/ アクションの振る舞い実装群
  • models/ データスキーマ定義置き場
  • router/ ルーターラッパー実装
  • store/ ストアデータ置き場
  • views/ ビュー定義置き場
  • index.html SPA の不変部分の HTML 記述
  • main.go GopherJS メイン実装(app.js のメインソース)

index.html の工夫

Vecty の一つの欠点は成果物 JS ファイルが数メガバイト以上になる点です。初期 HTML をスピナー表示にしておき、app.js を defer 付きで読むようにすると読み込み中の不安感は多少緩和されます。(ロード完了後ルーターが発火して画面遷移します)(複数の script を読む場合に async をつけると実行順がバラつくので依存がある場合は defer のほうが良い)

スマホ向け調整に必要であれば、viewport や manifest.json などの記述を追加しましょう。

タイトルは Vecty から動的にvecty.SetTitle("タイトル") にて変更できます。

index.html

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <link href="assets/app.css" media="all" rel="stylesheet" />
</head>
<body>
    <div class="loader">Loading...</div>
</body>
<script defer src="https://code.jquery.com/jquery-3.1.1.slim.min.js"></script>
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/tether/1.4.0/js/tether.min.js"></script>
<script defer src="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-alpha.6/js/bootstrap.min.js"></script>
<script defer src="app.js"></script>
</html>

もう一つの希望はスライドでは言及していますが、

https://jsgo.io

というサイトが GopherJS のコンパイル結果を CDN キャッシュしてくれるというところ。うまく使えれば初期表示は大幅に高速化できるし開発中も手早く再構築できますね。

HTTP サーバーの工夫

main.go

package main

import (
  "context"
  "flag"
  "log"
  "net"
  "net/http"
  "net/http/httputil"
  "net/url"

  "github.com/nobonobo/vecty-chatapp/backend"
)

func main() {
  log.SetFlags(log.LstdFlags | log.Lshortfile)
  var development bool
  flag.BoolVar(&development, "dev", false, "reverseproxy to gopherjs serve(localhost:8080)")
  flag.Parse()
  if development {
    log.Println("development mode")
    u, _ := url.Parse("http://localhost:8080") // GopherJS開発サーバのエンドポイント
    rp := httputil.NewSingleHostReverseProxy(u)
    http.Handle("/", rp)
  } else {
    log.Println("normal mode")
    http.Handle("/", http.FileServer(http.Dir("./app")))
  }
  ctx, cancel := context.WithCancel(context.Background())
  defer cancel()
  h := backend.Setup(ctx, "/api")
  http.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
    log.Println(r.Method, r.RequestURI)
    h.ServeHTTP(w, r)
  })
  l, err := net.Listen("tcp", ":8888")
  if err != nil {
    log.Fatalln(err)
  }
  log.Println("listen:", l.Addr())
  if err := http.Serve(l, nil); err != nil {
    log.Fatalln(err)
  }
}

「development mode」では予め「gopherjs serve github.com/nobonobo/vecty-chatapp/app」として開発サーバーを起動しておき、フロントリソースへのリクエストをそこへリバースプロキシします。「/api/」で始まるパスへのアクセスはバックエンド実装がハンドルする仕掛けです。

開発中はこの「development mode」を利用すると大変便利です。

「normal mode」ではフロントのスタティックファイルを集めたフォルダを FileServer する挙動です。

このサンプル付属の Makefile を見てもらえばわかりますが、「make build」すれば dist フォルダにフロントとバックの配布用ファイル一式が出力されます。そのフォルダをカレントディレクトリにして「./server」を起動すればフロントは静的ファイルでバックエンドは Go の実装でサーブされます。

ルーターとビューの工夫

“github.com/nobonobo/vecty-chatapp/app/router” パッケージはルーターインスタンスをシングルトンで持つ形にラップしてある。

Godoc: https://godoc.org/github.com/nobonobo/vecty-chatapp/app/router

HandleFunc や RenderBody 、 Rerender は vecty のものではなく、 router.RenderBody(view) や router.Rerender() を使います。

import (
  orig "github.com/go-humble/router"
  "github.com/nobonobo/vecty-chatapp/app/router"
)

func main() {
  router.HandleFunc("/", func(ctx *orig.Context) {
    router.RenderBody(&views.TopView{})
  })
  router.Start()
}

また、router.RenderBody の際は以下の手順が実行されます。

  • 古いビューが Teardown メソッドをサポートしていればそのメソッドを呼ぶ。
  • 新しいビューを RenderBody する
  • 新しいビューが Setup メソッドをサポートしていればそのメソッドを呼ぶ。

この仕掛けにより、ビューのレンダリング直後に実行したい処理や後始末処理をカスタマイズできます。これらのフックは router.Navigate やアンカーが書き換えられた場合も同様に呼び出されます。

router.CurrentView()にて現在表示中のビューインスタンスを取得できます。 router.Navigate(path)にて現在表示中のビューインスタンスを切り替えます。

デプロイ

以下のものを揃えれば他のコンピューターリソースを使ってデプロイできます。

  • HTTP サーバー本体
  • app パッケージビルド結果の app.js
  • app/assets/配下のファイル一式

これらを揃えるためのシナリオは「mkae build」を参考にしてください。 app.js はミニファイするように「gopherjs build -m …」というように「-m」オプションを付けています。

また、

https://github.com/rakyll/statik

などを活用すればシングルバイナリ化もできます。

Vecty の Pros,Cons

Pros

  • 基本の知識は HTML/CSS/Go のみで OK
  • API やモデル定義をクラサバで共有できる
  • 成果物は ES5 相当で polyfill 不要
  • 豊富な PureGo のライブラリが利用可能
  • 型のある開発は素晴らしい
  • Vecty 本体は small & clear なので読みやすい(読む価値のあるコードは 1000 行くらいしか無い。残りはジェネレートされたコード)

Cons

  • 結局 DOM まわりの知識は必要(特にイベント周り)
  • DOM まわりを Go で置き換えるのに慣れが必要
  • JS の既存 lib 使うならやっぱり JS 知識が必要
  • 成果物 JS のファイルサイズがやや大きめ
  • 開発中でもパッキングするのでリロード重い
  • コンポーネントの設計はやはり難しい
  • Vecty 安定版までに破壊的変更の予定がある

まとめ

  • JS で必要な多くの処理を「gopherjs build」が一発で処理するため最終成果物生成速度がとにかく早い。
  • 依存解決、コード整形、テストなども Go のツールセットをそのまま使えます。
  • Pure-Go、JS、GopherJS すべての資産を利用可能。
  • フロントエンドもバックエンドも Go の記述で開発できます。(Isomorphic!)
  • 開発サーバーならブラウザで Go のソースコードでデバッグできます。

GopherJS+Vecty 大変おすすめですー。

PWA サポートが Android だけでなく Windows や iOS にやってくるみたいですね。よりこういった SPA のニーズは今後も増えてくることが予想されます。ネイティブに回帰するものと SPA で二極化が進むのかなぁ。従来のフォームサブミット&ページ再構築が多用されるスタイルはより嫌われるようになるのは間違いなさそうです。

追記

  • ツール間の調停作業が不要なのと例えば2年後同じやり方でアプリ作れるかという観点で見てもらえばよいかと。
  • コンポーネントを公式がガッツリ揃えてくれるのを期待してる人多いみたいですね。
  • しかしあったとしても結局デザインやニーズが揃わなくて自分で作ることになると思いますよ。
  • コンポーネントセットをデザインを統一して提供するのはユーザードメインの課題の肩代わりです。
  • GopherJS や Vecty も Go の様に「必要十分な機能を直交するように提供」することに注力していくので、公式にコンポーネントセットを提供するようなことはおそらくしないでしょう。
  • SSR もそうです。「Why Vecty doesn’t support SSR」という FAQ を準備中です。
  • JSX ライクな GOX は Vecty の最新の変更に追従していないので組み合わせにはご注意ください。
  • HTML 構築にツラミを感じたらどんどん使い捨てコンポーネントにしちゃいましょう。
  • 誰にでも満足できるコンポーネントを作るのはプロでもなかなかできないので諦めましょう。
  • (逆にプロほどデカイものを作りすぎてチューニングしにくい、カスタマイズしにくいものを作りがち。)

参考情報

どのくらいの JS ファイルサイズ?

  • react のサンプル群は 300KiB〜1MiB くらいがおおいですね。
  • vue.js のサンプル群は 1MiB~2MiB くらい。
  • minimal.js が約 345KiB(gzip:62KiB)
  • chatapp の app.js が 6MiB(gzip:1MiB)

私の作った bootstrap4 コンポーネントセットは単一パッケージに全部入れてしまってるのがよろしくないですね。コンポーネント単位でパッケージ分けておいたほうが良さそう。(GopherJS は参照してないパッケージは JS に含めない仕組み)

電卓を作ってみた

HTML->Go コンバータをつくってみた。

Html 側に html 貼り付けると、Go 側に Vecty コンポーネントの Render メソッドで return 可能な記述が生成されます。

コマンドライン版のインストール

go get -u github.com/nobonobo/vecty-samples/html2go/cmd/html2go

使い方

cat index.html | html2go [-b]
# または
html2go [-b] index.html

-bつけると body タグを出力します(ビュー用)