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

显示模式

登录
ARCHIVE DOCUMENTVUE

不好意思!耽误你的十分钟,让 MVVM 原理还给你

所属馆藏
Vue
文件格式
Markdown
原始路径
Vue/57-不好意思!耽误你的十分钟,让MVVM原理还给你
本文目录11 个章节
  1. 一、MVVM 到底解决了什么问题
  2. 二、Object.defineProperty 的基础
  3. 三、一个教学版的响应式核心
  4. 四、一个可以运行的简化 MVVM
  5. 五、从数据变化到视图更新
  6. 六、数组和 Vue 2 的限制
  7. 七、computed 和生命周期应该怎样理解
  8. 八、Vue 3.5 的响应式方式
  9. 九、依赖收集的真实边界
  10. 十、原文调试图片
  11. 十一、总结

不好意思!耽误你的十分钟,让 MVVM 原理还给你

原文用一个简化的 Vue 2 风格 MVVM 解释数据劫持、数据代理、发布订阅、编译和双向绑定。这个学习路径仍然有价值,但自写代码不能等同于 Vue 2.7.16 的完整实现,更不能直接代表 Vue 3.5。

版本说明:Vue 2.7 使用 getter/setter、Observer、Dep、Watcher 和异步 scheduler;Vue 3.5 的对象/数组响应式主要使用 Proxy,同时 ref.value 仍然通过 getter/setter 追踪依赖。Vue 2 已于 2023 年 12 月 31 日结束官方维护,新项目优先使用 Vue 3。

一、MVVM 到底解决了什么问题

MVVM 可以作为一种架构理解:

  • Model:数据和业务状态;
  • View:用户看到的界面;
  • ViewModel:把状态变化同步到视图,并把用户输入转换成状态更新的中间层。

“数据驱动视图”并不意味着 Vue 会替开发者任意双向修改数据。更准确的描述是:

Model -> View:响应式依赖变化后更新渲染结果
View -> Model:输入事件、emit 或 v-model 协议更新状态

React、Vue 和 Angular 的更新模型不能简单统称为同一种“数据劫持 + 发布订阅”。本文的代码只用来解释一种 Vue 2 风格的教学实现。

二、Object.defineProperty 的基础

Object.defineProperty 可以定义数据属性或访问器属性。访问器属性中的 get 在读取时执行,set 在赋值时执行:

const obj = {}
let music = '发如雪'

Object.defineProperty(obj, 'music', {
  configurable: true,
  enumerable: true,
  get() {
    console.log('读取 music')
    return music
  },
  set(nextValue) {
    console.log('修改 music:', nextValue)
    music = nextValue
  },
})

console.log(obj.music) // 发如雪
obj.music = '夜曲'
console.log(obj.music) // 夜曲

这里使用的是 accessor descriptor,所以不能同时配置 value/writablewritable 只属于数据属性,不能用来解释上面 set() 的赋值行为。

Object.defineProperty 是 ES5 能力。Vue 2 的响应式实现需要它,因此 Vue 2 不支持 IE8 及以下;“IE8+”容易造成误解,应写成“IE9+ 时代的 Vue 2 浏览器支持范围”。

三、一个教学版的响应式核心

下面的实现只覆盖普通对象、简单路径和文本/输入框,用于观察依赖收集过程。它不是 Vue 2 源码,没有完整处理数组方法、Vue.set、依赖清理、计算属性 watcher、组件更新队列和 SSR。

3.1 Dep 和 Watcher

let activeWatcher = null

class Dep {
  constructor() {
    this.subscribers = new Set()
  }

  depend(watcher) {
    if (watcher) this.subscribers.add(watcher)
  }

  notify() {
    this.subscribers.forEach(watcher => watcher.update())
  }
}

class Watcher {
  constructor(vm, getter, callback) {
    this.vm = vm
    this.getter = getter
    this.callback = callback
    this.value = this.get()
  }

  get() {
    const previous = activeWatcher
    activeWatcher = this
    try {
      return this.getter(this.vm)
    } finally {
      activeWatcher = previous
    }
  }

  update() {
    const nextValue = this.get()
    if (!Object.is(nextValue, this.value)) {
      const previous = this.value
      this.value = nextValue
      this.callback(nextValue, previous)
    }
  }
}

真实 Vue 2 的 Watcher 还会维护新旧依赖集合、清理失效依赖并由 scheduler 去重和异步刷新。教学版直接使用 Set 去重,但仍然比“每次读取都 push 一个订阅”更接近真实语义。

3.2 观察对象

function observe(value) {
  if (!value || typeof value !== 'object') return

  Object.keys(value).forEach(key => {
    defineReactive(value, key, value[key])
  })
}

function defineReactive(target, key, initialValue) {
  const dep = new Dep()
  let value = initialValue

  observe(value)

  Object.defineProperty(target, key, {
    configurable: true,
    enumerable: true,
    get() {
      dep.depend(activeWatcher)
      return value
    },
    set(nextValue) {
      if (Object.is(nextValue, value)) return
      value = nextValue
      observe(nextValue)
      dep.notify()
    },
  })
}

每个响应式属性都应有自己的 Dep。不能把一个对象的所有 key 共用一个 Dep,否则修改 a 会错误地通知只依赖 b 的 watcher。

3.3 数据代理

function proxy(vm, data) {
  Object.keys(data).forEach(key => {
    Object.defineProperty(vm, key, {
      configurable: true,
      enumerable: true,
      get() {
        return vm.$data[key]
      },
      set(value) {
        vm.$data[key] = value
      },
    })
  })
}

数据代理的作用是让 vm.message 转发到 vm.$data.message。Vue 2 真实实现还会避开 $_ 等保留键,并检查与 props、methods 的命名冲突。

四、一个可以运行的简化 MVVM

4.1 解析路径

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

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

4.2 编译文本和 v-model

class Compile {
  constructor(root, vm) {
    this.root = typeof root === 'string'
      ? document.querySelector(root)
      : root
    this.vm = vm

    if (!this.root) throw new Error('找不到挂载节点')
    this.walk(this.root)
  }

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

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

    const modelExpression = node.getAttribute('v-model')?.trim()
    if (modelExpression && 'value' in node) {
      const updateInput = () => {
        node.value = String(getPath(this.vm, modelExpression) ?? '')
      }

      new Watcher(
        this.vm,
        vm => getPath(vm, modelExpression),
        updateInput,
      )

      node.addEventListener('input', event => {
        setPath(this.vm, modelExpression, event.target.value)
      })
    }

    Array.from(node.childNodes).forEach(child => this.walk(child))
  }

  bindText(node) {
    const template = node.textContent
    const expressionPattern = /\{\{\s*(.*?)\s*\}\}/g
    const expressions = []
    let match

    while ((match = expressionPattern.exec(template)) !== null) {
      expressions.push(match[1])
    }

    if (expressions.length === 0) return

    const render = () => {
      node.textContent = template.replace(
        expressionPattern,
        (_, expression) => String(getPath(this.vm, expression) ?? ''),
      )
    }

    // 每个占位符初始化一次 watcher;不要在每次 render 中重复 new Watcher。
    expressions.forEach(expression => {
      new Watcher(
        this.vm,
        vm => getPath(vm, expression),
        render,
      )
    })

    render()
  }
}

class Mvvm {
  constructor(options = {}) {
    this.$options = options
    this.$data = options.data || {}

    observe(this.$data)
    proxy(this, this.$data)

    if (options.el) {
      new Compile(options.el, this)
    }

    options.mounted?.call(this)
  }
}

配套 HTML:

<div id="app">
  <h1>{{ song }}</h1>
  <p>《{{ album.name }}》是{{ singer }}的专辑</p>
  <p>主打歌为{{ album.theme }}</p>
  <input v-model="singer" />
</div>

<script src="mvvm.js"></script>
<script>
  const vm = new Mvvm({
    el: '#app',
    data: {
      song: '发如雪',
      album: {
        name: '十一月的萧邦',
        theme: '夜曲',
      },
      singer: '周杰伦',
    },
    mounted() {
      console.log('mounted')
    },
  })
</script>

这个示例只实现了单向文本更新和简单 v-model。它没有 Vue 模板表达式解析、事件指令、修饰符、数组响应式和组件系统,不应直接称为“手写 Vue”。

五、从数据变化到视图更新

vm.singer = '新名字' 为例:

执行 vm.singer = ...
  ↓
代理 setter 转发到 $data.singer
  ↓
响应式属性 setter 调用 dep.notify()
  ↓
Watcher 重新读取 getter
  ↓
Watcher 的 callback 更新文本节点或 input

真实 Vue 2 的流程还会把 watcher 放入 scheduler 队列,按 id 排序、去重,并在 next tick 批量刷新。也就是说,Vue 2 的 Dep.notify() 不是简单地同步执行所有渲染工作。

六、数组和 Vue 2 的限制

Object.defineProperty 本身可以劫持已经存在的数组下标,但 Vue 2 的 Observer 并不是为每个数组下标都定义 getter。Vue 2 对数组采用七个变异方法的拦截:

push、pop、shift、unshift、splice、sort、reverse

因此 Vue 2 中下面两种写法不会自动触发更新:

this.items[index] = nextValue
this.items.length = 0

可以使用:

this.$set(this.items, index, nextValue)
this.items.splice(index, 1, nextValue)

Vue 3 的 Proxy 可以直接追踪对象新增/删除、数组下标和 length,但它仍有数组 instrumentation 来处理迭代、身份查询和批量更新。两代实现不能混为同一段源码。

七、computed 和生命周期应该怎样理解

原文尝试手写 computed 和 mounted,这对理解概念有帮助,但真实 computed 并不是简单地给实例定义一个 getter:

  • Vue 2 computed 使用 lazy watcher,并缓存上次结果;
  • 依赖变化时会让 computed 失效,下一次访问再求值;
  • computed 还会把依赖转发给外层渲染 watcher;
  • Vue 3 使用 ComputedRefImpl、Dep 和调度机制;
  • mounted 只在客户端组件挂载后执行,SSR 不执行需要 DOM 的 mounted。

如果只是学习 JavaScript,可以写一个最小 getter:

const state = { a: 1, b: 2 }
const view = {
  get sum() {
    return state.a + state.b
  },
}

但不要把它描述成 Vue computed 的完整实现。

八、Vue 3.5 的响应式方式

Vue 3 新项目通常这样写:

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

const singer = ref('周杰伦')
const count = ref(1)
const double = computed(() => count.value * 2)

function updateSinger() {
  singer.value = '新名字'
}
</script>

<template>
  <p>{{ singer }}</p>
  <p>{{ double }}</p>
  <button @click="updateSinger">修改</button>
</template>

reactive() 适合对象、数组和 Map/Set:

import { reactive, toRefs } from 'vue'

const state = reactive({
  user: { name: 'Vue' },
  items: [],
})

const { user, items } = toRefs(state)

setup()<script setup> 不使用 Options API 的 this,而是使用闭包中的 ref、reactive、props、emit 和 composable。Vue 3 的对象响应式主要依赖 Proxy,但 ref.value 仍然是 getter/setter 形式的响应式引用。

九、依赖收集的真实边界

教学版经常写成:

Dep = 一个数组
Watcher = 一个回调
get = addSub
set = notify

Vue 2.7 的真实实现还包括依赖去重、依赖清理、target stack、数组依赖、computed watcher、user watcher、渲染 watcher 和 scheduler。Vue 3.5 则使用 targetMap、Dep、Link、ReactiveEffect 和 batch 调度。简化模型用于建立直觉,源码结论必须以对应版本为准。

十、原文调试图片

以下图片保留原文用于说明递归观察和数据代理的调试截图,已经本地化:

Vue 2 递归 observe 调试截图(历史资料)

Vue 2 新对象 observe 调试截图(历史资料)

Vue 2 数据代理调试截图(历史资料)

图片只用于辅助理解,不能作为当前源码的版本证明。

十一、总结

  1. MVVM 是组织 Model、View 和 ViewModel 的一种方式;
  2. Vue 2.7 的响应式主线可以概括为 getter/setter、Observer、Dep、Watcher 和 scheduler;
  3. 一个教学版 MVVM 可以用 defineProperty + Dep + Watcher 帮助理解依赖收集;
  4. 教学代码必须明确边界,不能把简化实现冒充 Vue 源码;
  5. Vue 3.5 的对象和数组主要使用 Proxy,ref.value 仍有 getter/setter;
  6. Vue 2 数组下标、新属性和 length 修改存在响应式限制,Vue 3 的 Proxy 改善了这些边界;
  7. 新项目应优先学习 Vue 3 Composition API,不要把 Vue 2 的 this._data、指令和 watcher 代码直接迁移过去。

原文出处:不好意思!耽误你的十分钟,让 MVVM 原理还给你

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

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

按分类浏览

10 COLLECTIONS