简单响应式数据系统
简单响应式系统
最近读了《Vue.js设计与实现》一书,详细介绍了渐进式JavaScript框架Vue.js的设计理念和实现原理。
深受启发,原来Vue.js的底层原理和框架结构并没想象的那么神秘和复杂。这次就以Vue.js最核心的部分之一——响应式功能为例,看看到底是如何实现的吧。
副作用函数
所谓副作用函数就是产生副作用的函数。
副作用就是会直接或间接影响其他函数执行的作用。
比如,一个函数修改了全局变量,又或者访问对象的一个属性, 而修改或访问的变量有可能触发其他函数的执行,所以说这个函数是副作用函数。
响应式数据
我们希望在数据的变化时,能自动触发与之关联的副作用函数(比如更新DOM对象),这样能节约很多人力维护的成本。
const obj = { text: 'hello world' }
// effect 是一个副作用函数
function effect() {
// effect 函数的执行会读取 obj.text 并赋值给dom对象
document.body.innerText = obj.text
}
obj.text = 'hello vue3' // 修改 obj.text 的值,同时希望副作用函数会重新执行
在这个例子中,当obj.text的值发生变化时,我们希望副作用函数effect能被重新调用。
如果能通过某些技术手段,实现了这个目标,那么对象obj就是响应式数据。
响应式数据的基本实现
观察可以发现,当修改obj.text 的值时,必然会调用obj.text的设置操作。
当副作用函数effect执行时,必然会触发字段obj.text的读取操作。
如果能拦截一个对象的设置和读取操作,并在其中做手脚,事情就变得简单了。
当读取字段obj.text时,把副作用函数effect保存到一个变量deps,
接着,当设置 obj.text时,从deps中取出副作用函数,依次执行即可。
可见关键问题在于,如果拦截对象属性的读取和设置操作。
在ES2015之前,只能通过 Object.defineProperty 函数实现,这也是 Vue.js 2 所采用的方式。
在ES2015以后,我们可以使用代理对象 Proxy来实现,这也是 Vue.js 3 所采用的方式。
下面我们来按照上面的思路,分别采用 Object.defineProperty 和Proxy和来实现响应式数据。
属性描述符
在JavaScript中,属性描述符提供了一种精确控制对象属性行为的方式。
属性描述符分为两类:数据描述符和存取描述符。
数据描述符关注属性的值和可变性,而存取描述符则允许定义属性的getter和setter方法,从而控制属性的读取和设置过程。
数据描述符包含以下属性:
- value:属性的值。
- writable:决定属性的值是否可以被修改。
- enumerable:决定属性是否可以在for-in循环中被枚举。
- configurable:决定属性是否可以被删除或修改。
存取描述符则包含:
- get:属性的getter方法。
- set:属性的setter方法。
要获取属性的描述符,可以使用Object.getOwnPropertyDescriptor(obj, prop)方法,其中obj是目标对象,prop是要获取描述符的属性名。这个方法会返回一个包含属性描述信息的对象。
要定义或修改属性的描述符,可以使用Object.defineProperty(obj, prop, descriptor)方法,其中descriptor是包含属性描述信息的对象。这个方法允许精确地控制属性的行为。
基于属性描述符的响应式数据
了解属性描述符的作用和用法,就可以着手响应式数据的实现了:
// 定义一个变量,用于声明一个对象的属性为响应式数据
function defineReactive(obj, key, val) {
// 依赖收集器,用于存放对象属性相关的副作用函数
const deps = new Set();
// 定义属性修饰符get和set方法
Object.defineProperty(obj, key, {
get() {
// 收集依赖
deps.add(effect);
return val;
},
set(newVal) {
// 更新属性值,并执行副作用函数
val = newVal;
deps.forEach(effect => effect());
},
});
}
//示例
const user = {};
defineReactive(user, 'name', 'Alice');
const effect = () => {
console.log(`Name: ${user.name} changed!`);
//在这里执行更多的副作用,比如修改dom
}
effect()//执行副作用函数,触发get,并收集这个函数
setTimeout(()=>{
user.name = 'Bob'
},3000)
//当user.name的值变化时,effect函数会主动执行。
但是目前的实现还有很多缺陷。比如我们只能收集函数名为effect的副作用函数,而且还要手动执行一次effect函数来触发get。这在实际开发中很不灵活。
我们需要一种注册机制,可以用于注册响应式数据副作用函数。
// 当前激活的副作用函数,用于储存被注册的副作用函数。这是一个全局变量,可以被响应式声明器访问到
let activeEffect = null;
// 副作用函数注册器
function watchEffect(effect) {
activeEffect = effect;
effect(); // 首次执行,触发依赖收集
activeEffect = null;
}
//示例
const user = {};
defineReactive(user, 'name', 'Alice');
//注册副作用函数
watchEffect(() => {
console.log(`Name: ${user.name} changed!`);
});
有了这个注册器,我们可以很方便的对副作用函数进行注册。以前的代码也相应的修改:
function defineReactive(obj, key, val) {
const deps = new Set();
Object.defineProperty(obj, key, {
get() {
// 自动收集当前被注册的函数到相关联的对象属性 所在的 依赖收集器中。
if (activeEffect) {
deps.add(activeEffect);
}
return val;
},
set(newVal) {
//检查属性值是否变化,避免多余的副作用。
if (newVal !== val) {
val = newVal;
// 触发更新
deps.forEach(effect => effect());
}
},
});
}
可以看到,我们使用一个匿名的副作用函数作为 watchEffect 函数的参数。在我们声明多个响应式数据后,使用watchEffect函数可以把副作用函数注册到相关的响应式的对象属性中。当 watchEffect 函数执行时,首先会把匿名的副作用函数赋值给全局变量 activeEffect。接着执行被注册的匿名副作用函数,这将会触发副作用函数各个依赖的响应式数据的读取操作,进而触发对象修饰符的 get 拦截,被依赖收集器收集。
对象代理Proxy
代理(Proxy)是JavaScript中的一个高级功能,它允许你在目标对象之前设置一个层,这一层可以拦截和自定义基本操作,如属性查找、赋值、枚举和函数调用等。这种技术被称为“元编程”(meta programming),即对编程语言本身进行编程。
要创建一个代理对象,需要使用Proxy构造函数,它接受两个参数:一个目标对象和一个处理器对象。
返回一个被代理的对象
处理器对象是一个包含捕捉器(traps)的占位符对象,这些捕捉器定义了执行各种操作时代理的行为。
const p = new Proxy(target, handler);
捕捉器可以拦截多种操作,例如:
- get:拦截对象属性的读取。
- set:拦截对象属性的设置。
- has:拦截in操作符。
- deleteProperty:拦截delete操作符。
- apply:拦截函数调用操作。
- construct:拦截new操作符。
基于对象代理实现的响应式数据
const deps = new Set()
// 原始数据
const data = { text: 'hello world' }
// 对原始数据进行代理
const obj = new Proxy(data, {
get(target, key) {
if(activeEffect){
deps.add(activeEffect)
console.log('依赖收集成功!')
}
return target[key]
},
set(target, key, newVal) {
if (newVal !== target[key]) {
console.log('正在设置新值!')
target[key] = newVal;
deps.forEach(effect => effect());
}
}
})
let activeEffect
function watchEffect(fn) {
activeEffect = fn
fn()
activeEffect = null
}
// 示例
watchEffect(
() => {
console.log(`obj.text changed:${obj.text}`)
}
)
用这种方法实现原理和用属性修饰符实现的原理相同,这里不再说明。
关于Object.defineProperty和Proxy的主要区别是:
Object.defineProperty 只能拦截已定义的属性; 而Proxy可以拦截整个对象的操作。
性能方面,可能Object.defineProperty 更强,功能方面Proxy更强。
下面我会接着利用Proxy的这个例子,继续说明。
更加完善的响应式数据
继续上面的例子说明。如果我们给代理对象一个不存在的属性赋值,会发生什么?
obj.notExist = "123"
我们发现set陷阱又被触发了!注册了的副作用函数又被执行了一遍。这实际上不是我们想要的结果,因为那个注册了副作用函数不是代理对象 notExist 属性的依赖。
因为Proxy不像Object.defineProperty那样只拦截对象的一个属性,而是对对象全部属性都做拦截。
所以我们不能只用一个依赖收集器来管理对象的各个属性的副作用函数。
很简单,只需要增加depsMap,用来保存 key('String') -> deps('Set') 的关系就好了。
//使用新的数据类型
const depsMap = new Map();
// 原始数据
const data = {
name: 'Alice',
age: 18,
}
//代理
const obj = new Proxy(data, {
get(target, key) {
// 收集依赖
if (activeEffect) {
let deps = depsMap.get(key);
if (!deps) {
deps = new Set();
depsMap.set(key, deps);
}
deps.add(activeEffect);
}
return target[key];
},
set(target, key, newVal) {
if (target[key] !== newVal) {
target[key] = newVal;
// 触发更新
const deps = depsMap.get(key);
if (deps) {
deps.forEach(effect => effect());
}
}
return true;
},
});
// 当前激活的副作用函数
let activeEffect = null;
// 副作用函数注册器
function watchEffect(effect) {
activeEffect = effect;
effect(); // 首次执行,触发依赖收集
activeEffect = null;
}
// 示例
const user = reactive({
name: 'Alice',
age: 18,
});
// 注册副作用函数
watchEffect(() => {
console.log(`Name: ${user.name}, Age: ${user.age}`);
});
// 修改属性
user.name = 'Bob'; // 输出:Name: Bob, Age: 18
user.age = 20; // 输出:Name: Bob, Age: 20
当声明多个响应式对象的时候,会创建多个depsMap。但是如果创建多个响应式对象,depsMap就不方便管理了。
我们可以把对象的代理封装成一个函数,在函数内部创建各个的 depsMap,这样可以很好的解决问题。相关代码:
function reactive(obj) {
const depsMap = new Map();
return new Proxy(obj, {
get(target, key) {
if (activeEffect) {
let deps = depsMap.get(key);
if (!deps) {
deps = new Set();
depsMap.set(key, deps);
}
deps.add(activeEffect);
}
return target[key];
},
set(target, key, newVal) {
if (target[key] !== newVal) {
target[key] = newVal;
const deps = depsMap.get(key);
if (deps) {
deps.forEach(effect => effect());
}
}
},
});
}
//...副作用函数注册器
// 示例
const user = reactive({
name: 'Alice',
age: 18,
});
//...注册副作用函数
这样是不是和Vue3的reactive方法很像呢。
如果我希望将get和trigger的逻辑封装成独立的函数,有什么办法呢。
除了将depsMap做为参数传到独立的函数,更好的做法是将 depsMap 提升到模块作用域,使用一个全局变量来存储每个对象的依赖关系。
对应关系是: obj('Object') -> depsMap('Map'),的关系,其中depsMap还是保存key('string') -> desp('Set') 的关系。
这样以来,一个响应式对象有多个响应式属性,每个响应式属性有多个副作用函数。这俨然形成了一种树型结构。
这里我们考虑使用一种新的数据结构:WeakMap。它很好的满足我们以对象作为键的需求。
WeakMap 是 JavaScript 中的一个特殊对象,它允许开发者将对象作为键来存储值,而不会阻止键对象被垃圾回收机制回收。这意味着,WeakMap 中的键对象可以被自动清理,从而避免了内存泄漏的问题。WeakMap 的键只能是对象或非全局注册的符号,而值可以是任意的 JavaScript 类型。
之所以采用WeakMap,而不是Map, 是因为 WeakMap 的 key 是 弱引用,不会影响垃圾回收器的工作。
如果使用Map, 有可能用户的的代码对响应式对象没有引用了,但是这个对象还是不被回收,最终可能导致内存溢出。
最后,我们还可以对函数进行一些封装。把追踪逻辑放到track函数,把触发副作用的函数放到trigger函数中。
// 依赖收集器
const targetMap = new WeakMap();
// 当前激活的副作用函数
let activeEffect = null;
// 副作用函数注册器
function watchEffect(fn) {
activeEffect = fn;
fn();
activeEffect = null;
}
// 响应式对象工厂函数
function reactive(obj) {
return new Proxy(obj, {
get(target, key) {
// 收集依赖
track(target, key);
return target[key];
},
set(target, key, newVal) {
target[key] = newVal;
// 触发更新
trigger(target, key);
return true;
},
});
}
// 依赖收集
function track(target, key) {
if (activeEffect) {
// 获取对象的依赖集合
let depsMap = targetMap.get(target);
if (!depsMap) {
depsMap = new Map();
targetMap.set(target, depsMap);
}
// 获取属性的依赖集合
let deps = depsMap.get(key);
if (!deps) {
deps = new Set();
depsMap.set(key, deps);
}
// 添加当前激活的副作用函数
deps.add(activeEffect);
}
}
// 触发更新
function trigger(target, key) {
// 获取对象的依赖集合
const depsMap = targetMap.get(target);
if (depsMap) {
// 获取属性的依赖集合
const deps = depsMap.get(key);
if (deps) {
// 执行所有副作用函数
deps.forEach(effect => effect());
}
}
}
// 示例
const obj = reactive({ text: 'hello world', count: 0 });
watchEffect(() => {
console.log(`obj.text changed: ${obj.text}`);
});
watchEffect(() => {
console.log(`obj.count changed: ${obj.count}`);
});
// 修改属性
obj.text = 'hello weakmap'; // 输出:obj.text changed: hello weakmap
obj.count = 1; // 输出:obj.count changed: 1
这样代码就变得十分明了,同时给我们带了了极大的灵活性。