技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTVUE

基于 Vue 的思想实现一个简易 MVVM

所属馆藏
Vue
文件格式
Markdown
原始路径
Vue/73-基于Vue实现一个简易MVVM
本文目录8 个章节
  1. 一、MV* 设计模式的演变
  2. 二、观察者模式和发布/订阅模式
  3. 三、Vue 的运行机制简述
  4. 四、基于 Vue 机制实现一个简易 MVVM
  5. 五、Vue 3 写法的现代对应关系
  6. 六、常见错误和边界
  7. 七、总结
  8. 参考资料

基于 Vue 的思想实现一个简易 MVVM

原文从 MV* 模式、观察者/发布订阅、Vue 运行机制和一个自定义 b-* 指令系统展开。本文保留这条学习路线,并把抓取后损坏的代码整理成一个有边界、可运行的教学实现。它用于理解原理,不是 Vue 源码,也不建议替代 Vue 处理生产项目。

一、MV* 设计模式的演变

如果把 GUI 应用的关注点拆开,通常可以看到:

  • View:展示界面并接收用户交互;
  • Model:业务数据和领域状态;
  • Logic:处理输入、业务规则和状态同步。

MVC、MVP、MVVM 都是在尝试拆分这些职责。它们不是前端框架必须严格遵循的语法,而是帮助组织代码的概念模型。

MVC

经典 MVC 中,View 接收用户操作,Controller 协调输入和业务,Model 保存状态并通知相关 View:

经典 MVC 结构示意图(已本地化)

优点:

  • 职责分离;
  • Model 可以被多个视图使用;
  • Controller 和业务逻辑有机会复用。

局限:

  • Controller 可能不断膨胀;
  • View 与 Model 的依赖关系在不同实现中差异很大;
  • 经典 GUI MVC 不能直接等同于服务端 MVC。

服务端 MVC 和 JSP Model 1/2

JSP Model 1 把页面、控制逻辑和数据访问混在 JSP/JavaBean 中,职责较为混乱:

JSP Model 1 示意图(已本地化)

JSP Model 2 把请求处理、模型和视图分开,更接近服务端 MVC:

JSP Model 2 示意图(已本地化)

服务端 MVC 和浏览器中的响应式 UI 不是同一个运行环境:HTTP 请求通常是无状态的,页面是否保留状态还涉及 Cookie、Session、缓存和客户端脚本。

服务端 MVC 示意图(已本地化)

MVP

MVP 让 View 尽量变成被动视图,由 Presenter 负责协调 Model 和 View:

MVP 结构示意图(已本地化)

这样可以让 View 更容易测试和复用,但 Presenter 也可能变得很厚。

MVVM

MVVM 在 View 和 Model 之间加入 ViewModel。ViewModel 通常包含状态、派生状态、事件处理和数据绑定逻辑:

MVVM 结构示意图(已本地化)

“数据双向绑定”常被用来描述 MVVM,但需要准确理解:

Model → View:响应式渲染
View → Model:用户事件/输入处理

这不是任意对象修改都会自动互相同步。Vue 的 v-model 也是属性绑定加事件监听的语法糖;组件之间仍然应遵循 props 下行、emits 上行的单向数据流。

二、观察者模式和发布/订阅模式

1. 观察者模式

观察者模式中,Subject 维护 Observer 列表。Observer 主动注册,Subject 状态变化后直接调用观察者的更新方法:

观察者模式示意图(已本地化)

subject.subscribe(observer)
subject.notify(change)

Subject 通常知道观察者集合,因此耦合关系比较直接。Vue 2 的 Dep/Watcher、Vue 3 的依赖集合和 effect,都是“依赖收集—触发更新”思想的具体实现,但不能把它们简单等同于业务层的 Subject/Observer 类。

2. 发布/订阅模式

发布者和订阅者通过事件通道或 broker 传递消息,发布者通常不直接持有订阅者对象:

发布订阅模式示意图(已本地化)

channel.subscribe('user:updated', handler)
channel.publish('user:updated', user)

两者的常见区别:

维度观察者模式发布/订阅模式
中间层通常没有独立 broker有事件通道或 broker
依赖Subject 直接维护 ObserverPublisher 和 Subscriber 可解耦
调试依赖关系较容易沿对象查找事件名可能形成隐式依赖
适用响应式依赖、对象状态通知跨模块事件、消息分发

“发布订阅一定是双向通信”并不准确。通信方向取决于是否建立反向频道;模式本身既不保证双向,也不自动解决事件顺序、异常、取消订阅和内存泄漏。

三、Vue 的运行机制简述

原文以 Vue 2 Runtime + Compiler 为主要背景。Vue 3.5 的编译器、响应式和 renderer 已经重写,但总体链路仍可以这样理解:

组件选项 / <script setup> / template
                ↓
       编译器生成 render function
                ↓
      首次执行 render 生成 VNode
                ↓
       renderer mount 到宿主环境
                ↓
响应式状态变化 → 重新调度组件更新 → patch

Vue 运行机制示意图(已本地化)

1. Vue 2 的初始化和编译

Vue 2 Runtime + Compiler 构建中,如果组件提供 template 而没有 render,会经历大致流程:

  1. parse:把模板解析为 AST;
  2. optimize:标记静态节点;
  3. generate:生成 render function 字符串;
  4. $mount:创建渲染 watcher 并首次执行 render;
  5. patch:把 VNode 创建、更新为真实 DOM。

Vue 2 的响应式主要使用 Object.definePropertyDepWatcher。首次执行 render 时读取响应式数据,getter 收集当前渲染 watcher;数据 setter 触发后,相关 watcher 入队并进行批量更新。

2. Vue 3 的编译和渲染

Vue 3 的 template 编译可以抽象为:

parse → transform → codegen

运行时还会利用:

  • 静态提升;
  • Patch Flags;
  • Block Tree;
  • keyed children 的高效更新;
  • 组件更新 job 的调度和去重。

因此,不能把 Vue 的性能收益概括成“Virtual DOM 一定比 DOM 快”或“Diff 总是产生最小 DOM 操作”。实际成本取决于组件边界、编译优化、列表 key、响应式范围和真实设备。

3. Vue 3 的响应式

Vue 3 主要使用 Proxy/Reflect 拦截对象操作,配合 track/trigger 和 ReactiveEffect:

Vue 3 Proxy 响应式流程示意图(已本地化)

读取 reactive 对象属性
       ↓ track(target, key)
建立 effect 与属性的依赖
       ↓
写入、删除或修改集合
       ↓ trigger(target, key)
调度组件、computed 或 watcher

Proxy 改善了新增/删除属性、数组和 Map/Set 等操作的覆盖范围,但不是没有成本,也无法在 IE11 中被完整 polyfill。refcomputed、scheduler 和集合 instrumentation 仍然是响应式系统的重要部分。

四、基于 Vue 机制实现一个简易 MVVM

下面实现一个有限范围的 b-* 指令系统,支持:

  • b-model="input.message":输入框双向绑定;
  • b-text="input.message":安全地更新 textContent
  • b-on-click="increment":绑定实例方法;
  • {{ path }}:简单文本插值;
  • 通过 Mediator 按数据路径发布变化;
  • destroy() 清理事件和订阅。

它有意不实现 Vue 的完整模板语法、组件、条件渲染、列表、表达式解析器和安全沙箱。

1. HTML 使用方式

<div id="app">
  <label>
    消息:
    <input b-model="input.message">
  </label>
  <p b-text="input.message"></p>
  <p>插值:{{ input.message }}</p>
  <button b-on-click="increment">计数:{{ count }}</button>
</div>

<script src="./mini-mvvm.js"></script>
<script>
  const vm = new MiniMVVM({
    el: '#app',
    data: {
      input: { message: 'Hello MVVM!' },
      count: 0,
    },
    methods: {
      increment() {
        this.count += 1
      },
    },
  })
</script>

简易 MVVM 示例输出(已本地化)

2. Mediator

class Mediator {
  constructor() {
    this.channels = new Map()
  }

  subscribe(channel, callback) {
    if (!this.channels.has(channel)) {
      this.channels.set(channel, new Set())
    }

    const subscribers = this.channels.get(channel)
    subscribers.add(callback)

    return () => {
      subscribers.delete(callback)
      if (subscribers.size === 0) this.channels.delete(channel)
    }
  }

  publish(channel, payload) {
    const subscribers = this.channels.get(channel)
    if (!subscribers) return

    for (const callback of [...subscribers]) {
      callback(payload)
    }
  }
}

原文使用数字 uid 取消订阅,这种方式可以工作,但返回 unsubscribe 函数更不容易误删,也能让组件在销毁时清理自身监听器。

3. 路径读取和设置

function getPath(object, path) {
  return path.split('.').reduce((value, key) => value?.[key], object)
}

function setPath(object, path, nextValue) {
  const keys = path.split('.')
  const lastKey = keys.pop()
  const target = keys.reduce((value, key) => value[key], object)

  if (!target || !lastKey) {
    throw new Error(`无法设置路径:${path}`)
  }

  target[lastKey] = nextValue
}

任意用户输入都不应被当成 JavaScript 表达式执行。这里仅支持点分隔属性路径,是为了避免自行实现一个不安全的 eval 模板表达式解析器。

4. Object.defineProperty 数据劫持

function observe(object, basePath, notify, seen = new WeakSet()) {
  if (!object || typeof object !== 'object' || seen.has(object)) return
  seen.add(object)

  for (const key of Object.keys(object)) {
    let value = object[key]
    const path = basePath ? `${basePath}.${key}` : key

    observe(value, path, notify, seen)

    Object.defineProperty(object, key, {
      enumerable: true,
      configurable: true,
      get() {
        return value
      },
      set(nextValue) {
        if (Object.is(nextValue, value)) return
        value = nextValue
        observe(value, path, notify, seen)
        notify(path, nextValue)
      },
    })
  }
}

这段代码是 Vue 2 响应式思想的极简练习,不是完整实现:

  • 只能为初始化时已经存在的普通对象属性定义 getter/setter;
  • 新增/删除属性不会自动被捕获,需要显式 API 或重新处理;
  • 数组索引和 length 需要额外处理,Vue 2 通过改写部分变更方法弥补;
  • Map/Set 需要独立的集合操作拦截;
  • 循环引用、不可扩展对象、非枚举属性和原型链还有更多边界。

原文说 Object.defineProperty “无法深层监听对象”不够准确:只要递归遍历已知嵌套对象,它可以深层观察已存在的属性;真正的限制是新增/删除属性、数组和集合等操作覆盖不完整,以及初始化递归成本较高。

数据劫持和发布更新示意图(已本地化)

5. 编译视图指令

class Compiler {
  constructor(root, vm) {
    this.root = root
    this.vm = vm
    this.cleanups = []
  }

  compile() {
    this.walk(this.root)
    return () => {
      for (const cleanup of this.cleanups) cleanup()
      this.cleanups = []
    }
  }

  walk(node) {
    if (node.nodeType === Node.TEXT_NODE) {
      this.compileText(node)
      return
    }

    if (node.nodeType !== Node.ELEMENT_NODE) return

    for (const attribute of [...node.attributes]) {
      const { name, value } = attribute

      if (name === 'b-text') {
        this.bindText(node, value)
        node.removeAttribute(name)
      } else if (name === 'b-model') {
        this.bindModel(node, value)
        node.removeAttribute(name)
      } else if (name.startsWith('b-on-')) {
        this.bindEvent(node, name.slice(5), value)
        node.removeAttribute(name)
      }
    }

    for (const child of [...node.childNodes]) this.walk(child)
  }

  compileText(node) {
    const template = node.textContent
    if (!template || !/{{\s*([\w.]+)\s*}}/.test(template)) return

    const update = () => {
      node.textContent = template.replace(
        /{{\s*([\w.]+)\s*}}/g,
        (_, path) => String(getPath(this.vm.$data, path) ?? ''),
      )
    }

    this.bindPath(update, template)
  }

  bindText(node, path) {
    this.bindPath(() => {
      node.textContent = String(getPath(this.vm.$data, path) ?? '')
    }, path)
  }

  bindModel(node, path) {
    const update = () => {
      node.value = String(getPath(this.vm.$data, path) ?? '')
    }

    const input = event => {
      setPath(this.vm.$data, path, event.target.value)
    }

    node.addEventListener('input', input)
    this.cleanups.push(() => node.removeEventListener('input', input))
    this.bindPath(update, path)
  }

  bindEvent(node, eventName, methodName) {
    const method = this.vm.$methods[methodName]
    if (typeof method !== 'function') {
      throw new Error(`方法不存在:${methodName}`)
    }

    const handler = event => method.call(this.vm, event)
    node.addEventListener(eventName, handler)
    this.cleanups.push(() => node.removeEventListener(eventName, handler))
  }

  bindPath(update, template) {
    update()

    const paths = [...template.matchAll(/\b[\w]+(?:\.[\w]+)*/g)]
      .map(match => match[0])
      .filter(path => getPath(this.vm.$data, path) !== undefined)

    for (const path of new Set(paths)) {
      const unsubscribe = this.vm.$mediator.subscribe(path, update)
      this.cleanups.push(unsubscribe)
    }
  }
}

这个 Compiler 只支持教学所需的有限语法。生产模板编译器必须处理 HTML 解析、指令参数、表达式作用域、组件、插槽、事件修饰符、XSS 和编译缓存,不能通过简单正则替代。

6. ViewModel

class MiniMVVM {
  constructor(options) {
    this.$data = options.data || {}
    this.$methods = options.methods || {}
    this.$mediator = new Mediator()
    this.$destroyed = false

    for (const key of Object.keys(this.$data)) {
      Object.defineProperty(this, key, {
        enumerable: true,
        configurable: true,
        get: () => this.$data[key],
        set: value => {
          this.$data[key] = value
        },
      })
    }

    observe(this.$data, '', (path, value) => {
      this.$mediator.publish(path, value)
    })

    const root = typeof options.el === 'string'
      ? document.querySelector(options.el)
      : options.el

    if (!root) throw new Error('找不到挂载元素')

    this.$compiler = new Compiler(root, this)
    this.$stopCompiler = this.$compiler.compile()
  }

  $destroy() {
    if (this.$destroyed) return
    this.$destroyed = true
    this.$stopCompiler?.()
  }
}

简易 MVVM 的数据流示意图(已本地化)

在这个练习中,流程是:

初始化 data
  ↓
defineProperty 劫持已知属性
  ↓
Compiler 解析 b-* 指令和插值
  ↓
View 事件修改 Model
  ↓
setter 发布路径变化
  ↓
Mediator 通知绑定该路径的视图

它和 Vue 的关键差异是:

  • 没有基于读取行为的精确依赖收集;
  • 没有组件实例、VNode、renderer 和 scheduler;
  • 没有异步批量更新;
  • 只支持简单点路径,不支持模板表达式;
  • 不处理数组、Map/Set、动态属性和组件生命周期。

五、Vue 3 写法的现代对应关系

在真实 Vue 3 项目中,不应继续维护自定义 b-* 编译器,而可以这样写:

<script setup lang="ts">
import { computed, reactive, watchEffect } from 'vue'

const state = reactive({
  message: 'Hello Vue',
  count: 0,
})

const label = computed(() => `${state.message} (${state.count})`)

function increment() {
  state.count++
}

watchEffect(() => {
  console.log('当前状态:', state.message, state.count)
})
</script>

<template>
  <input v-model="state.message">
  <p>{{ label }}</p>
  <button @click="increment">增加</button>
</template>

这里:

  • reactive/ref 提供响应式状态;
  • computed 提供缓存的派生状态;
  • watchEffect 承担自动追踪的副作用;
  • 模板编译器处理插值、v-model 和事件;
  • renderer 将 VNode patch 到真实 DOM。

组件通信仍然推荐 props/emits:

<!-- Parent.vue -->
<Child :value="state.message" @update:value="state.message = $event" />

Vue 3 中也可以使用:

<Child v-model="state.message" />

v-model 不是任意 Model 和 View 自动双向修改,而是编译为 modelValue prop 和 update:modelValue 事件;多个模型可以使用 v-model:title 等参数。

六、常见错误和边界

  1. Vue、React、Angular 都是严格 MVVM 实现:不严谨。它们都借鉴了数据驱动、组件化或 MV* 思想,但具体数据流和渲染模型不同;
  2. 双向绑定就是数据自动互相修改:不准确。Vue 的 View → Model 仍通过事件或 v-model 约定;
  3. Observer 和 Pub/Sub 完全相同:不准确,前者通常有直接依赖,后者通过消息通道解耦;
  4. Object.defineProperty 无法递归监听:不准确,递归已知属性可以做到,但新增属性、数组和集合支持不完整;
  5. Proxy 解决所有响应式问题:不准确。Proxy 扩大拦截范围,但仍需要依赖收集、effect、scheduler 和集合处理;
  6. innerHTML 更新用户输入安全:错误。动态 HTML 必须经过可信来源或清洗,普通文本使用 textContent
  7. 自定义 MVVM 代码可以直接替代 Vue:错误。教学实现缺少模板安全、组件系统、异步调度、SSR 和完整 renderer;
  8. Vue 一定只做最小 DOM 更新:错误。优化取决于编译信息、key、组件更新范围和具体 renderer。

七、总结

MVVM 的学习价值在于理解 View、Model 和同步逻辑如何解耦。一个简易实现通常需要:

  1. 保存 Model;
  2. 劫持或代理状态变化;
  3. 解析有限的视图绑定;
  4. 处理 View → Model 的输入事件;
  5. 在 Model 变化后通知对应视图;
  6. 在销毁时清理事件和订阅。

Vue 2 使用 defineProperty、Dep、Watcher 和模板编译;Vue 3 使用 Proxy、ReactiveEffect、scheduler、编译器优化和 renderer。理解这些机制可以帮助我们正确使用 refreactivecomputedwatchv-model 和组件通信,但不要把教学版 MVVM 的代码直接当作 Vue 源码或生产框架。

参考资料

原文作者:子弈。原文关于 MV* 历史、观察者/发布订阅、Vue 运行机制、数据劫持、视图编译和简易 MVVM 的主线予以保留;课程推广、抓取噪声、损坏的代码块、不安全的 innerHTML 说明及错误的响应式绝对化表述已清理或修正。

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS