作者| 张翰(门柳)
出品|阿里巴巴新零售淘系技术部
本文知识点提炼:
1、把复杂的 C++ 框架编译成 WebAssembly。
2、在 wasm 模块里调用 DOM API !
3、在 js 和 wasm 之间传递复杂数据结构。
4、对 WebAssembly 技术发展的期待。
上一篇文章《基础为零?如何将 C++ 编译成 WebAssembly》里介绍了怎么把简单的 C++ demo 编译成 WebAssembly,但这是远远不够的。正好手头在写一个 C++ 的项目,功能独立完整也足够复杂(有 2W+ 行代码),就顺便编译成了 WebAssembly,未必是一个合适的使用场景,主要是为了学习这项技术,亲身体验一下过程中遇到的问题。
项目背景
我现在在用 C++ 写一个原生的响应式框架,定位和前端框架差不多,但是用 C++ 来实现,可以有更好的性能,也方便对接各种原生渲染引擎和各种语言。上层开发者仍然以 JS 为开发语言,API 和 Web Component 相似,而且可以像小程序和 Vue.js 那样用模板+数据(声明式+响应式)的方式开发原生 UI,框架本身就不介绍了,本文的重点是涉及 WebAssembly 的部分。
▐ 为什么要编译成 WebAssembly ?
框架是 C++ 写的,本身的设计是源码集成进各种渲染容器的,跟随原生 SDK 发版,即使是对接浏览器,也是和浏览器内核代码(如 UC 的 U4 内核)打包在一起,但这样就无法运行在独立的浏览器上,功能无法降级。如果说把 C++ 代码编译成 WebAssembly 的话,那框架就可以从远程加载了再运行,相当于 C++ 的框架也有了动态化的能力。
简单来讲,现在这个 C++ 框架已经能运行在各种原生渲染引擎之上了,我想保持同一份 C++ 源码,让它能运行在干净的浏览器中。
这条链路应该只会用于降级的场景,重点是验证链路能不能跑通,性能倒不是我最关注的问题。
需求分析
▐ 要实现的目标
首先细化一下要实现的目标,下面是一段使用响应式框架 API 开发的代码,它可以运行在原生渲染引擎上,目标是让这段代码能运行在干净的浏览器里:
// 1. 自定义一个组件
class HelloWorld extends ReactiveElement { /* ... */ }
// 2. 向环境中注册组件,给定一个名称
customElements.define('hello-world', HelloWorld)
// 3. 定义组件的模板
customElements.defineTemplate(HelloWorld, {
type: 'h1',
// 添加数据绑定,表示 h1 的 innerText 是由表达式 message 计算出来的
innerText: { '@binding': '`Say: ${message}`' }
})
// 4. 创建组件,传递初始数据
const app = new HelloWorld({ message: 'Hi~' })
// 5. 把组件挂载到 #root
customElements.mount('#root', app)
// 6. 更新组件的数据,会自动触发 UI 的更新
app.setState({
message: "What's up!"
})
这段代码是一个完整的例子, 1, 2, 4 是 Web Component 的标准写法(用 ReactiveElement 代替 HTMLElement),浏览器已经支持,5 是用于挂载节点的语法糖, 3 和 6 是新增的 API,用于定义组件的模板和传递数据。方案算是对 Web Component 的增强,加入了模板和数据绑定的能力,在真实场景里模板不会是手写的,而是由小程序、Vue.js 所定义的模板语法编译而来。
看起来用 JS 写个 polyfill 就可以搞定。但是模板的运算和数据绑定怎么实现?里面是可以包含循环、分支和表达式的,前端框架的做法是把它编译成 js 代码,如果写 polyfill,很可能又写出了一个前端框架,或者基于现有前端框架做封装,但是这样就和原生框架的行为不一致了。
▐ 遇到的问题
想用响应式框架跑通上面的例子,就是要实现这么一个调用链路:
demo.js <-----> [响应式框架] <-----> DOM API
在原生框架中,ReactiveElement class 和 customElements 上的接口都是由 C++ 实现的,类似于 DOM API,有 ES6 的类,也有普通函数,接受的参数有 class(Function),String 和 Object 等各种类型。然而 wasm 目前只可以 import 和 export C 语言函数风格的 API,而且参数只有四种数据类型(i32, i64, f32, f64),都是数字,可以理解为赤裸裸的二进制编码,没法直接传递复杂的类型和数据结构。所以在浏览器中这些高级类型的 API 必须靠 JS 来封装,中间还需要一个机制实现跨语言转换复杂的数据结构。
WebAssembly 是一种编译目标,虽然运行时是 wasm 和 JS 之间互相调用,但是写代码的时候感知到的是 C++ 和 JS 之间的互相调用。文中说的 JS 和 C++ 的调用,实际意思是 JS 和 wasm 模块之间的调用。
另外,如果要实现在浏览器里渲染和更新 UI 的话,就必须要用到 DOM API,众所周知 WebAssembly 调用不了 DOM API,也不是个靠谱的用法。原生框架提供了 C++ 的 ComponentRenderer 抽象类来对接渲染引擎,不同的渲染引擎分别实现这个类然后注入框架,不管靠谱不靠谱,在浏览器里也只能靠 JS 实现这个渲染器了,封装 DOM 操作然后把接口传给 C++ 调用,而且也要传递复杂的数据结构。如果想精确更新某个特定组件节点,还得解决 C++ 组件和 JS 组件一对一 binding 的问题。
总结下来,是两个问题:
- 在 C++ 和 JS 之间传递复杂数据结构。(数据通信)
- 实现 C++ 和 JS 复杂数据类型的一对一绑定。(类型绑定)
技术方案
▐ 如何编译代码
首先需要确定一下如何把 C++ 文件编译成 wasm 文件,前一篇文章讲提到两种方式,用 Emscripten 会生成 wasm + js 两个文件,可以很好的跑在浏览器和 Node.js 上;用 wasi-sdk 可以编译出独立的 wasm 包,但是想把它运行起来需要运行时给它注入必要的接口。
我的目标是跑在浏览器里,所以用 Emscripten 来编译,生成一份 “js glue” 文件来辅助运行, 像个奥利奥,有种必须被 js 捏在手里运行的感觉,但是在浏览器里不得不这样。这很可能是浏览器里 WebAssembly 和 JS 需要长期保持的关系…… 有
提案说用
具体怎么编译?参考 Emscripten 官方文档配置一下编译脚本就好了,这里列出响应式框架的其中一部分编译脚本,可供参考。
使用 wasi-sdk 的编译链路我也在尝试,可以编译出来独立的 wasm 包,但是目前还没把它运行起来,需要在运行时注入响应式框架依赖的渲染接口。
算上上面的 demo 文件,一共有三个文件,大概的执行顺序是 1. 加载框架的 js 文件, 2. 框架 js 文件自动加载并编译 wasm 文件,3. 执行 demo 文件。伪代码如下:
<script src="path/to/wasm-framework.js"></script>
<script>
initializeWasmAPIs().then(exports => {
// wasm 初始化完成后,动态插入 demo 的脚本
const $script = document.createElement('script')
$script.setAttribute('src', `path/to/demo.js`)
document.body.appendChild($script)
})
</script>
▐ 接口的互相调用
明确了打包生成的代码是 js-wasm-js 这种奥利奥格式的,代入上面提到的调用链路里就是这样:
(A) (B) (C) (D)
demo.js <-----> [js <---> wasm <---> js] <-----> DOM API
看起来这个渲染链路是比较长的,但是中括号里的属于框架内部的调用,不被外界感知的。全部展开来,可以分成 A/B/C/D 四个环节的接口调用,其中 (A) 和 (D) 都是 js 和 js 之间的调用,封装 API 而已,没什么难的,关键要搞定 (B) 和 (C) 的调用。
再回过头来看 wasm 在运行时的层次结果,如下图左半部分,demo.js 属于逻辑 JS,运行与响应式框架之上,响应式框架运行与浏览器之上。再把每一层展开,可以看到每层之间的接口调用关系,如下图右半部分所示:
图中 (A) 透出的接口就是上面 demo.js 用到的接口,主要是一个 ReactiveElement 的 ES6 class 和 customElements 这个对象。 (D) 就是前端很熟悉的 DOM API,不再赘述。中间响应式框架这层把 wasm + js 两个文件画成三层的奥利奥结构,是因为虽然 js 文件只有一个,但是从逻辑上区分它做了两件事情,分别处理 wasm 的输入和输出。
前面提到过 wasm 通过 import/export 的方式实现和外部的接口调用,而且接口只能是普通的函数而且参数只能是数字,分别对应了图中的 (C) 和 (B)。所以运行与 wasm 下方的 js renderer 是负责封装 DOM API,把它转成 wasm 声明的 import 接口 (C),在实例化 wasm 包的时候把接口注入进去。
(C) 接口的详细设计如下图所示,以 _ui 开头的都是响应式框架声明的、需要在运行时注入的接口。
同理,wasm 导出的接口也是 C 语言函数风格的,想要转成 Web Component 风格的 API 需要在 JS 层做封装,这就是图中 js api wrapper 这一层所做的事,拿到 wasm 导出的接口 (B) 并把它封装成 (A)。
(B) 接口的详细格式如下图所示,以 _ui 开头的都是响应式框架导出的。
在实际编译的时候,Emscripten 提供了 --pre-js ,--post-js 和 --js-library 等方式注入 JS 代码,具体含义参考其
官方文档。我在项目里用 --post-js 打包 api.js 文件实现接口 (B) 到 (A) 的封装,用 --js-library 打包 renderer.js 文件,实现 (D) 到 (C) 的封装。
除了上面提到的接口封装方式以外, Emscripten 还提供了 Embind 和 Web IDL Binder 的方式绑定 JS 接口,原理和上面方法相同,我觉得封装太厚了就没选择使用。
▐ 搞定数据通信
接口调用搞定了,然后还需要搞定传值的问题。wasm 导入和导出的函数参数只能是数字,要传递复杂值,必须借助
WebAssembly.Memory()实现,它是由宿主环境开辟的内存,交给 wasm 模块来运行,在运行时这块内存可以被 wasm 模块和宿主环境共同管理,这是 wasm 和宿主环境实现大块数据通信的基础。
传递内存 buffer
如下代码创建了一块初始大小为 256 的内存(单位是“页”,每页64KB),并在实例化时传递给 wasm 模块。
const wasmMemory = new WebAssembly.Memory({ initial: 256 })
WebAssembly.instantiate(wasmBinary, {
env: {
memory: wasmMemory
}
})
这块内存就是 wasm 模块运行时使用的内存,可以通过 wasm 指令读取、写入以及 grow,对应到 C++ 源码里就是指针和 new/malloc 之类的操作;同时这块内存又是一个 js 变量,也可以在 js 里读写它的值。
所以 js 和 wasm 通信的过程就是:先把想要传递的数据序列化成 ArrayBuffer,然后把 buffer 写入 Memory 并且把数据的起始位置、数据大小传给 wasm 模块,在 wasm 中根据位置取到这块 buffer,最后把 buffer 反序列化成自己想要的类型。伪代码如下:
// 把想要传递的数据转成 ArrayBuffer (假设是 Uint8Array)
const dataBuffer = encodeDataByJS({ /* my data */ })
// 向 wasm 申请一段内存,由 wasm 代码负责实现并返回内存内存起始地址
const offset = applyMemoryFormWasm(dataBuffer.length)
// 以 unit8 的格式操作 wasm 的内存 (格式应该与 dataBuffer 的格式相同)
const heapUint8 = new Uint8Array(wasmMemory.buffer, offset, dataBuffer.length)
// 把数据写入 wasm 内存
heapUint8.set(dataBuffer)
传递复杂数据结构
仅支持传递 buffer 并不能解决所有问题,总不能在写代码的时候都去手动操作内存吧,而且 JS 的数据类型和 C++ 的数据类型差别很大,传递数字和数组还好一些,在传递复杂数据结构的时候,如何正确的实现数据的序列化和反序列化?
先说一下通用的解法,这是一个跨语言数据结构转换的问题。面对同一块内存,要让不同的语言都能按照同样的格式(或内存布局)来读取这块内存,就得定义一套语言无关的二进制数据格式。这个在业界有挺多方案的,首先有种叫
BSON的数据格式,就是二进制版本的 JSON,主要用在 MongoDB 数据库里。另外 Google 的
Protocol Buffers也是做这个事情的,
自定义了一套二进制格式,并且提供了多种语言(没有 JS)的序列化、反序列化 API。另外 Flutter 的 MethodChannel 也是
自己设计了一套二进制编码格式,用于实现跨语言的接口调用。
但是上面这些方式用在 WebAssembly 里都不太合适,或者说太重太麻烦了,现在 WebAssembly 社区在讨论的 WebAssembly Interface Types 就是要解决这个问题的,借鉴 Web IDL 的思路,有希望定义出一套适用于 wasm 的语言无关的类型标准,但是目前还有很多问题需要解决,如 anyref 和 GC objects 的支持等。
思来想去,针对我这个场景,我最终决定用 JSON 字符串实现数据的序列化,因为比较省事……
如上图所示,二进制的 Buffer 是可以在 C++ 和 JS 共同访问的,在 JS 里我把想要传递的数据都用 JSON.stringify() 转成字符串,然后用 TextEncoder 把字符串转成 Uint8Array,解码的时候,先用 TextDecoder 把二进制数据转成字符串,然后用 JSON.parse() 转成我想要的数据结构。在 JS 里我全都用浏览器提供的 API 就可以实现序列化和反序列化,不需要自己写太多代码。在 C++ 里,从二进制数据到 std::string 的转换是很简单的,直接构造和取值就行了,而且巧了,在响应式框架里我设计了一套和 JS 类型对等的 C++ 数据结构,实现了类似 JSON.stringify() 和 JSON.parse() 接口,内部的组件模板、数据绑定、组件状态都可以转换成这套数据类型。所以,对我这个框架来说,每一步的序列化反序列化接口都已经存在了,我只要把它们串起来就行了。
使用 JSON 字符串来传递复杂类型仅仅适用于我这个项目,并不是个通用方案,如何高效实现 wasm 数据通信还要具体情况具体分析。
功能的实现
▐ 代码实现
技术方案以及介绍的比较清楚了,具体代码该怎么写就不说太多了,与框架内部的逻辑有关,与 WebAssembly 关系不大。下面就以 setState() 这个接口的伪代码为例,简单捋一下调用流程。
在 demo.js 里调用到的 ReactiveElement 接口是 js 实现的,如上图最左侧代码, setState() 函数的实现就是把数据转成 buffer 塞到 wasm memory 里面,然后调用响应式框架导出的 _component_set_state() 接口设置组件状态。中间的 wasm 代码是由 C++ 编译而来的。在 C++ 的实现里,根据传入的 buffer 位置和大小初始化成 std::string 然后把字符串解析成状态数据,然后调用 C++ 对象的 SetState() 方法更新状态。
▐ 对接效果
代码编译好之后,准备一个 HTML 页面再启动一个 Web 服务就可以在浏览器里预览 wasm 的例子了。大部分浏览器都已经支持 WebAssembly 了,包括各大 App 里的 webview,下面是一个简单 demo 在微信扫码打开的效果(快看,我把一个 C++ 框架在微信里跑起来了):
图中 f 函数的计算是在 js 里实现的,点击事件也是在 js 里绑定的。首次渲染时 wasm 框架会生成一份 HTML 字符串塞到根节点的 innerHTML 里(比逐个加节点要快),过程中会绑定点击事件。当用户点击了按钮时,原生事件通过 DOM API 透到 js,js 再传给响应式框架的 wasm 包做处理,然后调用 js 绑定的回调函数,回调函数里调用了 setState() 把新状态传给 wasm 框架,wasm 里计算新的节点信息,期间会调用 js 的 calculate 函数,最后调用 DOM API 更新节点(并不是重新生成 HTML 再替换,而是可以精确更新特定的节点)。
wasm 文件理论上也可以调试,可以自己尝试一下,然后你就知道调试 WebAssembly 是多么坑的一件事了。另外 demo 也不一定非得用 JS 写,可以用其他语言写,然后编译成 wasm,也能够运行在响应式框架之上。
▐ 性能对比
下面到了大家喜闻乐道的性能测试环节。开头我也说了,把这个项目编译成 WebAssembly 并不是为了追求性能,现阶段 js + wasm 的运行链路大家也都看到了,整个链路跑下来性能未必有什么优势,但是并不代表 wasm 的性能差,未来可优化的潜力是比 js 要高得多的。
所以我并没有很认真的测性能…… 现阶段意义不大。下面是我用一个稍复杂的页面(大概 200+节点),在 Node.js v12 环境下渲染生成 HTML 字符串测出来的结果:
解释一下各个字段。第一列 n 是循环生成 HTML 字符串的次数,第二列和第三列都是使用 wasm 表示使用渲染 HTML 字符串的耗时,它两个的区别是 wasm 代码仅包含了 js api wrapper 和一个几乎为空的 js renderer (参考上文接口的调用链路),因为仅生成 HTML 字符串的话不需要 renderer;而 wasm+js 是包含了 js renderer 代码,这样在渲染过程中就会有比较频繁的 wasm <--> js 调用和传值。最后两列是前面三列数据的比值。
可以看到把 c++ 编译成 wasm,执行时间大概是原版 c++ 的 1.7~1.8 倍,这个基本上比的是执行 wasm 指令和执行原生指令的性能差距,也是符合预期的。而带上 wasm+js 的通信之后,性能变成了 C++ 的 4.4~4.5 倍,所以说大部分性能其实耗在了通信上,而不是指令的运行上。
使用 WebAssembly 调用 DOM API 并不是一个合适的使用场景,和 JS 进行频繁的调用和传值也不是 WebAssembly 的强项,这个测试用例放大了通信开销。
对 WebAssembly 的期待
现在 WebAssembly 虽然还存在很多问题,但它依然是一个很有潜力的技术,在 W3C 有标准化的规范,得到了主流浏览器的一致支持,技术社区也很活跃,大家的关注度也很高。从种种因素来看,它几乎必然是个会被大规模使用的技术,就看在什么时机爆发了。
现在关注 WebAssembly 技术的人,大部分都在思考两个问题:
• 我能用 WebAssembly 做点什么?
• 我能为 WebAssembly 做的什么?
第一个问题是 WebAssembly 的使用场景,但是不能为了用新技术而用新技术,得找到最适合使用 WebAssembly 而且具备不可替代性的场景,目前来看,客户端上用在视频、游戏、AR、AI 等领域比较合适。
第二个问题是促进 WebAssembly 的发展,解决实践中的问题帮助它落地。要实现 WebAssembly 在真实业务场景中落地,还需要继续完善基础设施,我很期待社区能够有人解决下面几个问题:
- 在工程层面解决 WebAssembly 研发链路的问题。现在无论是开发、编译还是调试都会遇到很多问题,开发体验和开发效率都比较低。目前我个人觉得比较靠谱的三种开发语言是 C++、Rust 和 AssemblyScript,分别面向不同类型的开发者。
- 在平台侧解决 WebAssembly 模块的管理问题。解决 wasm 在真正使用时的加载、分发、依赖管理、复用等问题,要是能构建出 npm 这样丰富的生态就好了。
- 在客户端/服务端解决 WebAssembly 独立运行时的问题。能够把丰富的平台原生能力,高效的、标准的透出到 wasm 模块中,并且解决性能、稳定性、安全性等问题。
- 性能优化!性能优化!性能优化! 无需多说,性能优化永无止境。
等上面的基础设施建设完成后,可以为 WebAssembly 的落地扫清大部分障碍。
最后结合本文的例子,设想在未来使用 WebAssembly 的更合理的架构:
左边是目前在浏览器里运行 WebAssembly 的层次结构,业务逻辑 JS 运行与框架之上,wasm 模块要裹上一层 js glue 才可以运行,浏览器是集 JS 脚本引擎、WebAssembly 运行时、渲染引擎与一体的运行环境,平台的原生能力透过浏览器(或 webview)的壳透出到 JS 环境中。这个架构里最关键的是浏览器,集中实现了各种引擎,功能稳定而且标准化,但是也增大了定制和改造的难度,要引入就都得引入。
右边是一个理想的运行 WebAssembly 的层次结构,在最底层的还是平台原生能力,再往上就不是简单的对接浏览器了,而是将 JS 引擎和 WebAssembly 运行时分开,这两个都可以算作脚本引擎,至于渲染引擎,它应该运行与脚本引擎的下方,属于平台原生能力的一部分(图中未画出来)。有了独立的 WebAssembly 运行时,平台原生能力就能够直接以 wasi 的形式透出,开发和运行 wasm 的时候就不再受 JS 的影响,也有助于实现标准化,向上透出的接口是语言无关的,依然可以被 JS 调用,也可以支持其他语言,也支持其他编译好的 WebAssembly 模块。
写在最后
文章主要讲的是我在响应式框架里的使用 WebAssembly 的经验,虽然未必是一个很合适的使用场景,但是大部分技术方案是通用的,给大家做个参考,写得不对的地方欢迎指正。如果有对 WebAssembly 感兴趣的同学欢迎加入淘宝技术部和我一起交流~