什么是响应式数据?
用过Vue的同学一定对这个概念不陌生,熟悉的小伙伴可以直接看第二章。
对于传统前端开发来说,如果要实现这么一个需求:页面中显示一个数字,会根据每秒一次的请求结果实时更新。那代码可能是这样的:
javascript
setInterval((() => {
const numElement = document.querySelector("#numBox");
return () => {
axios.get('number/get').then((data) => {
numElement.innerHTML = data.number;
})
}
})(), 1000);
这么看起来也挺简便的,但这只是修改一个页面元素的内容,如果这一次要更新十个或者更多元素的内容呢?代码量就要蹭蹭往上涨,可维护性蹭蹭往下掉。
javascript
setInterval((() => {
const numElement = document.querySelector("#numBox");
const numElement1 = document.querySelector("#numBox1");
const numElement2 = document.querySelector("#numBox2");
const numElement3 = document.querySelector("#numBox3");
const numElement4 = document.querySelector("#numBox4");
const numElement5 = document.querySelector("#numBox5");
...
const numElement10 = document.querySelector("#numBox10");
return () => {
axios.get('number/get').then((data) => {
numElement.innerHTML = data.number;
numElement1.innerHTML = data.number1;
numElement2.innerHTML = data.number2;
numElement3.innerHTML = data.number3;
numElement4.innerHTML = data.number4;
numElement5.innerHTML = data.number5;
...
numElement10.innerHTML = data.number10;
})
}
})(), 1000);
如此下去,这个方法全在维护数据和DOM之间的关系去了,业务逻辑逐渐没淹没在其中。
但如果使用Vue这样的数据响应式框架:
html
<div>
<p>{{ numbers.number }}</p>
<p>{{ numbers.number1 }}</p>
<p>{{ numbers.number2 }}</p>
<p>{{ numbers.number3 }}</p>
...
<p>{{ numbers.number10 }}</p>
</div>
javascript
setInterval(() => {
axios.get('number/get').then((result) => {
this.data.numbers = result;
})
}, 1000);
效果立竿见影,代码量不会因为数据增多而增多。而之所以叫"响应式数据",就是在于只要数据更新,页面就会同步进行更新。这样就只用维护数据,不用再费尽心思维护数据和DOM之间的关系啦。
响应式数据对于前端开发人员来说,确实大大提高了开发效率,但是响应式是如何实现的呢?这也是前端面试的一个高频问题,接下来我就带你一步步搞懂响应式的实现逻辑。
如何实现响应式?
响应式做了什么事情?其实无外乎就是在数据变化的时候,执行对应的逻辑(更新页面)而已。
那怎么实现这个功能呢?别急,我们一步一步来。
构建基础页面
先来制作这样一个页面:页面中会显示state.num
的值,点击按钮后会对state.num
进行+1,并在页面上对数字进行更新展示。
html
<div id="numBox"></div>
<button id="btn">+1</button>
<script>
const state = { num: 1, numLength: 1 };
function refreshNum() {
document.querySelector("#numBox").innerHTML = state.num;
}
refreshNum();
document.querySelector("#btn").addEventListener("click", function () {
state.num += 1;
refreshNum();
});
</script>
代码很简单就能实现这个功能,但这并不是响应式的:我们在每次数字变化后,依然还要手动调用refreshNum方法对页面进行更新,我们要把这个改造成自动的。
加入Object.defineProperty
html
<div id="numBox"></div>
<button id="btn">+1</button>
<script>
const state = { num: 1, numLength: 1 };
function refreshNum() {
document.querySelector("#numBox").innerHTML = state.num;
}
refreshNum();
document.querySelector("#btn").addEventListener("click", function () {
state.num += 1;
// 去除了refreshNum方法调用
});
let value = state['num']
// 新增了Object.defineProperty方法
Object.defineProperty(state, 'num', {
get() {
return value;
},
set(newVal) {
value = newVal;
refreshNum()
},
});
</script>
看起来大体上的代码依然没变,只是把点击事件中对refreshNum
的调用删掉,并且新增了一个 Object.defineProperty()
。
运行代码,依然实现了同样的功能,页面数字随着按钮的点按而变化。
这乍一看,我们好像已经实现了响应式:数据只要一改变,页面就会自动更新。 确实,这里是Vue响应式原理中最核心部分之一:Object.defineProperty()
,完成了这一步,就打通了半条响应式的任督二脉。 那为什么新增了Object.defineProperty()
就不需要去手动调用refreshNum
方法了呢?
什么是Object.defineProperty
Object.defineProperty()
静态方法会直接在一个对象上定义一个新属性,或修改其现有属性,并返回此对象。
简单来说,Object.defineProperty()
可以给对象定义/修改新属性,比如:
javascript
const object1 = {
property1: 12
};
Object.defineProperty(object1, 'property2', {
value: 42
});
console.log(object1.property1, object1.property2); // 12, 42
上面的这个操作和"直接用大括号定义对象属性"是一样的效果。
但Object.defineProperty()
方法能做到大括号定义做不到的事:设置对象属性的getter
和setter
。
getter
熟悉面向对象编程的同学应该对getter
和setter
非常了解,它们俩的功能本身也非常简单:getter
就是"访问器",当一个值被访问的时候就是获取它的getter
方法的返回值,比如:
javascript
const object1 = {
property: 0
};
let value = object1.property
Object.defineProperty(object1, 'property', {
get() {
return value
}
});
console.log(object1.property); // 0
此时,读取object1.property
时会收到getter
中的返回结果0
。
可能有同学会说这不是脱裤子放屁吗?我要读取这个值根本不需要来写这个getter
也可以正常读取,绕这么大一圈是在玩呢?
其实getter
的重点并不是在于读取值本身 ,而是在于控制读取值的逻辑。
如果你要统计object1.property
被读取了多少次,这时候getter
就能派上用场了:
javascript
let count = 0
const object1 = {
property: 0
};
let value = object1.property;
Object.defineProperty(object1, 'property', {
get() {
count++;
return value;
}
});
console.log(object1.property, count); // 0, 1
console.log(object1.property, count); // 0, 2
console.log(object1.property, count); // 0, 3
这就是用以前直接定义的方式做不到的事情了,基于这个特性我们可以做更多有趣的事情,但这里我们暂时不延伸,我们先来说说setter
。
setter
理解了getter
再来看setter
就好理解多了,getter
负责读取值的逻辑
,setter自然就是负责设置值的逻辑
:每一次设置修改这个值都会通过setter
方法。
setter
方法接收一个参数,也就是新的值,我们可以这样使用它:
javascript
const object1 = {
property: 0
};
let value = object1.property
Object.defineProperty(object1, 'property', {
get() {
return value
},
set(newValue) {
value = newValue
}
});
object1.property = 1
console.log(object1.property); // 1
这段代码看起来依然是脱裤子放屁,但和前面说的getter
一样,setter
的重点在于控制修改值的逻辑 ,所以我们依然可以在setter
方法中做一些别的逻辑,比如统计修改次数:
javascript
let count = 0
const object1 = {
property: 0
};
let value = object1.property
Object.defineProperty(object1, 'property', {
get() {
return value
},
set(newValue) {
value = newValue
count++
}
});
object1.property = 1
console.log(object1.property, count); // 1, 1
object1.property = 10
console.log(object1.property, count); // 10, 2
object1.property = 20
console.log(object1.property, count); // 20, 3
object1.property = 30
console.log(object1.property, count); // 30, 4
到这里,我们已经知道getter
和setter
是怎么玩的了,我们再回过头看前面的"响应式代码":
html
<div id="numBox"></div>
<button id="btn">+1</button>
<script>
const state = { num: 1, numLength: 1 };
function refreshNum() {
document.querySelector("#numBox").innerHTML = state.num;
}
refreshNum();
document.querySelector("#btn").addEventListener("click", function () {
state.num += 1;
});
let value = state['num']
Object.defineProperty(state, 'num', {
get() {
return value;
},
set(newVal) {
value = newVal;
refreshNum() // 在setter中调用了refreshNum
},
});
</script>
反应快的同学肯定已经意识到我们前面的"响应式代码"是如何实现的了:每一次修改值都会调用setter
方法,那我们就在setter
中调用刷新页面数字的方法refreshNum
,以此实现"自动刷新页面"的效果。
恭喜🎉!读到这里的你已经基本了解了Vue2的响应式核心原理的核心知识点Object.defineProperty
!
但我们发现现在的代码有个问题:现在我们只对state.num
进行了响应式处理,但我们应该对state
中所有的属性(比如state.numLength
)全都进行响应式处理,我们该怎么做?
解决这个问题非常容易,直接上代码:
html
<div id="numBox"></div>
<button id="btn">+1</button>
<script>
const state = { num: 1, numLength: 1 };
function refreshNum() {
document.querySelector("#numBox").innerHTML = state.num;
}
refreshNum();
document.querySelector("#btn").addEventListener("click", function () {
state.num += 1;
});
function defineReactive(obj) {
for (const key in obj) {
let value = obj[key];
Object.defineProperty(obj, key, {
get() {
return value;
},
set(newVal) {
value = newVal;
refreshNum()
},
});
}
}
defineReactive(state);
</script>
处理的逻辑非常简单,就是将state
对象进行遍历,对其中的每一个属性都设置它的getter
和setter
,功能确实也可以正常运行。
但新问题又出现了:setter
中调用的refreshNum
方法是针对num
属性值修改后调用的,而现在给其他属性设置的setter
调用的依然也是refreshNum
这个方法。
这可不就出问题了吗:现在就算我们只是修改state.numLength
属性值,也会调用refreshNum
方法对页面进行更新。
所以,我们需要对每一个属性都绑定它自己的setter
逻辑,这该怎么做呢?
比如我们希望,每一次numLength
变化时都只执行属于它的checkLength
方法,如果值大于1就弹出警告框:
javascript
function checkLength() {
if (state.numLength > 1) alert(`it's too long!!`)
}
这还不简单,让函数执行自定义函数,这不就是回调函数的套路嘛。我们熟悉的setTimeout
方法,传入一个回调函数和等待时间,就可以在等待时间后由setTimeout
来对我们传入的回调函数进行调用,这个我们都知道:
javascript
// 普通回调函数示例
function callback(params) {
console.log('我是回调函数,我被调用啦');
}
setTimeout(callback, 1000);
这refreshNum
和checkLength
不也就是个回调函数,我们想办法把回调函数传入对应的setter
方法进行调用不就好啦?
但在setter
中调用回调没有那么简单,毕竟setter
也不能传入自定义参数,这个回调该怎么传进去呢?
带着这个疑问,我们先来看看我们要执行的这两个方法:
javascript
// 需要在state.num的setter中执行的方法
function refreshNum() {
document.querySelector("#numBox").innerHTML = state.num;
}
// 需要在state.numLength的setter中执行的方法
function checkLength() {
if (state.numLength > 1) alert(`it's too long!!`)
}
看一看里面的规律:
- 我们之所以要在
state.num
变化后去调用refreshNum
方法,是因为refreshNum
方法中读取了state.num
; - 而之所以要在
state.numLength
变化后去调用checkLength
方法,是因为checkLength
方法中读取了state.numLength
!
总结来说,就是方法Func
读取了数据data
,所以Func
的功能是依赖于data
的,需要知道data
实时的动态,在data
变化后需要第一时间通知(调用)Func
。
也就是说,setter
中的回调函数不需要我们手动传入,我们只用通过观察每个回调方法内部的代码,只要记录下方法内部要读取哪些数据,让这些被读取数据的setter
调用这个回调方法就可以了。
道理我都懂了,但这该怎么做呢?怎么才能知道方法里面读取了哪些数据呢?
还记得我们前面讲过的getter
吗?在每个数据被读取的时候,都会执行它的getter
方法,对吧?那我们直接先把回调方法运行一次,在getter中守株待兔,不就知道哪个数据被调用了。
这下思路打开了,我们来试一试:
javascript
const state = { num: 1, numLength: 1 };
function defineReactive(obj) {
for (const key in obj) {
let value = obj[key];
Object.defineProperty(obj, key, {
get() {
console.log(`我是${key},我被调用啦`); // 新增输出
return value;
},
set(newVal) {
value = newVal;
},
});
}
}
defineReactive(state);
function refreshNum() {
console.log(`refreshNum方法执行,下面被调用的是我依赖的数据`); // 新增输出
document.querySelector("#numBox").innerHTML = state.num;
}
refreshNum();
function checkLength() {
console.log(`checkLength方法执行,下面被调用的是我依赖的数据`); // 新增输出
if (state.numLength > 1) alert(`it's too long!!`)
}
checkLength();
我们在两个方法中新增的console.log
和getter
中的console.log
按时间顺序输出结果为:
很好,这一看就非常明了了:refreshNum
方法读取的数据是state.num
;checkLength
方法读取的数据是numLength
。我们接下来就只要把方法放到对应属性的setter
中被调用就好了。
道理我都懂了,但这又该怎么做呢?现在getter
虽然知道自己被读取了,但还不知道是谁在读取自己。我们先来解决这个问题。
解决这个问题就用一些简单粗暴的办法吧,逻辑很简单:我们在调用方法前,先把这个方法存到全局变量中,在getter
中获取这个全局变量的值,这个值不就是正在读取属性的方法嘛!这不就完事了,思路明确了直接上代码:
javascript
const state = { num: 1, numLength: 1 };
let active; // 用来存储正在执行方法的全局变量
function defineReactive(obj) {
for (const key in obj) {
let value = obj[key];
Object.defineProperty(obj, key, {
get() {
console.log(`我是${key},我被${active}调用啦`);
return value;
},
set(newVal) {
value = newVal;
},
});
}
}
defineReactive(state);
function refreshNum() {
console.log(`refreshNum方法执行,下面被调用的是我依赖的数据`);
document.querySelector("#numBox").innerHTML = state.num;
}
active = refreshNum; // 调用方法前先给全局变量action赋值
refreshNum();
active = null;
function checkLength() {
console.log(`checkLength方法执行,下面被调用的是我依赖的数据`);
if (state.numLength > 1) alert(`it's too long!!`)
}
active = checkLength; // 调用方法前先给全局变量action赋值
checkLength();
active = null;
运行结果是这样的:
非常符合我们的预期,下一步就也很清晰了:getter
中已经知道是谁在读取自己,我们把这个值存储起来,下次setter
被调用的时候直接拿来用就好了。如果有同学问,为什么不直接在setter
里面调用active
,你好好想想一定能想明白的🤓。
这...道理我都懂,但这个值存在哪里呢?我们先把注意力集中在这里:
javascript
for (const key in obj) {
let value = obj[key];
Object.defineProperty(obj, key, {
get() {
console.log(`我是${key},我被${active}调用啦`);
return value;
},
set(newVal) {
value = newVal;
},
});
}
这个地方就先不展开讲了,直接说结论:这个for...in
的每一次循环都对应一个对象属性,我们需要记录每一个对象属性依赖方法。
所以我们可以利用闭包 的特性,将依赖方法记录在循环的体产生块级作用域
中,这样setter
也可以获取到当前属性需要调用的方法。
就像这样:
javascript
for (const key in obj) {
let value = obj[key];
let dep = []; // 新增dep数组,用于记录依赖方法
Object.defineProperty(obj, key, {
get() {
dep.push(active); // 将调用getter时全局变量active中存储的方法记录到dep数组中
console.log(`我是${key},我被${dep[0]}调用啦`);
return value;
},
set(newVal) {
value = newVal;
dep[0](); // 值改变时调用dep中存储的方法
},
});
}
现在这段代码做了这么几件事:
- 我们在循环体内定义一个用
let
声明的变量dep
,这样每一次循环都会产生一个块级上下文,也就是每个对象属性都会对应一个dep
变量; - 在
getter
被调用时,我们将全局变量action
的值赋值给局部变量dep
,action
的值也就是此时正在读取当前属性的方法; setter
被调用时,setter
调用dep
方法,此时dep
的内容就是第2步中存储的回调方法,此时回调函数被正确调用。
看起来我们已经完成了这个逻辑!我们来把代码补全试试看:
javascript
const state = { num: 1, numLength: 1 };
let active; // 存储正在执行方法的全局变量
function defineReactive(obj) {
for (const key in obj) {
let value = obj[key];
let dep = [];
Object.defineProperty(obj, key, {
get() {
dep.push(active);
console.log(`我是${key},我被${dep[0]}调用啦`);
return value;
},
set(newVal) {
value = newVal;
dep[0]();
},
});
}
}
defineReactive(state);
function refreshNum() {
console.log(`refreshNum方法执行,下面被调用的是我依赖的数据`);
document.querySelector("#numBox").innerHTML = state.num;
}
active = refreshNum; // 调用方法前先给全局变量action赋值
refreshNum();
active = null;
function checkLength() {
console.log(`checkLength方法执行,下面被调用的是我依赖的数据`);
if (state.numLength > 1) alert(`it's too long!!`)
}
active = checkLength; // 调用方法前先给全局变量action赋值
checkLength();
active = null;
document.querySelector("#btn").addEventListener("click", function () {
state.num += 1;
});
此时代码正常执行,我们已经快要完成最核心的响应式逻辑!
但是现在代码看起来有些怪怪的:
javascript
active = refreshNum; // 调用方法前先给全局变量action赋值
refreshNum();
active = null;
active = checkLength; // 调用方法前先给全局变量action赋值
checkLength();
active = null;
每个方法调用前后还要做存储全局变量的操作,这太繁琐了,我们先把这个逻辑优化一下。
我们新建一个方法watcher,我们将要依赖于响应式数据的方法都传给watcher方法来管理,使用起来就会方便很多:
javascript
const state = { num: 1, numLength: 1 };
let active; // 存储正在执行方法的全局变量
function defineReactive(obj) {
for (const key in obj) {
let value = obj[key];
let dep = [];
Object.defineProperty(obj, key, {
get() {
dep.push(active);
console.log(`我是${key},我被${dep[0]}调用啦`);
return value;
},
set(newVal) {
value = newVal;
dep[0]();
},
});
}
}
defineReactive(state);
// 新建watcher方法,用来管理需要依赖响应式数据的方法
function watcher(func) {
active = func; // 调用方法前先给全局变量action赋值
func();
active = null;
}
function refreshNum() {
console.log(`refreshNum方法执行,下面被调用的是我依赖的数据`);
document.querySelector("#numBox").innerHTML = state.num;
}
function checkLength() {
console.log(`checkLength方法执行,下面被调用的是我依赖的数据`);
if (state.numLength > 1) alert(`it's too long!!`)
}
watcher(refreshNum)
watcher(checkLength)
document.querySelector("#btn").addEventListener("click", function () {
state.num += 1;
});
还差一小步了!
不知道你有没有注意到,现在numLength
变量还没有被修改过,没有任何逻辑会修改到numLength
,所以依赖于numLength
的checkLength
方法除了初始化之外从来没有被执行过。
这是因为我们还缺失了一个逻辑:当state.num
发生变化时,调用后续新建的calculateLength
方法对state.num
进行字符长度计算。比如:1的字符长度1、10的字符长度2、100的字符长度3;并且把计算结果赋值给state.numLength
;而state.numLength
发生变化时,就调用checkLength
方法进行长度检查,长度大于1就会进行弹窗提示。
所以要做到这一点,我们要先把calculateLength
方法加入进来,并完成初次调用。
需要注意的是,因为refreshNum
和calculateLength
两个方法都是依赖于state.num
的,所以state.num
属性的dep
中会有两个回调函数,我们需要对setter
中调用回调函数的逻辑做一点点优化:
javascript
const state = { num: 1, numLength: 1 };
let active;
function defineReactive(obj) {
for (const key in obj) {
let value = obj[key];
let dep = [];
Object.defineProperty(obj, key, {
get() {
active && dep.push(active); // active为空时不存储
console.log(`我是${key},我被${dep}调用啦`);
return value;
},
set(newVal) {
value = newVal;
dep.forEach((watcher) => watcher()); // 遍历dep列表,依次执行
},
});
}
}
defineReactive(state);
function watcher(func) {
active = func;
func();
active = null;
}
function refreshNum() {
console.log(`refreshNum方法执行,下面被调用的是我依赖的数据`);
document.querySelector("#numBox").innerHTML = state.num;
}
// 新建的calculateLength方法对state.num进行字符长度计算,并赋值给state.numLength
function calculateLength() {
state.numLength = String(state.num).length;
}
function checkLength() {
console.log(`checkLength方法执行,下面被调用的是我依赖的数据`);
if (state.numLength > 1) alert(`it's too long!!`)
}
watcher(refreshNum)
watcher(calculateLength) // 初始化calculateLength方法
watcher(checkLength)
来看看效果:
我们已经完全实现了想要的效果!我们刚刚完成了非常重要的响应式核心:依赖收集!
依赖收集
上述解决的问题是Vue响应式学习中的一个难点,也就是"依赖收集"。
顾名思义,我们需要对每一个响应式数据的"依赖"进行"收集",这个"依赖"可以理解为数据更新后需要执行的"回调函数集合","收集"也就是我们需要对这些"回调"进行记录,以便在后续数据变化时进行相应的调用。 在Vue中,Vue会对你的"模版代码"先进行解析:
html
<div>
<p>{{ numbers.number }}</p>
<p>{{ numbers.number1 }}</p>
<p>{{ numbers.number2 }}</p>
<p>{{ numbers.number3 }}</p>
...
<p>{{ numbers.number10 }}</p>
</div>
Vue会通过watcher
调用一个叫做updateComponent
的方法,这个方法会把模版代码中所有的对象属性替换为对应的对象属性值,并最终把替换后的结果重新渲染到页面上。这个过程自然就会进入到这些对象属性的getter
方法并完成依赖收集。
而后续一旦这些值发生变化,就会在setter
中触发调用updateComponent
方法,再次完成页面渲染。 虽然实际代码更加复杂,但你已经理解核心的基础响应式逻辑啦!
后记
当然我们完成的是一个最基础版的响应式数据逻辑,Vue中的实际代码比这个要多很多细节和功能,比如watcher
在实际源码中也不仅仅是个方法,而是和dep
一样是个类,它们内部都有非常复杂的逻辑和实现。
但源码依然是以这个DEMO的核心逻辑作为核心的,理解了前面的内容再去阅读源码会轻松很多。
要值得注意的是,本篇文章只是讲了普通对象 如何实现响应式,但Vue2中对数组进行了特殊处理,处理方式和对象有很大的不同。涉及到很多原型链的知识,就不在这里展开啦,想要看关于数组的响应式实现的同学可以多多留言!