在 Web Workers 中使用 Pyodide
上一篇文章我们介绍了借助 Pyodide 如何在前端浏览器中运行 Python 程序的方法,从例子中可以看到当运行计算任务比较密集的代码的时候,前端用户界面会出现停止响应的情况,原因就是浏览器执行 JavaScript 代码所使用的单线程架构,运行时间长的代码会阻塞线程,这种情况可以尝试把部分代码放到 Web Workers 中去执行。
Web Workers 独立于主线程之外并在后台单独开出的一个线中程执行代码,这样可以避免阻塞主线程代码的执行。目前 Web Workers 技术还有一些限制,比如不支持在Web Work 线程中使用 DOM 对象,Pyodide 不支持在多个 Web Workers 以及 Work 和主线程之间共享 Python 解释器、程序包,也不支持 Work 和主线程之间共享全局对象,使用的时候需要注意。
本篇文章介绍如何使用 Web Workers 在单独的线程里画图,然后把图形数据传回主线程,并在主线程里展现图片。下面的部分代码参考并引用自官网的指南。
一、引用 Pyodide
Pyodide 本地安装请参考上一篇文章的说明,本例中的代码使用了模块的方式(也可以不用),所以在插件的主程序中需要做一些特殊处理。
add_action( 'wp_enqueue_scripts', "pyodide_enqueue");
function pyodide_enqueue() {
wp_enqueue_script( 'pyodide', plugins_url( '/pyodide/pyodide.js', __FILE__ ), array('jquery'));
wp_enqueue_script( 'main', plugins_url( '/js/main.js', __FILE__ ), array('pyodide'));
}
//对需要模块化的javascript文件做类型替换
add_filter("script_loader_tag", "add_module_to_script", 10, 2);
function add_module_to_script($tag, $handle) {
if ("main" === $handle) {
$tag = str_replace('text/javascript', 'module', $tag);
}
return $tag;
}
二、新建 WordPress 文章
在文章中插入 “自定义HTML” 区块,输入下面的内容并保存:
<div id="pct"/>
三、主程序
main.js,主要负责 Python 程序代码的建立,worker 运行上下文参数的建立,使用 Web Workers 代理(py-worker.js)创建 worker,提取 worker 返回值,用户界面处理等职能。
import { asyncRun } from "/wp-content/plugins/my-plugin/js/py-worker.js";
//Python代码
const script = `
import numpy as np
import scipy.stats as stats
import matplotlib.pyplot as plt
import io, base64
import js
from js import obj1, obj2
# matplotlib的一些方法要使用document对象,做下面的处理
class matplotlib_patch:
def __init__(self, *args, **kwargs) -> None:
return
def __getattr__(self, __name: str):
return matplotlib_patch
js.document = matplotlib_patch()
//输出主程序传递过来的数据
print(obj1, obj2.to_py())
# 画图
t = np.arange(0, 3.1, 0.1)
s = 1 + np.sin(2 * np.pi * t)
fig, ax = plt.subplots()
ax.plot(t, s)
ax.set(xlabel='time (s)', ylabel='voltage (mV)', title='Draw sin curve')
ax.grid()
buf = io.BytesIO()
fig.savefig(buf, format='png')
buf.seek(0)
# 返回图形数据
'data:image/png;base64,' + base64.b64encode(buf.read()).decode('UTF-8')
`;
//要传入到Work中Python程序所使用的参数
const context = {
obj1: [1, 2, 3],
obj2: {a:1, b:2, c:3}
};
async function run_worker() {
try {
//调用代理py-worker.js建立worker,运行python程序,并把返回结果显示到界面上
const { results, error } = await asyncRun(script, context);
if (results) {
jQuery(document).ready( function() {
let div_container = document.getElementById('pct');
div_container.innerHTML = "<img id='fig' />"
let img_tag = document.getElementById('fig');
//把worker生成的图片数据赋值给img元素
img_tag.src = results;
});
} else if (error) {
console.log("Error: ", error);
}
} catch (e) {
console.log(
`Error at ${e.filename}, Line: ${e.lineno}, ${e.message}`
);
}
}
document.getElementById('pct') && run_worker();
四、Worker 代理程序
py-worker.js,主要负责新建 worker,给 worker 发消息传递主程序建立的 Python 脚本、上下文参数,把 worker 运行 Python 程序的结果返回给主程序。
const pyodideWorker = new Worker("/wp-content/plugins/my-plugin/js/webworker.js");
//保存每个worker相关的Promise运行成功回调函数的引用
const callbacks = {};
//接收worker返回的结果
pyodideWorker.onmessage = (event) => {
//根据worker返回数据里的Promise id,提取关联的回调函数
const { id, ...data } = event.data;
const onSuccess = callbacks[id];
delete callbacks[id];
//运行回调函数,把结果返回给主程序
onSuccess(data);
};
//主程序调用asyncRun创建worker
const asyncRun = (() => {
let id = 0; // 给每个worker运行的Promise分配唯一标识
return (script, context) => {
//产生id的算法
id = (id + 1) % Number.MAX_SAFE_INTEGER;
//新建Promise给worker发消息
return new Promise((onSuccess) => {
//保存Promise运行成功回调函数引用,获得worker消息后调用该函数
callbacks[id] = onSuccess;
//给worker发消息,传递python程序代码、上下文参数和Promise id
pyodideWorker.postMessage({
...context,
python: script,
id,
});
});
};
})();
export { asyncRun };
五、Worker 程序
webworker.js,主要负责装载 Python 程序包,接收代理程序消息,提取 Python 程序脚本、上下文参数,运行 Python 程序,给代理程序发消息返回结果。
importScripts("/wp-content/plugins/my-plugin/pyodide/pyodide.js");
//装载Python程序包
async function loadPyodideAndPackages() {
self.pyodide = await loadPyodide();
await self.pyodide.loadPackage(["numpy", "pytz"]);
await self.pyodide.loadPackage(['scipy', "matplotlib"]);
}
let pyodideReadyPromise = loadPyodideAndPackages();
//接收代理程序发来的消息,运行Python程序
self.onmessage = async (event) => {
//等待实例化pyodide和装载python程序包完成
await pyodideReadyPromise;
//提取代理程序传入的主程序建立的参数和本次运行的Promise id
const { id, python, ...context } = event.data;
//把参数拷贝到worker自己的运行环境中,这样在Python程序中可以用import引入
for (const key of Object.keys(context)) {
self[key] = context[key];
}
//调用pyodide运行python程序,发消息给代理程序返回结果
try {
await self.pyodide.loadPackagesFromImports(python);
let results = await self.pyodide.runPythonAsync(python);
//除了返回Python程序运行结果,还有代理程序传入的本次Promise id
self.postMessage({ results, id });
} catch (error) {
self.postMessage({ error: error.message, id });
}
};
最后打开 WordPress 中新建的文章,就可以看到图片了。
通过对上面程序模型的介绍,就可以根据自己的需求来编写 Python 程序,并基于代理建立 worker 并运行 Python 程序,再把结果返回给主程序并显示到用户界面上,这个过程没有主线程阻塞。另外,对于包装 Web Worker API,方便 worker 应用,谷歌的 Comlink、Workbox,AMP 的 worker-dom 都是不错的选择。