Lesson 04 / 25
Template and Expression Syntax
Binding markup to data; compiling a template once and running it many times, separating static and dynamic parts, the scope and limits of expressions, text escaping, and the distinction between attribute and property binding.
Contents
The previous lesson’s components built the view description with nested function calls. When a measurement table’s entire structure is written this way, the shape of the markup is lost: where each element closes, and which attribute belongs to which element, do not become readable among the call parentheses.
This lesson covers writing markup in its own syntax and binding it to data. The mechanism to be built is called a template: a markup text that carries expressions, and a compilation step that turns it into a function producing a view description.
Two Writing Families
There are two ways to bring markup into a program, and framework families split along this line.
In the first way, markup is embedded into the language: the programming language’s syntax is extended, and markup becomes an expression of the language. There is no separate concept of an expression slot; everything inside the markup is already an expression of the language. The gain is that the full range of the language’s capabilities can be used directly.
In the second way, the template is a separate text: markers specific to markup are added, and this text is compiled into a function. The template language is smaller than the language; it allows only expressions and a handful of directives. The gain is that the small language is analyzable: the compiler knows which part is static and which is dynamic, and it can act on that knowledge.
Both ways arrive at the same place: a function that produces a view description from state. The difference is when this function is produced, and with how much information.
Compiling the Template
The compiler below is a small example of the second way. The template is parsed once, and the result turns into a command tree; only this tree runs on every render.
// template-compiler.mjs — compiles template text into a function that produces a view const TEMPLATE = ` <div class="badge" :state="value >= threshold ? 'exceeded' : 'normal'" @click="open"> <span class="name">{{name}}</span> <span class="value">{{value}} {{unit}}</span> </div>`.trim(); // 1) Lexical analysis: tags and text chunks. // The tag pattern skips the ">" character inside quotes. const TAG = /<\/[a-z][\w-]*>|<[a-z][\w-]*(?:\s+[:@]?[\w-]+(?:="[^"]*")?)*\s*>|[^<]+/g; const tokenize = (text) => text.match(TAG) ?? []; // 2) Splits text into static slices and expressions: "{{name}}: {{value}}" → 4 parts. function splitText(text) { const parts = []; let last = 0; for (const match of text.matchAll(/\{\{([^}]+)\}\}/g)) { if (match.index > last) parts.push({ kind: "static", value: text.slice(last, match.index) }); parts.push({ kind: "expression", source: match[1].trim() }); last = match.index + match[0].length; } if (last < text.length) parts.push({ kind: "static", value: text.slice(last) }); return parts; } // 3) Splits attributes into three: static, bound (:), and event (@). function splitAttributes(tag) { const staticAttrs = {}, bound = {}, event = {}; for (const [, name, value] of tag.matchAll(/([:@]?[\w-]+)="([^"]*)"/g)) { if (name.startsWith(":")) bound[name.slice(1)] = value; else if (name.startsWith("@")) event[name.slice(1)] = value; else staticAttrs[name] = value; } return { static: staticAttrs, bound, event }; } // 4) Compilation: the template is parsed once, the result turns into a command tree. function compile(template, scopeNames) { const evaluate = (source) => new Function(...scopeNames, `return (${source});`); const stack = [{ children: [] }]; let staticCount = 0, dynamicCount = 0; for (const chunk of tokenize(template)) { if (chunk.startsWith("</")) { stack.pop(); continue; } if (chunk.startsWith("<")) { const name = chunk.match(/^<([a-z]+)/)[1]; const { static: staticAttrs, bound, event } = splitAttributes(chunk); staticCount += Object.keys(staticAttrs).length; dynamicCount += Object.keys(bound).length + Object.keys(event).length; const node = { name, static: staticAttrs, children: [], bound: Object.fromEntries(Object.entries(bound) .map(([k, v]) => [k, evaluate(v)])), event: Object.fromEntries(Object.entries(event) .map(([k, v]) => [k, evaluate(v)])), }; stack.at(-1).children.push(node); stack.push(node); continue; } if (!chunk.trim()) continue; for (const p of splitText(chunk.trim())) { if (p.kind === "static") { staticCount++; stack.at(-1).children.push(p.value); } else { dynamicCount++; stack.at(-1).children.push(evaluate(p.source)); } } } console.log(`compile: ${staticCount} static parts, ${dynamicCount} dynamic parts`); return { root: stack[0].children[0], scopeNames }; } // 5) Execution: command tree + scope → view description. function produce({ root, scopeNames }, scope) { const args = scopeNames.map((name) => scope[name]); const out = (d) => { if (typeof d === "string") return d; if (typeof d === "function") return String(d(...args)); return { name: d.name, attrs: { ...d.static, ...Object.fromEntries(Object.entries(d.bound) .map(([k, f]) => [k, f(...args)])), ...Object.fromEntries(Object.entries(d.event) .map(([k, f]) => [`on:${k}`, f(...args).name || "function"])) }, children: d.children.map(out), }; }; return out(root); } const SCOPE = ["name", "value", "unit", "threshold", "open"]; const compiled = compile(TEMPLATE, SCOPE); const write = (d, indent = 0) => typeof d === "string" ? `${" ".repeat(indent)}${JSON.stringify(d)}` : [`${" ".repeat(indent)}<${d.name}${Object.entries(d.attrs) .map(([k, v]) => ` ${k}=${JSON.stringify(v)}`).join("")}>`, ...d.children.map((c) => write(c, indent + 2))].join("\n"); for (const measurement of [ { name: "Temperature", value: -4.2, unit: "°C", threshold: 30, open: () => {} }, { name: "Relative humidity", value: 94, unit: "%", threshold: 90, open: () => {} }, ]) console.log(`\n${write(produce(compiled, measurement))}`); // An expression sees only the declared scope names. try { new Function(...SCOPE, "return (exceededCount);")("x", 0, "", 0, null); } catch (error) { console.log(`\nname not in scope: ${error.constructor.name} — ${error.message}`); }
compile: 4 static parts, 5 dynamic parts
<div class="badge" state="normal" on:click="open">
<span class="name">
"Temperature"
<span class="value">
"-4.2"
" "
"°C"
<div class="badge" state="exceeded" on:click="open">
<span class="name">
"Relative humidity"
<span class="value">
"94"
" "
"%"
name not in scope: ReferenceError — exceededCount is not defined
The output has four results.
Compile once, run many times. The parsing line was written only once; the template text was never looked at again while producing the view for two different measurements. The same holds for fifteen badges. Parsing the template is not part of the render cost.
Static and dynamic parts are separated. The compiler knows that four parts depend on no state at all. This knowledge is used in the next lesson to reduce cost: the parts that never need comparing are known in advance.
An expression is evaluated in the declared scope. The name value in the
template is the value variable in the component’s scope; a name absent from the
scope produces an error at runtime. The names the template sees are limited to the
names the component supplies.
The three binding types are written with separate markers. An attribute without a prefix is static; the colon prefix takes its value from an expression; the at-sign prefix binds an event. Having the distinction visible in the syntax lets the compiler treat each one differently.
The Limits of Expressions
Template expressions run during rendering, and on every render. Two rules follow from this.
An expression must not produce a side effect. The previous lesson’s purity condition applies to the template as well: an expression that changes state, starts a request, or increments a counter behaves depending on the render count.
An expression must be cheap. Sorting or filtering a list inside the template is recomputed on every render. Doing that computation once and caching its result is the subject of the course’s derived-values lesson.
Keeping the template language limited to expressions supports these rules at the syntax level too: building a loop, performing an assignment, or catching an error is impossible in a language where statements cannot be written. That work belongs to the component’s body.
Text, Attribute, and Event Binding
The behavior of the three binding types differs, and confusing them produces two familiar problems.
// binding-types.mjs — text escaping and the difference between attribute/property binding // 1) Text interpolation escapes; raw insertion does not. const escape = (s) => String(s) .replace(/&/g, "&").replace(/</g, "<") .replace(/>/g, ">").replace(/"/g, """); const note = 'Sensor <b>suspicious</b> reading'; // free text entered into a form console.log(`text interpolation : <p>${escape(note)}</p>`); console.log(`raw insertion : <p>${note}</p>`); const attrValue = 'snow" data-access="full'; // value carrying a quote console.log(`\nattribute (escaped) : <li type="${escape(attrValue)}">`); console.log(`attribute (unescaped): <li type="${attrValue}">`); const attributeCount = (s) => [...s.matchAll(/[\w-]+="/g)].length; console.log(`attributes produced — escaped: ` + `${attributeCount(`<li type="${escape(attrValue)}">`)}, ` + `unescaped: ${attributeCount(`<li type="${attrValue}">`)}`); // 2) An attribute is the document's string; a property is the live value; they diverge once the user types. function makeField() { const node = { attrs: {}, liveValue: "", userTouched: false }; return { node, writeAttribute(value) { node.attrs.value = String(value); if (!node.userTouched) node.liveValue = String(value); // initial value }, writeProperty(value) { node.liveValue = String(value); }, userTypes(text) { node.userTouched = true; node.liveValue = text; }, }; } for (const type of ["attribute", "property"]) { const field = makeField(); const write = (v) => type === "attribute" ? field.writeAttribute(v) : field.writeProperty(v); write("snow"); // initial render: search text from state field.userTypes("snow de"); // user is typing write("snow depth"); // state updated, re-render console.log(`\n${type} binding → value in document: ` + `${JSON.stringify(field.node.attrs.value ?? null)}, ` + `visible in field: ${JSON.stringify(field.node.liveValue)}`); }
text interpolation : <p>Sensor <b>suspicious</b> reading</p> raw insertion : <p>Sensor <b>suspicious</b> reading</p> attribute (escaped) : <li type="snow" data-access="full"> attribute (unescaped): <li type="snow" data-access="full"> attributes produced — escaped: 1, unescaped: 2 attribute binding → value in document: "snow depth", visible in field: "snow de" property binding → value in document: null, visible in field: "snow depth"
Text interpolation escapes. Text the user enters stays text; the markup characters inside it are converted to the character references from the Web Fundamentals and HTML course. This is the template syntax’s default behavior, and it is a deliberate security decision. The raw insertion path the same template language offers turns this protection off: the value is interpreted as markup. Raw insertion’s input should never come from the user.
Attribute binding also escapes. An unescaped value closes the quote and opens a new attribute: the example produced two attributes instead of one. The reason the template compiler can choose the correct escaping is that it knows where it is placing the value; the same value is escaped by different rules inside text, inside an attribute, and as an address value.
Attribute and property are not the same thing. The distinction built in the DOM API lesson turns into a choice in the template. Writing an attribute changes the string in the document; once the user has touched the field, it no longer tracks the field’s live value. The output’s last two lines show this: the attribute path updates the value in the document, but the text visible in the field stays what the user typed. The property path updates the live value.
The rule follows from this: bindings that carry the displayed value are written as a property; bindings that carry metadata in the document are written as an attribute. Boolean attributes are a separate case: they carry presence or absence in the document, and true or false on the program side; writing a boolean attribute with a false value is not the same thing as removing it.
Event binding binds a function. The marker @click="open" in the template does
not produce a string; it produces a reference to a function found in the scope, and
that reference is what shows up in the output as on:click="open". Interpreting the
value as a string would mean writing code inside the markup.
Summary
- A template is markup written in its own syntax and bound to data; the compilation step turns it into a function that produces a view description.
- Markup is either embedded into the language or compiled as a separate text; in the second way, the compiler can tell static and dynamic parts apart.
- The template is parsed once, and the produced command tree runs on every render; parsing is not part of the render cost.
- Template expressions are evaluated in the component’s scope, must produce no side effect, and must be cheap; the inability to write statements supports this rule at the syntax level.
- Text interpolation and attribute binding escape by default; raw insertion does not, and its input should never come from the user.
- Bindings that carry the displayed value are written as a property, bindings that carry metadata in the document as an attribute; event binding binds a function reference, not a string.
Next Step
This lesson’s template described a single badge, and its structure stayed the same in every state: the same elements, in the same order. A real dashboard, by contrast, has a variable structure — when the filter matches no measurement, an empty-state message appears in place of the table, the measurement list grows and shrinks, rows get reordered. The next lesson adds conditional and iteration directives to the template and asks the real question: when the list changes, what determines which row in the new description corresponds to which node in the tree?
To keep your progress and take notes, Log in
My notes
Log in to take notes.