ejs是一个轻量的模板渲染引擎,通过简单的<%%>标签就能实现简单的模板渲染。本文仅针对<%%>和<%=%>两个常用标签进行分析。
入口代码,api保持了与ejs完全一致
const ejs = require('./ejs')
const html = '<%if(test){%><div><%=user.firstName%></div><%}else{%><%=user.lastName%><%}%>'
const options = {}
const data = {
test: false,
user: {
firstName: 'zhang',
lastName: 'san'
}
}
const template = ejs.compile(html, options)
const compiled = template(data)
console.log(compiled)
核心代码,重要代码的讲解写在了注释里,分成了6个step,注意注释的case使用的是
<div><%=user.name%></div>
const _REGEX_STRING = '(<%%|%%>|<%=|<%-|<%_|<%#|<%|%>|-%>|_%>)'
const _DEFAULT_OPEN_DELIMITER = '<'
const _DEFAULT_CLOSE_DELIMITER = '>'
const _DEFAULT_DELIMITER = '%'
const _DEFAULT_LOCALS_NAME = 'locals'
function stripSemi(str){
return str.replace(/;(\s*$)/, '$1')
}
const _MATCH_HTML = /[&<>'"]/g
const _ENCODE_HTML_RULES = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
}
const escapeFuncStr =
'var _ENCODE_HTML_RULES = {\n'
+ ' "&": "&"\n'
+ ' , "<": "<"\n'
+ ' , ">": ">"\n'
+ ' , \'"\': """\n'
+ ' , "\'": "'"\n'
+ ' }\n'
+ ' , _MATCH_HTML = /[&<>\'"]/g;\n'
+ 'function encode_char(c) {\n'
+ ' return _ENCODE_HTML_RULES[c] || c;\n'
+ '};\n';
function encode_char(c) {
return _ENCODE_HTML_RULES[c] || c;
}
function escapeXML(markup) {
return markup == undefined
? ''
: String(markup)
.replace(_MATCH_HTML, encode_char);
}
escapeXML.prototype.toString = function() {
return Function.prototype.toString.call(this) + ';\n' + escapeFuncStr
}
function rethrow(err, str, flnm, lineno, esc) {
var lines = str.split('\n');
var start = Math.max(lineno - 3, 0);
var end = Math.min(lines.length, lineno + 3);
var filename = esc(flnm);
// Error context
var context = lines.slice(start, end).map(function (line, i){
var curr = i + start + 1;
return (curr == lineno ? ' >> ' : ' ')
+ curr
+ '| '
+ line;
}).join('\n');
// Alter exception message
err.path = filename;
err.message = (filename || 'ejs') + ':'
+ lineno + '\n'
+ context + '\n\n'
+ err.message;
throw err;
}
function Template(text, opts) {
opts = opts || {}
const options = {}
this.templateText = text
this.mode = null
this.truncate = false
this.currentLine = 1
this.source = ''
options.client = opts.client || false
options.context = opts.context
options.strict = opts.strict || false
options.escapeFunction = opts.escape || opts.escapeFunction || escapeXML
options.openDelimiter = opts.openDelimiter || exports.openDelimiter || _DEFAULT_OPEN_DELIMITER
options.closeDelimiter = opts.closeDelimiter || exports.closeDelimiter || _DEFAULT_CLOSE_DELIMITER
options.delimiter = opts.delimiter || exports.delimiter || _DEFAULT_DELIMITER
options.localsName = opts.localsName || exports.localsName || _DEFAULT_LOCALS_NAME
if (options.strict) {
options._with = false;
} else {
options._with = typeof opts._with != 'undefined' ? opts._with : true;
}
this.opts = options
this.regex = this.createRegex()
}
Template.modes = {
EVAL: 'eval',
ESCAPED: 'escaped',
RAW: 'raw',
COMMENT: 'comment',
LITERAL: 'literal'
}
Template.prototype = {
// 创建解析<% %>标签的模板
createRegex() {
const str = _REGEX_STRING
return new RegExp(str)
},
compile() {
let prepended = ''
let appended = ''
let src = ''
let fn
const opts = this.opts
const escapeFn = opts.escapeFunction
if (!this.source) {
// step1,赋值this.source,把传入的template转成js代码string
// <div> => ; __append("<div>")
// <%=user.name%> => ; __append(escapeFn(user.name))
this.generateSource()
// step2,加在上一步生成代码前的代码,实际就是提供了__append方法,然后__output记录最终输出的js string
prepended +=
' var __output = "";\n' +
' function __append(s) { if (s !== undefined && s !== null) __output += s }\n'
// step3,把step1生成的代码放到with语法中,方便生成代码中获取data中的数据
// 因为生成代码中使用的是user.name,但实际应该是data.user.name,为了不修改原始数据使用with语法
// 实际官方已经不推荐使用with语法,实际开发中请勿使用
if (opts._with !== false) {
prepended += ' with (' + opts.localsName + ' || {}) {' + '\n';
appended += ' }' + '\n';
}
// step4,拼接最终返回__output的代码
appended += ' return __output;' + '\n'
this.source = prepended + this.source + appended
}
src = this.source
// step5,把js string转成最终的function
// new Function的用法:const fn = new Function('p1, p2, p3', 'console.log(p1, p2, p3)') // 参数名称列表 & 执行代码的string
// 执行时fn('1', '2', '3') => 打印1 2 3
fn = new Function(opts.localsName + ', escapeFn, include, rethrow', src)
// step6, opts.client为false的情况下,包装一个匿名函数供外部传入最终的data
const returnedFn = opts.client ? fn : function anonymous(data) {
// 暂时不实现include方法
var include = function() {
console.log('not implemented now')
}
return fn.apply(opts.context, [data || {}, escapeFn, include, rethrow])
}
return returnedFn
},
generateSource() {
const matches = this.parseTemplateText()
const d = this.opts.delimiter // %
const o = this.opts.openDelimiter // <
const c = this.opts.closeDelimiter // >
// ['<div>', '<%=', 'user.name', '%>', '</div>']
if (matches && matches.length) {
matches.forEach((line, index) => {
let closing
if ( line.indexOf(o + d) === 0 // If it is a tag
&& line.indexOf(o + d + d) !== 0) { // and is not escaped
closing = matches[index + 2]
// 检查<%等开始标签后 + 2的位置是否是对应的%>的结束标签,如果不是说明语法不符合,直接报错
if (!(closing == d + c || closing == '-' + d + c || closing == '_' + d + c)) {
throw new Error('Could not find matching close tag for "' + line + '".');
}
}
this.scanLine(line)
})
}
},
// 通过正则把string解析成按照<%等标签分割开的数组
parseTemplateText() {
let str = this.templateText
const regex = this.regex
/**
* 第一次执行的效果
* [
'<%=',
'<%=',
index: 5,
input: '<div><%=user.name%></div>',
groups: undefined
]
*/
let result = regex.exec(str)
const arr = []
let firstPos = 0
while(result) {
firstPos = result.index
if (firstPos !== 0) {
arr.push(str.substring(0, firstPos))
str = str.slice(firstPos)
}
arr.push(result[0])
str = str.slice(result[0].length)
result = regex.exec(str)
}
if (str) {
arr.push(str)
}
/**
* 最终返回['<div>', '<%=', 'user.name', '%>', '</div>']
*/
return arr
},
// 通过状态机,把parseTemplateText生成是数组,解析成对应的js片段
scanLine(line) {
const d = this.opts.delimiter;
const o = this.opts.openDelimiter;
const c = this.opts.closeDelimiter;
let newLineCount = 0;
newLineCount = (line.split('\n').length - 1);
switch(line) {
case o + d:
case o + d + '_':
this.mode = Template.modes.EVAL;
break;
case o + d + '=':
this.mode = Template.modes.ESCAPED;
break
case d + c:
case '-' + d + c:
case '_' + d + c:
if (this.mode == Template.modes.LITERAL) {
this._addOutput(line)
}
this.mode = null
// this.truncate = line.indexOf('-') === 0 || line.indexOf('_') === 0
break
default:
if (this.mode) {
switch(this.mode) {
case Template.modes.EVAL:
case Template.modes.ESCAPED:
case Template.modes.RAW:
if (line.lastIndexOf('//') > line.lastIndexOf('\n')) {
line += '\n'
}
}
switch (this.mode) {
// Just executing code
case Template.modes.EVAL:
this.source += ' ; ' + line + '\n'
break
// Exec, esc, and output
case Template.modes.ESCAPED:
this.source += ' ; __append(escapeFn(' + stripSemi(line) + '))' + '\n'
break
case Template.modes.LITERAL:
this._addOutput(line)
break
}
} else {
this._addOutput(line)
}
}
},
_addOutput(line) {
this.source += ' ; __append("' + line + '")' + '\n';
}
}
function compile(template, opts) {
const templ = new Template(template, opts)
return templ.compile()
}
module.exports = {
compile
}
最终动态生成代码如下:
(function anonymous(locals, escapeFn, include, rethrow
) {
var __output = "";
function __append(s) { if (s !== undefined && s !== null) __output += s }
with (locals || {}) {
; if(test){
; __append("<div>")
; __append(escapeFn(user.firstName))
; __append("</div>")
; }else{
; __append(escapeFn(user.lastName))
; }
}
return __output;
})
执行后就能拿到编译后的模板啦~