(十三)从数据渲染页面并保留文本含义
1. 模板提供结构,值进入指定位置
第五章读写的是整段文本;页面模板在固定 HTML 结构里保留占位位置,服务器拿到本次用户与书籍后再填入。
# 项目文件:web_project/src/webbook/render.py
# 模板只提供固定结构;进入HTML文本和属性的值先转义,JSON显示交给textContent。
from html import escape
from pathlib import Path
from string import Template
TEMPLATES = Path(__file__).resolve().parent / "templates"
def render_page(template_name, **values):
template = Template((TEMPLATES / template_name).read_text(encoding="utf-8"))
return template.substitute(values)
def hidden_csrf(session):
return f'<input type="hidden" name="csrf_token" value="{escape(session.csrf, quote=True)}">'
def render_login(session, message="", username=""):
return render_page(
"login.html",
csrf=hidden_csrf(session),
message=escape(message),
username=escape(username, quote=True),
)string.Template 识别 $名称 这样的占位符,substitute 用传入值替换。模板路径按模块位置取得,启动目录改变后仍能找到文件。
escape 处理进入 HTML 文本或带引号属性的值。hidden_csrf 生成隐藏控件,render_login 填入说明与账号,错误响应不把密码再填回页面。
2. 用户文本不能变成页面指令
# 项目文件:demos/escaping.py
# HTML文本先转义;JSON仍是数据,前端用textContent显示其中的书名。
import json
from html import escape
title = '<img src=x onerror="alert(1)">'
print("HTML文本", escape(title, quote=True))
payload = json.dumps({"title": title}, ensure_ascii=False)
print("JSON恢复原文", json.loads(payload)["title"] == title)书名为 <img src=x onerror="alert(1)"> 时,读者保存的是一段书名文本。如果原样拼入 HTML,浏览器可能把它解释为标签及事件;转义以后以 <img...> 进入 HTML,页面显示原书名,而不会创建对应图像节点。
转义按输出位置进行。这里处理 HTML 文本和双引号属性,数据库中的书名仍保存原文本;JSON 序列化后也能恢复原书名,前端再用 textContent 显示。不能因为“已转成 JSON”就把它拼进 innerHTML。HTML 转义工具
3. 当前用户与列表分别填入页面
# 续写同一文件:web_project/src/webbook/render.py
# 根据登录状态选择表单和操作按钮;把用户文本转义后再放入 HTML。
def render_home(session, books, message="", values=None):
values = values or {}
if session.user_id is None:
auth = '<a class="button" href="/login">登录书架</a>'
create = '<p class="empty">登录后添加记录,列表只展示当前账号的书籍。</p>'
else:
auth = (
f"<span>当前账号:{escape(session.username)}</span>"
f'<form method="post" action="/logout">{hidden_csrf(session)}<button class="quiet">退出登录</button></form>'
)
create = render_page(
"book_form.html",
csrf=hidden_csrf(session),
title=escape(str(values.get("title", "")), quote=True),
tags=escape(str(values.get("tags", "")), quote=True),
checked="checked" if values.get("finished") == "1" else "",
)
rows = []
for book in books:
tags = "".join(
f'<span class="tag">{escape(tag)}</span>' for tag in book["tags"]
)
action = (
'<span class="done">已读</span>'
if book["finished"]
else f'<button class="finish quiet" data-id="{book["id"]}">标记已读</button>'
)
rows.append(
f'<li data-book-id="{book["id"]}"><div><strong>{escape(book["title"])}</strong><div class="tags">{tags}</div></div>{action}</li>'
)
return render_page(
"home.html",
auth=auth,
create=create,
rows="".join(rows) or '<li class="empty">还没有记录</li>',
message=escape(message),
csrf=escape(session.csrf, quote=True),
count=str(len(books)),
finished=str(sum(book["finished"] for book in books)),
)服务器先生成账号区、表单区和书籍列表。代码里的固定 HTML 片段由程序提供,书名、标签、用户名和错误信息在进入对应位置前单独转义。
<!-- 项目文件:web_project/src/webbook/templates/home.html -->
<!-- 固定页面结构接收渲染后的片段;用户输入在render.py中按HTML位置转义。 -->
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="$csrf">
<title>阅读书架 · Web 基础</title>
<link rel="stylesheet" href="/assets/app.css">
<script src="/assets/app.js" defer></script>
</head>
<body>
<main>
<header><div><p class="eyebrow">PYTHON 3.12 / WEB 练习</p><h1>阅读书架</h1><p>保存一本书,再观察请求与记录的变化。</p></div><nav>$auth</nav></header>
<div class="metrics"><span>当前书籍 <strong>$count</strong></span><span>已读 <strong>$finished</strong></span><span>存储 <strong>SQLite</strong></span></div>
<p class="message" role="status">$message</p>
<div class="columns">
<section><h2>添加书籍</h2><p class="sub">表单提交后,服务器保存记录并跳转回书架。</p>$create</section>
<section><h2>我的书籍</h2><ul id="books">$rows</ul></section>
</div>
<section class="api"><h2>同一份记录的 JSON</h2><p class="sub">按钮请求 /api/books,页面脚本把响应作为文本显示。</p><button id="load-api" type="button">读取 API</button><span id="api-status" role="status"></span><pre id="api-output">点击按钮后显示响应。</pre></section>
</main>
</body>
</html>link 加载 app.css 负责字号、颜色与布局,script 加载 app.js 负责浏览器中的操作;defer 让脚本等页面结构解析完成后执行。meta 保存当前页面令牌,列表里的 data-id 保存按钮对应的书籍编号。
浏览器收到的是本次渲染后的 HTML;Python 的循环已经在服务器完成,不会把 Python 解释器带到页面里。