Glittering's blog Glittering's blog
Home
  • 学习手册

    • 《JavaScript教程》
    • 《ES6 教程》
    • 《TypeScript 从零实现 axios》
    • 《Git》
    • 《Vite》
    • 《Vue3》
    • TypeScript
    • CSS
  • 技术文档
  • 算法
  • 工作总结
  • 实用技巧
  • collect
About
  • Classification
  • Label
GitHub (opens new window)

Glitz Ma

前端开发工程师
Home
  • 学习手册

    • 《JavaScript教程》
    • 《ES6 教程》
    • 《TypeScript 从零实现 axios》
    • 《Git》
    • 《Vite》
    • 《Vue3》
    • TypeScript
    • CSS
  • 技术文档
  • 算法
  • 工作总结
  • 实用技巧
  • collect
About
  • Classification
  • Label
GitHub (opens new window)
  • vue3学习路径
  • Vue3 Virtual DOM & 渲染机制
  • Vue3 响应式核心
  • Vue3数组、Map、Set 特殊处理
  • Vue3与vue2响应式处理的不同
  • 写一个Mini-vue
  • Mini-vue template增强版
  • 《Vue3》学习笔记
mamingjuan
2025-02-26

Mini-vue template增强版

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。

<!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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481

# 说明(要点回顾)

  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(例如 TEXT、PROPS),renderer 在更新时会根据这些 flag 做部分更新(只更新 dynamicProps 列表里指定的属性),减少 DOM 操作。
  6. 实现限制:为了简洁,编译器使用浏览器 DOM 解析器(template.innerHTML)做 AST,表达式通过 with(_ctx){ ... } 执行 —— 这在真实编译器中是更严格、安全、并且生成预编译函数的。我们的目标是「教学与实验」,不是生产安全或完备实现。
上次更新: 2025/12/10, 08:07:53
写一个Mini-vue

← 写一个Mini-vue

Copyright © 2015-2025 Glitz Ma
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式