
一、项目概述
1.1 项目目标
实现一个在线计算器,包含两种使用方式:
| 使用方式 | 文件 | 说明 |
|---|---|---|
| 纯 API 调用 | app.py | 只提供 JSON 接口,用 curl 或命令行工具调用 |
| 浏览器页面版 | app2.py + templates/simple.html | 有输入框和按钮的网页版 |
| 命令行客户端 | client.py | 终端交互式计算工具 |
1.2 技术栈
| 层 | 技术 |
|---|---|
| 后端 | Python + Flask(Web 框架) |
| 前端 | HTML + JavaScript(浏览器原生,无框架) |
| 数据格式 | JSON(前后端通信) |
| 环境隔离 | Python venv(虚拟环境) |
1.3 文件结构
python_web/├── venv/ # 虚拟环境(Python 解释器 + Flask 依赖)├── app.py # 纯 API 版后端├── app2.py # API + 前端页面版后端├── client.py # 命令行客户端├── requirements.txt # 依赖声明(flask)└── templates/ └── simple.html # 前端页面(JS 调用 API)二、阶段一:最基础的 API —— 从零开始的 app.py
2.1 什么是 API?
API(Application Programming Interface,应用程序接口)可以理解为:一个通过网络调用的函数。
- 普通函数:
result = calc("1+1")—— 在同一个程序里调用 - API 函数:通过 HTTP 请求调用,返回 JSON 数据
客户端(curl / 浏览器) │ POST /api/calc ← HTTP 请求 {"expr": "1+1"} │ ▼ Flask 服务器(app.py) │ ▼ {"expr": "1+1", ← HTTP 响应 "result": "2"}2.2 完整代码(25 行)
from flask import Flask, request, jsonify
app = Flask(__name__)
def safe_eval(expr: str) -> str: allowed = set("0123456789+-*/(). ") if not all(c in allowed for c in expr): raise ValueError("只允许数字和 + - * / ( ) 运算符") return str(eval(expr))
@app.route("/api/calc", methods=["POST"])def calc(): data = request.get_json(force=True, silent=True) or {} expr = str(data.get("expr", "")).strip()
if not expr: return jsonify({"error": "表达式为空"}), 400
try: result = safe_eval(expr) return jsonify({"expr": expr, "result": result}) except Exception as e: return jsonify({"expr": expr, "error": str(e)}), 400
if __name__ == "__main__": app.run(host="0.0.0.0", port=8000, debug=True)2.3 代码逐行详解
第 1 行:导入 Flask
from flask import Flask, request, jsonify| 导入项 | 作用 |
|---|---|
Flask | 创建 Web 应用的核心类 |
request | 全局对象,代表当前收到的 HTTP 请求 |
jsonify | 工具函数,把 Python 字典转换成 JSON HTTP 响应 |
第 3 行:创建应用实例
app = Flask(__name__)- 调用
Flask()构造函数,传入当前模块名__name__ - Flask 用这个信息知道项目根目录在哪里
app就是我们的 Web 应用对象,后面所有路由都挂在它上面
第 6-10 行:安全计算函数
def safe_eval(expr: str) -> str: allowed = set("0123456789+-*/(). ") if not all(c in allowed for c in expr): raise ValueError("只允许数字和 + - * / ( ) 运算符") return str(eval(expr))为什么要做安全检查?
Python 内置的 eval() 非常强大——强大到可以执行任意代码:
eval("__import__('os').system('rm -rf /')") # ❌ 危险!如果有人提交这样的表达式,服务器就被破坏了。所以必须在调用 eval() 之前做白名单检查:
| 步骤 | 代码 | 说明 |
|---|---|---|
| ① 白名单 | allowed = set("0123456789+-*/(). ") | 只允许这些字符 |
| ② 检查 | all(c in allowed for c in expr) | 每个字符都必须在白名单里 |
| ③ 拒绝 | raise ValueError(...) | 有非法字符就抛出异常中断 |
| ④ 计算 | str(eval(expr)) | 通过检查,安全执行 |
💡
all(c in allowed for c in expr)是 Python 的生成器表达式,相当于:for c in expr:if c not in allowed:return Falsereturn True遍历表达式的每个字符,检查是否全部合法。
第 13 行:路由装饰器
@app.route("/api/calc", methods=["POST"])@app.route(...) 是一个装饰器(decorator),作用是:
“当收到访问
/api/calc路径、且使用 POST 方法的请求时,执行下面的calc()函数。“
| 参数 | 含义 |
|---|---|
"/api/calc" | URL 路径(必须以 / 开头) |
methods=["POST"] | 只接受 POST 请求 |
GET vs POST 的区别:
| 方法 | 用途 | 数据位置 | 示例 |
|---|---|---|---|
| GET | 获取数据 | URL 里 | 打开网页、搜索 |
| POST | 提交数据 | 请求体里 | 登录、提交表单、提交计算 |
我们的计算需要用户提交表达式,所以用 POST。
第 14-25 行:请求处理函数
def calc(): data = request.get_json(force=True, silent=True) or {} expr = str(data.get("expr", "")).strip()
if not expr: return jsonify({"error": "表达式为空"}), 400
try: result = safe_eval(expr) return jsonify({"expr": expr, "result": result}) except Exception as e: return jsonify({"expr": expr, "error": str(e)}), 400| 行 | 代码 | 说明 |
|---|---|---|
| 15 | request.get_json(force=True, silent=True) or {} | 从请求体读取 JSON。 • force=True — 即使请求头没声明 JSON 也强行解析• silent=True — 解析失败返回 None,不抛异常• or {} — 解析失败就用空字典兜底 |
| 16 | str(data.get("expr", "")).strip() | 从 JSON 中读 "expr" 字段:• data.get("expr", "") — 取不到就用 ""• str(...) — 强制转字符串• .strip() — 去两端空格 |
| 18-19 | if not expr: return jsonify({...}), 400 | 表达式为空 → 返回错误。, 400 设置 HTTP 状态码 400(Bad Request) |
| 21-25 | try/except 结构 | 异常处理: • 成功 → 返回结果(状态码 200) • 失败 → 返回错误信息(状态码 400) • Exception as e — 把异常对象存到 e 中 |
第 28-29 行:启动服务器
if __name__ == "__main__": app.run(host="0.0.0.0", port=8000, debug=True)| 参数 | 含义 |
|---|---|
host="0.0.0.0" | 监听所有网络接口,局域网内其他设备也能访问 |
port=8000 | 监听 8000 端口 |
debug=True | 调试模式:①修改代码自动重启;②错误时显示详细信息 |
if __name__ == "__main__": 的含义:只有直接运行这个文件时才执行这段代码。如果这个文件被其他文件 import,则不执行。
2.4 运行与测试
启动服务器:
cd /Users/alan/code/python_webvenv/bin/python app.py看到以下输出说明启动成功:
* Running on http://127.0.0.1:8000用 curl 测试(新开一个终端):
# 正常计算curl -X POST http://localhost:8000/api/calc \ -H "Content-Type: application/json" \ -d '{"expr": "1+1"}'# → {"expr": "1+1", "result": "2"}
# 运算符优先级curl -X POST http://localhost:8000/api/calc \ -H "Content-Type: application/json" \ -d '{"expr": "(2+3)*4"}'# → {"expr": "(2+3)*4", "result": "20"}
# 非法字符curl -X POST http://localhost:8000/api/calc \ -H "Content-Type: application/json" \ -d '{"expr": "hello"}'# → {"expr": "hello", "error": "只允许数字和 + - * / ( ) 运算符"}
# 空表达式curl -X POST http://localhost:8000/api/calc \ -H "Content-Type: application/json" \ -d '{}'# → {"error": "表达式为空"}用 Python 命令行客户端:
venv/bin/python client.py "5+5"# ✅ 5+5 = 10
# 交互式模式venv/bin/python client.py# > 10/2# ✅ 10/2 = 5.0# > quit三、阶段二:添加前端页面 —— app2.py + simple.html
API 有了,现在给它加一个用户友好的网页界面,让用户用浏览器就能用。
3.1 架构思想:前后端分离
┌─────────────────┐ HTTP 请求 ┌─────────────────┐│ 浏览器 │ ─────────────▶│ Flask 服务器 ││ (前端) │ JSON 数据 │ (后端) ││ │ ◀──────────── │ ││ HTML + CSS │ │ app2.py ││ + JavaScript │ │ │└─────────────────┘ └─────────────────┘- 后端(
app2.py):提供数据和计算能力,只关心”算什么”和”返回什么” - 前端(
simple.html):关心用户交互和界面展示
前后端通过 JSON 格式 通信。
3.2 后端代码:app2.py(30 行)
from flask import Flask, request, jsonify, render_template
app = Flask(__name__)
@app.route("/")def page(): return render_template("simple.html")
@app.route("/api/calc", methods=["POST"])def calc(): data = request.get_json(force=True, silent=True) or {} expr = str(data.get("expr", "")).strip()
if not expr: return jsonify({"error": "请输入表达式"}), 400
allowed = set("0123456789+-*/(). ") if not all(c in allowed for c in expr): return jsonify({"error": "只能输入数字和 + - * / ( )"}), 400
try: result = str(eval(expr)) return jsonify({"expr": expr, "result": result}) except Exception as e: return jsonify({"error": str(e)}), 400
if __name__ == "__main__": app.run(host="0.0.0.0", port=8000, debug=True)3.3 与 app.py 的差异
相比 app.py,app2.py 有三处不同:
| 差异 | app.py | app2.py |
|---|---|---|
| ① 导入 | from flask import Flask, request, jsonify | from flask import Flask, request, jsonify, render_template |
| ② 页面路由 | 无 | @app.route("/") def page(): return render_template("simple.html") |
| ③ 安全函数 | 独立函数 safe_eval() | 检查逻辑嵌入 calc() 内 |
关键新增:render_template("simple.html")
@app.route("/")def page(): return render_template("simple.html")- 当用户访问网站根路径
/(即http://localhost:8000/)时,执行这个函数 render_template()会自动去templates/目录下找simple.html文件- 读取文件内容,返回给浏览器
⚠️ 注意:
render_template()查找文件的路径是固定的:必须放在名为templates/的文件夹中。这是 Flask 的约定。
3.4 前端代码:templates/simple.html(35 行)
<!DOCTYPE html><html><head><meta charset="UTF-8"><title>计算器</title></head><body>
<h1>Python 计算器</h1>
<input type="text" id="expr" placeholder="例如 1+1"><button onclick="calc()">计算</button>
<p id="result"></p>
<script>async function calc() { const expr = document.getElementById('expr').value.trim(); if (!expr) return;
const resp = await fetch('/api/calc', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expr: expr }) });
const data = await resp.json(); const el = document.getElementById('result');
if (data.error) { el.textContent = '错误:' + data.error; } else { el.textContent = data.expr + ' = ' + data.result; }}</script>
</body></html>3.5 HTML 部分详解
HTML(HyperText Markup Language,超文本标记语言)描述页面的结构。
<h1>Python 计算器</h1><!-- 标题 -->
<input type="text" id="expr" placeholder="例如 1+1"><!-- 文本输入框 type="text" → 这是文本输入框 id="expr" → 唯一标识,JavaScript 通过它找到这个元素 placeholder="例如 1+1" → 输入框为空时显示的灰色提示文字-->
<button onclick="calc()">计算</button><!-- 按钮 onclick="calc()" → 点击时执行名为 calc() 的 JavaScript 函数-->
<p id="result"></p><!-- 段落标签,用来显示结果 id="result" → JS 通过这个 id 找到它 当前为空,内容由 JavaScript 动态写入-->💡
id就像给元素贴的门牌号码,JavaScript 通过门牌号码精确定位到某个元素。
3.6 JavaScript 部分详解
JavaScript 负责页面的交互逻辑。
async function calc() {| 关键字 | 含义 |
|---|---|
function | 定义函数(类似 Python 的 def) |
calc | 函数名,对应 onclick="calc()" |
async | 表示函数内将有异步操作(如网络请求),只有 async 函数内才能用 await |
const expr = document.getElementById('expr').value.trim();从右到左拆解:
| 部分 | 作用 |
|---|---|
document.getElementById('expr') | 找到 id="expr" 的输入框 |
.value | 读取输入框当前的值 |
.trim() | 去掉两端空格(= Python 的 .strip()) |
const expr = ... | 保存到常量 expr |
if (!expr) return;| 部分 | 含义 |
|---|---|
!expr | 逻辑非。空字符串 "" → true;有内容 → false |
return; | 立即退出函数,不再执行后面的代码 |
完整含义:用户没输入任何东西(或只输入了空格)时,不发送请求。
const resp = await fetch('/api/calc', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expr: expr })});核心:向服务器发送 HTTP 请求
| 部分 | 作用 | Python 类比 |
|---|---|---|
fetch(url, options) | 浏览器发送 HTTP 请求的标准方式 | requests.post(url, json=...) |
'/api/calc' | 请求的 URL | 'http://localhost:8000/api/calc' |
{ method: 'POST', ... } | 配置对象(类似 Python 字典) | — |
method: 'POST' | 使用 POST 方法 | method='POST' |
headers: { 'Content-Type': 'application/json' } | 告诉服务器:我发的数据是 JSON 格式 | headers={'Content-Type': 'application/json'} |
body: JSON.stringify({ expr: expr }) | 请求体,把 JS 对象转成 JSON 字符串 | json.dumps({"expr": expr}) |
await | 等待服务器响应回来再继续执行 | await |
💡 为什么需要
await?发送网络请求就像寄快递:你把东西寄出去,不可能立刻收到回复。在等待期间,浏览器可以继续响应用户的其他操作。
await就是告诉 JavaScript:“在这里暂停一下,等对方回复后再继续执行后面的代码。”没有
await的话,代码会立即执行下一行,但此时响应还在路上,会拿到空值。
const data = await resp.json();把响应体(JSON 字符串)解析成 JavaScript 对象。例如:
输入: '{"expr":"1+1","result":"2"}' (字符串)输出: { expr: "1+1", result: "2" } (JS 对象)之后就可以用 data.expr、data.result、data.error 来取值了(类似 Python 的 data["expr"])。
const el = document.getElementById('result');找到 id="result" 的 <p> 元素,存到变量 el 里(el 是 element 的缩写,“元素”的意思)。
if (data.error) { el.textContent = '错误:' + data.error;} else { el.textContent = data.expr + ' = ' + data.result;}根据后端返回结果显示内容:
| 情况 | 后端返回 | 页面显示 |
|---|---|---|
| ✅ 成功 | {"expr":"1+1","result":"2"} | 1+1 = 2 |
| ❌ 错误 | {"error":"只能输入数字和..."} | 错误:只能输入数字和... |
| 代码 | 作用 |
|---|---|
if (data.error) | 如果返回数据中有 error 字段 |
el.textContent = ... | 修改元素里显示的文字 |
+ | 字符串拼接(和 Python 的 + 一样) |
🔒 安全要点:用
textContent而不是innerHTML
textContent:把内容当作纯文本处理,浏览器不会解析其中的 HTML 标签innerHTML:把内容当作 HTML 代码处理,其中的<script>等可能被执行如果用户输入
<script>alert('hello')</script>这样的恶意内容,用textContent会原样显示文字,用innerHTML会执行其中的脚本。所以显示用户输入的内容时,永远用textContent。
3.7 完整的前后端交互流程
用户在浏览器打开 http://localhost:8000 ↓ 第 1 次请求(GET /)Flask 执行 page() 函数 → 返回 simple.html ↓浏览器收到 HTML,渲染出页面(标题、输入框、按钮) ↓用户输入 "5+5",点击"计算" ↓浏览器触发 onclick="calc()",开始执行 JS 函数 ↓document.getElementById('expr').value → "5+5" ↓第 2 次请求(POST /api/calc)fetch('/api/calc', { method: 'POST', body: JSON.stringify({ expr: "5+5" })}) ↓Flask 收到请求,执行 calc() 函数 → safe_eval("5+5") → "10" ↓返回 JSON: {"expr": "5+5", "result": "10"} ↓JavaScript 解析 JSON,更新页面 → el.textContent = "5+5 = 10" ↓用户看到结果 ✅3.8 运行测试
启动带前端的服务器:
cd /Users/alan/code/python_webvenv/bin/python app2.py在浏览器中访问:
http://localhost:8000看到输入框 → 输入 1+1 → 点击”计算” → 显示 1+1 = 2 ✅
四、JavaScript ↔ Python 速查表
学习前端时,把 JS 语法和已知的 Python 做对照,可以快速建立直觉:
| JavaScript | Python | 含义 |
|---|---|---|
const x = ... | x = ... | 定义常量 |
let x = ... | x = ... | 定义可变变量 |
var x = ... | — | 旧版变量定义(不推荐) |
document.getElementById('x') | — | 通过 id 找 HTML 元素 |
.value | — | 读取输入框的值 |
.trim() | .strip() | 去两端空格 |
if (!x) | if not x: | 如果 x 为假 |
fetch(url, {...}) | requests.post(url, json=...) | 发送 HTTP 请求 |
await | await | 等待异步操作完成 |
JSON.stringify(obj) | json.dumps(dict) | 对象转 JSON 字符串 |
resp.json() | resp.json() | 响应体解析为对象 |
obj.key | dict["key"] | 取对象属性 / 字典值 |
obj['key'] | dict["key"] | 另一种取值方式 |
+ 拼接字符串 | + 拼接字符串 | 字符串拼接 |
el.textContent = ... | — | 设置元素显示的文字 |
el.innerHTML = ... | — | 设置元素的 HTML 内容(⚠️ 有安全风险) |
async function | async def | 异步函数定义 |
// 注释 | # 注释 | 单行注释 |
/* 多行 */ | """多行""" | 多行注释 |
{ key: value } | { "key": value } | 对象 / 字典字面量 |
[1, 2, 3] | [1, 2, 3] | 数组 / 列表字面量 |
五、核心注意事项
5.1 安全方面
❌ 永远不要相信用户输入
| 风险 | 原因 | 防范措施 |
|---|---|---|
eval() 执行任意代码 | eval("__import__('os').system('rm -rf /')") | 字符白名单检查 |
| XSS 跨站脚本攻击 | 用户输入 <script>...</script> | 前端用 textContent,后端 HTML 转义 |
| SQL 注入 | 拼接 SQL 字符串 | 使用参数化查询(本项目不涉及数据库) |
本项目的安全做法:
- 后端字符白名单:只允许
0-9 + - * / ( ) . 空格 - 前端
textContent:不把用户输入当 HTML 解析
5.2 Flask 约定
| 事项 | 约定 |
|---|---|
| 模板文件位置 | 必须放在 templates/ 文件夹中 |
| 静态文件位置 | 必须放在 static/ 文件夹中(CSS、图片等) |
render_template() | 会自动去 templates/ 下找文件 |
debug=True | 仅开发环境使用!生产环境必须关闭 |
5.3 HTTP 基础
| 概念 | 含义 |
|---|---|
| GET | 获取资源,参数在 URL 中 |
| POST | 提交数据,参数在请求体中 |
| 状态码 200 | 成功 |
| 状态码 400 | 客户端错误(请求数据不对) |
| 状态码 404 | 资源不存在(路径不对) |
| 状态码 500 | 服务器内部错误(代码崩溃) |
Content-Type: application/json | 请求/响应体是 JSON 格式 |
5.4 JSON 格式
前后端通信的数据格式,非常简单:
{ "expr": "1+1", "result": "2"}| JSON 类型 | Python 对应 | 示例 |
|---|---|---|
| 对象 | dict | {"key": "value"} |
| 数组 | list | [1, 2, 3] |
| 字符串 | str | "hello" |
| 数字 | int/float | 42, 3.14 |
| 布尔 | bool | true, false |
| null | None | null |
5.5 异步与 await
async function calc() { const resp = await fetch('/api/calc', {...}); // 这里在"等" const data = await resp.json(); // 这里也在"等"}规则:
async函数内才能使用awaitawait会暂停当前函数,等异步操作完成后继续执行- 等待期间,浏览器不会卡死,可以继续响应其他操作
六、扩展思路(可自行尝试)
掌握基础后,可以尝试以下改进,练习 Flask 和 JS:
- 添加样式:在 HTML 中加入 CSS,美化页面
- 回车键触发计算:用户按下回车就计算,不需要点击按钮
- 历史记录:每次计算结果都显示在页面上,形成历史列表
- 多个按钮:预设一些常用按钮(清除、1+1、π 等)
- 支持变量:让用户定义变量(如
x=5,然后x*3) - 添加更多接口:例如
/api/sum、/api/power,扩展 API
七、启动命令速查
cd /Users/alan/code/python_web
# 创建虚拟环境(首次)python3 -m venv venv
# 安装 Flask(首次)venv/bin/pip install flask
# 启动纯 API 版venv/bin/python app.py
# 启动带前端页面版venv/bin/python app2.py
# 命令行客户端venv/bin/python client.py
# curl 测试curl -X POST http://localhost:8000/api/calc \ -H "Content-Type: application/json" \ -d '{"expr": "1+1"}'八、学习路径总结
第 1 步 app.py 理解 Flask 路由、请求、JSON 响应第 2 步 client.py 理解如何用 Python 调用 API第 3 步 simple.html 理解 HTML 结构和 JS 事件第 4 步 app2.py + simple.html 前后端协作,完整流程第 5 步 自己动手改代码 在练习中巩固理解建议学习方法:先让代码跑起来 → 改一行、看效果 → 再改、再看 → 逐步理解每一行的作用。
Comments
Quiet notes for this article.