preface

Modern front-end frameworks use THE MVVM architecture, which allows developers to render pages by defining state data and performing modifications, freeing front-end engineers from spending a lot of time in dom manipulation.

Template compilation plays an important role in the implementation of MVVM. Only through template compilation can data state and HTML strings be combined back to the browser for rendering. This article will separate out the template compilation to explain its core code, you can see what the bottom of the way to achieve. Requirements are as follows:

const data = { num: 1, price: [ { value: 60, }, ] }; const template = '<div>{{ -1*price[0].value && 12+(8*! 6-num)*10+2 }}</div>'; const compiler = new Parser(template); console.log(compiler.parse(data)); //<div>4</div>Copy the code

Given a template string, write a Parser class that passes data as a status parameter to the instance method and expects the output to be

4

.

Many students think that this is actually very simple, just the expression of the code in the with statement in a function block output.

This is certainly a quick way to do it, as many frameworks on the market use this method to compile. However, with has its limitations. The statement wrapped with will greatly reduce the computing speed in the execution process. Inspired by the AngularJs source code, this article takes a different approach to compiling expressions (the full code is posted at the end).

The principle of interpretation

{{… }} the expressions inside are the most important ones to pay attention to. Expressions can list many symbols, such as the arithmetic operators ‘+’,’-‘,’*’,’/’, and ‘%’.

And logical operators should also support, including ‘&’, ‘| |’, ‘= =’. There are also parentheses and decimal ‘()’,'[]’,’.’ handlers.

In addition to these notations, how variables in expressions relate to states in data, and how variables are handled when combined with ‘()’,'[]’, and ‘.’.

From the above description, it is difficult to come up with a foolproof solution to support all cases in the beginning. Let’s change the way of thinking, from simple to complex, symbol by symbol to do, start with the simplest requirements.

String cutting

Before we can deal with the expression formally, we need to do some preparatory work first by putting {{… }} the wrapped expression is separated from the template string. For example, there is a template to compile as follows:

  const template = "<div><p>{{ name }}</p>{{ age + 100 }}</div>";
Copy the code

The current requirement is clear, separating {{… }} in the expression, only get all the expression can start the next stage of compilation.

A list of expressions can be stored using a variable exp_list = [], and a variable fragments can be defined to store non-expression HTML strings. The result of the expression is not returned to the user until it is concatenated with the HTML string.

Now we need to compile a function that cuts the string separation expression, and expect the output as follows.

  compile(template){
     ...
   
   exp_list = [{index:1,exp:"name"},{index:3,exp:"age + 100"}];
   
   fragments = ["<div><p>","","</p>","","</div>"];
   
  }
Copy the code

If we design the returned data structure to look like the above, the rest of the work will be easy.

Exp_list stores a list of expressions for template strings, and its index property represents the index of the current expression in fragments space, and exp is the content of the expression.

This is where the code comes in, and it’s pretty clear. Loop through each expression of exp_list, compute the result, populate it with fragments, and join the contents of the fragments array to form a string.

How to compile the compile function, the core code is as follows:

compile(exp){ let i = 0, last = 0, tmp = '', record = false; while (i < exp.length) { if (exp[i] === '{' && exp[i + 1] === '{') { this.fragments.push(exp.slice(last, i), ''); last = i; record = true; // start recording I += 2; continue; } else if (exp[i] === '}' && exp[i + 1] === '}') { this.exp_list.push({ index: this.fragments.length - 1, exp: exp.slice(last + 2, i), }); last = i + 2; record = false; tmp = ''; } else if (record) { tmp += exp[i]; } i++; }... }Copy the code

Exp corresponds to the template string to be compiled, and the judge matches {{and}} by traversing the string. If you encounter anything to the left of {{, categorize it into a static HTML string and store it in fragments. }} is a string that can be stored in exp_list.

Expression parsing

Now that you’ve successfully obtained the expression list exp_list from the previous stage, you just need to iterate through the list and fetch each expression and calculate the result.

If the expression extracted from the template string {{100 + age + 10}} is “100 + age + 10”, how should this expression be evaluated?

Assuming data = {age:10}, it is obvious that the above expression cannot be evaluated directly. Since age is the state we defined in our data object, if the expression can be converted to 100 + data.age +10, the result is 120.

Now, how do I know which characters are attached to the data state and which are not?

A close look at the above expression reveals some patterns. The key link in any expression is the operation symbol, which holds all the data together. For example, the + sign above, the + to the left or the + to the right are the two elements that are being added. It follows that the element on either side of the plus sign could be the constant 100, or it could be the state age.

Having discovered the rules of symbolic cohesion, we can cut strings into symbols. The goal is to separate symbols from elements and categorize elements precisely as constants or states.

For example, in the above expression “100 + age + 10”, we expect to write a parseExpression function that returns the following result:

  parseExpression(expression) {
     ...
    result = [
       {
          type: 'state',
          exp:"100",
          isConstant:true
       },
       {
          type: 'symbal',
          exp:"+"
       }
       {
          type: 'state',
          exp:"age",
          isConstant:false
       },
       {
          type: 'symbal',
          exp:"+"
       },
       {
          type: 'state',
          exp:"10",
          isConstant:true
       },
    ] 
  }
Copy the code

Type :’symbal’ means that the current item is an operation symbol, and type:’state’ means that the current item is a state variable. An additional attribute isConstant is added to identify whether the current data is a numeric constant 100 or a state variable data[age] that needs to be converted.

If you have a data structure like the one above, the expression “100 + age + 10” can be easily computed. The result loop is iterated, and each element and symbol is taken out in turn. If the element is a numeric constant, it will directly participate in the calculation; if the element is a state, it will use data to get the data and then participate in the symbolic operation.

The parseExpression function is used to process the expression string and return the data structure as result. The core code of parseExpression is as follows: expression represents the expression string.

parseExpression(expression) { const data = []; let i = 0, tmp = '', last = 0; while (i < expression.length) { const c = expression[i]; if (this.symbal.includes(c)) { let exp = expression.slice(last, i), isConstant = false; If (this.isConstant(exp)) {// isConstant = true; } // It is a character data.push({type: 'state', exp, isConstant,}); data.push({ type: 'symbal', exp: c, }); last = i + 1; } i++; }... return data; }Copy the code

The above case only symbol +, but the actual expression symbol also contains a -, *, /, %, [and], (,), &&, | |,! At present, these symbols are defined in an array symbal. Similarly, symbols and elements can be classified according to the logic of +, and then elements can be divided into constants and state variables.

Symbol resolution

If an expression is all additive, converting to the above data structure and iterating through the loop yields the corresponding result. But if the expression is 10 + age * 2. Follow the above parsing steps to convert the data structure into the following format:

 result = [
     {
          type: 'state',
          exp:"10",
          isConstant:true
     },
     {
          type: 'symbal',
          exp:"+"
     },
     {
          type: 'state',
          exp:"age",
          isConstant:false
     },
     {
          type: 'symbal',
          exp:"*"
     },
     {
          type: 'state',
          exp:"2",
          isConstant:true
     }
 ]
Copy the code

The result array cannot be iterated directly after the multiplication because the expression 10 + age * 2 should be multiplied before it is added.

To make multiplication take precedence over addition, we can multiply result first, convert age*2 to a whole, and then add to ensure precedence. We expect the data structure of result to look something like this after multiplication.

result = [ { type: 'state', exp:"10", isConstant:true }, { type: 'symbal', exp:"+" }, { type: 'state' catagory: "*", left: "age", right: "2" getValue (scope) = > {... / / returns the value of the age * 2}}]Copy the code

This data structure becomes the familiar addition, which is obtained by iterating through the result array for symbols and element values. The third element of result is obtained by calling getValue to get the product of age * 2.

Now let’s look at how this layer of multiplication is implemented.

function exec(result){ for (let i = 0; i < result.length; I++) {if (result [I] type = = = 'symbal' && result [I]. J exp = = = "*") {/ / delete three elements const TMP = result. The splice (I - 1, 3); // Combine new elements const left = TMP [0]; const right = tmp[2]; Const new_item = {type: 'state', catagory: TMP [1]. Exp, getValue: (data) => {// data corresponds to state data const lv = data[left]; // left = 'age' const rv = right; // right = 2 return lv * rv; }}; Result.splice (i-1, 0, new_item); // Insert new element result.splice(i-1, 0, new_item); // modify index I -= 1; }}}Copy the code

The result array is iterated to determine whether the symbol of the current element is *. If it matches, the left and right elements involved in the multiplication operation on both sides of * are retrieved.

Remove the left element,*, and right element from the result array, combine them into a new element, new_item, and insert it back in place. The getValue of new_item corresponds to the product of age * 2.

GetValue is a simplified version of left and right constants. If it is a numeric constant, it is directly extracted and calculated. If it is a state, it is necessary to call data to obtain the value of the state and then participate in the calculation.

By the same token, it’s easy to have only + and * in an expression. In fact, there are many symbols, such as (),[],&&, etc. But no matter which notation they are, they have a different order of precedence, just as multiplication takes precedence over addition. In the same way, parentheses take precedence over multiplication, while addition takes precedence over logical symbols such as &&.

There are many symbols, but they do the same thing as *, which first converts the higher-priority symbols and elements into a new element, new_item, that reserves a getValue function to calculate the value of the whole. Once all the higher-priority operators have been converted, the array is left with simple operators that can easily iterate through the values to get the desired result.

If we sort the precedence of all the operators, we end up with a result array that looks like this:

Function optimize(result){function optimize(result){function optimize(result) = this.brackethanlder (result); // Handle '[' and ']' and '.' this.squreBracketHanlder(result); // Step 3 handle "!" result = this.exclamationHandler(result); // "*","/","%" this.superiorClac(result); // This. BasicClac (result); / / step 6 "&", "| |" and "= =" this logicClac (result); return result; }Copy the code

The parentheses are the highest priority of all operators and are therefore placed in the first step.

Assuming the existing expression 10 * (age + 2), the corresponding parsed result data structure is as follows.

const result = [
	 {
          type: 'state',
          exp:"10",
          isConstant:true
     },
     {
          type: 'symbal',
          exp:"*"
     },
     {
          type: 'symbal',
          exp:"("
     },
     {
          type: 'state',
          exp:"age",
          isConstant:false
     },
     {
          type: 'symbal',
          exp:"+"
     },
     {
          type: 'state',
          exp:"2",
          isConstant:true
     },
     {
          type: 'symbal',
          exp:")"
     }
]
Copy the code

If the bracketHanlder(result) function is used, it is easy to convert the data structure into the following form.

const result = [ { type: 'state', exp:"10", isConstant:true }, { type: 'symbal', exp:"*" }, { type: 'expression, exp: "age + 2," getValue (data) = > {... / / will be able to get the value of the age + 2}}]Copy the code

The processing logic of bracketHanlder function is as follows.

/** * bracketHanlder(result) {... // omit const exp = result.slice(start + 1, end).reduce((cur, next) => {return cur + next.exp; } "); // exp = "age+2" result.push({ type: 'expression', exp, getValue: (data) => { return this.parseExpression(exp)(data); }}); . / / omit}Copy the code

Start and end correspond to the index of ‘(‘ and ‘)’ in the result array, respectively, and ‘exp’ is the expression wrapped in parentheses.

The parenthesis is a little bit different from other operators because you can write any symbol inside the parenthesis, which is itself an expression.

In order to parse the contents of the parentheses, you can repeat the above process with the statement wrapped in the parentheses. The code simply calls parseExpression recursively and treats the contents of the parentheses as an expression.

When parseExpression is executed, it returns a parsing function that returns the value of the expression simply by passing it the status value.

[] can be preceded by a state, such as array[3], or a bracket, such as the multidimensional array[0][1], and the enclosed part of the bracket is an expression, array[1+age*2], which is treated like the parentheses.

The logic of the decimal point is relatively simple; it only needs to focus on the left and right elements. If the element on the left is a number, then something like 100.11, the whole thing is going to be treated as a decimal. If there is a state ob.score on the left, the whole thing is treated as an object.

There is no definite priority order for [] and. For example, there is a template {{list[0].students[0].name}}. Brackets may be computed before or after the decimal point.

Next, take a look at the core logic of parentheses and decimal point handling.

*/ squreBracketHanlder(result){// squreBracketHanlder(result){ If (result[I].exp === ']'){if(result[I].exp === ']'){if(result[I].exp === ']'){if(result[I].exp === ']') Result.splice (start_index-1,i-start_index+2); // Drop the contents wrapped in parentheses and the elements on the left. // Add a new element to make a new whole const left = // Const right = extra. Slice (2,extra. Leng-1).reduce((cur, Return cur + next.exp;}, ''); result.splice(start_index-1,0,{type:"state", category:"array", GetValue (data)=>{if(typeof left === "function"){return left(data)[this.deepparse (right)(data)];} else{// Return data[left][this.deepParse(right)(data)];}}}) // Modify index I = If (result[I].exp === '.') else if(result[I].exp === '.'){}}Copy the code

The workflows of squreBracketHanlder can be summarized as follows. Their core idea is the same as multiplication, combining elements and symbols into a whole.

For example, in the expression {{list[0].students[0].name}}, the function squreBracketHanlder will first process the list[0] and convert it to an entire Item1, then modify the loop index due to the deletion of elements, and then continue iterating.

I’m going to convert item1 and.students to Item2, and I’m going to change the index and I’m going to iterate.

Item2 and [0] convert to Item3, modify the index to continue traversal.

And then finally item3 and.name are converted to the last element item4 and then we do all the other operations.

Item4 has a getValue function that returns the value of the entire expression list[0].students[0].name.

The remaining operators, %,/,&&, and so on, have the same processing logic as *, requiring only the elements on both sides of the symbol to be converted into a whole new element.

The source code

The complete code