手动实现Vue3 & 原理解析:setup环境 & reactive函数 & effect函数(一)

781次阅读  |  发布于2年以前

前言

本篇解析参阅 vue3源码、崔大的mini-vue、霍春阳大佬的《Vuejs设计与实现》尽可能记录我的Vue3源码阅读学习过程。我会结合自己的思考,提出问题,找到答案,附在每一篇的底部。希望大家能在我的文章中也能一起学习,一起进步,有 get 到东西的可以给作者一个小小的赞作为鼓励吗?谢谢大家!

手写简易vue3 setup环境 && reactive函数 && effect函数

setup环境

npm init 命令生成 package.json

当前项目主要采用 ts 来编写,用 jest 来做单元测试

说明:ts 会使用 any 类型,希望能把重点放在 vue3 的实现原理,如需要 会在后面做修改补充

所以需要安装如下的依赖包:

  1. jest (核心包)
  2. typescript (核心包)
  3. @types/jest (jest 语法 ts 解释)
  4. ts-jest (预处理 ts 的 jest 预制)
  5. @babel/core (babel 核心)
  6. @babel/preset-env (perset-env 预设)
  7. @babel/preset-typescript (babel ts 预设)
  8. babel-jest (jest es依赖包)

附带安装指令:npm install jest typescript @types/jest ts-jest @babel/core @babel/preset-env @babel/preset-typescript babel-jest \--save-dev

ts config 命令创建 tsconfig.json

重点介绍以下配置:

"target": "es2016",  // 为发出的JavaScript设置JavaScript语言版本,并包含兼容的库声明。
"lib": [   // 指定一组描述目标运行时环境的捆绑库声明文件。
      "DOM",
      "ES2015"
    ],
"types": ["jest"],   // 指定要包含而不在源文件中引用的类型包名称。
"noImplicitAny": false,    // 隐式 any 声明不会报错
复制代码

创建 babel.config.js 文件,配置 babel 预设规则,配置如下:

module.exports = {
  presets: [
    ["@babel/preset-env", { targets: { node: "current" } }],
    "@babel/preset-typescript"
  ]
}
复制代码

创建 jest.config.js 文件,配置 jest 依赖包,配置如下

module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
};
复制代码

至此,初始开发环境 setup 完毕

reactive 函数

众所周知,vue3 采用 Proxy 来代理对象,通过劫持方法来实现响应式

reactive函数就是将传入的对象变成一个代理对象

reactive 函数的初步实现

初步实现:

export function reactive(raw) {
  return new Proxy(raw, {
    get(target, key) {
      const res = Reflect.get(target, key)
      // TODO 依赖收集
      // tarck(target, key)
      return res
    },
    set(target, key, value) {
      const res = Reflect.set(target, key, value)
      // TODO 触发依赖
      // trigger(target, key, value)
      return res
    }
  })
}
复制代码

相关问题:Proxy是什么?Reflect是什么?为什么要用Reflect?[1]

接下来,我们需要实现 在 get 中实现 依赖收集 以及 在 set 中实现 触发依赖

依赖收集 & 触发依赖

依赖收集我们将它封装为一个 track 函数,在触发代理对象的 get 拦截器的时候 去收集依赖

触发依赖我们将它封装为一个 trigger 函数,在触发代理对象的 set 拦截器的时候去 触发依赖

这里首先要依赖一个 副作用函数产生的 activeEffect[2]

欢迎回来,这时候我们已经知道了 activeEffect 的由来

依赖收集:

我们想要收集依赖,得知道是哪个对象(target) 的哪个 key 吧?还得知道对应这个 key 有哪些依赖

这里我们采用的方法是:

  1. 利用 全局 WeakMap 来保存所有对象
  2. 利用 Map 来保存对象中所有 key
  3. 利用 Set 来保存 key 中的依赖

类似这样的结构:

WeakMap ├─ Map Obj1 │ ├─ Set Obj1.key1 │ │ ├─ ReactiveEffectA │ │ └─ ReactiveEffectB │ ├─ Set Obj1.key2 │ └─ Set Obj1.key3 ├─ Map Obj2 └─ Map Obj3

依赖执行:

依赖的执行就比较简单了,就是根据传入的 target 和 key 去取出所有的 ReactiveEffect 实例并执行方法

// 最外层用来保存每一个 target 的 weakMap
const targetMap = new WeakMap()
/**
 * get 依赖收集函数
 * @param target 传入的对象
 * @param key 对应属性的 key
 */
export function track(target, key) {
  // 拿到当前 target 对应的 map (每个对应的 target 底下应该保存着自己的 key 的 map)
  let depsMap = targetMap.get(target)
  // 拿不到,说明需要新建一个 map 并存入 weakMap
  if (!depsMap) {
    depsMap = new Map()
    targetMap.set(target, depsMap)
  }
  // 拿到当前 key 的对应的 set (每个对应 key 底下应该保存着自己的 set, set里边是所有的依赖 ReactiveEffect)
  let dep = depsMap.get(key)
  // 拿不到,说明需要创建一个新的 set 并存入对应的 map
  if (!dep) {
    dep = new Set()
    depsMap.set(key, dep)
  }
  // 已经在 dep 中就不用 add 了
  if (dep.has(activeEffect)) return
  dep.add(activeEffect) // 把对应的 ReactiveEffect 实例加入 set 里
}

/**
 * set 触发依赖
 * @param target 传入的对象
 * @param key 对应属性的 key
 */
export function trigger(target, key) {
  let depsMap = targetMap.get(target)
  let dep = depsMap.get(key)
  // 找到对应的 ReactiveEffect 实例,执行他们的 run 方法
  for (const effect of dep) {
    effect.run()
  }
}
复制代码

实现 readonly

我们现在有一个需求,响应式对象应该是只读的,不允许修改

根据需求,我们不难得到修改方案:

  1. get 方法没必要收集依赖了
  2. set 方法应该抛出一个警告,不允许修改

那么我们可以新增 readonly 函数,返回一个 和 reactive 不一样的代理对象:

export function readonly(raw) {
  return new Proxy(raw, {
    get(target, key) {
      const res = Reflect.get(target, key)
      // 不需要 track
      return res
    },
    set(target, key, value) {
      // 输出一个 warning
      console.warn(`key: ${key} set failed, because ${target} is readonly`)
      return true
    }
  })
}
复制代码

那显然我们发现这个代码和我们的 reactive 极其相似,我们应该将共同的代码抽离出来,因此我们:

语义化 new Proxy

export function createActiveObject(raw: any, baseHandlers) {
  return new Proxy(raw, baseHandlers)
}
复制代码

实现 createSetter 和 createGetter 方法并分别导出 handlers

// 缓存 get set readonlyGet 这样只有触发代理的时候才会调用函数
const get = createGetter()
const set = createSetter()
const readonlyGet = createGetter(true)
// 对 get 包一层,isReadonly 默认为 fasle
function createGetter(isReadonly = false) {
  return function get(target, key) {
    const res = Reflect.get(target, key)
    // 不是只读的才要 track
    if (!isReadonly) {
      track(target, key)
    }
    return res
  }
}
// 对 set 包一层
function createSetter() {
  return function set(target, key, value) {
    const res = Reflect.set(target, key, value)
    trigger(target, key)
    return res
  }
}
// 当调用 reactive 的时候传入这个 handlers
export const mutableHandlers = {
  get,
  set,
}
// 当调用 readonly 的时候传入这个 handlers
export const readonlyHandlers = {
  get: readonlyGet,
  set(target, key, value) {
    console.warn(`key: ${key} set failed, because ${target} is readonly`)
    return true
  }
}
复制代码

那么封装完成后,我们去生成响应式的代理对象/只读的代理对象就可以调用以下方法:

export function reactive(raw) {
  return createActiveObject(raw, mutableHandlers)
}
export function readonly(raw) {
  return createActiveObject(raw, readonlyHandlers)
}
复制代码

是不是清爽了非常多呢!!

实现 isReactive / isReadonly 方法

我们现在还需要两个方法 分别用来判断当前的这个对象是不是 响应式/只读 对象

那么我们需要增加 ReactiveFlag元组 、 isReactive方法 、 isReadonly方法,修改 createGetter方法:

export const enum ReactiveFlags {
  IS_REACTIVE = "__v_isReactive",
  IS_READONLY = "__v_isReadonly"
}
export function isReactive(obj) {
  // 访问obj的xxxx属性会触发 get 方法
  // 当 obj 不是一个响应式的时候 由于没有 isRecvtive属性 所以会是一个undefined 这时候用 !! 把它变成一个布尔类型
  return !!obj[ReactiveFlags.IS_REACTIVE]
}
export function isReadonly(obj) {
  // 同上
  return !!obj[ReactiveFlags.IS_READONLY]
}

function createGetter(isReadonly = false) {
  return function get(target, key) {
    // 根据这里的判断来返回 isReadonly 就可以了
    if (key === ReactiveFlags.IS_REACTIVE) {
      return !isReadonly
    } else if (key === ReactiveFlags.IS_READONLY) {
      return isReadonly
    }
    const res = Reflect.get(target, key)
    if (!isReadonly) {
      track(target, key)
    }
    return res
  }
}
复制代码

这里并不复杂,就是通过 创建 get 时的 isReadonly 参数来返回响应的值即可

实现 shallowReactive / shallowReadonly 函数

我们还希望面对一个嵌套对象,我们不想他内部的属性对象也变成一个 响应式/只读 的代理对象,在 vue2 里我们可以利用 object.freeze 来使得内部对象无法被数据劫持。

那在 vue3 我们要怎么实现呢?其实基于上边的代码,我们只需要停止对内部对象做递归即可。

那我们需要创建对 createGetter 做修改,并利用 Object.assign(我们已经语义化成了 extend) 来实现对应的 handlers :

// 分别创建getter
const shallowReactiveGet = createGetter(false, true)
const shallowReadonlyGet = createGetter(true, true)
// 注意这里入参增加了 shallow 参数默认为false
function createGetter(isReadonly = false, shallow = false) {
  return function get(target, key) {
    if (key === ReactiveFlags.IS_REACTIVE) {
      return !isReadonly
    } else if (key === ReactiveFlags.IS_READONLY) {
      return isReadonly
    }
    const res = Reflect.get(target, key)
    // 这里就是关键了!!!!如果不需要深度响应式 那么直接返回 res
    if (shallow) return res
    // 如果 res 是对象 那么还需要深层次的实现响应式 
    if (isObject(res)) {
      return isReadonly ? readonly(res) : reactive(res)
    }
    if (!isReadonly) {
      track(target, key)
    }
    return res
  }
}
// reactive 的 set 还是不变,只是修改 getter  extend 其实就是 Object.assign
export const shallowReactiveHandlers = extend({}, mutableHandlers, {
  get: shallowReactiveGet
})
// readonly 的 set 还是不变,只是修改 getter  extend 其实就是 Object.assign
export const shallowReadonlyHandlers = extend({}, readonlyHandlers, {
  get: shallowReadonlyGet
})
复制代码

接着我们需要创建 shallowReactive 、 shallowReadonly 方法,使用这两个handler:

export function shallowReactive(raw) {
  return createActiveObject(raw, shallowReactiveHandlers)
}

export function shallowReadonly(raw) {
  return createActiveObject(raw, shallowReadonlyHandlers)
}
复制代码

ok! 大功告成,这里的实现也并不复杂。让我们接着往下看!

effect 函数

effect 函数我们也称作 副作用函数

顾名思义,就是当 effect 函数被执行的时候它可能会直接或者间接的影响其他函数的执行,这就是产生了副作用

effect 函数 初步实现

当我们调用 effect 函数的时候 需要传入一个 执行函数 fn

内部生成一个 ReactiveEffect 实例后执行 这个 fn

从这个命名上我们也能知道 reactive 和 effect 的关系是十分密切的

let activeEffect // 保存当前正在执行的 effect
class ReactiveEffect{
  private _fn: any
  constructor(fn) {
    this._fn = fn
  }
  run() {
    // 收集当前的 effect 实例 赋值到全局变量 activeEffect
    activeEffect = this
    // 执行方法并返回
    return this._fn()
  }
}
export function effect(fn) {
  // 生成 ReactiveEffect 实例
  const _effect = new ReactiveEffect(fn)
  // 执行方法
  _effect.run()
}
复制代码

关键点就是我们的这个 activeEffect 变量啦,当我们调用 effect函数 的时候他会生成一个 ReactiveEffect 实例,并保存到全局

这里我们可以回到 reactive 的依赖收集以及触发依赖[3]

effect 函数优化 ———— 调用 effect 的时候应该返回当前的执行函数

我们希望 调用 effect 的时候我们也能得到这个 effect 函数,我们手动执行 传入的 fn

附 jest 测试用例:

it('should return runner when call effect', () => {
  let age = 10
  // runner 拿到 effect 创建的 runner
  const runner = effect(() => {
    age++
    return 'Age'
  })
  // 调用 effect 的时候 age++ 了所以应该是 11
  expect(age).toBe(11)
  // r 执行 runner 这时候 age又 ++ 了 所以应该是 12 了
  const r = runner()
  expect(age).toBe(12)
  // r 自身拿到 return 的 'Age'
  expect(r).toBe('Age')
});
复制代码

因此我们需要对我们的 effect 函数做出以下修改:

export function effect(fn, options: any = {}) {
  const _effect = new ReactiveEffect(fn)
  _effect.run()
  // run 函数内部涉及 activeEffect 的赋值 (activeEffect = this) 所以这里应该bind一下
  const runner: any = _effect.run.bind(_effect)
  runner.effect = _effect
  return runner
}
复制代码

这样子之后我们调用 effect 之后就能拿到这个 runner 对应的其实就是 ReactiveEffect 实例 的 run 方法

effect 函数优化 ———— scheduler函数选项

我们希望 effect 可以传入 一个 scheduler函数选项

当传入了 scheduler 的时候,

  1. 只有首次执行 effect 的时候会执行 fn
  2. 后续当响应式对象 触发 set 的时候 fn 不会执行 而是执行 scheduler
  3. 当执行 runner 的时候 会再次执行 fn

附上相应的 jest 测试用例:

/**
 * 1. 通过 effect 的第二个参数给定的 一个 scheduler 的 fn
 * 2. effect 第一次执行的时候 还会执行 fn
 * 3. 当响应式对象 触发 set 的时候 fn 不会执行 而是执行 scheduler
 * 4. 如果当执行 runner 的时候 会再次执行 fn
 */
it('scheduler', () => {
  let dummy
  let run: any
  // 定义一个 scheduler 函数,给 全局变量 run 赋值拿到 runner
  const scheduler = jest.fn(() => {
    run = runner
  })
  // 创建响应式对象
  const obj =  reactive({ age: 13 })
  // 执行 effect 并拿到 runner,这里 effect 我们传入了 scheduler函数 ,它被包裹在一个对象内部
  const runner = effect(
    () => {
      dummy = obj.age
    },
    { scheduler }
  )
  // 断言 scheduler 一开始不会被调用
  expect(scheduler).not.toHaveBeenCalled()
  expect(dummy).toBe(13)
  obj.age++
  // age++ 不会去调用 dummy = obj.age 而是应该调用 传入的 scheduler 
  expect(scheduler).toHaveBeenCalledTimes(1)
  expect(dummy).toBe(13)
  // 调用了 scheduler 后 run 拿到 runner,这时候调用 run 才去执行 dummy = obj.age 
  run()
  expect(dummy).toBe(14)
});
复制代码

为此,我们需要对我们的 effect函数 、 ReactiveEffect类 、trigger函数 做出修改:

class ReactiveEffect{
  private _fn: any
  // 接收可选参数 scheduler 并设置为 public
  constructor(fn, public scheduler?) {
    this._fn = fn
  }
  run() {
    // ...没有变化
  }
}
// 注意这里,我们让 effect 接收第二个参数 options
export function effect(fn, options: any = {}) {
  // 这里创建 ReactiveEffect 实例的时候 我们把 scheduler 带上了
  const _effect = new ReactiveEffect(fn, options.scheduler)
  _effect.run()
  const runner: any = _effect.run.bind(_effect)
  runner.effect = _effect
  return runner
}
export function trigger(target, key) {
  let depsMap = targetMap.get(target)
  let dep = depsMap.get(key)
  for (const effect of dep) {
    // 这个 effect 有 scheduler 的时候我们就执行 scheduler 不执行 run 了
    if (effect.scheduler) {
      effect.scheduler()
    } else {
      effect.run()
    }
  }
}
复制代码

ok,这样我们就能实现我们的要求了,重复一遍!!

传入 scheduler函数 选项时,effect 只有在初始化的时候执行 fn,当 set(trigger触发) 的时候,执行的是 scheduler函数,而 fn 我们可以通过 runner 手动执行

effect 函数优化 ———— stop 方法 以及 onStop hooks

我们又有新的需求了!!!

我们希望有一个 stop 方法,当我们调用 stop方法时 响应式对象属性被修改 不会触发 执行依赖(run)的动作,原传入的依赖还是要可以手动执行(runner执行)的

我们还希望每次执行完 stop 之后会触发一个 onStop 的hooks,这个 onStop 作为 effect 的 options 之一

先看测试用例:

it('stop', () => {
  let dummy
  const obj =  reactive({ prop: 1 })
  const runner = effect(() => {
    dummy = obj.prop
  })
  obj.prop = 2
  expect(dummy).toBe(2)
  // stop 了 runner 去修改响应式的时候 就不应该触发了
  stop(runner)
  obj.prop = 3
  expect(dummy).toBe(2)
  // 被 stop 了的 runner 仍然可以手动执行
  runner()
  expect(dummy).toBe(3)
  obj.prop++
  // 上边 如果换成  obj.prop++ 那么其实是不通过的
  // 原因:obj.prop++ 会先访问 obj.prop 属性 (obj.prop = obj.prop + 1),这就会触发 get ,又去收集依赖了
  // expect(dummy).toBe(4)
  // // 由于依赖被重新收集,所以又变成响应式了
  // obj.prop = 5
  // expect(dummy).toBe(5)
  // 调了 stop 的话在 track 存入依赖前 return 后
  expect(dummy).toBe(3)
  runner()
  expect(dummy).toBe(4)
});
/**
 * onStop hooks,在调用 stop 方法后应该被执行
 */
it('onStop', () => {
  const obj =  reactive({ foo: 1 })
  const onStop = jest.fn()
  let dummy
  const runner = effect(
    () => {
      dummy = obj.foo
    }, 
    { onStop }
  )
  stop(runner)
  expect(onStop).toBeCalledTimes(1)
});
复制代码

为此,我们先整理下我们要做的事:

  1. 新增 stop 方法
  2. ReactiveEffect 实例应该要能接收 onStop 函数,如果用户传了 在 stop方法执行后应该调用

注意前方高能!!!!!

修改 effect函数 、 ReactiveEffect类 、track函数,新增 stop方法 、 cleanupEffect方法 、 isTracking方法 ,新增 shouldTrack 全局变量 如下:

let shouldTrack // 当前这个 实例需不需要 收集依赖
class ReactiveEffect{
  private _fn: any
  deps = [] // 反向收集 set 数组
  active = true // 是否清空过
  onStop?: () => void // onStop hooks
  constructor(fn, public scheduler?) {
    this._fn = fn
  }
  run() {
    // 被 stop 了直接执行 fn 不需要再次去收集依赖 shouldTrack 为false
    if (!this.active) return this._fn()
    // 收集
    activeEffect = this
    shouldTrack = true
    const result = this._fn()
    // 重置
    shouldTrack = false
    return result
  }
  stop() {
    // 利用 active 来缓存stop结果,这样就不需要每次都去 cleanupEffect
    if (this.active) {
      cleanupEffect(this)
      this.active = false
    }
    // 如果传入了 onStop 就执行
    if (this.onStop) this.onStop()
  }
}
// 清除 对应 set 下的当前实例
function cleanupEffect(effect) {
  effect.deps.forEach((dep: any) => {
    dep.delete(effect)
  })
  effect.deps.length = 0
}

export function track(target, key) {
  // 没有 effect || 不应该 track  直接 return
  if (!isTracking()) return
  // ... 中间没有变化
  // 已经在 dep 中就不用 add 了
  if (dep.has(activeEffect)) return
  dep.add(activeEffect) // 把对应的 effect 实例加入 set 里
  activeEffect.deps.push(dep) // 实例的 deps 属性收集当前的 set
}
// 判断当前的 实例 需不需要收集依赖
function isTracking() { 
  return shouldTrack && activeEffect !== undefined
}

export function effect(fn, options: any = {}) {
  const _effect = new ReactiveEffect(fn, options.scheduler)
  // options 处理
  // _effect.onStop = options.onStop 太笨了,考虑后面还有其他option 我们可以使用 Object.assign
  // extned 语义化处理 options : const extend = Object.assign
  // 实际上是调用 Object.assign(_effect, options)
  extend(_effect, options)
  // ... 后面没有变化
}
// 新增 stop 方法
export function stop(runner) {
  runner.effect.stop()
}
复制代码

OK。我们可以看到这一次我们是加了很多东西,不要怕,我们来重点解释一下。

ReactiveEffect类中的 deps数组 属性 当我们把 ReactiveEffect实例 加入到 对应 key 的 Set集合中时,我们把这个 Set 给存储到这个实例的 deps中,方便我们在 cleanupEffect方法 中清除当前的 实例

shouldTrack全局变量保证了我们在触发到 get(track方法) 的时候能够知道当前应不应该收集依赖,我们重点看一下测试用例 stop中,当我们访问 obj.prop++ 的时候,实际上它执行的是 obj.prop = obj.prop + 1,那么这里他是会触发到响应对象(obj, prop)的 get 方法的。这样子我们就能保证 实例被 stop 后即使触发 get 也不会再去收集依赖了

isTracking方法 实际上是对 shouldTrack 和 activeEffect 做一个判断封装

问题:Proxy是什么?Reflect是什么?为什么要用Reflect?

Proxy 可以创建一个代理对象,实现对其他对象的代理(注意只能代理对象,无法对原始值代理)

代理:对一个对象基本语义代理,允许我们拦截并重新定义对一个对象的基本操作

Reflect 是一个全局对象,Relfect 下的方法与 Proxy 的拦截器方法名字相同 (换句话说,你能在 Proxy 的拦截器中找到的方法,在 Relfect 中都能找到同名函数)

Reflect 的功能:

提供了一个访问对象属性的默认行为,实际上以下的行为是等价的:

const obj = { foo: 1 }

// 直接读取 
console.log(obj.foo) // 1
// 使用 Reflect.get 读取
console.log(Reflect.get(obj, 'foo'))
复制代码

那么新的问题来了:既然他们等价,我干嘛还要用 Reflect 呢?直接 obj.foo 不香吗?

实际上 Reflect 的函数可以接收第三个参数,即函数调用过程中的 this

比如:

const obj = { foo: 1 }
console.log(Reflect.get(obj, 'foo', { foo: 2 })) // 输出的是 2 而不是 1
复制代码

当然 Reflect 还有其他的功能特性:JS 标注内置对象--Reflect[4]

我们这里暂时只关心这个,因为它与响应式数据的实现密切相关。

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8