---
title: 'Compiler-Based Approaches'
source: 'https://academia.sh/en/courses/component-based-development/compiler-based-approaches'
course: 'Component-Based Interface Development'
language: en
updated: '2026-08-17T18:11:04+00:00'
license: 'CC BY-SA 4.0'
---

# Compiler-Based Approaches

Resolving dependencies at compile time; a small transformer that turns a template into update code, reading the generated code, the soundness limit of analysis, and the cost of the compile step.

The previous two families both kept their ledger at runtime: one by comparing trees, the
other by recording subscriptions. Both had a counterpart in the downloaded code.

Yet which expression depends on which value can, in most cases, be read straight from the
source text: in a template, which variable a text node reads is written down. A tool that
extracts this before the code runs can leave the runtime nothing but direct code —
"if this value changes, write this spot."

## Dependency Is Written in the Source Text

The chain built in the Compilation Stages lesson from the How Computers Work course
repeats here at small scale: lexical analysis, tree building, analysis, and code
generation. The input is a template, the output is executable update code.

The difference is this: in the previous two families, the framework interprets the
application's **data**; in this family, the tool interprets the application's **source
text**. Interpretation happens once, at compile time, and its result never changes again.

The transformer below builds this in its plainest form: it splits the template into
static parts and expression slots, finds the state fields each expression reads, and
produces, per field, a function that writes only the slots that field affects.

```js
// compiler.mjs — resolving the template at compile time into a transformer that generates update code
const STATE_FIELDS = ["name", "value", "timestamp", "thresholdExceeded"];

const TEMPLATE = `<tr class="row {thresholdExceeded ? 'warning' : 'normal'}">
  <th scope="row">{name}</th>
  <td class="value">{value.toFixed(1)} °C</td>
  <td class="timestamp">{thresholdExceeded ? timestamp : '—'}</td>
</tr>`;

// 1) Parsing: the template is split into static parts and expression slots.
function parse(template) {
  const staticParts = [];
  const expressions = [];
  let last = 0;
  for (const match of template.matchAll(/\{([^{}]*)\}/g)) {
    staticParts.push(template.slice(last, match.index));
    expressions.push(match[1].trim());
    last = match.index + match[0].length;
  }
  staticParts.push(template.slice(last));
  return { staticParts, expressions };
}

// No analysis can be correct without first stripping out string literals.
const stripStrings = (expr) => expr.replace(/'[^']*'|"[^"]*"/g, (d) => " ".repeat(d.length));

// 2) Analysis: find the state fields each expression reads.
function analyze(expr) {
  const g = stripStrings(expr);
  const dynamic = /\[[^\]]*\]/.test(g);                    // access like state[variable]
  const names = STATE_FIELDS.filter((a) => new RegExp(`(?<![.\\w$])${a}\\b`).test(g));
  return { names, dynamic };
}

// 3) Code generation: state names in the expression are qualified with `d.`.
function qualify(expr) {
  const g = stripStrings(expr);
  let result = "";
  let last = 0;
  const matches = [];
  for (const a of STATE_FIELDS)
    for (const e of g.matchAll(new RegExp(`(?<![.\\w$])${a}\\b`, "g"))) matches.push({ index: e.index, length: a.length });
  matches.sort((x, y) => x.index - y.index);
  for (const e of matches) {
    result += expr.slice(last, e.index) + "d." + expr.slice(e.index, e.index + e.length);
    last = e.index + e.length;
  }
  return result + expr.slice(last);
}

function compile(template) {
  const { staticParts, expressions } = parse(template);
  const resolved = expressions.map(analyze);
  const bodies = expressions.map((expr, i) => `y[${i}] = String(${qualify(expr)});`);
  const affected = {};
  for (const field of STATE_FIELDS)
    affected[field] = resolved.map((c, i) => (c.dynamic || c.names.includes(field) ? i : -1)).filter((i) => i >= 0);

  const source = [
    `const STATIC = ${JSON.stringify(staticParts)};`,
    `const slotWriters = [`,
    ...bodies.map((g, i) => `  (d, y) => { ${g} },   // slot ${i}`),
    `];`,
    `const setup = (d) => { const y = []; for (const f of slotWriters) f(d, y); return y; };`,
    ...STATE_FIELDS.map((a) =>
      `const ${a}Changed = (d, y) => { ${affected[a].map((i) => `slotWriters[${i}](d, y);`).join(" ")} return ${affected[a].length}; };`),
    `const render = (y) => STATIC.reduce((m, s, i) => m + s + (y[i] ?? ""), "");`,
  ].join("\n");

  // Turn the generated code into something runnable.
  const slotWriters = bodies.map((g) => new Function("d", "y", g));
  const module = {
    setup: (d) => { const y = []; for (const f of slotWriters) f(d, y); return y; },
    render: (y) => staticParts.reduce((m, s, i) => m + s + (y[i] ?? ""), ""),
  };
  for (const a of STATE_FIELDS)
    module[`${a}Changed`] = (d, y) => { for (const i of affected[a]) slotWriters[i](d, y); return affected[a].length; };
  return { source, staticParts, expressions, resolved, affected, module };
}

const compiled = compile(TEMPLATE);
console.log("--- generated code ---");
console.log(compiled.source);

console.log("\n--- expression / dependency table ---");
compiled.expressions.forEach((expr, i) =>
  console.log(`  slot ${i}: ${expr.padEnd(41)} → ${JSON.stringify(compiled.resolved[i].names)}`));
console.log("  field → affected slots:", JSON.stringify(compiled.affected));

const m = compiled.module;
const state = { name: "sensor-120", value: 42.5, timestamp: "06:00", thresholdExceeded: true };
const slots = m.setup(state);
console.log("\nfirst render:");
console.log(m.render(slots));

state.value = 43.1;
console.log(`\nvalue changed → expressions re-evaluated: ${m.valueChanged(state, slots)}`);
console.log(m.render(slots));

state.thresholdExceeded = false;
console.log(`\nthreshold state changed → expressions re-evaluated: ${m.thresholdExceededChanged(state, slots)}`);
console.log(m.render(slots));

console.log("\nchanged field           compiled  runtime without analysis");
for (const field of STATE_FIELDS)
  console.log(`${field.padEnd(23)} ${String(compiled.affected[field].length).padStart(9)} ${String(compiled.expressions.length).padStart(34)}`);

// 4) The limit of analysis: dynamic access produces a dependency on all fields.
const ambiguous = compile(`<td>{value[name]}</td>`);
console.log("\ntemplate with dynamic access:");
console.log("  expression:", ambiguous.expressions[0], "| dynamic:", ambiguous.resolved[0].dynamic);
console.log("  field → affected slots:", JSON.stringify(ambiguous.affected));
```

```
--- generated code ---
const STATIC = ["<tr class=\"row ","\">\n  <th scope=\"row\">","</th>\n  <td class=\"value\">"," °C</td>\n  <td class=\"timestamp\">","</td>\n</tr>"];
const slotWriters = [
  (d, y) => { y[0] = String(d.thresholdExceeded ? 'warning' : 'normal'); },   // slot 0
  (d, y) => { y[1] = String(d.name); },   // slot 1
  (d, y) => { y[2] = String(d.value.toFixed(1)); },   // slot 2
  (d, y) => { y[3] = String(d.thresholdExceeded ? d.timestamp : '—'); },   // slot 3
];
const setup = (d) => { const y = []; for (const f of slotWriters) f(d, y); return y; };
const nameChanged = (d, y) => { slotWriters[1](d, y); return 1; };
const valueChanged = (d, y) => { slotWriters[2](d, y); return 1; };
const timestampChanged = (d, y) => { slotWriters[3](d, y); return 1; };
const thresholdExceededChanged = (d, y) => { slotWriters[0](d, y); slotWriters[3](d, y); return 2; };
const render = (y) => STATIC.reduce((m, s, i) => m + s + (y[i] ?? ""), "");

--- expression / dependency table ---
  slot 0: thresholdExceeded ? 'warning' : 'normal'  → ["thresholdExceeded"]
  slot 1: name                                      → ["name"]
  slot 2: value.toFixed(1)                          → ["value"]
  slot 3: thresholdExceeded ? timestamp : '—'       → ["timestamp","thresholdExceeded"]
  field → affected slots: {"name":[1],"value":[2],"timestamp":[3],"thresholdExceeded":[0,3]}

first render:
<tr class="row warning">
  <th scope="row">sensor-120</th>
  <td class="value">42.5 °C</td>
  <td class="timestamp">06:00</td>
</tr>

value changed → expressions re-evaluated: 1
<tr class="row warning">
  <th scope="row">sensor-120</th>
  <td class="value">43.1 °C</td>
  <td class="timestamp">06:00</td>
</tr>

threshold state changed → expressions re-evaluated: 2
<tr class="row normal">
  <th scope="row">sensor-120</th>
  <td class="value">43.1 °C</td>
  <td class="timestamp">—</td>
</tr>

changed field           compiled  runtime without analysis
name                            1                                  4
value                           1                                  4
timestamp                       1                                  4
thresholdExceeded               2                                  4

template with dynamic access:
  expression: value[name] | dynamic: true
  field → affected slots: {"name":[0],"value":[0],"timestamp":[0],"thresholdExceeded":[0]}
```

## Reading the Generated Code

The first section of the output shows exactly what reaches the runtime: four slot-writer
functions, one update function per field, and a text combiner — no diffing algorithm, no
subscriber set, no tree representation. The code says **directly** which spot gets
written on which change.

`thresholdExceededChanged` writes two slots at once, because the threshold state is read
both in the row's class name and in the timestamp cell. This information comes from the
dependency table and rests on no computation at runtime.

`timestampChanged` writes only the third slot. When the timestamp changes, the row's class
name is not recomputed — because the class name does not read the timestamp. A runtime
without analysis would re-evaluate all four expressions no matter which field changed; the
table shows this difference.

Separating out the static parts is a second gain: the `<th scope="row">` text is produced
once and never touched by any update again. In the virtual-tree family this node is
traversed on every comparison; here there is no comparison step at all.

## The Soundness Limit of Analysis

The last section shows the approach's breaking point: in the expression `value[name]`,
the field being read is not apparent from the source text — which key gets used is only
known at runtime. The analyzer must act **conservatively**: it treats the expression as
dependent on every field, so the slot gets rewritten no matter which field changes.

This is the compiler-based approach's general rule. Inference must be **sound**: missing a
dependency means a view that does not update, a correctness problem, not a performance one.
An extra dependency only produces unnecessary work — faced with ambiguity, the tool always
chooses the extra.

The result is a move toward a subset of the language: template languages deliberately
restrict expression syntax — limited expression forms, limited access paths, explicitly
declared state. The restriction is not a deficiency; it is the condition for inference to
work at all. The same idea appears in the preprocessors from the Layout Systems and
Responsive Design course — the narrower the input language, the more the tool can tell
you.

Analysis also sees **unused** paths: if a state field is never read, the update function
generated for it stays empty, and unused components and branches can be dropped from the
bundle entirely. The dead code elimination from the Compilation Stages lesson moves here
to the application level.

## The Cost of the Compile Step

The price of these gains is that the code that runs is not the code that was written.

Debugging is the first consequence: the line where a breakpoint is set and the line that
actually runs are different, and a source map builds the bridge between them. The source
map introduced in the Source Debugging lesson from The Browser and the Web Platform course
is a required part of this family, not an optional convenience — if the mapping is missing
or broken, the error message points at an incomprehensible location.

The second cost is a toolchain dependency: the code does not run directly in the browser,
it has to pass through a compile step. This step needs to be fast during development, or
every change means waiting. Version upgrades must also preserve compatibility between
compiler and source text — the tool's version becomes part of the application's source
text.

The third cost is predictability. Even when the generated code is readable, what the
written code will produce is not always obvious: moving an expression into a helper
function can keep the analyzer from seeing the dependency and drop it into conservative
mode, increasing work with no error raised. Diagnosing performance problems in this family
requires reading the generated code.

## The Family's Profile

The update unit is a single write, determined at compile time. The runtime code is
proportional not to the application's complexity but to the features actually used; no
general-purpose update machinery is carried along.

In exchange comes a compile step, a source-map chain, and a restriction on the input
language. Because analysis must stay sound, dynamic constructs produce conservative
dependencies — a direct trade-off between the language's expressive power and the
sharpness of the inference.

The boundary between this family and the previous two is not sharp. Frameworks that track
dependencies at runtime also compile their templates; virtual-tree-holding frameworks can
also mark static subtrees at compile time and exclude them from comparison. The
distinction is a matter of **where** the weight of the decision falls.

## Summary

- In this family, which expression depends on which value is extracted from the source
  text before the code runs, and the result is generated directly as update code.
- The generated code has no diffing algorithm, no subscriber set, and no tree
  representation; which slots get written per field is fixed.
- Static parts are produced once and never traversed by any update.
- Inference must be sound: in ambiguous cases like dynamic access, the tool acts
  conservatively and produces a dependency on every field.
- A restricted input language is the condition for sharp inference; dropping unused paths
  from the bundle comes from the same analysis.
- The cost is that the code that runs is not the code that was written: the source map
  becomes a required part, and the tool's version becomes a dependency of the source text.

## Next Step

All three families answered the same question: which part of the document gets written
when state changes. Above the component layer sit other questions — which view an address
maps to, where a form gets its validation rules, how a service reaches a component. In the
families covered so far, the answers lie outside the framework, and each team builds its
own combination. Another approach supplies these answers as part of the framework itself.
The next lesson covers frameworks that gather routing, the form model, and dependency
resolution into a single contract, and shows by counting how integration shrinks the
decision surface — and what it binds in exchange.
