30 分钟
项目全解析

核心模块逐行解析:执行器与判题引擎

就拆解项目最核心的两个模块:Pyodide 执行器和自动判题引擎

  • 理解 Web Worker 通信机制
  • 掌握 Pyodide 执行器的完整流程
  • 理解判题引擎的三种判题模式
  • 学会超时保护和错误处理

Pyodide 执行器:lib/pyodide/executor.ts

这是项目里最核心的文件。它在 Web Worker 里加载 Pyodide、执行 Python 代码、收集输出。就按段落拆着说。

单例模式与请求管理

JavaScript
// ===== 单例 Worker 管理 =====
let worker: Worker | null = null;
let pyodideReady = false;
let requestId = 0;

// 待处理请求的 Map:请求 ID -> { resolve, reject, timer }
const pendingRequests = new Map<
  number,
  {
    resolve: (result: ExecutionResult) => void;
    reject: (error: Error) => void;
    timer: ReturnType<typeof setTimeout>;
  }
>();

/*
 * 设计要点:
 * 1. worker 是单例:整个应用共享一个 Worker,避免重复加载
 * 2. requestId 自增:每次执行分配唯一 ID,用于匹配返回结果
 * 3. pendingRequests Map:同时发起多个请求时,通过 ID 区分回调
 * 4. 每个请求有 timer:超时保护,防止死循环永久挂起
 */
🐍为什么用 Map 而不是对象?

Map 的 key 可以是数字,增删性能更好。频繁增删的场景,用 Map 比普通对象合适。

创建 Worker 与消息处理

JavaScript
// ===== 创建 Worker =====
function createWorker(): Worker {
  // type: "module" 是因为 Pyodide v314 使用 ES 模块
  const w = new Worker("/pyodide/worker.js", { type: "module" });

  w.onmessage = (e: MessageEvent) => {
    const msg = e.data;
    if (msg.type === "result" && msg.id !== undefined) {
      // 找到对应的请求回调
      const pending = pendingRequests.get(msg.id);
      if (pending) {
        clearTimeout(pending.timer);
        pendingRequests.delete(msg.id);
        pending.resolve({
          output: msg.output || "",
          error: msg.error || null,
          executionTime: msg.executionTime || 0,
        });
      }
    }
  };

  w.onerror = () => {
    // Worker 崩溃时,拒绝所有待处理请求
    pendingRequests.forEach((pending) => {
      clearTimeout(pending.timer);
      pending.reject(new Error("Worker 加载失败,请刷新重试。"));
    });
    pendingRequests.clear();
    worker = null;
  };

  return w;
}

/*
 * 通信流程:
 * 主线程                          Worker
 *   │ postMessage({id, code}) ───→│ 接收代码
 *   │                              │ Pyodide 执行
 *   │←── postMessage({id, result}) │ 返回结果
 *   │ 通过 id 找到 resolve 回调     │
 */

执行 Python 代码

TypeScript
// ===== 执行入口 =====
export async function executePython(
  code: string,
  options: ExecuteOptions = {}
): Promise<ExecutionResult> {
  const { timeout = 5000, stdin = [] } = options;

  // 懒初始化:第一次执行时才创建 Worker
  if (!worker) worker = createWorker();

  return new Promise((resolve, reject) => {
    const id = requestId++;

    // 超时保护:5 秒后自动终止
    const timer = setTimeout(() => {
      pendingRequests.delete(id);
      reject(new Error("执行超时(超过 5 秒)。检查是否有死循环。"));
    }, timeout);

    pendingRequests.set(id, { resolve, reject, timer });
    worker!.postMessage({ type: "execute", id, code, stdin });
  });
}

/*
 * 关键设计:
 * 1. Promise 封装:回调风格变成 async/await
 * 2. 超时保护:setTimeout 在指定时间后 reject
 * 3. 懒加载:第一次运行代码时才加载 Pyodide
 */

Web Worker 内部:worker.js

JavaScript
// worker.js 核心逻辑
let pyodide = null;

async function loadPyodideRuntime() {
  if (pyodide) return pyodide;
  const mod = await import("/pyodide/pyodide.js");
  const py = await mod.loadPyodide({ indexURL: "/pyodide/" });

  // 重定向 stdout:Python 的 print() 调用这个回调
  py.setStdout({ batched: (text) => { currentOutput += text + "\n"; } });
  py.setStderr({ batched: (text) => { currentError += text + "\n"; } });

  pyodide = py;
  return py;
}

self.onmessage = async (e) => {
  const { type, id, code } = e.data;
  if (type !== "execute") return;

  const py = await loadPyodideRuntime();
  currentOutput = ""; currentError = "";
  const startTime = performance.now();
  try {
    await py.runPythonAsync(code);
  } catch (err) {
    currentError = err.message;
  }
  self.postMessage({
    type: "result", id,
    output: currentOutput,
    error: currentError || null,
    executionTime: performance.now() - startTime,
  });
};

判题引擎:lib/judge/engine.ts

Python
# 三种判题模式
print("""
模式 1:标准输出比对
  用户 print() 的输出 == 期望输出
模式 2:函数返回值断言
  执行用户定义的函数,断言返回值
模式 3:异常捕获测试
  验证代码是否正确抛出/处理异常
""")

输出比对的实现

TypeScript
// ===== 输出比对 =====
function compareOutput(actual: string, expected: string): boolean {
  // 逐行比对,忽略首尾空白和行尾空格
  const a = actual.trim().split("\n").map(l => l.trimEnd());
  const e = expected.trim().split("\n").map(l => l.trimEnd());
  if (a.length !== e.length) return false;
  return a.every((line, i) => line === e[i]);
}

/*
 * 为什么要 trim?
 * 1. 用户可能多打一个换行
 * 2. Windows 和 Unix 换行符不同
 * 3. 行尾空格不可见但会导致比较失败
 * 但不能过度宽松:只 trim 行尾,不 trim 行首(缩进可能是答案的一部分)
 */
🚫判题安全注意事项

判题代码和用户代码在同一个 Pyodide 环境里跑。用户要是改了内置函数,比如写 print = lambda x: None,可能会影响判题。生产环境得在独立命名空间里执行。

选择题

executePython 中 requestId 的作用是什么?

预测输出
print("hello" == "hello\n".strip())
选择题

为什么执行 Python 要放进 Web Worker,而不是主线程?

资深工程师加餐

底层原理 · 大厂视角 · 工程经验,点卡片展开

客户端把进度存到本地(localStorage / SQLite)后,下一版字段结构一变,老用户存档就可能解析失败、进度全丢。专业做法是给存档加 version 字段,读取时按版本依次执行迁移函数(补默认值、改名、重组结构),并对解析全程 try/catch、坏档能回退到安全默认。写入时还要防御 NaN/undefined(如 JSON.stringify(NaN) 会变成 null),这正是「数据比代码活得久」的含义。

挑战任务

实现请求 ID 配对器

简单+50 XP

多个请求并发发出、响应可能乱序回来。请实现 pair(pending_ids, incoming):incoming 是乱序的 [id, payload],返回按 pending_ids 顺序排列的 payload 列表。

实现请求 ID 配对器
1 个测试用例