How to create custom template tags and filters

Django 模板语言包含了很多 内置 tags 和 filters,设计目的是满足应用需要占位逻辑需求。极少情况下,你可能发现需要的功能未被核心模板集覆盖。你能通过 Python 代码自定义 tags 和 filters 扩展集成模板引擎,通过 {% load %} 标签使其可用。

代码布局

定制自定义模板 tags 和 filters 的位置就是 Django 应用内。如果它们关联至某个已存在的应用,在那里将它们打包就很有用;否则,它们能被添加至新应用。当一个 Django 应用被添加至 INSTALLED_APPS,所以其在常规位置(下面介绍)定义的标签都可以在模板中自动加载。

该应用应包含一个 templatetags 目录,与 models.pyviews.py 等同级。若该目录不存在,创建它——不要忘了用 __init__.py 文件确保目录被视作一个 Python 包。

开发服务器并不会自动重启

添加 templatetags 模块后,你需要重启服务器,这样才能在模板中使用 tags 和 filters。

自定义的 tags 和 filters 会保存在模块名为 templatetags 的目录内。模块文件的名字即稍候你用来加载 tags 的名字,所以小心不要采用一个可能与其它应用自定义的 tags 和 filters 冲突的名字。

例如,如果你的 tags/filters 保存在一个名为 poll_extras.py 的文件中,你的应用布局可能看起来像这样:

polls/
    __init__.py
    models.py
    templatetags/
        __init__.py
        poll_extras.py
    views.py

在模板中你会使用以下代码:

{% load poll_extras %}

为了使 {% load %} 标签生效,包含自定义标签的应用必须包含在 INSTALLED_APPS 中。这是个安全特性:它允许你在一个主机上持有多个模板库,而不是让每个 Django 安装都能访问所有的库。

我们并未限制放入 templatetags 包中的模块数量。只需牢记 {% load %} 语句会加载名字指定 Python 模块的 tags/filters,而不是应用。

要成为一个可用的 tag 库,模块必须包含一个名为 register 的模块级变量,它是一个 template.Library 实例。所有的 tags 和 filters 均在其中注册。所以,在模块的开始,输入以下内容:

from django import template

register = template.Library()

或者,模板标签模块能通过 DjangoTemplates'libraries' 参数注册。这在加载模板名字时,想为模板标签起个别名时很有用。这也让你能在未安装应用的情况下注册标签。

幕后

For a ton of examples, read the source code for Django's default filters and tags. They're in django/template/defaultfilters.py and django/template/defaulttags.py, respectively.

更多关于 load 标签的信息,阅读本文档。

编写自定义的模板过滤器

自定义的过滤器就是一些有一到两个参数的 Python 函数:

  • (输入的)变量的值,不一定得是字符串类型
  • 而参数的值,它们可以有一个默认值,或者被排除在外

举个例子,在过滤器 {{ var|foo:"bar" }} 中,变量 var 和参数 bar 会传递给过滤器 foo

因为模板语言不提供异常处理机制,所以任何从模板过滤器中抛出的异常都将被视为服务器异常。因此,如果有一个合理的返回值将要被返回的话,过滤器函数应当避免产生异常。万一模板中出现有明显错误的输入,产生异常也仍然比隐藏这个 bug 要好。

这是一个过滤器定义的例子:

def cut(value, arg):
    """Removes all values of arg from the given string"""
    return value.replace(arg, '')

这个例子展示了如何使用这个过滤器:

{{ somevariable|cut:"0" }}

大部分的过滤器并没有参数。这样的话,只需要把这些参数从你的函数中去掉就好。

def lower(value): # Only one argument.
    """Converts a string into all lowercase"""
    return value.lower()

注册自定义过滤器

django.template.Library.filter()

每当你写好你的过滤器定义的时候,你需要用你的 Library 实例去注册它,从而让它对于 Django 模板语言而言是可用的

register.filter('cut', cut)
register.filter('lower', lower)

Library.filter() 方法有两个参数:

  1. 过滤器的名称——字符串。
  2. 编辑函数——一个 Python 函数(不是函数名的字符串)。

你也能以装饰器的模式使用 register.filter():

@register.filter(name='cut')
def cut(value, arg):
    return value.replace(arg, '')

@register.filter
def lower(value):
    return value.lower()

若你不填 name 参数,像第二个例子展示的一样,Django 会将函数名当做过滤器名。

最后, register.filter() 也接受 3 个关键字参数, is_sageneeds_autoescape,和 expects_localtime。这些参数在下面的 过滤器和自动转义过滤器和时区 介绍。

模板过滤器期望字符串

django.template.defaultfilters.stringfilter()

如果编写只接收一个字符串作为第一个参数的模板过滤器,你需要使用 stringfilter 的装饰器。它会将参数前转为字符串后传递给函数:

from django import template
from django.template.defaultfilters import stringfilter

register = template.Library()

@register.filter
@stringfilter
def lower(value):
    return value.lower()

这样,您就可以将一个整数传递给这个过滤器,而不会导致 AttributeError (因为整数没有 lower() 方法)。

过滤器和自动转义

编写自定义过滤器时,考虑一下过滤器将如何与 Django 的自动转义行为交互。

  • 原始字符串 指原生 Python 字符串。在输出时,如果自动转义生效,则对它们进行转义,否则将保持不变。

  • 安全字符串 是在输出时被标记为安全的字符串,不会进一步转义。必要的转义已在之前完成。它们通常用于原样输出 HTML,HTML 会在客户端被解释。

    实质上,这些字符串是 SafeString 类的实例。你能用以下代码测试它们:

    from django.utils.safestring import SafeString
    
    if isinstance(value, SafeString):
        # Do something with the "safe" string.
        ...
    

模板过滤器代码有两种情况:

  1. 你的过滤器不会将任何 HTML 不安全的字符(<, >, ', " or &)引入尚未出现的结果中。这种情况下,可以让 Django 自动为您处理所有的转义操作。你只需在注册自己的过滤器函数时,将 is_safe 标志置为 True,像这样:

    @register.filter(is_safe=True)
    def myfilter(value):
        return value
    

    该标志告诉 Django,若一个“安全”字符串传给您的过滤器,结果仍会是安全的。若传入了不安全的字符串,Django 会在需要时自动转义。

    你可以这么认为,“过滤器是安全的——它不会产生任何不安全的 HTML。”

    一定要 is_safe 的原因是大量的字符串操作会将 SafeData 对象返回为普通 str 对象,而不是尝试全部捕获(挺难的),Django 在过滤完成后尝试修复这些损伤。

    举例来说,假定有个过滤器,会在任何输入后追加 xx。由于此操作不会在结果产生任何 HTML 危险的字符(除了那些已存在的),你需要用 is_safe 标记你的过滤器:

    @register.filter(is_safe=True)
    def add_xx(value):
        return '%sxx' % value
    

    当该过滤器对某个启用了自动转义的模板生效时,Django 会对输出自动转义,不管输入是否被标记为“安全的”。

    默认情况下, is_sateFalse,你可以为不要求此项的过滤器忽略它。

    在确定过滤器是否确实将安全字符串保留为安全字符串时要千万小心。如果你正在 删除 字符,你可能不经意的在结果中留下不成对的 HTML 标记或实体。例如,从输入中删除一个 > 可能将 <a> 转为 <a,后者可能需要转移,避免导致输出错误。类似的,删除一个分号(;)会将 &amp; 转为 &amp,后者不再是一个有效的实体,因此需要转义。大多数情况下都没这么复杂,但是检查代码时要注意类似的问题。

    标记过滤器的 is_safe 会强制该过滤器的返回值为字符串。如果你的过滤器要返回一个布尔值或非字符串值,将其标记为 is_safe 可能会导致出乎意料的结果(类似将一个布尔值 False 转为字符串 'False')。

  2. 或者,你的过滤器代码要手动关注必须的转义操作。这在输出新 HTML 标记时是必须的。想要避免你的 HTML 标记不被后续操作转义,你要将输出标记为安全的,且需要自己处理输入。

    要将输出标记为安全字符串,使用 django.utils.safestring.mark_safe()

    不过还是要小心,你要做的不只是将输出标记为安全的。你需要确保它 真的是 安全的,你所做的取决于自动转义是否生效。理想状态下,编写的过滤器在自动转义开启与关闭的情况下均能正确的操作模板,这样模板作者用起来就更简单了。

    为了让你的过滤器知道自动转移开关的状态,在你注册过滤器函数时将 needs_autoescape 标志(默认 False)设置为 True。该标志告诉 Django 过滤器函数额外接受一个名为 autoescape 关键字参数,值为 True 时说明自动转义生效中, False 说明关闭。推荐将 autoescape 参数的默认值设为 True,这样,从 Python 代码调用此函数时,默认开启自动转义功能。

    例子,让我们编写一个强制大写字符串的首字母的过滤器:

    from django import template
    from django.utils.html import conditional_escape
    from django.utils.safestring import mark_safe
    
    register = template.Library()
    
    @register.filter(needs_autoescape=True)
    def initial_letter_filter(text, autoescape=True):
        first, other = text[0], text[1:]
        if autoescape:
            esc = conditional_escape
        else:
            esc = lambda x: x
        result = '<strong>%s</strong>%s' % (esc(first), esc(other))
        return mark_safe(result)
    

    needs_autoescape 标志和 autoescape 自动转义关键字参数意味着函数会在过滤器被调用时知道自动转义是否生效。我们利用 autoescape 来决定是否要将输入数据传递给 django.utils.html.conditional_escape。(后续实例中,我们使用同一函数作为“转义”函数。) conditional_escape() 函数除了它只转义 不是 一个 SafeData 的实例的输入之外其他等同于 escape() 。如果一个 SafeData 实例经过 conditional_escape() ,数据将会不经过修改返回。

    最后,在上述例子中,我们牢记将结果标为安全的,所以 HMTL 未经转义就直接插入模板中。

    这种场景下无需担心 is_safe 标志(虽然包含它不会有什么问题)。无论你何时决定手动处理自动转义,并返回安全字符串, is_safe 标志不会有任何影响。

警告

在重用内置过滤器时避免 XSS 漏洞

Django 内置的过滤器默认配置 autoescape=True,获取合适的自动转义行为,并避免跨站脚本漏洞。

在旧版本的 Django 中,复用 Django 内置过滤器时要小心,因为 自动转义 默认为 None。你需要传入 autoescape=True 启用自动转义。

举个例子,如果你想编写一个联合 urlizelinebreaksbr 过滤器的,名为 urlize_and_linebreaks 的自定义过滤器,可通过以下代码:

from django.template.defaultfilters import linebreaksbr, urlize

@register.filter(needs_autoescape=True)
def urlize_and_linebreaks(text, autoescape=True):
    return linebreaksbr(
        urlize(text, autoescape=autoescape),
        autoescape=autoescape
    )

接下来:

{{ comment|urlize_and_linebreaks }}

等价于:

{{ comment|urlize|linebreaksbr }}

过滤器和时区

如果你编写了一个自定义过滤器,处理 datetime 对象,注册过滤器时通常将 expects_localtime 标志置为 True:

@register.filter(expects_localtime=True)
def businesshours(value):
    try:
        return 9 <= value.hour < 17
    except AttributeError:
        return ''

设置该标志后,如果过滤器接收的第一个参数是一个时区敏感的 datetime,Django 在将其传递给过滤器前的某个合适时间将其转换为当前时区的时间,依据 模板中的时区转换规则

编写自定义模板标签

标签比过滤器更复杂,因为标签啥都能做。Django 提供了很多快捷方式,简化了编写绝大多数类型的标签过程。我们先探索这些快捷方式,然后解释如何在快捷方式不够强大的情况下从零编写标签。

简单标签

django.template.Library.simple_tag()

许多模板标签接受多个参数——字符串或模板变量——并仅根据输入参数和一些额外信息进行某种处理,并返回结果。例如, current_time 标签可能接受一个格式字符串,并将时间按照字符串要求的格式返回。

为了简化创建标签类型的流程,Django 提供了一个助手函数, simple_tag。该函数实际是 django.template.Library 的一个方法,该函数接受任意个数的参数,将其封装在一个 render 函数以及上述其它必要的位置,并用模板系统注册它。

我们的 current_time 函数因此能这样写:

import datetime
from django import template

register = template.Library()

@register.simple_tag
def current_time(format_string):
    return datetime.datetime.now().strftime(format_string)

关于 simple_tag 助手函数,有几点要注意:

  • 检测要求参数的个数等在调用函数时就已完成,所以我们无需再做。
  • 包裹参数(如果有的话)的引号已被删除,所以我们收到一个普通字符串。
  • 如果参数是一个模板变量,函数将传递变量值,而不是变量本身。

若模板上下文处于自动转义模式,不像其它标签实体, simple_tag 通过 conditional_escape() 传递输出,为了确保输出正确的 HTML,避免 XSS 漏洞的威胁。

如果不需要额外转义,你可能需要在万分确定您的代码不会引入任何 XSS 漏洞的情况下使用 mark_safe()。如果只是构建小的 HTML 片段,强烈建议使用 format_html(),而不是 mark_safe()

若您的模板标签需要访问当前上下文,你可以在注册标签时传入 takes_context 参数:

@register.simple_tag(takes_context=True)
def current_time(context, format_string):
    timezone = context['timezone']
    return your_get_current_time_method(timezone, format_string)

注意,第一个参数必须是 context

更多关于 takes_context 选项如何工作的信息,参见章节 包含标签

若你需要重命名标签,你可以为其提供一个自定义名称:

register.simple_tag(lambda x: x - 1, name='minusone')

@register.simple_tag(name='minustwo')
def some_function(value):
    return value - 2

simple_tag 函数可以接受任意数量的位置或关键字参数。例如:

@register.simple_tag
def my_tag(a, b, *args, **kwargs):
    warning = kwargs['warning']
    profile = kwargs['profile']
    ...
    return ...

随后在模板中,任意数量的,以空格分隔的参数会被传递给模板标签。与 Python 中类似,关键字参数的赋值使用等号("="),且必须在位置参数后提供。例子:

{% my_tag 123 "abcd" book.title warning=message|lower profile=user.profile %}

将标签结果存入一个模板变量而不是直接将其输出是可能的。这能通过使用 as 参数,后跟变量名实现。这样做能让你在期望的位置输出内容:

{% current_time "%Y-%m-%d %I:%M %p" as the_time %}
<p>The time is {{ the_time }}.</p>

包含标签

django.template.Library.inclusion_tag()

另一种常见的模板标签会为 另一个 模板渲染数据。例如, Django 的后台利用自定义模板标签在表单页的底部展示按钮。这些按钮看起来一样,但是连接目标根据被编辑的对象不同而不同——所以,这是一个极好的例子,展示如何用当前对象的细节填充小模板。(在后台例子中,即 submit_row 标签。)

这种标签被称为“包含标签”。

编写包含标签可能最好通过实例来展示。让我们编写一个标签,它会将指定 Poll 对象(就像 教程 中创建的那样)的选项以列表输出。我们像这样使用标签:

{% show_results poll %}

输出看起来像这样:

<ul>
  <li>First choice</li>
  <li>Second choice</li>
  <li>Third choice</li>
</ul>

首先,定义一个函数,接受参数,并返回一个字典。此处的要点是我们只需返回一个字典,不是任何其它复杂的东西。这将作为一个模板上下文被模板碎片使用。例子:

def show_results(poll):
    choices = poll.choice_set.all()
    return {'choices': choices}

随后,创建用于渲染标签输出的模板。该模板是标签的一个固有特性:标签作者指定它,而不是模板设计者。跟随我们的例子,模板非常简短:

<ul>
{% for choice in choices %}
    <li> {{ choice }} </li>
{% endfor %}
</ul>

现在,在 Library 对象上调用 inclusion_tag() 创建并注册该包含标签。如果上述模板位于一个名为 results.html 的文件中,在模板加载器搜索的目录中,我们像这样注册该标签:

# Here, register is a django.template.Library instance, as before
@register.inclusion_tag('results.html')
def show_results(poll):
    ...

或者,也能用 django.template.Template 实例注册包含标签:

from django.template.loader import get_template
t = get_template('results.html')
register.inclusion_tag(t)(show_results)

在第一次创建该函数时。

有时候,你的包含标签可能要求超多参数,模板作者不得不传入所有参数,并牢记它们的顺序,非常痛苦。为了解决此问题, Django 为包含标签提供了一个 take_context 选项。如果在创建模板标签时指定了 takes_context,该标签将没有必要的参数,底层 Python 函数将只有一个参数——标签创建时的模板上下文。

举个例子,假设你编写了一个包含标签,总会在一个包含指向首页的 home_linkhome_title 的上下文环境下使用。Python 函数看起来会像这样:

@register.inclusion_tag('link.html', takes_context=True)
def jump_link(context):
    return {
        'link': context['home_link'],
        'title': context['home_title'],
    }

注意,该函数的第一个参数 必须context

register.inclusion_tag() 行,我们制定了模板名并设置 takes_context=True。以下是模板 link.html 的样子:

Jump directly to <a href="{{ link }}">{{ title }}</a>.

后面,当你想用该自定义标签时,加载它的库,并不带任何参数的调用它,像这样:

{% jump_link %}

注意,只要使用了 takes_context=True,就无需为模板标签传递参数。它自动从上下文获取。

takes_context 参数默认为 False。当其为 True,标签会被传入上下文对象,像本例展示的那样。这是本例和之前的 包含标签 实例的唯一不同之处。

包含标签 函数能接受任意个数的位置或关键字参数。例子:

@register.inclusion_tag('my_template.html')
def my_tag(a, b, *args, **kwargs):
    warning = kwargs['warning']
    profile = kwargs['profile']
    ...
    return ...

随后在模板中,任意数量的,以空格分隔的参数会被传递给模板标签。与 Python 中类似,关键字参数的赋值使用等号("="),且必须在位置参数后提供。例子:

{% my_tag 123 "abcd" book.title warning=message|lower profile=user.profile %}

进阶自定义模板标签

有时候,用于自定义模板标签的基础特性不够用。不要担心,Django 开放了从零开始构建模板标签所需的所有内置机制。

简介

模板系统工作只需两步:编译和渲染。为了定义自定义模板标签,你需指定如何编译和渲染。

Django 编译模板时,会将原始模板文本划为“节点”。每个节点都是一个 django.template.Node 实例,拥有一个 render() 方法。编译完的模板就是一个包含 节点 对象的列表。当你在已编译的模板上调用 render(),该模板会为节点列表中的每个 节点 携带指定上下文调用 render() 方法。结果会自动连接,形成模板的输出。

因此,要定义一个自定义模板标签,你要指定如何将原始模板标签转换为 节点 (编译函数),还要指定 render() 方法的操作。

编写编译函数

模板解析器遇到的每个模板标签,解析器都会调用一个 Python 函数,参数是标签内容和解析器对象本身。该函数需要基于标签的内容返回一个 节点 实例。

举个例子,让我们完整地实现模板标签 {% current_time %},该标签根据标签中指定的参数,以 strftime() 语法格式化当前时间或日期。先决定标签语法是个不错的主意。在本例中,我们要这样使用标签:

<p>The time is {% current_time "%Y-%m-%d %I:%M %p" %}.</p>

这个函数的解析器应用获取参数,并创建一个 节点 对象:

from django import template

def do_current_time(parser, token):
    try:
        # split_contents() knows not to split quoted strings.
        tag_name, format_string = token.split_contents()
    except ValueError:
        raise template.TemplateSyntaxError(
            "%r tag requires a single argument" % token.contents.split()[0]
        )
    if not (format_string[0] == format_string[-1] and format_string[0] in ('"', "'")):
        raise template.TemplateSyntaxError(
            "%r tag's argument should be in quotes" % tag_name
        )
    return CurrentTimeNode(format_string[1:-1])

注意:

  • parser 是模板解析器对象。本例中不需要。
  • token.contents 是包含标签原始内容的字符串。本例中是 'current_time "%Y-%m-%d %I:%M %p"'
  • token.split_contents() 方法按空格分隔字符串,但不会分隔引号包裹的部分。二愣子 token.contents.split() 就没那么健壮了,它直接在空格处分割字符串,不论它们是否被引号包裹。推荐总是使用 token.split_contents()
  • 该方法要在语法错误发生时抛出包含有用信息的 django.template.TemplateSyntaxError
  • TemplateSyntaxError 异常使用了 tag_name 变量。不要在错误消息中硬编码标签名,因为这会使的标签名与函数耦合。 token.contents.split()[0] 总会返回标签名——即便标签没有参数。
  • 该函数返回一个 CurrentTimeNode,内含节点需要了解的标签的一切信息。在本例中,传递了参数—— "%Y-%m-%d %I:%M %p"。开头和结尾的引号由 format_string[1:-1] 删除。
  • 这种解析是很低级的。Django 开发者已试着在该解析系统之上编写小型解析框架,使用类似 EBNF 语法,但这些尝试使得模板引擎运行的很慢。低级意味着快。

编写渲染器

编写自定义标签的第二步是定义一个 Node 子类,带有一个 render() 方法。

承接上述例子,我们需要定义 CurrentTimeNode:

import datetime
from django import template

class CurrentTimeNode(template.Node):
    def __init__(self, format_string):
        self.format_string = format_string

    def render(self, context):
        return datetime.datetime.now().strftime(self.format_string)

注意:

  • __init__()do_current_time() 获取 format_string。总是通过 节点__init__() 方法为其传入 options/parameters/arguments。
  • render() 方法是实际干活的地方。
  • render() 应该无声失败,尤其是在生产环境。不过,某些场景下,尤其是 context.template.engine.debugTrue 时,该方法可能抛出一个异常,简化调式流程。例如,某些核心标签在接受个数不对的参数时抛出 django.template.TemplateSyntaxError

最终,这种对编译和渲染的解耦会产生一个高效的模板系统,因为一个模板无需多次解析就能渲染多个上下文。

自动转义的注意事项

模板标签的输出 不会 自动通过自动转义过滤器(除了上述的 simple_tag() 之外)。不过,在编写模板标签时,你仍需牢记几点。

若模板标签的 render() 方法在上下文变量中存储结果(而不是以字符串返回结果),它要小心地在合适的时机调用 mark_safe()。当变量最后一次被渲染时,它会在这个时候受到自动转义配置的影响,所以为了避免变量被进一步转义,需要如此配置。

同理,如果你的模板标签为某些子渲染进程创建了新的上下文,那么就需要将当前上下文对应的自动转义属性传入。 Context 类的方法 __init__ 的参数 autoescape 就是为此目的设计的。例如:

from django.template import Context

def render(self, context):
    # ...
    new_context = Context({'var': obj}, autoescape=context.autoescape)
    # ... Do something with new_context ...

该场景不常见,但在自助渲染模板时很有用。例如:

def render(self, context):
    t = context.template.engine.get_template('small_fragment.html')
    return t.render(Context({'var': obj}, autoescape=context.autoescape))

在本例中,如果我们忽略了将当前的 context.autoescape 值传递给新 Context,结果 总会 被自动转义,这可能与期望不同,尤其是模板标签被用于 {% autoescape off %} 块之内的时候。

线程安全的注意事项

节点被解析后,其 render 方法可能被任意次地调用。由于 Django 有可能运行于多线程环境,一个节点可能同时以不同的上下文进行渲染,以相应不同的请求。因此,确保你的模板标签是线程安全就非常重要了。

为了确保你的模板标签是线程安全的,你应该永远不要在节点中存储状态信息。例如,Django 提供了一个内置的 cycle 模板标签,每次渲染时它都在一个给定字符串列表间循环:

{% for o in some_list %}
    <tr class="{% cycle 'row1' 'row2' %}">
        ...
    </tr>
{% endfor %}

CycleNode 的原生实现看起来可能像这样:

import itertools
from django import template

class CycleNode(template.Node):
    def __init__(self, cyclevars):
        self.cycle_iter = itertools.cycle(cyclevars)

    def render(self, context):
        return next(self.cycle_iter)

但是,假设有两个模板渲染器同时渲染上述模板片段:

  1. 线程 1 执行其第一次迭代, CycleNode.render() 返回 'row1'
  2. 线程 2 执行其第一次迭代, CycleNode.render() 返回 'row2'
  3. 线程 1 执行其第二次迭代, CycleNode.render() 返回 'row1'
  4. 线程 2 执行其第二次迭代, CycleNode.render() 返回 'row2'

CycleNode 正在被迭代,却是全局范围的。就像线程 1 和线程 2 担心的那样,它们总是返回同样的值。这不是我们想要的。

为了定位此问题,Django 提供了一个 render_context,关联至当前正在渲染的模板的 contextrender_context 表现的像一个 Python 字典,应该在其中保存多次同时调用 render 方法时的 Node 状态。

让我们用 render_context 重构我们的 CycleNode 实现:

class CycleNode(template.Node):
    def __init__(self, cyclevars):
        self.cyclevars = cyclevars

    def render(self, context):
        if self not in context.render_context:
            context.render_context[self] = itertools.cycle(self.cyclevars)
        cycle_iter = context.render_context[self]
        return next(cycle_iter)

注意,将 Node 生命周期中都不会发生变化的全局信息保存为属性是非常安全的。在 CycleNode 例中, cyclevars 参数在 Node 初始化后就不会变了,所以无需将其放入 render_context。但是当前正在渲染的模板的状态信息,类似 CycleNode 的当前迭代信息,就应该被保存在 render_context

注解

注意我们是如何利用 selfCycleNode 的特定参数装入 render_context 的。一个模板中可能有多个 CycleNodes,所以我们要十分小心,不要破坏其它节点的状态信息。最简单的方式就是一直将 self 作为键存入 render_context。如果你同时追踪好几个状态变量,将 render_context[self] 做成一个字典。

注册该标签

最后,用你的模块的 Library 实例注册该标签,像上文 编写自定义模板标签 1 介绍的那样。举个例子:

register.tag('current_time', do_current_time)

tag 方法接收两个参数:

  1. 模板标签的名字——一个字符串。若为空,将会使用编译函数的名字。
  2. 编辑函数——一个 Python 函数(不是函数名的字符串)。

就像过滤器注册一样,这里也能用装饰器:

@register.tag(name="current_time")
def do_current_time(parser, token):
    ...

@register.tag
def shout(parser, token):
    ...

若未输入 name 参数,像上述第二个例子一样,Django 会将函数名作为标签名。

传递模板变量给标签

虽然你能利用 token.split_contents() 将任意数量的变量传递给一个模板标签,但是解包出来的参数均是字符串文本。要将一个动态内容(一个模板变量)作为参数传递给模板标签需要额外工作。

前文的例子已经成功将当前时间转为字符串并将之返回,假设你想传入一个 DateTimeField 对象,并想用该模板标签格式化这个对象:

<p>This post was last updated at {% format_time blog_entry.date_updated "%Y-%m-%d %I:%M %p" %}.</p>

首先,token.split_contents() 会返回 3 个值:

  1. 标签名 format_time
  2. 字符串 'blog_entry.date_updated' (不包含引号)。
  3. 格式化字符串 '"%Y-%m-%d %I:%M %p"'split_contents() 的返回值会为类似这样的字符串保留引号。

现在,你的标签应该看起来像这样:

from django import template

def do_format_time(parser, token):
    try:
        # split_contents() knows not to split quoted strings.
        tag_name, date_to_be_formatted, format_string = token.split_contents()
    except ValueError:
        raise template.TemplateSyntaxError(
            "%r tag requires exactly two arguments" % token.contents.split()[0]
        )
    if not (format_string[0] == format_string[-1] and format_string[0] in ('"', "'")):
        raise template.TemplateSyntaxError(
            "%r tag's argument should be in quotes" % tag_name
        )
    return FormatTimeNode(date_to_be_formatted, format_string[1:-1])

你也需要修改 renderer,让其获取 blog_entry 对象的 date_updated 属性的真实内容。这能通过在 django.template 中使用 Variable() 完成。

要使用 Variable 类,用变量名实例化它,并调用 variable.resolve(context) 上下文。举个例子:

class FormatTimeNode(template.Node):
    def __init__(self, date_to_be_formatted, format_string):
        self.date_to_be_formatted = template.Variable(date_to_be_formatted)
        self.format_string = format_string

    def render(self, context):
        try:
            actual_date = self.date_to_be_formatted.resolve(context)
            return actual_date.strftime(self.format_string)
        except template.VariableDoesNotExist:
            return ''

变量解决方案会在无法在当前页的上下文中找到指定字符串时抛出 VariableDoesNotExist 异常。

在上下文中设置变量

上述例子输出了一个值。一般来说,如果你的模板标签设置模板变量,会比直接输出更加灵活。这样,模板作者在你的模板标签创建时能复用这些值。

要在上下文中设置变量,需要在 render() 方法中对其上下文使用字典赋值。新版的 CurrentTimeNode 设置了一个模板变量 current_time,而不是直接输出:

import datetime
from django import template

class CurrentTimeNode2(template.Node):
    def __init__(self, format_string):
        self.format_string = format_string
    def render(self, context):
        context['current_time'] = datetime.datetime.now().strftime(self.format_string)
        return ''

注意, render() 返回了空字符串。 render() 应该总是返回字符串。如果所有的模板标签都设置了变量, render() 应该返回空字符串。

下面是如何使用新版标签的实例:

{% current_time "%Y-%m-%d %I:%M %p" %}<p>The time is {{ current_time }}.</p>

上下文中的变量作用域

上下文内变量仅在模板中相同 block 内生效。这是故意的;提供有作用域的变量不会与其它区块中的上下文发生冲突。

但是, CurrentTimeNode2 有个问题:变量名 current_time 是硬编码的。这意味着你需要确认模板未在其它地方使用 {{ current_time }},因为 {% current_time %} 会绑定兵重写该变量的值。一个简洁的方法是让模板标签指定输出变量的值,像这样:

{% current_time "%Y-%m-%d %I:%M %p" as my_current_time %}
<p>The current time is {{ my_current_time }}.</p>

为此,你需要重构编译函数和 Node 类,像这样:

import re

class CurrentTimeNode3(template.Node):
    def __init__(self, format_string, var_name):
        self.format_string = format_string
        self.var_name = var_name
    def render(self, context):
        context[self.var_name] = datetime.datetime.now().strftime(self.format_string)
        return ''

def do_current_time(parser, token):
    # This version uses a regular expression to parse tag contents.
    try:
        # Splitting by None == splitting by spaces.
        tag_name, arg = token.contents.split(None, 1)
    except ValueError:
        raise template.TemplateSyntaxError(
            "%r tag requires arguments" % token.contents.split()[0]
        )
    m = re.search(r'(.*?) as (\w+)', arg)
    if not m:
        raise template.TemplateSyntaxError("%r tag had invalid arguments" % tag_name)
    format_string, var_name = m.groups()
    if not (format_string[0] == format_string[-1] and format_string[0] in ('"', "'")):
        raise template.TemplateSyntaxError(
            "%r tag's argument should be in quotes" % tag_name
        )
    return CurrentTimeNode3(format_string[1:-1], var_name)

此处的不同点是 do_current_time() 处理了格式化字符串和变量名,并将它们传递给 CurrentTimeNode3

最后,如果你的自定义上下文更新模板标签只需要简单的语法,考虑使用 simple_tag() 快捷方式,它支持将标签结果分配给模板变量。

解析直到碰到另一区块的标签

模板标签能串联工作。例如,标准标签 ttag:{% comment %} <comment> 隐藏任何东西,直到碰到 {% endcomment %}。要创建一个类似的标签,在编译函数中使用 parser.parse()

以下是如何实现一个简单的 {% comment %} 标签的介绍:

def do_comment(parser, token):
    nodelist = parser.parse(('endcomment',))
    parser.delete_first_token()
    return CommentNode()

class CommentNode(template.Node):
    def render(self, context):
        return ''

注解

{% comment %} 的实现略有不同,它允许 {% comment %}{% endcomment %} 出现破损的标记。它通过调用 parser.skip_past('endcomment') (而不是 parser.parse(('endcomment',))),后跟 parser.delete_first_token(),从而避免生成节点列表。

parser.parse() 接受一个包含 “解析直到” 区块标签的元组。它返回一个 django.template.NodeList 实例,它解析器遇到元组中的标签前解析的 Node 对象。

上文例子 "nodelist = parser.parse(('endcomment',))" 中, nodelist 是一个包含了 {% comment %}{% endcomment %} 之间所有节点的列表,但不包含 {% comment %}{% endcomment %} 本身。

在调用 parser.parse() 后,解析还未 “消费掉” {% endcomment %} 标签,所以代码需要显示地调用 parser.delete_first_token()

CommentNode.render() 返回了一个空字符串。 {% comment %}{% endcomment %} 注释之间的全部内容均被忽略。

解析直到碰到另一区块标签,并保存内容。

在前文的例子中, do_comment 抛弃了 {% comment %}{% endcomment %} 之间的所有内容。现在我们不这么做,我们要对区块标签之间的东西做点什么。

举个例子,这里有个模板标签, {% upper %},它将自己与 {% endupper %} 之间的内容全部大写。

用法:

{% upper %}This will appear in uppercase, {{ your_name }}.{% endupper %}

与之前的例子一样,我们将使用 parser.parse()。但这次,我们将生成的 nodelist 传递给 Node:

def do_upper(parser, token):
    nodelist = parser.parse(('endupper',))
    parser.delete_first_token()
    return UpperNode(nodelist)

class UpperNode(template.Node):
    def __init__(self, nodelist):
        self.nodelist = nodelist
    def render(self, context):
        output = self.nodelist.render(context)
        return output.upper()

唯一的新概念是 UpperNode.render() 中的 self.nodelist.render(context)

更多关于复杂渲染的例子,查看 django/template/defaulttags.py{% for %} 的源码,或 django/template/smartif.py{% if %} 的源码。