
简介这是一份基于 Flask 框架与 CodeMirror 编辑器组件实现的网页版 Python 编辑器课程大作业源码面向计算机相关专业学生及 Web 开发初学者可用于完成程序设计课程项目或作为在线 IDE 开发的入门参考。项目使用 HTML/CSS 构建前端页面CodeMirror 提供高亮显示、自动补全与主题切换能力Flask 承载用户注册登录、文件新建删除保存、代码运行等接口还可根据 Python 代码解析生成流程图运行结果会实时输出到编辑区下方的控制台区域。压缩包共 630 个文件、约 1.75MB其中 310 个 JavaScript 与 82 个 CSS 多为编辑器前端资源190 个 HTML 构成页面模板12 个 Python 文件则集中了 Flask 路由与业务逻辑目录结构清晰方便定位前后端代码。该资源已有 235 人学习下载部署运行后可快速体验登录、建文件、写代码、运行并生成流程图的完整流程也适合拆解在线编辑器的关键实现积累 Flask 与 CodeMirror 的集成经验。1. 网页版Python编辑器为什么把IDE塞进浏览器值得做一次一个纯前端编辑器配一个 Flask 后端不到 500 行代码就能做出「浏览器里写 Python、点运行、下面出结果、还能生成流程图」的效果这就是这门课程大作业的完整形态。CodeMirror 提供高亮和补全Flask 管认证和文件持久化服务端 subprocess 执行用户代码并把 stdout/stderr 抓回来这套结构拿去做内部工具平台、在线实训系统、甚至轻量教学后台的原型都够用。适合刚写完基础 Flask 项目想往前推一步的开发者也想知道「浏览器编辑器到底怎么和服务端通信」的人。看完你能完整复现出来也知道卡在并发和沙箱时下一步该往哪想。2. Flask应用骨架与注册登录模块先让用户体系跑起来整个项目以一个 main.py 作为入口Flask 默认端口 5000启动后用 localhost 访问首页。功能拆开看分为认证、文件管理、代码编辑运行、流程图生成这四块它们不是平级的认证拦截在前面文件目录跟着用户走编辑器页面是核心壳运行和流程图都依赖后端。2.1 项目目录结构与main.py入口设计项目实际展开后目录大概是下面这种形态。前端静态资源集中在 static 目录CodeMirror 的 css 和 js 文件原样放进来主题文件如 ambiance.css、solarized.css、mdn-like.css 都保留方便后面做主题切换。project/ ├── main.py ├── templates/ │ ├── login.html │ ├── register.html │ └── editor.html ├── static/ │ ├── css/ │ │ ├── codemirror.css │ │ ├── theme/ # 各主题css │ │ └── docs.css │ ├── js/ │ │ ├── codemirror.js │ │ ├── mode/python/python.js │ │ ├── addon/hint/show-hint.js │ │ └── editor.js └── data/ ├── users.db # SQLite └── codes/username/ # 用户代码文件main.py 里最核心的入口逻辑是启动后自动初始化数据库和目录结构这部分我习惯写成一个 create_app 函数。下面是一个与该项目一致的 Flask 启动骨架import os import sqlite3 from flask import Flask BASE_DIR os.path.dirname(os.path.abspath(__file__)) DATA_DIR os.path.join(BASE_DIR, data) CODES_DIR os.path.join(DATA_DIR, codes) def create_app(): app Flask(__name__) app.secret_key os.environ.get(EDITOR_SECRET, dev-secret-key) os.makedirs(CODES_DIR, exist_okTrue) _init_db() from auth import auth_bp from editor import editor_bp app.register_blueprint(auth_bp) app.register_blueprint(editor_bp) return app def _init_db(): db_path os.path.join(DATA_DIR, users.db) conn sqlite3.connect(db_path) conn.execute(CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, created_at TEXT DEFAULT (datetime(now)))) conn.commit() conn.close()这里有几个细节值得说明。secret_key 从环境变量读而不是硬编码进源码里因为项目用了 Flask session 保存登录态如果 secret 被其他人拿到就能伪造会话 cookie。_init_db 表结构里把 username 设为 UNIQUE注册重名直接抛 IntegrityError省掉一次额外的查询校验。数据目录 data 下按用户名建 codes 子目录每个用户读到的文件列表只来自自己的目录多用户之间天然隔离。2.2 用户表结构与注册接口实现用户表只存 username、password_hash、created_at 三个业务字段密码只存哈希后的值不存明文。Flask 生态里 werkzeug.security 提供了 generate_password_hash 和 check_password_hash大作业直接用这两个函数就行不要再自己拼一个加盐算法。注册路由通常会做一个简单的表单校验用户名长度 3-20 位、两次密码一致、用户名不能已存在。from flask import Blueprint, request, redirect, url_for, flash from werkzeug.security import generate_password_hash auth_bp Blueprint(auth, __name__) auth_bp.route(/register, methods[POST]) def register(): username request.form.get(username, ).strip() pwd_a request.form.get(password, ) pwd_b request.form.get(password2, ) if not (3 len(username) 20): flash(用户名长度需在 3-20 之间, error) return redirect(url_for(auth.register_page)) if pwd_a ! pwd_b or not pwd_a: flash(两次密码不一致或为空, error) return redirect(url_for(auth.register_page)) conn sqlite3.connect(DB_PATH) try: conn.execute( INSERT INTO users (username, password_hash) VALUES (?, ?), (username, generate_password_hash(pwd_a)), ) conn.commit() except sqlite3.IntegrityError: flash(用户名已存在, error) return redirect(url_for(auth.register_page)) finally: conn.close() user_dir os.path.join(CODES_DIR, username) os.makedirs(user_dir, exist_okTrue) return redirect(url_for(auth.login_page))这段逻辑的要点在错误路径的处理上。如果用户重名或参数不合法用 flash 把错误信息带回到注册页面注册成功后为这个用户单独创建代码目录等登录时直接复用。不要在注册成功接口里顺手做自动登录——这个项目的前端页面跳转是登录路由自动登录会让 session 状态管理和理解成本增加拆开更稳。登录校验的反面也要补上查询用户时把 username 作为占位符参数传进 SQL不要用 f-string 拼接。用户输入形如admin--的内容时如果直接拼 SQL就等于把登录逻辑绕过了这类注入在这个项目里是最高频的靶点。2.3 登录态管理与请求拦截登录成功后把用户身份写进 session然后在所有非公开路由前面加一个统一的检查。从 functools 引入 wraps 写一个 login_required 装饰器分别装饰「编辑器主页、文件操作、运行代码、生成流程图」这些接口比在每个路由函数头部复制一遍 session 判断要稳妥。from functools import wraps from flask import session, redirect, url_for def login_required(view): wraps(view) def wrapped_view(**kwargs): if not session.get(username): return redirect(url_for(auth.login_page)) return view(**kwargs) return wrapped_view这里有个容易被忽略的细节wraps(view) 会把被装饰函数的name和doc复制给 wrapper。Flask 根据 view 函数的名称自动生成 endpoint如果不加 wraps所有带这个装饰器的路由 endpoint 都变成 wrapped_view重名直接抛 ValueError。装饰器写好后编辑器首页路由这样用editor_bp.route(/, methods[GET]) login_required def editor_page(): username session[username] files _list_user_files(username) return render_template(editor.html, usernameusername, filesfiles)登录态本身依赖 Flask session 的 cookie默认情况下浏览器关闭就失效。想保持更长时间业务上可以给 session 设置 permanentFlask 会据此调整 cookie 的过期时间。把项目里的路由按权限聚一下类方便照着搭接口路由方法功能权限/registerGET/POST注册公开/loginGET/POST登录公开/GET编辑器主页登录/savePOST保存文件登录/runPOST运行代码登录/flowchartPOST生成流程图登录这张路由表里 /run 和 /flowchart 只接受 POST这和前端按钮的点击动作一一对应。初学者常把 GET 和 POST 的分工用错导致点击运行按钮时前端直接弹出 404——因为浏览器地址栏输入只发 GET后端没定义 GET 方法就报 Method Not Allowed。2.4 测试账号与首次启动验证项目自带了两个测试账号CaptainChen 和 Alice密码都是 12345。这个设计不是随便写的它同时验证了「库里没这个用户」和「用户首次登录没有代码目录」这两类边界。首次启动后先不注册直接用测试账号登录观察后端是否给 CaptainChen 创建 codes/CaptainChen 目录再去注册一个新用户确认识别不同用户名目录互不可见。验证命令就是项目文档里写的pip install flask python main.py启动成功后浏览器打开 localhost:5000 会跳到登录页。如果你用的是远程主机需要让 Flask 监听 0.0.0.0main.py 末尾应写成app.run(host0.0.0.0, port5000, debugFalse)。这里 debug 必须关掉——Werkzeug 的调试器会在请求出现异常后提供一个交互式控制台任何能触发异常的用户都能直接执行代码这个风险在公网环境下是致命的。3. CodeMirror集成高亮、自动补全与主题定制页面右侧那块编辑区域由 CodeMirror 支撑其核心价值不在于「把一个 textarea 换成一个好看的框」而是拆分词法、维护内部状态、在输入过程中实时进行语法高亮并用增量方式渲染。相比 CodeMirror 6 的按需加载模块化体系这个项目用的 CodeMirror 5 是整体式 API引入 codemirror.js、写入 Python mode 和补全 addon几行代码就能初始化。3.1 CodeMirror初始化与Python模式加载编辑器页面的初始化脚本大概是下面这样放在一个原生 JavaScript 文件里在 DOM 加载完成后执行var editor CodeMirror.fromTextArea( document.getElementById(code), { mode: python, theme: ambiance, lineNumbers: true, indentUnit: 4, tabSize: 4, autoCloseBrackets: true, matchBrackets: true, lineWrapping: false, viewportMargin: Infinity } );CodeMirror.fromTextArea 会把隐藏的原生 textarea 替换成编辑器实例mode 决定词法解析规则Python mode 文件在 mode/python/python.js 中定义不能漏引入否则高亮和缩进都对不上。indentUnit 设为 4 符合 PEP 8 的习惯tabSize 设成 4 是为了让 Tab 键和空格在视觉上等宽避免混合缩进时看不出错。autoCloseBrackets 和 matchBrackets 分别控制括号补全与配对高亮这两个 addon 分别位于 addon/edit/closebrackets.js 和 addon/edit/matchbrackets.js不引文件就算配置了也没效果。viewportMargin: Infinity 是这里一个不常见的配置。默认情况下 CodeMirror 只渲染视口附近的几十行上下滚动时临时补渲染对项目这种小文件场景反而可能出现滚动条跳动。把它设为 Infinity 能强制一次性渲染全部行文件不大时性能没有明显差异但整体滚动行为会平滑很多。等以后代码文件扩展到上千行再改回默认值换取内存节省。初始化前先把 addon 对应的文件列出来排查漏引时直接对着核addon 文件功能启用方式addon/edit/closebrackets.js自动闭合括号autoCloseBrackets: trueaddon/edit/matchbrackets.js括号匹配高亮matchBrackets: trueaddon/hint/show-hint.js显示补全下拉列表cm.showHint()addon/selection/active-line.js当前行背景高亮styleActiveLine: trueaddon/comment/comment.js块注释切换toggleComment3.2 自动补全本地模板与jedi服务端提示自动补全有两条路。偏轻量的一条是用 CodeMirror 自带的 show-hint 插件把 Python 关键字和内置函数放在一个 wordList 里每次触发时按前缀匹配偏工程的一条是前端把光标前的 token 发到 Flask 后端由 jedi 库分析完整代码后返回补全候选。课程作业的原始实现里补全基于本地词表基本够用下面这种写法兼容两种数据源CodeMirror.registerHelper(hint, python, function(cm) { var cur cm.getCursor(); var token cm.getTokenAt(cur); var start token.start; var end cur.ch; var list [ def, class, if, elif, else, for, while, import, from, try, except, with, as, print, len, range, list, dict, set, self, append, split, join, format ]; var prefix token.string.slice(0, end - start); var filtered list.filter(function(w) { return w.indexOf(prefix) 0 w ! prefix; }); return { list: filtered, from: CodeMirror.Pos(cur.line, start), to: CodeMirror.Pos(cur.line, end) }; }); editor.setOption(extraKeys, { Ctrl-Space: function(cm) { cm.showHint({ hint: CodeMirror.hint.python, completeSingle: false }); } });这段代码里最关键的是 return 对象的 range 信息。from 和 to 指定了补全替换的范围必须从 token 起始位置到光标位置如果漏掉 from插件默认从光标替换导致多余字符残留。completeSingle: false 的作用是只有一个匹配项时不自动替换让用户自己按 Tab 或 Enter 确认避免误补全。本地词表的方式有一个硬伤它对变量名、函数参数、第三方库内部方法无能为力。要进阶把 token 内容 POST 到 /api/complete服务端用 jedi.Script(code, path..., lineline, columncol).complete() 取候选返回 JSON前端再把它接进 registerHelper 的 list 即可。这一步改造能把补全从「关键词联想」提升到「语义补全」是该项目后期最有性价比的优化点。3.3 主题切换与快捷键绑定项目自带了一堆 CodeMirror 主题 cssambiance、abbott、solarized、mdn-like、element、liquibyte、merge、docs 等这些主题文件原样放在 static/css/theme 下页面挂一个主题下拉框就能用。切换主题不需要重新初始化编辑器只要调用 setOption 改 theme 的类名function switchTheme(themeName) { editor.setOption(theme, themeName); localStorage.setItem(editor_theme, themeName); } var savedTheme localStorage.getItem(editor_theme); if (savedTheme) { editor.setOption(theme, savedTheme); }主题切换做成持久化存储时注意一个问题localStorage 是浏览器域级别的同域下的所有页面共享。如果后续有其他页面用到这个 key建议加前缀比如 editor_theme_ 加用户名避免不同账号之间的主题偏好互相覆盖。CodeMirror 的 extraKeys 属于 EditorOption 的一部分setOption(extraKeys, {...}) 会整体覆盖之前设置不能将多个快捷键定义分散在不同 init 调用里否则后者会覆盖前者的完整对象。3.4 文件树联动与脏标记编辑器页面左侧是文件选择列表上方是新建设删除运行保存生成流程图按钮。一个容易忽视的交互是「编辑内容变了没保存」的判断。没有脏标记时用户点了别的文件直接扔掉当前修改数据就丢了有脏标记时切换文件前弹一个确认保存后把标记复位。var dirty false; var lastSavedContent ; editor.on(change, function() { if (editor.getValue() ! lastSavedContent) { dirty true; } }); function askBeforeSwitch(filename) { if (dirty) { var ok confirm(当前文件未保存确认切换); if (!ok) return; } loadFile(filename); } function afterSave(content) { lastSavedContent content; dirty false; refreshFileList(); }把 lastSavedContent 在每次保存成功后更新为 editor.getValue()这是区分「用户主动修改」和「setValue 初始化」的简单手段。CodeMirror 的 change 事件对 setValue 同样触发所以不能只靠 change 事件置位必须与保存时记录的快照做对比否则从后端加载文件也会误报脏标记。课程作业里最容易出的 bug 就藏在这一处用户什么都没改切换文件却弹确认框。4. 代码运行与流程图生成从AST到可视化编辑器只是前端壳真正让代码跑起来的是 Flask 后端的执行接口。用户点运行后前端拿到当前 editor.getValue() 发送到 /run后端把代码写进用户目录的临时文件用独立 subprocess 执行再捕获 stdout 和 stderr 返回给前端。运行和生成流程图两个按钮都要求先保存这个限制的本质是后端必须基于磁盘上的文件做操作而不是接收随时可能被前端篡改的字符串否则文件树和运行结果会对不上。4.1 运行接口与subprocess执行沙箱运行接口的关键代码如下。先从请求 JSON 里取 filename拼出用户目录下的绝对路径校验文件存在且后缀是 .py然后交给 subprocessimport subprocess import sys from flask import request, jsonify editor_bp.route(/run, methods[POST]) login_required def run_code(): data request.get_json(forceTrue) if not data or filename not in data: return jsonify({error: 缺少文件名}), 400 username session[username] filepath os.path.join(CODES_DIR, username, data[filename]) if not os.path.isfile(filepath): return jsonify({error: 文件不存在请先保存或新建}), 404 if not filepath.endswith(.py): return jsonify({error: 仅支持运行 .py 文件}), 400 code open(filepath, encodingutf-8).read() try: proc subprocess.run( [sys.executable, -u, filepath], capture_outputTrue, textTrue, cwdos.path.dirname(filepath), timeout10, ) return jsonify({ stdout: proc.stdout, stderr: proc.stderr, returncode: proc.returncode, }) except subprocess.TimeoutExpired: return jsonify({error: 程序运行超时10秒限制}), 500这里有几个参数要说清。sys.executable 确保执行环境是当前 Flask 进程所在解释器避免系统存在多个 Python 版本时用了另一个环境导致 import 不到依赖。-u 参数关闭 stdout 缓冲区让 print 的输出能立刻写回管道不关闭的话运行结果会滞后甚至缺失。capture_outputTrue 等价于 stdoutPIPE 和 stderrPIPE把运行过程中的标准输出和错误信息统一抓回来textTrue 让它在 Python 层解码而不是返回 bytes 再手动 decode。timeout10 是硬性保护课程作业环境里最常见的死循环脚本就靠它兜底。4.2 前端运行按钮与结果回显前端收到 JSON 后需要把 stdout 和 stderr 分别显示在下方运行结果区里。简单做法是把两者用不同颜色区分不能让报错混在正常输出里否则排错时很难定位function runCode() { saveFile(function() { fetch(/run, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ filename: currentFile }) }) .then(function(res) { return res.json(); }) .then(function(data) { if (data.error) { showResult(data.error, error); return; } var out data.stderr ? [stderr]\n data.stderr \n : ; showResult(out data.stdout, data.returncode 0 ? ok : warn); }); }); }这段代码里 saveFile 回调里嵌套 runCode 不是随手为之它呼应了「运行必须在保存后」的业务规则。先调保存接口把当前编辑器内容写盘再根据保存是否成功决定是否继续执行如果保存接口是异步实现那运行按钮不能直接向后端发请求否则刚保存的内容还没落盘后端跑的还是旧版本。fetch 拿到的响应必须先 res.json() 转成对象再分别取 error、stdout、stderr、returncode 字段如果后端返回 500 而且没走 JSON 格式res.json() 会直接抛异常稳妥写法是在 then 里先判断 res.ok。4.3 流程图生成的三种技术路径生成流程图是该项目里最有区分度的功能。常见的实现路径有下表中三种课程作业选 pyflowchart 这类 AST 解析方案最省事技术路线原理优缺点适用场景pyflowchart用 AST 解析源码将 if/for/while 映射为流程图节点接入成本低、输出 Graphviz 文本快复杂表达式支持有限教学场景、基础语法生成的课程代码graphviz AST/CFG手动遍历 ast 构建控制流图再用 Graphviz 渲染表达能力强可画函数调用关系代码量大需要精确控制图结构的项目前端解析 SVG浏览器内解析 Python 源码并直接渲染无后端压力解析器体积大、兼容性参差演示性质、离线环境这个项目用 pyflowchart 思路核心接口是 Flowchart.from_code 解析出结构文本再用 graphviz 渲染成 SVG 返回。如果只是生成 mermaid 格式文本连 graphviz 都不用装但 SVG 在浏览器里展示起来更直观import pyflowchart from graphviz import Source editor_bp.route(/flowchart, methods[POST]) login_required def generate_flowchart(): data request.get_json(forceTrue) filename data.get(filename, ) filepath os.path.join(CODES_DIR, session[username], filename) with open(filepath, encodingutf-8) as f: code f.read() try: fc pyflowchart.Flowchart.from_code(code) graph Source(fc.flowchart()) svg graph.pipe(formatsvg).decode(utf-8) return jsonify({svg: svg}) except Exception as e: return jsonify({error: 源码存在语法错误无法生成流程图}), 400pyflowchart.Flowchart.from_code 会先调用 ast.parse 拿到抽象语法树再把 FunctionDef 的 body 中有 If/For/While 的节点转成 diamond/rect 结构的流程图。用 graphviz 的 Source 对象直接 pipe 出 SVG前端将其渲染在弹窗里比让后端生成 png 再传输少一次文件读写。这里有个坑pyflowchart 要求代码可解析如果用户保存的只是半截代码ast.parse 会抛 SyntaxError所以后端要捕获异常并提示「源码存在语法错误」不能直接让 500 页面露出来。4.4 流程图前端渲染与保存前置约束前端拿到 SVG 字符串后把它显示在一个 modal 中而不是替换编辑区。SVG 本身是 XML直接拼进 innerHTML 时要注意转义稳妥做法是把 svg 字符串放进受控 DOM 元素的 innerHTML 后清掉里面可能残留的 script 节点function showFlowchart(svgText) { var modal document.getElementById(flowchartModal); var container document.getElementById(flowchartSvg); container.innerHTML svgText; var bad container.querySelectorAll(script); bad.forEach(function(n) { n.parentNode.removeChild(n); }); modal.style.display block; }这个 SVG 渲染的附加防护非常实用。虽然 pyflowchart 生成的是标准 SVG不包含脚本但多一层过滤能防止未来接入自定义解析渲染时某个节点文本把带事件属性的标签带进 DOM。课程作业如果只在本地跑风险尚可接受放到公网给多个用户使用就必须做这一步清理。流程图按钮同样走「先保存再生成」的约束逻辑和运行按钮一致避免前端与服务端文件内容不一致。5. 资源隔离与并发写文件的实战排查一个看起来能跑的编辑器和能安全跑起来的编辑器差距就在这几点临时文件命名、进程清理、并发与写冲突、以及不能相信前端传来的任何内容。5.1 文件命名与命名空间多用户系统里如果文件名直接用用户上传的原始名称当两个用户在不同目录下操作时问题不大但后端如果因为某个功能把文件写到了公共 tmp 目录同名的概率就很高。常见做法是在文件名前加用户 id 前缀或把文件严格存放在以用户名命名的目录下user_dir os.path.join(CODES_DIR, session[username]) safe_name os.path.basename(filename) filepath os.path.join(user_dir, safe_name) if not os.path.abspath(filepath).startswith(os.path.abspath(user_dir) os.sep): return jsonify({error: 非法文件名}), 400os.path.basename 能去掉用户传入的 ../、/etc/passwd 这类路径分隔但要提防 Windows 风格的反斜杠路径所以再加一层 startswith 校验做目录穿透拦截防止用户用 ../ 拼接路径跳出自己的命名空间读写他人文件。5.2 同名文件的并发写入问题两个用户理论上有不同目录不会互相覆盖但同一个用户同时打开两个浏览器标签页编辑同一个文件并先后点击保存就会形成写-写竞争。Flask 默认每个请求独立线程处理此时对同一个文件的写入需要在保存接口内部加锁import threading file_locks {} def get_file_lock(username, filename): key f{username}/{filename} with threading.Lock(): if key not in file_locks: file_locks[key] threading.Lock() return file_locks[key] editor_bp.route(/save, methods[POST]) login_required def save_file(): data request.get_json(forceTrue) filename data.get(filename, ) content data.get(content, ) lock get_file_lock(session[username], filename) with lock: filepath os.path.join(CODES_DIR, session[username], filename) with open(filepath, w, encodingutf-8) as f: f.write(content) return jsonify({ok: True})get_file_lock 用线程锁的 dict 做缓存同一个文件映射到同一个锁对象配合 with lock 块确保同一时刻只有一个线程在写某个文件。这个方案只适用于单进程模型——如果后续用 gunicorn 起了多个 worker 进程threading.Lock 不跨进程需要换成文件锁或借助 SQLite 的写事务。5.3 并发执行时的进程清理4.1 的 subprocess 方案在接口层面已经限制了 timeout10 秒但没处理超时之后进程是否真的被杀死。capture_output 加 timeout 抛出 TimeoutExpired 时子进程可能还占着系统资源。稳妥做法是显式 killproc subprocess.Popen( [sys.executable, -u, filepath], stdoutsubprocess.PIPE, stderrsubprocess.PIPE, textTrue ) try: out, err proc.communicate(timeout5) except subprocess.TimeoutExpired: proc.kill() out, err proc.communicate()proc.communicate(timeout5) 在超时后抛出 TimeoutExpired此时调用 kill 终止进程再用一次 communicate 回收 stdout/stderr 管道缓冲防止进程成为僵尸或管道堵塞。注意这里管道数据量较大的风险如果用户循环打印一万行缓冲区满了之后子进程会因为在写管道时阻塞而停在中间状态可以考虑在 communicate 前就限制输出大小。5.4 保存接口的 XSS 边界最后说一个容易被忽视的边界。编辑器保存的是 .py 代码运行结果回显到页面时如果没有转义用户写一个print(scriptalert(1)/script)的结果前端 showResult 直接 innerHTML等于给所有访问该页面的人注入脚本。修复方案有两个要点结果区域优先用 textContent 渲染不要直接把字符串拼进 innerHTML后端返回结果时对、做 HTML 转义。编辑器这种场景下真正的安全边界是「代码内容可以任意但展示内容的 DOM 必须由可信方式写入」。课程作业做到这一步已经比大多数线上 demo 考虑得周全如果还要再进一步可以在响应头加 CSP 限制脚本执行把内联事件和外部脚本来源全部收紧。本文还有配套的精品资源点击获取