简介

go-app是一个使用 Go + WebAssembly 技术编写渐进式 Web 应用的库。WebAssembly 是一种可以运行在现代浏览器中的新式代码。近两年来,WebAssembly 技术取得了较大的发展。我们现在已经可以使用 C/C++/Rust/Go 等高级语言编写 WebAssembly 代码。本来就来介绍go-app这个可以方便地使用 Go 语言来编写 WebAssembly 代码的库。

快速使用

go-app对 Go 语言版本有较高的要求(Go 1.14+),而且必须使用Go module。先创建一个目录并初始化Go Module(Win10 + Git Bash):

  1. $ mkdir go-app && cd go-app
  2. $ go mod init

然后下载安装go-app包:

  1. $ go get -u -v github.com/maxence-charriere/go-app/v6

至于Go module的详细使用,去看煎鱼大佬的Go Modules 终极入门

首先,我们要编写 WebAssembly 程序:

  1. package main
  2. import "github.com/maxence-charriere/go-app/v6/pkg/app"
  3. type Greeting struct {
  4. app.Compo
  5. name string
  6. }
  7. func (g *Greeting) Render() app.UI {
  8. return app.Div().Body(
  9. app.Main().Body(
  10. app.H1().Body(
  11. app.Text("Hello, "),
  12. app.If(g.name != "",
  13. app.Text(g.name),
  14. ).Else(
  15. app.Text("World"),
  16. ),
  17. ),
  18. ),
  19. app.Input().
  20. Value(g.name).
  21. Placeholder("What is your name?").
  22. AutoFocus(true).
  23. OnChange(g.OnInputChange),
  24. )
  25. }
  26. func (g *Greeting) OnInputChange(src app.Value, e app.Event) {
  27. g.name = src.Get("value").String()
  28. g.Update()
  29. }
  30. func main() {
  31. app.Route("/", &Greeting{})
  32. app.Run()
  33. }

go-app中使用组件来划分功能模块,每个组件结构中必须内嵌app.Compo。组件要实现Render()方法,在需要显示该组件时会调用此方法返回显示的页面。go-app使用声明式语法,完全使用 Go 就可以编写 HTML 页面,上面绘制 HTML 的部分比较好理解。上面代码中还实现了一个输入框的功能,并为它添加了一个监听器。每当输入框内容有修改,OnInputChange方法就会调用,g.Update()会使该组件重新渲染显示。

最后将该组件挂载到路径/上。

编写 WebAssembly 程序之后,需要使用交叉编译的方式将它编译为.wasm文件:

  1. $ GOARCH=wasm GOOS=js go build -o app.wasm

如果编译出现错误,使用go version命令检查 Go 是否是 1.14 或更新的版本。

接下来,我们需要编写一个 Go Web 程序使用这个app.wasm

  1. package main
  2. import (
  3. "log"
  4. "net/http"
  5. "github.com/maxence-charriere/go-app/v6/pkg/app"
  6. )
  7. func main() {
  8. h := &app.Handler{
  9. Title: "Go-App",
  10. Author: "dj",
  11. }
  12. if err := http.ListenAndServe(":8080", h); err != nil {
  13. log.Fatal(err)
  14. }
  15. }

go-app提供了一个app.Handler结构,它会自动查找同目录下的app.wasm(这也是为什么将目标文件设置为app.wasm的原因)。然后我们将前面编译生成的app.wasm放到同一目录下,执行该程序:

  1. $ go run main.go

默认显示"Hello World"

每日一库之33:go-app - 图1

在输入框中输入内容之后,显示会随之变化:

每日一库之33:go-app - 图2

可以看到,go-app为我们设置了一些基本的样式,网页图标等。

简单原理

GitHub 上这张图很好地说明了 HTTP 请求的执行流程:

每日一库之33:go-app - 图3

用户请求先到app.Handler层,它会去app.wasm中执行相关的路由逻辑、去磁盘上查找静态文件。响应经由app.Handler中转返回给用户。用户就看到了app.wasm渲染的页面。实际上,在本文中我们只需要编写一个 Go Web 程序,每次编写新的 WebAssembly 之后,将新编译生成的 app.wasm 文件拷贝到 Go Web 目录下重新运行程序即可。注意,如果页面未能及时刷新,可能是缓存导致的,可尝试清理浏览器缓存

组件

自定义一个组件很简单,只需要将app.Compo内嵌到结构中即可。实现Render()方法可定义组件的外观,实际上app.Compo有一个默认的外观,我们可以这样来查看:

  1. func main() {
  2. app.Route("/app", &app.Compo{})
  3. app.Run()
  4. }

编译生成app.wasm之后,一开始的 Go Web 程序不需要修改,直接运行,打开浏览器查看:

每日一库之33:go-app - 图4

事件处理

快速开始中,我们还介绍了如何使用事件。使用声明式语法app.Input().OnChange(handler)即可监听内容变化。事件处理函数必须为func (src app.Value, e app.Event)类型,app.Value是触发对象,app.Event是事件的内容。通过app.Value我们可以得到输入框内容、选择框的选项等信息,通过app.Event可以得到事件的信息,是鼠标事件、键盘事件还是其它事件:

  1. type ShowSelect struct {
  2. app.Compo
  3. option string
  4. }
  5. func (s *ShowSelect) Render() app.UI {
  6. return app.Div().Body(
  7. app.Main().Body(
  8. app.H1().Body(
  9. app.If(s.option == "",
  10. app.Text("Please select!"),
  11. ).Else(
  12. app.Text("You've selected "+s.option),
  13. ),
  14. ),
  15. ),
  16. app.Select().Body(
  17. app.Option().Body(
  18. app.Text("apple"),
  19. ),
  20. app.Option().Body(
  21. app.Text("orange"),
  22. ),
  23. app.Option().Body(
  24. app.Text("banana"),
  25. ),
  26. ).
  27. OnChange(s.OnSelectChange),
  28. )
  29. }
  30. func (s *ShowSelect) OnSelectChange(src app.Value, e app.Event) {
  31. s.option = src.Get("value").String()
  32. s.Update()
  33. }
  34. func main() {
  35. app.Route("/", &ShowSelect{})
  36. app.Run()
  37. }

上面代码显示一个选择框,当选项改变时上面显示的文字会做相应的改变。初始时:

每日一库之33:go-app - 图5

选择后:

每日一库之33:go-app - 图6

嵌套组件

组件可以嵌套使用,即在一个组件中使用另一个组件。渲染时将内部的组件表现为外部组件的一部分:

  1. type Greeting struct {
  2. app.Compo
  3. }
  4. func (g *Greeting) Render() app.UI {
  5. return app.P().Body(
  6. app.Text("Hello, "),
  7. &Name{name: "dj"},
  8. )
  9. }
  10. type Name struct {
  11. app.Compo
  12. name string
  13. }
  14. func (n *Name) Render() app.UI {
  15. return app.Text(n.name)
  16. }
  17. func main() {
  18. app.Route("/", &Greeting{})
  19. app.Run()
  20. }

上面代码在组件Greeting中内嵌了一个Name组件,运行显示:

每日一库之33:go-app - 图7

生命周期

go-app提供了组件的 3 个生命周期的钩子函数:

  • OnMount:当组件插入到 DOM 时调用;
  • OnNav:当一个组件所在页面被加载、刷新时调用;
  • OnDismount:当一个组件从页面中移除时调用。

例如:

  1. type Foo struct {
  2. app.Compo
  3. }
  4. func (*Foo) Render() app.UI {
  5. return app.P().Body(
  6. app.Text("Hello World"),
  7. )
  8. }
  9. func (*Foo) OnMount() {
  10. fmt.Println("component mounted")
  11. }
  12. func (*Foo) OnNav(u *url.URL) {
  13. fmt.Println("component navigated:", u)
  14. }
  15. func (*Foo) OnDismount() {
  16. fmt.Println("component dismounted")
  17. }
  18. func main() {
  19. app.Route("/", &Foo{})
  20. app.Run()
  21. }

编译运行,在浏览器中打开页面,打开浏览器控制台观察输出:

  1. component mounted
  2. component navigated: http://localhost:8080/

编写 HTML

在前面的例子中我们已经看到了如何使用声明式语法编写 HTML 页面。go-app为所有标准的 HTML 元素都提供了相关的类型。创建这些对象的方法名也比较好记,就是元素名的首字母大写。如app.Div()创建一个div元素,app.P()创建一个p元素,app.H1()创建一个h1元素等等。在go-app中,这些结构都是暴露出对应的接口供开发者使用的,如div对应HTMLDiv接口:

  1. type HTMLDiv interface {
  2. Body(nodes ...Node) HTMLDiv
  3. Class(v string) HTMLDiv
  4. ID(v string) HTMLDiv
  5. Style(k, v string) HTMLDiv
  6. OnClick(h EventHandler) HTMLDiv
  7. OnKeyPress(h EventHandler) HTMLDiv
  8. OnMouseOver(h EventHandler) HTMLDiv
  9. }

可以看到每个方法都返回该HTMLDiv自身,所以支持链式调用。调用这些方法可以设置元素的各方面属性:

  • Class:添加 CSS Class;
  • ID:设置 ID 属性;
  • Style:设置内置样式;
  • Body:设置元素内容,可以随意嵌套。div中包含h1pp中包含img等;

和设置事件监听:

  • OnClick:点击事件;
  • OnKeyPress:按键事件;
  • OnMouseOver:鼠标移过事件。

例如下面代码:

  1. app.Div().Body(
  2. app.H1().Body(
  3. app.Text("Title"),
  4. ),
  5. app.P().ID("id").
  6. Class("content").Body(
  7. app.Text("something interesting"),
  8. ),
  9. )

相当于 HTML 代码:

  1. <div>
  2. <h1>title</h1>
  3. <p id="id" class="content">
  4. something interesting
  5. </p>
  6. </div>

原生元素

我们可以在app.Raw()中直接写 HTML 代码,app.Raw()会生成对应的app.UI返回:

  1. svg := app.Raw(`
  2. <svg width="100" height="100">
  3. <circle cx="50" cy="50" r="40" stroke="green" stroke-width="4" fill="yellow" />
  4. </svg>
  5. `)

但是这种写法是不安全的,因为没有检查 HTML 的结构。

条件

我们在最开始的例子中就已经用到了条件语句,条件语句对应 3 个方法:If()/ElseIf()/Else()

IfElseIf接收两个参数,第一个参数为bool值。如果为true,则显示第二个参数(类型为app.UI),否则不显示。

Else必须在IfElseIf后使用,如果前面的条件都不满足,则显示传入Else方法的app.UI

  1. type ScoreUI struct {
  2. app.Compo
  3. score int
  4. }
  5. func (c *ScoreUI) Render() app.UI {
  6. return app.Div().Body(
  7. app.If(c.score >= 90,
  8. app.H1().
  9. Style("color", "green").
  10. Body(
  11. app.Text("Good!"),
  12. ),
  13. ).ElseIf(c.score >= 60,
  14. app.H1().
  15. Style("color", "orange").
  16. Body(
  17. app.Text("Pass!"),
  18. ),
  19. ).Else(
  20. app.H1().
  21. Style("color", "red").
  22. Body(
  23. app.Text("fail!"),
  24. ),
  25. ),
  26. app.Input().
  27. Value(c.score).
  28. Placeholder("Input your score?").
  29. AutoFocus(true).
  30. OnChange(c.OnInputChange),
  31. )
  32. }
  33. func (c *ScoreUI) OnInputChange(src app.Value, e app.Event) {
  34. score, _ := strconv.ParseUint(src.Get("value").String(), 10, 32)
  35. c.score = int(score)
  36. c.Update()
  37. }
  38. func main() {
  39. app.Route("/", &ScoreUI{})
  40. app.Run()
  41. }

上面我们根据输入的分数显示对应的文字,90及以上显示绿色的Good!60-90之间显示橙色的Pass!,小于60显示红色的Fail!。下面是运行结果:

每日一库之33:go-app - 图8

每日一库之33:go-app - 图9

每日一库之33:go-app - 图10

Range

假设我们要编写一个 HTML 列表,当前有一个字符串的切片。如果一个个写就太繁琐了,而且不够灵活,且容易出错。这时就可以使用Range()方法了:

  1. type RangeUI struct {
  2. app.Compo
  3. name string
  4. }
  5. func (*RangeUI) Render() app.UI {
  6. langs := []string{"Go", "JavaScript", "Python", "C"}
  7. return app.Ul().Body(
  8. app.Range(langs).Slice(func(i int) app.UI {
  9. return app.Li().Body(
  10. app.Text(langs[i]),
  11. )
  12. }),
  13. )
  14. }
  15. func main() {
  16. app.Route("/", &RangeUI{})
  17. app.Run()
  18. }

Range()可以对切片或map中每一项生成一个app.UI,然后平铺在某个元素的Body()方法中。

运行结果:

每日一库之33:go-app - 图11

上下文菜单

go-app中,我们可以很方便的自定义右键弹出的菜单,并且为菜单项编写响应:

  1. type ContextMenuUI struct {
  2. app.Compo
  3. name string
  4. }
  5. func (c *ContextMenuUI) Render() app.UI {
  6. return app.Div().Body(
  7. app.Text("Hello, World"),
  8. ).OnContextMenu(c.OnContextMenu)
  9. }
  10. func (*ContextMenuUI) OnContextMenu(src app.Value, event app.Event) {
  11. event.PreventDefault()
  12. app.NewContextMenu(
  13. app.MenuItem().
  14. Label("item 1").
  15. OnClick(func(src app.Value, e app.Event) {
  16. fmt.Println("item 1 clicked")
  17. }),
  18. app.MenuItem().Separator(),
  19. app.MenuItem().
  20. Label("item 2").
  21. OnClick(func(src app.Value, e app.Event) {
  22. fmt.Println("item 2 clicked")
  23. }),
  24. )
  25. }
  26. func main() {
  27. app.Route("/", &ContextMenuUI{})
  28. app.Run()
  29. }

我们在OnContextMenu中调用了event.PreventDefault()阻止默认菜单的弹出。看运行结果:

每日一库之33:go-app - 图12

点击菜单项,观察控制台输出~

app.Handler

上面我们都是使用go-app内置的app.Handler处理客户端的请求。我们只设置了简单的两个属性AuthorTitleapp.Handler还有其它很多字段可以定制:

  1. type Handler struct {
  2. Author string
  3. BackgroundColor string
  4. CacheableResources []string
  5. Description string
  6. Env Environment
  7. Icon Icon
  8. Keywords []string
  9. LoadingLabel string
  10. Name string
  11. RawHeaders []string
  12. RootDir string
  13. Scripts []string
  14. ShortName string
  15. Styles []string
  16. ThemeColor string
  17. Title string
  18. UseMinimalDefaultStyles bool
  19. Version string
  20. }
  • Icon:设置应用图标;
  • Styles:CSS 样式文件;
  • Scripts:JS 脚本文件。

CSS 和 JS 文件必须在app.Handler中声明。下面是一个示例app.Handler

  1. h := &app.Handler{
  2. Name: "Luck",
  3. Author: "Maxence Charriere",
  4. Description: "Lottery numbers generator.",
  5. Icon: app.Icon{
  6. Default: "/web/icon.png",
  7. },
  8. Keywords: []string{
  9. "EuroMillions",
  10. "MEGA Millions",
  11. "Powerball",
  12. },
  13. ThemeColor: "#000000",
  14. BackgroundColor: "#000000",
  15. Styles: []string{
  16. "/web/luck.css",
  17. },
  18. Version: "wIKiverSiON",
  19. }

本文代码

本文中 WebAssembly 代码都在各自的目录中。Go Web 演示代码在 web 目录中。先进入某个目录,使用下面的命令编译:

  1. $ GOARCH=wasm GOOS=js go build -o app.wasm

然后将生成的app.wasm拷贝到web目录:

  1. $ cp app.wasm ../web/

切换到 web 目录,启动服务器:

  1. $ cd ../web/
  2. $ go run main.go

总结

本文介绍如何使用go-app编写基于 WebAssembly 的 Web 应用程序。可能有人会觉得,go-app编写 HTML 的方式有点繁琐。但是我们可以写一个转换程序将普通的 HTML 代码转为go-app代码,感兴趣可以自己实现一下。WebAssembly 技术非常值得关注一波~

大家如果发现好玩、好用的 Go 语言库,欢迎到 Go 每日一库 GitHub 上提交 issue😄

参考

  1. go-app GitHub:https://github.com/maxence-charriere/go-app
  2. Go 每日一库 GitHub:https://github.com/go-quiz/go-daily-lib