Skip to content
Created: February 26, 2023

Scope: this browser teaching implementation runs only trusted templates that you write; dynamic function compilation is subject to CSP. It omits a full keyed diff, reactive props, Map/Set support and a deduplicating scheduler. Component templates use one root node.

template compiler enhanced version, added:

  • v-if (compiled to ternary/short circuit)
  • v-for (compiled to .map(), supports item in items / (item, index) in items)
  • :prop / v-bind:prop expressions (compiled to dynamic props)
  • @event / v-on and event modifiers (.stop .prevent .self)
  • Generate simple patchFlag and dynamicProps metadata; mount sets all attributes, while later updates use PROPS to narrow attribute updates. TEXT is metadata only here.

This implementation remains short and runnable (open directly in the browser), making it easy for you to experiment, extend, and learn by comparing it with real Vue compilation ideas.The following is a complete single file implementation. Save the entire HTML as mini-vue-compiler.html and open it in the browser to run the demo.

html
<!doctype html>
<html>
<head>
  <meta charset="utf-8" />
  <title>mini-vue extended template compiler (v-if / v-for / :prop / @event.mod)</title>
  <style>button{margin:4px}</style>
</head>
<body>
  <div id="app"></div>

<script>
/* ---------------------------
  Minimal reactivity (reactive/ref/effect)
   --------------------------- */
let activeEffect = null
const targetMap = new WeakMap()
function effect(fn, options = {}) {
  const runner = () => {
    if (!runner.active) return
    for (const dep of runner.deps) dep.delete(runner)
    runner.deps = []
    const parent = activeEffect
    try {
      activeEffect = runner
      return fn()
    } finally {
      activeEffect = parent
    }
  }
  runner.active = true
  runner.deps = []
  runner.scheduler = options.scheduler
  if (!options.lazy) runner()
  return runner
}

function track(target, key){
  if (!activeEffect) return
  let m = targetMap.get(target)
  if (!m) targetMap.set(target, m = new Map())
  let dep = m.get(key)
  if (!dep) m.set(key, dep = new Set())
  if (!dep.has(activeEffect)) {
    dep.add(activeEffect)
    activeEffect.deps.push(dep)
  }
}
function trigger(target, key){
  const m = targetMap.get(target); if (!m) return
  const dep = m.get(key); if (!dep) return
  const effects = new Set(dep)
  effects.forEach(e => e.scheduler ? e.scheduler(e) : e())
}
function reactive(obj){
  if (typeof obj !== 'object' || obj === null) return obj
  return new Proxy(obj, {
    get(t,k,r){ const v = Reflect.get(t,k,r); track(t,k); return (typeof v==='object' && v!==null)? reactive(v) : v },
    set(t,k,v,r){ const old = t[k]; const res = Reflect.set(t,k,v,r); if (old !== v) trigger(t,k); return res },
    deleteProperty(t,k){ const had = k in t; const res = Reflect.deleteProperty(t,k); if (had && res) trigger(t,k); return res}
  })
}
function ref(val){
  const r = { __isRef: true,
    get value(){ track(r,'value'); return val },
    set value(v){ val = v; trigger(r,'value') }
  }
  return r
}

/* ---------------------------
  VNode / h / TEXT symbol
   --------------------------- */
const TEXT = Symbol('text')
function h(type, props = null, children = null) {
  return { type, props, children, el: null, patchFlag: 0, dynamicProps: null }
}
function createTextVNode(text){
  return { type: TEXT, props: null, children: String(text), el: null, patchFlag: 0, dynamicProps: null }
}

/* ---------------------------
  Compiler (extended)
  - supports: {{}} interpolation, :prop, @event.mod, v-if, v-for
  - generates render function string using `with(_ctx){ ... }`
  - outputs patchFlag & dynamicProps for runtime optimization
   --------------------------- */

const PatchFlags = {
  TEXT: 1 << 0,
  PROPS: 1 << 1,
  FULL_PROPS: 1 << 2
}

function compileTemplate(template) {
  const tpl = document.createElement('template')
  tpl.innerHTML = template.trim()

  function parseNode(node) {
    if (node.nodeType === Node.TEXT_NODE) {
      const raw = node.textContent
      const tokens = []
      let last = 0
      const re = /{{([^}]+)}}/g
      let m
      while ((m = re.exec(raw)) !== null) {
        if (m.index > last) tokens.push(JSON.stringify(raw.slice(last, m.index)))
        tokens.push('(' + m[1].trim() + ')')
        last = m.index + m[0].length
      }
      if (last < raw.length) tokens.push(JSON.stringify(raw.slice(last)))
      if (tokens.length === 0) return null
      return { type: 'text', code: tokens.join(' + '), dynamic: tokens.some(t => t.startsWith('(')) }
    }
    if (node.nodeType === Node.ELEMENT_NODE) {
      const tag = node.tagName.toLowerCase()
      // attributes
      const attrs = []
      const dynamicProps = []
      const eventProps = []
      const children = []
      for (let i = 0; i < node.attributes.length; i++) {
        const a = node.attributes[i]
        const name = a.name
        const value = a.value
        if (name === 'v-if') {
          attrs.push({ kind:'v-if', exp: value })
        } else if (name === 'v-for') {
          // parse: (item, idx) in items  OR  item in items
          const inMatch = value.match(/^\s*(?:\(([^)]+)\)|([^]+?))\s+in\s+(.+)\s*$/)
          if (!inMatch) throw new Error('v-for expression invalid: ' + value)
          const rawVars = inMatch[1] || inMatch[2]
          const vars = rawVars.split(',').map(s => s.trim())
          attrs.push({ kind:'v-for', source: inMatch[3].trim(), alias: vars })
        } else if (name.startsWith(':') || name.startsWith('v-bind:')) {
          const propName = name.startsWith(':') ? name.slice(1) : name.slice(7)
          attrs.push({ kind:'bind', prop: propName, exp: value })
          dynamicProps.push(propName)
        } else if (name.startsWith('@') || name.startsWith('v-on:')) {
          const evtNameRaw = name.startsWith('@') ? name.slice(1) : name.slice(5)
          const parts = evtNameRaw.split('.')
          const evt = parts.shift()
          const mods = parts // modifiers like stop, prevent, self
          attrs.push({ kind:'on', event: evt, exp: value, mods })
          eventProps.push('on' + capitalize(evt))
          dynamicProps.push('on' + capitalize(evt))
        } else {
          // static prop
          attrs.push({ kind:'static', prop: name, value: value })
        }
      }

      node.childNodes.forEach(n => {
        const parsed = parseNode(n)
        if (parsed) children.push(parsed)
      })

      return { type: 'element', tag, attrs, children, dynamicProps: dynamicProps, eventProps }
    }
    return null
  }

  const body = []
  tpl.content.childNodes.forEach(n => { const p = parseNode(n); if (p) body.push(p) })

  function genNode(n) {
    if (n.type === 'text') {
      // produce text vnode or text expression
      const code = `createTextVNode(${n.code})`
      return { code, dynamic: n.dynamic, isArray: false }
    }
    if (n.type === 'element') {
      // handle v-if / v-for on this element
      const vIfAttr = n.attrs.find(a => a.kind === 'v-if')
      const vForAttr = n.attrs.find(a => a.kind === 'v-for')

      // generate children code
      const childrenCodes = n.children.map(c => genNode(c))
      const childrenCode = '[' + childrenCodes.map(c => c.code).join(', ') + '].flat(Infinity).filter(Boolean)'

      // props code: static + dynamic
      const staticProps = n.attrs.filter(a=>a.kind==='static').map(a => `${JSON.stringify(a.prop)}: ${JSON.stringify(a.value)}`)
      const bindProps = n.attrs.filter(a=>a.kind==='bind').map(a => `${JSON.stringify(a.prop)}: ${a.exp}`)
      const onProps = n.attrs.filter(a=>a.kind==='on').map(a => {
        const evt = a.event
        const mods = a.mods || []
        const wrapperParts = []
        if (mods.includes('stop')) wrapperParts.push('e.stopPropagation()')
        if (mods.includes('prevent')) wrapperParts.push('e.preventDefault()')
        if (mods.includes('self')) {
          // self: only call if e.target === e.currentTarget
          wrapperParts.push('if(e.target !== e.currentTarget) return')
        }
        // call handler from context
        const expression = a.exp.trim()
        const method = /^[$A-Z_a-z][\w$]*(?:\.[\w$]+)*$/.test(expression)
        wrapperParts.push(method ? `return (${expression})(e)` : `return (${expression})`)
        const fnCode = `(e)=>{ const $event = e; ${wrapperParts.join(';')} }`
        return `${JSON.stringify('on' + capitalize(evt))}: ${fnCode}`
      })

      const propsParts = []
      if (staticProps.length) propsParts.push(...staticProps)
      if (bindProps.length) propsParts.push(...bindProps)
      if (onProps.length) propsParts.push(...onProps)
      const propsObj = propsParts.length ? `{ ${propsParts.join(', ')} }` : 'null'

      // compute patchFlag and dynamicProps list
      const hasDynamicProps = bindProps.length + onProps.length > 0
      const patchFlags = (childrenCodes.some(c=>c.dynamic) ? PatchFlags.TEXT : 0) | (hasDynamicProps ? PatchFlags.PROPS : 0)
      const dynamicList = hasDynamicProps ? JSON.stringify( n.dynamicProps.length ? n.dynamicProps : [] ) : 'null'

      const vnodeCode = `(() => { const vnode = h(${JSON.stringify(n.tag)}, ${propsObj}, ${childrenCode}); vnode.patchFlag = ${patchFlags}; vnode.dynamicProps = ${dynamicList}; return vnode })()`

      // v-if
      if (vIfAttr) {
        return { code: `( (${vIfAttr.exp}) ? ${vnodeCode} : null )`, dynamic: true, isArray: false }
      }
      // v-for
      if (vForAttr) {
        const aliasVars = vForAttr.alias // array of names
        const item = aliasVars[0] || '$item'
        const index = aliasVars[1] || '$index'
        const source = vForAttr.source
        // create map expression; ensure each iteration returns vnode or null
        const mapCode = `(${source}) ? (${source}).map((${item}, ${index}) => { return ${vnodeCode} }) : []`
        return { code: mapCode, dynamic: true, isArray: true }
      }

      return { code: vnodeCode, dynamic: hasDynamicProps || childrenCodes.some(c=>c.dynamic), isArray: false }
    }
    return { code: 'null', dynamic: false, isArray: false }
  }

  // build root code (single node or array)
  const rootNodes = body.map(n => genNode(n))
  const rootCode = rootNodes.length === 1 ? rootNodes[0].code : '[' + rootNodes.map(n=>n.code).join(', ') + ']'

  const fnCode = `
    return function render(_ctx) {
      const runtime = this && this.__runtimeHelpers || runtimeHelpers;
      const { h, createTextVNode } = runtime;
      with(_ctx){
        return ${rootCode};
      }
    }
  `
  const factory = new Function('runtimeHelpers', fnCode)
  return (helpers) => factory(helpers)
}

/* ---------------------------
  Renderer with patchFlag-aware setProps
   --------------------------- */

function createRenderer() {
  function resolveComponents(vnode, components = {}) {
    if (Array.isArray(vnode)) {
      return vnode.flat(Infinity).filter(Boolean).map(v => resolveComponents(v, components))
    }
    if (!vnode) return null
    if (typeof vnode.type === 'string') {
      const name = Object.keys(components).find(key => key.toLowerCase() === vnode.type.toLowerCase())
      if (name) vnode.type = components[name]
    }
    if (Array.isArray(vnode.children)) vnode.children = resolveComponents(vnode.children, components)
    return vnode
  }
  function setProps(el, oldProps = {}, newProps = {}, keys = null) {
    for (const key of keys || new Set([...Object.keys(oldProps), ...Object.keys(newProps)])) {
      const value = newProps[key]
      if (oldProps[key] === value || key === 'key') continue
      if (key.startsWith('on')) el[key.toLowerCase()] = typeof value === 'function' ? value : null
      else if (value == null || value === false) el.removeAttribute(key)
      else el.setAttribute(key, value === true ? '' : value)
    }
  }
  function unmount(vnode) {
    if (!vnode) return
    if (Array.isArray(vnode)) { vnode.forEach(unmount); return }
    if (vnode.component) {
      const runner = vnode.component.update
      runner.active = false
      for (const dep of runner.deps) dep.delete(runner)
      unmount(vnode.component.subTree)
    } else {
      if (Array.isArray(vnode.children)) vnode.children.forEach(unmount)
      if (vnode.el) vnode.el.remove()
    }
  }
  function patchChildren(c1, c2, el) {
    if (Array.isArray(c2)) {
      if (Array.isArray(c1)) {
        for (let i = 0; i < Math.max(c1.length, c2.length); i++) patch(c1[i], c2[i], el)
      } else {
        el.textContent = ''
        c2.forEach(c => patch(null, c, el))
      }
    } else {
      if (Array.isArray(c1)) c1.forEach(unmount)
      const text = c2 == null ? '' : String(c2)
      if (el.textContent !== text) el.textContent = text
    }
  }
  function mountComponent(vnode, container, anchor) {
    const Comp = vnode.type
    const instance = vnode.component = {
      props: { ...vnode.props }, setupState: {}, subTree: null, update: null
    }
    const result = Comp.setup ? Comp.setup(instance.props, { emit() {} }) : {}
    if (typeof result === 'function') instance.render = result
    else instance.setupState = result || {}
    if (!instance.render) instance.render = Comp.render || compileTemplate(Comp.template)({ h, createTextVNode })
    instance.update = effect(() => {
      const nextTree = resolveComponents(instance.render(instance.setupState), Comp.components)
      patch(instance.subTree, nextTree, container, anchor)
      instance.subTree = nextTree
      vnode.el = nextTree && nextTree.el
    }, { scheduler: runner => Promise.resolve().then(runner) })
  }
  function patch(n1, n2, container, anchor = null) {
    if (n1 === n2) return
    if (!n2) { unmount(n1); return }
    if (Array.isArray(n2)) {
      const before = Array.isArray(n1) ? n1 : []
      if (n1 && !Array.isArray(n1)) unmount(n1)
      for (let i = 0; i < Math.max(before.length, n2.length); i++) patch(before[i], n2[i], container, anchor)
      return
    }
    if (n1 && n1.type !== n2.type) {
      anchor = n1.el ? n1.el.nextSibling : anchor
      unmount(n1)
      n1 = null
    }
    if (n2.type === TEXT) {
      if (!n1) {
        n2.el = document.createTextNode(n2.children)
        container.insertBefore(n2.el, anchor)
      } else {
        n2.el = n1.el
        if (n1.children !== n2.children) n2.el.textContent = n2.children
      }
    } else if (typeof n2.type === 'string') {
      if (!n1) {
        const el = n2.el = document.createElement(n2.type)
        setProps(el, {}, n2.props || {})
        patchChildren(null, n2.children, el)
        container.insertBefore(el, anchor)
      } else {
        n2.el = n1.el
        setProps(n2.el, n1.props || {}, n2.props || {}, n2.patchFlag & 2 ? n2.dynamicProps : null)
        patchChildren(n1.children, n2.children, n2.el)
      }
    } else {
      if (!n1) mountComponent(n2, container, anchor)
      else {
        const instance = n2.component = n1.component
        for (const key of Object.keys(instance.props)) {
          if (!(key in (n2.props || {}))) delete instance.props[key]
        }
        Object.assign(instance.props, n2.props || {})
        instance.update()
        n2.el = instance.subTree && instance.subTree.el
      }
    }
  }
  return {
    patch,
    createApp: root => ({
      mount(selector) { patch(null, h(root), document.querySelector(selector)) }
    })
  }
}

const { createApp, patch } = createRenderer()
const runtimeHelpers = { h, createTextVNode }

/* ---------------------------
  Demo components + template showcasing features
   --------------------------- */

const ItemList = {
  template: `
    <div>
      <h3>Items (v-for with v-if example)</h3>
      <ul>
        <li v-for="(it, idx) in items" :key="it.id">
          <span>{{ idx }} - {{ it.text }}</span>
          <button @click.stop="remove(it.id)">remove</button>
        </li>
      </ul>
      <div v-if="items.length === 0">No items</div>
    </div>
  `,
  setup() {
    const state = reactive({ items: [ {id:1,text:'one'}, {id:2,text:'two'} ] })
    function remove(id) { state.items = state.items.filter(i=>i.id!==id) }
    return { get items() { return state.items }, remove }
  }
}

const App = {
  template: `
    <div>
      <h1>mini-vue extended compiler demo</h1>
      <div :data-test="msg" @click.self="onRootClick" style="padding:6px;border:1px solid #ddd">
        <div>{{ msg }}</div>
        <button @click.prevent="inc">inc</button>
        <button @click="toggle">toggle child</button>
        <div v-if="show">
          <ItemList></ItemList>
        </div>
        <div>
          <h3>v-for simple numbers</h3>
          <div v-for="n in nums">{{ n }}</div>
        </div>
      </div>
    </div>
  `,
  components: { ItemList },
  setup() {
    const state = reactive({ count:0, show:true, nums: [1,2,3] })
    function inc(e){ state.count++ ; console.log('inc click', e && e.type) }
    function toggle(){ state.show = !state.show }
    function onRootClick(){ console.log('root clicked (self)') }
    return {
      get msg() { return 'hello mini-vue: ' + state.count },
      get show() { return state.show },
      get nums() { return state.nums },
      inc, toggle, onRootClick
    }
  }
}

function capitalize(s) { return s.charAt(0).toUpperCase() + s.slice(1) }

createApp(App).mount('#app')

</script>
</body>
</html>

Description (key points review) ​

  1. v-if: It is converted into a conditional expression cond ? vnode : null at compile time, and returns null directly during runtime (no rendering) to avoid unnecessary patches.
  2. v-for: compiled to source.map((item, index) => { return vnode }); child arrays are flattened. The renderer compares positions and unmounts surplus nodes; keyed node moves are not implemented.
  3. :prop/v-bind: Recognized as dynamic props, the compiler will put these prop expressions into the props object and record the dynamicProps list (used for patchFlag optimization).
  4. @event.mod: Supports .stop, .prevent, .self (can be extended), and will wrap modifier logic in the generated event processing function.
  5. patchFlags: PROPS narrows updates to the dynamicProps list; initial mount still sets all attributes. TEXT is not yet used to skip other node comparisons.
  6. Implementation limitation: For brevity, the compiler uses the browser DOM parser (template.innerHTML) to do the AST, and expressions are executed via with(_ctx){ ... } - this is stricter, safer, and generates precompiled functions in a real compiler.Our goal is "teaching and experimentation", not production safety or complete implementation.