在 Go 中运行 JS 代码

通常在 Go 程序中,调用 JavaScript 代码需要借助 CGO 调用 QuickJS 实现。

QuickJS 是一个 C 实现的 JavaScript 虚拟机,借助 CGO,可以在 Go 中调用 C 代码从而实现需求。
但是,这种 Go 调用 C,C 调用 JavaScript 的方式,并不能算是一种“优雅”的实现,而且 CGO 会导致 Go 的跨平台受到限制。

更为合适的实现,应该是直接使用 Go 实现的 JavaScript 虚拟机,目前使用较多的有如下几个:

这些模块本身都是比较好的实现,这里选择以 goja 作为例子

直接执行 JS

引入 goja 后,可以使用 vm := goja.New() 初始化一个 JS 虚拟机,并且可以使用虚拟机执行对应的 JavaScript 代码,使用 vm.RunString()vm.RunScript()vm.RunProgram() 都可以获取对应代码执行的结果(类似直接在 Chrome Dev Tools 中执行代码的默认输出),输出的结果是 goja.Value 类型,可以通过判断类型,将其输出为 Go 原生类型进行处理。

当虚拟机执行代码后,可以使用 vm.Get() 获取虚拟机内任意的全局变量(或函数),并且将其导出到 Go 中处理

对于已经编译成单文件的 JS 代码(xxx.min.js),可以使用 embed 或者读入内容后,将其在虚拟机内执行,从而可以在 Go 中获得大部分 JS 代码的执行结果。

package main

import (
	"fmt"

	"github.com/dop251/goja"
)

func tryInline() {
	vm := goja.New()
	val, err := vm.RunString("1+2")
	if err != nil {
		panic(err)
	}
	fmt.Printf("%v %#v\n", val, val.Export())
}

func tryInline2() {
	vm := goja.New()
	val, err := vm.RunString("Object.keys({a:1,b:2})")
	if err != nil {
		panic(err)
	}
	fmt.Printf("%v %#v\n", val, val.Export())
}

func tryFunction() {
	vm := goja.New()
	_, err := vm.RunString("function sum(a, b) { return a + b; }")
	if err != nil {
		panic(err)
	}

	var sum func(a, b int) int
	if err = vm.ExportTo(vm.Get("sum"), &sum); err != nil {
		panic(err)
	}
	fmt.Println(sum(2, 5))
}

func tryFunction2() {
	vm := goja.New()
	_, err := vm.RunString("function sum(a, b) { return a + b; }")
	if err != nil {
		panic(err)
	}

	sum, ok := goja.AssertFunction(vm.Get("sum"))
	if !ok {
		panic("error")
	}

	fmt.Println(sum(goja.Undefined(), vm.ToValue(2), vm.ToValue(5)))
}

func tryCompile() {
	vm := goja.New()

	program, err := goja.Compile("", "var a=1+5; a*2", true)
	if err != nil {
		panic(err)
	}

	val, err := vm.RunProgram(program)
	if err != nil {
		panic(err)
	}

	fmt.Println(val)
}

func main() {
	tryInline()
	tryInline2()
	tryFunction()
	tryFunction2()
	tryCompile()
}

模拟 DOM

JavaScript 本身是为操作网页 DOM 服务的,因此有些 JS 代码不能脱离 DOM 存在(即使他们可能对 DOM 的操作并不是必须的)
这时,可以使用虚拟 DOM 来使得这些代码正常运行,引入 jsdom 模块后,即可生成一个 NodeJS 环境下的虚拟 DOM。
首先执行 jsdom,而后再执行对应的 JS 代码,来实现对应的功能。

jsdom 不是万能的,如 mermaid.js 使用 DOM 来计算字体宽度,因此必须使用真实 DOM 来完成任务,相关讨论可见 #559 server side mermaid with jsdom
作为替代,我们可以使用真实的 DOM 来实现功能,如 chromedp,其在一个真正的 chromeium 中来执行相应的内容,因此其表现与浏览器内完全无异。

chromedp 的 JS 执行需要一个“网页”作为依托

  • 可以使用一个空白页面,将 chromedp 导航至下面字符串

    data:text/html,<!DOCTYPE html>
    <html lang="en">
        <head><meta charset="utf-8"></head>
        <body></body>
    </html>
    
  • 也可以将其导航至一个真正的页面内

两种形式本质上没有区别,就是在地址栏输入对应的内容而已。

chromedp 在执行后,通过 Action 来完成对页面的操作,Action 包含等待某个元素加载,也包含对某个 DOM 元素操作。当然,在这里,我们主要要讨论的是执行 JS 代码(类似于在 Chrome Dev Tools 的 Console 中执行)。
执行一个 JS 代码,使用 chromedp.Evaluate(),第一个参数是需要执行的代码(可以访问作用域内所有内容),第二个参数则是执行结果。但与其他模块不同,这里将会直接将 JS 类型导出到传入的参数中,也即需要执行者确保类型的一致性。

ctx, cancel := chromedp.NewContext(
    // allocCtx,
    context.Background(),
    // chromedp.WithLogf(log.Printf),
)
defer cancel()

source = strings.ReplaceAll(source, "`", "\\`") // 转义字符
source = strings.TrimSpace(source)              // 清除首尾空格

var ok bool
if err := chromedp.Run(ctx,
    chromedp.Navigate(fmt.Sprintf(content)),
    chromedp.Evaluate(mermaidjs, &ok),                                                   // 加载 mermaid.js
    chromedp.Evaluate(fmt.Sprintf("mermaid.render('mermaid', `%s`);", source), &result), // 生成 svg
); err != nil {
	result = "<p style='color:red'>render error</p>"
}