1.写在前面
在上篇文章中,我们讨论了compted的实现原理,就是利用effect和options参数进行封装。同样的,watch也是基于此进行封装的,当然watch还可以传递第三参数去清理过期的副作用函数。不仅可以利用副作用函数的调度性,去实现回调函数的立即执行,也可以控制回调函数的执行时机。
2.watch的实现原理
watch本质就是去观测一个响应式数据,当数据变化时通知并执行相应的回调函数。watch的实现本质和computed类似,基于effect函数和options.scheduler选项。
const data = {
name:"pingping",
age:18,
flag:true
};
const state = new Proxy(data,{
/*...*/
})
watch(state,()=>{
console.log("数据变化了呀...");
});
//在响应数据的age值被修改了,就会导致回调函数执行
state.age++;
watch函数的实现代码如下所示,副作用函数存在scheduler选项中,当响应数据发生变化时,会触发scheduler调度函数执行,而非直接触发副作用函数执行。
//watch 函数接收source响应数据和回调函数cb
function watch(source, cb){
effect(
//触发读取操作,建立联系
()=>source.age,
{
scheduler(){
// 当数据发生变化,调用回调函数cb
cb();
}
}
)
}
上面代码片段中,我们在使用watch对数据进行监听时,只能对soure.age的变化进行观测,不具有通用性,对此需要进行进一步封装。
function watch(source, cb){
effect(
//调用traverse函数递归读取操作,建立联系
()=>traverse(source),
{
scheduler(){
// 当数据发生变化,调用回调函数cb
cb();
}
}
)
}
const isObject = (value:any) => typeof value === "object" && value !== null;
function traverse(value, seen = new Set()){
//如果读取的数据是原始值,或已经读取过响应数据,则什么也不做
if(!isObject(value) || seen.has(value)) return;
//将数据添加到seen中,表示遍历读取过数据,避免循环引用导致死循环
seen.add(value);
//对数据对象进行遍历递归读取,用于依赖收集
for(const k in value){
traverse(value[k],seen);
}
return value;
}
在上面代码中,单独封装了一个递归函数traverse可以对响应数据进行遍历递归读取操作,这样就可以读取到对象的上所有属性,从而监听任意属性值发生变化时都能够触发回调函数。
事实上,使用watch进行数据观测时,不仅可以观测响应数据,还可以观测getter函数。那么,我们只需要先对输入的被观测数据判断数据类型是否为function,如果是则赋值给getter,否则还是监听响应式数据。
function watch(source,cb){
let getter;
if(typeof source === "function"){ // 如果是函数表示是getter,可以直接赋值
getter = source;
}else{
getter = () => traverse(source)// 包装成effect对应的effectFn, 函数内部进行遍历达到依赖收集的目的
}
let oldValue, newValue;
const effectFn = effect(
()=>getter(),
{
//开启lazy选项,将返回值存储到effectFn中以便于之后手动调用
lazy: true,
scheduler(){
newValue = effectFn(); // 值变化时再次运行effect函数,获取新值
cb(newValue,oldValue);
//更新旧值,不然下次得到的是错误的旧值
oldValue = newValue;
}
}
)
//手动调用副作用函数,拿到的值是旧值
oldValue = effectFn();
}
其实,上面代码中充分了利用了lazy选项的特性,利用其创建一个懒执行的effect。通过手动执行effectFn函数得到的返回值是旧值,当数据变化并触发scheduler调度器执行时,会重新执行effectFn函数并且得到新值。
这样,我们获取到了数据变化前后的新值和旧值,可以将其作为参数传递给回调函数cb,在变化执行副作用函数后需要将新值赋值给oldValue,方便后续执行计算,否则后续变更会获取到错误的旧值。
写个demo使用下:
watch(
()=>state.age,
(newValue, oldValue)=>{
console.log(newValue, oldValue);
}
)
state.age++
3.立即执行的watch与回调执行时机
watch本质上对effect的二次封装,其具有两个特性:立即执行的回调函数、回调函数的执行时机。
立即执行的回调函数
立即执行的回调函数,默认情况下,一个watch的回调函数只会在响应数据发生变化时才执行,但是在Vue.js中可以通过options.immediate来指定回调是否立即执行。
当options.immediate存在且为true时,回调函数在该watch创建时立即执行一次。事实上,回调函数的立即执行和后续执行在本质上区别不大,对此可以将其调度器scheduler进行封装为通用函数,通过options.immediate的存在与否判断是在初始化还是变更时进行执行。
function watch(source, cb, options={}){
let getter;
if(type === "function"){
getter = source;
}else{
getter = ()=>traverse(source);
}
let oldValue, newValue;
// 提取调度函数为独立的函数
const scheduler = ()=>{
newValue = effectFn(); // 值变化时再次运行effect函数,获取新值
cb(newValue,oldValue);
//更新旧值,不然下次得到的是错误的旧值
oldValue = newValue;
}
const effectFn = effect(
()=>getter(),
{
//开启lazy选项,将返回值存储到effectFn中以便于之后手动调用
lazy: true,
scheduler: scheduler
}
)
if(options.immediate){
//当immediate为true时,立即执行scheduler函数从而触发回调执行
scheduler()
}else{
//手动调用副作用函数,拿到的值是旧值
oldValue = effectFn();
}
}
上面代码中,回调函数是立即执行的,在第一次回调函数执行时没有所谓的旧值,此时回调函数的oldValue值是undefined。
回调函数的执行时机
当然,除了上面的可以指定回调函数为立即执行外,还可以通过options参数来指定回调函数的执行时机。在Vue.js3中可以通过flush选项来指定调度函数的执行时机,当flush的值为"post"时,表示调度函数需要将副作用函数放在微任务队列中,等待DOM更新结束后执行。
function watch(source, cb, options={}){
let getter;
if(type === "function"){
getter = source;
}else{
getter = ()=>traverse(source);
}
let oldValue, newValue;
// 提取调度函数为独立的函数
const obj = ()=>{
newValue = effectFn(); // 值变化时再次运行effect函数,获取新值
cb(newValue,oldValue);
//更新旧值,不然下次得到的是错误的旧值
oldValue = newValue;
}
const effectFn = effect(
()=>getter(),
{
//开启lazy选项,将返回值存储到effectFn中以便于之后手动调用
lazy: true,
scheduler(){
if(options.flush === "post"){
const p = Promise.resolve();
p.then(obj);
}else{
obj();
}
}
}
)
if(options.immediate){
//当immediate为true时,立即执行scheduler函数从而触发回调执行
scheduler()
}else{
//手动调用副作用函数,拿到的值是旧值
oldValue = effectFn();
}
}
其实就是根据options.flush是否等于"post",来实现是否需要将obj函数进行异步处理。
4.过期的副作用函数和cleanup
在讲到watch过期的副作用函数,就要提到在多进程或多线程编程中经常被提及的竞态问题。在下面代码片段中,使用watch观测state对象的变化,每次state对象发生变化时都会发送网络请求。
let finalData;
watch(state, async ()=>{
//发送等待网络请求
const res = await fetch("/user/info");
finalData = res;
})
在上面代码看起来是没啥问题,但其实会发生竞态问题,在第一次修改state对象的字段值后,会导致回调执行,同时发送第一次请求A;在A请求返回结果之前,我们继续修改state的字段值,同时发送第二次请求B。但是请求A和请求B谁的结果先返回,我们并不知道?
将A的请求结果覆盖B的请求结果
在理论分析下,我们先后发送A、B请求,按道理应该是先返回A,再返回B请求的结果。这是因为请求A是副作用函数第一次执行产生的副作用,而请求B是副作用函数第二次执行产生的副作用。请求B在请求A后发生,请求A应当在这之前就过期了,返回的结果应该是无效的。
但是,在前面没有对watch的执行时机进行调度的情况下,就会发生请求A的值后返回覆盖B请求返回值的错误。
要想解决这种问题,我们只需要提供一个副作用过期的手段即可。事实上,watch函数的回调函数可以传入第三个参数onInvalidate函数,让其注册一个回调在当前副作用函数过期时执行:
function watch(source, cb, options={}){
let getter;
if(type === "function"){
getter = source;
}else{
getter = ()=>traverse(source);
}
let oldValue, newValue;
let cleanupFn;//用于存储用户注册的过期回调
//定义onInvalidate函数
const onInvalidate = (fn)=>{
//将过期的回调函数存储到cleanupFn中
fn();
}
// 提取调度函数为独立的函数
const obj = ()=>{
newValue = effectFn(); // 值变化时再次运行effect函数,获取新值
//在调用回调函数cb之前,先调用过期的回调函数
if(cleanupFn){
cleanupFn();
}
cb(newValue,oldValue,onInvalidate);
//更新旧值,不然下次得到的是错误的旧值
oldValue = newValue;
}
const effectFn = effect(
()=>getter(),
{
//开启lazy选项,将返回值存储到effectFn中以便于之后手动调用
lazy: true,
scheduler(){
if(options.flush === "post"){
const p = Promise.resolve();
p.then(obj);
}else{
obj();
}
}
}
)
if(options.immediate){
//当immediate为true时,立即执行scheduler函数从而触发回调执行
scheduler()
}else{
//手动调用副作用函数,拿到的值是旧值
oldValue = effectFn();
}
}
在上面代码片段中,定义变量存储用户通过onInvalidate函数注册的回调函数,将过期的回调赋值给cleanupFn,在job函数中每次执行回调函数cb前都会检查是否存在过期回调。存在过期回调则执行cleanupFn函数清理,最后将onInvalidate返回给用户使用。
写个demo实践下:
watch(state, async (newValue, oldValue, onInvalidate)=>{
let expired = false;
onInvalidate(()=>{
expired = true;
})
const res = await fetch("/user/info");
if(!expired){
finaleData = res;
}
});
//第一次修改
state.age++;
setTimeout(()=>{
state.age++;
},200)
示意图如下:
请求过期
5.写在最后
本文中,讨论了watch函数是如何利用副作用函数和options进行封装实现的,也通过调度函数去控制回调函数的立即执行和执行时机,还可以解决竞态问题。