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