序
博客代码已上传至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方法:接收一个对象targetreactive方法:生成了 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 来访问数据呢?
现在根据咱们所学,你能回答出来了吗?