Photo: Vincent Guth
Note: All codes in this paper can be found in colon, my personal project, and this paper has also been synchronized to Zhihu column
You’ve probably already experienced the convenience of Vue, in part because of its dom-based template rendering engine with its concise syntax. This article will show you how to implement a DOM-based templating engine (like Vue’s templating engine).
Preface
Before we begin, let’s take a look at the final result:
const compiled = Compile('Hey 🌰, {{greeting}}
', {
greeting: `Hello World`}); compiled.view// => 'Hey 🌰, Hello World
'
Copy the code
Compile
Implementing a template engine is essentially implementing a compiler like this:
const compiled = Compile(template: String|Node, data: Object);
compiled.view // => compiled template
Copy the code
First, let’s look at how Compile is implemented internally:
// compile.js
/** * template compiler * * @param {String|Node} template * @param {Object} data */
function Compile(template, data) {
if(! (this instanceof Compile)) return new Compile(template, data);
this.options = {};
this.data = data;
if (template instanceof Node) {
this.options.template = template;
} else if (typeof template === 'string') {
this.options.template = domify(template);
} else {
console.error(`"template" only accept DOM node or string template`);
}
template = this.options.template;
walk(template, (node, next) => {
if (node.nodeType === 1) {
// compile element node
this.compile.elementNodes.call(this, node);
return next();
} else if (node.nodeType === 3) {
// compile text node
this.compile.textNodes.call(this, node);
}
next();
});
this.view = template;
template = null;
}
Compile.compile = {};
Copy the code
walk
The constructor of Compile (————) iterates through the template, and then makes different compilations based on the type of the node. Here is not how to iterate through the template. We’ll focus on how to compile these different types of nodes, using node.nodeType === 1 as an example:
/** * compile element node * * @param {Node} node */
Compile.compile.elementNodes = function (node) {
const bindSymbol = ` : `;
let attributes = [].slice.call(node.attributes),
attrName = ` `,
attrValue = ` `,
directiveName = ` `;
attributes.map(attribute= > {
attrName = attribute.name;
attrValue = attribute.value.trim();
if (attrName.indexOf(bindSymbol) === 0&& attrValue ! = =' ') {
directiveName = attrName.slice(bindSymbol.length);
this.bindDirective({
node,
expression: attrValue,
name: directiveName,
});
node.removeAttribute(attrName);
} else {
this.bindAttribute(node, attribute); }}); };Copy the code
Oh, I forgot to mention that HERE I refer to Vue’s command syntax, which has colons: You can write JavaScript expressions directly, and special instructions such as :text, :show, etc. are provided to do different things to elements.
It actually does two things:
- Iterate over all the attributes of the node and perform different operations by determining whether the attribute type is a colon or not
:
Start and property values are not empty; - Bind the corresponding directive to update the property.
Directive
Second, take a look at how Directive is implemented internally:
import directives from './directives';
import { generate } from './compile/generate';
export default function Directive(options = {}) {
Object.assign(this, options);
Object.assign(this, directives[this.name]);
this.beforeUpdate && this.beforeUpdate();
this.update && this.update(generate(this.expression)(this.compile.options.data));
}
Copy the code
Directive does three things:
- Registration Instruction (
Object.assign(this, directives[this.name])
); - Evaluate the actual value of the instruction expression (
generate(this.expression)(this.compile.options.data)
); - Update the calculated value to the DOM (
this.update()
).
Before introducing the directive, let’s look at its usage:
Compile.prototype.bindDirective = function (options) {
newDirective({ ... options,compile: this}); }; Compile.prototype.bindAttribute =function (node, attribute) {
if(! hasInterpolation(attribute.value) || attribute.value.trim() ==' ') return false;
this.bindDirective({
node,
name: 'attribute'.expression: parse.text(attribute.value),
attrName: attribute.name,
});
};
Copy the code
BindDirective encapsulates Directive in a very simple way, accepting three mandatory attributes:
node
: The current compiled node, inDirective
çš„update
Method to update the current node;name
: The name of the currently bound directive that distinguishes which directive updater is used to update the view;expression
: JavaScript expression after parse.
updater
In Directive we adopt Object. Assign (this, directives[this.name]); To register different directives, so the variable cache value may be:
// directives
export default {
// directive `:show`
show: {
beforeUpdate() {},
update(show) {
this.node.style.display = show ? `block` : `none`; }},// directive `:text`
text: {
beforeUpdate() {},
update(value) {
// ...,}}};Copy the code
So if the name of a directive is show, object-assign (this, directives[this.name]); Is equivalent to:
Object.assign(this, {
beforeUpdate() {},
update(show) {
this.node.style.display = show ? `block` : `none`; }});Copy the code
For the instruction show, the instruction update will change the display value of the element style to achieve the corresponding function. If you want to extend the functionality of the compiler, you can simply write an update for the instruction. Here’s an example of the instruction text:
// directives
export default {
// directive `:show`
// show: { ... },
// directive `:text`
text: {
update(value) {
this.node.textContent = value; ,}}};Copy the code
Notice how easy it is to write a directive and then use our text directive like this:
const compiled = Compile(' ', {
greeting: `Hello World`}); compiled.view// => 'Hey 🌰, Hello World
'
Copy the code
generate
Hey 🌰, {{greeting}}
< H1 >Hey 🌰, Hello World
, To calculate the real data for the expression, perform the following three steps:
- the
<h1>Hey 🌰, {{greeting}}</h1>
Parsed into'Hey 🌰, '+ greeting
JavaScript expressions like this; - Extract the dependent variables and get where they are
data
Corresponding value of; - using
new Function()
To create an anonymous function that returns the expression; - Finally, the anonymous function is called to return the final calculated data and pass the instruction
update
Method is updated to the view.
parse text
// reference: https://github.com/vuejs/vue/blob/dev/src/compiler/parser/text-parser.js#L15-L41
const tagRE = / \ {\ {((? :.|\n)+?) \}\}/g;
function parse(text) {
if(! tagRE.test(text))return JSON.stringify(text);
const tokens = [];
let lastIndex = tagRE.lastIndex = 0;
let index, matched;
while (matched = tagRE.exec(text)) {
index = matched.index;
if (index > lastIndex) {
tokens.push(JSON.stringify(text.slice(lastIndex, index)));
}
tokens.push(matched[1].trim());
lastIndex = index + matched[0].length;
}
if (lastIndex < text.length) tokens.push(JSON.stringify(text.slice(lastIndex)));
return tokens.join('+');
}
Copy the code
This function, which I refer directly to Vue’s implementation, parses a string containing double curly braces into a standard JavaScript expression, such as:
parse(`Hi {{ user.name }}, {{ colon }} is awesome.`);
// => 'Hi ' + user.name + ', ' + colon + ' is awesome.'
Copy the code
extract dependency
We will use the following function to extract possible variables in an expression:
const dependencyRE = /"[^"]*"|'[^']*'|\.\w*[a-zA-Z$_]\w*|\w*[a-zA-Z$_]\w*:|(\w*[a-zA-Z$_]\w*)/g;
const globals = [
'true'.'false'.'undefined'.'null'.'NaN'.'isNaN'.'typeof'.'in'.'decodeURI'.'decodeURIComponent'.'encodeURI'.'encodeURIComponent'.'unescape'.'escape'.'eval'.'isFinite'.'Number'.'String'.'parseFloat'.'parseInt',];function extractDependencies(expression) {
const dependencies = [];
expression.replace(dependencyRE, (match, dependency) => {
if( dependency ! = =undefined &&
dependencies.indexOf(dependency) === - 1 &&
globals.indexOf(dependency) === - 1) { dependencies.push(dependency); }});return dependencies;
}
Copy the code
The regular expression dependencyRE matches the possible variable dependencies, and then some comparisons are made, such as whether they are global variables. The effect is as follows:
extractDependencies(`typeof String(name) === 'string' && 'Hello ' + world + '! ' + hello.split('').join('') + '.'`);
// => ["name", "world", "hello"]
Copy the code
This is exactly what we need. Typeof, String, split, and join are not dependent variables in data, so they do not need to be extracted.
generate
export function generate(expression) {
const dependencies = extractDependencies(expression);
let dependenciesCode = ' ';
dependencies.map(dependency= > dependenciesCode += `var ${dependency} = this.get("${dependency}"); `);
return new Function(`data`.`${dependenciesCode}return ${expression}; `);
}
Copy the code
The purpose of extracting variables is to generate corresponding variable assignment strings in generate for use in generate, for example:
new Function(`data`.` var name = data["name"]; var world = data["world"]; var hello = data["hello"]; return typeof String(name) === 'string' && 'Hello ' + world + '! ' + hello.split('').join('') + '.'; `);
// will generated:
function anonymous(data) {
var name = data["name"];
var world = data["world"];
var hello = data["hello"];
return typeof String(name) === 'string' && 'Hello ' + world + '! ' + hello.split(' ').join(' ') + '. ';
}
Copy the code
In this case, we just need to pass in the corresponding data when we call the anonymous function to get the result we want. If you look back at the previous Directive part of the code, it should be obvious:
export default class Directive {
constructor(options = {}) {
// ...
this.beforeUpdate && this.beforeUpdate();
this.update && this.update(generate(this.expression)(this.compile.data)); }}Copy the code
Generate (this.expression)(this.pile.data) is the value we need when this.pile.data evaluates the expression.
compile text node
Node.nodetype === 1; node.nodeType === 1; node.nodeType == 1;
/** * compile text node * * @param {Node} node */
Compile.compile.textNodes = function (node) {
if (node.textContent.trim() === ' ') return false;
this.bindDirective({
node,
name: 'text'.expression: parse.text(node.textContent),
});
};
Copy the code
By binding the text Directive and passing in the parsed JavaScript expression, the Directive calculates the actual value of the expression and calls the text update function to update the view to complete the rendering.
:each
instruction
So far, the template engine implements only compare the basic functions, and a list of the most common and important rendering function is not implemented, so now we want to achieve a: each instruction to render a list, it may have to pay attention to, not according to the two instructions in front of the train of thought, should change an Angle to think, List rendering is essentially a “subtemplate” with variables in the “local scope” of the data received by each.
// :each updater
import Compile from 'path/to/compile.js';
export default {
beforeUpdate() {
this.placeholder = document.createComment(`:each`);
this.node.parentNode.replaceChild(this.placeholder, this.node);
},
update() {
if (data && !Array.isArray(data)) return;
const fragment = document.createDocumentFragment();
data.map((item, index) = > {
const compiled = Compile(this.node.cloneNode(true), { item, index, });
fragment.appendChild(compiled.view);
});
this.placeholder.parentNode.replaceChild(fragment, this.placeholder); }};Copy the code
Before update, we remove the :each node from the DOM structure, but note that we can’t remove it directly. Instead, we insert a comment node in the removed position as a placeholder, so that after we render the table data, You can retrieve the original position and insert it into the DOM.
To compile this template, we need to iterate through: the each command receives Array data (currently only supported by this type, of course you can also add Object support, the principle is the same). Second, we compile the template for each item in the list and insert the rendered template into the created Document Fragment. When the entire list is compiled, replace the placeholder for the comment type you just created with a Document Fragment to complete the rendering of the list.
At this point, we can use the each directive like this:
Compile(`<li :each="comments" data-index="{{ index }}">{{ item.content }}</li>`, {
comments: [{
content: `Hello World.`}, {content: `Just Awesome.`}, {content: `WOW, Just WOW! `,}]});Copy the code
Will render:
<li data-index="0">Hello World.</li>
<li data-index="1">Just Awesome.</li>
<li data-index="2">WOW, Just WOW!</li>
Copy the code
If you are careful, you will notice that the item and index variables used in the template are actually two keys of the data value in the Compile(template, data) compiler in the each update function. So it’s easy to customize these two variables:
// :each updater
import Compile from 'path/to/compile.js';
export default {
beforeUpdate() {
this.placeholder = document.createComment(`:each`);
this.node.parentNode.replaceChild(this.placeholder, this.node);
// parse alias
this.itemName = `item`;
this.indexName = `index`;
this.dataName = this.expression;
if (this.expression.indexOf(' in ') != - 1) {
const bracketRE = / / (((? :.|\n)+?) \)/g;
const [item, data] = this.expression.split(' in ');
let matched = null;
if (matched = bracketRE.exec(item)) {
const [item, index] = matched[1].split(', ');
index ? this.indexName = index.trim() : ' ';
this.itemName = item.trim();
} else {
this.itemName = item.trim();
}
this.dataName = data.trim();
}
this.expression = this.dataName;
},
update() {
if (data && !Array.isArray(data)) return;
const fragment = document.createDocumentFragment();
data.map((item, index) = > {
const compiled = Compile(this.node.cloneNode(true), {[this.itemName]: item,
[this.indexName]: index,
});
fragment.appendChild(compiled.view);
});
this.placeholder.parentNode.replaceChild(fragment, this.placeholder); }};Copy the code
This way we can customize the item and index variables of each directive with (aliasItem, aliasIndex) in items by parsing the expression of each directive beforeUpdate. Extract the relevant variable names, and the above example can be written like this:
Compile(`<li :each="(comment, index) in comments" data-index="{{ index }}">{{ comment.content }}</li>`, {
comments: [{
content: `Hello World.`}, {content: `Just Awesome.`}, {content: `WOW, Just WOW! `,}]});Copy the code
Conclusion
Here, in fact, a relatively simple template engine is implemented, of course, there are many places to improve, such as can add :class, :style, :if or: SRC and so on you can think of the command functions, adding these functions are very simple.
The entire core is nothing more than traversing the entire node tree of the template, parsing the string value of each node into the corresponding expression, then calculating the actual value through the new Function() constructor, and finally updating the view through the update Function of the instruction.
If you still do not know how to write these instructions, please refer to the relevant source code of my project Colon (some code may have minor differences that do not affect understanding, but can be ignored), and you can mention any questions on issue.
At present, there is a limitation that dom-based template engine is only applicable to the browser side. At present, the author is also implementing a version compatible with Node side. The idea is to parse the string template into AST, then update the data into AST, and finally convert the AST into string template. After the implementation is available, I will introduce the implementation of Node.
Finally, if something is wrong or there is a better way to implement it, feel free to point it out.
More dry goods please pay attention to the public number front small column: QianDuanXiaoZhuanLan