响应式基础
vue开篇提到了怎么在vue的选项式写法中声明组件状态,就是在对象中写一个data属性,这个属性要是一个函数,这个函数要返回一个对象,返回的对象会被vue在合适的时候调用赋予它响应的能力,然后vue会把这个对象上的属性都放到组件自身上, 我们再讨论接下来的问题之前c,先展示vue2以及vue3是怎么大致实现响应式的, 帮助理解
vue2响应式
vue2实现响应式的思路就是给对象加setter和getter,把这些属性全部挂载到组件实例对象上, 然后给每个属性添加上setter更新值的时候要触发的响应函数就可以实现响应式了,具体看下面这个js例子
javascript
class Dep {
constructor() {
this.bukets = [];
}
addDep(fn) {
this.bukets.push(fn);
}
notify() {
this.bukets.forEach((fn) => {
fn.update();
});
}
}
//观察者
class Watcher {
constructor(obj, name, updateCb) {
this.updateCb = updateCb;
this.init(obj, name);
}
init(obj, name) {
//把注册函数送出去,注册好响应式
Dep.target = this;
obj[name]; // 触发Dep响应,添加进这个watcher者
this.update();
Dep.target = null;
}
update() {
this.updateCb();
}
}
//定义给对象响应式属性
const defineReactive = (obj, key, val) => {
//为这个属性实例化一个观察者
const dep = new Dep();
Object.defineProperty(obj, key, {
get() {
//当触发key时,说明要使用这个依赖
if (Dep.target) {
dep.addDep(Dep.target);
}
return val;
},
set(newVal) {
val = newVal;
//通知
dep.notify();
}
});
};
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
#app {
display: inline-flex;
column-gap: 10px;
padding: 10px 12px;
border-radius: 8px;
margin: 100px 200px;
background-color: #f5f5f5;
cursor: pointer;
user-select: none;
}
#app span {
display: flex;
justify-content: center;
align-items: center;
width: 20px;
height: 30px;
background-color: #ececec;
}
</style>
</head>
<body>
<div id="app">
<span data-action="sub">-</span>
<span class="count"></span>
<span data-action="add">+</span>
</div>
<script src="./index.js"></script>
<script>
let obj = {};
defineReactive(obj, "count", 0);
const countEle = document.querySelector(".count");
new Watcher(obj, "count", () => {
countEle.innerText = obj.count;
});
document.querySelector("[data-action='sub']").addEventListener("click", () => {
obj.count--;
});
document.querySelector("[data-action='add']").addEventListener("click", () => {
obj.count++;
});
</script>
</body>
</html>
关注我们重点的最开头的四个函数,这就是vue2大致实现响应式的样子,我们可以看到,我们实际上是给data指定的数据使用Object.defineProperty定义了get和set函数, , 然后在初始的时候在get函数里添加上watcher,,在这个属性触发set的时候,我们通知这些watcher使用最新的值进行更新,这就是大致流程, 然后我们再来看看vue3对于响应式是怎么实现的
vue3响应式
javascript
let activeFn;
const effect = (fn) => {
activeFn = fn;
fn();
activeFn = null;
};
const buckets = new WeakMap();
const trigger = (target, property) => {
const depsMap = buckets.get(target);
if (!depsMap) {
return ;
}
const fns = depsMap.get(property);
console.log(fns, "fns");
fns && fns.forEach(fn => fn());
};
const track = (target, property) => {
let depsMap = buckets.get(target);
if (!depsMap) {
buckets.set(target, (depsMap = new Map()));
}
let deps = depsMap.get(property);
if (!deps) {
depsMap.set(property, (deps = new Set()));
}
deps.add(activeFn);
};
const reactive = (data) => {
return new Proxy(data, {
set(target, property, newVal, receiver) {
trigger(target, property);
return Reflect.set(target, property, newVal, receiver);
},
get(target, property, receiver) {
if (activeFn) {
console.log(target,property, "target-property");
track(target, property);
}
console.log("触发set");
return Reflect.get(target, property, receiver);
}
});
};
html
<style>
#app {
display: inline-flex;
column-gap: 10px;
padding: 10px 12px;
border-radius: 8px;
margin: 100px 200px;
background-color: #f5f5f5;
cursor: pointer;
user-select: none;
}
#app span {
display: flex;
justify-content: center;
align-items: center;
width: 20px;
height: 30px;
background-color: #ececec;
}
</style>
</head>
<body>
<div id="app">
<span data-action="sub">-</span>
<span class="count"></span>
<span data-action="add">+</span>
</div>
<script src="./index2.js"></script>
<script>
let obj = {count: 0};
obj = reactive(obj);
const countEle = document.querySelector(".count");
effect(() => {
countEle.innerText = obj.count;
});
document.querySelector("[data-action='sub']").addEventListener("click", () => {
obj.count--;
});
document.querySelector("[data-action='add']").addEventListener("click", () => {
obj.count++;
});
</script>
</body>
我们可以看到,我们基于Proxy实现的响应式系统是现有一个obj对象, 然后我们定义了一个代理对象,我们后续都是操作这个代理对象去实现响应式更新
总结
基于上述描述,我们可以知道,vue2的响应式的确是在原始对象上定义了一个新的属性然后设置get和set,我们在这个对象属性上触发了set的时候,也会触发响应函数更新, 在vue3的时候,是现有原始的对象,我们给这个对象设置了一个代理对象,后续的响应式都是通过触发代理对象的set和get实现的,在代理对象上触发了set的时候,会触发响应函数更新, 完全与原始对象解耦了。同时也可以注意到,我们在vue2的实现中,并没有return 一个函数或者是包含函数的对象,但是我们的属性val,却因为defineProperty的实现而被留存了下来,通过这种形式也实现了一个闭包,所以我们可以说,没有return一个使用了内部变量的函数就不是闭包的说法是错误的,只要实现了将内部变量外泄到外部代码,并且外部代码只能受控的间接访问这个内部变量的这么个现象,我们就可以认为是一个闭包,return一个使用了内部变量的函数只是实现的一个具体方法。
回到Vue文档
查看下面一个vue文档给出的例子
javascript
export default {
data() {
return {
someObject: {}
}
},
mounted() {
const newObject = {}
this.someObject = newObject
console.log(newObject === this.someObject) // false
}
}
当你在复制后再访问this.someObject, 这个时候因为触发了this的set函数,属性是someObject, 所以在vue3中会创建一个新的响应式对象,然后复制给this.someObject,这个对象是代理后的对象,它的原始对象是newObject, 而对于vue2,它会接受这个对象,然后在这个对象上设置getter和setter,把这个对象转换成响应式 由于转换是在同一个对象上进行的 ,所以文档说当你在赋值后再访问this.someObject, 此值已经是原来的newOject的一个响应式代理,与vue2 不同的是,这里的原始的newObject不会变为响应式,请确保始终通过this来访问响应式状态
声明方法
先看下面一个例子
javascript
export default {
data() {
return {
count: 0
}
},
methods: {
increment() {
this.count++
}
},
mounted() {
// 在其他方法或是生命周期中也可以调用方法
this.increment()
}
}
vue文档在这里说不应该使用箭头函数,因为箭头函数的this值是跟着作用域走了,而在对象中使用 ...() {}, 的形式相当于function () {} ,其中的this是由调用方觉定的,所以这里的methods中的方法使用箭头函数后如果是顶层的箭头函数的this就是window,不会改变
响应式状态新增属性
当我们在vue2的响应式状态上新增一个属性的时候,vue2没有办法检测到变化,查看下面一个例子
javascript
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>数组列表渲染重点</title>
</head>
<body>
<div id="app">
{{obj.nested.count}}
{{JSON.stringify(obj.nested)}}
<button @click="mutateDeeply">增加</button>
</div>
<script src="https://cdn.bootcdn.net/ajax/libs/vue/2.6.11/vue.js"></script>
<script>
const app = new Vue({
el: "#app",
data() {
return {
obj: {
nested: { count: 0 },
}
}
},
methods: {
mutateDeeply() {
// 以下都会按照期望工作
this.obj.nested.count++
}
}
})
</script>
</body>
</html>
如果我们在控制台输入app.obj.nested.count2 = 2;可以发现,这个时候我们的页面并没有发生变化,如果我们换成vue3的写法,会怎么样,请查看下面一个例子
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>数组列表渲染重点</title>
</head>
<body>
<div id="app">
{{obj.nested.count}}
{{JSON.stringify(obj.nested)}}
<button @click="mutateDeeply">增加</button>
</div>
<script src="https://cdn.bootcdn.net/ajax/libs/vue/3.5.22/vue.global.min.js"></script>
<script>
const app = Vue.createApp({
data() {
return {
obj: {
nested: { count: 0 },
}
}
},
methods: {
mutateDeeply() {
// 以下都会按照期望工作
this.obj.nested.count++
}
}
}).mount("#app");
</script>
</body>
</html>
如果我们在上面的这个例子控制台中实时的添加app.obj.nested.count2 = 2;可以看到,页面发生了变化! 这是为什么呢,其实,vue2的响应是基于definePRoperty,这就意味着vue2在实现响应式的时候在统一注册响应式的阶段在对象的属性上定义setter/getter,这个时候新增一个属性,压根就没有给这个对象赋予一个setter/getter,所以也就不会触发setter/getter了,如果是在vue3中,我们使用代理对象,响应是基于整个对象的,如果你新增了一个属性,这个时候就会触发整个对象的getter/setter,然后更新整个页面,所以最后的区别也还是因为vue2的响应式是基于对象属性的,而vue3的响应式是基于整个对象的,这是我们在响应式系统上讨论的vue3和vue2的第二个区别