构建文章页面
文章列表只返回 JSON 时,适合程序读取,却不足以展示一个可浏览的文章站点。本专题把页面结构放进模板,用 Python 提供数据,再让 CSS 等资源由静态文件服务提供。
模板负责生成 HTML,静态文件负责提供不随请求动态渲染的资源。先完成列表和详情页,再让页面正确加载样式。示例见下文。
从 HTML 字符串到模板
HTMLResponse 可以返回很短的 HTML;页面结构复杂后,用 Jinja2Templates 和 TemplateResponse 更易维护。
先看“从字符串到模板”的过渡。
HTML 字符串写法
短页面的 Python 写法:
from starlette.applications import Starlette
from starlette.responses import HTMLResponse
from starlette.routing import Route
async def homepage(request):
html = """
<html>
<head>
<title>文章首页</title>
</head>
<body>
<h1>文章首页</h1>
<p>欢迎学习 Starlette 模板。</p>
</body>
</html>
"""
return HTMLResponse(html)
routes = [
Route("/", homepage),
]
app = Starlette(routes=routes)Jinja2 模板写法
模板首页的关键代码:
from starlette.applications import Starlette
from starlette.routing import Route
from starlette.templating import Jinja2Templates
templates = Jinja2Templates(directory="templates")
async def homepage(request):
return templates.TemplateResponse(
request,
"index.html",
{"title": "文章首页"},
)
routes = [
Route("/", homepage),
]
app = Starlette(routes=routes)templates/index.html:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>{{ title }}</title>
</head>
<body>
<h1>{{ title }}</h1>
<p>欢迎学习 Starlette 模板。</p>
</body>
</html>现在流程变成:
Python 代码准备 title
↓
TemplateResponse 找到 index.html
↓
把 title 填进模板
↓
生成最终 HTML 返回浏览器HTML 字符串的局限
因为页面一复杂,字符串会越来越长,Python 代码和 HTML 结构会缠在一起,后期很难维护。
向文章列表模板传数据
处理函数准备文章数据,模板通过上下文使用变量与循环。页面只负责展示,不在模板中做新的数据查询。
接下来把文章列表传给模板。
文章列表的关键代码:
from starlette.applications import Starlette
from starlette.routing import Route
from starlette.templating import Jinja2Templates
ARTICLES = [
{"id": 1, "title": "认识 Starlette 应用对象"},
{"id": 2, "title": "学习 Request 请求对象"},
{"id": 3, "title": "学习 Response 响应对象"},
]
templates = Jinja2Templates(directory="templates")
async def article_list(request):
return templates.TemplateResponse(
request,
"article_list.html",
{"articles": ARTICLES},
)
routes = [
Route("/articles", article_list),
]
app = Starlette(routes=routes)templates/article_list.html:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>文章列表</title>
</head>
<body>
<h1>文章列表</h1>
<ul>
{% for article in articles %}
<li>{{ article.title }}</li>
{% endfor %}
</ul>
</body>
</html>这里的重点是:
{"articles": ARTICLES}它把 Python 列表传给模板。
模板里再通过:
{% for article in articles %}逐条渲染。
模板上下文
可以先记住:
Python 用字典传数据
模板用变量和循环取数据渲染文章详情页
详情页从路径参数取得编号,找不到文章时给出 404,再把文章对象传给模板。
现在再做一个文章详情页。
文章详情的关键代码:
from starlette.applications import Starlette
from starlette.exceptions import HTTPException
from starlette.routing import Route
from starlette.templating import Jinja2Templates
ARTICLES = [
{"id": 1, "title": "认识 Starlette 应用对象", "content": "这是第一篇文章。"},
{"id": 2, "title": "学习 Request 请求对象", "content": "这是第二篇文章。"},
]
templates = Jinja2Templates(directory="templates")
async def article_detail(request):
article_id = request.path_params["article_id"]
for article in ARTICLES:
if article["id"] == article_id:
return templates.TemplateResponse(
request,
"article_detail.html",
{"article": article},
)
raise HTTPException(status_code=404, detail="文章不存在")
routes = [
Route("/articles/{article_id:int}", article_detail),
]
app = Starlette(routes=routes)templates/article_detail.html:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>{{ article.title }}</title>
</head>
<body>
<h1>{{ article.title }}</h1>
<p>{{ article.content }}</p>
</body>
</html>这里把三部分连了起来:
路由里的路径参数
↓
endpoint 中查找文章
↓
把文章对象传给模板
↓
模板渲染详情页请求与模板的协作
这里可区分为:
路由决定访问哪篇文章
request 读出 article_id
Python 找到对应文章
模板负责显示文章加载 CSS 静态资源
用 StaticFiles 挂载资源目录。模板引用 CSS 的路径必须与挂载路径一致。
现在让页面加载 CSS。
挂载 static 目录
from starlette.applications import Starlette
from starlette.routing import Mount, Route
from starlette.staticfiles import StaticFiles
from starlette.templating import Jinja2Templates
templates = Jinja2Templates(directory="templates")
async def homepage(request):
return templates.TemplateResponse(
request,
"index.html",
{"title": "文章首页"},
)
routes = [
Route("/", homepage),
Mount("/static", app=StaticFiles(directory="static"), name="static"),
]
app = Starlette(routes=routes)引用 CSS
templates/index.html:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>{{ title }}</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="container">
<h1>{{ title }}</h1>
<p>这是一个带样式的页面。</p>
</div>
</body>
</html>CSS 样式文件
static/style.css:
body {
margin: 0;
font-family: Arial, "Microsoft YaHei", sans-serif;
background: #f5f5f5;
color: #333;
}
.container {
width: 800px;
margin: 40px auto;
background: #fff;
padding: 24px;
border-radius: 8px;
}现在流程变成:
浏览器访问页面
↓
服务端返回 HTML
↓
浏览器继续请求 /static/style.css
↓
StaticFiles 从 static/ 目录找到文件
↓
浏览器加载样式模板与静态文件的分工
对应关系是:
模板负责页面结构和动态数据
静态文件负责样式、图片、脚本等资源文章列表与详情站点
把前面的步骤合在一起后,应用提供首页、文章列表、文章详情、JSON 列表和 CSS 文件。完整示例包含可运行的路由、模板文件和样式文件。这些文件一起构成一个案例,复制单个处理函数不能得到同样的页面效果。
在 _code_starlette/p02c04/case02_article_site 中运行 uvicorn article_site:app --reload。依次访问 /、/articles、/articles/1 和 /api/articles:前三个地址返回 HTML,最后一个返回 JSON。访问 /static/style.css 应得到 CSS;访问不存在的文章应得到 404。也可以运行下方的演示脚本查看状态码和响应体。
页面请求的关键路径是:处理函数准备文章数据,TemplateResponse 把数据交给模板生成 HTML;页面中的样式地址随后由 StaticFiles 提供。模板组织显示内容,静态目录保存 CSS、图片或脚本,业务数据仍由 Python 处理。
前面已分别验证模板变量、循环、详情页和静态文件。下面对照完整源码,把这些文件连成一条可访问的路径。
案例源码
模板基础案例
from starlette.applications import Starlette
from starlette.routing import Route
from starlette.templating import Jinja2Templates
templates = Jinja2Templates(directory="templates")
async def homepage(request):
return templates.TemplateResponse(
request,
"index.html",
{"title": "文章首页"},
)
routes = [
Route("/", homepage),
]
app = Starlette(routes=routes)
if __name__ == "__main__":
import uvicorn
uvicorn.run("template_homepage:app", host="127.0.0.1", port=8000, reload=True)import requests
def test_homepage():
response = requests.get("http://127.0.0.1:8000/")
print("\n=== GET / ===")
print(f"status_code: {response.status_code}")
print(response.text)
if __name__ == "__main__":
test_homepage()模板基础案例的页面
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>{{ title }}</title>
</head>
<body>
<h1>{{ title }}</h1>
<p>欢迎查看文章列表。</p>
<p><a href="/articles">进入文章列表</a></p>
</body>
</html>文章站点案例
from starlette.applications import Starlette
from starlette.exceptions import HTTPException
from starlette.responses import JSONResponse
from starlette.routing import Mount, Route
from starlette.staticfiles import StaticFiles
from starlette.templating import Jinja2Templates
templates = Jinja2Templates(directory="templates")
ARTICLES = [
{
"id": 1,
"title": "认识 Starlette 应用对象",
"summary": "学习如何创建一个最小 Starlette 应用。",
"content": "Starlette 应用对象是整个 Web 程序的入口。通过 routes 参数,我们可以把路由表交给应用。",
},
{
"id": 2,
"title": "学习 Request 请求对象",
"summary": "学习如何读取客户端传来的请求数据。",
"content": "Request 对象封装了本次请求的信息,例如路径参数、查询参数、请求头、Cookie 和请求体。",
},
{
"id": 3,
"title": "学习 Response 响应对象",
"summary": "学习如何根据不同场景返回不同类型的响应。",
"content": "Response 对象负责把服务端处理结果返回给客户端。文本、JSON、HTML、文件和流式内容都可以通过不同响应对象返回。",
},
{
"id": 4,
"title": "学习 Starlette 路由系统",
"summary": "学习如何用 Route 组织路径、请求方法和 endpoint。",
"content": "路由系统负责把不同 URL 分发给不同处理函数。路径参数、请求方法和路由命名都是路由系统中的重要内容。",
},
]
def find_article(article_id):
for article in ARTICLES:
if article["id"] == article_id:
return article
return None
async def http_exception_handler(request, exc):
return JSONResponse(
{"code": exc.status_code, "message": exc.detail},
status_code=exc.status_code,
)
async def homepage(request):
return templates.TemplateResponse(
request,
"index.html",
{"title": "Starlette 文章小站"},
)
async def article_list(request):
return templates.TemplateResponse(
request,
"article_list.html",
{"articles": ARTICLES},
)
async def article_detail(request):
article_id = request.path_params["article_id"]
article = find_article(article_id)
if article is None:
raise HTTPException(status_code=404, detail="文章不存在")
return templates.TemplateResponse(
request,
"article_detail.html",
{"article": article},
)
async def api_articles(request):
return JSONResponse({"items": ARTICLES})
routes = [
Route("/", homepage),
Route("/articles", article_list),
Route("/articles/{article_id:int}", article_detail),
Route("/api/articles", api_articles),
Mount("/static", app=StaticFiles(directory="static"), name="static"),
]
exception_handlers = {
HTTPException: http_exception_handler,
}
app = Starlette(
routes=routes,
exception_handlers=exception_handlers,
)
if __name__ == "__main__":
import uvicorn
uvicorn.run("article_site:app", host="127.0.0.1", port=8000, reload=True)import requests
def test_homepage():
response = requests.get("http://127.0.0.1:8000/")
print("\n=== GET / ===")
print(f"status_code: {response.status_code}")
print(response.text)
def test_article_list_page():
response = requests.get("http://127.0.0.1:8000/articles")
print("\n=== GET /articles ===")
print(f"status_code: {response.status_code}")
print(response.text)
def test_article_detail_page():
response = requests.get("http://127.0.0.1:8000/articles/1")
print("\n=== GET /articles/1 ===")
print(f"status_code: {response.status_code}")
print(response.text)
def test_api_articles():
response = requests.get("http://127.0.0.1:8000/api/articles")
print("\n=== GET /api/articles ===")
print(f"status_code: {response.status_code}")
print(response.json())
def test_static_css():
response = requests.get("http://127.0.0.1:8000/static/style.css")
print("\n=== GET /static/style.css ===")
print(f"status_code: {response.status_code}")
print(response.text)
def test_article_not_found():
response = requests.get("http://127.0.0.1:8000/articles/999")
print("\n=== GET /articles/999 ===")
print(f"status_code: {response.status_code}")
print(response.json())
if __name__ == "__main__":
test_homepage()
test_article_list_page()
test_article_detail_page()
test_api_articles()
test_static_css()
test_article_not_found()文章站点样式
body {
margin: 0;
font-family: Arial, "Microsoft YaHei", sans-serif;
background: #f5f5f5;
color: #333;
}
.container {
width: 900px;
margin: 40px auto;
background: #ffffff;
padding: 24px;
border-radius: 8px;
}
h1 {
margin-top: 0;
}
a {
color: #2563eb;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.article-list {
list-style: none;
padding: 0;
}
.article-card {
padding: 16px;
border: 1px solid #e5e7eb;
border-radius: 6px;
margin-bottom: 12px;
background: #fafafa;
}
.article-card h2 {
margin-top: 0;
font-size: 20px;
}
.article-summary {
color: #666;
}
.article-content {
line-height: 1.8;
margin-top: 16px;
}文章站点模板
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>{{ article.title }}</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="container">
<article class="article-card">
<h1>{{ article.title }}</h1>
<p class="article-summary">{{ article.summary }}</p>
<div class="article-content">{{ article.content }}</div>
</article>
<p>
<a href="/articles">返回文章列表</a>
|
<a href="/">返回首页</a>
</p>
</div>
</body>
</html><!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>文章列表</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="container">
<h1>文章列表</h1>
{% if articles %}
<ul class="article-list">
{% for article in articles %}
<li class="article-card">
<h2><a href="/articles/{{ article.id }}">{{ article.title }}</a></h2>
<p class="article-summary">{{ article.summary }}</p>
</li>
{% endfor %}
</ul>
{% else %}
<p>暂无文章。</p>
{% endif %}
<p><a href="/">返回首页</a></p>
</div>
</body>
</html><!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>{{ title }}</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="container">
<h1>{{ title }}</h1>
<p>欢迎来到 Starlette 文章小站。</p>
<p>本页面使用模板渲染,样式来自静态 CSS 文件。</p>
<p><a href="/articles">查看文章列表</a></p>
</div>
</body>
</html>