12.30 记一次完整 C++ 项目编译成 WebAssembly 的实践

简介: 有 2W+ 行代码,一篇通用的技术方案

记一次完整 C++ 项目编译成 WebAssembly 的实践


作者| 张翰(门柳)
出品|阿里巴巴新零售淘系技术部

本文知识点提炼:
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 开发的代码,它可以运行在原生渲染引擎上,目标是让这段代码能运行在干净的浏览器里:

<code>// 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!"
})/<code>

这段代码是一个完整的例子, 1, 2, 4 是 Web Component 的标准写法(用 ReactiveElement 代替 HTMLElement),浏览器已经支持,5 是用于挂载节点的语法糖, 3 和 6 是新增的 API,用于定义组件的模板和传递数据。方案算是对 Web Component 的增强,加入了模板和数据绑定的能力,在真实场景里模板不会是手写的,而是由小程序、Vue.js 所定义的模板语法编译而来。

看起来用 JS 写个 polyfill 就可以搞定。但是模板的运算和数据绑定怎么实现?里面是可以包含循环、分支和表达式的,前端框架的做法是把它编译成 js 代码,如果写 polyfill,很可能又写出了一个前端框架,或者基于现有前端框架做封装,但是这样就和原生框架的行为不一致了。

▐ 遇到的问题

想用响应式框架跑通上面的例子,就是要实现这么一个调用链路:

<code>demo.js  [响应式框架]  DOM API/<code>

在原生框架中,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 的问题。

总结下来,是两个问题:

  1. 在 C++ 和 JS 之间传递复杂数据结构。(数据通信)
  2. 实现 C++ 和 JS 复杂数据类型的一对一绑定。(类型绑定)
  1. 无需多说,性能优化永无止境。

等上面的基础设施建设完成后,可以为 WebAssembly 的落地扫清大部分障碍。

最后结合本文的例子,设想在未来使用 WebAssembly 的更合理的架构:

记一次完整 C++ 项目编译成 WebAssembly 的实践

左边是目前在浏览器里运行 WebAssembly 的层次结构,业务逻辑 JS 运行与框架之上,wasm 模块要裹上一层 js glue 才可以运行,浏览器是集 JS 脚本引擎、WebAssembly 运行时、渲染引擎与一体的运行环境,平台的原生能力透过浏览器(或 webview)的壳透出到 JS 环境中。这个架构里最关键的是浏览器,集中实现了各种引擎,功能稳定而且标准化,但是也增大了定制和改造的难度,要引入就都得引入。

右边是一个理想的运行 WebAssembly 的层次结构,在最底层的还是平台原生能力,再往上就不是简单的对接浏览器了,而是将 JS 引擎和 WebAssembly 运行时分开,这两个都可以算作脚本引擎,至于渲染引擎,它应该运行与脚本引擎的下方,属于平台原生能力的一部分(图中未画出来)。有了独立的 WebAssembly 运行时,平台原生能力就能够直接以 wasi 的形式透出,开发和运行 wasm 的时候就不再受 JS 的影响,也有助于实现标准化,向上透出的接口是语言无关的,依然可以被 JS 调用,也可以支持其他语言,也支持其他编译好的 WebAssembly 模块。


分享到:


相關文章: