01. 实现effect和reactive函数

发布于 2022年 04月 30日 10:08

创建项目以后先搭建jest测试环境

先感谢崔老师的课程,崔老师的B站地址: space.bilibili.com/175301983

先确保jest成功搭建以后,写下第一个effect的测试用例:

describe('effect'() => {
  it('happy path'() => {
    const obj = reactive({
      count5,
    })
    let nextCount
    effect(() => {
      nextCount = obj.count + 1
    })
    expect(nextCount).toBe(6)
  })
})

然后想办法让这个测试用例通过。 可以观察到,effect接收的是一个函数,并且这个函数被调用了一次,所以nextCount的值变成了5+1=6。 那么我们可以很轻松的实现出来effect逻辑:

export function effect(fn: Function) {
  fn()
}

同理reactive的逻辑:

export function reactive(object{
  return object
}

好啦,现在测试通过~ 当然这样的代码是没有任何用处的,众所周知vue是响应式的,那么我们继续copy测试用例:

it('当响应式的值变化时候,触发传给effect的函数', () => {
    const obj = reactive({
      count: 5,
    })
    let nextCount
    effect(() => {
      nextCount = obj.count + 1
    })
    obj.count++
    expect(nextCount).toBe(7)
  })

这下子就真的得好好想想要如何实现响应式了 虽然我读书少,但是我知道vue2是用Object.defineProperties实现的响应式,vue3换成了Proxy来实现;而且我恰巧知道一点点Proxy可以定义一个对象的key被访问了,以及这个key对应的value被修改了。那我似乎有了一点实现的方法: 我先把传给reactive的入参用Proxy包装一下,然后当修改它的count这个属性时候,我就再去调用一下传给effect的入参(以下称为fn)。 感觉似乎可行,试一波,先将reactive.ts修改为:

export function reactive(object) {
  return new Proxy(object, {
    get(target, key) {
      console.log('访问key:', key)
      return target[key]
    },
    set(target, key, value) {
      console.log(`设置${String(key)}新value:${value}`)
      return true
    },
  })
}

淦,为啥ts还提示我 "Cannot find name 'Proxy'"呢? 先google,要在tsconfig上加入配置:

"lib": [ "dom", "ES2015" ]

这里先不去深究,挖个坑先。 改完了reactive以后,再回去看一眼第一个测试用例是否正常,这是一个很重要的事情,tdd就应该是小步迭代的,频繁测试的,ok,第一个测试用例很正常,继续。 这里得好好想想了,effect现在跟reactive没有任何关联,但是我们想实现当reactive的某个属性的值发生变化时,要再次调用fn,那么这个fn肯定得存到某个地方去,先把他存到window.fn上,然后当reactive的属性值变化后,再调用window.fn不就行啦~ (jest运行再node.js环境,应该使用globalThis)

export function reactive(object) {
  return new Proxy(object, {
    get(target, key) {
      console.log('访问key:', key)
      return target[key]
    },
    set(target, key, value) {
      console.log(`设置${String(key)}新value:${value}`)
      target[key] = value
      globalThis.fn()
      return true
    },
  })
}
export function effect(fn: Function) {
  fn()
  globalThis.fn = fn  // jest是运行在nodejs环境的,没有window
}

好了,这回测试通过了(面向测试用例编程哈哈哈) 但是我自己也知道这个肯定是有问题的啦,毕竟globalThis的fn只有一份,如果有第二个属性需要响应式,就会把第一个响应式的属性覆盖掉,所以fn应该放到一个Map中,我将其取名为targetMap,以对象本身为key。再仔细想想的话,一个响应式对象的属性也可能会有多个,同样也可能会有覆盖问题,那么Reactive的value也应该是个Map,取名为depMap,以属性名为key,声明一个Set,里边存放fn,嗯,这样似乎就没问题了。 我们先写出一个可以报错的测试用例出来:

  it('当存在多个值需要响应式时候,应该触发各自的回调函数', () => {
    const obj = reactive({
      count: 5,
      type: '有点困',
    })
    let nextCount
    let nextType
    effect(() => {
      nextCount = obj.count
    })
    effect(() => {
      nextType = obj.type
    })
    obj.type += 'z'
    obj.count++
    // 这里失败的原因是第二次调用的effect把globalThis的fn给覆盖了
    expect(nextCount).toBe(obj.count)
    expect(nextType).toBe(obj.type)
  })

然后修改代码~

export function effect(fn: Function) {
  globalThis.fn = fn  // 应该先设置fn,再调用fn
  fn()
}

const targetMap = new Map()

export function reactive(object) {
  return new Proxy(object, {
    get(target, key) {
      console.log('访问key:', key)
      let depsMap = targetMap.get(target)
      if (!depsMap) {
        depsMap = new Map()
        targetMap.set(target, depsMap)
      }
      let dep = depsMap.get(key)
      if (!dep) {
        dep = new Set()
        depsMap.set(key, dep)
      }
      if (globalThis.fn) {
        dep.add(globalThis.fn)
      }
      globalThis.fn = null
      return target[key]
    },
    set(target, key, value) {
      console.log(`设置${String(key)}新value:${value}`)
      target[key] = value
      const dep = targetMap.get(target).get(key)
      if (dep) {
        dep.forEach((dep) => {
          dep()
        })
      }
      return true
    },
  })
}

ok 能用,但是代码比较混乱,那今天我们重构他一下。

先关注reactive这个函数,它实际上做了两件事:

  1. 创建一个proxy对象
  2. 当proxy对象调用getter时收集(track)依赖,调用setter时候去触发(trigger)依赖

可以发现第二件事是和effect极度相关的,那我们就把它拿到effect.ts中去

import { track, trigger } from './effect'
export function reactive(object) {
  return new Proxy(object, {
    get(target, key) {
      console.log('访问key:', key)
      track(target, key as string)
      return target[key]
    },
    set(target, key, value) {
      console.log(`设置${String(key)}新value:${value}`)
      target[key] = value
      trigger(target, key as string)
      return true
    },
  })
}
export function effect(fn: Function) {
  globalThis.fn = fn // jest是运行在nodejs环境的,没有window
  fn()
}

const targetMap = new Map<any, Map<string, Set<Function>>>()

// 收集依赖
export function track(target, key: string) {
  let depsMap = targetMap.get(target)
  if (!depsMap) {
    depsMap = new Map()
    targetMap.set(target, depsMap)
  }
  let dep = depsMap.get(key)
  if (!dep) {
    dep = new Set()
    depsMap.set(key, dep)
  }
  if (globalThis.fn) {
    dep.add(globalThis.fn)
  }
  globalThis.fn = null
}

// 触发依赖
export function trigger(target, key: string) {
  const dep = targetMap.get(target)?.get(key)
  if (dep) {
    dep.forEach((dep) => {
      dep()
    })
  }
}

哦了,再回头看一眼测试用例,全绿,完毕~

推荐文章