这是我参与8月更文挑战的第2天,活动详情查看:8月更文挑战
什么是响应式
在面试中,大家经常被问到的一个问题就是,你了解什么是响应式吗?响应式的原理是什么?
类似这种问题,其实我们应该考虑的是说,怎么去说出面试官想要了解的内容,而不是我们认为他想知道的内容。
我觉得对于这种问题,一个通用的回答思路是,
首先,对基本概念有个简单的描述。
然后,从使用层面、原理层面对它进行分析。
最后,对我们额外了解的内容加以补充。
我认为,按照这种模式去回答,一定可以让你获得面试官的青睐。
那么接下来,我会按照这个顺序去解释一下,「什么是响应式」这个问题应该怎么回答。
简单的描述
响应式是一种 Web 开发常用的特性,在很多 Web 框架中都有使用,包括但不限于 Vue2、Vue3、AngularJS、Knockout 等。响应式的本质是当修改数据后,会自动触发一系列的事件,也就是常说的 发布订阅 设计模式。
使用层面 & 原理层面
发布订阅
✏️ 使用层面
因为 响应式 的本质是 发布订阅 设计模式。它在不同 Web 框架中的实现不尽相同,那么我们先来介绍一下 发布订阅 模式的使用方式。
(() => {
subscribe('event1', (params) => {
console.log('触发 event1-1: ' + params)
})
subscribe('event1', (params) => {
console.log('触发 event1-2: ' + params)
})
publish('event1', '拌饭')
})()
// 触发 event1-1: 拌饭
// 触发 event1-2: 拌饭
我们希望可以通过
- subscribe 来订阅某个事件,并且传入一个回调函数(缓存该回调函数)
- publish 来发布某个事件,并且传入一个参数(执行该事件对应的所有回调函数)
🚀 原理层面
我们已经了解了如何去使用 发布订阅 设计模式,现在我们来看看如何去实现。
(() => {
const topics = new Map()
// 订阅
function subscribe (topic, callback) {
if (topics[topic]) {
topics[topic].push(callback)
} else {
topics[topic] = [callback]
}
}
// 发布
function publish (topic, params) {
(topics[topic] || []).forEach(fn => fn(params))
}
})()
显而易见,我们需要一个 topics 对象去把所有 事件以及对应的回调函数去保存,所以开始需要声明一个 topics 对象。
最终 topics 的数据结构形如:
const topics = {
event1: [
(params) => {/* do something */},
(params) => {/* do something */},
/* ... */
],
event2: [
/* ... */
]
}
接下来,我们构造函数: subscribe 用于 订阅事件,将事件及回调函数存入 topics 对象中。publish 用于 发布事件,将参数以此传入当前事件对应的所有回调函数中。
至此,我们就实现了一个 发布订阅 模式。
📚 总结
发布订阅 模式作为响应式的基础,在 Web 开发中随处可见。在下文中的具体实现也会用到 发布订阅 模式。
让我们以 Vue2 来举例说明一下:
Vue2 的响应式
在 Vue2 中,诸多特性都涉及到了响应式,比如 data、computed、watch、renderEffect(data、computed 更新导致页面引用到的部分也发生更新)。这里我们以 data、computed 为例来解释一下其中的响应式原理,其他 api 同理。
✏️ 使用层面
我们通过例子来看看 Vue2 中 data、computed 是如何使用的。
new Vue({
el: '#app',
template: '<div>{{computeCount}}</div>',
data: {
count: 0,
},
computed: {
computeCount() {
return '[computed] ' + this.count
}
},
mounted() {
setInterval(() => {
this.count += 1
}, 1000)
}
})
运行代码可以看到,每隔 1s 页面中会显示 '[computed] 0...' 。即当 this.count 更新时,computed 中的 computeCount 也自动发生了更新,进而触发 Vue 模板的重新渲染。
🚀 原理层面
这里我会讲清楚 Vue2 中 computed 的实现原理。但在这之前,我们先通过一个个问题逐步地接近这个答案。
日常开发中,在修改一个对象时,我们是如何获取到它的变更的呢?
比如我们想在 this.count 变更时,打印最新的 count 值,这个要怎么实现呢。
在 MDN 中记录了,Object.defineProperty [1] 可以检测到对象中某个属性的更新。
在这个例子中,只需要如下的代码,就可以获取到 this.count 更新后的值:
let value = data.count
Object.defineProperty(data, 'count', {
get() {
return value
},
set(newValue) {
if (value !== newValue) {
// 获取到 count 的最新值
console.log(newValue)
value = newValue
}
}
})
由于我们希望当 count 值变更的时候,去通知所有依赖 count 的 computed 函数重新执行。但我们怎么知道哪些 computed 函数依赖了 count 呢?
对于这个问题,我们先剖析一下,什么叫做 函数依赖了某个变量值。
我们知道,如果某个函数执行时使用了这个变量,那么我们就可以称为 这个函数依赖了这个变量。
那对于执行时使用了这个变量 的定义,是不是可以转化为 执行了这个变量的 getter 呢。
我们通过一段代码来理解一下:
(() => {
const data = { count: 0 }
Object.defineProperty(data, 'count', {
get() {
console.log('依赖了变量 count')
}
})
const fn = () => {
console.log('执行 fn')
data.count
console.log('fn 执行结束')
}
})()
执行上面的代码,可以发现,当 fn 执行时,会调用 data.count,最终触发 count 的 getter 函数。最终打印出了 依赖了变量 count。
到此,我们可以总结出,想知道哪些 computed 函数依赖了 count 这个变量,可以通过在 getter 中去做文章。代码如下:
+ let Listener = null
let value = data.count
+ let event = Symbol('')
Object.defineProperty(data, 'count', {
get() {
+ Listener && subscribe(event, Listener)
return value
},
set(newValue) {
if (value !== newValue) {
// 获取到 count 的最新值
console.log(newValue)
+ publish(event, null)
value = newValue
}
}
})
const computed = {
computeCount() {
return '[computed] ' + this.count
}
}
+ const fn = computed.computeCount
+ Listener = fn
+ fn()
+ Listener = null
我们来解释一下这段代码。
首先,创建了一个 Listener 用于存放当前的 computed 函数。
然后我们通过 Symbol 创建了一个唯一的事件名。
还记得上一段提到的 发布订阅 实现吗?我们使用了 subscribe 来订阅了这个 Symbol 事件,然后回调函数传的是当前的 Listener。大家可能会想,这时候 Listener 不是 null 吗?确实,此时的 Listener 不存在,但是这一行代码是写在 count 的 getter 中的,此时 getter 并没有执行,当真正执行的时候,Listener 就会有值了。
之后,我们在 setter 中添加了一行 publish,它会将 getter 时缓存的 Symbol 事件名的所有回调函数全部执行。
最后一段中,我们将 computeCount 这个 computed 函数命名为 fn,然后在执行 fn 前,将它赋给 Listener,这个操作的目的是,在真正执行 fn 的时候,会触发 this.count 的 getter,最终触发 subscribe 的逻辑,实现 count 变量 和 Listener 产生关联。当我们修改 count 值时,就可以借助 发布订阅 模式,通过 publish 通知 computeCount 函数执行。
到此,我们已经讲完了了 Vue2 中关于 computed 的实现。
(() => {
const topics = new Map();
// 订阅
function subscribe(topic, callback) {
if (topics.has(topic)) {
topics.set(topic, topics.get(topic).concat(callback));
} else {
topics.set(topic, [callback]);
}
}
// 发布
function publish(topic, params) {
(topics.get(topic) || []).forEach((fn) => fn(params));
}
// Vue2 computed
let Listener = null;
function proxy(state) {
let value = state.count;
let event = Symbol("");
let p = Object.defineProperty(state, "count", {
get() {
if (Listener) {
subscribe(event, Listener);
}
return value;
},
set(newValue) {
if (value !== newValue) {
publish(event, null);
value = newValue;
}
},
});
return p;
}
const component = {
data: { count: 0 },
computed: {
computeCount() {
const value = "[computed] " + this.count;
console.log(value);
return value;
},
},
};
const state = proxy(component.data);
const fn = component.computed.computeCount.bind(state);
Listener = fn;
fn();
Listener = null;
const moutedFn = function () {
setInterval(() => {
this.count += 1;
}, 1000);
};
moutedFn.call(state);
})();
📚 总结
在 Vue2 中,响应式的实现关键主要有:
- 通过 Object.defineProperty 劫持对象的 getter 和 setter
- 借助 发布订阅 模式将每个对象属性与监听事件联系在一起
Vue2 响应式实现的劣势在于:
- Object.defineProperty 无法对数组进行有效的监听(Vue2 通过劫持重写 push、unshift 等八个数组方法,但对于下标的修改不能获取到 setter,所以才会有 $set 这个 api 的出现)