最近项目中遇到一个文档解析的场景,目标是在浏览器端能预览markdown文件。
拿到这个需求,相信很多前端同学会想到使用开源的库,比如github上很受欢迎的marked,当然,是一个简单而有效的方案。
但是如果你了解webassembly一点点的话,相信你也会觉得,像这种数据处理的活交给C++来干,没错。
好吧,我们抱着这个猜想开始下面的尝试吧。
搭建环境
为了把C++代码编译成能在浏览器上运行的wasm,我们需要使用 Emscripten
。
安装Emscripten依赖如下几个工具:Git、CMake、GCC、Python 2.7.x。
编译 Emscripten:
git clone https://github.com/juj/emsdk.git
cd emsdk
./emsdk install sdk-incoming-64bit binaryen-master-64bit
./emsdk activate sdk-incoming-64bit binaryen-master-64bit
source ./emsdk_env.sh
创建项目
推荐如下的目录结构:
.
├── build
├── build.sh
├── include
│ └── sundown
│ ├── autolink.c
│ ├── autolink.h
│ ├── buffer.c
│ ├── buffer.h
│ ├── houdini.h
│ ├── houdini_href_e.c
│ ├── houdini_html_e.c
│ ├── html.c
│ ├── html.h
│ ├── html_blocks.h
│ ├── markdown.c
│ ├── markdown.h
│ ├── stack.c
│ └── stack.h
├── src
│ ├── index.cc
│ └── wasm.c
└── web
├── index.html
├── index.js
├── index.wasm
└── test.md
这里为了测试,我是直接使用了通过C解析markdown文档开源库sundown。就是目录中的include/sundown。
我们需要一个入口文件,取一个名字wasm.c
。
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <emscripten/emscripten.h>
#include "markdown.h"
#include "html.h"
#include "buffer.h"
#define READ_UNIT 1024
#define OUTPUT_UNIT 64
const char*
EMSCRIPTEN_KEEPALIVE wasm_markdown(char* source)
{
struct buf *ib, *ob;
struct sd_callbacks callbacks;
struct html_renderopt options;
struct sd_markdown *markdown;
ib = bufnew(READ_UNIT);
bufgrow(ib, READ_UNIT);
size_t char_len = strlen(source);
bufput(ib, source, char_len);
ob = bufnew(OUTPUT_UNIT);
sdhtml_renderer(&callbacks, &options, 0);
markdown = sd_markdown_new(0, 16, &callbacks, &options);
sd_markdown_render(ob, ib->data, ib->size, markdown);
sd_markdown_free(markdown);
/* cleanup */
bufrelease(ib);
bufrelease(ob);
return (char *)(ob->data);
}
入口文件是调用lib的方法实现md字符解析,输出html格式的字符。完成编码部分,接下来就可以构建了。
这是我的build脚本:
emcc src/wasm.c \
-O3 \
./include/sundown/markdown.c \
./include/sundown/buffer.c \
./include/sundown/autolink.c \
./include/sundown/html.c \
./include/sundown/houdini_href_e.c \
./include/sundown/houdini_html_e.c \
./include/sundown/stack.c \
-s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap", "ccall"]' \
-s TOTAL_MEMORY=67108864 \
-s TOTAL_STACK=31457280 \
-o build/index.js -I./include/sundown \
cp build/index.js build/index.wasm web/
解释下其中的几个参数:
EXTRA_EXPORTED_RUNTIME_METHODS
,允许js通过cwrap和ccall的方式调用c函数。TOTAL_MEMORY
,分配内存大小。TOTAL_MEMORY
,分配栈大小。因为md文档比较大,默认的5M不够用。
测试
启动一个web Server,因为webAssembly不支持file协议下加载。
emrun --port 3000 ./web
emrun是Emscriptem自带的webServer工具,你也可以使用你喜欢的。
初始化并调用C接口。
<script src="http://127.0.0.1:3000/markdown.js"></script>
<script>
const wasm_markdown = Module.cwrap('wasm_markdown', 'string', ['string']);
console.log(wasm_markdown('# hello wasm'));
// 输出:<h1>hello wasm</h1>
</script>
多线程
先看DEMO,分析在代码之后。
const mdUrl = 'http://127.0.0.1:3000/markdown.js';
class MarkdownParse {
isInited = false;
worker = undefined;
async init(url) {
if (this.isInited) {
return;
}
return new Promise(rs => {
const workerScripts = `
addEventListener('message', async(e) => {
if (e.data == "startWorker") {
importScripts("${url}");
postMessage({ type: 'init' });
} else if (e.data.type === 'parseData') {
await markdown.ready;
const data = markdown.parse(e.data.input);
postMessage({ type: 'parseSuccess', data });
}
}, false)`;
this.worker = new Worker(window.URL.createObjectURL(new Blob([workerScripts])));
this.worker.addEventListener('message', e => e.data.type === 'init' ? rs() : '');
this.worker.postMessage("startWorker");
this.isInited = true;
})
}
async parse(input) {
if (!this.isInited) {
await this.init(mdUrl);
}
return new Promise(resolve => {
this.worker.addEventListener('message',
e => e.data.type === 'parseSuccess' ?
resolve(e.data.data) : null
);
this.worker.postMessage({ type: 'parseData', input });
});
}
};
(async() => {
const md = new MarkdownParse();
// // 触发多次解析
// const html = [
// await md.parse('# Hello Markdown'),
// await md.parse('- [ ] Todo1'),
// await md.parse('- [ ] Todo2'),
// await md.parse('- [x] Todo3'),
// await md.parse('> Date.now()'),
// await md.parse('`const a = Date.now();`'),
// ];
// document.querySelector('#markdown-body').innerHTML = html.join('');
md.parse('123');
const text = await (await fetch('test.md')).text();
const testJS = () => {
const a = Date.now();
// marked 是JS版本的markdown解析库
marked(text);
return Date.now() - a;
};
const testWasm = async() => {
const a = Date.now();
await md.parse(text);
return Date.now() - a;
};
const vs = async() => {
const result = {
js_parse_time: testJS(),
wasm_parse_time: await testWasm(),
};
result.speed = result.js_parse_time / result.wasm_parse_time;
// 显示wasm和JS的解析速度对比
document.querySelector('#markdown-body').innerHTML = JSON.stringify(result);
}
await vs();
// 输出markdown的HTML
// document.querySelector('#markdown-body').innerHTML = await md.parse(text);
})();
解析下思路,线抽线一个类 MarkdownParse
来实现wasm的加载和初始化以及api。
默认情况下, web worker是不允许跨域的,但是,有方案的。web worker内部提供了一个importScripts方法来加载非同源的JS。
收获&总结
到此我们完成了今天的构建webassembly应用实例,有如下收获:
- wasm比JS,效率高出3倍左右
- 通过web worker去处理wasm的加载,初始化,数据计算处理等,不会占用浏览器的主线程
总结,本文可能只是一个很小的场景,而且单从效率这点来看,JS的200ms对比wasm的50ms,其实对于前端来说,并没有特别惊艳的优势。BUT,这只是一个开始,wasm对前端带来的性能提升会百花齐放,我们拭目以待吧~