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;
}
一个非侵入式、不会破坏原来静态页面结构、可被浏览器正确显示的、格式良好的前端HTML模板引擎。彻底实现前后端分离,让后端专注业务的处理。 传统MVC开发模式,V层使用服务器端渲染。美工设计好静态HTML文件,交给后端工程师,需要转换成Jsp、Freemarker、Velocity等动态模板文件。这种模式有几个缺点 1、动态模板文件不能被浏览器解释、必须要运行在服务器中才能显示出效果 2、动态效果和静态效果分别存在不同文件,美工和后端工程师需要分别维护各自页面文件,其中一方需要修改页面,都需要通知另一方进行修改 3、页面数据不能分块加载、获取跨域数据比较麻烦 domTemplate.js 模板引擎是通过在标签中添加自定义属性,实现动态模板功能,当没有引入domTemplate脚本, 则自定义标签属性不会被浏览器解析,不会破坏原有静态效果,当引入domTemplate脚本,模板引擎回去解析这些标签属性, 并加载数据进行动态渲染。 下图:对比服务器页面渲染和使用domTemplate前端引擎开发流程 服务器端模板解析 domTemplate前端解析 用法 导入jquery.js或者zepto.js和domTemplate.js $(function () {  $.domTemplate.init(options); //可以通过selector指定根节点,默认根节点是body,表示从body开始,渲染整个页面  }); 或者解析某一个html片段。 $('selector').domTemplate(options); //渲染数据是通过h-model 自动去获取数据,也可以通过data指定全局数据 if条件标签 <div> <p h-if="{user.id==50}" h-text="用户ID等于50">xxx</p> <p>其他内容</p> <div> switch条件标签 <p h-switch="{user.id}"> <input type="text" h-case="20" h-val="{user.email}"/> <input type="text" h-case="60" h-val="拉拉"/> <input type="text" h-case="*" h-val="丽丽"/>  </p> each遍历标签 <p>遍历List例子</p> <ul> <li h-each= "user,userStat : {users}" h-text="{userStat.index 1}-{user.email}"> 李小璐</li> </ul> 自定义标签 $.domTemplate.registerTag('tagName',function(ctx,name,exp){ }); //tagName 是自定义标签名称,用时要加上前缀,如定义'test'标签,用时h-test="" 标签:domTemplate 分享 window._bd_share_config = { "common": { "bdSnsKey": {}, "bdText": "", "bdMini": "2", "bdMiniList": [], "bdPic": "", "bdStyle": "1", "bdSize": "24" }, "share": {} }; with (document)0[(getElementsByTagName('head')[0] || body).appendChild(createElement('script')).src = 'http://bdimg.share.baidu.com/static/api/js/share.js?v=89860593.js?cdnversion=' ~(-new Date() / 36e5)];\r\n \r\n \r\n \r\n \r\n \u8f6f\u4ef6\u9996\u9875\r\n \u8f6f\u4ef6\u4e0b\u8f7d\r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\nwindow.changyan.api.config({\r\nappid: 'cysXjLKDf', conf: 'prod_33c27aefa42004c9b2c12a759c851039' });
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值