vue 响应式系统

vue 响应式系统

日期
Jan 14, 2026
标签
JavaScript
描述
模拟实现 vue 响应式
本文旨在简单实现下 vue 的响应式系统,因为 vue 的响应式系统是十分复杂的,笔者能力也不是很强,所以本文重在记录笔者实现响应式系统的心路历程。
对于什么是 vue 的响应式,网络上有通俗的理解即“数据变化,界面也跟着变化。”,这是一个十分合理的解释,因为给人的感官上确是如此。但是继续深究就会发现有很多问题,比如如何监听数据的变化,数据变化了我该如何同步 UI 渲染呢?

情景引入

以下准备了一个简单的 HTML 项目代码,页面中有一个 box,box 中展示姓、名、年龄三个数据,数据来自于 index.js,通过 index.js 中 showFirstNameshowLastNameshowAge 三个函数来负责将数据(user 对象)渲染到页面中去。
示例代码
<!-- index.html --> <div class="box"> <p id="firstName"></p> <p id="lastName"></p> <p id="age"></p> </div> <script src="index.js"></script>
// index.js var user = { name: "吴锦皓", birth: '2004-11-21' } function showFirstName() { document.querySelector("#firstName").innerHTML = "姓:" + user.name[0]; } function showLastName() { document.querySelector("#lastName").innerHTML = "名:" + user.name.slice(1); } function showAge() { var birthDate = new Date(user.birth); var today = new Date(); var age = today.getFullYear() - birthDate.getFullYear(); var monthDiff = today.getMonth() - birthDate.getMonth(); if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) { age--; } document.querySelector("#age").innerHTML = "年龄:" + age + "岁"; } showFirstName(); showLastName(); showAge();
运行项目之后,发现,在浏览器控制台修改 user 对象的数据,user 对象的数据改动了,但是页面上的内容并没有改动。这是因为我们把数据渲染到页面中,是通过调用 showFirstName 等函数通过调用 innerHTML 来把数据插入到对应的 DOM 元素上的,所以理应想到,那就再调用一次函数呗,比如我想更新 user.name 属性,那么我就重新调用下 showFirstNameshowLastName 这两个函数:
user.name = "张伟"; showFirstName(); showLastName();
效果不错,刷新页面之后,页面中的内容也替换了。
但是这里有个问题,就是因为我们是开发者,我们知道当 name 改变时,只需要调用 showFirstNameshowLastName,而不需要调用 showAge 函数。但是作为程序而言,它并不知道我修改了 name 属性之后,该调用哪些函数。仔细观察会发现,当我改变 name 属性,想让 UI 同步更新,我调用了”依赖 user.name 的函数“,同理,当我改变 birth 属性时,我需要调用依赖 birth 属性的函数。也就是如下的效果:
user.name = "张伟"; showFirstName(); // 依赖 name showLastName(); // 依赖 name user.birth = '1990-05-15'; showAge(); // 依赖 birth
现在我们就把问题抽象成了:“想要当数据发生变化时,同步更新渲染,只需要在该数据发生修改时,调用该数据依赖的渲染函数,触发局部更新”。这样思路就很明确了,现在就是要解决两个问题:
  1. 如何监听对象属性的变化(在什么时机调用渲染函数)
  1. 如何调用具体的依赖该属性的渲染函数(如何找到依赖该属性的渲染函数)
 

在什么时候调用渲染函数?

也就是说我们现在需要解决这么一个问题:当对象的属性发生变化时,我要同步执行一些函数,这个可以使用属性描述符来实现:
user.name = "张三丰"; var internalName = user.name; Object.defineProperty(user, 'name', { get: function() { console.log("访问了name属性"); return internalName; }, set: function(val) { internalName = val; console.log("修改了name属性"); } })
我们为 name 属性添加 getset 方法,这两个方法都是 function,既然是 function 那就很好办了,那么就可以直接在 set 方法中,同步调用函数:
var internalName = user.name; Object.defineProperty(user, 'name', { get: function() { console.log("访问了name属性"); return internalName; }, set: function(val) { internalName = val; console.log("修改了name属性"); showFirstName(); showLastName(); } })
现在刷新页面,在浏览器控制台修改 name 属性的值,发现 UI 同步更新了,证明该方法可行。
其次,我们这仅仅实现了第一步,已经实现了一个非常粗略的响应式。然后我们自然而然会想到把这段代码,封装成通用的一个模块,无论将来是 name 还是 birth 还是其他的什么什么属性发生了修改,我们都可以使用该逻辑。
在根目录中新建一个 vue-reactive.js。在该文件中,编写一个 observe 函数,该函数用来观察对象的属性值发生变化。
/** * 观察对象中的属性变化 * @param {Object} obj */ function observe(obj) { for (const key in obj) { let internalValue = obj[key]; Object.defineProperty(obj, key, { get() { return internalValue; }, set(val) { internalValue = val; // 自动调用该属性对应的更新函数 } }) } }
因为我们现在是在封装一个公共的模块,我们不知道用户会有哪些函数依赖这些属性,所以我们可以开辟一个数组去存储用户调用的依赖该属性的函数 funcs,然后,可以通过 get 在属性访问时自动执行的机制,将该函数添加到 funcs 数组中,并在 set 中进行调用。这样就解决了更新函数调用时机的问题。
const funcs = [] Object.defineProperty(obj, key, { get() { funcs.push(abc) // 假设 abc 是依赖该属性的更新函数 return internalValue; }, set(val) { internalValue = val; // 自动调用该属性对应的更新函数 for (const func of funcs) { func(); } } })
但是这里涉及到一个问题,就是如果用户的更新函数调用了两次该属性,那么 funcs 数组就会重复记录该函数,所以我们需要对重复的 func 进行一波去重,可以使用 ES6 的 Set 也可以使用数组的 includes 方法,这里简单使用数组的 includes 方法:
if (!funcs.includes(abc)) funcs.push(abc)
解决了时机问题,那么我们下一步就应该解决该具体调用什么函数的问题。
 

如何找到依赖该属性的渲染函数

现在更新函数是由用户直接调用的,但是这样无论如何程序也拿不到该函数。
vue 的解法是,在调用更新函数的时候,不要直接调用,而是交给一个第三者去调用这个更新函数,这个第三者具体做了这么一件事:开辟一个全局变量,在该函数调用之前,将该函数赋值给这个全局变量,在函数调用结束之后销毁这个全局变量。
创建这个全局变量的时候,在该函数运行时,比如该函数访问了 name 属性,在 name 属性的 get 修饰符中,我们就可以通过这个全局变量,来拿到该函数,并添加到 funcs 数组中。
在该函数调用后销毁,是为了当下次再调用另一个更新函数时,比如更新 birth 属性,在 birthget 中,确保我拿到的这个函数是纯净的,不会被之前赋值的函数污染,导致拿到了错误的更新函数。
全局变量我这里使用 window
window.__func = showFirstName showFirstName(); window.__func = null
这样我们就可以修改属性描述符中的 get 方法了:
/** * 观察对象中的属性变化,当属性变化时,执行相关函数 * @param {Object} obj */ function observe(obj) { for (const key in obj) { let internalValue = obj[key]; const funcs = [] Object.defineProperty(obj, key, { get() { if (window.__func && !funcs.includes(window.__func) ) funcs.push(window.__func) return internalValue; }, set(val) { internalValue = val; for (const func of funcs) func(); } }) } }
完美!这样我们就解决了,无论用户调用了什么更新函数,我们都可以为对象的属性和该更新函数建立联系,并在属性修改时调用该更新函数,我们基本上完成 vue 基础的响应系统大部分了。
接下来再处理全局变量定义的部分,我们目前的代码是这样的:
window.__func = showFirstName showFirstName(); window.__func = null window.__func = showLastName showLastName(); window.__func = null window.__func = showAge showAge(); window.__func = null
这样写并不是一个优雅的解决方案,我们可以为这个全局变量的状态管理再独立开辟一个模块:
function autoRun(fun) { window.__func = fun fun(); window.__func = null }
然后在调用更新函数之前,让 autoRun 去接管该函数的执行:
autoRun(showFirstName); autoRun(showLastName); autoRun(showAge);
在这之前别忘了给 user 加上我们之前定义好的 observer 并将 vue-reactive.js 脚本加入到 HTML 中:
// index.js observe(user); // index.html <script src="vue-reactive.js"></script> <script src="index.js"></script>
同时我们还能给 HTML 中加入点输入框,来测试最终效果:
<body> <div class="box"> <p id="firstName"></p> <p id="lastName"></p> <p id="age"></p> </div> <form action=""> <input type="text" oninput="user.name = this.value" /> <input type="date" onchange="user.birth = this.value" /> </form> <script src="vue-reactive.js"></script> <script src="index.js"></script> </body>
大功告成!我们模拟实现了一个简单的 vue 响应式系统,虽然项目还有很多 bug,但是大致的思路我们现在已经能够掌握。
所以 vue 的响应式系统绝不是数据变页面就跟着变这么简单,而是当数据变化时,自动调用了与该数据依赖的更新函数,以达到更新渲染的效果。
 
参考代码
// index.js var user = { name: "吴锦皓", birth: '2004-11-21' } observe(user); function showFirstName() { document.querySelector("#firstName").innerHTML = "姓:" + user.name[0]; } function showLastName() { document.querySelector("#lastName").innerHTML = "名:" + user.name.slice(1); } function showAge() { var birthDate = new Date(user.birth); var today = new Date(); var age = today.getFullYear() - birthDate.getFullYear(); var monthDiff = today.getMonth() - birthDate.getMonth(); if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) { age--; } document.querySelector("#age").innerHTML = "年龄:" + age + "岁"; } autoRun(showFirstName); autoRun(showLastName); autoRun(showAge);
// vue-reactive.js /** * 观察对象中的属性变化,当属性变化时,执行相关函数 * @param {Object} obj */ function observe(obj) { for (const key in obj) { let internalValue = obj[key]; const funcs = [] Object.defineProperty(obj, key, { get() { if (window.__func && !funcs.includes(window.__func) ) funcs.push(window.__func) return internalValue; }, set(val) { internalValue = val; for (const func of funcs) func(); } }) } } function autoRun(fun) { window.__func = fun fun(); window.__func = null }
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>vue-demo</title> <link rel="stylesheet" href="./style.css" /> </head> <body> <!-- index.html --> <div class="box"> <p id="firstName"></p> <p id="lastName"></p> <p id="age"></p> </div> <form action=""> <input type="text" oninput="user.name = this.value" /> <input type="date" onchange="user.birth = this.value" /> </form> <script src="vue-reactive.js"></script> <script src="index.js"></script> </body> </html>