近期线上收实验报告的时候,学生们上传的图片乱七八糟的。后期提示使用 扫描王 等软件处理后再上传效果好了很多。但无疑这给学生了带来了相应的繁琐。于是:如何在WEB能快速的处理图片,并实时的显示效果成为了新的需求。
首先,我们可以点击demo感受一下它的魅力。
而处理图片往往都在后端执行,直接在 WEB 处理则需要一个叫WebAssembly的知识,简单来说就是浏览器允许运行二进制的文件,而这个二进制的文件则是各种原后端语言通过编译器编译出来的。
所以可以用C++来写一个图片处理程序,并使用WebAssembly把它应用到浏览器中便成了解决方案。
本文在macos下,演示如何把Hello world运行在浏览器中。
Emscripten
要想把C++源码编译成浏览器可以运行的 WebAssembly , 则需要一些编译器,而Emscripten则属于其中的一个。
docker(推荐)
docker无疑是最简单的安装方式,官方image提供了多个版本供我们选择。
我们在使用前仅仅需要下载相应的image即可,比如我们下载最新的版本:
% docker pull emscripten/emsdk
然后我们进行文件夹映射,并执行容器中的emcc命令即可,比如:
docker run --rm -v $(pwd):/src -u $(id -u):$(id -g) \
emscripten/emsdk emcc helloworld.cpp -o helloworld.js
则表示将当前路径下的helloworld.cpp编译成helloworld.js
macos
安装 Emscripten 需要从github下载相当的代码,并执行相应的操作,
环境要求:
- macOS 10.14 Mojave及以上
- 安装 Xcode Command Line Tools
- 安装git
- 安装cmake
命令如下:
# 下载代码
$ git clone https://github.com/emscripten-core/emsdk.git --depth=1
# 进入下载的文件夹
$ cd emsdk
# 执行安装命令,由于这个操作会从网下下载相应的第三方安装包,所以这可能需要一个比较友好的网络
$ ./emsdk install latest
# 源活我们刚刚安装的 latest 版本
$ ./emsdk activate latest
# 源活环境变量,每启动一新的shell,都要执行一次
$ source ./emsdk_env.sh
验证:
创建以下文件:
#include <stdio.h>
int main() {
printf("Hello World\n");
return 0;
}
验证
我们新建hello.c文件
#include <stdio.h>
int main() {
printf("Hello World\n");
return 0;
}
然后执行emcc hello.c -o hello.html
panjie@panjies-Mac-Pro src $ emcc hello.c -o hello.html
shared:INFO: (Emscripten: Running sanity checks)
cache:INFO: generating system asset: symbol_lists/ed436b369ffc02205671a0a9df422f9da2cf641b.txt... (this will be cached in "/Users/panjie/github/emscripten-core/emsdk/upstream/emscripten/cache/symbol_lists/ed436b369ffc02205671a0a9df422f9da2cf641b.txt" for subsequent builds)
cache:INFO: - ok
然后我们就得到了 一个 html 文件,一个js文件以及一个wasm文件:
panjie@panjies-Mac-Pro src % ls
hello.c hello.html hello.js hello.wasm
接着我们起一个http-server,并在浏览器中查看效果:
panjie@panjies-Mac-Pro src % http-server
Starting up http-server, serving ./
Available on:
http://127.0.0.1:8081
http://192.168.0.242:8081
Hit CTRL-C to stop the server
如果你是用的docker,则可以如下执行:
panjie@panjies-Mac-Pro src % docker run --rm -v $(pwd):/src -u $(id -u):$(id -g) emscripten/emsdk emcc hello.c -o hello.html
cache:INFO: generating system asset: symbol_lists/ed436b369ffc02205671a0a9df422f9da2cf641b.txt... (this will be cached in "/emsdk/upstream/emscripten/cache/symbol_lists/ed436b369ffc02205671a0a9df422f9da2cf641b.txt" for subsequent builds)
cache:INFO: - ok
最后的效果是一致的。
分析
.c文件编译后生成了3个新的文件,html文件用于展示页面并且调用js文件,js文件则充当获取二进制文件,装载二进制文件,调用二进制文件并获取返回值的目的,而wasm则是浏览器直接执行的二进制文件。该文件由c语言编译而来,可以兼顾功能与效率,重要的是原本一些只能支持在应用程序中的功能,可以被移植到浏览器中来了。
编译至指定模板
Emscripten的github源码中,为我们提供了自定义的html模板,下面我们将 HelloWorld输入到这个自定义的模板中。
首先我们在当前文件夹中建立子文件夹html_template,并将位于Emscripten的github源码文件夹中的 /upstream/emscripten/src/shell_minimal.html
复制到html_template
文件夹。
panjie@panjies-Mac-Pro src % tree
.
├── hello.c
├── hello.html
├── hello.js
├── hello.wasm
└── html_template
└── shell_minimal.html
1 directory, 5 files
接着我们执行如下命令:
panjie@panjies-Mac-Pro src % docker run --rm -v $(pwd):/src -u $(id -u):$(id -g) emscripten/emsdk emcc -o hello1.html hello.c -O3 --shell-file html_template/shell_minimal.html
cache:INFO: generating system asset: symbol_lists/812dbbffa7488aec7a503446fae422688638f439.txt... (this will be cached in "/emsdk/upstream/emscripten/cache/symbol_lists/812dbbffa7488aec7a503446fae422688638f439.txt" for subsequent builds)
cache:INFO: - ok
注意,上面的命令中O3
不是03
.
此时,便会使用模模板html_template/shell_minimal.html
来生成hello1.html
,使用http-server起个服务后查看结果如下:
如果我们将当前的网络模拟成慢速3G:
则会发下如下启动过程:
先下载
再准备
最后才是呈现结果
请求的时序如下:
如果我们开启缓存,那么整体请求将会友好的多:
自定义模板
学习 DEMO
通过 shell_minimal.html 模板的学习,我们简单的把关键的信息拿出来学习一下。首先是 CSS 样式部分,该部门主要用于控制页面显示,我们暂时略过。
上图这个html基本上可以分为两个部分,第一部分是图像UI输出,第三部分是sheel控制台输出。比如我们的hello.c,并没有输出任何图像,而是直接打印了 Hello World,所以上述图像就输出了一个黑框框。
在模板中,用于输出图像的标签是canvas
:
<canvas class="emscripten" id="canvas" oncontextmenu="event.preventDefault()" tabindex=-1></canvas>
而用于输出shell信息的是textarea
<textarea class="emscripten" id="output" rows="8"></textarea>
而以下的script代码的作用起的是衔接的作用:加载.c,运行.c,输出.c的结果。
<script type='text/javascript'>
// 获取三个dom,分别用于显示 状态、进度,以及 loading时转圈圈
var statusElement = document.getElementById('status');
var progressElement = document.getElementById('progress');
var spinnerElement = document.getElementById('spinner');
// 订义一个对象,该对象的各个属性方法都是WebAssembly规定好的
var Module = {
// 运行前执行的
preRun: [],
// 运行后执行的
postRun: [],
// 输出结果
print: (function() {
// 获取用于输出结果的 textarea DOM
var element = document.getElementById('output');
if (element) element.value = ''; // 清空textarea中的内容
// 返回function供hello.js调用,hello.js会在执行hello.c后调用该函数,并把执行hello.c结果做为参考text传入
return function(text) {
// arguments的上下文位于hello.js中,字段意思看是 调用参数. 如果调用的参数大于1,则重写 text 的为arguments数组
if (arguments.length > 1) text = Array.prototype.slice.call(arguments).join(' ');
// 下面这几行就给出的示例,主要是用于替换html中的关键字,比如 < 使用 < 来替换
//text = text.replace(/&/g, "&");
//text = text.replace(/</g, "<");
//text = text.replace(/>/g, ">");
//text = text.replace('\n', '<br>', 'g');
console.log(text);
// 向 textarea 中输出内容,并调焦点设置为textarea的底部
if (element) {
element.value += text + "\n";
element.scrollTop = element.scrollHeight; // focus on bottom
}
};
})(),
// 输出图像
canvas: (function() {
// 获取 canvas
var canvas = document.getElementById('canvas');
// 浏览器对图像的渲染基于webgl,所以做一个默认的初始化选项,
// 为了保证程序的健壮性,我们应该在webgl上下文丢失时,提醒用户重新刷新界面
// 原文如下:
// As a default initial behavior, pop up an alert when webgl context is lost. To make your
// application robust, you may want to override this behavior before shipping!
// See http://www.khronos.org/registry/webgl/specs/latest/1.0/#5.15.2
canvas.addEventListener("webglcontextlost", function(e) { alert('WebGL context lost. You will need to reload the page.'); e.preventDefault(); }, false);
// 将这个 canvas 返回给hello.js,hello.js则会将图像输出到这个 canvas 上
return canvas;
})(),
// 设置状态(比如开始下载、下载的百分比,准备完毕),这个应该不是WebAssembly的官方接口,而是自定义的
setStatus: function(text) {
// 设置个最后更新时间
if (!Module.setStatus.last) Module.setStatus.last = { time: Date.now(), text: '' };
// 如果更新内容与最后的更新内容相同,则什么也不做
if (text === Module.setStatus.last.text) return;
// 判断传入的是否为下载的百分比(进度)
var m = text.match(/([^(]+)\((\d+(\.\d+)?)\/(\d+)\)/);
var now = Date.now();
// 如果传入的是下载百分比,而且距离上次传入的时间小于30ms,则什么也不做。
if (m && now - Module.setStatus.last.time < 30) return; // if this is a progress update, skip it if too soon
// 设置最后的时间及最后的文本
Module.setStatus.last.time = now;
Module.setStatus.last.text = text;
if (m) {
// 传入的是下载百分比,则格式化
text = m[1];
progressElement.value = parseInt(m[2])*100;
progressElement.max = parseInt(m[4])*100;
progressElement.hidden = false;
spinnerElement.hidden = false;
} else {
// 不是百分比,则清空进度值
progressElement.value = null;
progressElement.max = null;
progressElement.hidden = true;
// 当text为空时,隐藏掉spinner
if (!text) spinnerElement.hidden = true;
}
// 最后设置状态元素的内容
statusElement.innerHTML = text;
},
// 总依赖数
totalDependencies: 0,
// 监视运行依赖项,该方法会被间隔调用,用于通知当前加载的进度
// @param left 剩余依赖项
monitorRunDependencies: function(left) {
// 未加载完毕,则设置状态为 Preparing... (已加载数/未加载数);否则显示 All downloads complete
this.totalDependencies = Math.max(this.totalDependencies, left);
Module.setStatus(left ? 'Preparing... (' + (this.totalDependencies-left) + '/' + this.totalDependencies + ')' : 'All downloads complete.');
}
};
// 设置起始状态
Module.setStatus('Downloading...');
// 设置下异常的回调
window.onerror = function() {
Module.setStatus('Exception thrown, see JavaScript console');
spinnerElement.style.display = 'none';
Module.setStatus = function(text) {
if (text) console.error('[post-exception status] ' + text);
};
};
</script>
模板最后存在的{{{ SCRIPT }}}
则用于替换为 js 文件的引用。如此,我们先声明了符合 WebAssembly 接口的对象 Module,然后引入了 js 文件,而js文件则会应用这个刚刚声明的Module。这样一来,WebAssembly的 JS 文件更与当前页面结合起来了。
自定义模板
为了验证前面的假设,我们下面来如下自定义模板并命名为sample.html
,同是样存到html_template文件夹中:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>WebAssembly</title>
</head>
<body>
<div style="margin-left: auto; margin-right: auto; width: 400px; margin-top:10em; text-align: center;">
<p id="output">这里是输出的内容</p>
</div>
<script type="text/javascript">
// 订义一个对象,该对象的各个属性方法都是WebAssembly规定好的
var Module = {
preRun: [],
postRun: [],
// 输出结果
print: (function() {
// 获取用于输出结果的 textarea DOM
var element = document.getElementById('output');
if (element) element.innerHTML = '';
return function(text) {
if (element) {
element.innerHTML += text;
}
};
})(),
totalDependencies: 0,
monitorRunDependencies: function(left) {
console.log(left);
}
};
// 设置下异常的回调
window.onerror = function() {
alert('error');
};
</script>
{{{ SCRIPT }}}
</body>
</html>
然后我们运行以下命令来将hello.c渲染进来:
docker run --rm -v $(pwd):/src -u $(id -u):$(id -g) emscripten/emsdk emcc -o sample.html hello.c -O3 --shell-file html_template/sample.html
最后我们将得到以sample打头的js html 以及 wasm 文件,运行http-server并查看:
函数
最后我们再看看如何调用.cpp文件中的函数,我们简单用c++语言写个求开平方,并把它应用html页面中。官方文档指出调用 C 语言中function最简单的方法便是使用 ccall以及cwrap.
ccall()
使用指定的参数来调用一个编译后的 C 函数,而cwrap()
则是把 C 中的函数包裹成js的函数,然后再像调用普通的js函数一样来进行调用。所以如果我们只想调用一次,那么用ccall
就好了,如果我们想多次调用,则建立使用cwarp
来封装一下。
创建一个 sqrt.cpp 文件并加入以下代码:
#include <math.h>
// 兼容 C++
extern "C" {
int int_sqrt(int x) {
return sqrt(x);
}
}
接下来我们将其编译为 js wasm文件,需要注意的是:
- 本次我们是先编译,然后再写模板,所以我们把目标文件设置为sqrt.js,而非sqrt.html
- 我们需要指定编译的方法,并以
_
打头 - 我们需要指定ccall、cwarp
docker run --rm -v $(pwd):/src -u $(id -u):$(id -g) emscripten/emsdk emcc sqrt.cpp -o sqrt.js -sEXPORTED_FUNCTIONS=_int_sqrt -sEXPORTED_RUNTIME_METHODS=ccall,cwrap
最终将生成 js 及 wasm 两种类型的文件。
最后,我们写个html代码来尝试调用一下:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>加法器</title>
</head>
<body style="margin-left:auto; margin-right:auto; width: 800px;">
<div>请输入:<input type="number" id="num"/></div>
<div>结果:<p id="output"></p></div>
<button onclick="sqrt()">运算</button>
<script type="text/javascript">
// 订义一个对象,该对象的各个属性方法都是WebAssembly规定好的
var Module = {};
// 设置下异常的回调
window.onerror = function() {
alert('error');
};
// 获取输入、输出
var num = document.getElementById("num");
var output = document.getElementById("output");
// 定义开平方方法
var sqrt = function() {
// 调用c++中的int_sqrt方法
const result = Module.ccall(
"int_sqrt", // 方法名
"number", // 返回值类型
["number"], // 参数类型,这是个数组,因为可能是多参数
[+num.value] // 参数值,也是个数组,因为可能是多参数
);
// 最后将结果给html元素
output.innerHTML = result;
}
</script>
<!-- 手动引用js文件 -->
<script async src=sqrt.js></script>
</body>
</html>
最后我们再测试下 cwrap
:
<script type="text/javascript">
let intSqrt;
// 订义一个对象,该对象的各个属性方法都是WebAssembly规定好的
var Module = {
monitorRunDependencies: function(left) {
// 加载完毕后初始化iniSqrt方法
if (left === 0 && !intSqrt) {
intSqrt = Module.cwrap('int_sqrt', 'number', ['number'])
}
}
};
// 设置下异常的回调
window.onerror = function() {
alert('error');
};
// 获取输入、输出
var num = document.getElementById("num");
var output = document.getElementById("output");
// 定义开平方方法
var sqrt = function() {
// 直接调用intSqrt方法
output.innerHTML = intSqrt ? intSqrt(+num.value) : '';
}
</script>
最终实现效果相同。
总结
WebAssembly是个变革性的东西,有人说它将引领下一代WEB开发,它的出现使得原本仅能安装客户端才能实现在功能,当下可以直接在 WEB 端来使用了。同时由于其编译为2进制的特性,在保证了性能的同时能提供了足够的安全性。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。