Skip to content

构建文章页面 ​

文章列表只返回 JSON 时,适合程序读取,却不足以展示一个可浏览的文章站点。本专题把页面结构放进模板,用 Python 提供数据,再让 CSS 等资源由静态文件服务提供。

模板负责生成 HTML,静态文件负责提供不随请求动态渲染的资源。先完成列表和详情页,再让页面正确加载样式。示例见下文。

从 HTML 字符串到模板 ​

HTMLResponse 可以返回很短的 HTML;页面结构复杂后,用 Jinja2Templates 和 TemplateResponse 更易维护。

先看“从字符串到模板”的过渡。

HTML 字符串写法 ​

短页面的 Python 写法:

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 模板写法 ​

模板首页的关键代码:

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)

templates/index.html:

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>

现在流程变成:

text
Python 代码准备 title
    ↓
TemplateResponse 找到 index.html
    ↓
把 title 填进模板
    ↓
生成最终 HTML 返回浏览器

HTML 字符串的局限 ​

因为页面一复杂,字符串会越来越长,Python 代码和 HTML 结构会缠在一起,后期很难维护。

HTML 字符串与 Jinja2 模板渲染对比

向文章列表模板传数据 ​

处理函数准备文章数据,模板通过上下文使用变量与循环。页面只负责展示,不在模板中做新的数据查询。

接下来把文章列表传给模板。

文章列表的关键代码:

python
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:

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>

这里的重点是:

python
{"articles": ARTICLES}

它把 Python 列表传给模板。

模板里再通过:

html
{% for article in articles %}

逐条渲染。

模板上下文 ​

可以先记住:

text
Python 用字典传数据
模板用变量和循环取数据

模板上下文传递与循环渲染流程

渲染文章详情页 ​

详情页从路径参数取得编号,找不到文章时给出 404,再把文章对象传给模板。

现在再做一个文章详情页。

文章详情的关键代码:

python
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:

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>

这里把三部分连了起来:

text
路由里的路径参数
↓
endpoint 中查找文章
↓
把文章对象传给模板
↓
模板渲染详情页

请求与模板的协作 ​

这里可区分为:

text
路由决定访问哪篇文章
request 读出 article_id
Python 找到对应文章
模板负责显示文章

详情页路径参数解析与模板渲染流转

加载 CSS 静态资源 ​

用 StaticFiles 挂载资源目录。模板引用 CSS 的路径必须与挂载路径一致。

现在让页面加载 CSS。

挂载 static 目录 ​

python
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:

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:

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;
}

现在流程变成:

text
浏览器访问页面
↓
服务端返回 HTML
↓
浏览器继续请求 /static/style.css
↓
StaticFiles 从 static/ 目录找到文件
↓
浏览器加载样式

模板与静态文件的分工 ​

对应关系是:

text
模板负责页面结构和动态数据
静态文件负责样式、图片、脚本等资源

静态资源挂载与浏览器双阶段请求加载机制

文章列表与详情站点 ​

把前面的步骤合在一起后,应用提供首页、文章列表、文章详情、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 处理。

前面已分别验证模板变量、循环、详情页和静态文件。下面对照完整源码,把这些文件连成一条可访问的路径。

文章系统全景架构

案例源码 ​

模板基础案例 ​

py
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)
py
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()

模板基础案例的页面 ​

html
<!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>

文章站点案例 ​

py
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)
py
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()

文章站点样式 ​

css
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;
}

文章站点模板 ​

html
<!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>
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>
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>