一、前言:什么是响应式?
响应式的核心思想很简单:当数据发生变化时,所有依赖于这个数据的函数(例如视图渲染函数、计算函数等)会自动重新执行 ,从而保持数据与依赖它的事物同步。在Vue2中,这是通过Object.defineProperty实现的。今天,我们就顺着这个思路,用原生JavaScript手写一个简易的响应式Demo。

二、基础版本:手动触发更新
我们先实现一个最基础的版本。点击按钮,让obj.age增加,并同步更新页面。
html
<div id="age">18</div>
<button onclick="changeObj()">年龄++</button>
js
// 1. 定义数据对象
const obj = {
age: 18
};
// 2. 获取DOM元素
const ageEl = document.querySelector('#age');
// 3. 定义更新DOM的函数
const updateDom = function() {
ageEl.textContent = obj.age.toString();
console.log('页面已更新: ', ageEl.textContent);
}
// 4. 定义改变数据的函数
const changeObj = () => {
obj.age++; // 修改数据
updateDom(); // 手动更新DOM
}
效果: 点击按钮,年龄增加,页面显示同步更新。

这确实复现了"响应式"的效果,但存在一个明显问题:我们需要手动调用updateDom 。如果依赖obj.age的函数很多,比如有dependFn1, dependFn2, dependFn3,那么changeObj函数会变得非常冗长和难以维护。
js
const updateDom = function() {
age.textContent = obj.age.toString()
console.log(age.textContent);
}
const dependFn1 = ()=>{
console.log(obj.age);
}
const dependFn2 = ()=>{
console.log(obj.age);
}
const dependFn3 = ()=>{
console.log(obj.age);
}
const changeObj = ()=>{
console.log(obj.age);
obj.age++
updateDom()//更新dom
dependFn1()
dependFn2()
dependFn3()
}

三、优化一:统一管理依赖函数
为了解决上述问题,我们可以把所有依赖函数收集到一个数组中,当数据变化时,统一遍历执行。
js
//创建依赖函数数组
let fnArrays = []
//创建触发依赖函数的函数
const dispatchFns = ()=>{
fnArrays.forEach(fn => fn())
}
//收集依赖函数的函数
const gatherFns = (fn)=>{
fnArrays.push(fn)
}
gatherFns(dependFn1)
gatherFns(dependFn2)
gatherFns(dependFn3)
const changeObj = ()=>{
console.log(obj.age);
obj.age++
updateDom()//更新dom
dispatchFns()
}
这样,我们只需要调用一次dispatchFns(),所有依赖函数都会执行。但这种方式依然不够灵活,如果多个数据对象拥有各自的依赖函数,管理起来会非常混乱。

四、优化二:引入Depend类
更好的做法是为每个响应式数据创建一个独立的依赖管理器 。我们定义一个Depend类来专门负责收集和触发依赖函数。
js
//这时候得创建一个Depend(依赖)类
class Depend {
constructor(){
this.dependFns = new Set()
}
//收集依赖函数
gatherFns(fn){
if(fn && typeof fn === 'function'){
this.dependFns.add(fn)
}
}
//统一调用依赖函数
dispatchFns(){
this.dependFns.forEach(fn => {
fn()
})
}
}
const depend1 = new Depend()
depend1.gatherFns(dependFn1)
depend1.gatherFns(dependFn2)
depend1.gatherFns(dependFn3)
const changeObj = ()=>{
console.log(obj.age);
obj.age++
updateDom()//更新dom
depend1.dispatchFns()
}

现在,每个响应式数据都有自己的Depend实例,依赖管理更加清晰。但我们仍然需要在修改数据后手动调用dispatchFns()。
五、核心实现:自动依赖收集和触发
要实现真正的自动响应式,我们需要在获取数据时自动收集依赖 ,在修改数据时自动触发更新 。这可以通过Object.defineProperty拦截数据的读取和修改操作来实现。
js
//创建一个myReactive的函数,接收一个对象,进行对它的属性劫持
function myReactive(obj){
Object.keys(obj).forEach(key => {
const instance = new Depend() //创建一个实例对象
let value = obj[key] //获取对应属性的值,保留等下要用
//加上属性劫持
Object.defineProperty(obj,key,{
get : function(){
return value
},
set : function(newValue){
value = newValue
//通知对应的实例执行所有依赖函数
instance.dispatchFns()
}
})
})
}

现在是解决了对象属性改变时,可以劫持使用set方法进行调用依赖函数,但是收集函数就成了一个难题,因为实例是在内部创建的,形成了闭包,外界拿不到内部的实例,所以只有一个方法了,就是设置一个全局活跃函数,当调用依赖函数时,会触发获取对象属性,触发get方法,就可以在get方法调用内部的instance可以获取外部的全局活跃函数,将其加入实例中的Set数组中!
关键思路:设置"全局活跃函数"
我们需要一个方法来标记当前正在执行的函数,这样在读取数据时,就能知道是哪个函数依赖了这个数据。
js
//设置活跃函数
let activeFn = null
//加入依赖的函数
const addDependFn = (fn)=>{
activeFn = fn
fn()
activeFn = null
}
addDependFn(dependFn1)
addDependFn(dependFn2)
addDependFn(dependFn3)
//创建一个myReactive的函数,接收一个对象,进行对它的属性劫持
function myReactive(obj){
Object.keys(obj).forEach(key => {
const instance = new Depend() //创建一个实例对象
let value = obj[key] //获取对应属性的值,保留等下要用
//加上属性劫持
Object.defineProperty(obj,key,{
get : function(){
instance.gatherFns(activeFn)
return value
},
set : function(newValue){
value = newValue
//通知对应的实例执行所有依赖函数
instance.dispatchFns()
}
})
})
}

其实到这里就已经实现了简单的响应式啦!哈哈哈, 完整demo代码如下
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>手撕类Vue2响应式demo</title>
</head>
<body>
<div id="age">18</div>
<button onclick="changeObj()">年龄++</button>
<script>
// obj对象
const obj = {
age : 18
}
//使用原生拿到元素
const age = document.querySelector('#age')
const updateDom = function() {
age.textContent = obj.age.toString()
console.log(age.textContent);
}
//模拟依赖函数1
const dependFn1 = ()=>{
console.log(obj.age);
}
//模拟依赖函数2
const dependFn2 = ()=>{
console.log(obj.age);
}
//模拟依赖函数3
const dependFn3 = ()=>{
console.log(obj.age);
}
//这时候得创建一个类
class Depend {
constructor(){
this.dependFns = new Set()
}
//收集依赖函数
gatherFns(fn){
if(fn && typeof fn === 'function'){
this.dependFns.add(fn)
}
}
//统一调用依赖函数
dispatchFns(){
this.dependFns.forEach(fn => {
fn()
})
}
}
const changeObj = ()=>{
console.log(obj.age);
obj.age++
}
//设置活跃函数
let activeFn = null
//加入依赖的函数
const addDependFn = (fn)=>{
activeFn = fn
fn()
activeFn = null
}
//手动执行一遍函数
addDependFn(dependFn1)
addDependFn(dependFn2)
addDependFn(dependFn3)
//创建一个myReactive的函数,接收一个对象,进行对它的属性劫持
function myReactive(obj){
Object.keys(obj).forEach(key => {
const instance = new Depend() //创建一个实例对象
let value = obj[key] //获取对应属性的值,保留等下要用
//加上属性劫持
Object.defineProperty(obj,key,{
get : function(){
instance.gatherFns(activeFn)
return value
},
set : function(newValue){
value = newValue
//通知对应的实例执行所有依赖函数
instance.dispatchFns()
}
})
})
}
myReactive(obj) //传入对象,添加劫持
addDependFn(updateDom) //添加活跃函数
</script>
</body>
</html>
六、最终优化
但是到现在,其实它还有很多可以优化的地方,比如可能一个对象多次传入myReactive函数,会创建多个实例,还有实例是在myReactive函数里面创建的,属于闭包,可能出现内存泄漏,所以我们可以采用WeakMap集合来收集对象,这样子的话,就可以在myReactive函数开始那里判断一下对象是否创建过。然后每一个对象对应着一个(属性-实例)Map表,这样子的话,就可以方便的拿到实例了,就不会有强引用来泄漏内存了。
这里是优化,就不赘述了,直接上代码
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>手撕类Vue2响应式demo</title>
</head>
<body>
<div id="age">18</div>
<button onclick="changeObj()">年龄++</button>
<script>
// obj对象
const obj = {
age : 18
}
//使用原生拿到元素
const age = document.querySelector('#age')
const updateDom = function() {
age.textContent = obj.age.toString()
console.log(age.textContent);
}
//模拟依赖函数1
const dependFn1 = ()=>{
console.log(obj.age);
}
//模拟依赖函数2
const dependFn2 = ()=>{
console.log(obj.age);
}
//模拟依赖函数3
const dependFn3 = ()=>{
console.log(obj.age);
}
//这时候得创建一个类
class Depend {
constructor(){
this.dependFns = new Set()
}
//收集依赖函数
gatherFns(fn){
if(fn && typeof fn === 'function'){
this.dependFns.add(fn)
}
}
//统一调用依赖函数
dispatchFns(){
this.dependFns.forEach(fn => {
fn()
})
}
}
const changeObj = ()=>{
console.log(obj.age);
obj.age++
}
//设置活跃函数
let activeFn = null
//加入依赖的函数
const addDependFn = (fn)=>{
activeFn = fn
fn()
activeFn = null
}
//手动执行一遍函数
addDependFn(dependFn1)
addDependFn(dependFn2)
addDependFn(dependFn3)
const targetMap = new WeakMap()//创建一个集合,将响应式对象放进去
//查找依赖实例的函数
const getDepend = (obj,key)=>{
let target = targetMap.get(obj)
if(!target){
//创建
const propertyDependMap = new Map() //创建一张属性和对应依赖实例的对照表
targetMap.set(obj,propertyDependMap) //将对象和对应的属性和对应依赖实例的对照表关联,可以通过对象找到对照表
target = targetMap.get(obj)
}
let depend = target.get(key)
if(!depend){
//创建
const instance = new Depend()
target.set(key,instance) //将映射关系写好
depend = instance
}
return depend
}
//创建一个myReactive的函数,接收一个对象,进行对它的属性劫持
function myReactive(obj){
Object.keys(obj).forEach(key => {
let value = obj[key] //获取对应属性的值,保留等下要用
//加上属性劫持
Object.defineProperty(obj,key,{
get : function(){
getDepend(obj,key).gatherFns(activeFn)
return value
},
set : function(newValue){
value = newValue
//通知对应的实例执行所有依赖函数
getDepend(obj,key).dispatchFns()
}
})
})
}
myReactive(obj)
addDependFn(updateDom)
</script>
</body>
</html>

七、总结
响应式其实就是3个重要的点
- 依赖收集 :通过
Object.defineProperty的getter拦截属性访问,收集当前正在执行的函数作为依赖 - 依赖触发:通过setter拦截属性修改,自动通知所有依赖函数更新
- 依赖管理 :使用
Depend类管理每个属性的依赖关系,使用WeakMap和Map建立对象-属性-依赖的映射关系
这个简易实现包含了Vue2响应式系统的核心思想,但是vue封装的更加复杂!这个只是我的个人总结,如果能帮到你的话,就更好啦!如果觉得写的好的话,可以给个一键三连哈哈哈哈哈哈哈哈