实现一个简单的模板引擎

hugo_seth

对现在的前端来说,模板是非常熟悉的概念。毕竟现在三大框架那么火,不会用框架还能叫前端吗?,而框架是必定有模板的。那我们写的模板是如何转换成 HTML 显示在网页上的呢?

我们先从简单的说起,静态模板一般用于需要 SEO 且页面数据是动态的网页。由前端编写好静态模板,后端负责将动态的数据和静态模板交给模板引擎,最终编译成 HTML 字符串返回给浏览器。这种时候我们用到的模板引擎可能是远古的 jsp,或是现在用的比较多的 pug(原来叫 jade)、ejs。

模板引擎做的就是编译模板的工作。它说白了就是一个函数:将模板字符串转换成 HTML 字符串。

我们先写一个最简单的静态模板编译函数:

正则替换

我们的模板和数据如下:

const tpl = '<p>hello,我是{{name}},职业:{{job}}<p>'

const data = {
  name: 'hugo',
  job: 'FE'
}

那我们想到的最简单的办法就是正则替换,当然我们别忘了要把前缀加上,name 要转换成 data.name

function compile(tpl, data) {
  const regex = /\{\{([^}]*)\}\}/g
  const string = tpl.trim().replace(regex, function(match, $1) {
    if ($1) {
      return data[$1]
    } else {
      return ''
    }
  })
  console.log(string) // <p>hello,我是hugo,职业:FE<p>
}

compile(tpl, data)

上面的编译函数在例子中是可以工作的,但要是我把模板和数据改一下呢?

const tpl = '<p>hello,我是{{name}},年龄:{{info.age}}<p>'

const data = {
  name: 'hugo',
  info: {
    age: 26
  }
}

这个时候控制台打印的就是:

<p>hello,我是hugo,年龄:undefined<p>

因为 data["info.age"] 的值是 undefined 。所以我们还要处理正则匹配到的字符串,这个时候再用正则已经非常不好做了。既然这样,不如就直接全改用字符串匹配:

字符串解析

function compile(tpl) {
  let string = ''
  tpl = tpl.trim()
  while (tpl) {
    const start = tpl.indexOf('{{')
    const end = tpl.indexOf('}}')
    if (start > -1 && end > -1) {
      if (start > 0) {
        string += JSON.stringify(tpl.slice(0, start))
      }
      string += '+ data.' + tpl.slice(start + 2, end).trim() + ' +'
      tpl = tpl.slice(end + 2)
    } else {
      string += JSON.stringify(tpl)
      tpl = ''
    }
  }
  console.log(string)
  // "<p>hello,我是"+ data.name +",年龄:"+ data.info.age +"<p>"

  return new Function('data', 'return ' + string)
}

compile(tpl)(data) // <p>hello,我是hugo,年龄:26<p>

这样我们新的编译函数就可以处理 {{info.age}} 这种嵌套属性的情况了。上面的 JSON.stringify 作用是给字符串的两端加上 ",然后转义字符串中的特殊字符。

虽然我们解决了嵌套属性的问题,但又面临更困难的问题,就是怎样让模板里插值支持像 {{ '名字是: ' + name }} 这样表达式。在这种情况下,我们是很难在每个正确的地方加 data. 前缀的,因为前缀只能加上变量前,而表达式里可能还有字符串。

使用 with 语句

我们考虑最简单的处理方式,也就是不加前缀了,使用 with 语句指定变量的作用域。所以我们只要编译后返回一个函数,在这个函数内使用 with 语句指定作用域,函数再返回 HTML 字符串。在下面的例子中,我使用的是 ejs 模板的语法:

const tpl = `<p>hello,我的<%= '名字是: ' + name %>,年龄:<%= info.age %><p>`

const data = {
  name: 'hugo',
  info: {
    age: 26
  }
}
function compile(tpl) {
  const ret = []
  tpl = tpl.trim()
  ret.push('var _data_ = [];')
  ret.push('with(data) {')
  while (tpl) {
    let start = tpl.indexOf('<%=')
    const end = tpl.indexOf('%>')
    if (start > -1 && end > -1) {
      if (start > 0) {
        ret.push('_data_.push(' + JSON.stringify(tpl.slice(0, start)) + ');')
      }
      ret.push('_data_.push(' + tpl.slice(start + 3, end) + ');')
      tpl = tpl.slice(end + 2)
    } else {
      ret.push('_data_.push(' + JSON.stringify(tpl) + ');')
      tpl = ''
    }
  }
  ret.push('}')
  ret.push('return _data_.join("")')
  return new Function('data', ret.join('\n'))
}

const fn = compile(tpl)
fn(data)
// <p>hello,我的名字是: hugo,年龄:26<p>

上面的编译函数将模板根据模板语法 <%=%> 分割成各个部分放入数组中,再将数组中的元素由换行符连接,成为 new Function 的函数体,生成的函数如下:

function(data/*``*/) {
  var _data_ = [];
  with(data) {
    _data_.push("<p>hello,我的");
    _data_.push('名字是: ' + name);
    _data_.push(",年龄:");
    _data_.push(info.age);
    _data_.push("<p>");
  }
  return _data_.join("")
}

我们再将 data 作为参数传入这个函数就可以得到期望的 HTML 字符串。

现在我们已经实现了能够编译插值是表达式的模板引擎。但我们还差一个非常重要的功能,那就是编译模板中的语句,如:for 循环和 if 语句。要实现编译语句的功能,我们必须将语句和插值区分开,因此要使用不同的模板语法:语句用 <% %>,插值则用<%= %>。那我们就可以将上面的编译函数稍微修改下,根据不同的语法分别处理,就可以支持模板语句了:

const tpl = `
<p>hello,我是<%= name + '-seth' %>,年龄:<%= info.age %><p>
<% if (info.age > 18 && info.age < 28){ %>
  <p>是个九零后中年人</p>
<% } %>
<h3>兴趣</h3>
<ul>
  <% for (var i = 0; i < interests.length; i++) { %>
    <li><%= interests[i] %></li>
  <% } %>
</ul>
`
const data = {
  name: 'hugo',
  info: {
    age: 26
  },
  interests: ['movie']
}
function compile(tpl) {
  const ret = []
  tpl = tpl.trim()
  ret.push('var _data_ = [];')
  ret.push('with(data) {')
  while (tpl) {
    let start = tpl.indexOf('<%')
    const end = tpl.indexOf('%>')
    if (start > -1 && end > -1) {
      if (start > 0) {
        ret.push('_data_.push(' + JSON.stringify(tpl.slice(0, start)) + ');')
      }
      if (tpl.charAt(start + 2) === '=') {
        ret.push('_data_.push(' + tpl.slice(start + 3, end) + ');')
      } else {
        ret.push(tpl.slice(start + 2, end))
      }
      tpl = tpl.slice(end + 2)
    } else {
      ret.push('_data_.push(' + JSON.stringify(tpl) + ');')
      tpl = ''
    }
  }
  ret.push('}')
  ret.push('return _data_.join("")')
  return new Function('data', ret.join('\n'))
}
const fn = compile(tpl)
fn(data)
// <p>hello,我的名字是: hugo,年龄:26<p>

//   <p>是个九零后中年人</p>

// <h3>兴趣</h3>
// <ul>
  
//     <li>movie</li>
  
// </ul>

这个修改后的编译函数没什么好解释的,就是根据不同的模板语法做不同的处理,最终返回的函数如下:

function(data /*``*/ ) {
  var _data_ = [];
  with(data) {
    _data_.push("<p>hello,我的");
    _data_.push('名字是: ' + name);
    _data_.push(",年龄:");
    _data_.push(info.age);
    _data_.push("<p>\n");
    if (info.age > 18 && info.age < 28) {
      _data_.push("\n  <p>是个九零后中年人</p>\n");
    }
    _data_.push("\n<h3>兴趣</h3>\n<ul>\n  ");
    for (var i = 0; i < interests.length; i++) {
      _data_.push("\n    <li>");
      _data_.push(interests[i]);
      _data_.push("</li>\n  ");
    }
    _data_.push("\n</ul>");
  }
  return _data_.join("")
}

这样我们就已经完成了一个功能简单的模板引擎。

阅读 4.1k

前端漫步
前端漫漫长路,一起学习!

Stay hungry,Stay foolish

541 声望
22 粉丝
0 条评论

Stay hungry,Stay foolish

541 声望
22 粉丝
文章目录
宣传栏