概要
通过通读ejs
(v2.4.1)源代码, 和大家分享下ejs
实现思路, 本文期望通过提问的形式来阐述ejs
中的一些重要部分, 有欠妥的地方望批评指正.
ejs实现核心思路
ejs首先将模板内容转化成一行一行的内容, 保存在__output
数组中, 而该数组也是字符串,这样便于执行模板中js与html混合部分代码, 如下所示:
模板代码:
<% [1,2].forEach(function(v){ %>
<span>num is: <%= v %>
<% }) %>
ejs解析后的部分可执行代码如下(其中_append
与_output
的push
函数等同):
try {
var __output = [], __append = __output.push.bind(__output);
with (locals || {}) {
; [1,2].forEach(function(v){
; __append("\n<span>num is: ")
; __line = 2
; __append(escape(v))
; __append("\n")
; __line = 3
; })
; __append("\n\n")
; __line = 5
}
return __output.join("");
} catch (e) {
rethrow(e, __lines, __filename, __line);
}
最后生成的是源代码字符串, 这些字符串通过new Function([变量], src)
生成可执行函数fn
, 最后传入参数data
即执行fn(data)
函数生成最终编译后内容.
关于ejs模板的五种模式对应几种指令
ejs
主要提供了如下几种指令:
-
<%
, 该指令主要通过js中的eval
来执行js代码, 如上模板代码<% [1,2].forEach(function(v){ %>
将通过eval
编译成; [1,2].forEach(function(v){
即直接可执行的js代码, 并且不会存放到__output
函数中输出. -
<%=
, 该指令主要用于输出变量内容, 如上模板代码<%= v %>
将通过escape
函数编译成__append(escape(v))
, 可以看到该指令用于输出变量内容, 最后将通过__output
输出内容. -
<%-
, 该指令与<%=
区别是,<%=
指令使用escape
函数来对特殊字符进行编码, 如将>
转为%3E
, 查看关于escape函数. -
<%#
, 该指令主要用于模板内注释, 既不会执行也不会输出. -
<%%
, 主要用于输出字面值%
.
关于以上各个指令对应的解析, 可参考ejs
源码根目录lib/ejs.js
文件中的scanLine
函数.
关于ejs的几个关键函数
以下通过解析ejs
中的几个重要函数来了解ejs
的解析过程.
#compile
该函数主要负责生成模板编译函数, 其主要的两部分代码如下所示
- 源代码生成组装部分
该部分通过函数generateSource
生成源代码字符串, 然后加上源代码字符串前缀部分prepended
, 源代码后缀部分appended
来生成整个源代码字符串, 源代码字符串中最后通过__output.join(";")
作为函数的返回值:
if (!this.source) {
this.generateSource();
prepended += ' var __output = [], __append = __output.push.bind(__output);' + '\n';
if (opts._with !== false) {
prepended += ' with (' + opts.localsName + ' || {}) {' + '\n';
appended += ' }' + '\n';
}
appended += ' return __output.join("");' + '\n';
this.source = prepended + this.source + appended;
}
- 源代码字符串生成函数过程
以上生成的只是源代码的字符串, 而该部分即是通过Function
函数将源代码字符串变成可执行函数fn
, 该函数也是整个compile
的返回值, 当然最后对该函数通过函数returnedFn
包装了, 主要是为了递归解析模板字符串中的include
指令.
fn = new Function(opts.localsName + ', escape, include, rethrow', src);
#generateSource
顾名思义该函数就是生成模板字符串对应的字符串源代码, 其主要通过函数parseTemplateText
将模板内容按行解析到数组matches
中, 然后对matches
进行循环, 同时借助scanLine
函数对每行模板字符串内容进行解析, 解析后的内容保存在__output
数组中.
#parseTemplateText
该函数主要通过匹配规则_REGEX_STRING = (<%%|<%=|<%-|<%_|<%#|<%|%>|-%>|_%>)
并借助exec
正则循环匹配出指定模板字符串内容, 例如如下的解析示例:
<h1>head</h1>
<% var a = 1; %>
<h1>trail</h1>
通过匹配<%
匹配成如下数组形式:
arr = [
'<h1>head</h1>',
'<%',
' var a = 1; ',
'%>'
'<h1>trail</h1>'
];
#scanLine
该函数应该算是编译过程中最重要的函数了, 该函数基于parseTemplateText
将模板字符串转换成数组, 其主要根据数组中的每一项是否包含ejs
中上述所讲的五种指令转换成对应的源代码字符串, 例如<%=
指令, 遇到该指令时将this.mode
设置为Template.modes.ESCAPED
, 遇到结束指令%>
时则重置this.mode
为null
,<%=
与%>
中间出现的字符串将通过escape
编码并通过__append
函数保存字符串内容, 当然这里省略了ejs
中对于空白、回车、换行符的处理, 这里主要分析ejs
中比较重要的部分.
[param]与locals.[param]异同
经常在使用ejs
模板的时候发现传入的参数param
既可单独的使用也可以通过locals
访问, 那到底这两者有什么区别呢? 这里的关键就在于with
语句, 该语句可以帮助我们省略输入对象名称从而直接访问对象的属性, 如对于对象var v = {a: 1, b:2}
, 使用with(v)
时可以直接使用属性a,b
而不必v.a, v.b
这样访问. 但是如果访问了不在v
对象中的属性就会抛出异常而这里如果通过locals.[属性名称]
形式访问则不会抛出异常, 因为ejs
中默认将locals
作为参数传入这样即使访问了不存在的对象属性最多该值为undefined
却不会抛出异常, 可通过如下例子来理解下:
定义函数a:
function example(locals){
with(locals){
console.log(a);
// c不存在, 此处抛出ReferenceError异常
console.log(c);
// c不存在, 但是locals是一个对象, 此处输出undefined
console.log(locals.c);
}
};
传入参数并访问a:
data = {
a: 1,
b: 2
};
example(data);
关于ejs其它方面
ejs
对外暴露了两个主要函数分别是render
及renderFile
, 其主要区别是render
传入模板字符串内容而renderFile
传入模板字符串文件路径, 这样对于第三方库(如koa-ejs
)的封装提供了更多的选择. 此外ejs
中提供了简单的模板缓存功能, 通过handleCache
函数缓存模板的编译函数.
最后对于ejs
的代码注释部分详见lib/ejs.js部分注释
;
原文链接:浅谈ejs模板解析