news 2026/9/10 6:22:35

FastAPI模板渲染利器:Jinja2过滤器完全指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
FastAPI模板渲染利器:Jinja2过滤器完全指南

用FastAPI做模板渲染的人,迟早会遇到这么个场景:接口数据取出来了,可真要把它塞进HTML页面时才发现,后端算好的东西跟页面要展示的东西之间,还差了一大截格式化工作。时间戳要显示成“2025-06-18 14:30”,文章摘要要从正文里截,阅读量过万要显示成“1.2万”,标签列表要用顿号串起来。这些脏活累活,在Jinja2模板里都有一个统一的出口——过滤器。这篇系列第14篇就把这个模板语法里的过滤器讲透:内置的怎么用、FastAPI项目里怎么注册自定义过滤器、哪些场景不该用过滤器,以及我在实际项目里踩过的坑。无论你是刚开始用FastAPI,还是已经在生产环境跑了一段时间,这篇都值得从头扫一遍,很多细节容易被忽略,但对排查问题却非常关键。

1. 过滤器是模板引擎的“格式化工坊”

1.1 一条竖线背后的设计逻辑

Jinja2中的过滤器语法很简单,就三种形态:

{{ 变量|过滤器名称 }} {{ 变量|过滤器名称(参数) }} {{ 变量|过滤器1|过滤器2|过滤器3 }}

一条竖线|把它左边的值“传”给右边的过滤器函数,函数处理完的结果再交给下一个过滤器或直接渲染。这个设计很容易理解:原始数据是原材料,过滤器是一道道加工工序,竖线是传送带。模板里想展示什么形态,就挂上对应的工序。

为什么要这么设计?因为Web页面要展示的数据,几乎从来不等于后端查出来的原始值。我从数据库里取出一个字段create_time是datetime对象,页面却要显示成“2025-06-18 14:30”;列表页里文章正文是一大段HTML,卡片上只需要60字的纯文本摘要;用户填的昵称首字母不够整齐,想统一转成大写。这些变换如果全部放在视图函数里做,视图会被塞满格式化代码,模板又变成一坨光秃秃的变量;如果在模板里挨个写if/else判断,维护起来会更痛苦。过滤器把“取值”和“展示”彻底分开,视图层只管给原始数据,展示层自己去加工。

1.2 过滤器、模板方法调用与全局函数,到底怎么选

有人可能会问,模板里也能直接调用对象的Python方法,比如:

{{ username.upper() }} {{ tags.count() }}

这看起来和过滤器几乎一样,为什么还要额外学一套过滤器语法?我的经验是:能写过滤器就不写方法调用。原因有三。第一,对象方法受类型限制,username.upper()只对字符串有效,换成别的类型要么报错要么得先做类型判断,而过滤器对任意输入更宽容,很多内置过滤器本身就做了类型兜底。第二,过滤器能链式组合,一个竖线一个竖线接下去,方法调用嵌套起来可读性差很多。第三,过滤器可以被覆盖和注册,你可以注册一个同名过滤器改变全局行为,比如模板里所有|length都输出成“共N项”,这种替换能力方法调用给不了。

至于全局函数,比如range()namespace(),它的使用场景和过滤器完全不同。全局函数通常是“凭空生成一个值”,过滤器是“把一个已有的值变成另一个形态”。如果你发现自己想在模板里做一段比较复杂的数据加工,例如先对列表筛选再排序再取前三条,优先考虑在视图层算好,直接把结果传给模板,而不是在模板里叠一堆过滤器。模板终究是展示层,不是业务逻辑层。

2. 内置过滤器分类速查与实战示例

2.1 文本处理:大小写、替换、截断与去标签

文本处理是过滤器最家常的应用。Jinja2内置了upper、lower、title、capitalize、trim、replace、truncate、striptags、wordwrap等一连串文本过滤器。直接看用例:

{{ name|upper }} {# 转大写 #} {{ name|lower }} {# 转小写 #} {{ name|title }} {# 每个单词首字母大写 #} {{ name|capitalize }} {# 整句首字母大写 #} {{ title|trim }} {# 去掉首尾空白 #} {{ "我-是-关键词"|replace("-", " ") }} {# 替换字符 #}

实际项目里最常用的是truncate和striptags,我单独拉出来说。truncate用来截断字符串,常用写法是:

{{ article.content|truncate(80, killwords=True, end="…") }}

它的三个核心参数:第一个是最大长度,默认255;第二个killwords决定是否在单词中间硬切;第三个end是截断后追加的字符串,默认是英文省略号。这里有个细节:truncate在计算长度时把end的长度也算进去,也就是说如果你设end="…",最终展示的字符数会比80略少一点。对中英文混排的页面不要过度依赖truncate的精确截断,真正严格按字数控制标题的地方,我更建议后端先算好摘要再传过来。

striptags会剥掉字符串里所有HTML标签。文章列表页需要纯文本摘要时,先striptags再truncate是非常经典的一条管道:

{{ article.content|striptags|truncate(80, end="…") }}

这个组合我在博客、管理系统、移动端接口的标题生成上都用过,效果稳定。要注意的是striptags只是去标签,不负责过滤XSS,如果要展示用户提交的含标签内容,还得配合转义一起处理。

2.2 数值处理:round与filesizeformat

数值类过滤器在统计页面和后台管理页面上特别常用。内置的有int、float、round、abs、filesizeformat。

round过滤器负责处理小数精度,常配合method参数使用:

{{ 3.1415926|round(2) }} {# 3.14 #} {{ 3.1415926|round(2, method='ceil') }} {# 3.15 #} {{ 3.1415926|round(2, method='floor') }} {# 3.14 #}

method默认是'common',即四舍五入;可选'ceil'向上取整和'floor'向下取整。做分页、金额计算时这两个取值方式很有用,比如计算订单页数时,我习惯用total // page_size|round(0, method='ceil')确保最后一页也能被算进去。当然,遇到真正需要严谨计算的场景,我更推荐在后端用math.ceil算好再传给模板,模板里的round更适合做展示层的“差不多就行”。

filesizeformat会把字节数显示成友好格式:

{{ 1048576|filesizeformat }} {# 1.0 MB #} {{ 1024|filesizeformat }} {# 1.0 KB #}

做文件上传列表、对象存储管理页时,这个过滤器能省掉一长串换算逻辑。

2.3 列表、字典与集合:排序、分组、拼接与切片

列表类过滤器处理的对象是数组,常见的包括first、last、length、sum、sort、reverse、unique、join、batch、slice。几个典型用例:

{{ users|first }} {{ users|last }} {{ users|length }} {{ [1, 2, 3, 4]|sum }} {{ users|map(attribute='name')|join(', ') }} {{ tags|sort }} {{ tags|reverse|first }}

join过滤器非常实用,它会把列表元素拼成一个字符串:

{{ tags|join("、") }} {{ users|join(",", attribute="name") }}

第二个例子是把每个user对象的name字段取出来再拼接,免去了先map再join的两层写法。

sort过滤器还可以指定排序字段和方向。按发布时间倒序展示文章列表:

{% for article in articles|sort(attribute="published_at", reverse=True) %} ... {% endfor %}

groupby按字段分组,适合做“按分类展示文章”“按日期归档”这类页面:

{% for group in articles|groupby("category") %} <h2>{{ group.grouper }}</h2> {% for article in group.list %} <p>{{ article.title }}</p> {% endfor %} {% endfor %}

batch和slice是按数量分块,常用于栅格布局,比如每行三列卡片:

{% for row in products|batch(3) %} <div class="row"> {% for product in row %} <div class="col">{{ product.name }}</div> {% endfor %} </div> {% endfor %}

2.4 缺省值、转义与安全:default、safe与tojson

default可能是整个Jinja2里使用频率最高的过滤器之一,几乎所有列表页都会用到。作用很简单:当变量不存在或者值为None时,输出你指定的默认值。

{{ user.nickname|default("游客") }}

但这里有一个几乎人人都会踩的坑:当变量的值是空字符串、0、False这类“假值”时,默认的default并不会替换它。比如用户把昵称清空后存了空字符串,页面依然显示空白而不是“游客”。想要让空字符串也走默认值,必须加boolean=True

{{ user.nickname|default("游客", boolean=True) }}

boolean=True会把左边的值先按布尔判断,只有真值才保留,否则直接用默认值。做面向用户的展示层时,我基本上都会带上boolean=True,除非业务上明确区分“没填”和“填了但为空”。

转义和安全类是过滤器里最需要谨慎对待的部分。Jinja2默认会在输出HTML时自动转义,因此{{ name }}里如果包含<script>,会被转成&lt;script&gt;。escape过滤器就是手动强制转义,但绝大多数情况不需要手动做,因为默认已经开了。和它相反的是safe,safe会告诉模板引擎“这个字符串可以按原始HTML输出,不要转义”。

safe是把双刃剑。模板里展示后端生成的富文本时,确实需要safe:

<div>{{ article.content_html|safe }}</div>

但如果是未经深思熟虑就把用户提交的HTML直接safe,XSS就会找上门。在后面的踩坑章节我会专门展开,这里先记住一个原则:safe只能用在你完全信任的数据上。

tojson过滤器是另一个常用的安全类工具,它能把Python对象序列化成JSON字符串,并且自动做HTML转义,适合在模板中直接把后端数据传给前端JavaScript:

<script> const appData = {{ app_data|tojson }}; </script>

不用tojson的话,你可能会手写json.dumps然后担心特殊字符把页面搞坏,用tojson就稳很多。

2.5 链式组合的读法

过滤器真正的威力来自组合。一条管道从左往右读,先执行的在前、后执行的在后:

{{ article.summary|default("暂无摘要")|striptags|truncate(60, end="…") }}

这条管道的意思是:取summary,如果为空就用“暂无摘要”,去掉可能存在的HTML标签,再截断成60字。整行代码读下来,处理流程一目了然。我建议每个过滤器只做一件事,这样链条再长也不会混乱。一旦发现某条链上要写超过三四个过滤器,或者中途需要if判断,就该考虑在后端提前处理好了。

3. FastAPI里把过滤器真正用起来

3.1 最小可运行的模板渲染环境

先搭一个能在FastAPI里跑通Jinja2的最小结构。工程里我一般是这样组织:

project/ ├── main.py └── templates/ └── index.html

main.py内容:

from fastapi import FastAPI, Request from fastapi.templating import Jinja2Templates from fastapi.responses import HTMLResponse app = FastAPI() templates = Jinja2Templates(directory="templates") @app.get("/", response_class=HTMLResponse) async def index(request: Request): context = { "username": "ada", "score": 87.345, "tags": ["FastAPI", "Jinja2", "Templates"], } return templates.TemplateResponse( request=request, name="index.html", context=context, )

这段代码里有两个容易搞混的细节。第一,FastAPI新版本推荐把request作为TemplateResponse的第一个参数传入,context参数里就不用再写request了;旧版本常见写法是把request塞进context字典,形如templates.TemplateResponse("index.html", {"request": request, ...})。两种写法在对应版本都能工作,但如果你照着网上旧教程抄到新版本里,会看到类型签名相关的报错或警告。第二,response_class=HTMLResponse是为了让接口文档能识别响应类型,不写也能返回HTML,但写了更清晰。

3.2 模板里的过滤器实际渲染

templates/index.html:

<!DOCTYPE html> <html> <head> <title>{{ username|title }}</title> </head> <body> <h1>Hello, {{ username|title }}</h1> <p>分数:{{ "%.2f"|format(score) }}</p> <p>标签:{{ tags|join("、") }}</p> </body> </html>

在模板里,{{ }}之间的内容就是最终会被渲染的表达式。过滤器在这里正常工作,Jinja2引擎会在服务端把所有管道计算完毕、生成一段纯HTML字符串后返回给浏览器。你在浏览器里看到的是处理后的结果,而不会看到|title这种语法残留。这也意味着,如果过滤器写错了,浏览器不一定立刻报错,可能只是显示了格式不对的内容,这时候要回到模板代码本身排查。

3.3 request在模板环境中的位置

使用Jinja2Templates渲染时,Starlette会把request注入到模板上下文里,所以模板中可以直接访问request.methodrequest.urlrequest.client.host等属性。过滤器同样可以处理这些值,比如显示当前请求路径,或者用replace把URL里的参数拼成更友好的展示。不过这里要提醒一句:模板里的request自动注入并非Jinja2原本的特性,而是Starlette的Jinja2Templates做的封装。理解这一点对排查问题很有帮助,比如当你脱离FastAPI单独用Jinja2时,发现模板里根本没有request这个变量,不用惊讶。

4. 自定义过滤器:注册与调用

4.1 哪些场景必须自定义过滤器

内置过滤器虽然多,但真实的业务永远比通用功能刁钻。我项目里比较典型的自定义过滤器场景包括:

  • 时间显示成“刚刚 / 5分钟前 / 昨天 / 2025-06-18”
  • 阅读量、浏览量从12345显示成“1.2万”
  • 手机号、身份证号脱敏,比如138****8888
  • 把Markdown文本渲染成纯文本摘要
  • 把枚举值转成中文状态:status|status_label
  • 给数字加千分位:1234567显示成1,234,567

这些逻辑简单、重复使用、只影响展示形态,非常适合做成过滤器。如果哪天多个模板都要用同一个格式化规则,直接注册一个过滤器,比在每个视图函数里复制粘贴代码要优雅得多。我这里特别强调“展示形态”这四个字——如果某个逻辑涉及状态修改、数据写入,或者计算结果会作为后续业务的判断依据,那就不该用过滤器,老老实实放后端。

4.2 在FastAPI中注册自定义过滤器的具体写法

注册过滤器的本质,就是往Jinja2环境对象的filters字典里塞一个函数。FastAPI里我们拿到Jinja2Templates实例后,通过templates.env就能访问到那个Jinja2 Environment。

第一种写法,直接给env.filters赋值:

def format_views(value): if value is None: return "0" value = int(value) if value >= 10000: return f"{value / 10000:.1f}万" return str(value) templates.env.filters["views"] = format_views

第二种写法,用Jinja2 Environment自带的filter装饰器:

@templates.env.filter def time_ago(value, now=None): if value is None: return "" if now is None: now = datetime.utcnow() delta = now - value if delta.days >= 30: return f"{delta.days // 30}个月前" if delta.days >= 1: return f"{delta.days}天前" if delta.seconds >= 3600: return f"{delta.seconds // 3600}小时前" if delta.seconds >= 60: return f"{delta.seconds // 60}分钟前" return "刚刚"

第二种写法的函数名会直接成为模板中的过滤器名,函数名得起得干净利落。我更喜欢把自定义过滤器集中放在单独的modules/filters.py里统一管理,而不是散落在各个路由文件里:

# modules/filters.py def format_views(value): ... def time_ago(value): ... def register_template_filters(env): env.filters["views"] = format_views env.filters["time_ago"] = time_ago return env

然后在main.py里先创建Jinja2Templates,再注册:

templates = Jinja2Templates(directory="templates") register_template_filters(templates.env)

这种拆分的好处是,项目里的过滤器越来越多时,不会污染主路由文件,测试也好写。模板里直接这样用:

<p>{{ article.views|views }}</p> <p>{{ article.published_at|time_ago }}</p>

4.3 带参数的过滤器与链式调用

自定义过滤器还能接收额外参数。函数签名里第一个参数永远是被管道传入的变量,后面的参数来自|filter(param1, param2)调用。举个例子,手机号脱敏过滤器:

def mask_phone(value, start=3, end=7, mask_char="*"): if not value: return "" value = str(value) return value[:start] + mask_char * (end - start) + value[end:]

模板里的使用方式:

{{ user.phone|mask_phone(3, 7) }} {{ user.id_card|mask_phone(3, 12) }}

参数还可以带默认值,这样部分场景可以不传参数。把自定义过滤器和内置过滤器串起来也很常见,比如:

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/10 6:21:40

低功耗开发实战:从安卓wakelock到MCU寄存器级优化

1. 这不是“省电小技巧”&#xff0c;而是设备工程师的生存基本功低功耗开发&#xff0c;四个字听着像手机调个深色模式、关个后台APP那么简单。但如果你真这么想&#xff0c;去投安卓或嵌入式岗位时&#xff0c;简历可能连HR那关都过不了——因为招聘JD里写的“具备低功耗优化…

作者头像 李华
网站建设 2026/9/10 6:21:02

hermes-agent:智能体消息路由与自动化任务调度实战解析

你们有没有过这种经历——消息提醒从早响到晚&#xff0c;钉钉群、邮件、GitHub通知、监控告警轮番轰炸&#xff0c;真正要处理的任务却一个都没推进。我去年在这种状态下熬了大半年&#xff0c;最后决定不再忍了&#xff0c;动手写了一个叫hermes-agent的项目。名字取自希腊神…

作者头像 李华
网站建设 2026/9/10 6:20:28

Chrome侧边栏AI扩展:sidePanel+iframe实现多模型并排对比

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/10 6:18:01

CANN/ge GE Python API - GeApi接口文档

GeApi 【免费下载链接】ge GE&#xff08;Graph Engine&#xff09;是面向昇腾的图编译器和执行器&#xff0c;提供了计算图优化、多流并行、内存复用和模型下沉等技术手段&#xff0c;加速模型执行效率&#xff0c;减少模型内存占用。 GE 提供对 PyTorch、TensorFlow 前端的友…

作者头像 李华
网站建设 2026/9/10 6:17:43

AI代码审查落地C/C++:22万行代码全量扫描实战与避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华