Skip to content
Created: February 24, 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.

Contains: reactive, ref, effect, VNode + h, simple renderer (patch), component support and a very small template-compiler (supports tags, text, interpolation, basic properties and @event binding).

Mini-Vue: single file implementation (copy and paste into browser console or run in web page) ​

html
<!-- 把这一整段放到一个 HTML 文件里,打开即可看到 demo -->
<!doctype html>
<html>
<head>
  <meta charset="utf-8" />
  <title>mini-vue demo</title>
</head>
<body>
  <div id="app"></div>

  <script>
/* ---------------------------
   reactivity: effect / track / trigger / reactive / ref
   --------------------------- */
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 depsMap = targetMap.get(target)
  if (!depsMap) targetMap.set(target, (depsMap = new Map()))
  let dep = depsMap.get(key)
  if (!dep) depsMap.set(key, (dep = new Set()))
  if (!dep.has(activeEffect)) {
    dep.add(activeEffect)
    activeEffect.deps.push(dep)
  }
}

function trigger(target, key) {
  const depsMap = targetMap.get(target)
  if (!depsMap) return
  const dep = depsMap.get(key)
  if (!dep) return
  const effects = new Set(dep)
  effects.forEach(eff => {
    if (eff.scheduler) eff.scheduler(eff)
    else eff()
  })
}

function reactive(target) {
  if (typeof target !== 'object' || target === null) return target
  return new Proxy(target, {
    get(t, k, r) {
      const res = Reflect.get(t, k, r)
      track(t, k)
      return typeof res === 'object' && res !== null ? reactive(res) : res
    },
    set(t, k, v, r) {
      const old = t[k]
      const result = Reflect.set(t, k, v, r)
      if (old !== v) trigger(t, k)
      return result
    },
    deleteProperty(t, k) {
      const had = k in t
      const result = Reflect.deleteProperty(t, k)
      if (had && result) trigger(t, k)
      return result
    }
  })
}

function ref(raw) {
  const r = {
    __isRef: true,
    get value() {
      track(r, 'value')
      return raw
    },
    set value(v) {
      raw = v
      trigger(r, 'value')
    }
  }
  return r
}

/* ---------------------------
   VNode + h helper
   --------------------------- */
function h(type, props = null, children = null) {
  return { type, props, children, el: null }
}

const TEXT = Symbol('text')

function createTextVNode(text) {
  return { type: TEXT, props: null, children: String(text), el: null }
}

/* ---------------------------
   Simple template compiler
   - Uses a <template> element to parse HTML (works in browsers)
   - Supports:
     - elements with attributes (attr="value")
     - event attrs like @click => props.onClick
     - text nodes with {{ expr }} interpolation (evaluated against _ctx)
   - Returns a render function: (ctx) => vnode
   NOTE: This is intentionally tiny and not fully featured.
   --------------------------- */

function compileTemplate(template) {
  const tplRoot = document.createElement('template')
  tplRoot.innerHTML = template.trim()
  const parseNode = (node) => {
    if (node.nodeType === Node.TEXT_NODE) {
      const raw = node.textContent
      const tokens = []
      let lastIndex = 0
      const re = /{{([^}]+)}}/g
      let match
      while ((match = re.exec(raw)) !== null) {
        const index = match.index
        if (index > lastIndex) {
          tokens.push(JSON.stringify(raw.slice(lastIndex, index)))
        }
        tokens.push(`(${match[1].trim()})`)
        lastIndex = index + match[0].length
      }
      if (lastIndex < raw.length) {
        tokens.push(JSON.stringify(raw.slice(lastIndex)))
      }
      if (tokens.length === 0) return null
      const code = tokens.join(' + ')
      // create a function that returns a text vnode
      return {
        type: TEXT,
        renderCode: `createTextVNode(${code})`
      }
    } else if (node.nodeType === Node.ELEMENT_NODE) {
      const tag = node.tagName.toLowerCase()
      // attributes
      const attrs = []
      const propsCode = []
      for (let i = 0; i < node.attributes.length; i++) {
        const a = node.attributes[i]
        if (a.name.startsWith('@')) {
          // event
          const eventName = 'on' + a.name.slice(1)[0].toUpperCase() + a.name.slice(2)
          propsCode.push(`${JSON.stringify(eventName)}: _ctx.${a.value}`)
        } else {
          propsCode.push(`${JSON.stringify(a.name)}: ${JSON.stringify(a.value)}`)
        }
      }
      const children = []
      node.childNodes.forEach(n => {
        const parsed = parseNode(n)
        if (parsed) children.push(parsed)
      })
      return {
        type: 'element',
        tag,
        propsCode,
        children
      }
    }
    return null
  }

  const bodyNodes = []
  tplRoot.content.childNodes.forEach(n => {
    const parsed = parseNode(n)
    if (parsed) bodyNodes.push(parsed)
  })

  // generate render function code
  function genNodeCode(n) {
    if (n.type === TEXT) return n.renderCode
    if (n.type === 'element') {
      const props = n.propsCode.length ? `{ ${n.propsCode.join(', ')} }` : 'null'
      const kids = n.children.length
        ? `[${n.children.map(c => genNodeCode(c)).join(', ')}]`
        : 'null'
      return `h(${JSON.stringify(n.tag)}, ${props}, ${kids})`
    }
    return 'null'
  }

  const rootCode = bodyNodes.length === 1
    ? genNodeCode(bodyNodes[0])
    : `[${bodyNodes.map(n => genNodeCode(n)).join(', ')}]`

  const fnCode = `
    return function render(_ctx) {
      const { h, createTextVNode } = runtimeHelpers;
      with (_ctx) { return ${rootCode}; }
    }
  `
  // runtimeHelpers will be injected when calling the function
  const renderFactory = new Function('runtimeHelpers', fnCode)
  return (helpers) => renderFactory(helpers)
}

/* ---------------------------
   Renderer / patch (very small)
   - mountElement
   - patchElement (naive)
   - component mounting (setup + render effect)
   --------------------------- */

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 runtime = { h, createTextVNode }

/* ---------------------------
   Demo app / usage
   --------------------------- */

// Simple Counter component using template
const Counter = {
  template: `
    <div class="counter">
      <h2>Count: {{ count }}</h2>
      <div>
        <button @click="inc"> + </button>
        <button @click="dec"> - </button>
      </div>
    </div>
  `,
  setup() {
    const state = reactive({ count: 0 })
    function inc() { state.count++ }
    function dec() { state.count-- }
    // expose to template as returned object
    return Object.assign(state, { inc, dec })
  }
}

// App component with nested component and ref
const App = {
  template: `
    <div>
      <h1>mini-vue demo</h1>
      <div>{{ message }}</div>
      <Counter></Counter>
    </div>
  `,
  components: { Counter },
  setup() {
    const message = ref('Hello from mini-vue!')
    return { get message() { return message.value } }
  },
}
createApp(App).mount('#app')

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

Notes and learning points ​

  • reactive / ref / effect: simplified track / trigger / effect code demonstrates dependency collection and triggering, including branch cleanup and parent-effect restoration.
  • h + VNode + renderer: Implements minimal h(), text vnode, element mount/patch, component mount (returning state or render through setup).
  • template compiler: Use the browser's DOM parser to parse the template into AST, and then generate a simple render function.Supports interpolation and @click event properties (mapped as onClick on props), with access to the context (_ctx) returned by the component setup.
  • Limits: the example omits keyed diff, reactive props, full lifecycle support, scope/slots and compilation caching. Basic events accept method names only. Use dynamic template expressions only with trusted content.