Hardog's blog

trace forever

Group: 572218159
Email: 1273203953@qq.com
Location: hangzhou·zhejiang
GitHub: https://github.com/hardog

概要

通过通读ejs(v2.4.1)源代码, 和大家分享下ejs实现思路, 本文期望通过提问的形式来阐述ejs中的一些重要部分, 有欠妥的地方望批评指正.

ejs实现核心思路

ejs首先将模板内容转化成一行一行的内容, 保存在__output数组中, 而该数组也是字符串,这样便于执行模板中js与html混合部分代码, 如下所示:

模板代码:

1
<% [1,2].forEach(function(v){ %>
<span>num is: <%= v %>
<% }) %>

ejs解析后的部分可执行代码如下(其中_append_outputpush函数等同):

1
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主要提供了如下几种指令:

关于以上各个指令对应的解析, 可参考ejs源码根目录lib/ejs.js文件中的scanLine函数.

关于ejs的几个关键函数

以下通过解析ejs中的几个重要函数来了解ejs的解析过程.

#compile

该函数主要负责生成模板编译函数, 其主要的两部分代码如下所示

该部分通过函数generateSource生成源代码字符串, 然后加上源代码字符串前缀部分prepended, 源代码后缀部分appended来生成整个源代码字符串, 源代码字符串中最后通过__output.join(";")作为函数的返回值:

1
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指令.

1
fn = new Function(opts.localsName + ', escape, include, rethrow', src);

#generateSource

顾名思义该函数就是生成模板字符串对应的字符串源代码, 其主要通过函数parseTemplateText将模板内容按行解析到数组matches中, 然后对matches进行循环, 同时借助scanLine函数对每行模板字符串内容进行解析, 解析后的内容保存在__output数组中.

#parseTemplateText

该函数主要通过匹配规则_REGEX_STRING = (<%%|<%=|<%-|<%_|<%#|<%|%>|-%>|_%>)并借助exec正则循环匹配出指定模板字符串内容, 例如如下的解析示例:

1
<h1>head</h1>
<% var a = 1; %>
<h1>trail</h1>

通过匹配<%匹配成如下数组形式:

1
arr = [
    '<h1>head</h1>',
    '<%',
    ' var a = 1; ',
    '%>'
    '<h1>trail</h1>'
];

#scanLine

该函数应该算是编译过程中最重要的函数了, 该函数基于parseTemplateText将模板字符串转换成数组, 其主要根据数组中的每一项是否包含ejs中上述所讲的五种指令转换成对应的源代码字符串, 例如<%=指令, 遇到该指令时将this.mode设置为Template.modes.ESCAPED, 遇到结束指令%>时则重置this.modenull,<%=%>中间出现的字符串将通过escape编码并通过__append函数保存字符串内容, 当然这里省略了ejs中对于空白、回车、换行符的处理, 这里主要分析ejs中比较重要的部分.

paramlocals.param异同

经常在使用ejs模板的时候发现传入的参数param既可单独的使用也可以通过locals访问, 那到底这两者有什么区别呢? 这里的关键就在于with语句, 该语句可以帮助我们省略输入对象名称从而直接访问对象的属性, 如对于对象var v = {a: 1, b:2}, 使用with(v)时可以直接使用属性a,b而不必v.a, v.b这样访问. 但是如果访问了不在v对象中的属性就会抛出异常而这里如果通过locals.[属性名称]形式访问则不会抛出异常, 因为ejs中默认将locals作为参数传入这样即使访问了不存在的对象属性最多该值为undefined却不会抛出异常, 可通过如下例子来理解下:

1
定义函数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对外暴露了两个主要函数分别是renderrenderFile, 其主要区别是render传入模板字符串内容而renderFile传入模板字符串文件路径, 这样对于第三方库(如koa-ejs)的封装提供了更多的选择. 此外ejs中提供了简单的模板缓存功能, 通过handleCache函数缓存模板的编译函数.

最后对于ejs的代码注释部分详见lib/ejs.js部分注释;