Skip to content

template compiler 增强版,加入了:

  • v-if(编译为三元/短路)
  • v-for(编译为 .map(),支持 item in items / (item, index) in items
  • :prop / v-bind:prop 表达式(编译为动态 props)
  • @event / v-on 与事件修饰符(.stop .prevent .self
  • 生成简易 patchFlagdynamicProps 列表,并在 runtime 的 setProps 中利用这些信息做最小化更新(只更新动态 props / 文本)

这个实现依然保持简短、可运行(在浏览器直接打开),便于你实验、扩展以及与真实 Vue 的编译思想对照学习。下面是完整单文件实现,把整段 HTML 保存为 mini-vue-compiler.html 并在浏览器打开即可运行 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 eff = () => {
    try { activeEffect = eff; return fn() }
    finally { activeEffect = null }
  }
  eff.scheduler = options.scheduler
  if (!options.lazy) eff()
  return eff
}
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())
  dep.add(activeEffect)
}
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 childrenHasArray = childrenCodes.some(c => c.isArray)
      const childrenCode = childrenCodes.length === 0 ? 'null'
                          : (childrenCodes.length === 1 ? childrenCodes[0].code
                          : '[' + childrenCodes.map(c => c.code).join(', ') + ']')

      // 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
        wrapperParts.push(`return (${a.exp})(e)`)
        const fnCode = `(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 capitalize(s){ return s.charAt(0).toUpperCase()+s.slice(1) }

  function setProps(el, oldProps = {}, newProps = {}, dynamicList = null) {
    // if dynamicList provided (array of names), only update those; else full update
    if (Array.isArray(dynamicList)) {
      // remove keys not present in newProps among dynamicList
      for (const key of dynamicList) {
        const oldVal = oldProps && oldProps[key]
        const newVal = newProps && newProps[key]
        if (key.startsWith('on')) {
          // event
          if (oldVal !== newVal) {
            el[key.toLowerCase()] = newVal || null
          }
        } else {
          if (oldVal !== newVal) {
            if (newVal == null) el.removeAttribute(key)
            else el.setAttribute(key, newVal)
          }
        }
      }
    } else {
      // full diff
      for (const k in oldProps) {
        if (!(newProps && k in newProps)) {
          if (k.startsWith('on')) el[k.toLowerCase()] = null
          else el.removeAttribute(k)
        }
      }
      for (const k in newProps || {}) {
        const v = newProps[k]
        if (k.startsWith('on') && typeof v === 'function') el[k.toLowerCase()] = v
        else el.setAttribute(k, v)
      }
    }
  }

  function mountElement(vnode, container) {
    const el = document.createElement(vnode.type)
    vnode.el = el
    const { props, children } = vnode
    if (props) setProps(el, {}, props, vnode.dynamicProps)
    if (Array.isArray(children)) {
      children.forEach(child => patch(null, child, el))
    } else if (children != null) {
      el.appendChild(document.createTextNode(children))
    }
    container.appendChild(el)
  }

  function patchElement(n1, n2, container) {
    const el = (n2.el = n1.el)
    // props: if patchFlag indicates props only update dynamicProps
    if (n2.patchFlag & PatchFlags.PROPS) {
      setProps(el, n1.props || {}, n2.props || {}, n2.dynamicProps)
    } else {
      setProps(el, n1.props || {}, n2.props || {}, null)
    }
    // children
    const c1 = n1.children, c2 = n2.children
    if (Array.isArray(c2)) {
      // naive: clear and remount
      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 Comp = vnode.type
    // setup
    if (Comp.setup) {
      const res = Comp.setup(instance.props || {}, { emit: () => {} })
      if (typeof res === 'function') instance.render = res
      else instance.setupState = res || {}
    }
    if (!instance.render && Comp.template) {
      const renderFactory = compileTemplate(Comp.template)
      instance.render = renderFactory({ h, createTextVNode })
    }
    if (!instance.render && Comp.render) instance.render = Comp.render

    function componentUpdate() {
      if (!instance.isMounted) {
        const sub = instance.render(instance.setupState)
        instance.subTree = sub
        patch(null, sub, container)
        instance.isMounted = true
        vnode.el = sub && sub.el
      } else {
        const newSub = instance.render(instance.setupState)
        patch(instance.subTree, newSub, container)
        instance.subTree = newSub
        vnode.el = newSub && newSub.el
      }
    }
    instance.update = effect(componentUpdate, { scheduler(fn){ Promise.resolve().then(fn) } })
  }

  function patch(n1, n2, container) {
    if (n1 === n2) return
    if (n1 && n1.type !== n2.type) {
      if (n1.el && n1.el.parentNode) n1.el.parentNode.removeChild(n1.el)
      n1 = null
    }
    if (n2 == null) return
    if (n2.type === TEXT) {
      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') {
      if (!n1) mountComponent(n2, container)
      else {
        // simplistic: remount component
        mountComponent(n2, container)
      }
    } else if (Array.isArray(n2)) {
      n2.forEach(child => patch(null, child, container))
    }
  }

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

/* ---------------------------
  Runtime helpers export
   --------------------------- */
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 { items: 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 { msg: 'hello mini-vue: ' + state.count, inc, toggle, show: state.show, nums: state.nums, onRootClick }
  }
}

// Mount: we need to resolve component tags in templates. Our compiler emits string tags,
// and createRenderer treats string tags as native elements. For component usage we mount manually:
function mountApp(rootComponent, selector) {
  // build root vnode manually combining compiled root and component nodes where necessary.
  // For simplicity: compile root template to render function that can return child component VNodes
  const renderFactory = compileTemplate(rootComponent.template)
  const render = renderFactory(runtimeHelpers)
  const ctx = rootComponent.setup ? rootComponent.setup() : {}
  // Now render will produce VNodes; but component tags produced are string names (e.g. 'itemlist')
  // To support <ItemList>, we will walk produced VNodes and replace nodes with matching component registrations.
  function resolveComponents(vnode, comps){
    if (!vnode) return vnode
    if (Array.isArray(vnode)) return vnode.flatMap(v=>resolveComponents(v, comps) || [])
    if (vnode.type === TEXT) return vnode
    if (typeof vnode.type === 'string') {
      const tag = vnode.type
      // try to match registered component by case-insensitive or PascalCase
      const comp = comps[tag] || comps[capitalize(tag)] || comps[lowerFirst(tag)]
      if (comp) {
        // transform into component vnode
        const compVNode = h(comp, vnode.props, vnode.children)
        return compVNode
      }
      return vnode
    }
    return vnode
  }

  const raw = render.call({ __runtimeHelpers: runtimeHelpers }, ctx)
  // raw might be array or single vnode
  const resolved = resolveComponents(raw, rootComponent.components || {})
  const container = document.querySelector(selector)
  if (Array.isArray(resolved)) resolved.forEach(v=>patch(null, v, container))
  else patch(null, resolved, container)
}

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

mountApp(App, '#app')

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

说明(要点回顾)

  1. v-if:在编译期被转为条件表达式 cond ? vnode : null,运行时直接返回 null(不渲染),避免不必要的 patch。
  2. v-for:被转为 source.map((item, index) => { return vnode }),生成的 render 会返回数组(isArray),renderer 当前采用扁平化/重建策略(简单明了)。
  3. :prop / v-bind:被识别为动态 props,编译器会把这些 prop 表达式放入 props 对象并记录 dynamicProps 列表(用于 patchFlag 优化)。
  4. @event.mod:支持 .stop.prevent.self(可以扩展),会在生成的事件处理函数里包装修饰符逻辑。
  5. patchFlags:编译器会生成简易的 patchFlag(例如 TEXTPROPS),renderer 在更新时会根据这些 flag 做部分更新(只更新 dynamicProps 列表里指定的属性),减少 DOM 操作。
  6. 实现限制:为了简洁,编译器使用浏览器 DOM 解析器(template.innerHTML)做 AST,表达式通过 with(_ctx){ ... } 执行 —— 这在真实编译器中是更严格、安全、并且生成预编译函数的。我们的目标是「教学与实验」,不是生产安全或完备实现。