部署 Emscripten 编译的页面

Emscripten 编译的输出可以在命令行中的 JS shell 中直接运行,也可以托管在网页上。当将 asm.js 和 WebAssembly 编译的页面作为 .html 托管以供浏览器执行时,Emscripten 提供一个默认的 HTML shell 文件,用作运行代码的启动器,简化了开发入门。但是,在准备发布并将内容托管在网站上时,可能需要许多额外的功能和自定义来完善访问者的体验。本指南重点介绍在将网站部署到公共环境时需要注意的事项。

构建文件和自定义 shell

Emscripten 构建输出由两个基本部分组成:1) 低级编译的代码模块,2) 用于与之交互的 JavaScript 运行时。例如,当使用 -o out.html 构建时,编译的代码存储在文件 out.wasm 中,运行时位于文件 out.js 中。当针对 asm.js 时,还有一个额外的二进制文件 out.mem 包含编译代码的静态内存部分。当针对 WebAssembly 时,这部分被嵌入到 out.wasm 文件中。

根据使用哪些功能,还可能存在其他构建输出文件。如果使用 Emscripten 文件打包器,则会生成一个二进制 out.data 包,以及一个关联的 out.data.js 加载器文件。此外,Emscripten pthreads 和 Fetch API 也有它们自己的关联 Web Worker 相关脚本 .js 输出文件。

开发人员可以选择输出到 JavaScript 或 HTML。如果输出 JavaScript (emcc -o out.js),则开发人员需要手动创建 out.html 主页,在其中在浏览器中运行代码。当使用 emcc -o out.html (推荐的构建模式) 针对 HTML 时,Emscripten 会自动生成 HTML shell 文件。可以使用 emcc -o out.html --shell-file path/to/custom_shell.html 链接器指令来自定义此 shell 文件。将 Emscripten 存储库中的 默认最小 HTML shell 文件 复制到项目树中,以获取自定义 shell 文件的良好入门模板。

以下部分提供了有关改善网站体验的提示。

优化下载大小

页面加载速度最慢的原因通常是需要下载大量项目资产数据,尤其是如果页面大量使用 WebGL 纹理或几何体。编译的代码通常比手写的 JavaScript 占用更多空间,但机器代码压缩效率很高。因此,在托管 asm.js 和 WebAssembly 时,必须确保所有内容都使用 gzip 压缩传输,目前所有浏览器和 CDN 都内置支持 gzip 压缩。gzip 压缩 .wasm 文件平均可以将大小缩减 60-75%,因此几乎永远不会有理由提供未压缩的文件。

  • 要在 CDN 上提供 gzip 压缩的资产,请使用 gzip 压缩工具并离线预压缩资产文件,然后再上传到 CDN。一些 Web 服务器支持动态压缩文件,但对于静态资产内容,应避免这种情况,因为对于服务器 CPU 来说,不断重新压缩文件会很昂贵。调整 Web 服务器的配置以托管预压缩的文件,并使用 HTTP 响应头 Content-Encoding: gzip。这会指示 Web 浏览器在将数据传递给页面本身之前,应透明地解压缩下载的内容。

  • 确保 gzip 压缩不会混淆提供资产的 MIME 类型。所有 JavaScript 文件(无论是否预压缩)都应该使用 HTTP 响应头 Content-Type: application/javascript 提供,所有资产文件 (.data, .mem) 都应该使用头 Content-Type: application/octet-stream 提供。WebAssembly .wasm 文件应使用 Content-Type: application/wasm 提供。

  • 尝试使用 Emscripten --preload-file 链接器标志最小化预加载的资产数据量。此数据文件在 Emscripten 编译的应用程序开始执行 main() 函数之前就已经加载了,因此存储在此包中的所有文件都会大大减慢启动时间。最好将下载的资产文件拆分成多个单独的包,并在 Emscripten 中使用异步资产下载 API,这些 API可以在应用程序运行时操作。

  • WebGL 应用程序的资产大小通常由纹理数量决定,因此使用压缩纹理格式有助于缩小资产大小。Web 可以是一个与原生平台完全不同的平台,因为在 Web 上不能假设特定压缩纹理格式会在访问者的硬件上得到支持,尤其是在开发应该在移动和桌面浏览器上都能工作的网站时。为了支持广泛的硬件,最佳做法是为每个支持的平台生成多组压缩纹理,并根据 WebGL 上下文支持的格式下载相应的纹理。

  • 如果目标是多个屏幕尺寸(例如,桌面和移动设备的外形尺寸),请考虑将纹理分成 SD 和 HD 变体,以便移动设备能够更快地加载页面,这些设备具有较小的显示分辨率。

优化页面启动时间

除了下载页面之外,启动序列的其他部分有时也会很慢。需要考虑的事情包括

  • 如果目标是 asm.js 并在 Firefox 或 Edge 上运行,则网页控制台会在 asm.js 模块编译完成后显示一条日志消息。此日志消息包含有关编译耗时多长时间的计时信息。Asm.js 编译从 asm.js 脚本源文件添加到 DOM 的那一刻开始,一旦完成,就会调用脚本标签的 onload 事件。这可以用来计时 asm.js 编译在 Safari、Opera 和 Chrome 上花费的时间。

  • 建议迁移到 WebAssembly,以加快浏览器中编译代码的启动时间。与 asm.js 相比,WebAssembly 模块的解析和编译速度快得多。此外,编译的 WebAssembly.Module 对象可以手动持久化到 IndexedDB,从而在第二次运行时完全避免编译步骤。(见下一节)

  • 有时很容易将启动缓慢归咎于 asm.js/WebAssembly 编译,而实际的缓慢原因可能是执行应用程序本身的 main() 函数入口点。这是因为这两个操作都是紧密地背靠背执行的。值得准确地单独分析这两个操作,检查 src/preamble.js 中的 function callMain(),它会启动应用程序 main() 代码的执行。如果执行 main() 需要很长时间,请考虑将其拆分成由多个 setTimeout() 调用或 emscripten_set_main_loop() 事件循环驱动的单独操作。

  • 为了加快网络传输速度,经验表明,在正常网络条件下,最快的方法是积极地并行启动所有网络下载(假设只有少数几个),而不是例如一次下载一个输入文件,然后再开始下一个文件。因此,为了最大限度地提高网络传输速度,请尝试编写应用程序的主 HTML 页面,以并行启动所有必要的网络下载,而不是将它们排队以进行顺序传输。

  • 在首次加载页面主要受网络传输影响的情况下,利用 CPU 在等待下载完成时大部分处于空闲状态这一事实很有用。此 CPU 时间可用于执行其他繁重任务。asm.js/WebAssembly 模块的下载和编译是在下载其他页面资产时进行的,这是此类操作的理想候选者。

  • 目前在 Windows 系统上已知的问题是编译 WebGL 着色器可能会很慢。这也是在下载其他页面资产时要执行的操作的主要候选者。

提供快速的第二次加载

虽然首次访问页面的体验可能需要一些时间才能完成所有下载,但通过确保浏览器缓存首次访问的结果,可以使第二次访问页面的体验快得多。

  • 所有浏览器都对资源有实现定义的限制(20MB 或 50MB),超过该限制的文件将完全绕过浏览器的内置网络缓存。因此,建议将大型 .data 文件由主页面手动缓存到 IndexedDB。Emscripten 链接器选项 --use-preload-cache 可用于让 Emscripten 实现此功能,尽管以自定义方式在 html 页面上手动管理这一点可能更可取,因为这样可以控制将资源缓存到的数据库以及将用什么方案从数据库中清除数据。

  • 虽然 .wasm 文件像任何资源一样自动缓存,但它们在实例化之前仍需要在浏览器中编译。幸运的是,基于 Chromium 的浏览器支持自动缓存编译后的 WebAssembly 模块(阅读这篇 v8.dev 博客文章)。以前,建议通过 IndexedDB 手动缓存编译后的 Webassembly 模块;然而,这现在基本不受支持(详情请参阅 WebAssembly 规范票证)。

  • 如果编译后的 C/C++ 代码本身执行任何计算,例如在 main() 中的计算,这些计算可以在第二次加载时跳过,请使用 IndexedDB 或 localStorage API 来跨页面运行缓存这些计算的结果。IndexedDB 适合存储大型文件,但它异步工作。另一方面,localStorage API 是完全同步的,但其使用仅限于存储小型 cookie 风格的数据字段。

  • 在实现基于 IndexedDB 的缓存时,要注意到 IndexedDB 操作作为异步 API,执行磁盘访问,因此也存在一些延迟。因此,如果在启动时执行多个读取操作,最好尽可能并行地触发所有操作,以减少延迟。

  • 持久化数据的另一个重要方面是,为了对用户提供最佳实践,最好在使用 IndexedDB 或 localStorage 持久化大量数据时提供明确的视觉标识,并提供一个简单的机制来清除或卸载这些数据。这是因为目前浏览器没有实现方便的 UI 来对这些存储中的数据进行细粒度删除,但清除数据通常被显示为一种“从所有页面清除缓存”类型的选项。

为编译代码保留内存

asm.js 和 WebAssembly 应用程序的一个固有特性是它们需要一个线性内存块来表示应用程序 。这通常是 Emscripten 编译页面执行的最大单个内存分配,因此,如果用户的系统内存不足,它最有可能失败。

由于这种内存分配需要是连续的,因此可能会出现用户浏览器进程有足够的内存,但进程的地址空间过于碎片化,没有足够的线性地址空间来满足分配的情况。为了避免这个问题,最佳实践是在主页面顶部,在执行任何其他分配或页面脚本加载操作之前,预先分配 WebAssembly.Memory 对象(对于 asm.js 而言是 ArrayBuffer)。这样可以确保分配有最大的成功机会。有关更多信息,请参阅字段 Module['buffer']Module['wasmMemory']

此外,可以选择专门针对需要这种大型分配的网页启用内容进程隔离。要使用此机制,在提供主 html 页面时指定 HTTP 响应头 Large-Allocation: <MBytes>。此支持目前在 Firefox 53 中实现。

最后,在页面加载后,很容易意外地保留住大量不需要的内存块。例如,在 WebAssembly 中,一旦 WebAssembly 模块已实例化为 WebAssembly.Instance 对象,原始 WebAssembly.Module 对象就不再需要在内存中,最好清除对它的所有引用,以便垃圾回收器可以回收它,因为 Module 对象可能有多达数十兆字节。类似地,确保所有 XHR 文件、资产数据和大型脚本在不再使用时不再被引用。检查浏览器的内存分析工具以及 Firefox 中的 about:memory 页面以执行内存分析,确保没有浪费内存。

健壮的错误处理

为了提供最佳的用户体验,请确保考虑了页面可能失败的不同方式,并向用户提供良好的错误报告。特别是,遵循以下最佳实践清单。

  • 尽早失败。让用户感到沮丧的一个主要原因是,用户的系统无法运行给定的页面,但错误只有在等待了一分钟下载了 100MB 的资产后才变得明显。例如,尝试在实际加载页面之前预先分配所需的堆内存。这样,如果内存分配失败,失败将立即发生,并且根本不需要尝试下载任何资产。

  • 如果已知某个特定浏览器不支持,请尽量不要读取 navigator.userAgent 字段来限制使用该浏览器的用户,如果可能的话。例如,如果您的页面需要 WebGL 2 但已知 Safari 不支持它,请不要使用以下类型的检查来排除 Safari 用户

    if (navigator.userAgent.indexOf('Safari') != -1) alert('Your browser does not support WebGL 2!');
    

而是检测实际错误

if (!canvas.getContext('webgl2')) alert('Your browser does not support WebGL 2!'); // And look for webglcontextcreationerror here for an error reason.

这样,一旦该特定功能的支持在以后变得可用,页面将与未来兼容。

  • 通过模拟不同的问题和浏览器限制来预先测试各种失败情况。例如,在 Firefox 中,可以通过导航到 about:config 并将首选项 webgl.enable-webgl2 设置为 false 来手动禁用 WebGL 2。这允许您调试您的页面在这种情况下将向用户呈现什么样的错误报告。要完全禁用 WebGL 支持以进行测试,请将首选项 webgl.disabled 设置为 true

  • 在使用 IndexedDB 时,请做好准备处理用户即将用完可用磁盘空间或该域的允许配额时的配额不足错误。

  • 通过为 WebAssembly.Memory 对象以及任何预加载的文件包分配不切实际的大量内存来模拟内存不足错误。确保将内存不足错误正确地标记为内存不足错误(并将其报告给用户或错误数据库)。

  • 通过以编程方式中止 XHR 下载、物理断开网络访问或使用 Fiddler 等外部工具来模拟下载超时。这些类型的工具可以显示出许多意想不到的失败情况,并有助于诊断在这种情况下错误处理路径是否如预期一样。

  • 使用网络限制器工具来限制下载或上传带宽速度,以模拟缓慢的网络连接。这可以发现与网络传输的时序依赖性相关的错误。例如,一个小型的网络传输可能隐式地被认为在大型网络传输之前完成,但这并不总是如此。

  • 在本地开发页面时,请使用本地 Web 服务器进行测试,而不仅仅是通过 file:// URL 进行测试。Emscripten 源代码树中的脚本 emrun.py 被设计为为此目的充当临时 Web 服务器。Emrun 预先配置为处理提供 gzip 压缩文件(后缀为 .gz),并启用对 Large-Allocation 标头的支持,并允许编译页面的命令行自动化运行。

  • 捕获来自调用编译后的 asm.js 和 WebAssembly 代码的入口点的所有异常。有三种不同的异常类,编译后的代码可以抛出

    1. 由抛出的整数表示的 C++ 异常,并且未被 C++ 程序捕获。此整数指向应用程序堆中包含指向已抛出对象的指针的内存位置。

    2. 由 Emscripten 运行时调用 abort() 函数引起的异常。这些对应于编译后的代码无法恢复的致命错误。例如,这可能发生在调用无效函数指针时。

    3. 由编译后的 WebAssembly 代码引起的陷阱。这些对应于来自 WebAssembly 虚拟机的致命错误。例如,这可能发生在执行整数除以零时或在将大的浮点数转换为整数时(如果浮点数超出该整数类型可表示的数字范围)。

  • 通过实现 window.onerror 脚本,在页面上实现一个最终的“捕获所有”错误处理程序。如果页面上引发的异常没有其他来源处理,则将调用此处理程序。请参阅 MDN 上的 window.onerror 文档。

  • 不要让 html 页面“冻结”并将错误消息埋藏在网页控制台中,因为大多数用户不会知道如何在那里找到它。努力在主 html 页面上向用户提供有意义的错误报告,最好附带如何操作的提示。如果更新浏览器版本或 GPU 驱动程序,或释放一些磁盘空间可能有助于页面运行,请让用户知道他们可以尝试哪些操作。如果问题中的错误完全出乎意料,请考虑提供一个链接或电子邮件地址,以便用户报告问题。

  • 提供有意义的交互式加载进度指示器,以向用户显示加载进度是否仍在进行,下一步将发生什么。尝试防止用户陷入 “我想知道它还在加载还是卡住了?” 的心态。

为 Web 环境做好准备

在计划发布网站之前进行测试矩阵时,以下项目可能是值得回顾的。

  • 作为顶级窗口运行时,网页的行为可能与在 iframe 中运行时略有不同。如果适用,请确保测试这两种情况。

  • 测试 32 位和 64 位浏览器,尤其是模拟 32 位浏览器上的内存不足情况。

  • 请注意 HTTP跨域资源共享 规则,以及它们与您托管的网站架构的关系。

  • 请注意 内容安全策略 规则,并记录网站计划使用哪种CSP策略。

  • 请注意浏览器强加的 混合内容安全 限制。

  • 确保网站在隐私浏览(隐身)模式下运行良好。例如,这将阻止网站将数据持久保存到IndexedDB。

  • 测试将页面放入后台标签时是否正常运行。使用 blurfocusvisibilitychange DOM 事件来响应页面隐藏和显示事件。这对于执行音频播放的应用程序尤其重要。

  • 如果页面使用WebGL,请确保它能够优雅地处理WebGL上下文丢失事件。使用 WebGL_lose_context 开发人员扩展在测试时以编程方式触发上下文丢失事件。

  • 验证页面在具有不同 window.devicePixelRatio(DPI)设置的显示器上按预期工作,尤其是在使用WebGL时。参见 Khronos.org: HandlingHighDPI。在Windows和macOS上,尝试更改桌面显示缩放设置以测试浏览器报告的不同 window.devicePixelRatio 值。

  • 测试不同的页面缩放级别不会破坏网站布局,尤其是在浏览器窗口已预先缩放的情况下导航到页面时。

  • 同样,验证当调整浏览器窗口大小,或访问最初已将浏览器窗口大小调整为非常小或非常大,或调整为不成比例的长宽比的网站时,页面布局不会被破坏。

  • 尤其是在针对移动设备时,请注意 <meta viewport> 标签,了解如何在移动设备上开发效果良好的网站布局。

  • 如果页面使用WebGL,请在目标平台上测试不同的GPU。特别地,验证网站在模拟任何必需的WebGL扩展和压缩纹理格式支持缺失时的行为。

  • 如果使用 requestAnimationFrame() API(即 emscripten_set_main_loop() 函数)来驱动渲染,请注意该函数调用的速率并不总是60 Hz,但在运行时可能会发生变化,例如,当在多显示器设置中将浏览器窗口从一个显示器移动到另一个显示器时,如果显示器具有不同的刷新率。诸如75Hz、90Hz、100Hz、120Hz、144Hz和200Hz之类的更新间隔正变得越来越普遍。

  • 模拟页面可能需要的任何特殊API的缺乏,例如游戏手柄、加速度计或触摸事件,并确保在这些情况下也处理适当的错误流。

如果您有好的提示或建议要分享,请通过在 Emscripten bug trackeremscripten-discuss 邮件列表中发布反馈来帮助改进本指南。