WebAssembly(或者说Wasm)在相对不久前被加入到了Web浏览器标准之中. 而它对于拓展Web平台的能力具有不可小觑的潜力.
尽管WebAssembly的学习曲线十分陡峭, AssemblyScript提供了一个简单的门道. 让我们先来看看为什么说WebAssembly是一门十分杰出的技术, 以及我们能怎样通过AssemblyScript来解放它的潜力.
WebAssembly是浏览器中一个相对低层的语言. 它给予了开发者除了JavaScript以外的另一个编译目标环境, 使得网站代码能够以一个接近原生的速度运行在一个安全的沙盒环境中.
它由主流浏览器的开发者们主持开发, 包括Chrome, Firefox, Safari以及Edge, 它们在2017年达成了设计共识. 目前这些浏览器都支持WebAssembly. 此外, 总的来说, 大约87%的浏览器都支持WebAssembly这一特性.
WebAssembly以二进制形式被分发. 在速度和文件大小上都优于JavaScript. 同时它也具有可读的文本格式(类似Java的字节码).
当WebAssembly初面世时, 一些开发者们认为它具有替代JavaScript成为浏览器的首要开发语言的潜力. 但是我们最好还是先认定WebAssembly只是一个与现有Web平台集成完备的工具. 这也是它的上层目标.
相较于取代现在已有用例中的JavaScript, WebAssembly反倒是通过它赋能的新特性吸引了更多的人. 目前WebAssembly并不能直接访问DOM, 现存的大多数网站仍然更倾向于使用JavaScript, 毕竟JavaScript经过这么多年的优化大多数情况下已经足够快了. 下面是WebAssembly可能的使用场景:
这些应用在大众的认知来中往往都是桌面级应用. 而有了WebAssembly, 这些大量使用CPU的应用的性能能够更接近原生应用, 也更有理由扩展到Web平台.
已经存在的站点同样可以通过WebAssembly受益. Figama就是一个真实的例子. 它使用了WebAssebly, 因而显著地降低了加载时间. 如果一个站点存在大量的计算, 用WebAssembly来替代这部分源代码将会成为一个不错的选择.
现在你可能已经对WebAssembly产生了一些兴趣. 你可以直接通过学习和编写这门语言本身, 但它真的只是作为一个编译目标来设计的. 也因此被设计得对C, C++语言具有良好的支持. Go也在其1.11
版本后添加了支持. Rust也在对其进行探索.
但可能你并不想为了使用WebAssembly来学习任何这其中的一门语言. 现在是时候让AssemblyScript进场了.
AssemblyScript是一个TypeScript到WebAssembly的编译器. 微软开发的TypeScript为JavaScript添加了类型这一概念. 它在随后成为了一门十分受欢迎的语言. 而即使对TS不太熟悉, AssemblyScript的上手也比较轻松, 毕竟它只使用了TS的有限的一部分子集.
正是由于其与JavaScript的相似性, AssemblyScript允许Web开发者们轻松地将WebAssembly加入到他们的站点中, 过程中并不需要另一门完全陌生的语言的参与.
先让我们来编写我们的第一个AssemblyScript模块(以下所有代码都能在这个Github Repo里找到). 我们需要最小8.0
版本的Nodejs来获取WebAssembly的支持.
进入一个空文件夹. 创建一个package.json
文件. 然后安装AssemblyScript. 注意我们需要直接安装它的github repo. 因为开发者们认为它还没有准备好被投入使用, 所以并没有将其发布到NPM中
译者注: 已经发布, 接下来我会修改一部分原文.
mkdir assemblyscript-demo
cd assemblyscript-demo
npm init
npm install --save-dev assemblyscript
使用asinit
命令来生成脚手架文件.
npx asinit .
我们的package.json
文件应该会包括这些scripts
{
"scripts": {
"asbuild:untouched": "asc assembly/index.ts -b build/untouched.wasm -t build/untouched.wat --sourceMap --validate --debug",
"asbuild:optimized": "asc assembly/index.ts -b build/optimized.wasm -t build/optimized.wat --sourceMap --validate --optimize",
"asbuild": "npm run asbuild:untouched && npm run asbuild:optimized"
}
}
根目录下的index.js
长这样:
const fs = require("fs");
const compiled = new WebAssembly.Module(fs.readFileSync(__dirname + "/build/optimized.wasm"));
const imports = {
env: {
abort(_msg, _file, line, column) {
console.error("abort called at index.ts:" + line + ":" + column);
}
}
};
Object.defineProperty(module, "exports", {
get: () => new WebAssembly.Instance(compiled, imports).exports
});
它让我们像使用一个普通的JavaScript模块一样, 通过require
来使用我们的WebAssembly模块.
assembly
目录中包含了我们的AssemblyScript源代码. 其中是一个简单的加法的例子.
export function add(a: i32, b: i32): i32 {
return a + b;
}
把这个函数的签名看做add(a: number. b: number): number
, 它就变成了TypeScript. 使用i32
的原因是AssemblyScript使用WebAssembly专门区分了的整数与浮点数类型, 而不是TypeScript统一看待的number
类型
让我们来构建这个例子
npm run asbuild
build
目录下应该会出现这些文件:
optimized.wasm
optimized.wasm.map
optimized.wat
untouched.wasm
untouched.wasm.map
untouched.wat
我们能得到普通的和优化过的两个构建版本. 对于每一个版本, 都有一个.wasm
二进制文件, 一个.wasm.map
源代码映射, 以及一个.wat
的二进制文件的可读文本格式. 这个文本格式的二进制文件的设计初衷就是为了其可被阅读的. 但对于我们的最终目的来说, 我们不需要去阅读或者理解他 -- 我们使用AssemblyScript的目的之一就是避免跟原生WebAssembly打交道.
启动Node, 像调用其他普通模块的方式调用编译好的模块
$ node
Welcome to Node.js v12.10.0.
Type ".help" for more information.
> const add = require('./index').add;
undefined
> add(3, 5)
8
从Node调用WebAssembly就是这么简单!
由于AssemblyScript目前还没有watch
的模式, 因此对于开发过程, 我推荐使用一个类似onchange
能够在代码改动时能够自动重新构建对应的模块的工具.
npm install --save-dev onchange
在pakcage.json
中添加脚本asbuild:watch
. 添加-i
标志来使得它初次运行的时候就执行一次构建
{
"scripts": {
"asbuild:untouched": "asc assembly/index.ts -b build/untouched.wasm -t build/untouched.wat --sourceMap --validate --debug",
"asbuild:optimized": "asc assembly/index.ts -b build/optimized.wasm -t build/optimized.wat --sourceMap --validate --optimize",
"asbuild": "npm run asbuild:untouched && npm run asbuild:optimized",
"asbuild:watch": "onchange -i 'assembly/**/*' -- npm run asbuild"
}
}
现在可以通过一次运行asbuild:watch
来代替反复重新运行asbuild
了
接下来我们会通过一个基础的基准测试来看看性能提升了多少. WebAssembly的特长就是处理CPU重度的任务, 比如代数计算. 那么就从这方面下手: 判断一个数字是不是质数.
参考实现如下, 这是一个很原始, 很粗暴的算法, 毕竟目标就是测试这样的高强度代数计算
function isPrime(x) {
if (x < 2) {
return false;
}
for (let i = 2; i < x; i ++) {
if (x % i === 0) {
return false;
}
}
return true;
}
等效的AssemblyScript实现只需要添加一些类型注解:
function isPrime(x: u32): bool {
if (x < 2) {
return false;
}
for (let i: u32 = 2; i < x; i++) {
if (x % i === 0) {
return false;
}
}
return true;
}
同时我们会用到Benchmark.js
npm install --save-dev benchmark
创建benchmark.js
文件:
const Benchmark = require('benchmark');
const assemblyScriptIsPrime = require('./index').isPrime;
function isPrime(x) {
for (let i = 2; i < x; i++) {
if (x % i === 0) {
return false;
}
}
return true;
}
const suite = new Benchmark.Suite;
const startNumber = 2;
const stopNumber = 10000;
suite.add('AssemblyScript isPrime', function () {
for (let i = startNumber; i < stopNumber; i++) {
assemblyScriptIsPrime(i);
}
}).add('JavaScript isPrime', function () {
for (let i = startNumber; i < stopNumber; i++) {
isPrime(i);
}
}).on('cycle', function (event) {
console.log(String(event.target));
}).on('complete', function () {
const fastest = this.filter('fastest');
const slowest = this.filter('slowest');
const difference = (fastest.map('hz') - slowest.map('hz')) / slowest.map('hz') * 100;
console.log(`${fastest.map('name')} is ~${difference.toFixed(1)}% faster.`);
}).run();
在我的机器上运行node benchmark
, 可以得到这些结果:
AssemblyScript isPrime x 74.00 ops/sec ±0.43% (76 runs sampled)
JavaScript isPrime x 61.56 ops/sec ±0.30% (64 runs sampled)
AssemblyScript isPrime is ~20.2% faster.
需要注意的是, 这只是一个很小的不具备规模的基准测试, 在把这个结果用于严肃场合之前请一定要谨慎.
更多AssemblyScript的基准测试, 推荐可以看看WasmBoy benchmark和wave qeuation benchmark
接下来让我们把我们的模块加载到网页中. 创建一个index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>AssemblyScript isPrime demo</title>
</head>
<body>
<form id="prime-checker">
<label for="number">Enter a number to check if it is prime:</label>
<input name="number" type="number" />
<button type="submit">Submit</button>
</form>
<p id="result"></p>
<script src="demo.js"></script>
</body>
</html>
创建一个demo.js
.
理论上有不止一个方法来加载WebAssembly模块, 但效率最高的还是编译他们, 并结合WebAssembly.instantiateStreaming
方法把它们实例化到一个Stream中. 注意我们需要提供一个在断言不通过时调用的abort
方法
(async () => {
const importObject = {
env: {
abort(_msg, _file, line, column) {
console.error("abort called at index.ts:" + line + ":" + column);
}
}
};
const module = await WebAssembly.instantiateStreaming(
fetch("build/optimized.wasm"),
importObject
);
const isPrime = module.instance.exports.isPrime;
const result = document.querySelector("#result");
document.querySelector("#prime-checker").addEventListener("submit", event => {
event.preventDefault();
result.innerText = "";
const number = event.target.elements.number.value;
result.innerText = `${number} is ${isPrime(number) ? '' : 'not '}prime.`;
});
})();
我们需要一个server来使用WebAssembly.instantiateStreaming
, 这些模块需要被按照application/wasm
的MIME type来提供. static-server
能提供这个功能(译者注: 总之我没找到VSCode的live
server在哪里配置MIME Type).
npm install --save-dev static-server
在package.json
中添加一行脚本:
{
"scripts": {
"serve-demo": "static-server"
}
}
运行npm run serve-demo
然后在浏览器中打开localhost
. 在表格中提交一个数字, 那么应该能看到一个提示来告知这个数字是否是一个质数. 现在我们已经搞定了从编写AssemblyScript到在网页中使用它的步骤.
WebAssembly, 就算加上AssemblyScript的拓展, 也不会神奇地把所有网站变得更快, 但这并不重要. WebAssembly存在的意义在于它赋能了很大一系列的Web能力.
而对于AssemblyScript, 它使得WebAssembly能够被更多的开发者使用, 让我们能够更轻松地在沿用JavaScript的同时能够在需要大量代数计算的地方使用WebAssembly.