用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>,会被转成<script>。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.htmlmain.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.method、request.url、request.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) }}参数还可以带默认值,这样部分场景可以不传参数。把自定义过滤器和内置过滤器串起来也很常见,比如: