Q: 我们常说 React, Vue 等实现以"响应式"的逻辑进行前端开发, 请讲一下你对"响应式"的理解
分析
指令式
首先我们需要明确, 单纯的 JS 代码本身是一行一行执行的指令. 在没有封装逻辑的前提下, JS 代码对 X 的操作就局限于 X, 并不会影响到 Y 或 Z
js
let X = 1
let Y = 2
let Z = X + Y
console.log(Z) // 3
X = 2
console.log(Z) // 依然是 3, 因为我们并没有发出对 Z 进行操作的指令
操作 DOM 的原生 API 也是这样"指令式"风格
html
<html>
<button>+1</button>
<output>1</output>
</html>
<script>
// 获取 <button> 元素
const btn = document.querySelector("button");
// 获取 <output> 元素
const output = document.querySelector("output");
function addCount() {
output.innerText++;
}
// 当点击 <button> 元素时, 让 <output> 元素内部的数字 +1
btn.addEventListener("click", addCount)
</script>
响应式
我们再回到上文中 X, Y 和 Z 的代码:
js
let X = 1
let Y = 2
let Z = X + Y
虽然 X, Y 和 Z 的类型都是数字(number
), 但 Z 可以被视作 X 和 Y 经过加和逻辑处理后的结果, 也可以说加和逻辑从 X 和 Y 获取数据后得到了 Z
这时我们可以认为 X 和 Y 是加和逻辑的依赖 (dependencies), 因为加和逻辑获得结果 Z 需要 X 和 Y; 也可以说加和逻辑订阅(subscribe)了 X 和 Y
在"指令式"风格下, 如果我们希望维持依赖组 (dependencies)和结果组(results)之间的绑定关系, 就必须在每一次依赖组被修改后调用一次绑定逻辑(BindLogic)来更新结果组
js
dependencies = changeDependencies()
result = bindLogic(dependencies)
出于代码简洁性(懒🤪), 我们希望当依赖组被修改时, 绑定逻辑可以自动响应 来更新结果组. 这时绑定逻辑就好像"修改依赖组"这一行为的副作用(side effect)一样
为了实现绑定逻辑的响应式 , 我们需要一个位于更高层的监听逻辑. 当监听到依赖组内的对象被修改时, 调用相应的绑定逻辑
js
let X = 1
let Y = 2
let Z
function update() {
// 依赖组(X, Y)和结果组(Z)之间以加和逻辑绑定
Z = X + Y
}
whenDepsChange(update)
console.log(Z) // 3
X = 2
console.log(Z) // 4, whenDepsChange() 监听到 X 的变化后调用 update() 来更新 Z, 加和关系保持成立
更进一步, 当依赖组内部对象被读取 (get), 这就表明某一逻辑依赖了该对象, 此时应追踪 (track)该逻辑; 当依赖组内的对象被赋值 (set), 此时应根据被赋值对象的追踪情况, 触发 (trigger)所有依赖该被赋值对象的逻辑. 这时我们就实现了一个响应式系统
Vue
Vue 提供了相应 API 来构建上述响应式逻辑:
Vue API | 作用 |
---|---|
reactive() | ref() |
声明将被放入依赖组或结果组的对象 |
watchEffect() |
声明绑定逻辑并指明相应依赖组和结果组 |
computed() |
利用绑定逻辑和依赖组对象来声明结果组对象 |
js
import { ref, watchEffect } from "vue"
const X = ref(0)
const Y = ref(1)
const Z = ref()
watchEffect(() => {
// 绑定逻辑为"加和(+)"
// X 和 Y 放入依赖组, Z 放入结果组
Z.value = X.value + Y.value
})
// Vue 将自动维护 X, Y 和 Z 之间的加和逻辑, 此时 Z 的值变为 3
X.value = 2
用 computed()
改写上述代码:
js
import { ref, computed } from "vue"
const X = ref(0)
const Y = ref(1)
// 直接用依赖组对象 X 和 Y, 以及加和逻辑来声明结果组对象 Z
const Z = computed(() => X.value + Y.value)
X.value = 2
平时我们使用 Vue, 最常见的绑定关系是状态 -渲染-> DOM
js
import { ref, watchEffect } from "vue"
const count = ref(0)
watchEffect(() => {
document.body.innerHTML = `count is: ${count.value}`
})
// 依赖组 count 发生改变, 渲染逻辑将被触发
count.value++
这就是开发者只需要管理好状态, Vue 会自动将其反映到 DOM 上 的内部逻辑. 但在具体实现上, Vue 对渲染逻辑进行了大量优化, 所以更推荐将状态和 DOM 之间的具体渲染逻辑放在 <template>
中
html
<template>
<button @click="addCount">+1</button>
<output>{{count}}</output>
</template>
<script setup>
import { ref } from "vue";
const count = ref(0);
function addCount() {
count.value += 1;
}
</script>
回答
在分析"响应式"之前, 我们应首先明确: 不进行任何逻辑封装的前提下, JS 是指令式 的, 即对 X 的操作就局限于 X, 并不会影响到 Y 或 Z. 原生 DOM 操作也沿袭了指令式 风格. 因此在为网站添加动态性 , 也就是维护数据 -渲染-> DOM 这一关系时, 数据变动 指令其后必须跟随渲染指令
出于代码的简洁性, 让数据变动摆脱掉渲染这条"小尾巴", 前端开发引入了响应式 的概念. 具体表现为通过使用 React 或 Vue 等来自动维护数据 和DOM 之间的渲染 逻辑. 当数据发生改变, React 或 Vue 将响应变化并依据改变后的结果执行渲染
此时我们可以将数据分到依赖组 (dependencies), DOM分到结果组 (results), 渲染逻辑从依赖组中获取数据, 经过执行后得到结果组. 那么泛化来说, 依赖组 中可以放入任意数据; 结果组 中也可以不局限于 DOM; 依赖和结果之间的绑定逻辑 也可以自由指定, 且当依赖发生改变时自动执行, 来对结果进行更新, 维护依赖和结果之间的对应关系. 这就实现了响应式
更进一步, 当依赖组内部对象被读取 (get), 这就表明某一逻辑依赖了该对象, 此时应追踪 (track)该逻辑; 当依赖组内的对象被赋值 (set), 此时应根据被赋值对象的追踪情况, 触发 (trigger)所有依赖该被赋值对象的逻辑. 这时我们就实现了一个响应式系统
补充
这道题是一道非常适合面试切入的题. 既可以深入提问响应式 在代码层面如何实现, 又可以问响应式关联的两端: 从具体依赖什么出发, 深入到前端状态管理, 第三方状态集成等; 从到底如何得到结果出发, 深入到 Vue, React 等渲染流程, 彼此异同等. 是一道非常关键的前端面试题