前言
如今前端框架react,vue,angular都拥有自己的模板引擎,但是我们通过学习原生js的模板引擎,尤其是底层对各种字符串和表达式的处理,可以有助于更好的去理解其他框架是如何渲染模板数据的.

本文借鉴underscore源码,使用70行左右的代码实现一款简易版的模板引擎.包含如下核心功能,比如数据渲染,表达式渲染(兼容if语句和for循环)以及html字符串渲染.

用户端调用方式如下,编写compile函数,期待输出相应结果.

1.渲染数据

<?= ?>代表输出变量的值

const data = {
  country: 'China',
};
const template = compile(`    //compile生成模板函数
  <div>
     <?= country?>
  </div>
`);
console.log(template(data)); //template传入参数data,生成模板字符串

输出结果:


China

2.条件判断 <? ?>可在其中直接书写js语句,比如 if 条件判断、for循环

const data = {
  country: 'China',
  gender: 'male',
};
const template = compile(`
  <div>
     <? if(gender === 'male'){?>
      <?= country?>
     <?}?>
  </div>
`);
console.log(template(data));

输出结果:


China

3.循环语句 const data = { country: 'China', gender: 'male', array: [1, 2, 3], };

const template = compile(`
  <div>
     <? for(var i = 0; i< array.length ; i++) {?>
        <span><?= gender + i ?></span>
     <?}?>
  </div>
`);

console.log(template(data));

输出结果:


male0 male1 male2


值渲染
初始先实现一个最简单的需求,渲染两个值如下:

const data = {
  country: 'China',
  gender: 'male',
};
const template = compile('<div><?= country?><span><?= gender?></span></div>');
console.log(template(data));

期待的结果:


China male

从上面的执行代码可知,compile传入模板字符串后返回一个新函数,当向这个函数传递data执行后就能得到最终的结果.

with语句
在讨论模板引擎的实现之前,先学习一个知识点with语法.使用with能避免冗余的对象调用,看以下案例.

function test(data){
 with(data){
 age = 100;
 }
 console.log(data);
 }
 test({age:1})


结果:

{age: 100}
with可以限定上下文对象的作用范围.在with包裹的范围内,没有定义过的变量就代表着data上的属性,可以直接操作.比如上面with传入data,那么在with内部就可以直接操作age属性,而不用再加data前缀.

with的特性有助于模板的编译.在with作用下,模板字符串可以直接写成属性调用,而不用加对象的前缀.

逻辑分析
compile传入模板字符串后会返回一个新函数,再调用data就能返回编译后的最终结果.利用with的特性,compile写成如下形式就能达到目的.

function compile(string){ return function(data){ var _p = ‘’; with(data){ _p += ‘
 

  ’+country+’ 
 ’+gender+’’+’ 

’; 
 } 
 return _p; 
 } 
 }



string 现在为 ‘

<?= country?> <?= gender?>


如果将 string 变换成 ‘


’+country+’ ’+gender+’’+’

’ 就能实现模板编译.但现在碰到的问题是对string无论做任何处理也只能返回一个总的字符串,根本无法做到类似上面

添加单引号,而 country不加单引号.


因此compile里面不能像上面一样直接返回一个函数.为了能让with内部的标签加引号而属性不加引号,可以使用传参的方式创建函数.

将compile函数改造如下:

function compile(string){
 var template_str = var _p = ''; with(data){ _p += '<div>'+country+'</div>'; } return _p;;
 var fn = new Function(‘data’,template_str );
 return fn;
 }


现在只需要将 string 转化成 template_str 的样子就大功告成了.

function compile(string) {
 var template_str = var _p = ''; with(data){ _p += '<div>'+country+'</div>'; } return _p;;function render() {
 var str = ‘’;
 str += “var _p = ‘’”;
 str += ‘with(data){’;
 str += ‘_p +=’;
 str += templateParse();
 str += ‘;}return _p;’;
 }function templateParse() {
 var reg = /<?=([\s\S]+?)?>/g;
 string.replace(reg, function (matches, $1, offset) {
 console.log($1,offset);
 });
 }var template_str = render();
 var fn = new Function(‘data’, template_str);
 return fn;
 }


输出结果:

country 5
gender 24

function compile(string) {
 function render() {
 var str = ‘’;
 str += “var _p = ‘’;”;
 str += ‘with(data){’;
 str += ‘_p +=’;
 str = templateParse(str);
 str += ‘;}return _p;’;
 console.log(str);
 return str;
 }function templateParse(str) {
 var reg = /<?=([\s\S]+?)?>/g;
 var index = 0;
 string.replace(reg, function (matches, $1, offset) {
 str += “’” + string.slice(index, offset) + “’”;
 str += ‘+’;
 str += $1;
 str += ‘+’;
 index = offset + matches.length;
 });
 str += “’” + string.slice(index) + “’”;
 return str;
 }var template_str = render();
 var fn = new Function(‘data’, template_str);
 return fn;
 }var _p = ‘’;with(data){_p +=’
 

  ’+ country+’ 
 ’+ gender+’ 

’;}return _p; 
 

  China 
 male 

 
表达式处理
 const data = {
 country: ‘China’,
 gender: ‘male’,
 };
 const template = compile(
 ’ 

  <? if(country === "China"){ ?> 
 <?= gender?> <?}?> 

’ 

 ); 

 console.log(template(data)); 
 
 
 male

逻辑分析 最开始的想法把模板字符串想办法转化成下面形式就可以了,但实践中发现不管是 if 还是 for 表达式都不能直接和字符串相加,结果会报错

function render(data){
 var _p += ‘’;
 with(data){
_p += '<div>' + if(country === "China"){ return '<span>'+gender+'</span>'; } + '</div>';

  }
  return _p;
}

既然表达式不能与字符串直接相加,那么只能将表达式的逻辑和字符串隔离开.改造如下,在每个表达式前面加一个分号,将前面的字符串相加的代码结束.随后直接渲染表达式的内容,但是表达式内部包裹的内容要使用_p加起来.

function render(data){
  var _p += '';
  with(data){
    _p += '<div>';

    if(country === "China")
    { 
      _p+='<span>'+gender+'</span>'; 
    }

    _p += '</div>';

  }
  return _p;
}

表达式和值的渲染不同,它不仅有if语法,它还有if else, if else if,以及 for 循环语句

但不管是哪一种表达式,我们都可以从上面需要的的渲染结构中总结一些规律.1.表达式前面要加一个分号将前面代码逻辑隔离开 2.表达式本身不用加引号直接选渲染 3.表达式后面的内容需要用_p加起来并赋值给_p

function compile(string) {
 function render() {
 var str = ‘’;
 str += “var _p = ‘’;”;
 str += ‘with(data){’;
 str += ‘_p +=’;
 str = templateParse(str);
 str += ‘;}return _p;’;
 console.log(str);
 return str;
 }function templateParse(str) {
 var reg = /<?=([\s\S]+?)?>|<?([\s\S]+?)?>/g;
 var index = 0;
 string.replace(reg, function (matches, $1, $2, offset) {
 str += “’” + string.slice(index, offset) + “’”;
 if ($1) {
 //渲染值
 str += ‘+’;
 str += $1;
 str += ‘+’;
 } else if ($2) {
 //渲染表达式
 str += ‘;’; //第一步加个分号将前面的逻辑终止
 str += $2; //第二步直接拼接表达式
 str += ‘_p+=’; //第三步要将表达式包裹的内容与_p相加并赋值给_p
 }
 index = offset + matches.length;
 });
 str += “’” + string.slice(index) + “’”;
 return str;
 }var template_str = render();
 var fn = new Function(‘data’, template_str);
 return fn;
 }
 render最后编译出的函数体var _p = ‘’;with(data){_p +=’
 

  ‘; if(country === “China”){ _p+=’ 
 ‘+ gender+’ ‘;}_p+=’ 

';}return _p;

最终结果:

male

渲染HTML代码 const data = { code: ‘

name:张三

’,

};

const template = compile(’

<?- country?>

’);

console.log(template(data));

期待结果:

name:张三

<?- ?>用来标记输出html字符串

渲染html代码非常简单,只需要将templateParse函数内的正则新增一条,在条件判断里面将html字符串拼接上去即可

改动如下

function templateParse(str) {
 var reg = /<?=([\s\S]+?)?>|<?-([\s\S]+?)?>|<?([\s\S]+?)?>/g;
 var index = 0;
 string.replace(reg, function (matches, $1, $2, $3, offset) {
 str += “’” + string.slice(index, offset) + “’”;
 if ($1) {
 //渲染值
 str += ‘+’;
 str += $1;
 str += ‘+’;
 } else if ($2) {
 //渲染html字符串
 str += ‘+’ + $2 + ‘+’;
 } else if ($3) {
 //渲染表达式
 str += ‘;’; //第一步加个分号将前面的逻辑终止
 str += $3; //第二步直接拼接表达式
 str += ‘_p+=’; //第三步要将表达式包裹的内容与_p相加并赋值给_p
 }
 index = offset + matches.length;
 });
 str += “’” + string.slice(index) + “’”;
 return str;
 }


但是仅仅将html字符串拼接上去是不安全的,为了预防xss攻击,我们需要将html字符串中的特殊字符进行转义.

将代码进行如下修改,即可实现对特殊字符编码的目的。

//将html字符串传递给 esacper 函数处理一遍
else if ($2) {
 //渲染html字符串
 str += '+ esacper(' + $2 + ') +';
}
//处理html字符串的特殊符号,预防xss攻击
function esacper(str) {
  const keyMap = {
    //需要转译的队列
    '&': '&',
    '<': '<',
    '>': '>',
    '"': '"',
    "'": '&hx27;',
    '`': '٠',
  };

  const keys = Object.keys(keyMap);

  const reg = new RegExp(`(?:${keys.join('|')})`, 'g');

  const replace = (value) => {
    return keyMap[value];
  };
return reg.test(str) ? str.replace(reg, replace) : str;
 }


输出结果:

name:张三

最终代码
最终代码如下,70行左右的代码即可实现一款包含值渲染,表达式渲染以及html字符串渲染的简易版模板引擎,如果还需要其他功能可自行扩展增强.

function compile(string) {
 string = string.replace(/\n|\r\n/g, ‘’); //为了调用时兼容es6模板字符串/**
• 将html字符串的特殊字符转义,预防xss攻击
 */
 function esacper(str) {
 const keyMap = {
 //需要转译的队列
 ‘&’: ‘&’,
 ‘<’: ‘<’,
 ‘>’: ‘>’,
 ‘"’: ‘"’,
 “’”: ‘&hx27;’,
 ‘`’: ‘٠’,
 };
const keys = Object.keys(keyMap);

const reg = new RegExp(`(?:${keys.join('|')})`, 'g');

const replace = (value) => {
  return keyMap[value];
};

return reg.test(str) ? str.replace(reg, replace) : str;
}
function render() {
 var str = ‘’;
 str += esacper.toString();
 str += “var _p = ‘’;”;
 str += ‘with(data){’;
 str += ‘_p +=’;
 str = templateParse(str);
 str += ‘;}return _p;’;
 return str;
 }function templateParse(str) {
 var reg = /<?=([\s\S]+?)?>|<?-([\s\S]+?)?>|<?([\s\S]+?)?>/g;
 var index = 0;
 string.replace(reg, function (matches, $1, $2, $3, offset) {
 str += “’” + string.slice(index, offset) + “’”;
 if ($1) {
 //渲染值
 str += ‘+’;
 str += $1;
 str += ‘+’;
 } else if ($2) {
 //渲染html字符串
 str += ‘+ esacper(’ + $2 + ‘) +’;
 } else if ($3) {
 //渲染表达式
 str += ‘;’; //第一步加个分号将前面的逻辑终止
 str += $3; //第二步直接拼接表达式
 str += ‘_p+=’; //第三步要将表达式包裹的内容与_p相加并赋值给_p
 }
 index = offset + matches.length;
 });
 str += “’” + string.slice(index) + “’”;
 return str;
 }var template_str = render();
var fn = new Function(‘data’, template_str);
return fn;
 }