包含:reactive、ref、effect、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 核心设计的关键思想,便于你扩展、调试与深入源码。