Vue设计与实现:响应系统实现

什么是副作用函数

副作用函数指在执行过程中会对函数外部环境产生可观察的改变或与函数外部环境进行交互的函数

例如一个函数修改了全局变量

js 复制代码
// 全局变量
let val = 1
function effect() {
  val = 2 // 修改全局变量,产生副作用
}

响应式数据的基本实现

思路:

  1. 设置一个set保存副作用函数
  2. 使用proxy代理原始数据设置get、set拦截函数
    2.1 当读取属性时(触发get函数)将副作用函数 effect 添加到桶里,即 bucket.add(effect),然后返回属性值
    2.2 当设置属性值时(触发set函数)先更新原始数据,再将副作用函数从桶里取出并重新执行
js 复制代码
// 存储副作用函数的桶
const bucket = new Set()

// 原始数据
const data = { text: 'hello world' }
// 对原始数据的代理
const obj = new Proxy(data, {
  // 拦截读取操作
  get(target, key) {
    // 将副作用函数 effect 添加到存储副作用函数的桶中
    bucket.add(effect)
    // 返回属性值
    return target[key]
  },
  // 拦截设置操作
  set(target, key, newVal) {
    // 设置属性值
    target[key] = newVal
    // 把副作用函数从桶里取出并执行
    bucket.forEach(fn => fn())
  }
})

// 副作用函数
function effect() {
  document.body.innerText = obj.text
}

// 执行副作用函数,触发读取
effect()

// 1 秒后修改响应式数据
setTimeout(() => obj.text = "hello vue3",1000)

结果:

先显示hello world文本 一秒后修改为hello vue3文本

完善的响应系统

上面的例子中我们硬编码了副作用函数的名字(effect),导致一旦副作用函数的名字不叫 effect,那么这段代码就不能正确地工作了。而我们希望的是,哪怕副作用函数是一个匿名函数,也能够被正确地收集到"桶"中。

思路:

  1. activeEffect存储被注册的副作用函数
  2. 重新定义effect函数,接收一个fn参数,即要注册的副作用函数
js 复制代码
// 用一个全局变量存储当前激活的 effect 函数
let activeEffect
function effect(fn) {
  // 当调用 effect 注册副作用函数时,将副作用函数复制给 activeEffect
  activeEffect = fn
  // 执行副作用函数
  fn()
}
diff 复制代码
const obj = new Proxy(data, {
  get(target, key) {
+    if (acticeEffect) {
+      bucket.add(activeEffect)
+    }
     return target[key]
  },
  set(target, key, newVal) {
    target[key] = newVal
    bucket.forEach(fn => fn())
    return true
  }
})

effect(() => {
  console.log('effect run')
  document.body.innerText = obj.text
})

setTimeout(() => obj.text = "hello vue3", 1000)

由于副作用函数已经存储到了 activeEffect 中,所以在 get 拦截函数内应该把 activeEffect收集到"桶"中,这样响应系统就不依赖副作用函数的名字了

结果:

一开始effect执行了传入的副作用函数,并保存到activeEffect 第二次打印是在set函数中

在响应式数据上设置一个不存在的属性时

在setTimeout中设置了obj.text2 = "hello vue3",不存在的text2也会触发effect函数,hello world并未改变,出现情况是因为字段名没有绑定对应的effect函数

js 复制代码
effect(() => {
  console.log('effect run')
  document.body.innerText = obj.text
})

setTimeout(() => obj.text2 = "hello vue3", 1000)

从之前effect函数中发现分别有三个角色:obj(代理对象)text(代理对象字段名)effect(副作用函数)

js 复制代码
effect(() => {
  document.body.innerText = obj.text
})

对应关系:

两个副作用函数同时读取同一个对象的属性值:

js 复制代码
effect(function effectFn1() {
 obj.text
})
effect(function effectFn2() {
 obj.text
})

对应关系:

一个副作用函数中读取了同一个对象的两个不同属性:

js 复制代码
effect(function effectFn() {
 obj.text1
 obj.text2
})

对应关系:

不同的副作用函数中读取了两个不同对象的不同属性:

js 复制代码
effect(function effectFn1() {
 obj.text1
})
effect(function effectFn2() {
 obj.text2
})

对应关系:

需要使用WeakMap 代替 Set 作为桶的数据结构:

思路:
  1. 首先之前bucket的set结构换成WeakMap结构
  2. 改造Proxy的get、set函数
    2.1 在get函数中首先判断acticeEffect不存在就直接return
    2.2bucket.get(target)取出depsMap,判断存放的元素数据是否存在,不存在给bucket设置一个以原始数据为key值为map
    2.3 depsMap通过key来取出存放effect的deps,不存在就设置一个以原始数据得key值为set的deps,再将effect函数添加到deps中
    2.4 向bucket去除target对应的depsMap,要是没有直接return,如果有再根据key取出对应的effects(也就是get函数中的deps),effects存在就遍历里面的effect函数
js 复制代码
const bucket = new WeakMap()
let acticeEffect
const data = {
  text: "hello world"
}

const obj = new Proxy(data, {
  get(target, key) {
    if (!acticeEffect) return target[key]
    let depsMap = bucket.get(target)
    if (!depsMap) {
      bucket.set(target, depsMap = new Map())
    }
    let deps = depsMap.get(key)
    if (!deps) {
      depsMap.set(key, deps = new Set())
    }
    deps.add(acticeEffect)
    return target[key]
  },
  set(target, key, newVal) {
    target[key] = newVal
    let depsMap = bucket.get(target)
    if (!depsMap) return
    const effects = depsMap.get(key)
    effects && effects.forEach(fn => fn())
  }
})

function effect(fn) {
  acticeEffect = fn
  fn()
}

effect(() => {
  console.log('effect run')
  document.body.innerText = obj.text
})

setTimeout(() => obj.text2 = "hello vue3", 1000)
结果:

只打印了text对应的effect函数,setTimeout中设置了text2并没有触发effect函数

分支切换

当字段 obj.ok 的值发生变化时,代码执行的分支会跟着变化,这就是分支切换

js 复制代码
const data = { ok: true, text: 'hello world' }
effect(() => {
  document.body.innerText = obj.ok ? obj.text : 'not'
})

一开始obj.ok为true,会触发ok、text字段的读取操作

对应关系:

把obj.ok修改为false,只会触发ok字段的读取操作,text字段不会触发读取操作

对应关系: 要是在每次执行effect函数时,先把对应effect集合清除掉,再通过get函数添加到集合中,这样集合只会保留对应字段的effect集合

清空集合:

  1. effect 函数接受一个参数 fn,该参数是一个副作用函数。
  2. 在函数内部,定义了一个名为 effectFn 的函数,它作为实际的副作用函数执行体。
  3. cleanup 函数用于清理副作用函数的相关依赖。
  4. activeEffect用于存储当前正在执行的副作用函数。
  5. effectFn 被赋值给 activeEffect,这样就能在副作用函数内部访问到自身。
  6. fn(即传入 effect 函数的副作用函数)执行一次,这样副作用函数就会被执行一次。
  7. 最后,将空数组赋值给 effectFndeps 属性,用于存储与该副作用函数相关的依赖集合。
js 复制代码
function effect(fn) {
  const effectFn = () => {
    cleanup(effectFn)
    // 当调用 effect 注册副作用函数时,将副作用函数复制给 activeEffect
    activeEffect = effectFn
    fn()
  }
  // activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
  effectFn.deps = []
  // 执行副作用函数
  effectFn()
}

function cleanup(effectFn) {
  for (let i = 0; i < effectFn.deps.length; i++) {
    const deps = effectFn.deps[i]
    deps.delete(effectFn)
  }
  effectFn.deps.length = 0
}

收集依赖集合:

将当前执行的副作用函数activeEffect 添加到依赖集合 deps 中,这说明 deps 就是一个与当前副作用函数存在联系的依赖集合,于是我们也把它添加到activeEffect.deps 数组中,这样就完成了对依赖集合的收集

diff 复制代码
function track(target, key) {
  let depsMap = bucket.get(target)
  if (!depsMap) {
    bucket.set(target, (depsMap = new Map()))
  }
  let deps = depsMap.get(key)
  if (!deps) {
    depsMap.set(key, (deps = new Set()))
  }
  deps.add(activeEffect)
+  activeEffect.deps.push(deps)
}

防止进入死循环

diff 复制代码
function trigger(target, key) {
  const depsMap = bucket.get(target)
  if (!depsMap) return
  const effects = depsMap.get(key)

+  const effectsToRun = new Set()
+  effects && effects.forEach(effectFn => effectsToRun.add(effectFn))
+  effectsToRun.forEach(effectFn => effectFn())
-  effects && effects.forEach(effectFn => effectFn())
}
js 复制代码
effect(() => {
  console.log('effect run')
  document.body.innerText = obj.ok ? obj.text : 'not'
})

setTimeout(() => {
  obj.ok = false
  setTimeout(() => {
    obj.text = 'hello vue3'
  }, 1000)
}, 1000)

结果:

读取obj.ok的执行顺序: 读取obj.text的执行顺序:

一秒后设置obj.ok的执行顺序:

  1. 执行trigger函数,取出被修改key对应的effects(deps),遍历循环effects执行每个effect

2. 执行effect就会触发cleanup,cleanup会把effectFn(也就是activeEffect,因为一开始activeEffect = effectFn)得deps给清空,再赋值给activeEffect 3. obj.text = 'hello vue3'触发set函数,执行effectsToRun.forEach(effectFn => effectFn()),再执行effectFn,清空effect集合,再触发fn由于obj.ok为false所以只会触发ok字段的get函数,不会触发text字段的get函数,

4. 由于text对应的effect集合为空,所以obj.text = 'hello vue3'时effectsToRun.forEach(effectFn => effectFn())没有effectFn可以执行

effect嵌套问题

Vue.js 的渲染函数就是在一个 effect 中执行的

js 复制代码
// Foo组件
const Foo = {
  render(){
    return xxxx
  }
}

effect(() => {
  Foo.render()
})

所以当组件发生嵌套,effect就嵌套了

js 复制代码
// Bar 组件
const Bar = {
  render() { /* ... */ }
 }
  // Foo 组件渲染了 Bar 组件
const Foo = {
  render() {
    return <Bar /> 
  },
}

effect(() => {
  Foo.render()
  // 嵌套
  effect(() => {
    Bar.render()
   })
 })

在现有响应系统添加嵌套effect并修改数据会发生什么?

js 复制代码
const data = { foo: true, bar: true }
let temp1, temp2

effect(function effectFn1() {
  console.log('effectFn1 执行')
  effect(function effectFn2() {
    console.log('effectFn2 执行')
    temp2 = obj.bar
  })
  temp1 = obj.foo
})
obj.foo = 2

结果:

前两个打印是在执行effectFn1、effectFn2时打印,最后一个打印是因为activeEffect = effectFn会替换上一个保存的activeEffect,导致最后一次get函数中deps.add(activeEffect)activeEffect已经是effectFn2,修改obj.foo = 2时触发effectsToRun.forEach(effectFn => effectFn())effectFn也是effectFn2

解决方法:

  1. effectStack存放副作用函数,在fn执行前push到effectStack中,

2. fn()当有嵌套effect时,再次执行effectFn,再push到effectStack中,现在effectStack是有多个effect

3. 当嵌套effect执行完后,就从effectStack删除,再从effectStack最后一个effect函数赋值给activeEffect

diff 复制代码
// 定义一个全局变量存储被注册的副作用函数
let activeEffect;
const effectStack = []  // 存储嵌套的副作用函数


function effect(fn) {
  const effectFn = () => {
    // 调用 cleanup 完成清除工作
    cleanup(effectFn);
    // effectFn 执行是,将其设置为当前激活的副作用函数
    activeEffect = effectFn;
+    effectStack.push(effectFn);
    fn();
    // 调用完副作用函数后,将副作用函数出栈
+    effectStack.pop();
    // 并将 activeEffect 设置为上一个激活的副作用函数
+    activeEffect = effectStack[effectStack.length - 1];
  }
  effectFn.deps = [];
  // 执行副作用函数
  effectFn();
}

避免无限递归循环

当effect传入数据自增会报错

js 复制代码
effect(() => {
  obj.foo = obj.foo + 1
})

报错原因:

  1. 首先obj.foo触发track操作,将当前effect存放到deps中deps.add(activeEffect);
  2. obj.foo + 1赋值给obj.foo触发trigger操作,再从deps中循环拿到activeEffecteffectsToRun && effectsToRun.forEach(effectFn => effectFn());
  3. 执行effectFn再执行到fn();,会再次执行obj.foo = obj.foo + 1,一直重复1-3步骤,才导致栈溢出

解决方法:

track跟trigger都是读取同一个effect才导致栈溢出,所以在trigger中加个判断,如果effects循环中effect跟activeEffect是同一个就不添加到effectsToRun

diff 复制代码
function trigger(target, key) {
  // 获得对应key的effect set
  const depsMap = bucket.get(target);
  if (!depsMap) return;
  const effects = depsMap.get(key);
  // 临时的 set
  const effectsToRun = new Set();
  // 将副作用函数 effect 取出并执行
  effects && effects.forEach(effect => {
    // 如果trigger触发的副作用函数和当前正在执行的函数相同,则跳过
+    if (effect !== activeEffect) {
      effectsToRun.add(effect);
+    }
  });
  effectsToRun.forEach(effect => effect())
}

调度执行

可调度性指的是当 trigger 动作触发副作用函数重新 执行时,有能力决定副作用函数执行的时机、次数以及方式

js 复制代码
const data = { foo: 1 }
effect(() => {
  console.log(obj.foo)
},
)

obj.foo++
console.log("结束")

// 打印
 1
 2 
 '结束'

要是结束跟2换位置要怎么做?

可以为 effect 函数设计一个选项参数 options,作为指定调度器

diff 复制代码
function effect(fn, options = {}) {
  const effectFn = () => {
    // 调用 cleanup 完成清除工作
    cleanup(effectFn);
    // effectFn 执行是,将其设置为当前激活的副作用函数
    activeEffect = effectFn;
    effectStack.push(effectFn);
    fn();
    // 调用完副作用函数后,将副作用函数出栈
    effectStack.pop();
    // 并将 activeEffect 设置为上一个激活的副作用函数
    activeEffect = effectStack[effectStack.length - 1];
  }
+  effectFn.options = options
  effectFn.deps = [];
  // 执行副作用函数
  effectFn();
}

function trigger(target, key) {
  // 获得对应key的effect set
  const depsMap = bucket.get(target);
  if (!depsMap) return;
  const effects = depsMap.get(key);
  // 临时的 set
  const effectsToRun = new Set();
  // 将副作用函数 effect 取出并执行
  effects && effects.forEach(effect => {
    // 如果trigger触发的副作用函数和当前正在执行的函数相同,则跳过
    if (effect !== activeEffect) {
      effectsToRun.add(effect);
    }
  });
  effectsToRun.forEach(effect => {
  // 如果一个副作用函数存在调度器,则调用该调度器,并将副作用函数作为参数 传递
+    if (effect.options.scheduler) {
+      effect.options.scheduler(effect)
+    } else {
      effect()
    }
  })
}
js 复制代码
effect(() => {
  console.log(obj.foo)
},
  {
    scheduler(fn) {
      setTimeout(fn)
    }
  }
)

obj.foo++

console.log("结束")

// 打印
1
结束
2

控制执行次数

代码会打印1、2、3,但是obj.foo执行两次,2只是过渡状态3才是最终的结果,理想应该打印1、3

js 复制代码
const data = { foo: 1 }
effect(() => {
  console.log(obj.foo)
},
)

obj.foo++
obj.foo++
思路:
  1. 首先定义一个set队列,两次obj.foo++会两次触发trigger通过调度器添加到set中,set可以去重确保只保存一个effect
  2. 两次obj.foo++会让flushJob触发两次,需要定义一个是否正在刷新队列变量isFlushing,第一次为false会执行,第二次为true代表正在刷新所以就直接return
  3. 想要在同步代码后面执行就需要使用异步操作,定义一个promise,在then方法中将set遍历执行,去除后的effect函数中取到的值就是最新,最后在finally方法中将isFlushing重置为false
js 复制代码
// 定义一个任务队列
const jobQueue = new Set();
// 使用 Promise.resolve 创建一个 promise 实例,用它将任务添加到微任务队列
const p = Promise.resolve();

// 一个标志代表是否正在刷新队列
let isFlushing = false;
function flushJob() {
  // 如果队列正在刷新,则什么也不做
  if (isFlushing) return;
  // 将 isFlushing 设置为 true,代表正在刷新
  isFlushing = true;
  // 将 jobQueue 中的任务取出,并执行
  p.then(() => {
    jobQueue.forEach(job => job);
  }).finally(() => {
    // 结束后重置 isFlushing
    isFlushing = false;
  })
}

effect(() => {
  console.log(obj.foo)
},
  {
    scheduler(fn) {
      // 每次调度,将副作用函数添加到 jobQueue 队列中
      jobQueue.add(fn);
      // 最终这里会等到副作用函数执行完后,即主线程执行完,之后会执行微任务,因此 flushJob 函数只会执行一次
      flushJob();
    }
  }
)

obj.foo++
obj.foo++

计算属性computed

之前的effect是直接执行的,但是计算属性是懒执行的,需要在options中给个参数来控制

js 复制代码
effect(() => {
  console.log(obj.foo)
},
  {
    lazy: true,
  }
)

设置懒执行

在effect中判断,如果不是懒执行就直接执行effectFn,再将effectFn返回手动执行effectFn

diff 复制代码
function effect(fn, options = {}) {
  const effectFn = () => {
    // 调用 cleanup 完成清除工作
    cleanup(effectFn);
    // effectFn 执行是,将其设置为当前激活的副作用函数
    activeEffect = effectFn;
    effectStack.push(effectFn);
    fn();
    // 调用完副作用函数后,将副作用函数出栈
    effectStack.pop();
    // 并将 activeEffect 设置为上一个激活的副作用函数
    activeEffect = effectStack[effectStack.length - 1];
  }
  effectFn.options = options
  effectFn.deps = [];

+  if (!options.lazy) {
    // 执行副作用函数
    effectFn();
+  }
+  return effectFn
}

const effectFn = effect(() => {
  console.log(obj.foo)
},
  {
    lazy: true,
  }
)

+ effectFn()

假设effect传入的函数是一个getter

js 复制代码
const effectFn = effect(() => {
  return obj.foo + obj.bar
},
  {
    lazy: true,
  }
)

// value 是 getter 的返回值
const value = effectFn()

effect需要执行fn再把执行结果再返回

diff 复制代码
function effect(fn, options = {}) {
  const effectFn = () => {
    // 调用 cleanup 完成清除工作
    cleanup(effectFn);
    // effectFn 执行是,将其设置为当前激活的副作用函数
    activeEffect = effectFn;
    effectStack.push(effectFn);
+    const res = fn();
    // 调用完副作用函数后,将副作用函数出栈
    effectStack.pop();
    // 并将 activeEffect 设置为上一个激活的副作用函数
    activeEffect = effectStack[effectStack.length - 1];
+   return res
  }
  effectFn.options = options
  effectFn.deps = [];

  if (!options.lazy) {
    // 执行副作用函数
    effectFn();
  }
  return effectFn
}

定义一个computed函数

它接收一个 getter 函数作为参数,我们把 getter 函数作为副作用函数,用它创建一个 lazy的 effect。computed 函数的执行会返回一个对象,该对象的 value 属性是一个访问器属性,只有当读取 value 的值时,才会执行effectFn 并将其结果作为返回值返回

js 复制代码
function computed(getter) {
  const effectFn = effect(getter, {
    lazy: true,
  })

  const obj = {
    get value() {
      return effectFn()
    }
  }

  return obj
}

const data = { foo: 1, bar: 2 }
const sumRes = computed(() => obj.foo + obj.bar)
console.log(sumRes.value) // 3

添加缓存功能

当多次读取sumRes.value 的值,会导致 effectFn 进行多次计算,需要做缓存功能

diff 复制代码
function computed(getter) {
  // value 用来缓存上一次计算的值
  // dirty 标志,用来标识是否需要重新计算值,为 true 则意味着"脏",需要计算
+  let value, dirty = true
  const effectFn = effect(getter, {
    lazy: true,
  })

  const obj = {
    get value() {
       // 只有"脏"时才计算值,并将得到的值缓存到 value 中
+      if (dirty) {
+        value = effectFn()
         // 将 dirty 设置为 false,下一次访问直接使用缓存到 value 中的值
+        dirty = false
+      }
+      return value
    }
  }

  return obj
}

现在添加缓存功能,但会出现后续修改obj数据再打印sumRes.value还是3,是因为第一次访问sumRes.value之后dirty为false,再次访问sumRes.value就直接返回上一次计算的值

  1. 在computed函数中传入了getter,getter中引用了obj的foo、bar字段,track函数会使这两个字段跟getter关联起来
  2. 在修改obj的foo时,会触发trigger函数执行effect的scheduler将dirty修改为true,再后续sumRes.value访问就会触发value = effectFn()取到最新的值
diff 复制代码
function computed(getter) {
  let value, dirty = true
  const effectFn = effect(getter, {
    lazy: true,
+    scheduler() {
+      // 添加调度器,在调度器中将 dirty 重置为 true
+      dirty = true
+    }
  })

  const obj = {
    get value() {
      if (dirty) {
        value = effectFn()
        dirty = false
      }
      return value
    }
  }

  return obj
}

当effect引用sumRes.value

在一个 effect 的副作用函数中读取了 sumRes.value 的值。修改 obj.foo 的值并不会使effect重新执行,应该就像在 Vue.js 的 模板中读取计算属性值的时候,一旦计算属性发生变化就会触发重新 渲染一样

js 复制代码
const sumRes = computed(() => obj.foo + obj.bar)
effect(() => {
  console.log("sumRes.value", sumRes.value)
})
obj.foo++

目前修改obj.foo并不会重新执行console.log("sumRes.value", sumRes.value)

思路:
  1. () => obj.foo + obj.bar传入computed再传入effct得到待执行的effectFn,返回obj
  2. console.log("sumRes.value", sumRes.value)传入effect,由于没有options.lazy所以会直接执行effectFn,将当前的effectFn设置为activeEffect并push到effectStack,执行fn也就是console.log("sumRes.value", sumRes.value)
  3. sumRes.value会触发computed返回obj的get,第一次dirty为true执行effectFn,执行effectFn中的fn也就是() => obj.foo + obj.bar会触发foo、bar的track,将foo、bar跟activeEffect建立关联,fn执行完毕得到最新计算结果3赋值给res并返回,将副作用函数出栈并将 activeEffect 设置为上一个激活的副作用函数,执行完effectFn将3赋值给value并将dirty设置为false,调用 track(obj, 'value')将obj的value跟上一个激活的副作用函数activeEffect建立关联
  4. obj.foo++会先执行obj.foo的track,当前activeEffect为undefined直接return,再执行obj.foo的trigger拿到obj.foo对应的effect也就是computed中的effectFn,判断是否有scheduler存在就执行将dirty设置为true,再执行scheduler中trigger(obj, 'value')trigger(obj, 'value')会拿到computed中obj.value对应的effect,判断是否有scheduler没有就直接执行effect也就是打印sumRes.value的effect,访问sumRes.value会执行2、3步
diff 复制代码
function computed(getter) {
  let value, dirty = true
  const effectFn = effect(getter, {
    lazy: true,
    scheduler() {
      dirty = true
+      // 但计算属性以来的响应式数据变化时,手动调用 trigger 函数触发响应
+      trigger(obj, 'value')
    }
  })

  const obj = {
    get value() {
      if (dirty) {
        value = effectFn()
        dirty = false
      }
+      // 当读取 value 时,手动调用 track 函数进行最终测试
+      track(obj, 'value')
      return value
    }
  }

  return obj
}
js 复制代码
const sumRes = computed(() => obj.foo + obj.bar)
effect(() => {
  console.log("sumRes.value", sumRes.value)
})
obj.foo++

//打印
3
4
结果:

watch

思路:

  1. 第一个参数传入一个对象再传给effect第一个参数,第二个参数传入对应回调函数再传入effect第二个参数的scheduler
  2. 在traverse函数中遍历读取obj的每个属性都会跟effect建立关联,修改obj数据时会拿到修改字段对应的effect,由于scheduler存在就执行scheduler中的内容也就是传入数据改变的回调函数
js 复制代码
function watch(source, cb) {
  effect(() => traverse(source), {
    scheduler() {
      cb()
    }
  })
}

watch(obj, () => console.log("数据改变了"))
obj.foo++

function traverse(value, seen = new Set()) {
  // 如果读取的值是原始类型,或者已经被读取过了,then do nothing,结束递归
  if (typeof value !== "object" || value === null || seen.has(value)) return;
  // 将数据添加到 seen 中,代表遍历地读取过了,避免循环引用引起的死循环
  seen.add(value);
  // 递归遍历 value 对象
  for (const k in value) {
    traverse(value[k], seen);
  }
  return value;
}

支持getter函数与返回回调函数新旧值

思路:
  1. 定义getter变量作为effect,如果source传入的是函数直接赋值给getter,否则传入traverse遍历访问source的字段
  2. 将getter传入effect第一个参数,再将lazy设置为true,如果不设置为true的话就会直接执行getter,手动执行effectFn为了拿到第一次执行得到的值,在修改foo时执行scheduler再执行effectFn拿到修改后的值,赋值给newValue再把newValue, oldValue传给回调函数,newValue更新oldValue
js 复制代码
function watch(source, cb) {
  // 定义 getter
  let getter;
  // 如果 source 是 function,则说明传的是 getter
  if (typeof source === "function") {
    getter = source;
  } else {
    getter = () => traverse(source);
  }
  // 定义旧值和新值
  let oldValue, newValue;
  // 返回值存储到 effectFn 中便于后续手动调用
  const effectFn = effect(
    () => getter(), // 触发读取操作,从而建立联系
    {
      lazy: true,
      scheduler() {
        // 在 scheduler 中重新执行得到的新值
        newValue = effectFn();
        // 当数据变化时,调用回调函数 callback
        cb(newValue, oldValue);
        oldValue = newValue;
      },
    }
  );
  // 手动调用一次副作用函数,拿到旧值
  oldValue = effectFn();
}

watch(() => obj.foo, (newVal, oldVal) => {
  console.log(newVal, oldVal);
});

obj.foo++

点击多次触发了watch回调函数

immediate控制是否第一次执行

思路:
  1. watch函数的第三个参数支持传入一个对象,对象中的immediate为true即开启第一次执行
  2. immediate为true就意味着watch的第二个参数回调函数也就是cb会先执行一次,将之前的scheduler单独抽离成一个job函数,执行job函数oldValue为undefined,newValue为effectFn执行后的结果也就是const res = fn();的res,fn为() => obj.foo
diff 复制代码
function watch(source, cb, options) {
  // 定义 getter
  let getter;
  // 如果 source 是 function,则说明传的是 getter
  if (typeof source === "function") {
    getter = source;
  } else {
    getter = () => traverse(source);
  }
  // 定义旧值和新值
  let oldValue, newValue;
  
+  // 提取 scheduler 调度函数为一个独立的 job 函数
+  const job = () => {
+    // 在 scheduler 中重新执行得到的新值
+    newValue = effectFn();
+    // 当数据变化时,调用回调函数 callback
+    cb(newValue, oldValue);
+    oldValue = newValue;
+  }
  // 返回值存储到 effectFn 中便于后续手动调用
  const effectFn = effect(
    () => getter(), // 触发读取操作,从而建立联系
    {
      lazy: true,
+      // 使用 job 函数作为调度器函数
+      scheduler: job,
    }
  );
+  if (options.immediate) {
+    // 当 immediate 为 true 时立即执行 job,从而触发回调执行
+    job()
+  } else {
    // 手动调用一次副作用函数,拿到旧值
    oldValue = effectFn();
  }
}

先打印了1、undefined是因为开启了immediate,后打印2、1是因为obj.foo+触发了trigger,trigger再执行了scheduler

flush控制调度函数的执行时机

思路:
  1. 当flush为post时就说明调度函数也就是job需要使用Promise,调度函数也就是job使等待 DOM 更新结束后再执行
  2. 当flush为sync时就说明是同步执行,所以直接执行job
diff 复制代码
function watch(source, cb, options) {
  // 定义 getter
  let getter;
  // 如果 source 是 function,则说明传的是 getter
  if (typeof source === "function") {
    getter = source;
  } else {
    getter = () => traverse(source);
  }
  // 定义旧值和新值
  let oldValue, newValue;
  const job = () => {
    // 在 scheduler 中重新执行得到的新值
    newValue = effectFn();
    // 当数据变化时,调用回调函数 callback
    cb(newValue, oldValue);
    oldValue = newValue;
  }
  // 返回值存储到 effectFn 中便于后续手动调用
  const effectFn = effect(
    () => getter(), // 触发读取操作,从而建立联系
    {
      lazy: true,
      scheduler: () => {
        console.log("scheduler")
+        // 在调度函数中判断 flush 是否为 'post',如果是,将其放到微任务 队列中执行
+        if (options.flush === "post") {
+          const p = Promise.resolve()
+          p.then(job)
+        } else {
          job()
        }
      },
    }
  );

  if (options.immediate) {
    job()
  } else {
    // 手动调用一次副作用函数,拿到旧值
    oldValue = effectFn();
  }
}
结果:

obj.foo+触发了scheduler打印了scheduler,但是加了flush: "post"导致job为异步所以先执行console.log("同步代码"),再打印job的执行结果2、1

过期的副作用

场景:

当watch监听一个响应式数据多次修改触发了请求数据接口,不知道该保存哪次请求返回回来的数据

思路:
  1. 要是cb中第一次请求数据后将后续的请求不让res赋值给finalData
  2. 在watch函数中给cb函数传一个onInvalidate函数,方便在调用watch时在cb函数中使用onInvalidate来处理过期副作用的逻辑因为是要处理逻辑所以给onInvalidate传递处理过期副作用逻辑函数参数
  3. 因为修改数据会触发job中的cb,所以在调用cb函数前判断onInvalidate的过期副作用逻辑函数参数是否存在。如果存在就执行过期副作用逻辑函数
diff 复制代码
function watch(source, cb, options) {
  // 定义 getter
  let getter;
  // 如果 source 是 function,则说明传的是 getter
  if (typeof source === "function") {
    getter = source;
  } else {
    getter = () => traverse(source);
  }
  // 定义旧值和新值
  let oldValue, newValue;
+ let cleanup
+  function onInvalidate(fn) {
+   // 将过期函数存到 cleanup 中
+   cleanup = fn
+ }
  const job = () => {
    // 在 scheduler 中重新执行得到的新值
    newValue = effectFn();
+    // 在调用回调函数 cb 之前,先调用过期回调
+    if (cleanup) {
+      cleanup()
+    }
    // 将 onInvalidate 作为回调函数的第三个参数,以便用户使用
+    cb(newValue, oldValue, onInvalidate);
    oldValue = newValue;
  }
  // 返回值存储到 effectFn 中便于后续手动调用
  const effectFn = effect(
    () => getter(), // 触发读取操作,从而建立联系
    {
      lazy: true,
      scheduler: () => {
        console.log("scheduler")
        if (options.flush === "post") {
          const p = Promise.resolve()
          p.then(job)
        } else {
          job()
        }
      },
    }
  );
js 复制代码
let count = 0
function fetch() {
  count++
  console.log("count", count)
  const res = count === 1 ? 'A' : 'B'
  return new Promise(resolve => {
    setTimeout(() => {
      resolve(res)
    }, count === 1 ? 1000 : 100);
  })
}

let finalData
watch(() => obj.foo, async (newVal, oldVal, onInvalidate) => {
  let valid = true
  onInvalidate(() => {
    valid = false
  })
  const res = await fetch()

  if (!valid) return

  finallyData = res
  console.log("finallyData", finallyData)
})

console.log("第一次修改")
obj.foo++
setTimeout(() => {
  console.log("第二次修改")
  obj.foo++
}, 200);

结果:

  1. obj.foo+触发watch的回调函数,onInvalidate传入修改valid为false的逻辑函数,执行fetch
  2. 执行setTimeout中的obj.foo++,现在valid为false执行fetch,由于valid为false第一次的fetch直接return,再执行第二次修改的featch
相关推荐
cs_dn_Jie3 分钟前
钉钉 H5 微应用 手机端调试
前端·javascript·vue.js·vue·钉钉
开心工作室_kaic37 分钟前
ssm068海鲜自助餐厅系统+vue(论文+源码)_kaic
前端·javascript·vue.js
有梦想的刺儿1 小时前
webWorker基本用法
前端·javascript·vue.js
cy玩具1 小时前
点击评论详情,跳到评论页面,携带对象参数写法:
前端
customer082 小时前
【开源免费】基于SpringBoot+Vue.JS周边产品销售网站(JAVA毕业设计)
java·vue.js·spring boot·后端·spring cloud·java-ee·开源
qq_390161772 小时前
防抖函数--应用场景及示例
前端·javascript
John.liu_Test2 小时前
js下载excel示例demo
前端·javascript·excel
Yaml43 小时前
智能化健身房管理:Spring Boot与Vue的创新解决方案
前端·spring boot·后端·mysql·vue·健身房管理
PleaSure乐事3 小时前
【React.js】AntDesignPro左侧菜单栏栏目名称不显示的解决方案
前端·javascript·react.js·前端框架·webstorm·antdesignpro
哟哟耶耶3 小时前
js-将JavaScript对象或值转换为JSON字符串 JSON.stringify(this.SelectDataListCourse)
前端·javascript·json