This article was first published in my zhihu column and forwarded to the Nuggets. For commercial use, please obtain my consent.

Respect for every serious writing of the front of the big guy, the end of the article gives my ideas of reference articles.

preface

For those of you who are reading this article, the original intention is to learn how to write a JavaScript template engine. For those of you who haven’t used a template engine before, let’s talk a little bit about what a template engine is.

If you have never used a template engine, but have tried to render a list on a page, the general approach is to concatenate strings as follows:

const arr = [{
	"name": "google"."url": "https://www.google.com"
}, {
	"name": "baidu"."url": "https://www.baidu.com/"
}, {
	"name": "Case"."url": "https://www.zhihu.com/people/Uncle-Keith/activities"
}]

let html = ' '
html += '<ul>'
for (var i = 0; i < arr.length; i++) {
	html += `<li><a href="${arr[i].url}">${arr[i].name}</a></li>`
}
html += '</ul>'
Copy the code

In the code above, I used ES6’s backquotation (‘ ‘) syntax to dynamically generate a UL list, which might not seem complicated (if string concatenation is used, it would be a lot more complicated), but there is one bad thing: the data and structure are strongly coupled. The problem with this is that if the data or structure changes, the above code needs to be changed, which is intolerable in current front-end development. What we need is loose coupling of data and structure.

If loose coupling is to be achieved, structure by structure, the data is fetched from the server and collated, then rendered from the template so that we can focus on JavaScript. This is how it works with a template engine. As follows:

HTML list

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

JS data

const arr = [{
	"name": "google"."url": "https://www.google.com"
}, {
	"name": "baidu"."url": "https://www.baidu.com/"
}, {
	"name": "Case"."url": "https://www.zhihu.com/people/Uncle-Keith/activities"
}]
const html = tmpl('list', arr)
console.log(html)
Copy the code

The printed result is zero

" 
       
https://www.google.com">google
  • https://www.baidu.com/">baidu
  • https://www.zhihu.com/people/Uncle-Keith/activities Copy the code

    As you can see from the above code, concatenation is achieved by passing structures and data into TMPL functions. And TMPL is what we call a template engine (function). So let’s implement this function.

    Implementation of a template engine

    Functions are used to stuff data into templates, but the internal implementation of functions is done by concatenating strings. And through the template way, can reduce the concatenation string error caused by the increase of time cost.

    The essence of template engine Function is to separate HTML structure from JavaScript statements and variables in the template, and dynamically generate HTML code with data through Function constructor + apply(call). If performance is a concern, the template can be cached.

    Please memorize the essence of the above, or even recite it.

    To implement a template engine function, there are roughly the following steps:

    1. A template for
    2. The HTML structure in the template is separated from JavaScript statements and variables
    3. Function + apply(call) dynamically generates JavaScript code
    4. Template cache

    OK, let’s see how:)

    1. A template for

    Typically, we write the template in a Script tag and assign an ID attribute to indicate that the template is unique. Assign the type=’text/ HTML ‘attribute to indicate that the MIME type is HTML, as follows

    <script type="text/html" id="template">
    	<ul>
    		<% if (obj.show) { %>
    			<% for (var i = 0; i < obj.users.length; i++) { %>
    				<li>
    					<a href="<%= obj.users[i].url %>">
    						<%= obj.users[i].name %>
    					</a>
    				</li>
    			<% } %>
    		<% } else{%> <p> Do not show list </p> <%} %> </ul> </script>Copy the code

    In the template engine, <% XXX %> is used to identify JavaScript statements, which are mainly used for flow control and have no output. <%= XXX %> identifies JavaScript variables used to output data to templates; The rest is HTML code. (Similar to EJS). Of course, you can also use < @xxx @>, <= @@ >, <* XXX *>, <*= XXX *>, etc.

    The first argument passed to the template engine function can be an ID or a template string. In this case, the re is used to determine whether it is a template string or an ID. The following

    let tpl = ' 'Const TMPL = (STR, data) => {// If it is a template string, it contains non-word parts (<, >, %, etc.); If it is an ID, you need to get it through getElementByIdif(! /[\s\W]/g.test(str)) { tpl = document.getElementById(str).innerHTML }else {
            tpl = str
        }
    }
    Copy the code

    2. Separate HTML structure from JavaScript statements and variables

    This step is the single most important step in the engine, and if it happens, it’s a big step. So we’re going to do it in two ways. Suppose we get a template string like this:

    " 
           
      <% if (obj.show) { %> <% for (var i = 0; i < obj.users.length; i++) { %>
    <%= obj.users[i].url %>"> < % = obj. The users [I] name % > < / a > < / li > < %} % > < %} else {% > < p > don't display list < / p > < %} % > < / ul >" Copy the code

    Let’s take a look at the first method, which is implemented primarily through the replace function. Explain the main process:

    1. Create array arr and concatenate string arr. Push (‘
    2. If a newline return is encountered, replace it with an empty string
    3. Replace with ‘) when <% is encountered;
    4. When >% is encountered, replace with arr.push(‘
    5. <%= XXX %>, combine steps 3 and 4, replace with ‘); arr.push(xxx); arr.push(‘
    6. Finally concatenate string ‘); return p.join(”);

    In your code, you need to write step 5 before steps 2 and 3 because it has a higher priority, or you will get a matching error. The following

    let tpl = ' 'Const TMPL = (STR, data) => {// If it is a template string, it contains non-word parts (<, >, %, etc.); If it is an ID, you need to get it through getElementByIdif(! /[\s\W]/g.test(str)) { tpl = document.getElementById(str).innerHTML }else {
          tpl = str
      }
      let result = `let p = []; p.push('` result += `${ tpl.replace(/[\r\n\t]/g, '') .replace(/<%=\s*([^%>]+?) \s*%>/g, "'); p.push(The $1); p.push('") .replace(/<%/g, "');") .replace(/%>/g, "p.push('") }` result += "'); return p.join(' ');"}Copy the code

    You’ll be able to concatenate HTML structure with JavaScript statements and variables by carefully examining each of these steps. The concatenated code looks like this (formatted code, otherwise no line breaks)

    " let p = []; p.push('
           
      '); if (obj.show) { p.push(''); for (var i = 0; i < obj.users.length; i++) { p.push('
    '); p.push(obj.users[i].url); p.push('">"); p.push(obj.users[i].name); p.push(''); } p.push(''); } else {p.ush ('

    don't show list

    '); } p.push(''); return p.join(''); "
    Copy the code

    The important thing to note here is that we can’t push JavaScript statements into an array. If you push it as a JS statement, an error will be reported. If you push it as a string, it doesn’t work, like for loops and if judgments. When JavaScript variables are pushed into an array, be careful not to push them as strings; otherwise, they will be invalid. Such as

    p.push('for(var i =0; i < obj.users.length; i++){') // invalid p.ush ('obj.users[i].name') // invalid p.ush (for(var i =0; i < obj.users.length; I++) {/ / an errorCopy the code

    As you can see from the template engine function, we concatenate the HTML structure with single quotes, and if you think about it for a moment, if you have single quotes in the template, it affects the execution of the whole function. Also, if a \ backquote occurs, the single quote is escaped. So you need to optimize the single and backquotes.

    1. \ backquotes encountered in template, need to escape
    2. A ‘single quote is encountered and needs to be escaped

    Convert to code, i.e

    str.replace(/\\/g, '\ \ \ \')
        .replace(/'/g, "\\'")
    Copy the code

    Combined with the above part, i.e

    let tpl = ' 'Const TMPL = (STR, data) => {// If it is a template string, it contains non-word parts (<, >, %, etc.); If it is an ID, you need to get it through getElementByIdif(! /[\s\W]/g.test(str)) { tpl = document.getElementById(str).innerHTML }else {
          tpl = str
      }
      let result = `let p = []; p.push('` result += `${ tpl.replace(/[\r\n\t]/g, '') .replace(/\\/g, '\ \ \ \') .replace(/'/g, "\ \" ") .replace(/<%=\s*([^%>]+?) \s*%>/g,"'); p.push(The $1); p.push('")
    	   .replace(/<%/g, "');")
    	   .replace(/%>/g, "p.push('")
      }`
      result += "'); return p.join('');"      
    }
    Copy the code

    The template engine functions here use ES6 syntax and regular expressions. If you are confused by regular expressions, you can learn about regular expressions first and then go back to this article.


    OK, let’s look at the second way to implement template engine functions. Unlike the first method, you don’t just use the replace function for a simple replacement. Here’s a quick idea:

    1. Requires a regular expression /<%=? \s*([^%>]+?) \s*%>/g, can match <% XXX %>, <%= XXX %>
    2. You need an auxiliary variable cursor that records the starting position of the HTML structure match
    3. The exec function is used, and the internal index value will change dynamically after each successful match
    4. Some of the remaining logic is similar to the first approach

    OK, let’s look at the actual code

    let tpl = ' '
    let match = ' '/ / recordexec// Matching template id const idReg = /[\s\W]/g // matching JavaScript statement or variable const tplReg = /<%=? \s*([^%>]+?) \s*%>/g const add = (str, result) => { str = str.replace(/[\r\n\t]/g,' ')
    		.replace(/\\/g, '\ \ \ \')
    		.replace(/'/g, "\\'")
    	result += `result.push('${string}'); 'return result} const TMPL = (STR, data) => {// Let cursor = 0 let result = 'let result = []; '// If it is a template string, it will contain non-word parts (<, >, %, etc.); If it is an ID, you need to get if (! Idreg.test (STR)) {TPL = document.getelementById (STR).innerhtml} else {TPL = STR} While (match = tplreg. exec(TPL)) {result = add(tpl.slice(cursor, mate.index), Result = add(match[1], Cursor = match. Index + match[0].length} result = add(TPL. Slice (cursor), Result += 'return result.join("")'
    }
    console.log(tmpl('template'))
    Copy the code

    The add function is used to optimize the template string to prevent illegal characters (newline, carriage return, single quote ‘, backquote \, etc.). After execution, the code is formatted as follows (there is no newline, because it is replaced with an empty string, for good looks..) .

    " let result =[];
    result.push('<ul>');
    result.push('if (obj.show) {');
    result.push('');
    result.push('for (var i = 0; i < obj.users.length; i++) {');
    result.push('<li><a href="');
    result.push('obj.users[i].url');
    result.push('">"); result.push('obj.users[i].name'); result.push(''); result.push('}'); result.push(''); result.push('} else {'); Result.push ('

    '); result.push('}'); result.push(''); return result.join("
    ")" Copy the code

    From the above code, you can see that the HTML structure is pushed into the Result array as a string. But JavaScript statements are also pushed in, variables are pushed in as strings… The reason for this is the same as in the first method, which is to separate statements and push variables into the array by themselves. Modify the code

    let tpl = ' '
    let match = ' '/ / recordexec// Matching template id const idReg = /[\s\W]/g // matching JavaScript statement or variable const tplReg = /<%=? \s*([^%>]+?) \s*%>/g const keyReg = /(for|if|else|switch|case|break| {|})/g / / * * * * to increase regular match statement const add = (STR, result, js) = > {STR = STR. Replace (/] [\ r \ n \ t/g,' ')
    		.replace(/\\/g, '\ \ \ \')
    		.replace(/'/g, "\\'") // **** add ternary expression judgment, three cases: JavaScript statements, JavaScript variables, HTML structures. result += js ? str.match(keyReg) ? `${str}` : `result.push(${str}); ` : `result.push('${str}'); 'return result} const TMPL = (STR, data) => {// Let cursor = 0 let result = 'let result = []; '// If it is a template string, it will contain non-word parts (<, >, %, etc.); If it is an ID, you need to get if (! Idreg.test (STR)) {TPL = document.getelementById (STR).innerhtml} else {TPL = STR} While (match = tplreg. exec(TPL)) {result = add(tpl.slice(cursor, mate.index), Result = add(match[1], result, True) // **** match JavaScript statement, variable cursor = match. Index + match[0]. Length // Change HTML result matching start position} result = Add (TPL. Slice (cursor), result) // match remaining HTML structure result += 'return result.join("")'
    }
    console.log(tmpl('template'))
    Copy the code

    The code is formatted as follows

    " let result = []; result.push('
           
      '); if (obj.show) { result.push(''); for (var i = 0; i < obj.users.length; i++) { result.push('
    '); result.push(obj.users[i].url); result.push('">"); result.push(obj.users[i].name); result.push(''); } result.push(''); } else {result.push('

    '); } result.push(''); return result.join("
    ")" Copy the code

    So far, our requirements have been met.

    The implementation of the two template engine functions has been covered, so here is a summary

    1. Both methods use arrays, and join once the concatenation is complete
    2. The first method simply uses the replace function and replaces it once the match is successful
    3. The second method uses the exec function to capture HTML structures, JavaScript statements, and variables with its dynamically changing index value

    Of course, you can use string concatenation either way, but I compared arrays in Chrome, and arrays are still much faster, so this is an optimization: array concatenation is about 50% faster than string concatenation! The following is a validation of string and array concatenation

    console.log('Start calculating string concatenation')
    const start2 = Date.now()
    let str = ' '
    for (var i = 0; i < 9999999; i++) {
      str += '1'} const end2 = date.now () console.log(' string concatenation run time:${end2 - start2}`ms)
    
    console.log('-- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -')
    
    console.log('Start calculating array concatenation')
    const start1 = Date.now()
    const arr = []
    for (var i = 0; i < 9999999; i++) {
      arr.push('1')
    }
    arr.join(' ') const end1 = date.now () console.log(' array concatenation run time:${end1 - start1}`ms)
    Copy the code

    The results are as follows:

    Start computing string concatenation string concatenation running time: 2548ms ---------------- start computing array concatenation running time: 1359msCopy the code

    Function + apply(call) dynamically generates HTML code

    In both cases, result is a string, so how do you turn it into executable JavaScript code? The Function constructor is used to create a Function (eval is also possible, but not recommended).

    In most cases, creating a function is done directly using a function declaration or function expression

    function test () {}
    const test = function test () {}
    Copy the code

    Functions generated in this way become instance objects of the Function constructor

    test instanceof Function   // true
    Copy the code

    It is also possible to create a Function directly using the Function constructor, which is slightly less performance (double parsing, JavaScript parses JavaScript code, code contained in strings, This means that a new parser must be started while the JavaScript code is running to parse the new code. Instantiating a new parser has significant overhead, so this code is much slower than direct parsing.

    const test = new Function('arg1'.'arg2'. .'console.log(arg1 + arg2)')
    test1 plus 2 over 3Copy the code

    You can’t have both fish and bear paw, rendering convenience at the same time brings some performance loss

    The Function constructor can pass in multiple arguments, the last of which represents the statement to execute. So we can do this

    const fn = new Funcion(result)
    Copy the code

    If you need to pass in parameters, you can use call or apply to change the scope in which the function is executed.

    fn.apply(data)
    Copy the code

    4. Template cache

    The reason for using templates is not only to avoid unnecessary errors by manually concatenating strings, but also to reuse template code in certain scenarios. To avoid concatenating strings from the same template multiple times, you can cache the template. We’re going to cache it here and we can cache it when we pass in an ID. The logic of the implementation is not complicated, as you will see in the following code.

    All right, with everything I’ve said above, here’s the final code for the template engine implemented in both ways

    The first method:

    let tpl = ' '// Matches the id of the templateletIdReg = /[\s\W]/g const cache = {} const add = TPL => {// Replace the matched valuereturn tpl.replace(/[\r\n\t]/g, ' ')
    		.replace(/\\/g, '\ \ \ \')
    		.replace(/'/g, "\\'") .replace(/<%=\s*([^%>]+?) \s*%>/g, "'); p.push($1); p.push('") .replace(/<%/g, "');" ) .replace(/%>/g, "p.push('") } const tmpl = (str, data) => { let result = `let p = []; P.ush (' // contains non-word parts (<, >, %, etc.) if it is a template string; If it is an ID, you need to get if (! idReg.test(str)) { tpl = document.getElementById('template').innerHTML if (cache[str]) { return cache[str].apply(data) }  } else { tpl = str } result += add(tpl) result += "'); return p.join('');" Let fn = new Function(result) if (! cache[str] && ! [STR] = fn} return fn. Apply (data) // apply changes the scope of function execution}Copy the code

    The second method:

    let tpl = ' '
    let match = ' 'Const cache = {} // Matching template ID const idReg = /[\s\W]/g // matching JavaScript statements or variables const tplReg = /<%=? \s*([^%>]+?) Const keyReg = /(const keyReg = /(for|if|else|switch|case|break|{|})/g
    
    const add = (str, result, js) => {
    	str = str.replace(/[\r\n\t]/g, ' ')
    		.replace(/\\/g, '\ \ \ \')
    		.replace(/'/g, "\\'")
    	result += js ? str.match(keyReg) ? `${str}` : `result.push(${str}); ` : `result.push('${str}'); ` return result } const tmpl = (str, data) => { let cursor = 0 let result = 'let result = []; '// If it is a template string, it will contain non-word parts (<, >, %, etc.); If it is an ID, you need to get if (! Idreg.test (STR)) {TPL = document.getelementById (STR).innerhtml }} else {TPL = STR} While (match = tplreg. exec(TPL)) {result = add(tpl.slice(cursor, mate.index), Result = add(match[1], result, Cursor = match. Index + match[0]. Length} result = add(TPL. Slice (cursor), Result += 'return result.join("")' let fn = new Function(result) if (! cache[str] && ! [STR] = fn} return fn. Apply (data) // apply changes the scope of function execution}Copy the code

    The last

    Whoo, that’s basically it. I just want to conclude a little bit

    If! If you are asked to give a general description of how a JavaScript template engine works, the following summary may help.

    Oh.. The principle of template engine implementation is roughly to separate the HTML structure in the template from JavaScript statements and variables, push the HTML structure into the array in the form of strings, extract the JavaScript statements independently, and push the JavaScript variables into the array by themselves. The HTML code with the data is built by replacing the replace Function or traversing the exec Function, and the executable JavaScript code is generated by the Function constructor + apply(call) Function.

    If you answer it, the interviewer suddenly found in his mind: You seem to be diao too? Then test the waters:

    1. Why an array? Can I use strings? What’s the difference?
    2. A simple use of the replace and exec functions?
    3. What is the difference between the exec and match functions?
    4. / < % =? \s*([^%>]+?) \s*%>/g
    5. What are the differences between apply, call, and bind?
    6. What are the drawbacks of using the Function constructor?
    7. What’s the difference between a function declaration and a function expression?
    8. .


    There are a lot of points that can be pulled out of this paragraph… Roll over, swift horse!


    OK, that’s it for implementing a simple JavaScript template engine. If you read this article patiently and carefully, I’m sure you’ll get a lot out of it. If you’re still confused, you can enjoy it a few more times if you don’t mind.


    Reference article:

    1. Recommended book: JavaScript Advanced Programming, 3rd Edition
    2. The simplest JavaScript template engine – Modest – Blog park
    3. Only 20 lines of Javascript code! Write a page template engine by hand