
好接下来我直接把当时手写这个框架的过程和思考完整写出来。这既是一次编码练习也是理解数据绑定与响应式原理最扎实的路径。我不会把代码贴完就结束每一步都会解释“为什么这么做”因为面试时最容易被问倒的往往是这些“为什么”。前几天有个朋友问我MVVM到底是个什么魔法怎么几行代码下去页面上的数字就自己更新了。我没急着让他背概念而是直接拉着他用原生JavaScript手写了一个极简的MVVM框架。当代码一步步跑起来的瞬间他对数据绑定与响应式的理解比刷十篇理论文章都管用。这篇分享就是那一次推导过程的完整记录也是我从零实现一个简易MVVM框架的经验沉淀。整篇内容不依赖任何框架源码只靠Object.defineProperty原生能力就能搭出响应式系统、依赖收集和模板编译三大核心模块。适合想弄懂Vue响应式原理的前端新人也适合在WPF、Android、Qt里写MVVM但想回头补课的同学。1. 别急着写代码先弄懂MVVM到底解决什么问题1.1 从“面条代码”到分层MVVM之前我每天在干嘛很多人第一次接触MVVM时看到的全是术语ViewModel、数据绑定、双向绑定、依赖收集。这些词单看都能理解拼在一起就懵了。所以我想先聊一个更朴素的问题在没有MVVM框架的日子里开发者的代码长什么样。就拿一个最简单的用户列表页面来说。你有一份数据数组要渲染成一个表格用户点某一行要高亮选中点删除按钮要把这一行从界面和数据里同时移除。用传统命令式写法这些动作全部要靠手写DOM操作完成。数据删了得手动找到对应的DOM节点删掉样式变了得手动改class如果表格里还有合计行、空状态提示那每个分支都要自己同步一遍。这种代码写不了几天就会乱成一锅粥。数据、交互、展示逻辑全搅在一起改一个需求可能要顺藤摸瓜改七八处。我早期做管理后台时就曾经因为一个“删除后重新计算合计”的逻辑漏改了一个分支结果线上数据对不上排查了整整一个下午。后来复盘时发现问题根源不在“漏改”而在“手动同步”这个模式本身太容易出错——只要有一处忘记调更新函数数据与界面就会悄悄分家。MVVM把这种混乱拆开的核心思路就是三个字分层。View只管模板长什么样Model只管数据长什么样ViewModel夹在中间负责把数据的变化“翻译”成界面的变化。但这只是职责划分的表面真正让这个设计成立的关键是一个叫“数据绑定”的机制——有了它你才不用手动同步。1.2 ViewModel才是灵魂它把状态和视图彻底解耦我在教别人看MVVM架构图时总会强调一句别把注意力放在View或Model上真正的灵魂是中间那层ViewModel。因为它的存在View和Model才能完全不知道对方的存在。Model层就是普通的数据对象它不关心自己会被渲染在表格里还是卡片里更不关心页面上有没有人点它。View层就是模板它不该自己去找数据也不该自己发请求改数据。而ViewModel这层把Model的数据“加工”成View能直接使用的形态同时把View上的用户操作“翻译”成对Model的修改。打个比方Model和View就像两个说着不同语言的同事ViewModel就是那个翻译器。翻译器的好处是任何一边换了另一边不需要跟着改。以前我用jQuery写项目数据变了要亲手同步DOM现在你用MVVM框架写项目数据变了页面自动刷新因为翻译器在替你干活。但这里有个容易误解的点ViewModel不是一个一成不变的静态对象它必须做到“Model一变View就知道”。这就要靠响应式系统来支撑。所以我们可以把MVVM看成两部分一部分是“绑定语法”比如Vue里的模板指令WPF里的Binding另一部分是“响应式引擎”也就是数据变化后自动通知界面的底层机制。我这次从零实现重点就是后面这个引擎。1.3 数据驱动视图一次“自动同步”替代无数手动赋值理解了分层之后还得建立另一个直觉MVVM的核心心智模型叫数据驱动视图。这句话怎么理解你不需要告诉界面“我要更新这一行数据”你只需要把数据改了界面自己会反应过来。我常拿Excel表格来类比。你在A1单元格写了5在B1单元格写了公式“A1*2”那么B1永远跟着A1走。A1改成10B1不用你动它自己变成20。这不就是数据绑定吗A1是数据源B1是依赖方Excel内部有一个依赖追踪系统A1一变所有依赖于A1的单元格都会自动重算。MVVM的响应式系统干的也是同一件事。你在data里定义了一个count模板里有三处地方显示count另外还有一个计算逻辑依赖于它。当你执行count时框架需要自动找到这三个显示位置和一个计算逻辑然后精准地通知它们去更新。注意是精准通知不是整页刷新。能做到精准靠的是依赖收集能做到自动靠的是数据劫持。这两件事就是后面所有代码的核心。带着这套心态再去看Vue的文档你会感觉很多语法都是顺理成章的如果直接去啃源码也能更快定位到关键代码的意图。2. 响应式系统的工作原理劫持、收集与派发2.1 Object.defineProperty的拦截原理给属性装上“门卫”现在正式进入响应式系统的第一块基石数据劫持。JavaScript里想感知一个对象属性的变化最经典的办法就是Object.defineProperty。这个API允许你在定义属性时额外提供get和set两个函数它们会在属性被读取、被修改时自动触发。我用一个生活化的例子解释。你可以把对象属性想象成一间屋子getter就是屋门口的摄像头每次有人进屋读取摄像头都看得见setter就是门卫每次有人要往屋里换家具修改门卫都可以决定放不放行或者做点额外记录。响应式系统就是在这间屋子的门口装了这套监控把读写操作全部拦截下来。具体到实现上每个属性都会预先存储一个真正的value。当外部执行obj.name时走的是get函数返回存储的value当外部执行obj.name 新值时走的是set函数先更新存储的value再触发一次“数据已经变化”的广播。这里有个细节值得注意get和set是通过闭包引用内部value的所以不会跟对象本身产生循环引用。这个设计几乎贯穿所有响应式框架Vue 2也基本是这个套路。你在阅读任何框架源码时只要看到Object.defineProperty第一反应就应该是这里在做数据劫持。2.2 Dep与Watcher数据变化如何精准通知视图数据劫持只能感知变化但感知到之后该通知谁才是依赖收集要解决的问题。我这里先引入两个概念Dep和Watcher。Dep全称Dependency可以理解为一个依赖管理列表。每个被劫持的属性都会配备一个Dep实例专门记录“谁在用这个属性”。Watcher可以理解为一个观察者它代表一个需要更新的目标。比如模板里有一处显示count的文本节点那个文本节点的更新函数就会被包装成一个Watcher。整个运行逻辑是这样的。模板编译时如果发现某个文本节点引用了count就创建一个对应的Watcher并让Watcher去读取一次实例上的count。读取count时count的get拦截器就被触发此时Dep会把当前正在创建的Watcher收集到自己维护的列表里这就是依赖收集的过程。当count的值被修改count的set拦截器被触发它会调用自己Dep列表里所有Watcher的update方法让每个Watcher重新执行一次渲染函数这就是派发更新的过程。一句话总结这套机制get时收集依赖set时通知依赖。你要是能把这个闭环记在脑子里响应式原理就掌握了一大半。这个设计里有个关键细节就是“当前正在创建的Watcher”怎么传递。大部分框架的通用做法是设置一个全局标记比如Dep.target属性创建Watcher时先把Dep.target指向自己再触发get拦截器完成依赖收集收集完立刻置空。这样无论对象层级多深收集过程中遇到的所有属性都能准确找到它们应该登记在哪个Watcher名下。2.3 为什么Vue 3要把Object.defineProperty换成Proxy我在讲响应式原理时几乎都会被问到同一个问题Vue 3为什么要把Object.defineProperty换成Proxy理解这个问题的答案也能反过来帮你更深刻地理解旧方案的局限。Object.defineProperty是在“属性”级别做拦截的这意味着如果对象上新增了一个属性或者删除一个属性原生的拦截器根本感知不到。数组也是同样的道理对索引赋值、修改length这类操作旧方案默认劫持不到。Vue 2为了解决这些问题不得不额外提供Vue.set、Vue.delete这样的API还得通过重写数组的push、pop等方法来模拟响应式。这些都是补丁不是根除。Proxy则不同它是在“对象”级别做拦截的。你不需要关心对象里有哪些属性也不需要预先遍历定义getter和setter只要给目标对象罩上一层代理任何属性的添加、删除、读取、修改都会先经过代理的处理器函数。这等于把原来的“每个属性装门卫”换成了“整栋楼门口一次安检”效率和覆盖面都大幅提升。不过Proxy也不是银弹。它对浏览器环境要求更高兼容性相比defineProperty要差一些而且在使用上要注意操作代理对象和操作原始对象完全是两回事稍不注意就容易出现“改了原始对象但页面不响应”的诡异现象。这些取舍我在手写框架时没有深入展开但心里一直清楚我们手写的简易版虽然用的是旧方案但它能让你完整理解新旧方案共同的那条核心链路——拦截、收集、通知。2.4 一次数据变更的完整生命周期从get收集到set派发把概念串起来之后我习惯用一条时间线描述一次数据变更的完整生命周期。假设页面上显示一个用户信息模板里有这么一段p{{ user.name }}/p。页面首次加载时编译过程会创建Watcher并触发Watcher去读取this.user.name。读取过程中会经过user的get拦截器和name属性的get拦截器于是这两个属性各自的Dep都把这个Watcher记入列表。随后Watcher首次执行渲染函数把user.name的值填入文本节点页面出现第一版内容。这时你在控制台里执行vm.user.name 新名字修改会先经过name属性的set拦截器。拦截器更新真正存储的value然后调用name对应Dep的notify方法通知列表里的Watcher执行update。Watcher收到通知后重新读取一次this.user.name发现新旧值不一致于是再次调用渲染函数把文本节点内容更新成新名字。整个过程中只有这个文本节点发生变化其他内容纹丝不动。我刻意强调读取user属性也收集依赖是因为很多人写代码时会漏掉嵌套对象这一层。如果模板里读取的是user.name但你只给name属性收集依赖却发现修改user对象整体引用比如vm.user newUser时页面不更新。原因就是user对象本身的get拦截器没有收集过这个Watcher。这就是为什么每一层get都要触发depend。手写框架时把这条时间线想清楚后面所有bug定位都会省力很多。3. 手把手实现一个迷你MVVM框架核心实操记录3.1 第一步实现observable让普通对象变“有知觉”先从一个最底层的函数开始observable。它的作用是接收一个普通对象遍历对象的每一个属性用Object.defineProperty给属性装上get和set拦截器。每个属性还会配一个专属的Dep实例用来管理依赖。我贴一段简化版代码重点看注释。class Dep { static target null; // 当前正在计算的Watcher constructor() { this.subs new Set(); // 用Set自动去重 } depend() { if (Dep.target) { this.subs.add(Dep.target); } } notify() { this.subs.forEach(watcher watcher.update()); } } function observable(obj) { Object.keys(obj).forEach(key { let value obj[key]; const dep new Dep(); Object.defineProperty(obj, key, { enumerable: true, configurable: true, get() { dep.depend(); // 读取属性时收集当前Watcher return value; }, set(newVal) { if (newVal value) return; // 值没变就不通知 value newVal; dep.notify(); // 修改属性时触发所有Watcher } }); }); return obj; }这里有几个设计决策需要说明。第一个Dep用Set而不是数组来管理Watcher因为同一个Watcher可能被同一个属性多次收集其实只需要记录一次Set天然去重避免后面重复执行更新。第二个set函数里先判断新旧值是否相同因为当赋值没有实际变化时去通知一堆Watcher只会造成无意义的渲染。第三个get和set都通过闭包访问value千万不要直接用obj[key]去读取否则会陷入get拦截器递归调用的死循环这是我第一次写时踩过的坑。嵌套对象也是个必踩的点。如果data.user是个对象那么observable默认不会深入递归。你需要在observable里加一段判断当属性值本身是对象时递归调用observable否则后续修改user.address.city这类深层属性时页面不会更新。实际操作里这一步越早加越好免得后面返工。3.2 第二步实现Watcher搭建数据与视图的桥梁有了可以感知变化的对象还需要一个能“订阅变化”的角色这就是Watcher。Watcher的职责是告诉Dep“我在关注某个属性”并且当属性变化时执行自己身上绑定的回调函数。我按这个思路实现了一个最基础的Watcher。class Watcher { constructor(vm, exp, cb) { this.vm vm; this.cb cb; this.value this.get(); // 首次读取完成依赖收集 } get() { Dep.target this; // 把自己设为全局当前Watcher const value this.vm[this.exp]; // 触发属性get拦截器 Dep.target null; // 收集完成后立刻清空 return value; } update() { const newVal this.vm[this.exp]; const oldVal this.value; if (newVal ! oldVal) { this.value newVal; this.cb.call(this.vm, newVal, oldVal); } } }这个类的关键动作在get方法里。它先把Dep.target指向自己然后去读取exp对应的属性值。一旦属性被读取get拦截器里就会执行dep.depend()而depend方法判断Dep.target存在于是把这个Watcher添加进依赖列表。读取结束后立刻把Dep.target清空防止这个Watcher误收集到后续其他操作触发的属性。我之所以把get方法首次读取放在构造函数里是为了让Watcher在创建时就完成依赖收集而不是等第一次更新才收集。这个行为也被称作“订阅即触发”实际使用中模板刚开始渲染时所有Watcher都会自动完成初始化收集页面自然能显示出第一版内容。update方法里我额外加了新旧值判断这其实是一道性能防线。数据频繁修改但不影响最终结果时可以减少重复渲染。别小看这个判断后续在处理异步批量更新时它还能帮忙过滤掉中间状态。3.3 第三步实现Compile解析模板并绑定依赖响应式对象和Watcher都就绪后还需要把模板跟它们串起来。这个串起来的过程就是模板编译我这里给它取名Compile。它的核心工作是遍历DOM节点把模板里的插值表达式和指令替换成真实的渲染逻辑并创建对应的Watcher。我选择直接操作真实DOM而不是构建虚拟DOM因为简化版的目的是理解原理不是追求渲染性能。编译过程主要处理两类节点文本节点和元素节点。文本节点里最常见的就是{{ msg }}这种插值语法元素节点里则要解析v-model、v-on:click这类指令。先看文本节点的处理思路。我的做法是先用正则匹配出所有插值表达式然后为每个表达式创建一个WatcherWatcher的回调函数负责把整段文本重新渲染一遍。function compileTextNode(node, vm) { const reg /\{\{\s*([\w.])\s*\}\}/g; const origin node.nodeValue; const render () { node.nodeValue origin.replace(reg, (match, key) { return getValue(vm, key.trim()); }); }; let match; const keys []; while ((match reg.exec(origin))) { keys.push(match[1].trim()); } keys.forEach(key new Watcher(vm, key, render)); render(); // 首次渲染 } function getValue(vm, path) { return path.split(.).reduce((acc, key) acc[key], vm); }这里有个容易忽略的坑一个文本节点里可能存在多个插值比如{{ firstName }} {{ lastName }}。如果只给第一个表达式建Watcher第二个表达式变化时不会触发更新。所以我把所有匹配到的key都解析出来每个key各建一个Watcher但回调共用同一个render函数。这样任何一个依赖变化整段文本都会重新渲染简单粗暴但有效。3.4 第四步加入v-model与事件绑定完成双向闭环模板编译不能只处理文本节点还得处理元素节点否则连最简单的点击交互都做不了。我选了两个最核心的指令来实现v-model和v-on:click。v-model的本质是语法糖它同时做了两件事把数据绑定到元素的value属性上并且监听元素的input事件反过来更新数据。这样用户在输入框里打字数据会变数据变了输入框内容也会跟着更新形成一个双向闭环。v-on:click则简单一些它把方法绑定到元素点击事件上。我实现时的主要工作就是解析属性名从vm实例上取出对应方法并确保方法内部的this指向vm。function compileElement(node, vm) { const attrs Array.from(node.attributes || []); attrs.forEach(attr { const name attr.name; const value attr.value.trim(); if (name.startsWith(v-on:)) { const event name.slice(5); const handler vm[value]; node.addEventListener(event, () handler.call(vm)); } else if (name v-model) { node.addEventListener(input, e { setValue(vm, value, e.target.value); }); new Watcher(vm, value, val { node.value val null ? : val; }); } }); }setValue函数需要支持嵌套路径的写入实现逻辑是把路径按点分割逐层向下找到最内层对象再对最内层属性赋值。配合observable的set拦截器赋值完成后所有依赖这个属性的Watcher会依次被通知页面自动更新。注意v-model首次渲染时我也创建了一个Watcher来同步数据的初始值否则输入框打开时会是空的数据明明有值却看不见。3.5 完整代码串联new SimpleVue后页面自动更新现在把前面几个模块组装起来就得到了一个完整的迷你MVVM框架。我把它封装成一个SimpleVue类构造函数接收el、data和methods三个配置项内部完成数据代理、方法绑定和模板编译三个步骤。class SimpleVue { constructor(options) { this.$el document.querySelector(options.el); this.$data observable(options.data()); this.$methods options.methods || {}; this._proxyData(); this._bindMethods(); compile(this.$el, this); } _proxyData() { Object.keys(this.$data).forEach(key { Object.defineProperty(this, key, { get: () this.$data[key], set: val { this.$data[key] val; } }); }); } _bindMethods() { Object.keys(this.$methods).forEach(key { this[key] this.$methods[key].bind(this); }); } }compile函数负责递归遍历DOM树对每个节点分派给compileTextNode或compileElement处理。做完这些之后页面自动完成三件事首次渲染出数据内容数据变化时更新渲染内容用户操作界面时反向修改数据。整个框架加起来不到两百行但数据绑定和响应式的核心链路已经完整跑通了。我在实际测试时习惯做一个最简单的demo页面上放一个输入框和一个展示文本输入内容下面文本实时同步再放一个按钮点击后把count加一页面上所有显示count的地方同时变化。如果这些基础场景都正常工作说明这个简易MVVM已经具备了框架的雏形。4. 实操中绕不开的坑与排查技巧4.1 数组与新增属性的“失灵”为什么改数据页面不动我用了这么多年响应式框架遇到最多的报障记录大概就是“明明改了数据页面就是不刷新”。这在手写框架里几乎必然遇到而且原因往往集中在两类数组和新增属性。数组的问题在于Object.defineProperty本身不能有效拦截length属性的变化对数组索引直接赋值也走不到响应式通知。Vue 2的解决方案是重写数组原型上的push、pop、splice等方法让这些方法在修改数组后主动触发更新。但如果是通过索引赋值比如arr[2] xxx即便重写数组方法也无能为力必须依赖Vue.set一类的辅助函数或调用splice。我在手写版里给出的处理方式是在observable函数里判断属性值是否为数组如果是就递归劫持数组里的元素对象并额外重写数组的七个常用方法。实际项目中你尽可以只支持push和splice因为这两个使用频率最高。新增属性的问题更隐蔽。响应式系统只会劫持初始化时存在的属性后面新增的属性因为没有经过defineProperty处理自然没有get和set拦截器。解决思路是提供一个类似observeNewValue的辅助函数对新增属性手动执行一次劫持并让该属性的Dep通知相关Watcher。如果你在用Vue 2直接调Vue.set即可在Vue 3里因为Proxy的存在这个问题从根上消失了。4.2 频繁赋值引发的性能问题给更新加点缓冲另一个让我印象深刻的坑是频繁赋值导致Watcher被反复调用。举个例子你在一次函数里连续执行三次同一个属性的赋值操作每一次都会触发notify文本节点就会被重复渲染三次。如果渲染逻辑里有复杂计算页面会出现明显卡顿。我想到的优化方案是异步批量更新也叫派发更新合并。核心思路是把所有需要更新的Watcher先缓存到一个队列里用Set去重然后通过微任务统一执行一轮更新。这样即使同一个属性被连续修改一百次同一个Watcher在队列里也只会保留一份最终只渲染一次。我在手写框架里实现了一个简单的scheduler。当Dep.notify触发时不直接调用Watcher.update而是把Watcher放入全局队列并调用Promise.resolve().then来调度一轮flushSchedulerQueue。flush时按相同顺序执行所有Watcher的update方法。这个方法让我实际体验到了“把同步通知改为异步合并”给性能带来的直观提升。4.3 模板解析的边界情况花括号、嵌套指令与DOM陷阱模板解析过程中我踩过很多边界情况这里挑几个有代表性的分享。第一个是文本节点的live特性。在遍历DOM子节点时如果你同时在节点上做替换操作NodeList是live集合索引位置会动态变化很容易跳过一些子节点或进入死循环。解决方法是先Array.from(node.childNodes)拷贝一份快照再遍历。第二个是插值语法里的正则贪婪问题。如果模板写成{{ user.name }}我的正则能正常匹配但如果表达式更复杂比如{{ count 1 }}简单的单一key匹配就失效了。为了兼顾复杂度我在手写版里只支持点路径和普通key但会预留扩展点如果你传的是一个函数就执行函数获取返回值作为渲染值。第三个是v-model作用于select、textarea等不同标签时的行为差异。input标签要处理的是value和input事件checkbox要处理的是checked属性select要处理的是option选中状态。这里很容易出现“数据改了但UI没同步”的问题因为我只监听了input事件。实际项目里要注意分类判断至少区分文本框和复选框两种情况。4.4 常见问题排查速查表我把手写过程中遇到的高频问题整理成了一张速查表方便以后排查时快速定位。这张表同样适用于你分析Vue 2或类似框架里的诡异现象。现象可能原因排查思路修改属性值后页面不更新属性不是响应式属性或set拦截器未正确触发检查数据是否在初始化时定义打印对象自身属性特性新增属性后没有自动刷新新增属性未经过劫持确认是否调用新增属性的劫持逻辑数组push后页面无变化数组方法重写未生效或未触发notify检查数组方法是否被正确拦截尝试splice替代修改值后没有变化但反复渲染新旧值比较逻辑缺失在set和update中增加值比较避免无效派发模板第一次渲染为空Watcher首次get没有执行渲染函数检查Watcher构造函数是否调用了get方法一次修改引发多次重复渲染无异步合并多个Watcher重复入队引入Set队列和微任务调度去重后统一执行排查这些问题的核心方法只有一个在get拦截器、set拦截器、Dep.notify里分别打日志或者断点一次数据变更应该依次经历读取、收集、修改、通知、更新这几个步骤。哪一步没执行到问题就在哪一段代码里。5. MVVM不止于前端WPF、Android、Qt里的同一种思想5.1 WPF/WinForms里的INotifyPropertyChanged写完前端版的MVVM再回头看其他技术栈你会发现它们只是在“通知机制”上换了种写法。比如微软WPF里的MVVM模式核心接口是INotifyPropertyChanged。这个接口定义得很简单只有一个PropertyChanged事件ViewModel在属性setter里手动触发事件View层通过绑定引擎订阅事件更新界面。我早年在做WinForms项目时用过DataBindings控件配合INotifyPropertyChanged。思路跟前端版几乎一模一样数据源在变化时发通知绑定层订阅通知并刷新控件。区别在于WPF的绑定表达式是在XAML里声明的比如{Binding UserName}绑定引擎自动为它创建监听你不用手写Watcher类。但底层逻辑仍然是数据提供方通知消费者。因为需要手动在setter里触发事件写过C#的开发者应该都有体会那套代码特别繁琐一个属性要写三行事件调用。所以后来有人用T4模板、源生成器来自动生成这些代码本质上也是在消灭“手写手动通知”的重复劳动。理解了前端手写框架里的Dep和Watcher你再看这些工程手段会发现大家都在解决同一个问题。5.2 Android里的DataBinding与StateFlowAndroid端的MVVM演化更有意思。早期用MVP时每来一个响应都要手动更新View和前端“手动同步DOM”完全同构。后来Google推出的DataBinding库允许在布局XML里直接绑定数据配合ObservableField来实现属性级更新这实际上就是WPF的Binding思路在Android上的移植。ObservableField内部持有依赖监听者setValue时自动通知跟前端的Dep收集Watcher极其相似。最近几年Jetpack Compose流行开来响应式的实现又换了一套面貌。StateFlow负责持有状态collectAsState把状态收集成UI状态底层靠协程和组合函数跟踪读写依赖定位上相当于前端里的“Proxy 副作用系统”。但不管API怎么变只要你理解了“读取时收集、修改时通知”这条路上手任何平台的响应式框架都比别人快不少。我在给团队讲Android的MVVM时会直接把前端那份框架代码拿出来映射ObservableField对应劫持后的属性DataBinding对应CompilesetValue对应notify。映射完之后大家就不再觉得DataBinding是什么黑魔法了。5.3 Qt里的信号槽机制与MVVM最后说说Qt。Qt虽然没有内建的MVVM模块但它的信号槽机制本身就是一个天然的观察者模式跟WPF的INotifyPropertyChanged异曲同工。你在ViewModel里定义属性变更信号View连接槽函数来响应然后把数据变更操作放到槽函数里更新界面控件这就是标准MVVM的落地姿势。Qt里的Q_PROPERTY配合NOTIFY关键字能够把属性变更信号暴露给元对象系统QML绑定可以直接监听这些信号。接触过QML的话你会发现它的绑定语法和前端插值表达式很像连依赖自动追踪都有。一个QML里写text: viewModel.username背后就有响应式系统在监听username的信号一旦变化界面上所有引用它的地方都会自动更新。不同平台的命名不同但底层模式高度一致。我建议你一旦吃透了任何一个平台的响应式核心不必背另一平台的API细节遇到新的框架先去查它的“通知机制是什么”就够了。看懂了机制语法只是皮囊。Mini框架写完那天我又特意跑了一遍测试页输入框敲字下面文本实时同步点击按钮计数器跳数字修改数组里某个对象字段列表项跟着更新。虽然整个项目不到两百行但每一行代码背后都能对上“劫持、收集、派发”这三个词。我个人在实际操作中的体会是手写一个简易MVVM框架的最大价值不是让你去换掉Vue或React而是把那种“框架很神秘”的距离感彻底打碎。之后再遇到“为什么数据不更新”、“为什么死循环渲染”这类问题你会习惯性地从数据劫持、依赖收集、派发更新这几个角度去拆解不再停留在猜和试的层次。如果你也想证明自己真的理解数据绑定与响应式原理别急着去刷源码先跟我一样从零写一个两百行的框架出来写通了你就入门了。