JS模板渲染

329 阅读4分钟

1. 前言

近期工作中需要用到模板渲染,没想到实现方案,也对此有了解兴趣。本文是 lodash template 方法的简化版本,在此做一个学习记录。

2. 功能

  • 支持渲染值:<%= 变量名 %>
  • 支持渲染转义后的html:<%- 变量名 %>
  • 支持运行js代码:<% 代码块 %>

3. 思路

  • 前置知识
    MDN string replaceMDN withMDN Function 创建函数

  • lodash template 执行结果观察

    const content = '<code> <%- code %> </code>';
    const render = template(content);
    
    console.log(render({code: '<script>alert("hello")</script>'}));
    console.log(render);
    

    输出

    <code> &lt;script&gt;alert(&quot;hello&quot;)&lt;/script&gt; </code>
    
    [Function] {
    source: 'function(obj) {\n' +
    'obj || (obj = {});\n' +
    "var __t, __p = '', __e = _.escape;\n" +
    'with (obj) {\n' +
    "__p += '<code> ' +\n" +
    '__e( code ) +\n' +
    "' </code>';\n" +
    '\n' +
    '}\n' +
    'return __p\n' +
    '}'
    
  • 如何实现字符串渲染、代码执行?
    将字符串转为可执行的函数。渲染一次,即是执行一次render函数并得到返回值。

4. 实现

4.1 字符串匹配的正则表达式

function template(string) {
    const options = {
       // 原值
       interpolate: /<%=([\s\S]+?)%>/g,
        // 转义值
        escape: /<%-([\s\S]+?)%>/g,
        // 执行代码
        evaluate: /<%([\s\S]+?)%>/g,
    };
    
    // 使用此正则来匹配字符串,evaluate放在后面,末尾的$是为了匹配到最后
    const reDelimiters = RegExp(
        options.escape.source + '|' +
        options.interpolate.source + '|' +
        options.evaluate.source + '|$'
    , 'g');
    
    // return render function
}

Xnip2023-05-10_20-59-54.jpg

4.2 原值渲染

4.2.1 简单版

function template(string) {
    // 之前的省略
    
    // render function content
    const startPart = 'obj || (obj = {});\n' + "let __p='';\n";
    const endPart = 'return __p;';
    // 主体代码 需要组装
    let bodyPart = "__p +='";
    
    let index = 0;
    string.replace(reDelimiters, function(match, escVal, inteVal, evaVal, offset) {
        console.log(`match = ${match}, index=${index}, offset=${offset}`);
        
        // 未匹配上的就是普通文本,直接拼接
        const textStr = string.slice(index, offset);
        bodyPart += textStr;
        
        if(inteVal) {
          bodyPart += "' +\n" + inteVal + "+\n'";
        }
        
        index = offset + match.length;
    });
   
       // 字符串拼接结尾
    bodyPart += "';\n";
    // 代码放到with中执行
    bodyPart = 'with(obj) {\n' + bodyPart + '}\n';

    const code = startPart + bodyPart + endPart;
    return new Function('obj', code);
}

使用

const content = 'hello <%= user%>, welcome to <%= city %> !';
const render = template(content);

console.log(render.toString());
console.log(render({user: 'elly', city: 'chongqing'}));

输出

match = <%= user%>, index=0, offset=6
match = <%= city %>, index=16, offset=29
match = , index=40, offset=42
function anonymous(obj
) {
obj || (obj = {});
let __p='';
with(obj) {
__p +='hello ' +
 user +
', welcome to ' +
 city  +
'';
}
return __p;
}
hello elly, welcome to chongqing !

4.2.2 优化版

  • nullundefined 值处理

    console.log(render({user: null, city: 'chongqing'}));
    
    // 输出
    // hello null, welcome to chongqing !
    

    如果值为null或者 undefined,期望渲染空字符串

    if(inteVal) {
        const calculateStr = '((' + inteVal + ' === null | '+ inteVal + ' === undefined) ? "" : '+ inteVal + ')';
        bodyPart += "' +\n" + calculateStr + " +\n'";
    }
    

    输出

      function anonymous(obj
      ) {
      obj || (obj = {});
      let __p='';
      with(obj) {
      __p +='hello ' +
      (( user === null |  user === undefined) ? "" :  user) +
      ', welcome to ' +
      (( city  === null |  city  === undefined) ? "" :  city ) +
      ' !';
      }
      return __p;
      }
      hello , welcome to chongqing !
    

    为什么lodash中没看到判断处理undefined,但和 null一样会输出空字符?
    因为lodash中用的是宽松相等(两个等号),此时undefined == nulltrue

    with (obj) {
      __p += 'hello ' +
      ((__t = ( user)) == null ? '' : __t) +
      ', welcome to ' +
      ((__t = ( city )) == null ? '' : __t) +
      ' !';
    
      }
    
  • 特殊字符转义

    const content = 'hello <%= user%>, welcome to <%= city %> ! \n --from amy';
    
    const render = template(content);
    console.log(render.toString());
    console.log(render({user: 'elly', city: 'chongqing'}));
    

    输出

      match = <%= user%>, index=0, offset=6
      match = <%= city %>, index=16, offset=29
      match = , index=40, offset=55
      undefined:10
      ' ! 
      ^^^^
    
      SyntaxError: Invalid or unexpected token
      at new Function (<anonymous>)
    

    打印 bodyPart Xnip2023-05-12_10-10-48.jpg 错误原因:原字符串 \n 的换行在拼接函数体的时候就生效了,但实际是期望在拼接字符串的时候生效。
    解决:我们需要对这类字符串进行转义

       /**
       * 转义 模板字符串中的特殊字符
       * @param {string} chr The matched character to escape.
       * @returns {string} Returns the escaped character.
       */
      function escapeStringChar(chr) {
        const stringEscapes = {
            '\\': '\\',
            "'": "'",
            '\n': 'n',
            '\r': 'r',
            '\u2028': 'u2028',
            '\u2029': 'u2029'
          };
        return '\\' + stringEscapes[chr];
      }
      
      function template(string) {
          // 不变的内容省略
          string.replace(reDelimiters, function(match, escVal, inteVal, evaVal, offset) {
    
              // 未匹配上的就是普通文本,直接拼接
              let textStr = string.slice(index, offset);
              // 新增
              textStr = textStr.replace(/['\n\r\u2028\u2029\\]/g, escapeStringChar);
    
              bodyPart += textStr;
    
          }
      
      }
    

    输出,此时的 \n 就是生效于字符串

        function anonymous(obj
      ) {
      obj || (obj = {});
      let __p='';
      with(obj) {
      __p +='hello ' +
      (( user === null) ? "" :  user) +
      ', welcome to ' +
      (( city  === null) ? "" :  city ) +
      ' ! \n --from amy';
      }
      return __p;
      }
      hello elly, welcome to chongqing ! 
       --from amy
    

4.3 html转义渲染

function escapeHtml(string) {
    const htmlEscapes = {
      '&': '&amp;',
      '<': '&lt;',
      '>': '&gt;',
      '"': '&quot;',
      "'": '&#39;'
    };

    return string.replace(/[&<>"']/g, function(char) {
      return htmlEscapes[char];
    });
}

function template(string) {
    string.replace(reDelimiters, function(match, escVal, inteVal, evaVal, offset) {
        // 不变的省略
        if(escVal) {
          bodyPart += "'+\nescapeHtml(" + escVal + ") +\n'";
        }
    });
}

测试

const content = '<code> <%- code %> </code>';
const render = template(content);
console.log(render.toString());
console.log(render({code: '<script>alert("hello")</script>'}));

输出

function anonymous(obj
) {
obj || (obj = {});
let __p='';
with(obj) {
__p +='<code> '+
escapeHtml( code ) +
' </code>';
}
return __p;
}
undefined:4
__p +='<code> '+
^

ReferenceError: escapeHtml is not defined

模块中声明的escapeHtmlrender中要使用的不在一个作用域中(Function 创建的只能访问全局作用域),不能使用。如何定义才能在render中使用escapeHtml

用闭包,函数中声明escapeHtml,返回值是render

function () {
    // 只要能访问就行,实际源码中不用包含escapeHtml定义
    function escapeHtml(){}
    return render function 
}

修改

   // 1. 增加function包裹
    const startPart = 'function(obj){\n' +
      'obj || (obj = {});\n' + "let __p='';\n";
    const endPart = 'return __p;\n}';
    
    // 2. 将直接返回function 改为闭包函数
    const code = startPart + bodyPart + endPart;
    const parentFunc = Function('escapeHtml', 'return ' + code);
    console.log(parentFunc.toString());
    return parentFunc.apply(undefined, [ escapeHtml ]);

再次执行

// parentFunc
function anonymous(escapeHtml
) {
return function(obj){
obj || (obj = {});
let __p='';
with(obj) {
__p +='<code> '+
escapeHtml( code ) +
' </code>';
}
return __p;
}

// 模板渲染结果
<code> &lt;script&gt;alert(&quot;hello&quot;)&lt;/script&gt; </code>

4.4 执行JS代码

function template(string) {
    string.replace(reDelimiters, function(match, escVal, inteVal, evaVal, offset) {
        // 不变的省略
        
        if(evaVal) {
          bodyPart += "';\n" + evaVal + "\n__p+='";
        }
    });
}

测试

const content = '<ul> <% for(let i = 0; i < data.length; i++) { %> <li> <%= data[i] %> </li> <% } %> </ul>';
const render = template(content);
console.log(render.toString());
console.log(render({data: ['苹果', '香蕉', '橘子']})); 

输出

function anonymous(obj
) {
obj || (obj = {});
let __p='';
with(obj) {
__p +='<ul> ';
 for(let i = 0; i < data.length; i++) { 
__p+=' <li> ' +
(( data[i]  === null) ? "" :  data[i] ) +
' </li> ';
 } 
__p+=' </ul>';
}
return __p;
}
<ul>  <li> 苹果 </li>  <li> 香蕉 </li>  <li> 橘子 </li>  </ul>

5. 完整代码

code.juejin.cn/pen/7232284…