underscore模版编译compile

172 阅读7分钟

著有《React 源码》《React 用到的一些算法》《javascript地月星》等多个专栏。欢迎关注。

文章不好写,要是有帮助别忘了点个赞,收藏~你的鼓励是我继续挖干货的的动力🔥。

另外,本文为原创内容,商业转载请联系作者获得授权,非商业转载需注明出处,感谢理解~

本文代码实现来自冴羽。创建思路和内容均为原创。

前言

看到模版编译冴羽,觉得其中的过程不好理解,特别是字符串的处理,这里加一点那里加一点。所以尝试把模版编译变得好理解一点。希望这篇足够好理解。

第一部分 目标字符串

我们要把模版编译成目标字符串的样子,先了解目标字符串是什么样的。

模版:

<% for ( var i = 0; i < users.length; i++ ) { %>
  <li><a href="<%=users[i].url%>"><%=users[i].name%></a></li>
<% } %>

编译成:

var __p = '';
for ( var i = 0; i < users.length; i++ ) {
  __p += '<li><a href="' + users[i].url +'">' + users[i].name +'</a></li>'
}
return __p;

运行后输出:

<li><a href="http://localhost"></a></li>
<li><a href="http://localhost">Casper</a></li>
<li><a href="http://localhost">Frank</a></li>

用函数包裹

function render(){
  var __p = '';
  for ( var i = 0; i < users.length; i++ ) {
    __p += '<li><a href="' + users[i].url +'">' + users[i].name +'</a></li>'
  }
  return __p;
}

用new Function实现,var 函数名 = new Function(函数参数,函数体)

我们要使用new Function创建函数,还需要变成字符串的形式,new Function会将字符串转换成上面的可执行脚本。

let source = 
"var __p = '';"+
"for ( var i = 0; i < users.length; i++ ) {"+
"  __p += '<li><a href=\"' + users[i].url +'\">' + users[i].name +'</a></li>'"+
"}" +
"return __p;"
let render = new Function('obj',source);

这个字符串将是我们目标,我们要把原本的模版弄成这个字符串的样子就基本实现模版编译了。

所以我们要不断的把编译函数输出的字符串和这个结果对照。

加上with

定义作用域,users[i].url实际上就是obj.user[i].url

function render(obj){
  with(obj||{}) {
    var __p = '';
    for ( var i = 0; i < users.length; i++ ) {
      __p += '<li><a href="' + users[i].url +'">' + users[i].name +'</a></li>'
    }
  }
  return __p;
}
var source = 
"with(obj||{}){"+
"  var __p = '';"+
"  for ( var i = 0; i < users.length; i++ ) {"+
"  __p += '<li><a href=\"' + users[i].url +'\">' + users[i].name +'</a></li>'"+
"	}" + 
"}" +
"return __p;"
let render = new Function('obj',source);

直接用上面手写的拼接字符串测试

使用jsdom在nodejs中能够使用dom

import { JSDOM } from 'jsdom';
// 创建一个虚拟 DOM 环境
const dom = new JSDOM(`
  <!DOCTYPE html>
    <html>
    <head>
        <title>template</title>
    </head>

    <body>
      <div id="container"></div>
    </body>
</html>
`);
const document = dom.window.document;
var results = document.getElementById("container");

var data = {
  users: [
    { "url": "http://localhost" },
    { "name": "Casper", "url": "http://localhost" },
    { "name": "Frank", "url": "http://localhost" }
  ]
}
var source = 
"with(obj||{}){"+
"  var __p = '';"+
"  for ( var i = 0; i < users.length; i++ ) {"+
"  __p += '<li><a href=\"' + users[i].url +'\">' + users[i].name +'</a></li>'"+
"	}" + 
"}" +
"return __p;"
let compiled = new Function('obj',source);
results.innerHTML = compiled(data);
console.log(results.innerHTML);
<li><a href="http://localhost">undefined</a></li><li><a href="http://localhost">Casper</a></li><li><a href="http://localhost">Frank</a></li>

掘金的“写文章"系统的粘贴功能似乎有点问题,没有处理转义符\,从笔记上粘贴到这里,很多转义符丢失了,所以我手动一个个加上了\,可能有疏漏,导致运行的错误。

第二部分 实现模版编译函数

我们要如何才能从模版编译到字符串格式呢?

使用正则匹配到<%=xxx%>和<%xxx%>匹配

let reg = new RegExp(/<%=([\s\S]+?)%>|<%([\s\S]+?)%>/,'g')
text.replace(reg,function(match, p1, p2, offset) {
    match是整个匹配的子字符串的内容包含了模版符号<%=xxx1%> 和 <%xxx2%>
    p1是正则中第一个()的内容xxx1
    p2是xxx2
    offset是匹配到的位置
    replace支持全局匹配,正则加了'g',进行循环的匹配
})

处理不匹配的内容

除了匹配的内容还有位于两个<%=%>或<%%>之间正则没有匹配到的内容

<% for ( var i = 0; i < users.length; i++ ) { %>
  <li><a href="<%=users[i].url%>"><%=users[i].name%></a></li>
<% } %>

<li><a href=""></a></li>就是不匹配的。

let reg = new RegExp(/<%=([\s\S]+?)%>|<%([\s\S]+?)%>/,'g')
let index = 0;
let source = '';
text.replace(reg,function(match, p1, p2, offset) {
   source += text.slice(index, offset)
   // index更新为 匹配子字符串的长度+匹配到的位置
   index = offset + match.length;
})

把匹配的和不匹配的加起来就是完整的内容

function template(text) {
  let reg = new RegExp(/<%=([\s\S]+?)%>|<%([\s\S]+?)%>/, 'g');
  let index = 0;
  let source = '';
  text.replace(reg, function (match, p1, p2, offset) {
    source += text.slice(index, offset);
    index = offset + match.length;

    if (p1) {
      source += p1;
    } else if (p2) {
      source += p2;
    }
    console.log(source);
    console.log('--------');
  })
}

输出:目前差不多是这个字符串:

"for ( var i = 0; i < users.length; i++ ) {" +
"    <li><a href=\"users[i].url\">users[i].name</a></li>" +
" }"

我们的编译目标是:

"var __p = '';"+
"for ( var i = 0; i < users.length; i++ ) {"+
"  __p += '<li><a href=\"' + users[i].url +'\">' + users[i].name +'</a></li>'"+
"}" +
"return __p;"

还要加上__p。users[i].url/name也还没有从字符串中独立出来。

加上__p, 提出user[i].url/name,顺便加上with

function template(text) {
  let reg = new RegExp(/<%=([\s\S]+?)%>|<%([\s\S]+?)%>/, 'g');
  let index = 0;
  let source = 'var __p = \'';
  text.replace(reg, function (match, p1, p2, offset) {
    source += text.slice(index, offset);
    index = offset + match.length;

    if (p1) {
      source += '\'+'+p1 + '+\'';
    } else if (p2) {
      source += '\';'+ p2 + '\n__p+=\'';
    }
    console.log(source);
    console.log('--------');
  })
  source +='\'; return __p';
  source = 'with(obj){' + source + '}';//顺便加上
  console.log(source);
}

输出:

"with(obj){var __p = '"+
"            '; for ( var i = 0; i < users.length; i++ ) { "+
"__p+='"+
"                <li><a href=\"'+users[i].url+'\">'+users[i].name+'</a></li>"+
"            '; } "+
"__p+=''; return __p;}"

修复Bug

"var __p = '"和下一个"';for(....)"之间空格太多了出现语法错误。加上trim()

source += text.slice(index, offset).trim();

输出:

"with(obj){var __p = ''; for ( var i = 0; i < users.length; i++ ) { "+
"__p+='<li><a href=\"'+users[i].url+'\">'+users[i].name+'</a></li>'; } "+
"__p+=''; return __p;}"

完成✅。

最终的代码

import { JSDOM } from 'jsdom';
function template(text) {
  let reg = new RegExp(/<%=([\s\S]+?)%>|<%([\s\S]+?)%>/, 'g');
  let index = 0;
  let source = 'var __p = \'';
  text.replace(reg, function (match, p1, p2, offset) {
    source += text.slice(index, offset).trim();
    index = offset + match.length;

    if (p1) {
      source += '\'+'+p1 + '+\'';
    } else if (p2) {
      source += '\';'+ p2 + '\n__p+=\'';
    }
    // console.log(source);
    // console.log('--------');
  })
  source +='\'; return __p;';
  source = 'with(obj){' + source + '}';
  console.log(source);
  let render = new Function('obj',source);
  return render;
}

第三部分 转义符

举个例子:

字符串"var a = '1\\n23';console.log(a)"

\n是在字符串中是换行,\\n是让字符串把\当成普通的斜线,它的换行含义不表现在当前字符串中"var a = '1\\n23';console.log(a)"

而是表现在脚本var a = '1\n23';console.log(a)'1\n23'中。

var fn = new Function("var a = '1\\n23';console.log(a)");会把字符串转换成可执行脚本:

function fn (){
  var a = '1\n23';console.log(a);
}

在可执行脚本var a = '1\n23'的字符串'1\n23'中生效。输出1换行23。

var fn = new Function("var a = '1\n23';console.log(a)");会报错,因为字符串\n会换行,双引号字符串换行需要需要+做拼接处理。

1\\23同理,\23的含义不表现在"var a = '1\\23';console.log(a)",而表现在下一个字符串中var a = '1\23';console.log(a)

然后有很多形态:

var log = new Function("var a = '1\\23';console.log(a)");
var log = new Function("var a = '1\\n23';console.log(a)");
var log = new Function("var a = '\taaa';console.log(a)");相当于"var a  = '	aaa';..."
var log = new Function("var a = '\\taaa';console.log(a)");相当于var a = '	aaa';...
var log = new Function("var a = '\"sssa\"';console.log(a)"); "和外部的"冲突了,\"避免冲突,相当于var a = '"sssa"'
var log = new Function("var a = '\'sss\'';console.log(a)");相当于var a = ' 'sss' ';...  '冲突了所以报错。var a = ''sss'';console.log(a)。
var log = new Function('var a = "\"sss\"";console.log(a)');同上报错,var a = ""sss"";console.log(a)。
var log = new Function('var a = "\\\'sss2\\\'";console.log(a)');相当于var a = "\'sss2\'",输出 ‘sss2‘,把引号当成普通字符串打印了出来。

如果模版中有转义符

 <script type="text/html" id="user_tmpl">
    <% for ( var i = 0; i < users.length; i++ ) { %>
        1\\23  a\nbc  'abc'  \u2028 ggg \u2029 hhh
        <li><a href="<%=users[i].url%>"><%=users[i].name%></a></li>
    <% } %>
</script>

document.getElementById(user_tmpl)是字符串,

"<% for ( var i = 0; i < users.length; i++ ) { %>" +
"   1\\23  a\nbc  'abc'  \u2028 ggg \u2029 hhh" + 
"   <li><a href=\"<%=users[i].url%>\"><%=users[i].name%></a></li>" +
"<% } %>"

1\\23而不是1\23因为字符串中使用\23会提示语法错。这也是我们后续在正则中用\\而不是\进行匹配的原因。我们是为了处理\\而不是\

var escape = {
     ...
    '\\': '\\\\',
    '\': '\\' -->❌无效的字符
  }
  
 "\✅   1\23❌不允许使用八进制转义序列。请使用语法“\x13”。  a\nbc  'abc'  \u2028 ggg \u2029 hhh"
 不处理\

如果模版中有转义符并且我们不想这个转义符在字符串中生效,而是在脚本内的字符串中生效,我们需要对这些转义符做处理。

处理'因为会和字符串拼接冲突例如模版text= "...'abc'...", source='with(){for(){__p=\' \'abc\' \''}}__p有一对'abc‘有一对'

处理\n因为用双引号的字符串直接换行没有做拼接会报错。

处理\\因为字符串"1\\\\23",脚本中1\\23,脚本会把\当成普通字符,在控制台中打印1\23。不处理\\,"1\\23"不先变成"1\\\\23",那么脚本中1\23,脚本把\23当成转义符,可能导致错误。

处理\u2028是行分隔符会被浏览器理解为换行,而在Javascript的字符串表达式中是不允许换行的,从而导致错误。

处理\u2029是段落分隔符。unicode中的特殊字符都建议进行处理。

function template(text) {
  let reg = new RegExp(/<%=([\s\S]+?)%>|<%([\s\S]+?)%>/, 'g');
  // \和'
  var reg2 = /[\\\'\n\u2028\u2029]/g;
  var escape = {
    '\'': '\\\'',// \\\'分解成 \\和\'
    '\\': '\\\\', // 1\\23
    '\n': '\\n',
    '\u2028': '\\u2028',
    '\u2029': '\\u2029'
  }
  let index = 0;
  let source = 'var __p = \'';
  text.replace(reg, function (match, p1, p2, offset) {
    source += text.slice(index, offset).trim().replace(reg2, function (match) {
      return escape[match];
    });
    index = offset + match.length;

    if (p1) {
      source += '\'+'+p1 + '+\'';
    } else if (p2) {
      source += '\';'+ p2 + '\n__p+=\'';
    }
    // console.log(source);
    // console.log('--------');
  })
  source +='\'; return __p;';
  source = 'with(obj){' + source + '}';
  console.log(source);
  let render = new Function('obj',source);
  return render;
}