前言
本篇解析参阅 vue3源码、崔大的mini-vue、霍春阳大佬的《Vuejs设计与实现》尽可能记录我的Vue3源码阅读学习过程。我会结合自己的思考,提出问题,找到答案,附在每一篇的底部。希望大家能在我的文章中也能一起学习,一起进步,有 get 到东西的可以给作者一个小小的赞作为鼓励吗?谢谢大家!
手写简易vue3 setup环境 && reactive函数 && effect函数
setup环境
npm init
命令生成 package.json
当前项目主要采用 ts
来编写,用 jest
来做单元测试
说明:ts 会使用 any 类型,希望能把重点放在 vue3 的实现原理,如需要 会在后面做修改补充
所以需要安装如下的依赖包:
- jest (核心包)
- typescript (核心包)
- @types/jest (jest 语法 ts 解释)
- ts-jest (预处理 ts 的 jest 预制)
- @babel/core (babel 核心)
- @babel/preset-env (perset-env 预设)
- @babel/preset-typescript (babel ts 预设)
- 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
重点介绍以下配置:
代码语言:javascript复制"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 函数的初步实现
初步实现:
代码语言:javascript复制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 有哪些依赖
这里我们采用的方法是:
- 利用 全局 WeakMap 来保存所有对象
- 利用 Map 来保存对象中所有 key
- 利用 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
我们现在有一个需求,响应式对象应该是只读的,不允许修改
根据需求,我们不难得到修改方案:
- get 方法没必要收集依赖了
- set 方法应该抛出一个警告,不允许修改
那么我们可以新增 readonly 函数,返回一个 和 reactive 不一样的代理对象:
代码语言:javascript复制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
代码语言:javascript复制export function createActiveObject(raw: any, baseHandlers) {
return new Proxy(raw, baseHandlers)
}
复制代码
实现 createSetter 和 createGetter 方法并分别导出 handlers
代码语言:javascript复制// 缓存 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
}
}
复制代码
那么封装完成后,我们去生成响应式的代理对象/只读的代理对象就可以调用以下方法:
代码语言:javascript复制export function reactive(raw) {
return createActiveObject(raw, mutableHandlers)
}
export function readonly(raw) {
return createActiveObject(raw, readonlyHandlers)
}
复制代码
是不是清爽了非常多呢!!
实现 isReactive / isReadonly 方法
我们现在还需要两个方法 分别用来判断当前的这个对象是不是 响应式/只读 对象
。
那么我们需要增加 ReactiveFlag元组 、 isReactive方法 、 isReadonly方法,修改 createGetter方法:
代码语言:javascript复制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:
代码语言:javascript复制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 测试用例:
代码语言:javascript复制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 函数做出以下修改:
代码语言:javascript复制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 的时候,
- 只有首次执行 effect 的时候会执行 fn
- 后续当响应式对象 触发 set 的时候 fn 不会执行 而是执行 scheduler
- 当执行 runner 的时候 会再次执行 fn
附上相应的 jest 测试用例:
代码语言:javascript复制/**
* 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函数 做出修改:
代码语言:javascript复制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 之一
先看测试用例:
代码语言:javascript复制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)
});
复制代码
为此,我们先整理下我们要做的事:
- 新增 stop 方法
- ReactiveEffect 实例应该要能接收 onStop 函数,如果用户传了 在 stop方法执行后应该调用
注意前方高能!!!!!
修改 effect函数 、 ReactiveEffect类 、track函数,新增 stop方法 、 cleanupEffect方法 、 isTracking方法 ,新增 shouldTrack 全局变量 如下:
代码语言:javascript复制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 的功能:
提供了一个访问对象属性的默认行为,实际上以下的行为是等价的:
代码语言:javascript复制const obj = { foo: 1 }
// 直接读取
console.log(obj.foo) // 1
// 使用 Reflect.get 读取
console.log(Reflect.get(obj, 'foo'))
复制代码
那么新的问题来了:既然他们等价,我干嘛还要用 Reflect 呢?直接 obj.foo 不香吗?
实际上 Reflect 的函数可以接收第三个参数,即函数调用过程中的 this
比如:
代码语言:javascript复制const obj = { foo: 1 }
console.log(Reflect.get(obj, 'foo', { foo: 2 })) // 输出的是 2 而不是 1
复制代码
当然 Reflect 还有其他的功能特性:JS 标注内置对象--Reflect[4]
我们这里暂时只关心这个,因为它与响应式数据的实现密切相关。