序
博客代码已上传至github
点击这里 即可访问
另提供:完整代码(ts+rollup)和视频教程
有一次,我去参加一个前端开发的面试,本来聊的一直都还挺好,心里觉得这次肯定十拿九稳。
直到面试官说:
那你说一下: 在 vue 中,通过 ref 声明的响应式数据,为什么需要通过 .value 来访问数据呢?
然后这次面试就凉了...
所以回来之后,我痛定思痛决定把 vue 响应式模块 好好的恶补一下...
前言
上一篇博客中,咱们聊了 响应式设计原则,凭借它可以应对部分的初级、中级的前端工程师面试。
但是如果面试官对问题更加深入(或许你可以趁机多要点钱)的时候,基本的设计原则可能就满足不了你的需求了。
那么这个时候,这篇博客或许可以帮助到你~~~~
本篇博客的核心主题是:手写响应式模块,主要涉及到以下几部分代码:
- effect 模块
- reactive 模块
- ref 模块
- 测试实例
博客中涉及到的所有代码,都可以 点击这里 进行访问。
正文开始
effect 模块
咱们先来看 effect
模块
啥是 effect ?
在 vue
中存在一个effect
模块,它的作用是:收集并且触发指定的方法。
啥意思呢?
我们来举个例子:
现在有一个数据 name = '张三',我们把它渲染到一个 div 中。
所以我们可以执行如下代码:
document.querySelector('#app').innerText = name
此时,当 name 的值发生变化时,在响应性的逻辑之下,div 中展示的内容需要同步跟随变化。
也就是再次执行:document.querySelector('#app').innerText = name
在上面的例子中,重复被执行的 document.querySelector('#app').innerText = name
代码,就是被:收集并且触发指定的方法,也就是 effect 方法
effect 咋用 ?
想要知道effect
模块如何进行实现。咱首先得知道effect
是怎么使用的。
我们可以通过如下方法去使用effect
模块:
effect(() => {
document.querySelector('#app').innerText = name
})
在上面的代码中,effect
接收一个回调函数作为参数。
根据咱们上面所说:回调方法存在两个执行时机:
- 最初执行时
- 数据变化时
那也就是说,在咱们封装effect
模块时,必须要保证它具有这两个执行时机。
封装 effect
咱们先列出effect
的代码,然后再逐步分析:
// 当前需要执行的 effect
let activeEffect
/**
* 2. 看完 effect 方法之后再看这里
* 响应性触发依赖时的执行类
*/
class ReactiveEffect {
constructor(fn) {
this.fn = fn
}
run() {
// 为 activeEffect 赋值
activeEffect = this
// 执行 fn 函数
return this.fn()
}
}
/**
* 1. 代码先看这里
* effect 函数
* @param fn 执行方法
* @returns 以 ReactiveEffect 实例为 this 的执行函数
*/
function effect(fn) {
// 生成 ReactiveEffect 实例
const _effect = new ReactiveEffect(fn)
// 执行 run 函数
_effect.run()
}
在上面的代码中,咱们主要做了两件事情:
1. effect 函数
- 咱们首先创建了一个
effect
函数,该函数接收一个回调函数作为参数 - 在该函数中,创建了
ReactiveEffect
的实例_effect
- 并且触发了
_effect
的run 函数
- 那么这个
run
函数做了什么事情呢?
2. ReactiveEffect 类
ReactiveEffect
的构造函数中,把fn
赋值给了this.fn
- 提供了
run
函数 3.run
函数主要做了两件事情:- 为全局变量
activeEffect
赋值 - 执行
this.fn(即:effect 的回调函数)
- 为全局变量
effect 代码总结
目前,咱们已经创建好了effect
模块,并且利用effect
做了两件事情:
effect
函数接收回调函数ReactiveEffect
实例可以调用run
函数,从而:- 为
activeEffect
变量赋值 - 执行回调函数
- 为
reactive 模块
vue
中提供 reactive 方法,可以用来创建响应性数据(仅限复杂数据类型)
那么接下来,咱就来看看这个reactive
是咋弄的。
响应性咋弄?
每一个变量通常都有两个行为:
getter
行为:访问变量的值时就会触发getter
行为。比如:const name = person.name
,那么此时就会触发person
的getter
行为。setter
行为:为变量赋值时就会触发setter
行为。比如person.name = '张三'
,那么此时就会触发person
的setter
行为。
而我们想要实现一个响应性,则必须要从这两个行为入手。
在 该博客中 咱们已经知道:通过 proxy 可以监听对象的 getter 和 setter 行为。
咱们需要在:
getter
行为时,保存被执行的effect 回调函数
,以便在setter(数据改变)
行为时重新执行。这个过程被叫做:依赖收集。setter
行为时,执行保存的effect 回调函数
,以便修改视图。这个过程被叫做:触发依赖。
这两步就是响应性的核心逻辑。
反推 reactive 实现
想要实现 reactive
模块,那么首先得先明确reactive
函数的用法,然后根据用法反推实现。
根据 官方文档 ,reactive
用法非常简单:
const obj = reactive({
name: '张三'
})
console.log(obj) // proxy 实例
reactive
接收一个对象,我们可以把该对象叫做target
,然后得到一个返回值 obj
。
打印obj
,可以发现得到的是一个proxy 实例。即:代理对象。
那么由此可知:reactive 方法内部必然生成了 proxy 实例,并把它进行了返回。
那么生成proxy
实例的目的是什么呢?
咱知道proxy
是可以监听getter、setter
行为的,那么结合咱们上面所说,生成proxy
实例的目的一定是:为了 监听 getter、setter行为,以便执行依赖收集和依赖触发。
那么依赖收集和依赖触发的过程是怎么做的呢? 这里大家可以看下我之前写的这篇博客,它详细说明了整个依赖收集和依赖触发过程。
那么咱们来总结下反推的结果:
reactive
方法:接收一个对象target
reactive
方法:生成了 proxy 实例,并把它进行了返回reactive
方法:利用proxy
监听target
的getter、setter
行为,以便执行依赖收集和依赖触发
封装 reactive 模块
reactive
模块的代码相对比较多,咱们分块去看。
reactive 方法封装
首先咱们来看reactive
方法的封装,代码如下:
function reactive(target) {
const proxy = new Proxy(target, {
get: (target, key, receiver) => {
// 利用 Reflect 得到返回值,该代码可以简单理解为:target.key
const res = Reflect.get(target, key, receiver)
// 收集依赖
track(target, key)
return res
},
set: (target, key, value, receiver) => {
// 利用 Reflect.set 设置新值,该代码可以简单理解为:target.key = value
const result = Reflect.set(target, key, value, receiver)
// 触发依赖
trigger(target, key)
return result
}
})
return proxy
}
在上面的代码中,咱们创建了一个reactive
方法,在该方法中做了如下实现:
- 创建了
proxy
实例,并进行了返回 - 监听了
getter
和setter
行为:- 在
getter
行为时:触发了track
方法,进行依赖收集 - 在
setter
行为时:触发了trigger
方法,进行依赖触发
- 在
然后咱们来看下 依赖收集 track
的过程。
依赖收集:track 封装
所谓的依赖收集,说白了就是:把 activeEffect(effect 方法的参数)保存起来。
至于保存的方式在 这里<Vue 3 深入响应式原理 - 聊一聊响应式构建的那些经历> 进行了详细描述。
据此,咱们可以得到如下代码:
/**
* 收集所有依赖的 WeakMap 实例:
* 1. `key`:响应性对象
* 2. `value`:`Map` 对象
* 1. `key`:响应性对象的指定属性
* 2. `value`:指定对象的指定属性的 执行函数
*/
const targetMap = new WeakMap()
/**
* 收集依赖
*/
function track(target, key) {
// 如果当前不存在执行函数,则直接 return
if (!activeEffect) return
// 尝试从 targetMap 中,根据 target 获取 map
let depsMap = targetMap.get(target)
// 如果获取到的 map 不存在,则生成新的 map 对象,并把该对象赋值给对应的 value
if (!depsMap) {
targetMap.set(target, (depsMap = new Map()))
}
// 获取指定 key 的 dep
let dep = depsMap.get(key)
// 如果 dep 不存在,则生成一个新的 dep,并放入到 depsMap 中
if (!dep) {
depsMap.set(key, (dep = new Set()))
}
// 把所有的 activeEffect 方法加入到 dep 中
dep.add(activeEffect)
}
在上面的代码中,我们创建了track
函数,利用WeaKMap对activeEffect
进行了保存。
依赖触发:trigger 封装
收集依赖的目的是为了:触发依赖。
所以下面我们就来看看触发依赖的逻辑。
咱们知道,触发依赖会通过trigger
函数进行:
/**
* 触发依赖
*/
function trigger(target, key) {
// 依据 target 获取存储的 map 实例
const depsMap = targetMap.get(target)
// 如果 map 不存在,则直接 return
if (!depsMap) {
return
}
// 依据指定的 key,获取 dep 实例
let dep = depsMap.get(key)
// dep 不存在则直接 return
if (!dep) {
return
}
// 触发 dep
triggerEffects(dep)
}
/**
* 依次触发 dep 中保存的依赖
*/
function triggerEffects(dep) {
// 把 dep 构建为一个数组
const effects = Array.isArray(dep) ? dep : [...dep]
// 依次触发
for (const effect of effects) {
effect.run()
}
}
在上面的代码中,咱们创建了trigger
函数,它会:
- 从
WeakMap
中,获取保存的activityEffect
实例,命名为effect
- 然后执行
effect.run
,本质上是触发了effect 方法的参数
那么至此,咱们就可以在:数据发生变化时,重新触发effect
,以达到响应式的目的。
试一下?
好不容易写好了reactive
方法,怎么能不试试呢?
const obj = reactive({
name: '张三'
})
// 调用 effect 方法
effect(() => {
document.querySelector('#app').innerText = obj.name
})
setTimeout(() => {
obj.name = '李四'
}, 2000);
创建如上代码,跑一跑吧~~~~~
ref 模块
reactive
模块是非常好的,但是存在一个问题,那就是:reactive 只能构建复杂数据类型的响应性。
出现这个问题的原因本质上是因为:proxy 只能监听复杂数据类型的 getter 和 setter 行为。
那简单数据类型咋办呢?
vue
为我们提供了ref 用来解决这个问题。
ref 咋弄的?
ref
方法的实现非常有意思,它利用了get 和set 标记语法。
咱们知道,reactive
之所以只能实现复杂数据类型响应性,是因为:proxy 无法监听简单数据类型的 getter 和 setter。
那么换句话来说:只要可以监听简单数据类型的 getter 和 setter,那么就可以实现简单数据的响应性。
反推 ref 模块
和实现reactive
时一样,想要实现 ref
模块,那么首先得先明确ref
函数的用法,然后根据用法反推实现。
根据 ref ,ref
使用起来和reactive
差不多:
const name = ref('张三')
console.log(name) // RefImpl 实例
ref
方法接收一个数据(可以是简单数据类型,也可以是复杂数据类型),我们可以把这个数据叫做value
,然后得到一个返回值name
打印name (不要 .value 哦~~)
,可以发现得到的是一个 RefImpl 实例。
那么由此可知:vue 内部必然存在一个 RefImpl 类,并且在 ref 中对它进行了实例化并返回。
那么生成RefImpl
实例的目的又是什么呢?
结合上一趴所说,RefImpl
实例一定是:用来监听 setter 和 getter 的。
想要访问RefImpl
上的真实数据,那么必须通过.value
进行访问,结合 get 和set 标记语法的作用,咱们可以大胆猜测下:.value 会不会是一个方法的调用?
当我们执行name.value = xxx
时,本质上是触发了set value(newVal) {}
方法。
当我们执行name.value
时,本质上是触发了get value() {}
方法。
那么我们来总结下反推的结果:
封装 ref 模块
明确好了以上内容之后,我们可以创建如下 ref
代码:
// 2. 先看 ref 方法,再看这里
class RefImpl {
_value
dep
constructor(value) {
this._value = value
}
/**
* get 语法将对象属性绑定到:查询该属性时,将被调用的函数。
* 即:xxx.value 时触发该函数
*/
get value() {
// 收集依赖
if (activeEffect) {
const dep = ref.dep || (ref.dep = new Set())
dep.add(activeEffect)
}
return this._value
}
/**
* set 语法将对象属性绑定到:赋值该属性时,将被调用的函数。
* 即:xxx.value = xxx 时触发该函数
*/
set value(newVal) {
// 更新数据
this._value = newVal
// 触发依赖
if (ref.dep) {
triggerEffects(ref.dep)
}
}
}
/**
* 1. 先看这里~~~~~~~~~~~~~~~~~~~
* ref 函数
* @param value unknown
*/
function ref(value) {
return new RefImpl(value)
}
在上面的代码中,我们主要做了两块事情:
- 在
ref
方法中,返回了RefImpl
的实例 - 在
RefImpl
中,通过get
和set
监听了value
方法的getter
和setter
行为,以便执行依赖收集和依赖触发
试一下?
创建个测试实例,试一下ref
方法:
const name = ref('张三')
// 调用 effect 方法
effect(() => {
document.querySelector('#app').innerText = name.value
})
setTimeout(() => {
name.value = '李四'
}, 2000);
代码成功运行,这可真是~~~~~~
总结
这篇博客是咱们Vue 3.2 源码系列的第三趴了。
对了,还记不记得,一开始面试官问的问题?
在 vue 中,通过 ref 声明的响应式数据,为什么需要通过 .value 来访问数据呢?
现在根据咱们所学,你能回答出来了吗?