模板引擎与前端集成

What — 是什么

模板引擎将 Python 数据与 HTML 模板结合,动态生成网页内容。Jinja2 是 Python 生态最流行的模板引擎,也是 Flask/Django 的默认模板后端。前端集成则涉及服务端渲染(SSR)与客户端渲染(CSR)的选择、API 驱动的前后端分离架构。

核心概念:

  • Jinja2:模板语言,支持变量插值、控制流、继承、宏、过滤器
  • 模板继承{% extends "base.html" %} + {% block content %},避免重复
  • SSR vs CSR:服务端渲染(Python 生成 HTML)vs 客户端渲染(JS 框架生成)
  • 前后端分离:后端只提供 API,前端用 React/Vue 独立开发

关键特性:

  • Jinja2 自动转义防 XSS({{ var }} 自动 HTML 转义)
  • 模板继承实现布局复用,block 定义可覆盖区域
  • 自定义过滤器和全局函数扩展模板能力
  • include 引入子模板片段,macro 定义可复用组件
  • 支持异步模板渲染(enable_async=True

Jinja2 核心语法:

{# 变量输出(自动转义) #}
{{ user.name }}

{# 控制流 #}
{% if user.is_admin %}
  <span>管理员</span>
{% elif user.is_staff %}
  <span>员工</span>
{% else %}
  <span>普通用户</span>
{% endif %}

{# 循环 #}
{% for item in items %}
  <li>{{ loop.index }}: {{ item.name }}</li>
{% endfor %}

{# 过滤器 #}
{{ content|truncate(100) }}
{{ price|round(2) }}
{{ date|datetimeformat('%Y-%m-%d') }}

{# 继承 #}
{% extends "base.html" %}
{% block title %}首页{% endblock %}

Why — 为什么

适用场景:

  • Django/Flask 传统 Web 应用(SSR)
  • 邮件模板、PDF 报表生成
  • 配置文件生成(Nginx/K8s 配置)
  • 前后端分离项目的前端部署

渲染模式对比:

维度SSR(服务端渲染)CSR(客户端渲染)同构渲染
首屏速度快(直出 HTML)慢(需加载 JS)
SEO友好不友好友好
交互体验传统(全页刷新)流畅(局部更新)流畅
服务器负载
技术栈Jinja2/DjangoReact/VueNext.js/Nuxt
适合场景内容站/管理后台SPA 应用全场景

优缺点:

  • ✅ 优点:
    • SSR 首屏快、SEO 好、无需 JS 框架
    • Jinja2 语法简洁,学习成本低
    • 模板继承减少重复代码
    • Django/Flask 原生集成
  • ❌ 缺点:
    • SSR 交互体验差(全页刷新)
    • 复杂 UI 难以用模板实现
    • 前后端耦合,前端无法独立开发
    • Jinja2 模板不能在浏览器运行

How — 怎么用

快速上手:Jinja2 模板继承

<!-- templates/base.html -->
<!DOCTYPE html>
<html lang="zh">
<head>
    <meta charset="UTF-8">
    <title>{% block title %}MyApp{% endblock %}</title>
    {% block head %}{% endblock %}
</head>
<body>
    <nav>
        <a href="/">首页</a>
        <a href="/blog">博客</a>
        {% if current_user.is_authenticated %}
        <a href="/logout">退出</a>
        {% else %}
        <a href="/login">登录</a>
        {% endif %}
    </nav>

    <main>
        {% with messages = get_flashed_messages(with_categories=true) %}
        {% for category, message in messages %}
        <div class="alert alert-{{ category }}">{{ message }}</div>
        {% endfor %}
        {% endwith %}

        {% block content %}{% endblock %}
    </main>

    <footer>{% block footer %}&copy; 2026{% endblock %}</footer>
</body>
</html>

<!-- templates/blog/post_list.html -->
{% extends "base.html" %}
{% block title %}博客 - {{ super() }}{% endblock %}

{% block content %}
<h1>最新文章</h1>
{% for post in posts %}
<article>
    <h2><a href="{{ url_for('blog.detail', slug=post.slug) }}">{{ post.title }}</a></h2>
    <p>{{ post.content|truncate(200) }}</p>
    <span class="meta">{{ post.created_at|datetimeformat }}</span>
</article>
{% else %}
<p>暂无文章</p>
{% endfor %}
{% endblock %}

代码示例1:宏、自定义过滤器和全局函数

# Flask 集成自定义过滤器
from flask import Flask

app = Flask(__name__)

@app.template_filter('datetimeformat')
def datetimeformat(value, format='%Y-%m-%d %H:%M'):
    from datetime import datetime
    if isinstance(value, str):
        value = datetime.fromisoformat(value)
    return value.strftime(format)

@app.template_global()
def format_currency(amount, currency='CNY'):
    symbols = {'CNY': '¥', 'USD': '$', 'EUR': '€'}
    return f"{symbols.get(currency, '')}{amount:,.2f}"

@app.context_processor
def inject_utils():
    return {
        'now': datetime.now,
        'app_name': 'MyBlog',
    }
<!-- templates/macros.html -->
{% macro pagination(pagination, endpoint) %}
<nav class="pagination">
    {% if pagination.has_prev %}
    <a href="{{ url_for(endpoint, page=pagination.prev_num) }}">&laquo; 上一页</a>
    {% endif %}

    {% for page in pagination.iter_pages() %}
        {% if page %}
            {% if page == pagination.page %}
            <span class="active">{{ page }}</span>
            {% else %}
            <a href="{{ url_for(endpoint, page=page) }}">{{ page }}</a>
            {% endif %}
        {% else %}
        <span class="ellipsis">...</span>
        {% endif %}
    {% endfor %}

    {% if pagination.has_next %}
    <a href="{{ url_for(endpoint, page=pagination.next_num) }}">下一页 &raquo;</a>
    {% endif %}
</nav>
{% endmacro %}

{% macro card(title, content, image=None) %}
<div class="card">
    {% if image %}
    <img src="{{ image }}" alt="{{ title }}">
    {% endif %}
    <h3>{{ title }}</h3>
    <p>{{ content|truncate(150) }}</p>
</div>
{% endmacro %}

<!-- 使用宏 -->
{% from "macros.html" import pagination, card %}

{% for post in posts %}
{{ card(post.title, post.content, post.cover_image) }}
{% endfor %}

{{ pagination(posts_pagination, 'blog.list') }}

代码示例2:前后端分离架构集成

# 方案1:Django + DRF API + Vue/React 前端
# 后端只提供 API
# frontend/ 目录放前端项目,构建后收集到 Django static

# settings.py
STATICFILES_DIRS = [
    BASE_DIR / "frontend" / "dist",
]

# 方案2:Django 模板 + Vue/React 组件(渐进增强)
# 模板中挂载 Vue/React 组件

# templates/dashboard.html
{% extends "base.html" %}
{% block content %}
<div id="app"></div>
<script type="module" src="{{ url_for('static', filename='js/dashboard.js') }}"></script>
{% endblock %}

# 方案3:Django 模板 + HTMX(轻量级交互增强)
# 无需前端框架,用 HTML 属性声明式交互

# templates/partials/task_list.html
{% for task in tasks %}
<div class="task" hx-target="#task-{{ task.id }}" hx-swap="outerHTML">
    <span>{{ task.title }}</span>
    <button hx-post="/tasks/{{ task.id }}/toggle"
            hx-target="#task-{{ task.id }}"
            hx-swap="outerHTML">
        {% if task.done %}✅{% else %}⬜{% endif %}
    </button>
</div>
{% endfor %}

# Django view 返回局部 HTML
from django.template.loader import render_to_string
from django.http import HttpResponse

def toggle_task(request, task_id):
    task = Task.objects.get(id=task_id)
    task.done = not task.done
    task.save()
    html = render_to_string('partials/task_item.html', {'task': task})
    return HttpResponse(html)

代码示例3:Jinja2 独立使用(非 Web 场景)

from jinja2 import Environment, FileSystemLoader, select_autoescape

# 独立使用 Jinja2
env = Environment(
    loader=FileSystemLoader('templates'),
    autoescape=select_autoescape(['html', 'xml']),
    trim_blocks=True,
    lstrip_blocks=True,
)

# 生成配置文件
nginx_template = env.from_string("""
server {
    listen {{ port }};
    server_name {{ domain }};

    {% for location in locations %}
    location {{ location.path }} {
        proxy_pass {{ location.upstream }};
        {% if location.websocket %}
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        {% endif %}
    }
    {% endfor %}
}
""")

config = nginx_template.render(
    port=8080,
    domain="example.com",
    locations=[
        {"path": "/", "upstream": "http://127.0.0.1:3000", "websocket": False},
        {"path": "/ws", "upstream": "http://127.0.0.1:8000", "websocket": True},
    ]
)
print(config)

# 批量生成邮件
email_template = env.get_template('email/welcome.html')
for user in users:
    html = email_template.render(user=user, app_name="MyApp")
    send_email(user.email, "欢迎加入", html)

常见问题与踩坑

问题原因解决方案
XSS 攻击`{{ varsafe }}` 跳过转义
模板继承多层覆盖子 block 覆盖父 block{{ super() }} 保留父 block 内容
模板中业务逻辑模板里写复杂 Python 逻辑逻辑放在视图,模板只做展示
静态文件缓存浏览器缓存旧版本url_for('static', v=app_version) 加版本号
CSRF 缺失表单未加 CSRF Token{% csrf_token %}(Django)或手动添加

最佳实践

  • 模板只做展示,业务逻辑放在视图层
  • 基础模板定义布局,子模板只覆盖 block
  • 宏用于可复用 UI 组件(分页、卡片、表单)
  • 自定义过滤器封装格式化逻辑
  • 用户输入永远自动转义,不用 |safe
  • 新项目优先前后端分离(API + 前端框架)
  • 渐进增强用 HTMX,比全 SPA 更轻量

面试题

Q1: Jinja2 的自动转义是如何工作的?

Jinja2 对 {{ var }} 输出的内容自动进行 HTML 实体转义(<&lt; 等),防止 XSS 攻击。{{ var|safe }} 跳过转义,仅用于可信内容。Flask 默认对 .html/.htm/.xml/.xhtml 启用自动转义,Django 默认对所有模板启用。开发者不应关闭自动转义,只对确认安全的内容使用 safe 过滤器。

Q2: 模板继承和 {% include %} 有什么区别?

继承(extends)是整体布局替换:子模板继承父模板的完整结构,用 block 覆盖指定区域。一个模板只能继承一个父模板。include 是片段插入:将子模板的渲染结果插入当前位置,不涉及 block 覆盖。继承用于页面整体布局(导航/侧栏/页脚),include 用于可复用片段(评论组件、广告位)。include 可以传递变量:{% include "item.html" with item=post %}

Q3: SSR 和 CSR 如何选择?

SSR 适合:内容型网站(博客/新闻/文档)、SEO 要求高、管理后台、首屏速度优先。CSR 适合:交互密集型应用(在线编辑器/仪表盘)、需要离线能力、团队前后端分离开发。混合方案:Next.js/Nuxt.js 的同构渲染,首屏 SSR + 后续 CSR。Django/Flask 项目可结合 HTMX 实现”SSR + 轻量交互”,比全 SPA 更简单。

Q4: Jinja2 的宏和 Python 函数有什么区别?

宏(macro)是模板级别的可复用片段,输出 HTML,在模板中用 {% macro name() %} 定义,{{ name() }} 调用。Python 函数是业务逻辑,在视图层定义,通过 context_processor 注入模板。宏适合 UI 组件(分页/卡片),函数适合数据计算。宏不能访问 Python 对象的方法,只能操作传入的参数。宏可导入:{% from "macros.html" import pagination %}

Q5: 如何在 Django 中集成 Vue/React 前端?

三种方案:1) 前后端完全分离——Django 只提供 API(DRF),Vue/React 独立项目,构建后放到 Django 的 static 目录或独立部署;2) Django 模板 + Vue/React 组件——模板中 <div id="app"> 挂载 Vue/React 应用,适合渐进式迁移;3) Django 模板 + HTMX——无前端框架,用 HTML 属性实现交互,最简单。新项目推荐方案 1,已有项目迁移推荐方案 2 或 3。

Q6: Jinja2 的 call 块有什么用途?

call 块允许向宏传递一段模板内容(类似 React 的 children/插槽)。定义:{% macro dialog(title) %}<div><h1>{{ title }}</h1>{{ caller() }}</div>{% endmacro %}。调用:{% call dialog("确认") %}<p>确定删除?</p>{% endcall %}caller() 在宏内部输出 call 块包裹的内容。这比纯参数传递更灵活,适合组件化的模板开发。

Q7: 如何防止模板注入攻击?

Jinja2 的自动转义防止 XSS,但还有其他风险:1) 不要用 |safe 处理用户输入;2) 不要在模板中直接 eval 用户数据;3) 沙箱模式(SandboxedEnvironment)限制模板可访问的对象和属性;4) 不要让用户提交模板内容并渲染;5) 敏感操作在视图中完成,模板只负责展示。Flask 默认使用沙箱环境,Django 模板语言更安全(默认不允许调用方法)。

Q8: Django 模板语言和 Jinja2 有什么区别?

Django 模板语言(DTL)更严格:不允许在模板中调用方法(user.get_name() 不行)、不支持表达式运算、过滤器语法不同。Jinja2 更灵活:支持 Python 表达式、方法调用、宏、call 块、行语句。Django 1.8+ 可以切换模板引擎为 Jinja2,但大多数 Django 项目仍用 DTL(因为 Admin/第三方包基于 DTL)。Flask 默认 Jinja2。如果项目同时用 Django 和 Jinja2,需要注意模板语法差异。


相关链接: