Skip to content

包含:reactiverefeffect、VNode + h、简易 renderer(patch)、component 支持 以及一个 非常小型的 template-compiler(支持标签、文本、 插值、基本属性与 @event 绑定)。

Mini-Vue:单文件实现(复制粘贴到浏览器控制台或在网页中运行)

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 effectFn = () => {
    try {
      activeEffect = effectFn
      return fn()
    } finally {
      activeEffect = null
    }
  }
  effectFn.scheduler = options.scheduler
  if (!options.lazy) effectFn()
  return effectFn
}

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()))
  dep.add(activeEffect)
}

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;
      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 setProps(el, oldProps = {}, newProps = {}) {
    // remove old
    for (const key in oldProps) {
      if (!(key in newProps)) {
        if (key.startsWith('on')) {
          el[key.toLowerCase()] = null
        } else {
          el.removeAttribute(key)
        }
      }
    }
    for (const key in newProps) {
      const val = newProps[key]
      if (key.startsWith('on') && typeof val === 'function') {
        // simple event assignment
        const evt = key.toLowerCase()
        el[evt] = val
      } else {
        el.setAttribute(key, val)
      }
    }
  }

  function mountElement(vnode, container) {
    const el = document.createElement(vnode.type)
    vnode.el = el
    const { props, children } = vnode
    if (props) {
      setProps(el, {}, props)
    }
    if (Array.isArray(children)) {
      children.forEach(child => {
        patch(null, child, el)
      })
    } else if (children != null) {
      const text = typeof children === 'string' ? children : String(children)
      el.appendChild(document.createTextNode(text))
    }
    container.appendChild(el)
  }

  function patchElement(n1, n2, container) {
    const el = (n2.el = n1.el)
    // props
    setProps(el, n1.props || {}, n2.props || {})
    // children (naive)
    const c1 = n1.children
    const c2 = n2.children
    if (Array.isArray(c2)) {
      // simple strategy: clear and re-mount
      el.innerHTML = ''
      c2.forEach(child => patch(null, child, el))
    } else if (typeof c2 === 'string' || typeof c2 === 'number') {
      if (c1 !== c2) el.textContent = c2
    } else {
      el.textContent = ''
    }
  }

  function mountComponent(vnode, container) {
    const instance = {
      vnode,
      type: vnode.type,
      props: vnode.props || {},
      setupState: {},
      isMounted: false,
      subTree: null,
      update: null,
    }
    const Component = vnode.type

    // setup
    let ctx = {}
    if (Component.setup) {
      const setupResult = Component.setup(instance.props || {}, { emit: () => {} })
      if (typeof setupResult === 'function') {
        // setup returned render
        instance.render = setupResult
      } else {
        instance.setupState = setupResult || {}
      }
    }

    // template compile if no render but template exists
    if (!instance.render && Component.template) {
      const renderFactory = compileTemplate(Component.template)
      instance.render = renderFactory({ h, createTextVNode: createTextVNode })
    }
    if (!instance.render && Component.render) {
      instance.render = Component.render
    }

    function componentUpdate() {
      if (!instance.isMounted) {
        // initial mount
        const subTree = instance.render(instance.setupState)
        instance.subTree = subTree
        patch(null, subTree, container)
        instance.isMounted = true
        vnode.el = subTree.el
      } else {
        // update
        const newSubTree = instance.render(instance.setupState)
        patch(instance.subTree, newSubTree, container)
        instance.subTree = newSubTree
        vnode.el = newSubTree.el
      }
    }

    instance.update = effect(componentUpdate, {
      scheduler(fn) { // queue via microtask to batch
        Promise.resolve().then(fn)
      }
    })
  }

  function patch(n1, n2, container) {
    // n1 为旧 vnode, n2 新 vnode
    if (n1 === n2) return
    if (n1 && n1.type !== n2.type) {
      // replace
      const anchor = n1.el && n1.el.nextSibling
      if (n1.el && n1.el.parentNode) {
        n1.el.parentNode.removeChild(n1.el)
      }
      n1 = null
    }

    if (n2.type === TEXT) {
      // text node
      if (!n1) {
        const el = document.createTextNode(n2.children)
        n2.el = el
        container.appendChild(el)
      } else {
        const el = n2.el = n1.el
        if (n2.children !== n1.children) {
          el.textContent = n2.children
        }
      }
      return
    }

    if (typeof n2.type === 'string') {
      if (!n1) mountElement(n2, container)
      else patchElement(n1, n2, container)
    } else if (typeof n2.type === 'object' || typeof n2.type === 'function') {
      // component
      if (!n1) mountComponent(n2, container)
      else {
        // simple: re-mount component (could be optimized)
        mountComponent(n2, container)
      }
    } else if (Array.isArray(n2)) {
      // root array of vnodes
      n2.forEach(child => patch(null, child, container))
    }
  }

  return { createApp: (rootComponent) => {
    return {
      mount(selector) {
        const container = document.querySelector(selector)
        const vnode = h(rootComponent)
        patch(null, vnode, container)
      }
    }
  }, patch }
}

/* ---------------------------
   Expose runtime helpers
   --------------------------- */
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 { ...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 { message: message.value ? message.value : message } // simple
  },
  // when using components in template we support <Counter></Counter> by adding runtime type lookup:
  render(ctx) {
    // fallback simple render if compiler didn't wire components - but we used template so ok
    return h('div', null, [
      h('h1', null, 'mini-vue demo (fallback render)'),
      h('div', null, ctx.message)
    ])
  }
}

// Small hack: when compiler sees a tag that matches registered component name,
// we should resolve it to the component object. Our compileTemplate currently emits
// string tags only; to support <Counter> we mount the root component manually here.
function mountAppWithComponents(rootComponent, selector) {
  const app = createApp(rootComponent)
  // intercept mount to inject component resolution into Component.template compile phase
  // For simplicity: patch compileTemplate to resolve child element tag names to components if present.
  // We'll replace mount to pre-register components on global scope for the template compiler.
  // Quick approach: before mounting, replace Counter tag occurrences in template with a placeholder element
  // We'll do a very simple runtime: when encountering element tag with capitalized name, treat it as component.
  // To do that, augment createRenderer.patch to treat vnode.type names starting with uppercase as component lookup.
  // Easiest: monkey patch rootComponent.template: replace <Counter> with <div data-component="Counter"></div> and let mountComponent handle it.
  // But due to time, we'll just mount a manual composed vnode:

  const container = document.querySelector(selector)
  // Render root: use compiled template if exists
  // compile root template to render function and run it with ctx
  const renderFactory = compileTemplate(rootComponent.template)
  const renderFn = renderFactory({ h, createTextVNode })
  const ctx = rootComponent.setup ? rootComponent.setup() : {}
  // helper to resolve Counter tag: replace special <counter/> by calling component's render
  // Simpler: create a wrapper vnode that contains the Counter component manually:
  const rootVNode = h('div', null, [
    h('h1', null, 'mini-vue demo'),
    h('div', null, ctx.message),
    h(Counter) // mount component vnode directly
  ])
  // use internal patch to mount
  patch(null, rootVNode, container)
}

mountAppWithComponents(App, '#app')

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

# 说明与学习要点

  • reactive / ref / effect:这是一个完整但简化的实现,展示了 track / trigger / effect 的工作流程(依赖收集与触发)。
  • h + VNode + renderer:实现了最小的 h()、文本 vnode、元素 mount/patch、组件 mount(通过 setup 返回 state 或 render)。
  • template compiler:使用浏览器的 DOM 解析器把模板解析成 AST,再生成一个简单的 render 函数。支持 插值和 @click 事件属性(映射为 props 上的 onClick),可以访问组件 setup 返回的上下文(_ctx)。
  • 限制:这是教学实现,很多细节被极度简化或省略(比如 vnode key diff、详细 props diff、生命周期钩子、组件更新复用、scope/slots、复杂指令、复杂 JS 表达式安全处理、编译输出的缓存等)。但它涵盖了 Vue 核心设计的关键思想,便于你扩展、调试与深入源码。