手把手带逛11ty第三集:Nunjucks

Eleventy 草稿

what is nunjucks

上一讲说到了eleventy支持模板,而模板文件其实就是模板化的HTML文档。有点类似于JSP或者Razor page。eleventy作为一个框架,其实支持很多种模板引擎,只不过最常见的是一种叫Nunjucks的模板(引擎)。这个模板语言是由Mozilla搞出来的,被广泛应用在Firefox的应用市场和Mozilla的一些产品上,比如Mozilla Webmarker。

不过说实话,不是很流行,是一个比较偏门的模板语言,所幸呢,这个模板语言的语法还是比较简单的,只需要花几个小时就可以完全掌握。。这玩意毕竟不是编程语言,难度有限。今天这一讲主要过一下这个模板语言,或者说模板引擎支持的语法。

在具体说语法之前,提一下,作为一种,虽然很简单,但至少也算是个“独立”的模板语言,nunjucks是有自己的编辑器扩展插件的,visual studio的扩展插件叫vscode-nunjucks,应用市场搜就能搜出来,装上之后能提升编码效率。prettier也有对应的插件可以使用。

总之怎么说呢,给人一种:你说它小众吧,该有的都有,你说他流行吧,确实之前没怎么听说过的感觉。

脱离开eleventy,怎么使用nunjucks

nunjucks是一门独立的模板语言,它可以脱离开eleventy框架来单独使用,有两种使用方式

在node环境中使用nunjucks

项目添加依赖npm install nunjucks,然后下面是一段示例代码:

njk = require("nunjucks");

njk.configure({autoescape: true});
renderResult = njk.renderString('Hello {{ username }}', {username: "James"});

console.log(renderResult);

运行结果是"Hello James"。上面的代码是使用了renderString这个API来使用nunjucks引擎的,不过鉴于我们的目的只是"了解nunjucks模板语言的语法,从而在eleventy框架中使用它",我们就不过多涉及这部分的内容了。有兴趣的可以去官方文档的API页面去查手册,手册也很简短,几个小时就能摸清楚。

不过,虽然直接使用nunjucks引擎不是我们的重点,但还是有一点点知识是需要了解的,看下面的代码例子:

njk = require("nunjucks");

njk.configure({autoescape: true});

let template = njk.compile('<h1>hello {{ username }}</h1>');
let context = {username: "Bond"};
renderResult = template.render(context);

console.log(renderResult);

这个例子将nunjuck引擎的工作流程展示了出来:首先,用户编写的njk文件内容会被送到一个神秘的地方去处理,然后生一个所谓的“编译结果”:一个JS对象。也就是上面代码中的template变量所引用的对象。

template是结构化的模板数据,而要获得渲染结果,我们还需要提供一个“上下文”:去告诉引擎,模板中引用的变量的值都是什么,也就是上面代码中的context变量所引用的对象。

最终,templatecontext通过方法render结合在一起,给出我们一个渲染结果。

(说实话,我真的有点讨厌前端领域在滥用“编译”这个术语,这问题不光出现在中文前端领域,其实在英文前端领域,大家也在滥用"compile"这个词)

在浏览器环境中使用nunjucks

简单,HTML文档中引用https://mozilla.github.io/nunjucks/files/nunjucks.js就可以,然后你就发挥想象力,为所欲为就行。不过,在浏览器环境中使用nunjucks的话,请:

谨防注入!!

nunjucks的引擎实现是比较简单的,没有做什么所谓的沙盒运行时,也没有做过多的安全检查,引擎的工作方式非常简单直接:你给我*.njk文件,我给你吐出*.html文件。所以,它天然是不能防注入攻击的。谨记这一点,不要瞎搞。

nunjucks的语法

表达式与过滤器

你可以在双大括号中输入几乎任何合法的JS表达式,然后它的值会被求作渲染结果。如果值是一个对象的话,那么tostring()就会被作为渲染结果。如下所示

{{ user }}
{{ user.firstName }}
{{ user["secondName"]}}
{{ "Mr." + user["secondName"] }}

另外,如果context中定义了函数的话,函数调用也是符合直觉的表达式

{{ foo(1, 2) }}

这些都是JS中的内容,可以简单的总结为:几乎所有符合直觉的JS表达式都可以写在{{}}中。而我这里说的是几乎所有,是因为有一种表达式nunjucks不支持:三元运算符写出来的表达式,比如:{{ foo == bar ? "foo equals to bar" : "foo not equals to bar"}}。作为补充,nunjucks有自己的一种特殊的三元运算符表达式写法,这里先不提,我们后面会说到。

然而nunjucks有一种特殊的“函数”,它叫“过滤器”,filter。它的调用语法有两种:一种是简易写法,一种是tag写法。后一种写法我们会在后面介绍,这里先介绍使用最广泛的简易写法

  • 设计上,是向bash的管道语法靠拢:使用|将表达式的值传递给过滤器函数
  • 过滤器函数还可以有额外的参数,额外的参数使用括号正常传递参数值

如下:

{{ foo | replace("foo", "bar") | capitalize }}

上面就是管道风格的过滤器函数调用,但有些filter比较特殊,比如上面的replace:除了管道传递过来的值外,还接受额外的参数,额外的参数就是以括号形式正常传递的。

普通的Tag

Tag是nunjucks模板的核心功能,它实现了程序设计语言中的分支判断、循环、跳转和一些其它功能。整体上,Tag的语法满足以下的形式:

{% tag xxx %}
    ...
    ...
    ...
{% endtag %}

最简单的Tag:在模板中就地补充context

一般来说,引擎是将“模板”和“context”这两个东西分开的,set这个tag就可以在模板中就地“创建”一个变量,你更应当将它理解为就地给context中的变量进行覆盖或者补充

{% set username = "joe" %}
{{ username }}

还有一点需要注意,在一些特殊的tag体内使用set的话,这个set的作用域一般也被限定在那个上级tag的体内。这些特殊的tag有两个:includemarco,先记得这个知识点,后面我们会介绍到这两个特殊的tag。

另外还可以将另外一个文件的内容整体读出来赋给一个变量

分支判断与循环

非常直观,没什么可多说的

单分支判断

{% if variable %}
    It is true
{% endif %}

多分支判断

{% if hungry %}
    I am hungry
{% elif tired %}
    I am tired
{% else %}
    I am good!
{% endif %}

复合条件

{% if happy and hungry %}
  I am happy *and* hungry; both are true.
{% endif %}

{% if happy or hungry %}
  I am either happy *or* hungry; one or the other is true.
{% endif %}

普通循环

{% for item in items %}
  <li>{{ item.title }}</li>
{% endfor %}

普通循环+空指针判断

这个语法糖比较有意思,它在for循环后再加个else语句,用来表达“当for循环所应用的集合本身为空时”应当执行的逻辑

{% for item in items %}
  <li>{{ item.title }}</li>
{% else %}
  <li>This would display if the 'item' collection were empty</li>
{% endfor %}

遍历对象中的属性

与JS的设计逻辑一样,循环也可以将一个对象视为Map来遍历它其中的键值对。假设我们有以下的context

var food = {
  'ketchup': '5 tbsp',
  'mustard': '1 tbsp',
  'pickle': '0 tbsp'
};

我们就可以把对象中的字段名称为ingredient,而将字段值称为amount来遍历上面这个对象,如下

{% for ingredient, amount in food %}
  Use {{ amount }} of {{ ingredient }}
{% endfor %}

渲染结果是:

  Use 5 tbsp of ketchup

  Use 1 tbsp of mustard

  Use 0 tbsp of pickle

“受益”于JS混乱又迷惑的类型体系,nunjucks引擎还支持通过for关键字来遍历Map对象,以及unpack数组中的元素。不过我不建议你深究这些迷乱的用法,让for专注于循环吧。

在循环体中可以通过特殊的loop对象来访问更多的循环信息

引擎没有提供传统的for(int i = 0; i < 10; ++i)这种循环,引擎仅提供了for .. in这一种风格,不过,你可以通过一个神奇的loop变量,在循环体内访问一些额外信息

属性 语义
loop.index 当前元素的脚标,但脚标是从1起算的
loop.index0 同上,但脚标是从0起算的
loop.revindex 反向脚标,但脚标是从1起算的
loop.revindex0 同上,但脚标是从0起算的
loop.first 一个布尔值,代表“当前循环是否是第一个循环,当前元素是否是第一个元素”
loop.last 一个布尔值,代表“当前循环是否是最后一个循环,当前元素是否是最后一个元素”
loop.length 循环集合的容量

额外知识点:用作条件判断的布尔表达式

我们上面讲了,几乎所有有效的JS表达式都能放在{{}}中进行求值渲染。对于布尔表达式,JS沿用了C语言的复合操作符设计:即用&&, ||, !来表示“且”, “或”, “非”。

不过在ifeliftag里,书写分支判断条件时,用的却是另外一套“操作符”:and, ornot。这一点需要特别注意,如下所示:

{% if users and showUsers %}...{% endif %}
{% if i == 0 and not hideFirst %}...{% endif %}
{% if (x < 5 or y < 5) and foo %}...{% endif %}

额外知识点:三元表达式

我们上面说了,虽然几乎所有合法的JS表达式都能放在{{}}中,但三元运算符写出来的表达式除外。nunjucks有自己独立的三元表达式语法,如下:

{{ "true" if foo else "false" }}

你可以将它看作是JS三元表达式的平替,所以它的求值结果可以传递给函数(当然前提是这个函数定义在context中),如下:

{{ baz(foo if foo else "default") }}

与JS中原生的三元表达式不同:else分支是可以被省略的

{{ "true" if foo }}

宏的概念和C语言中的宏十分相似:长得像函数,但实际上引擎是用复制粘贴的方式处理它的

{% macro field(name, value='', type='text') %}
<div class="field">
  <input type="{{ type }}" name="{{ name }}"
         value="{{ value | escape }}" />
</div>
{% endmacro %}

有了以上定义后,你就可以如下复用

{{ field('user') }}
{{ field('pass', type='password') }}

从上面的例子你也能看出来,nunjucks中的宏还支持参数默认值

引用

include是一个类似于C语言中#include的tag,但与C语言不同的是,在nunjucks引擎里,是把include的文件先进行独立渲染,然后把渲染结果就地引用的。而不是先引用,再渲染。

{% for item in items %}
{% include "item.html" %}
{% endfor %}

不过还是要正确理解这个“先渲染,再引用”:虽然渲染是独立的,但外部可以给include的文件进行context补充,比如上面的例子,外部调用现场先补充了item变量的定义,这样即使item.html是独立渲染的,也是可以访问到item变量的。

默认情况下,被引用的文件如果缺失,引擎就会报错,如果想让引擎别报错,可以在后面添加ignore missing,如下:

{% include "missing.html" ignore missing %}

导入

上面我们说了setmarco这两个Tag:一个用来补充context,一个用来定义宏。默认情况下,这些补充的变量定义与宏定义,都是“可以被其它文件使用”的,即"exported"

而其它文件要使用,就先要使用import这个tag来将目标文件进行“导入”。导入与引用的区别是:导入import使用的是目标模板文件的宏定义与变量定义,而引用include使用的是目标模板文件的渲染结果。

比如我们有一个form.njk,如下定义:

{% macro field(name, value='', type='text') %}
<div class="field">
  <input type="{{ type }}" name="{{ name }}"
         value="{{ value | escape }}" />
</div>
{% endmacro %}

{% macro label(text) %}
<div>
  <label>{{ text }}</label>
</div>
{% endmacro %}

我们在它里面定义了两个宏:fieldlabel,那么我们就可以在另外 一个模板文件中如下引用这两个宏:

{% import "forms.html" as forms %}

{{ forms.label('Username') }}
{{ forms.field('user') }}
{{ forms.label('Password') }}
{{ forms.field('pass', type='password') }}

上面的用法中,是把整个form.njk中exported的宏和变量,包装在as forms里。还可以直接将exported的宏和变量直接导入成独立的变量,如下:

{% from "forms.html" import field, label as description %}

{{ description('Username') }}
{{ field('user') }}
{{ description('Password') }}
{{ field('pass', type='password') }}

rawverbatim

这两个tag是完全等价的,verbatim是早期起的名字,后来因为太抽象不够直观被deprecate掉了,但依然可用。

这俩tag的效果就是“完全原样的输出内部内容”

显式调用过滤器与宏

过滤器的直接用法是|,宏的直接用法是括号传参。但如果有时你想将一些复杂内容传递给过滤器或宏时,简写的直接调用就有点力不从心,你就可以用如下的方式将复杂内容传递给过滤器和宏:过滤器用filter,宏用call

以下写法会把filter内部的渲染内容传递给titlereplace过滤器

{% filter title %}
may the force be with you
{% endfilter %}

{% filter replace("force", "forth") %}
may the force be with you
{% endfilter %}

call则稍微有点不同:除了本身宏定义的参数列表,宏还可以通过call将一大坨复杂内容传递到宏内部,而宏内部可以通过caller()宏来访问这些内容,听起来有些绕,看一眼就明白了:

{% macro add(x, y) %}
{{ caller() }}: {{ x + y }}
{% endmacro%}

{% call add(1, 2) -%}
The result is
{%- endcall %}

上面先定义了宏add,然后下面用call来调用这个add。在add的定义内部,它使用了caller()来访问传递进来的“复杂内容”。。怎么说呢,这个设计还是有些迷惑性的,谁想得到caller()取的只是复杂内容,而不是调用者的元数据呢?

模板的继承,与两个特殊的Tag: extendblock

上面提到了includeimport,这俩玩意某些程度上可以实现面向对象设计中的“包含”。除此外,nunjucks专门设计了一套“继承”。这个说起来太干巴,我们拿个例子来说明:

比如我们有一个parent.njk,当“父类”

{% block header %}
This is the default content
{% endblock %}

<section class="left">
  {% block left %}{% endblock %}
</section>

<section class="right">
  {% block right %}
  This is more content
  {% endblock %}
</section>

再有个child.njk,当“子类”

{% extends "parent.html" %}

{% block left %}
This is the left side!
{% endblock %}

{% block right %}
This is the right side!
{% endblock %}

最终child.njk的渲染结果就是:

This is the default content

<section class="left">
  This is the left side!
</section>

<section class="right">
  This is the right side!
</section>

这看起来很奇怪,但铭记如下规则就可以豁然开朗:

  • block这个关键字,在父类中,代表定义一个“抽象方法”:它可以有默认实现,就写在父类中。子类也可以override它
  • block这个关键字,在子类中,代表覆盖一个“抽象方法”
  • extend关键字就是继承关键字:在子类中使用,用来指明要继承的父类是谁

另外,在子类覆盖抽象方法时,可以通过super()来调用父类的默认实现,如下:

{% block right %}
{{ super() }}
Right side!
{% endblock %}

上面这个代码块的渲染结果如下:

This is more content
Right side!

nunjucks中的可调用物

到现在为止,nunjucks中的可调用物,总共有三种:

  1. 定义在context中的JS函数,使用括号风格调用,调用语句可视为一个JS表达式,放在{{}}里面求值
  2. 引擎自带的过滤器函数,使用|管道式调用,或使用filtertag进行调用。使用管道式调用时,可视为是一个表达式,可放在{{}}里面求值渲染
  3. 宏,引擎无自带,可导出,可使用括号式调用,括号式调用可视为表达式放在{{}}里面求值渲染。也可以使用calltag进行调用。

关于JS函数,其实引擎也附带了三个JS函数可供开箱使用,它们分别是:range, cyclerjoiner。这里我们先介绍这三个特殊的函数,另外再介绍一下引擎携带的过滤器函数。

nunjucks引擎自带的全局函数

range([start], stop, [step])

用来生成一个序列集合,比如如下的代码渲染结果就是0,1,2,3,4

{% for i in range(0, 5) -%}
  {{ i }},
{%- endfor %}

cycler(item1, item2, ...itemN)

生成一个特殊的对象,用迭代器的方式循环访问参数表里的值。比如下面这个例子:

{% set cls = cycler("odd", "even") %}
{% for row in rows %}
  <div class="{{ cls.next() }}">{{ row.name }}</div>
{% endfor %}

"odd", "even"两个字符串通过cycler函数生成了一个对象叫cls,这个cls可以看做是["odd", "even"]的迭代器,初始时指向第一个元素,即是"odd",然后调用cls.current()方法访问当前指向的对象,调用cls.next()访问下一个对象,并将迭代器+1。

最重要的是,这个迭代器是循环式的,当访问到最后一个对象时再调用cls.next(),迭代器又会返回指向第一个对象,即"odd"

joiner([sep])

joiner的返回值是一个函数,这个函数特别有意思:第一次调用这个函数时,它返回一个空字符串(或者可以理解为什么都不返回),而后续每一次调用它,它都返回当初你调用joiner时传递的那个sep参数。sep的默认值是一个逗号,即","。这么说你可能有点迷惑,这有什么用?你看下面的例子就知道了:

{% set comma = joiner() %}
{% for tag in tags -%}
  {{ comma() }} {{ tag }}
{%- endfor %}

如果tags的值是["food", "beer", "dessert"]的话,上面的渲染结果就是food, beer, dessert

它在渲染列表集合的时候特别有用,因为多数情况下我们都只希望列表项之前的分隔符只在第2个以及后续的列表项之前存在。

nunjucks引擎内置过滤器函数列表

nunjucks语言本身要求引擎支持一些过滤器函数,这些过滤器函数是由“语言标准”规定的,所以一定能用。另外除过这些过滤器函数外,你所使用的框架,比如eleventy,可以还扩展实现了一些额外的过滤器函数,这些函数就需要你自己去查阅相关的框架文档了。内置的过滤器函数数目比较有限,列表如下:

函数名 功能 额外参数说明 示例 示例的渲染结果
abs 求绝对值 {{ -3 | abs}} 3
batch(num) 将一个集合里的元素,每num个以拼接的形式合并在一起,形成一个新的集合 数值 {% for item in [1,2,3,4,5,6] | batch(2) %}{{"[" + item.toString() + "]"}}{% endfor %} [1,2][3,4][5,6]
capitalize 首字母大写 {{ "this is a test" | capitalize}} "This is a test"
center(length) 返回一个长度为length的新字符串,新字符串首尾由空格填充,将原字符串居中 数值 {{"[" + "xxx" | center(20) + "]"}} [ xxx ]
default(default, [falsyAsUdf]) 对管道对象可能出现的空值做处理 第二个可选参数是boolean类型,其默认值为false
falsyAsUdf == false, 也就是默认情况下:如果管道传入的值严格的等于undefined,则函数返回default,否则返回管道值本身。
falsyAsUdf == true时,只要管道值在JS里算是“假”,函数就会返回default,否则才会返回管道值本身
1. {{ undefined | default("it is undefined", false) }}
2. {{ null | default("it is undefined", false) }}
3. {{ false | default("it is undefined", false) }}
4. {{ undefined | default("it is falsy", true) }}
5. {{ null | default("it is falsy", true) }}
6. {{ false | default("it is falsy", true) }}
1. it is undefined
2.
3. false
4. it is falsy
5. it is falsy
6. it is falsy
dictsort 将一个对象或Map里的键值对进行排序(按键进行字典排序)。返回的是原对象的一个排序后的副本 {% for item in {"d":4, "a":1, "e":5, "b":2, "c":3} | dictsort %} {{item}} {% endfor %} a,1 b,2 c,3 d,4 e,5
dump([tabSize]) 内部调用JSON.stringify,并把结果中的引号等符号进行转义,以确保能原样显示在HTML文档中
这玩意常用于开发过程中要debug的时候,可以在页面上临时显示对象的值
相当于调用C语言时使用的printf
可选,代表输出的JSON字符串的缩进宽度,或缩进字符。默认不换行也不缩进,当传入数值时,代表缩进的空格数,当传入字符串时,会把传入的字符串作为缩进字符去使用 {{ ["a", 1, {b: true}] | dump }} [&quot;a&quot;,1,{&quot;b&quot;:true}]
escape 将字符串进行转义,确保字符串能原样显示在HTML页面上 {{ "<h1>xxx</h1>" | escape }} &lt;h1&gt;xxx&lt;/h1&gt;
first 获取集合中的第一个元素,或者字符串中的第一个字符 {{ [1,2,3] | first + "idiots" | first }} 1i
float([default=0.0]) 将管道对象先toString,再试图将结果转化成一个浮点数。如果转化失败,则返回0.0,或指定的值 可选,表示转化失败后要返回的默认值。默认情况下是0.0 {{ "35.67" | float + "one point seven" | float(1.7) }} 37.370000000000005
forceescape 行为与escape类似,在某些情况下它可能会多添加双引号 {{ "<h1>xxx</h1>" | forceescape }} &lt;h1&gt;xxx&lt;/h1&gt;
group(propertyName) 将集合中的元素按指定的key聚合起来,形成一个新的集合 聚合时使用的key {% set items = [
{ name: 'james', type: 'green' },
{ name: 'john', type: 'blue' },
{ name: 'jim', type: 'blue' },
{ name: 'jessie', type: 'green' }]
%}
{{ items | groupby("type") | dump(2) }}
{
&quot;green&quot;: \[
{
&quot;name&quot;: &quot;james&quot;,
&quot;type&quot;: &quot;green&quot;
},
{
&quot;name&quot;: &quot;jessie&quot;,
&quot;type&quot;: &quot;green&quot;
}
\],
&quot;blue&quot;: \[
{
&quot;name&quot;: &quot;john&quot;,
&quot;type&quot;: &quot;blue&quot;
},
{
&quot;name&quot;: &quot;jim&quot;,
&quot;type&quot;: &quot;blue&quot;
}
\]
}
indent([size=4], [firstLine=false]) 将包含换行符的字符串进行缩进 size代表缩进宽度,默认为4
firstLine代表是否缩进首行,默认为false
{{ "abc\nabc\nabc" | indent(2, true) }} abc
abc
abc
int([default=0]) 将管道对象先toString,再试图将结果转化成一个整数。如果转化失败,则返回0,或指定的值 可选,表示转化失败后要返回的默认值。默认情况下是0 {{ "35.67" | int + "seventeen" | int(17) }} 52
join([sep=""], [property]) 将集合中的元素都concat起来变成一个大字符串 sep表示元素与元素之间的连接字符,默认为空字符串
property指,如果元素是对象的话,要取它哪个属性进行字符串化,默认是在元素本身上执行toString()
{{ [1,2,3] | join }}
{{ [1,2,3] | join("-") }}
{{ [{v:1}, {v:2}, {v:3}] | join(",", "v") }}
123
1-2-3
1,2,3
last 获取集合中的最后一个元素,或者字符串中的最后一个字符 {{ [1,2,3] | last + "idiots" | last }} 3s