乐趣区

Mustache.js源码分析

mustache.js 是一个弱逻辑的模板引擎,语法十分简单,使用很方便。源码(v2.2.1)只有 600+ 行,且代码结构清晰。
一般来说,mustache.js 使用方法如下:
var template = ‘Hello, {{name}}’;
var rendered = Mustache.render(template, {
name: ‘World’
});
document.getElementById(‘container’).innerHTML = rendered;
通过使用 Chrome 对上述 Mustache.render 的 debug,我们顺藤摸瓜梳理了 mustache.js5 个模块(暂且称它们为:Utils, Scanner, Parser, Writer,Context)间的关系图如下:

代码层面,Mustache.render()方法是 mustache.js 向外暴露的方法之一,
mustache.render = function render(template, view, partials) {
// 容错处理
if (typeof template !== ‘string’) {
throw new TypeError(‘Invalid template! Template should be a “string” ‘ +
‘but “‘ + typeStr(template) + ‘” was given as the first ‘ +
‘argument for mustache#render(template, view, partials)’);
}
// 调用 Writer.render
return defaultWriter.render(template, view, partials);
};

在其内部,它首先调用了 Writer.render()方法,
Writer.prototype.render = function render(template, view, partials) {
// 调用 Writer 构造器的 parse 方法
var tokens = this.parse(template);
// 渲染逻辑,后文会分析
var context = (view instanceof Context) ? view : new Context(view);
return this.renderTokens(tokens, context, partials, template);
};

而 Writer.render()方法首先调用了 Writer.parse()方法,
Writer.prototype.parse = function parse(template, tags) {
var cache = this.cache;
var tokens = cache[template];
if (tokens == null)
// 调用 parseTemplate 方法
tokens = cache[template] = parseTemplate(template, tags);
return tokens;
};

Writer.parse()方法调用了 parseTemplate 方法,所以,归根结底,Mustache.render()方法首先调用 parseTemplate 方法对 html 字符串进行解析,然后,将一个对象渲染到解析出来的模板中去。
所以,我们得研究源码核心所在——parseTemplate 方法。在此之前,我们的先看一些前置方法:工具方法和扫描器。
工具方法(Utils)
// 判断某个值是否为数组
var objectToString = Object.prototype.toString;
var isArray = Array.isArray || function isArrayPolyfill(object) {
return objectToString.call(object) === ‘[object Array]’;
};
// 判断某个值是否为函数
function isFunction(object) {
return typeof object === ‘function’;
}
// 更精确的返回数组类型的 typeof 值为 ’array’,而非默认的 ’object’
function typeStr(obj) {
return isArray(obj) ? ‘array’ : typeof obj;
}
// 转义正则表达式里的特殊字符
function escapeRegExp(string) {
return string.replace(/[\-\[\]{}()*+?.,\\\^$|#\s]/g, ‘\\$&’);
}
// 判断对象是否有某属性
function hasProperty(obj, propName) {
return obj != null && typeof obj === ‘object’ && (propName in obj);
}
// 正则验证,防止 Linux 和 Windows 下不同 spidermonkey 版本导致的 bug
var regExpTest = RegExp.prototype.test;

function testRegExp(re, string) {
return regExpTest.call(re, string);
}
// 是否是空格
var nonSpaceRe = /\S/;

function isWhitespace(string) {
return !testRegExp(nonSpaceRe, string);
}
// 将特殊字符转为转义字符
var entityMap = {
‘&’: ‘&’,
‘<‘: ‘&lt;’,
‘>’: ‘&gt;’,
‘”‘: ‘&quot;’,
“‘”: ‘&#39;’,
‘/’: ‘&#x2F;’,
‘`’: ‘&#x60;’,
‘=’: ‘&#x3D;’
};

function escapeHtml(string) {
return String(string).replace(/[&<>”‘`=\/]/g, function fromEntityMap(s) {
return entityMap[s];
});
}
var whiteRe = /\s*/; // 匹配 0 个以上的空格
var spaceRe = /\s+/; // 匹配 1 个以上的空格
var equalsRe = /\s*=/; // 匹配 0 个以上的空格加等号
var curlyRe = /\s*\}/; // 匹配 0 个以上的空格加}
var tagRe = /#|\^|\/|>|\{|&|=|!/; // 匹配 #,^,/,>,{,&,=,!

扫描器(Scanner)
// Scanner 构造器,用于扫描模板
function Scanner(string) {
this.string = string; // 模板总字符串
this.tail = string; // 模板剩余待扫描字符串
this.pos = 0; // 扫描索引,即表示当前扫描到第几个字符串
}
// 如果模板扫描完成,返回 true
Scanner.prototype.eos = function eos() {
return this.tail === ”;
};
// 扫描的下一批的字符串是否匹配 re 正则,如果不匹配或者 match 的 index 不为 0
Scanner.prototype.scan = function scan(re) {
var match = this.tail.match(re);
if (!match || match.index !== 0)
return ”;
var string = match[0];
this.tail = this.tail.substring(string.length);
this.pos += string.length;
return string;
};
// 扫描到符合 re 正则匹配的字符串为止,将匹配之前的字符串返回,扫描索引设为扫描到的位置
Scanner.prototype.scanUntil = function scanUntil(re) {
var index = this.tail.search(re),
match;
switch (index) {
case -1:
match = this.tail;
this.tail = ”;
break;
case 0:
match = ”;
break;
default:
match = this.tail.substring(0, index);
this.tail = this.tail.substring(index);
}
this.pos += match.length;
return match;
};
总的来说,扫描器,就是用来扫描字符串的。扫描器中只有三个方法:

eos: 判断当前扫描剩余字符串是否为空,也就是用于判断是否扫描完了

scan: 仅扫描当前扫描索引的下一堆匹配正则的字符串,同时更新扫描索引

scanUntil: 扫描到匹配正则为止,同时更新扫描索引

现在进入 parseTemplate 方法。
解析器(Parser)
解析器是整个源码中最重要的方法,用于解析模板,将 html 标签与模板标签分离。整个解析原理为:遍历字符串,通过正则以及扫描器,将普通 html 和模板标签扫描并且分离,并保存为数组 tokens。

function parseTemplate(template, tags) {
if (!template)
return [];
var sections = []; // 用于临时保存解析后的模板标签对象
var tokens = []; // 保存所有解析后的对象
var spaces = []; // 包括空格对象在 tokens 里的索引
var hasTag = false; // 当前行是否有{{tag}}
var nonSpace = false; // 当前行是否有非空格字符
// 去除保存在 tokens 里的空格对象
function stripSpace() {
if (hasTag && !nonSpace) {
while (spaces.length)
delete tokens[spaces.pop()];
} else {
spaces = [];
}
hasTag = false;
nonSpace = false;
}
var openingTagRe, closingTagRe, closingCurlyRe;
// 将 tag 转换为正则,默认 tag 为 {{和}},所以转成匹配{{的正则,和匹配}} 的正则,以及匹配}}}的正则
// 因为 mustache 的解析中如果是 {{{}}} 里的内容则被解析为 html 代码
function compileTags(tagsToCompile) {
if (typeof tagsToCompile === ‘string’)
tagsToCompile = tagsToCompile.split(spaceRe, 2);
if (!isArray(tagsToCompile) || tagsToCompile.length !== 2)
throw new Error(‘Invalid tags: ‘ + tagsToCompile);
openingTagRe = new RegExp(escapeRegExp(tagsToCompile[0]) + ‘\\s*’);
closingTagRe = new RegExp(‘\\s*’ + escapeRegExp(tagsToCompile[1]));
closingCurlyRe = new RegExp(‘\\s*’ + escapeRegExp(‘}’ + tagsToCompile[1]));
}
compileTags(tags || mustache.tags);
var scanner = new Scanner(template);
var start, type, value, chr, token, openSection;
while (!scanner.eos()) {
start = scanner.pos;
// 开始扫描模板,扫描至{{时停止扫描,并且将此前扫描过的字符保存为 value
value = scanner.scanUntil(openingTagRe);
if (value) {
// 遍历{{之前的字符
for (var i = 0, valueLength = value.length; i < valueLength; ++i) {
chr = value.charAt(i);
// 如果当前字符为空格,这用 spaces 数组记录保存至 tokens 里的索引
if (isWhitespace(chr)) {
spaces.push(tokens.length);
} else {
nonSpace = true;
}
tokens.push([‘text’, chr, start, start + 1]);
start += 1;
// 如果遇到换行符,则将前一行的空格清除
if (chr === ‘\n’)
stripSpace();
}
}
// 判断下一个字符串中是否有{{,同时更新扫描索引到{{的后一位
if (!scanner.scan(openingTagRe))
break;
hasTag = true;
// 扫描标签类型,是 {{#}} 还是 {{=}} 或其他
type = scanner.scan(tagRe) || ‘name’;
scanner.scan(whiteRe);
// 根据标签类型获取标签里的值,同时通过扫描器,刷新扫描索引
if (type === ‘=’) {
value = scanner.scanUntil(equalsRe);
// 使扫描索引更新为 \s*= 后
scanner.scan(equalsRe);
// 使扫描索引更新为}}后,下面同理
scanner.scanUntil(closingTagRe);
} else if (type === ‘{‘) {
value = scanner.scanUntil(closingCurlyRe);
scanner.scan(curlyRe);
scanner.scanUntil(closingTagRe);
type = ‘&’;
} else {
value = scanner.scanUntil(closingTagRe);
}
// 匹配模板闭合标签即}},如果没有匹配到则抛出异常,
// 同时更新扫描索引至}}后一位, 至此时即完成了一个模板标签 {{#tag}} 的扫描
if (!scanner.scan(closingTagRe))
throw new Error(‘Unclosed tag at ‘ + scanner.pos);
// 将模板标签也保存至 tokens 数组中
token = [type, value, start, scanner.pos];
tokens.push(token);
// 如果 type 为 #或者 ^,也将 tokens 保存至 sections
if (type === ‘#’ || type === ‘^’) {
sections.push(token);
} else if (type === ‘/’) {
// 如果 type 为 / 则说明当前扫描到的模板标签为{{/tag}},
// 则判断是否有 {{#tag}} 与其对应
openSection = sections.pop();
// 检查模板标签是否闭合,{{#}}是否与 {{/}} 对应,即临时保存在 sections 最后的{{#tag}}
if (!openSection)
throw new Error(‘Unopened section “‘ + value + ‘” at ‘ + start);
// 是否跟当前扫描到的 {{/tag}} 的 tagName 相同
if (openSection[1] !== value)
throw new Error(‘Unclosed section “‘ + openSection[1] + ‘” at ‘ + start);
// 具体原理:扫描第一个 tag,sections 为[{{#tag}}],
// 扫描第二个后 sections 为[{{#tag}}, {{#tag2}}],
// 以此类推扫描多个开始 tag 后,sections 为[{{#tag}}, {{#tag2}} … {{#tag}}]
// 所以接下来如果扫描到 {{/tag}} 则需跟 sections 的最后一个相对应才能算标签闭合。
// 同时比较后还需将 sections 的最后一个删除,才能进行下一轮比较。
} else if (type === ‘name’ || type === ‘{‘ || type === ‘&’) {
// 如果标签类型为 name、{或 &,不用清空上一行的空格
nonSpace = true;
} else if (type === ‘=’) {
// 编译标签,为下一次循环做准备
compileTags(value);
}
}
// 确保 sections 中没有开始标签
openSection = sections.pop();
if (openSection)
throw new Error(‘Unclosed section “‘ + openSection[1] + ‘” at ‘ + scanner.pos);
return nestTokens(squashTokens(tokens));
}
我们来看经过解析器解析之后得到的 tokens 的数据结构:

每一个子项都类似下面这种结构

token[0]为 token 的类型,可能的值有 #、^、/、&、name、text,分别表示 {} 时,调用 renderSection 方法
Writer.prototype.renderSection = function renderSection(token, context, partials, originalTemplate) {
var self = this;
var buffer = ”;
// 获取 {{#xx}} 中 xx 在传进来的对象里的值
var value = context.lookup(token[1]);

function subRender(template) {
return self.render(template, context, partials);
}
if (!value) return;
if (isArray(value)) {
// 如果为数组,说明要复写 html,通过递归,获取数组里的渲染结果
for (var j = 0, valueLength = value.length; j < valueLength; ++j) {
buffer += this.renderTokens(token[4], context.push(value[j]), partials, originalTemplate);
}
} else if (typeof value === ‘object’ || typeof value === ‘string’ || typeof value === ‘number’) {
// 如果 value 为对象或字符串或数字,则不用循环,根据 value 进入下一次递归
buffer += this.renderTokens(token[4], context.push(value), partials, originalTemplate);
} else if (isFunction(value)) {
if (typeof originalTemplate !== ‘string’)
throw new Error(‘Cannot use higher-order sections without the original template’);
// 如果 value 是方法,则执行该方法,并且将返回值保存
value = value.call(context.view, originalTemplate.slice(token[3], token[5]), subRender);
if (value != null)
buffer += value;
} else {
// 如果不是上面所有情况,直接进入下次递归
buffer += this.renderTokens(token[4], context, partials, originalTemplate);
}
return buffer;
};
当模板标签类型为时,说明要当 value 不存在(null、undefined、0、”)或者为空数组的时候才触发渲染。
看看 renderInverted 方法的实现
Writer.prototype.renderInverted = function renderInverted(token, context, partials, originalTemplate) {
var value = context.lookup(token[1]);
// 值为 null,undefined,0,” 或空数组
// 直接进入下次递归
if (!value || (isArray(value) && value.length === 0)) {
return this.renderTokens(token[4], context, partials, originalTemplate);
}
};
结语
到这为止,mustache.js 的源码解析完了,可以看出来,mustache.js 最主要的是一个解析器和一个渲染器,以非常简洁的方式实现了一个强大的模板引擎。

退出移动版