前言
我们知道,互联网上的HTML页面极少是完全静态的。它们都或多或少掺入一些动态数据(比如,用户名),通常一个网页会含有大量的动态数据:展示的商品,或好友的动态等等。
怎么用一种简单的方法将动态数据插入到完全静态的页面中?要解决这个问题,我们必须设计出一种前端人员熟悉的类似于HTML标记语言的方式,将动态数据插入到静态的HTML页面中。
举个例子
我们知道,用户名是要动态的,因为页面根据不同的用户呈现出不同的效果;水果的列表也要是动态的,因为水果的价格不能一成不变,它需要从数据库中获取数据,动态改变价格或种类。
为了让HTML文档在我们代码中以字符串形式显示,我们必须将需要动态处理的数据单独标记出来,以便程序进行处理。
可以看到,整个页面被放在名为PAGE_HTML的字符串中;而可以用循环显示的数据(如我们的水果清单)可以单独放在用名为PRODUCT_HTML的字符串。
在上面的代码中,我们向make_page函数传入一个字符串与一个字典,分别表示用户名与水果。通过字符串的format函数可以将HTML中的标记的动态数据替换成我们想要的数据。可以看出,程序确实可以运行。但是随之而来的一个问题是,这段代码使用Python写的,不懂Python的人就不能使用了。而且,这只是个简单的HTML就已经要写如此复杂的代码。设想一下,当要处理的HTML文档是现在的100倍的时候,工作量是该有多大。
模板(Templates)
最好的方法是,前端人员可以直接在HTML文档里面编辑,甚至使用一些简单的语句。
我们知道,Python中的字符串是通过双引号或单引号标记起来的。当解释器遇到第一个双引号的时候,它就知道“哦,这里是一个字符串”,一直直到它遇到第二个双引号的时候,表示字符串的结束。
在我们的模板中也一样,当模板引擎遇到“{{”的时候,它就知道“哦,这里会输出一个表达式”,一直直到遇到“}}”才结束。
我们从头开始看,“<p>Welcome, ”是静态的HTML内容,一直到遇到“{{”的时候,切换到动态模式,在输出的时候使用变量user_name代替。这让我们想到在Python里面,有类似的格式化函数
当然,模板还可以使用条件判断语句跟循环语句。
这些文件被称为“模板”,是因为他们使用相同的结构却能生成许多内容不同的页面。为了在我们的程序中使用HTML模板,我们需要编写一个模板引擎(template engine):接受静态的模板,通过把动态数据与其组合起来,生成一个HTML文档。我们把这称为模板的解析(interpret)——把模板动态部分换成真实的数据。
语法
模板引擎的语法不尽相同。接下来要编写的模板引擎的语法跟Django的(一个著名的Web框架)差不多。
表达式
表达式在我们的模板引擎中用两对大括号括起来。它可是是一个变量,亦或是变量调用的方法。
在Python中,我们可以用如下方法访问变量的属性或方法。
在我们编写的模板引擎中,我们统一使用”.”操作符。来获取属性,如果获取的是可执行的方法将会自动执行。
我们也能用一个叫做“过滤器(filter)”的函数,过滤我们想要的数据。过滤器通过“|”分割
条件判断语句
条件判断语句,这是必须的。
循环语句
循环能大大减少了代码量
注释
注释功能还是挺重要的
开始着手
模板引擎通常会做两件事:
分析模板(parsing the template)
渲染模板(rendering the template):
- 在模板中找出动态数据
- 处理逻辑语句(模板中的条件判断或循环语句)
- 执行点操作符和过滤器
问题的关键是我们如何把分析模板跟渲染模板连接在一起,也就是我们要通过“分析模板”这个步骤得出些什么东西——这些东西能够被渲染到HTML文件中。方法主要有两种:解析(interpretation)和编译(compilation)。
在解释型模板引擎中:“分析模板”阶段的产物是一些代表模板的数据,然后每次“渲染模板”阶段都要运重复这个过程。著名Web框架Django里面的模板引擎就是其中的代表。
在编译型模板引擎中:“分析模板”阶段的产物是一些可直接执行的代码,“渲染模板”阶段执行这些代码并得到静态的HTML文件,无须再次编译。Jinja2与Mako就是其中的代表。
在速度方面,编译型模板引擎在第一次运行模板的时候速度会比解释型模板引擎要慢,而当第二次第三次…执行的时候,编译型模板引擎会比解释型模板引擎快。这是一个典型的“空间换时间”的例子。
我们的模板是编译型模板引擎,我们把模板编译成Python代码。每次运行这些Python代码都会渲染得到静态的HTML文件。
我们的模板编译器应用到了代码生成(code generation)的技术,代码生成可以产生很多灵活而又功能强大的工具,包括但不限于程序语言编译器(programming language compilers)。代码生成可能比较复杂,但很实用。
编译成Python代码
我们看回前面的代码。在分析模板阶段,模板引擎将会把它们转变成Python函数。
模板引擎会把模板转变成Python代码,虽然转换的结果看起来有点奇怪。
每一个模板都会被转换成一个名为render_function的函数,第一个参数接收一个字典(第二个参数我们后面说)。
我们看看函数的开始
我们把字典解包出来(因为这样会使得二次访问的时候速度更快),赋给带前缀“c_”的变量(加上前缀避免了命名冲突)
我们注意到append,extend,str方法赋给了局部变量result_append,result_extend和str。
为什么要这样做?我们看得更深一些。
Python里面对象(Object)的方法(method)(如result.append(“hello”))虽然看起来好像是一步执行,其实执行分为两步。第一步,获取对象的方法result.append;第二步,传递参数”hello”。所以我们可以将第一步的结果保存起来。这样做我们可以节省一步,这些细小的优化节省了一点点时间。
to_str也是一个细小的优化。Python里面,寻找局部变量比寻找全局变量或内置的对象和函数都要快。str是一个Python内置的对象,虽然无论在那里都可以使用,但是Python每次还是会寻找str。将其赋给一个局部变量,将会节省一点点时间。
接下来,我们使用result_append,result_extend把字符串添加到列表中。
最后函数返回的是字符串。将多个部分的字符串快速转换拼接为长字符串的方法是,创建一个列表然后把他们join起来。
编写引擎
CodeBuilder类
在我们编写Template类之前。我们先看看CodeBuilder。
模板引擎主要的工作就是分析模板并产生大量的Python代码。为了方便生成大量的Python代码,我们编写了CodeBuilder类,它生成代码,管理Python的缩进。为什么要管理缩进?因为我们要生成的Python代码是字符串的形式,然后使用exec函数执行。而Python是基于缩进来定义语法的,所以我们必须有一套办法来管理Python的缩进。
接来下我们开始编写,我们先看构造函数。构造函数生成一个名为code列表,用来保存最终生产的Python代码,还有一个名为indent_level 整形变量,用来管理Python的缩进。
我们重载了__str__方法,__str__返回一个字符串,将列表code里面的代码连接成字符串。
add_line函数生成一行新的代码,它会根据当前的缩进自动进行代码的缩进和换行。
根据Python的PEP8规范,规定一个缩进等于4个空格
indent和dedent方法,分别增加与减少缩进
add_section函数,生成一个新的CodeBuilder类。将当前列表code的代码连接成字符串(自动调用str方法),放入新的CodeBuilder类中,返回新的CodeBuilder类。
get_globals函数,执行code的代码(在我们的模板引擎中,也就是定义一个函数。),返回一个名为global_namespace的字典,里面包含有刚定义的函数。
如下面的代码中,global_namespace[‘SEVENTEEN’]就是数字17,global_namespace[‘three’]就是刚定义的函数three。
到此为止,我们的CodeBuilder类就已经完成了。CodeBuilder其实跟模板引擎没多大关系,我们仅仅是通过它来生成模板渲染的函数render_function。当然利用CodeBuilder定义的函数,因为不同的命名空间,所以完全避免的名字相同带来的冲突问题。
Template类
Template类仅有少数的接口,构造函数接收类型为字符串的模板。它的render方法通过接收一个字典,进行模板的渲染。
我通过向Template的构造函数传入模板,产生一个实例,我们就完成了编译。之后我们可以多次调用render方法渲染,从而得到不同的结果。
构造函数还接收一个字典(一般来说,是一些过滤器),把它放在template类中,当我们调用render方法的时候会用到。
编译
Duang!接来下我们开始编写,我们先看构造函数。编译的主要工作都在构造函数里面,构造函数时整个Template类的重中之重。我们一点一点来。
构造函数接收一个字符串以及多个变量(这些变量可以是函数,也可以是列表,字符串等在模板里面要用到的东西),将多个变量放入内部定义的context字典中。
我们还需要一个集合来存放定义的变量。集合all_vars存放所有模板里面出现的变量,集合loop_vars存放模板循环里面(如for循环)里面出现的变量。你现在可能感到困惑,等会儿你就知道这两个小东西对我们有啥帮助了。
我们遇到了之前我们写的CodeBuilder类。我们通过add_line方法添加一行Python语句,定义一个名为render_function的函数,之前我们讲过render_function函数的第一个参数接收一个字典参数,第二个参数接收一个点操作符执行函数do_dots。
注意到CodeBuilder类十分简单,它甚至不知自己在做什么,它只会生成一行行新的代码。
我们使用了add_section方法生成一个新的CodeBuilder实例,并把之前编写的代码放入其中。这可以方便我们以后插入代码。
接下来我们定义一个flush_output函数,帮我们把buffered里面的Python语句通过CodeBuilder的add_line方法把添加到CodeBuilder实例的code列表中。
回到我们的模板,当我们处理模板的条件判断语句或循环语句的时候,我们想要确认这些语句是否正确。我们就需要一个栈。例如,当我们遇到一个{% if .. %}标签的时候。我们把“if”push进栈;当我们遇到{% endif %}的时候,我们pop栈,如果没有“if”在栈顶的话,将会报错。
现在,我们正式开始分析模板。通过正则表达式,我们把模板分成各个部分,然后放入列表tokens中。
re.split是一个用正则表达式把一个长字符串分割成几个短字符串的函数;r表示raw_string;?s说明“.”匹配任何东西,包括换行符;{{.?}},{%.?%},{#.*?#}分别匹配表达式,条件判断与循环语句,注释。
如果,有如下模板:
我们把它分割成:
一件将模板分割成上面这样的列表,我们就可以遍历tokens列表并做下一步处理了。
每个token都被检测,看符合四种情况的哪一种。我们只需要检查前两个字符就可以。第一种情况是注释,我们直接忽略。
对于{{…}},我们砍掉两对大括号,忽略前后的空格,把里面的语句提取出来。然后把它传递给_expr_code方法。
_expr_code方法会把我们模板的表达式转化成Python的表达式。我们会在后面详细说_expr_code这个方法。
第三种情况就是{% … %},首先我们运行flush_output函数通过CodeBuilder的add_line方法把buffered里面的Python语句添加到CodeBuilder实例的code列表中。
现在我们对于if,for或者end这三种情况分别作出不同的处理。
对于if的情况。if标签通常只有一个表达式,所以对于长度不符的情况,我们使用_syntax_error方法抛出一个错误。我们把“if”压进栈 ops_stack,以便我们检查endif标签。然后通过_expr_code方法,把if标签里面的表达式编译成Python可识别的代码。
第二种情况就是for,这里出现了一个新的方法_variable。还记得我们之前说过的两个用来放变量的集合吗?方法_variable的作用除了检查变量是否有非法字符外,还会将变量添加到集合中。为了避免命名冲突,我们还把变量的名字加上了“c_”的前缀。注意,for..in的in后面可能跟的是一个变量,亦或是一个可迭代的表达式(如 for i in range(10))。所以我们要使用_expr_code方法。
最后一种情况就是end,通过字符串的切片提取,与ops_stack栈顶的元素作比较,判断语句是否正确。最后注意到有一个反缩进。
对于不可识别的,我们通过_syntax_error方法抛出错误
这样我们就完成了模板里面的三种不同的语法{{…}} , {#…#} 和 {%…%} 的处理了。最后剩下普通的字符串。我们把它添加到buffered里面以便输出。repr函数与str函数类似,但是它是将对象转换成Python内部的字符串,而str是将对象转换成用户可读的友好的字符串。注意到我们的条件判断语句,主要就检测空的字符串,因为我们必须防止append_result(“”)这样无用的操作。
最后,我们还需要检查一下ops_stack是否为空。当我们的语句都是合法的,有始有终的时候,ops_stack的值应为空的。如果不为空,我们已经在某处丢掉了end标签了。检查完之后,我们将调用flush_output函数,通过CodeBuilder的add_line方法把buffered里面的Python语句添加到CodeBuilder实例的code列表中
我们来看看一个模板
在模板中user_name与product是两个变量,因为他们在两对大括号之间。集合all_vars里面也有他们的名字,因为方法_expr_code将它们添加到集合allvars里面。但只有user
name需要从模板中提取出来,因为product是在循环里面定义的变量。
在模板里面的所有变量,都会保存在名为allvars的集合里面;而所有在模板的语句里面定义的变量,都会保存在名为loop
vars的集合里面。所以我们需要把在all_vars集合 而不在loopvars集合里面的变量找出来。把context里面的变量解包出来,放入加上“c”前缀的同名变量中。
最后。我们把CodeBuilder的属性code代码都连接起来。使用get_globals方法,执行code的代码(在我们的模板引擎中,也就是定义一个函数。(def render_function(..):)),返回一个名为global_namespace的字典,里面包含有刚定义的函数。
现在self._render_function就是一个Python函数了,我们将会在模板的渲染阶段用到这个函数。
编译表达式
上面我们只介绍了模板中的变量与语句,还有一个很重要的方法_expr_code。_expr_code将模板中的表达式编译成Python中的表达式。接下来我们来编写上面一直提到的_expr_code方法。
在我们编写的模板中,表达式可以是单独一个变量。
也可是包含属性方法和过滤器的复杂形式。
_expr_code方法必须能处理所有的情况。
第一考虑的是我们的表达式中是否存在“|”,如果存在,我们把它分隔开来,放在列表pipes 中。把“|”分离之后,pipes[0]即为变量与”.“操作符,我们对其继续用_expr_code方法。pipes的其他项为过滤器函数的名字,我们把它逐个放入all_vars集合中。然后生成一条“函数链”
如{{user.name.localized|upper|escape}},运行之后就得到code=c_escape(c_upper(user.name.localized))
对于表达式中存在的“.”。首先我们要理解“.”操作符是如何操作的,在模板中x.y在Python中有两种意思:x[‘y’]或者x.y(表示哪种意思取决于x[‘y’]或者x.y哪种是可行的)。如果结果是可执行的,自动执行。这种不确定性表明了,我们只能在运行的时候尝试这些可能性,而不是在编译的时候。所以我们把x.y.z编译成一个调用的函数do_dots(x, ‘y’, ‘z’)
do_dots 函数会在编译完成的Python代码运行的时候传递进去。我们后面会详细讲述如何编写这个函数。
_expr_code方法的最后一部分,没有“|”,没有“.”的表达式。注意的是,传进all_vars集合的只是名字而已。
辅助方法
抛出一个异常
检查变量是否有非法字符,将变量添加到集合中。
渲染
当我们把模板编译成Python函数之后。渲染函数要做的是,处理动态的数据,然后调用生成的Python函数。注意到,这里的self.context是一个包含需要用来渲染模板的动态数据和过滤器函数的字典。我们在Template类的构造函数里面已经update过,一般来说我们在Template类的构造函数里update的是过滤器的函数;在方法render里面update的是用来渲染模板的动态数据。因为创建了一个Template实例出来就说明编译完成。调用render,通过传入不同的context实现不同的渲染。
接下来是最后一个方法_do_dots。在编译阶段,模板表达式如x.y.z被编译成do_dots(x, ‘y’, ‘z’) 。首先把dot当做attribute,如果失败,当做key;如果可以被调用,调用它。
注意到每次调用self._render_function方法的时候,我们都传进去一个函数用来执行点表达式。但是很多时候我们传进去的函数都一样,我们还可以把这部分代码变成编译模板的一部分。当然,这是后面要讨论的东西了。
后记
到此为止,我们的简单模板引擎就已经完工了。如果有兴趣的话你还可以为这个模板添加如下功能。
- 模板继承与包含
- 自定义标签
- 自动转义
- 带参数的过滤器
- 更加复杂的条件语句,如else和elif
- 多个循环嵌套
- 空格控制