js模板引擎原理解析

前言

如今前端框架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,生成模板字符串

输出结果:

  <div>China</div>

2.条件判断

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

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

输出结果:

  <div>China</div>

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));

输出结果:

<div><span>male0</span><span>male1</span><span>male2</span></div>



值渲染

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

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

期待的结果:

 <div>China<span>male</span></div>

从上面的执行代码可知,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 +=  '<div>'+country+'<span>'+gender+'</span>'+'</div>';
                }			
			return _p;
	    }    
   }

string 现在为 '<div><?= country?><span><?= gender?></span></div>'

如果将 string 变换成 '<div>'+country+'<span>'+gender+'</span>'+'</div>' 就能实现模板编译.但现在碰到的问题是对string无论做任何处理也只能返回一个总的字符串,根本无法做到类似上面<div>添加单引号,而 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 +='<div>'+ country+'<span>'+ gender+'</span></div>';}return _p;
<div>China<span>male</span></div>



表达式处理

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

逻辑分析

最开始的想法把模板字符串想办法转化成下面形式就可以了,但实践中发现不管是 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 +='<div> '; if(country === "China"){ _p+=' <span>'+ gender+'</span> ';}_p+=' </div>';}return _p;

最终结果:

<div>  <span>male</span>  </div>



渲染HTML代码

    const data = {
      code: '<div style="color:red">name:张三</div>',
    };
    const template = compile('<div><?- country?></div>');
    console.log(template(data));

期待结果:

<div><div style="color:red">name:张三</div></div>

<?- ?>用来标记输出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 = {
	    //需要转译的队列
	    '&': '&amp;',
	    '<': '&lt;',
	    '>': '&gt;',
	    '"': '&quot;',
	    "'": '&hx27;',
	    '`': '&#x660;',
	  };
	
	  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;
}

输出结果:

<div>&lt;div style=&quot;color:red&quot;&gt;name:张三&lt;/div&gt;</div>



最终代码

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

function compile(string) {
  string = string.replace(/\n|\r\n/g, ''); //为了调用时兼容es6模板字符串

  /**
   * 将html字符串的特殊字符转义,预防xss攻击
   */
  function esacper(str) {
    const keyMap = {
      //需要转译的队列
      '&': '&amp;',
      '<': '&lt;',
      '>': '&gt;',
      '"': '&quot;',
      "'": '&hx27;',
      '`': '&#x660;',
    };

    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;
}

猜你喜欢

转载自blog.csdn.net/brokenkay/article/details/110533559