JavaScript 进阶学习(一)—— 基于正则表达式的简单 js 模板引擎实现

前言

这年头 MVC、MVVM 框架泛滥,很多时候我们只是用了这些框架,有时候想深入去了解这些框架背后的原理实现时,阅读源码时发现无从下手,js 基本功简直渣渣,所以想利用业余时间还是要补补基础。以前看 JavaScript 的一些书籍时总是把正则表达式这一章跳过了,遇到一些需要写正则的时候然后都是各种 copy,js 要进阶感觉还是要系统学习一下正则,虽然看起来像乱码一样的匹配规则,但是如果熟练使用会很有用,那么今天就先从正则开始吧!和大部分书籍一样,本文前篇也会是讲解基础,本文的很多内容都是摘自网络进行整理,有些内容需要各位自己进行实践验证。

正则表达式

正则表达式(英语:Regular Expression,在代码中常简写为 regex、regexp 或 RE)使用单个字符串来描述、匹配一系列符合某个句法规则的字符串搜索模式。搜索模式可用于文本搜索和文本替换。

基本语法

RegExp 构造函数可创建一个正则表达式对象,用特定的模式匹配文本。创建一个正则对象的两种方法:字面量和构造函数。要表示字符串,字面量形式不使用引号,而传递给构造函数的参数使用引号。

字面量: /pattern/flags
构造函数: RegExp(pattern [, flags])
  • pattern 正则表达式文本
  • flags 该参数可以是下面单个值或者几个值的任意组合:
  • g 全局匹配
  • i 忽略大小写
  • gi 或 ig 全局匹配、忽略大小写
  • m 多行查找,让开始和结束字符(^ 和 $)工作在多行模式(也就是,^ 和 $ 可以匹配字符串中每一行的开始和结束(行是由 \n 或 \r 分割的),而不只是整个输入字符串的最开始和最末尾处。
  • u Unicode。将模式视为 Unicode 码位(code points)序列。
  • y sticky。使用隐式的^锚点把正则锚定在了 lastIndex 所指定的偏移位置处,具体可以看看这篇文章:JavaScript:正则表达式的/y 标识

具体例子:

/ab+c/i;
new RegExp("ab+c", "i");
new RegExp(/ab+c/, "i");

当表达式被赋值时,字面量形式提供正则表达式的编译(compilation)状态,当正则表达式保持为常量时使用字面量。例如当你在循环中使用字面量构造一个正则表达式时,正则表达式不会在每一次迭代中都被重新编译(recompiled)。

而正则表达式对象的构造函数,如 new RegExp('ab+c') 提供了正则表达式运行时编译(runtime compilation)。如果你知道正则表达式模式将会改变,或者你事先不知道什么模式,而是从另一个来源获取,如用户输入,这些情况都可以使用构造函数。

从 ECMAScript 6 开始,当第一个参数为正则表达式而第二个标志参数存在时,new RegExp(/ab+c/, 'i')不再抛出 TypeError (“当从其他正则表达式进行构造时不支持标志”)的异常,取而代之,将使用这些参数创建一个新的正则表达式。

当使用构造函数创造正则对象时,需要常规的字符转义规则(在前面加反斜杠 \)。比如,以下是等价的:

var re = new RegExp("\\w+");
var re = /\w+/;

正则表达式中的特殊字符

^$ —— 作用是分别指出一个字符串的开始和结束。

  • ^The:表示所有以"The"开始的字符串"There","The cat"等;
  • of despair$:表示所以以"of despair"结尾的字符串;
  • ^abc$:表示开始和结尾都是"abc"的字符串——呵呵,只有"abc"自己了;
  • notice:表示任何包含"notice"的字符串。

最后那个例子,如果你不使用两个特殊字符,你就在表示要查找的串在被查找串的任意部分——你并不把它定位在某一个顶端。

*+?{} —— 表示一个或一序列字符重复出现的次数。

分别表示“没有或更多”,“一次或更多”,“没有或一次”和指定重复次数的范围。{}必须指定范围的下限,如:"{0,2}"而不是"{,2}"。*、+和?相当于{0,}、{1,}和{0,1}。

  • ab*:表示一个字符串有一个 a 后面跟着零个或若干个 b。("a", "ab", "abbb",……);
  • ab+:表示一个字符串有一个 a 后面跟着至少一个 b 或者更多;
  • ab?:表示一个字符串有一个 a 后面跟着零个或者一个 b;
  • a?b+$:表示在字符串的末尾有零个或一个 a 跟着一个或几个 b。
  • ab{2}:表示一个字符串有一个 a 跟着 2 个 b("abb");
  • ab{2,}:表示一个字符串有一个 a 跟着至少 2 个 b;
  • ab{3,5}:表示一个字符串有一个 a 跟着 3 到 5 个 b。

| —— 表示“或”操作

  • hi|hello:表示一个字符串里有"hi"或者"hello";
  • (b|cd)ef:表示"bef"或"cdef";
  • (a|b)\*c:表示一串"a""b"混合的字符串后面跟一个"c";

. —— 可以替代任何字符

  • a.[0-9]:表示一个字符串有一个"a"后面跟着一个任意字符和一个数字;
  • ^.{3}$:表示有任意三个字符的字符串(长度为 3 个字符);

[] —— 表示某些字符允许在一个字符串中的某一特定位置出现

  • [ab]:表示一个字符串有一个"a"或"b"(相当于"a|b");
  • [a-d]:表示一个字符串包含小写的'a'到'd'中的一个(相当于"a|b|c|d"或者"[abcd]");
  • ^[a-zA-Z]:表示一个以字母开头的字符串;
  • [0-9]%:表示一个百分号前有一位的数字;
  • ,[a-zA-Z0-9]$:表示一个字符串以一个逗号后面跟着一个字母或数字结束。

可以在方括号里用'^'表示不希望出现的字符,'^'应在方括号里的第一位。(如:%[^a-zA-Z]%表示两个百分号中不应该出现字母)。为了逐字表达,你必须在^.$()|*+?{\这些字符前加上转移字符''。在方括号中,不需要转义字符。

RegExp 对象的属性

属性 含义
$1-$9 如果它(们)存在,是匹配到的子串
$_ 参见 input
$* 参见 multiline
$& 参见 lastMatch
$+ 参见 lastParen
$` 参见 leftContext
$'' 参见 rightContext
constructor 创建一个对象的一个特殊的函数原型
global 是否在整个串中匹配(bool 型)
ignoreCase 匹配时是否忽略大小写(bool 型)
input 被匹配的串
lastIndex 最后一次匹配的索引
lastParen 最后一个括号括起来的子串
leftContext 最近一次匹配以左的子串
multiline 是否进行多行匹配(bool 型)
prototype 允许附加属性给对象
rightContext 最近一次匹配以右的子串
source 正则表达式模式
lastIndex 最后一次匹配的索引

详情大家可以查看这里:MDN JavaScript 标准库 RegExp 属性

RegExp 对象的方法

方法 含义
compile 正则表达式比较
exec 执行查找
test 进行匹配
toSource 返回特定对象的定义(literal representing),其值可用来创建一个新的对象。
toString 返回特定对象的串。重载 Object.toString 方法得到的。
valueOf 返回特定对象的原始值。重载 Object.valueOf 方法得到

详情大家可以查看这里:MDN JavaScript 标准库 RegExp 方法

不过在这里我们还是需要说明一下的是我们用得比较多的方法主要分为两类:

RegExp 对象方法

RegExp.prototype.compile() ——编译正则表达式用法:regexObj.compile(pattern, flags)

功能说明:

compile() 方法用于在脚本执行过程中编译正则表达式,也可用于改变和重新编译正则表达式。该方法可以编译指定的正则表达式,编译之后的正则表达式执行速度将会提高,如果正则表达式多次被调用,那么调用 compile 方法可以有效的提高代码的执行速度,如果该正则表达式只能被使用一次,则不会有明显的效果。

var str="Every man in the world! Every woman on earth!";
var patt=/man/g;
var str2=str.replace(patt,"person");
document.write(str2+"<br>");
patt=/(wo)?man/g;
patt.compile(patt);
str2=str.replace(patt,"person");
document.write(str2);
结果:
Every person in the world! Every woperson on earth!
Every person in the world! Every person on earth!

RegExp.prototype.exec() —— 检索字符串中指定的值。返回找到的值,并确定其位置。用法:regexObj.exec(str)

功能说明:

exec() 方法如果成功匹配,exec 方法返回一个数组,并且更新正则表达式对象的属性。返回的数组包括匹配的字符串作为第一个元素,紧接着一个元素对应一个成功匹配被捕获的字符串的捕获括号(capturing parenthesis)。(one item for each capturing parenthesis that matched containing the text that was captured.)如果匹配失败,exec 方法将返回 null。

var str="Hello world,hello zhaomenghuan!";
// look for "Hello"或"hello"
var patt=/hello/gi;
while((result = patt.exec(str))!== null){
	document.write("result:" + result +"的位置为"+ result.index + "<br />");
}
结果:
result:Hello的位置为0
result:hello的位置为12

RegExp.prototype.test() —— 检索字符串中指定的值。返回 true 或 false。用法:regexObj.test(str)

功能说明:

test() 方法用于检测一个字符串是否匹配某个模式,如果字符串中有匹配的值返回 true ,否则返回 false。

var result = /hello/.test('This is a hello world!');
document.write(result);
结果:
true

支持正则表达式的 String 对象的方法

search —— 检索与正则表达式相匹配的值.

用法:string.search(searchvalue)

searchvalue 必须。查找的字符串或者正则表达式。

功能说明:

search()方法用于检索字符串中指定的子字符串,或检索与正则表达式相匹配的子字符串。如果没有找到任何匹配的子串,则返回 -1。

var str="Mr. Blue has a blue house";
document.write(str.search(/blue/i));
结果:
4

match —— 找到一个或多个正则表达式的匹配。

用法:string.match(regexp)

regexp 必需。规定要匹配的模式的 RegExp 对象。如果该参数不是 RegExp 对象,则需要首先把它传递给 RegExp 构造函数,将其转换为 RegExp 对象。返回值类型为 Array。

**功能说明:**match() 方法可在字符串内检索指定的值,或找到一个或多个正则表达式的匹配。match() 方法将检索字符串 String Object,以找到一个或多个与 regexp 匹配的文本。这个方法的行为在很大程度上有赖于 regexp 是否具有标志 g。如果 regexp 没有标志 g,那么 match() 方法就只能在 stringObject 中执行一次匹配。如果没有找到任何匹配的文本, match() 将返回 null。否则,它将返回一个数组,其中存放了与它找到的匹配文本有关的信息。

var str = "The rain in SPAIN stays mainly in the plain";
var match = str.match(/ain/gi);
document.getElementById("demo").innerHTML = match;
结果: ain, AIN, ain, ain;

replace 替换与正则表达式匹配的子串。

用法:string.replace(searchvalue,newvalue)

searchvalue 必须。规定子字符串或要替换的模式的 RegExp 对象。请注意,如果该值是一个字符串,则将它作为要检索的直接量文本模式,而不是首先被转换为 RegExp 对象。 newvalue 必需。一个字符串值。规定了替换文本或生成替换文本的函数。返回值为 String 类型,一个新的字符串,是用 replacement 替换了 regexp 的第一次匹配或所有匹配之后得到的。

split 把字符串分割为字符串数组。

用法:string.split(separator,limit)

separator 可选。字符串或正则表达式,从该参数指定的地方分割 string Object。 limit 可选。该参数可指定返回的数组的最大长度。如果设置了该参数,返回的子串不会多于这个参数指定的数组。如果没有设置该参数,整个字符串都会被分割,不考虑它的长度。返回值为 Array 类型,一个字符串数组。该数组是通过在 separator 指定的边界处将字符串 string Object 分割成子串创建的。返回的数组中的字串不包括 separator 自身。

var str="How are you doing today?";
var match = str.split(/ /);
document.write(match);
结果:
How,are,you,doing,today?

正则表达式的应用实例说明

校验是否全由数字组成 —— /^[0-9]{1,20}$/

^ 表示打头的字符要匹配紧跟^后面的规则 $ 表示打头的字符要匹配紧靠$前面的规则 [ ] 中的内容是可选字符集 [0-9] 表示要求字符范围在 0-9 之间 {1,20}表示数字字符串长度合法为 1 到 20,即为[0-9]中的字符出现次数的范围是 1 到 20 次。

/^$/ 成对使用应该是表示要求整个字符串完全匹配定义的规则,而不是只匹配字符串中的一个子串。

校验纯中文字符 —— /^[\u4E00-\u9FA5]+$/

[\u4E00-\u9FA5] :中文字符集的范围

以上表达式均在下面的 javascript 中测试通过:

<html>
<head>
	<meta charset="utf-8"/>
	<title></title>
</head>
<body>
	<form>
		规则表达式 : (填写/ /之间的表达式)<br />
		<input type="input" name="regxStr" value="" ><br />
		校验字符串 :<br />
		<input type="input" name="str" value="" >
		<input type="button" name="match" value="匹配" onClick="alert(regx(regxStr.value,str.value));">
	</form>
	<script type="text/javascript">
    function regx(r,s){
      if (r == null || r == ""){
        return;
      }
      var patrn= new RegExp(r);
      return patrn.test(s);
    }
	</script>
</body>
</html>

js 模板引擎实现原理

前面我们花了很长的篇幅讲解正则表达式是为了大家看这部分是时候能够有所理解,如果前面的内容一下子没有看懂也没有关系,大家可以先看看这部分的内容回过头去查看刚刚的内容。

我们首先会想到写一个模板,我们常见的是写成这样:

<script type="text/html" id="template">
	<p>name: {{name}}</p>
	<p>age: {{profile.age}}</p>
</script>

当然也可以使用<template></template>标签,而且这个也是现在的流行趋势,拥抱模块化,不过本文不是讲这个标签和模块化,如果大家感兴趣可以看看这两篇文章:

大家也可以看下面这个基础例子:

<div class="content"></div>

<template id="template">
	<p>name: 小青年</p>
	<p>age: 22</p>
</template>

<script type="text/javascript">
var isSupport='content' in document.createElement('template');
if (isSupport) {
  var tpl = document.querySelector('#template');
  var content = document.querySelector(".content");
  var clone = document.importNode(tpl.content, true);
  content.appendChild(clone);
} else {
  alert("is not support template");
}
</script>

回归正题,我们继续说上面的模板,开始写模板引擎。

基础模板引擎原理讲解

我们使用 js 模板引擎,可以认为是在做一个 MVC 结构的系统,模型(model)-视图(view)-控制器(controller)。控制器(controller)作为中间部分,首先要拿到模型,这里我们需要拿到模板里面与视图相关的内容,如上面的例子中中的内容,首先用正则查找:

var re = /{{(.+?)}}/g;
while ((match = re.exec(tpl)) !== null) {
  console.log(match);
}
// 结果:
// "{{name}},name"
// "{{age}},age"

/{{(.+?)}}/g的意思是查找开头为{{和结尾为}}的子字符串。通过 RegExp 对象exec()方法搜索匹配得到的是一个数组,我们可以通过 match[0]表示匹配的原字符串,match[1] 表示匹配的目标字符串,我们通过执行字符串替换方法就可以得到目标字符串。

<div id="content"></div>
<!--HTML模板(类似MVC中的view)-->
<script type="text/html" id="template">
  <p>name: {{name}}</p>
  <p>age: {{age}}</p>
</script>

<script type="text/javascript">
// 模板引擎函数(类似MVC中的controller)
var mtpl = function(tpl, data) {
  var re = /{{(.+?)}}/g;
  while((match = re.exec(tpl))!==null) {
    if(match[1]) {
      tpl = tpl.replace(match[0], data[match[1]])
    } else {
      tpl = tpl.replace(match[0], '')
    }
  }
  return tpl;
}

// 模板数据(类似MVC中的model)
var tpl = document.getElementById("template").innerHTML;
document.getElementById("content").innerHTML = mtpl(tpl,{
  name: "zhaomenghuan",
  age: 22
});
</script>

这里我们通过 data['key'] 的形式取值然后替换模板中的 {{...}} 的内容实现了一个内容的替换。上述代码很简单,基本实现了一个字符替换而已,我们上面是通过字符串替换实现了模板和数据的匹配,但是如果我们上面那个json数据是这样的:

var data = {
  base: {
    name: "zhaomenghuan",
    age: 22
  },
  skills: ["html5", "javascript", "android"]
};

我们直接通过 data[match[1]] 进行显然会有问题,我们虽然可以通过 data.base['name'] 获取,但是对于模板引擎函数封装来说是不够完善的,而且也不能执行 JavaScript,好像并没有类似于一些有名的 js 模板引擎库中的语法功能,所以略显 low。下面我们在这个基础上进行改造。

下面我们说一下一种最原始的方法,通过 Function 构造器实现,根据字符串创建一个函数。在一篇文章中看到说这种方法执行 JavaScript 性能低下,但是对于初学者来说,学习一下实现思路我觉得也是有意义的,毕竟对于新手来说谈性能是件奢侈的事。我们首先看个例子:

var fn = new Function("arg", "console.log(arg + 1);");
fn(2); // outputs 3

fn 可是一个货真价实的函数,它接受一个参数,函数体是console.log(arg + 1);,上面那个例子等同于:

var fn = function(arg) {
  console.log(arg + 1);
};
fn(2);

我们通过 new Function 可以将字符串转成 JavaScript 执行,看起来是不是很すごい(厉害,'sigoyi',好像还有个单词是‘daijobu’,女朋友每次问我,我每次都是回答‘daijo’,女朋友喜欢看动漫,今天她放假先回家了,舍不得,想想她平时萌萌哒的样子,越是想念,学几个简单单词,下次见面说说,哈哈。)

接着说,我们有时候参数是多个,我们虽然可以输入多个参数,但是这显然不是最好的办法,我们可以使用 apply,这样我们不必显式地传参数给这个函数。比如我们前面的例子:

var data = {
  name: 'zhaomenghuan',
  age: 22
}
new Function("console.log(this.name + ' is ' + this.age +' years old.');").apply(data);
结果:
"zhaomenghuan is 22 years old."

apply()会自动设定函数执行的上下文,这就是为什么我们能在函数里面使用 this.name,这里 this 指向 data 对象。这里我们得到什么启示呢?我们考验通过给 new Function 传入模板字符串和数据生成我们的内容。

我们可以通过数组 push() 或者+=拼接方式将分隔的字符串连接起来,有文章中称,“在现代浏览器使用+=会比数组 push 方法快,在 v8 引擎中使用+=方法会比数组拼接快 4.7 倍,而在 IE6-8 下 push 比+=拼接快”。至于二者效率比较不在本文范围内,大家可以自行探究,但是我们为了简化问题,不考虑效率问题,我们可以将分隔的字符串用下列方法 push 拼接:

var r = [];
r.push("<p>");
r.push(this.name);
r.push("</p><p>");
r.push(this.age);
r.push("</p>");
return r.join("");

我们如果直接拼接成数组然后转成对象也可以,但是需要将<>转义,为了方便,我们有时候可以这样处理:

var data = {
  name: 'zhaomenghuan',
  age: 22
}
var code = 'var r=[];\n';
code += 'r.push("<p>");\n';
code += 'r.push(this.name);\n'
code += 'r.push("</p><p>");\n';
code += 'r.push(this.age);\n';
code += 'r.push("</p>");\n';
code += 'return r.join("");';
console.log(code)
var fn = new Function(code.replace(/[\r\t\n]/g, '')).apply(data);
console.log(fn)
结果:
"<p>zhaomenghuan</p><p>22</p>"

写到这里我相信聪明的人应该知道我接下来要做的事情了,主要是两个:如何根据我们自定义的分隔字符分隔模板字符串,然后就是动态生成字符串。不过我们可以看出来这里我们还有个没有提到的是让模板能够嵌入 JavaScript 的语法关键词,比如 if,for 等,这个处理方法和上面的略有不同,需要加以判断,不过我们可以划分为解析 html 和 js 两大类。

自定义分隔字符分隔模板字符

在讲如何分割字符串前我们先看三个函数:slice|splice|split

哈哈,是不是懵逼了?反正我经常是晕的,不行,还是要对比一下搞清楚。

常用函数 slice | splice | split 对比

String.prototype.slice() —— 从一个字符串中提取字符串并返回新字符串语法:str.slice(beginSlice[, endSlice])

参数:

  • beginSlice:从该索引(以 0 为基数)处开始提取原字符串中的字符。如果值为负数,会被当做 sourceLength + beginSlice 看待,这里的 sourceLength 是字符串的长度 (例如, 如果 beginSlice 是 -3 则看作是: sourceLength - 3)
  • endSlice:可选。在该索引(以 0 为基数)处结束提取字符串。如果省略该参数,slice 会一直提取到字符串末尾。如果该参数为负数,则被看作是 sourceLength + endSlice,这里的 sourceLength 就是字符串的长度(例如,如果 endSlice 是 -3,则是, sourceLength - 3)。

Array.prototype.slice()—— 把数组中一部分的浅复制(shallow copy)存入一个新的数组对象中,并返回这个新的数组。语法:arr.slice([begin[, end]])

参数:

  • begin:从该索引处开始提取原数组中的元素(从 0 开始)。如果该参数为负数,则表示从原数组中的倒数第几个元素开始提取,slice(-2)表示提取原数组中的倒数第二个元素到最后一个元素(包含最后一个元素)。如果省略 begin,则 slice 从索引 0 开始。
  • end:在该索引处结束提取原数组元素(从 0 开始)。slice 会提取原数组中索引从 begin 到 end 的所有元素(包含 begin,但不包含 end)。slice(1,4) 提取原数组中的第二个元素开始直到第四个元素的所有元素 (索引为 1, 2, 3 的元素)。如果该参数为负数, 则它表示在原数组中的倒数第几个元素结束抽取。 slice(-2,-1) 表示抽取了原数组中的倒数第二个元素到最后一个元素(不包含最后一个元素,也就是只有倒数第二个元素)。如果 end 被省略,则 slice 会一直提取到原数组末尾。

描述: slice 不修改原数组,只会返回一个包含了原数组中提取的部分元素的一个新数组。原数组的元素会按照下述规则拷贝("一级深拷贝"[one level deep]规则):

  • 如果该元素是个对象引用 (不是实际的对象),slice 会拷贝这个对象引用到新的数组里。两个对象引用都引用了同一个对象。如果被引用的对象发生改变,则改变将反应到新的和原来的数组中。
  • 对于字符串和数字来说(不是 String 和 Number 对象),slice 会拷贝字符串和数字到新的数组里。在一个数组里修改这些字符串或数字,不会影响另一个数组。

如果向两个数组任一中添加了新元素,则另一个不会受到影响。

Array.prototype.splice() —— 用新元素替换旧元素,以此修改数组的内容语法:array.splice(start, deleteCount[, item1[, item2[, ...]]])

参数:

  • start​:从数组的哪一位开始修改内容。如果超出了数组的长度,则从数组末尾开始添加内容;如果是负值,则表示从数组末位开始的第几位。
  • deleteCount:整数,表示要移除的数组元素的个数。如果 deleteCount 是 0,则不移除元素。这种情况下,至少应添加一个新元素。如果 deleteCount 大于 start 之后的元素的总数,则从 start 后面的元素都将被删除(含第 start 位)。
  • itemN:要添加进数组的元素。如果不指定,则 splice() 只删除数组元素。

返回值: 由被删除的元素组成的一个数组。如果只删除了一个元素,则返回只包含一个元素的数组。如果没有删除元素,则返回空数组。 描述: 如果添加进数组的元素个数不等于被删除的元素个数,数组的长度会发生相应的改变。请注意,splice() 方法与 slice() 方法的作用是不同的,splice() 方法会直接对数组进行修改。

String.prototype.split() —— 通过把字符串分割成子字符串来把一个 String 对象分割成一个字符串数组语法:string.split(separator,limit)

我们在前面讲解【支持正则表达式的 String 对象的方法】时讲到这个方法了,这里不再赘述,列出来只为方便大家对比学习。

这里列出的方法中对于我们分隔字符串有用的是String.prototype.slice()String.prototype.split(),另个方法的区别在于使用slice()方法们需要知道子字符串的索引值 index,使用split()方法我们需要子字符串的内容或者符合的正则表达式。很明显我们的思路就出来了,接下来我们用代码实现。

基于 String.prototype.slice()方法分隔字符串

我们这里参考前面的内容写一个基本函数,设置一个变量 cursor 作为索引值指针,每次执行完一个匹配操作,我们将指针移动一下。我们前面讲RegExp.prototype.exec()返回值时重点说到了三个参数。若 match = re.exec(str),则有:

  • match.index:匹配的对象起始位置
  • match[0]:表示匹配的原字符串
  • match[1]:表示匹配的目标字符串若我们明白了思路我们就可以写下面的代码:
<script type="text/html" id="template">
  <p>name: {{this.name}}</p>
  <p>age: {{this.age}}</p>
</script>

<script type="text/javascript">
  var mtpl = function(tpl,data) {
    var re = /{{(.+?)}}/g,cursor = 0;

    while((match = re.exec(tpl))!== null) {
      // 开始标签  {{ 前的内容和结束标签 }} 后的内容
      console.log(tpl.slice(cursor, match.index))
      // 开始标签  {{ 和 结束标签 }} 之间的内容
      console.log(match[1])
      // 每一次匹配完成移动指针
      cursor = match.index + match[0].length;
    }
    // 最后一次匹配完的内容
    console.log(tpl.substr(cursor, tpl.length - cursor))
  }

  // 调用
  var tpl = document.getElementById("template").innerHTML;
  mtpl(tpl,null);
</script>

通过上面的代码我们已经实现了将模板字符串分隔成子字符串。

基于 String.prototype.split()方法分隔字符串

使用字符串split()方法,下面我们不使用正则作为分隔符号,这里就使用自定义符号作为分隔标准,如:

var sTag = "{%"; //开始标签
var eTag = "%}"; //结束标签

然后我们以sTag为分隔符执行 split 方法:

var matchs = tpl.split(sTag);

返回值 matchs 为一个子字符串数组,然后对数组每一个子字符串以eTag为分隔符执行 split 方法,进一步得到的子字符串数组分为两种类型,一种是于我们匹配子字符串参数有关的子串数组,一种是与匹配参数无关的子串数组(这种数组长度为 1)。之所以要分得这么细是为了后面字符串连接时更方便的时候合适的方法连接。

<script type="text/html" id="template">
  <p>name: {%this.name%}</p>
  <p>age: {%this.age%}</p>
</script>

<script type="text/javascript">
  var mtpl = function(tpl, data) {
    var sTag = '{%';//开始标签
    var eTag = '%}';//结束标签
    var matchs = tpl.split(sTag);
    for (var i = 0, len = matchs.length; i < len; i++) {
      var match = matchs[i].split(eTag);
      if (match.length === 1) {
        console.log(match[0]);
      } else {
        if(match[0]){
          console.log(match[0]);
        }
        if (match[1]) {
          console.log(match[1]);
        }
      }
    }
  }

  // 调用
  var tpl = document.getElementById("template").innerHTML;
  mtpl(tpl,null);
</script>

动态连接字符串

我们上面使用了字符串分隔函数把字符串进行了分隔,并且提取了关键子字符串,下面我们讲解如何将这些字符串连接起来。我们这里定义一个这样的模板:

<script type="text/tpl" id="template">
  <p>name: {{this.name}}</p>
  <p>age: {{this.profile.age}}</p>
  {{if (this.sex) {}}
    <p>sex: {{this.sex}}</p>
  {{}}}
  <ul>
    {{for(var i in this.skills){}}
    <li>{{this.skills[i]}}</li>
    {{}}}
  </ul>
</script>

很明显我们这个模板就相对前面的复杂得多了,但是基本思路是一样的,无非是提取 {{...}} 的内容,然后结合数据重新组合成新的 html 字符串。但是与前面不同的是我们分隔的子字符串中有三种类型:

  • 含普通 html 标签的子字符串(如:<p>name:
  • 含 js 对象值的子字符串(如:this.name
  • 含 javascript 关键字的代码片段(如:if (this.sex) {

我们刚刚前面一直只提到了第 1、2 两种,这两种直接可以使用数组 push 方法就可以连接起来,但是第 3 中不能使用数组 push,而是应该直接连接。所以这里我们分两种情况:

var reExp = /(^( )?(var|if|for|else|switch|case|break|{|}|;))(.*)?/g,;

var code = 'var r=[];\n';
// 解析html
function parsehtml(line) {
  // 单双引号转义,换行符替换为空格,去掉前后的空格
  line = line.replace(/('|")/g, '\\$1').replace(/\n/g, ' ').replace(/(^\s+)|(\s+$)/g,"");
  code +='r.push("' + line + '");\n';
}

// 解析js代码
function parsejs(line) {
  // 去掉前后的空格
  line = line.replace(/(^\s+)|(\s+$)/g,"");
  code += line.match(reExp)? line + '\n' : 'r.push(' + line + ');\n';
}

当我们写完这两个函数的时候,我们直接替换上面我们分隔字符串时候得到的字字符串时候打印的函数即可。当然我们会看到很多文章为了压缩代码,将这两个函数合并成一个函数,其实对于我们理解这个问题,还原问题本质并没有实际意义,这里建议还是很开写更清晰。

完整代码如下:

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title></title>
</head>
<body>
  <div id="content"></div>
  <script type="text/tpl" id="template">
    <p>name: {{this.name}}</p>
    <p>age: {{this.profile.age}}</p>
    {{if (this.sex) {}}
      <p>sex: {{this.sex}}</p>
    {{}}}
    <ul>
      {{for(var i in this.skills){}}
      <li>{{this.skills[i]}}</li>
      {{}}}
    </ul>
  </script>

  <script type="text/javascript">
    var mtpl = function(tpl, data) {
    var re = /{{(.+?)}}/g,
      cursor = 0
      reExp = /(^( )?(var|if|for|else|switch|case|break|{|}|;))(.*)?/g,
      code = 'var r=[];\n';

      // 解析html
      function parsehtml(line) {
        // 单双引号转义,换行符替换为空格,去掉前后的空格
        line = line.replace(/('|")/g, '\\$1').replace(/\n/g, ' ').replace(/(^\s+)|(\s+$)/g,"");
        code +='r.push("' + line + '");\n';
      }

      // 解析js代码
      function parsejs(line) {
        // 去掉前后的空格
        line = line.replace(/(^\s+)|(\s+$)/g,"");
        code += line.match(reExp)? line + '\n' : 'r.push(' + line + ');\n';
      }

      while((match = re.exec(tpl))!== null) {
        // 开始标签  {{ 前的内容和结束标签 }} 后的内容
        parsehtml(tpl.slice(cursor, match.index))
        // 开始标签  {{ 和 结束标签 }} 之间的内容
        parsejs(match[1])
        // 每一次匹配完成移动指针
        cursor = match.index + match[0].length;
      }
      // 最后一次匹配完的内容
      parsehtml(tpl.substr(cursor, tpl.length - cursor));
      code += 'return r.join("");';
      return new Function(code.replace(/[\r\t\n]/g, '')).apply(data);
    }

    var tpl = document.getElementById("template").innerHTML.toString();
    document.getElementById("content").innerHTML = mtpl(tpl,{
      name: "zhaomenghuan",
      profile: {
        age: 22
      },
      sex: 'man',
      skills: ['html5','javascript','android']
    });
  </script>
</body>
</html>

另外一个自定义标签的和这个代码类似,大家可以自己试试,或者看本文所有的代码演示完整工程。

至此我们完成了一个基于正则表达式的简单 js 模板引擎,本文目的不在于造一个轮子,也不是为了重复制造文章,只是把相关问题进行梳理,在自己知识体系中形成一个更加清晰的思路,通过这个实际例子将正则表达式、数组和字符串相关知识点串起来,方面后面自己查阅,也方便初学者可以学习借鉴。

参考文章

写这些代码也许就一两个小时的事,写一篇大家好接受的文章需要几天的酝酿,如果文章对您有帮助请我喝杯咖啡吧!