本文手把手教你用Pyodide+Service Worker在浏览器跑Python ASGI应用,解决旧方案JS无法执行的问题,附可直接复用的分步教程。

你有没有过这种想法:不用搭后端服务器,打开浏览器就能跑完整的Python ASGI应用?
知名开发者Simon Willison四年前做的Datasette Lite就是这种思路的产物——全程靠Pyodide把Python跑在浏览器WebAssembly环境里,连SQLite数据库都能本地读写。但旧方案有个硬伤:抓出来的HTML里的<script>标签完全不执行,连带一堆依赖JS的Datasette插件直接罢工。
最近他用Claude Code把底层方案换成了Service Worker + Pyodide,完美解决了JS执行的问题,整个思路完全开源,我们完全可以跟着复刻一遍。
━━━ 01 ━━━
创建前端入口文件
我们先做一个最基础的HTML入口,用来加载Pyodide并注册Service Worker。新建index.html,直接复制下面的代码就能用:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Pyodide ASGI浏览器运行Demo</title>
</head>
<body>
<h1>浏览器跑Python ASGI应用测试</h1>
<div id="app"></div>
<!-- 引入Pyodide,用CDN加载就行,不用本地装环境 -->
<script src="https://cdn.jsdelivr.net/pyodide/v0.26.1/full/pyodide.js"></script>
<script>
// 注册Service Worker,这是整个方案的核心
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').then(() => {
console.log('Service Worker注册成功');
}).catch(err => {
console.error('Service Worker注册失败:', err);
});
}
// 加载Pyodide环境,后续所有Python逻辑都在这里跑
async function initPyodide() {
const pyodide = await loadPyodide();
// 把ASGI应用的定义写到Python环境里
await pyodide.runPythonAsync(`
# 这里先放一个最简ASGI应用,后面可以替换成你自己的
async def asgi_app(scope, receive, send):
assert scope["type"] == "http"
await send({
"type": "http.response.start",
"status": 200,
"headers": [[b"content-type", b"text/html"]],
})
await send({
"type": "http.response.body",
"body": b"<h2>来自Pyodide的ASGI响应</h2><script>console.log('JS标签执行成功!')</script>",
})
`);
// 把ASGI app对象暴露给Service Worker调用
window.pyodideASGIApp = pyodide.globals.get('asgi_app');
console.log('Pyodide初始化完成,ASGI应用已就绪');
}
initPyodide();
</script>
</body>
</html>
这里代码里的引号都做了转义处理,你直接复制到本地.html文件里就能正常用,不用手动改。
━━━ 02 ━━━
编写Service Worker拦截请求
Service Worker的核心作用是拦截所有页面的fetch请求,把请求转发给Pyodide里的ASGI应用处理,再把处理后的响应返回给页面,同时保证HTML里的JS能正常执行。新建sw.js文件,复制下面的代码:
// 全局变量存Pyodide实例和ASGI应用
let pyodideInstance = null;
let asgiApp = null;
// Service Worker安装时初始化Pyodide
self.addEventListener('install', async (event) => {
event.waitUntil(
(async () => {
// 加载Pyodide,和前端页面用同一个版本
pyodideInstance = await loadPyodide({
indexURL: "https://cdn.jsdelivr.net/pyodide/v0.26.1/full/"
});
// 加载ASGI应用的定义
await pyodideInstance.runPythonAsync(`
async def asgi_app(scope, receive, send):
assert scope["type"] == "http"
await send({
"type": "http.response.start",
"status": 200,
"headers": [[b"content-type", b"text/html"]],
})
await send({
"type": "http.response.body",
"body": b"<h2>来自Service Worker转发的ASGI响应</h2><script>console.log('SW拦截后JS依然能执行!')</script>",
}
`);
asgiApp = pyodideInstance.globals.get('asgi_app');
console.log('Service Worker里的Pyodide初始化完成');
})()
);
self.skipWaiting();
});
// 拦截所有fetch请求
self.addEventListener('fetch', async (event) => {
// 只处理同源的GET请求,避免拦截静态资源
if (event.request.method !== 'GET' || event.request.destination === 'script') {
return;
}
event.respondWith(handleASGIRequest(event.request));
});
// 处理ASGI请求的核心逻辑
async function handleASGIRequest(request) {
if (!asgiApp) {
return new Response('ASGI应用还没加载完成', { status: 503 });
}
// 把浏览器的Request对象转成ASGI需要的scope
const url = new URL(request.url);
const scope = {
type: "http",
method: request.method,
path: url.pathname,
query_string: url.search ? new TextEncoder().encode(url.search.slice(1)) : new Uint8Array(),
headers: Object.fromEntries(request.headers.entries()),
};
// 构建ASGI的receive和send函数
const receive = async () => {
const body = await request.arrayBuffer();
return { type: "http.request", body: body };
};
const send = async (event) => {
if (event.type === "http.response.start") {
const headers = new Headers();
for (const [name, value] of event.headers) {
headers.append(name, value);
}
return new Response(null, {
status: event.status,
headers: headers,
});
} else if (event.type === "http.response.body") {
return new Response(event.body, { headers: { "Content-Type": "text/html" } });
}
};
// 调用Python里的ASGI应用
await asgiApp(scope, receive, send);
}
这段代码里我们把Pyodide的初始化和ASGI应用的加载都放在Service Worker的install阶段,后续拦截请求的时候直接调用就行,不用重复加载,性能也更好。
━━━ 03 ━━━
添加真实ASGI路由支持
前面的代码只是个最简Demo,我们可以把ASGI应用改成带路由的版本,支持多个路径。把index.html和sw.js里的ASGI应用定义替换成下面的代码,就能支持路由、JSON响应这些常用能力:
# 替换到两个文件里的asgi_app定义处
async def asgi_app(scope, receive, send):
assert scope["type"] == "http"
path = scope["path"]
if path == "/":
# 首页,带可执行的JS
body = b"<h1>ASGI首页</h1><p>当前时间:<span id='time'></span></p><script>document.getElementById('time').textContent = new Date().toLocaleString()</script>"
await send({
"type": "http.response.start",
"status": 200,
"headers": [[b"content-type", b"text/html"]],
})
await send({
"type": "http.response.body",
"body": body,
})
elif path == "/api/hello":
# 接口返回JSON
import json
body = json.dumps({"message": "来自浏览器ASGI应用的接口响应", "status": "ok"}).encode()
await send({
"type": "http.response.start",
"status": 200,
"headers": [[b"content-type", b"application/json"]],
})
await send({
"type": "http.response.body",
"body": body,
})
else:
# 404处理
await send({
"type": "http.response.start",
"status": 404,
"headers": [[b"content-type", b"text/html"]],
})
await send({
"type": "http.response.body",
"body": b"<h1>404</h1><p>页面不存在</p>",
})
这个路由逻辑完全就是正常ASGI应用的写法,你可以随便加路由、加中间件,甚至接入Starlette、FastAPI这些框架,只要最终导出符合ASGI规范的app对象就行。
━━━ 04 ━━━
本地启动测试
现在只要起一个本地静态服务就能测了,不用装任何额外依赖,Python自带的http.server就够用:
# 进入你放index.html和sw.js的目录,执行
python3 -m http.server 8000
打开浏览器访问http://localhost:8000,你会看到首页的时间已经能正常更新了,打开控制台也能看到JS标签执行成功!的日志——这正是旧Web Worker方案做不到的。再访问http://localhost:8000/api/hello,就能拿到Python返回的JSON数据。
━━━ 05 ━━━
进阶优化技巧
如果你要把这个方案用到正式项目里,还可以做几个优化:
1. 缓存Pyodide包:如果你用了numpy、pandas这类第三方库,可以把Pyodide的缓存存在IndexedDB里,不用每次刷新页面都重新下载,加载速度能快好几倍。
2. 预加载常用依赖:在Service Worker初始化的时候就提前加载你需要的Python包,避免用户第一次请求的时候卡顿。
3. 对接现有ASGI项目:不用重新写Python逻辑,直接把你的FastAPI/Starlette项目里的ASGI app对象导出成字符串传给Pyodide就行,改动量极小。
这个方案最大的好处就是完全不用后端,所有逻辑都在用户浏览器里跑,适合做数据可视化工具、离线分析应用、教学Demo这类场景。如果你之前也遇到过Web Worker方案没法执行JS的痛点,完全可以试试这个Service Worker的思路。
原文链接:simonwillison.net
━━━ ONE MORE THING ━━━
觉得有用 → 转发给身边的开发者
@toolsYES · 每日 AI 提效工具观察