2.vue3源码-effect

84 阅读9分钟

1.介绍

本内容是为了方便自己以后查阅同时让基础比较薄弱的人也能看懂vue的源码,所以采用的方式比较啰嗦,勿喷。不喜欢直接划走即可!!!

这里的代码和vue的源码不是完全一样的。但是核心的代码以及思路是一样的。当你学会当前的代码再去阅读源码会容易很多

这里为了编写文档时方便 且方便理解 我将所有的内容全部写到一个文件中了,后续会在main分支将所有代码抽离

effect 这个API 做的事情就是 当获取数据(get)时,收集触发get的key和对应的函数的依赖关系,当修改数据(set)时,执行触发set的key对应的函数

不过在此之前你应该学会使用effect effect默认会先执行一次

这章节很重要 真的很重要 理解这个后续你就事半功倍了

2.编写代码

2.1.effect

首先我们要创建且导出effect 函数, vue3使用了类来创建响应式的Effect,下面是基本的代码结构 很简单对吧

class ReactiveEffect {
  constructor(private fn) {}
​
  run() {
    return this.fn()
  }
}
​
export function effect(fn) {
  const _effect = new ReactiveEffect(fn)
​
  _effect.run()
}

现在 我们来明确一件事 effect这个API的最终目的就是为了收集依赖,那么我们接下来的核心目的就是 当执行effect的时候让外界拿到当前正在执行的effect

let activeEffect = undefined
class ReactiveEffect {
  constructor(private fn) {}
  
  run() {
    activeEffect = this
    return this.fn()
  }
}

现在我们来分析一下会有哪些极端的情况

// 情况一
​
    effect(() => {
      app.innerHTML += proxyObj1.age
    })
​
    proxyObj1.address/**   这种情况 proxyObj1.address也会触发get 但是这个并没有被effect函数包裹,所以他并没有对应的effect函数 
    
    解决:执行当前的fn后让activeEffect为undefined  这样proxyObj1.address触发get 执行track函数的时候 activeEffect就是undefined了 就不会执行后续逻辑了
​
  run() {
    try {
      activeEffect = this
      return this.fn()
    } finally {
      activeEffect = undefined
    }
  }
    
*/// 情况二  effect嵌套
  effect(() => {  // e1
      app.innerHTML = proxyObj1.name
      effect(() => {  // e2
        app.innerHTML += proxyObj1.age
      })
      proxyObj1.address
    })
/**
 * 
    上面这段代码 如果使用情况一的解决方案 还是会有问题  
    name 对应的effect是 e1  
    age 对应的effect是 e2
    address 对应的effect是undefined 这就不对了 address应该对应e1
​
    解决: vue2和早期的vue3使用的是栈来解决 但是这会多出一个全局的变量 不太好
    现在的解决方案是 使用树结构来解决 
    原理是:给每个effect指定一个parent,指向的是当前effect的父effect,等到子effect执行完毕 在让activeEffect执行父effect  具体看上面完整代码
 */

解决方案

let activeEffect = undefinedclass ReactiveEffect {
  constructor(private fn) {}
​
  parent = undefined
​
  run() {
    // try包裹是为了防止执行fn函数报错 影响后续代码
    // 下面的代码解决了 情况一和情况二的问题
    try {
      this.parent = activeEffect
      activeEffect = this
      return this.fn()
    } finally {
      activeEffect = this.parent
    }
  }
}
​
​

至此 我们就能让外界拿到当前正在执行的effect了

2.2.分别在set和get设置对应的方法

现在我们需要在响应式对象的get方法中调用一个tarck函数用来收集依赖,在set方法中调用一个trigger方法来执行依赖

export const mutableHandles = {
  get(target, key, receiver) {
    // 解决情况二
    if (!target[ReactiveFlags.IS_REACTIVE])
      target[ReactiveFlags.IS_REACTIVE] = true
    // 收集依赖
    track(target, key)
    return Reflect.get(target, key, receiver)
  },
​
  set(target, key, value, receiver) {
    const oldValue = target[key]
​
    const r = Reflect.set(target, key, value, receiver)
    
    // 判断两次更新的值是否相同 如果相同就不用二次执行 提高性能
    if (oldValue !== value) trigger(target, key, value, oldValue)
    return r
  },
}

2.3.track

这个函数是为了保存key和当前执行的effect直接的关系的,将这个两者的依赖存储成如下的结构

weakMap => map => set

e g:{name:'zs'} => name => [effect1,effect2]

const targetMap = new WeakMap()
export function track(target, key) {
  // 只用是effect中的才跟踪收集依赖
  if (!activeEffect) return
​
  let depsMap = targetMap.get(target)
  if (!depsMap) {
    targetMap.set(target, (depsMap = new Map()))
  }
​
  let dep = depsMap.get(key)
  if (!dep) {
    depsMap.set(key, (dep = new Set()))
  }
​
  // 虽然set是不会重复的 但是内部肯定会做逻辑的 那么如果我们判断一下在加入会提升性能
  let shouldTrack = !dep.has(activeEffect)
  if (shouldTrack) {
    dep.add(activeEffect)
  }
}

至此我们已经能获取一张依赖表了

2.4.trigger

// 此方法目的是等到修改数据了从依赖表中拿到对应的effect 然后执行
export function trigger(target, key, newValue, oldValue) {
  const depsMap = targetMap.get(target)
​
  if (!depsMap) return
​
  const dep = depsMap.get(key)
​
  dep &&
    dep.forEach((effect) => {
      effect.run()
    })
}

现在来看一下极端的情况

// 情况三
/**
 * 当定时器更改obj.name 那么就会触发effect函数 执行obj.name = Math.random() 此时又会触发effect...如此往复成了死循环 因为random是随机数 */
effect(() => {
  obj.name = Math.random()
  app.innerHTML = obj.name
})
​
setTimeout(() => {
  obj.name = 'wzng'
}, 2000)
​
​

解决方案

export function trigger(target, key, newValue, oldValue) {
  const depsMap = targetMap.get(target)
​
  if (!depsMap) return
​
  const dep = depsMap.get(key)
​
  dep &&
    dep.forEach((effect) => {
      if (effect !== activeEffect) effect.run()
    })
}

2.5.cleanupEffect

看下面这个情况

// 情况四
// 如下代码 当我们执行定时器的内容 flag 已经是false了 然后在执行obj.name=‘xxx' 这时候不应该执行console.log
// 换句话来说就是 因为flag是false了就不应该再去收集name的依赖了,只用收集flag和age的依赖就行 这样会节约性能// 做法  当我们在收集依赖的时候 收集当前effect和触发的key之间的关系 每次执行run的时候先将其清空
// 然后等到更新时再次收集 eg:更新的时候会触发get和set
// 说句白话就是 在当前的effect.deps中存储key对应的effecteffect(() => {
  app.innerHTML = obj.flag ? obj.name : obj.age
})
​
setTimeout(() => {
  obj.flag = false
  setTimeout(() => {
    console.log('name')
    obj.name = 'xxx'
  }, 1000)
}, 2000)

解决方案

function cleanupEffect(effect) {
  const { deps } = effect
  console.log(deps)
  for (let i = 0; i < deps.length; i++) {
    deps[i].delete(effect)
  }
​
  effect.deps.length = 0
}
​
export class ReactiveEffect {
  constructor(private fn) {}
  // 解决情况四
  deps = []
   ...
​
  run() {
    // try包裹是为了防止执行fn函数报错 影响后续代码
    // 下面的代码解决了 情况一和情况二的问题
    try {
      this.parent = activeEffect
      activeEffect = this
      // 解决情况四
      cleanupEffect(this)
      return this.fn()
    } finally {
      activeEffect = this.parent
      // 复原parent  可要可不要 最好写上 保持好习惯
      this.parent = undefined
    }
  }
}
​
export function track(target, key) {
  ...
  if (shouldTrack) {
    dep.add(activeEffect)
    // 解决情况四
    activeEffect.deps.push(dep)
  }
}
​
// 此方法目的是等到修改数据了从依赖表中拿到对应的effect 然后执行
export function trigger(target, key, newValue, oldValue) {
  const depsMap = targetMap.get(target)
​
  if (!depsMap) return
​
  const dep = depsMap.get(key)
  dep &&
    dep.forEach((effect) => {
      // 做个判断 为了防止情况三 的发生
      if (effect !== activeEffect)  effect.run() 
    })
}

但是使用该解决方案后代码会进入死循环,原因如下

/*
跟新数据触发 trigger  会执行 
dep &&
    dep.forEach((effect) => {
      if (effect !== activeEffect) effect.run()
    })
接着执行 effect.run() 
run中会执行cleanupEffect()  该还是会将当前的dep中的内容清空
然后 会执行fn fn中又会收集依赖 会对dep中继续add 
此时代码还在forEach中 因为add了所以会继续往后遍历 继续执行run 然后就形成了死循环
下面是模仿上面的代码
*/// 这个就是dep
const set = new Set([1])
​
// 遍历
set.forEach((item) => {
  // 执行run方法
  run()
})
​
function run() {
  // 简单模仿
  set.delete(1)
  set.add(1)
}

解决方案

/*触发死循环的是因为在同一个set中添加 删除导致的 所以我们可以将其set中的内容放到一个数组 然后遍历数组即可*/ 
// 此方法目的是等到修改数据了从依赖表中拿到对应的effect 然后执行
export function trigger(target, key, newValue, oldValue) {
  const depsMap = targetMap.get(target)
​
  if (!depsMap) return
​
  const dep = depsMap.get(key)
  
  // 加个判断 看dep是否有值
  if (!dep) return
​
  // 解决情况四
  const effects = [...dep]
​
  effects &&
    effects.forEach((effect) => {
      // 做个判断 为了防止情况三 的发生
      if (effect !== activeEffect) {
        // 解决情况六
        if (effect.scheduler) {
          effect.scheduler()
        } else {
          effect.run()
        }
      }
    })
}

2.6.stop

官网的effect还提供了一种能力就是当数据变化时,不自动的更新,而是手动的更新,看下面的案列,这是官网提供的api$forceUpdate,底层就是stop

下面的effect是官方的effect 做了一件这样的事情 就是当数据变化时我们不希望自动更新,而是我们调用方法手动跟新 
​
const runner = effect(() => {
  app.innerHTML = obj.name
​
})
runner.effect.stop()
​
setTimeout(() => {
  obj.name = 'lisi'
  runner()
}, 2000)

我们来分析一下结构 可以看出effect返回的是一个函数,这个函数有effect属性(函数也是对象),effect中有stop方法,那么我们按照官网的effect来改造我们自己写的effect

export function effect(fn) {
  // 创建一个响应式effect
  const _effect = new ReactiveEffect(fn)
  _effect.run()
​
  // 绑定this 不然外界调用this会有问题
  const runner = _effect.run.bind(_effect)
  runner.effect = _effect
  
  return runner
}

现在我们来分析一下怎么实现上述说的功能,说白了就是当调用stop方法的时候,清空当前的依赖关系即可(上面已经编写过了该方法cleanupEffect),这样等到set的时候去依赖表里找依赖关系找不到 就不会更新了 但是会有一个问题 虽然清空了依赖 但是当修改数据的时候又会重新的搜集依赖,所以我们需要一个标识来表示是否是活跃的 如果不是活跃的那么就代表了调用了stop 只有激活的时候才执行run中的逻辑 非激活状态直接调用fn即可 (当用户手动调用runner的时候要执行fn 这样就做到了手动跟新)

export class ReactiveEffect {
  constructor(private fn) {}
  // 解决情况四
  deps = []
  // 解决情况二 嵌套effect的问题
  parent = undefined
​
  // 标识是否是激活状态
  active = true
​
  run() {
    // 如果是失活(调用了stop) 直接调用fn 不自动更新
    if (!this.active) return this.fn()
    // try包裹是为了防止执行fn函数报错 影响后续代码
    // 下面的代码解决了 情况一和情况二的问题
    try {
      this.parent = activeEffect
      activeEffect = this
      // 解决情况四
      cleanupEffect(this)
      return this.fn()
    } finally {
      activeEffect = this.parent
      // 复原parent  可要可不要 最好写上 保持好习惯
      this.parent = undefined
    }
  }
​
  stop() {
    // 只有是激活状态下才执行代码  言外之意就是调用了stop就不执行逻辑 节省性能
    if (this.active) {
      this.active = false
      // 清空依赖
      cleanupEffect(this)
    }
  }
}

2.7.scheduler

effect还提供了一个功能就是 当数据修改之后,不使用原来的effect跟新而是使用我们自己提供的方法来跟新

这其实有点和watch类似,某个数据变化后调用回调函数

const runner = effect(() => {
  app.innerHTML = obj.name
​
}, {
  scheduler() {
    app.innerHTML = 123
  }
})
​
// 当数据修改 不使用effect中的函数来更新 而使用提供的scheduler来更新
setTimeout(() => {
  obj.name = 'lisi'
}, 2000)

那么现在我们来分析一下实现的原理,其实原理是比较简单的,我们在使用effect时传入自己的方法,格式按照上面的来(官方的),那么当修改的数据的时候 调用trigger时 判断当前effect是否有scheduler 如果有就调用。没有就调用原来的effect

export class ReactiveEffect {
  // 添加scheduler
  constructor(private fn, public scheduler) {}
  ...
}
export function effect(fn, options: any = {}) {
  // 创建一个响应式effect  这里传入scheduler
  const _effect = new ReactiveEffect(fn, options.scheduler)
  _effect.run()
​
  // 绑定this 不然外界调用this会有问题
  const runner = _effect.run.bind(_effect)
​
  runner.effect = _effect
  return runner
}
​
​
export function trigger(target, key, newValue, oldValue) {
  ...
  effects &&
    effects.forEach((effect) => {
      // 做个判断 为了防止情况三 的发生
      if (effect !== activeEffect) {
        if (effect.scheduler) {
          effect.scheduler()
        } else {
          effect.run()
        }
      }
    })
}

3.仓库地址

github.com/wscymdb/vue…