前言
很多人第一次听到「用 Python 建网站」,脑子里立刻浮现出装一堆东西、配一整套环境的画面。其实从「浏览器里能看到一个页面」到「一个能点、能跳转的站」,中间的路是一段一段走出来的:标准库能带你走完第一步,真正让页面变成「站」的,是后面加上的路由、模板和静态文件组织方式。
另一个常见误解,是把「建网站」和「写 API」当成同一件事。API 面向的是程序调用方,返回的通常是 JSON;网站面向的是人,最终要吐出一整页 HTML 交给浏览器渲染。本文讲的是后者——一个有人能看的网页站点。API 的设计思路属于另一类话题,本文不展开。
本文按三步走:先用标准库http.server把页面跑起来,理解 HTTP 请求与响应这条最底层链路;再引出 WSGI 这个「服务器」与「应用」之间的接口约定,解释为什么框架可以换来换去;最后用一个轻量框架把路由、模板、静态文件三件套补齐,并讲清「服务端渲染」到底指什么。
文中涉及第三方框架的部分都需要自行安装,安装命令、版本号与具体参数一律以对应框架的官方文档为准,本文只讲结构与思路。
一、第一步:用 http.server 让浏览器看到第一行字
HTTP 协议的本质是一问一答:浏览器发来一段文本(请求),服务器回一段文本(响应)。响应由三部分组成——状态行、若干响应头、以及一个空行之后的响应体。状态行里那个200表示成功,404表示资源不存在。
标准库里的http.server模块允许我们直接写一个处理类,把响应的这三部分自己拼出来。
# 适用于 Python 3.7+
from http.server import BaseHTTPRequestHandler, HTTPServer
class HelloHandler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/":
body = "<h1>你好,Python 网站</h1>".encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
else:
self.send_error(404, "页面不存在")
if __name__ == "__main__":
server = HTTPServer(("127.0.0.1", 8000), HelloHandler)
print("请在浏览器打开 http://127.0.0.1:8000/")
server.serve_forever()几个关键点:send_response(200)会先写入状态行,并把Server与Date两个响应头加进缓冲区;后面两次send_header追加自定义头;end_headers()负责写入那个表示头部结束的空行——在 3.3 之后,头部是缓冲的,不调用end_headers()响应发不出去;最后往self.wfile写响应体。
Content-Length是响应体的字节数,不是字符数。中文一个字符占 3 个字节,所以必须先encode("utf-8")再取长度,否则浏览器会等一段永远等不到的数据。
这段代码已经是一个「能访问的页面」,但它还不是「网站」:每加一个网址,就要多写一个if;页面内容硬编码在 Python 字符串里;图片、CSS 文件没地方放。
二、从「一个脚本」到「一个站」:WSGI 接口约定
如果每个网站都自己实现一套「读请求 → 拼响应」的逻辑,那么服务器(负责监听端口、解析 HTTP)和框架(负责业务逻辑)就绑死了。WSGI(Web Server Gateway Interface,Web 服务器网关接口)解决的正是这个耦合问题。
WSGI 规定了一个极其简洁的调用约定:应用是一个可调用对象,接收两个参数并返回一个可迭代对象。
- 第一个参数是
environ,一个字典,装着这次请求的全部信息,比如REQUEST_METHOD(请求方法)、PATH_INFO(路径)、QUERY_STRING(查询串)。 - 第二个参数是
start_response,应用用它来声明状态行和响应头。 - 返回值是可迭代对象,每个元素是一段响应体的字节串。
标准库的wsgiref提供了这个接口的参考实现。下面这段代码和上一节功能相同,但遵循了 WSGI 约定。
# 适用于 Python 3.7+
from wsgiref.simple_server import make_server
def app(environ, start_response):
path = environ["PATH_INFO"]
if path == "/":
status = "200 OK"
body = "<h1>你好,Python 网站</h1>".encode("utf-8")
else:
status = "404 Not Found"
body = "<h1>页面不存在</h1>".encode("utf-8")
headers = [
("Content-Type", "text/html; charset=utf-8"),
("Content-Length", str(len(body))),
]
start_response(status, headers)
return [body]
if __name__ == "__main__":
with make_server("127.0.0.1", 8000, app) as httpd:
httpd.serve_forever()好处是:只要遵守这个约定,任何 WSGI 服务器都能跑你的应用,任何 WSGI 应用也能跑在任何服务器上。生产环境中常见的 Gunicorn、uWSGI 这类服务器,都吃这一套接口。框架负责把业务代码包装成 WSGI 应用,服务器负责把 HTTP 翻译成environ。
不过手写if path == ...分派还是太笨。真正的框架把这部分抽象成「路由」——你把「某个路径 + 某个方法」映射到一个函数上。
三、用轻量框架补齐三件套:路由、模板、静态文件
一个网站最小可用,需要三样东西:把路径映射到处理函数(路由)、把数据和网页骨架拼起来(模板)、把 CSS/图片/JS 这类不变的资源单独提供给浏览器(静态文件)。主流轻量框架都自带这三样。下面以 Flask 为例,安装命令与接口细节请以其官方文档为准。
# 安装方式以该框架官方文档为准
pip install flask# 适用于 Python 3.8+;需先安装 Flask
from flask import Flask, render_template
app = Flask(__name__)
POSTS = [
{"title": "第一篇", "body": "网站跑起来了。"},
{"title": "第二篇", "body": "模板也接上了。"},
]
@app.get("/")
def index():
return render_template("index.html", posts=POSTS)
@app.get("/post/<int:pid>")
def post_detail(pid):
if pid < 0 or pid >= len(POSTS):
return "没有这篇文章", 404
return render_template("post.html", post=POSTS[pid])
if __name__ == "__main__":
app.run(debug=True)@app.get(...)是 Flask 2.0 之后的写法,等价于更早版本的@app.route("/", methods=["GET"])。路径里用尖括号声明的片段是路径参数(上面的代码里把文章编号声明成了整数),框架会自动把 URL 里对应那一段转成整数再传进函数,不用自己切字符串。
模板是一个带占位符的 HTML 文件。框架会读它、把数据填进去、生成最终 HTML:
<!-- templates/index.html -->
<!DOCTYPE html>
<html lang="zh">
<head><meta charset="utf-8"><title>我的网站</title></head>
<body>
<h1>文章列表</h1>
<ul>
{% for post in posts %}
<li><a href="/post/{{ loop.index0 }}">{{ post.title }}</a></li>
{% endfor %}
</ul>
</body>
</html>静态文件放在框架约定的目录(Flask 默认是static/),通过固定的 URL 前缀访问:
<link rel="stylesheet" href="/static/style.css">框架会把/static/style.css直接映射到磁盘上的static/style.css,不经过你的视图函数。这样做的好处是:模板只负责结构,CSS 只负责样式,同一个样式文件被浏览器缓存一次,全站的页面都受益。
四、服务端渲染到底是什么
上面这套流程有个名字,叫服务端渲染(SSR,Server-Side Rendering)。它的意思很直白:HTML 是在服务器上完整拼好之后,才发给浏览器的。
以访问首页为例,服务器端的动作顺序是:收到 GET 请求 → 路由匹配到index函数 → 读取POSTS数据 → 用模板把数据渲染成一段完整 HTML → 把这段 HTML 作为响应体发出。浏览器收到的已经是一张排好版的页面,它不需要再执行多少脚本就能显示内容。
| 对比项 | 服务端渲染(SSR) | 客户端渲染(CSR) |
|---|
| HTML 由谁生成 | 服务器上的模板引擎 | 浏览器里的 JavaScript |
| 首屏可见速度 | 通常更快,响应即内容 | 需等脚本下载并执行完 |
| 搜索引擎抓取 | 直接看到完整内容 | 依赖爬虫执行脚本的能力 |
| 服务器压力 | 每次请求都要渲染,压力大 | 静态资源可走缓存,压力小 |
| 典型场景 | 内容型站点、后台管理页 | 交互复杂的单页应用 |
需要注意的是,SSR 并不等于「不能用 JavaScript」。它只描述首屏 HTML 从哪来。页面加载完之后,你依然可以在浏览器里用脚本做局部更新——两种渲染方式经常混着用。
常见坑点
1. 把http.server当生产服务器
❌ 直接拿标准库的那个服务器类对外提供正式服务。 ✅ 官方文档对此有明确警告:不推荐用于生产环境,因为它只实现了最基本的安全检查。它只适合测试和临时用途,要对外就用成熟的 WSGI 生产服务器。
2. 把路由写成一路if
❌ 每加一个页面就复制一份处理函数、再补一个if path == ...分支。 ✅ 用框架的路由装饰器,把「路径 + 方法」声明式地绑到函数上,路径参数由框架解析。
3. 模板里直接写业务计算
❌ 在{% for %}里做数据库查询、算复杂统计。 ✅ 模板只管展示;数据和计算在视图函数里准备好,再作为参数传进去。
4. 静态文件的 URL 和磁盘路径混淆
❌ 以为浏览器访问/static/style.css会命中项目根目录下的static/style.css,于是在别处又放了一份。 ✅ 以框架约定的目录为准(常见约定是static/),URL 前缀是框架映射出来的虚拟路径,照约定放文件就行。
5. 中文页面不声明编码
❌ 返回中文 HTML 却不写Content-Type,浏览器按本地编码猜,出现乱码。 ✅ 响应头写text/html; charset=utf-8,HTML 里的meta charset也写一遍,两者保持一致。
6. 以为服务端渲染就不能用 JavaScript
❌ 担心「选了 SSR 就再也不能做局部更新」,于是把交互逻辑全塞到服务器。 ✅ 服务端渲染只描述首屏 HTML 从哪里来;页面加载完之后照样可以在浏览器里做局部更新,两种方式经常混用。
总结
| 阶段 | 用什么 | 你得到什么 | 缺什么 |
|---|
| 起步 | http.server | 一个能响应的地址 | 路由、模板、静态文件都要手写 |
| 解耦 | wsgiref+ WSGI 约定 | 服务器与应用解耦 | 业务分派仍是手写的if |
| 成型 | 轻量框架 | 路由、模板、静态文件三件套 | 需要按官方文档安装与配置 |
从「一个脚本」到「一个站」,本质上是把重复劳动一层层收进约定里:HTTP 的细节收进服务器,分派逻辑收进路由,页面拼接收进模板。理解 WSGI 这层接口,你就明白了为什么 Python 的 Web 框架能百花齐放却又能共用同一批服务器;理解服务端渲染,你就明白了「网站」和「API」在交付物上的根本差别。