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 templateCopy 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 judging the different types of attributes. The criteria for judging is whether the attribute name is a colon: starts and the value of the attribute is 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:

  • Register directives (Object. Assign (this, directives[this.name]));

  • The calculation expression of the actual value (the generate (enclosing expression) (this.com running. The options. The data));

  • Update the calculated actual 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 currently compiled node used to update the current node in the Directive update method;

  • Name: the name of the currently bound directive, which 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:

  • Parse

    Hey 🌰, {{greeting}}

    into JavaScript expressions like ‘Hey 🌰, ‘+ greeting;

  • Extract the dependent variable and obtain the corresponding value in the data;

  • Use new Function() to create an anonymous Function to return the expression;

  • Finally, the anonymous function is called to return the computed data and update it to the view via the directive’s UPDATE method.

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.

:eachinstruction

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.