掘金总是有人抵触ref,抵触reactive,说ref有个value很丑,reactive有类型限制等等,其实没什么好吵的,爱用哪个用哪个。当一个组件响应式变量很多的时候我们其实用reactive更好,不多用ref更好,这仅仅是站在减轻代码量的角度。像这种话题每次春招之前都是个热点话题,同样,赶在春招前我也来沾沾这个热点,带大家从源码看看二者的区别
当我们需要在vue中创建响应式变量时,就会用上
ref或者reactive,在用法上,reactive将引用类型变成响应式,ref通常用于将原始类型变成响应式,但是ref也能把引用类型变成响应式
reactive源码
我们先看下reactive,这里一个情景,点击按钮累加
xml
<template>
<div>
<p>{{state.count}}</p>
<button @click="() => state.msg++">add</button>
</div>
</template>
<script setup>
import { reactive } from 'vue'
const state = reactive({
count: 1
})
</script>
<style lang="css" scoped>
</style>
v8引擎在把这个变量进行重新赋值的操作,但是更改值会让浏览器重新渲染页面。
好,我们接下来自己手搓一个reactive,引入自己写的reactive源码也要能实现上面的效果,也就是说可以驱使浏览器进行二次渲染并且不刷新页面
我们写的vue代码会被vue源码中的编译器给编译成html代码,然后拿给浏览器去渲染,然后我们写的代码发生了变更,vue需要一个监听器去实时监听是否发生了变更,一旦变更就调用vue编译器去编译我们的代码再送给浏览器渲染,当js发生变更,浏览器默认就会重新渲染。
所以整个响应式原理,其核心就是监听器
接下来我就自己手搓一个reactive,来到src新建一个reactivity目录,这里面放所有关于响应式的代码
在reactivity下新建一个reactive.js文件,我们在这里写reactive源码
reactive将引用类型变成响应式,这个任务交给createReactiveObject来完成
javascript
export function reactive(target) { // 将target变成响应式
return createReactiveObject(target)
}
createReactiveObject抛不抛出都可以,但是yyx考虑得更多,可能需要拿到别的地方用,这里也抛出了
接受的参数必须是对象
javascript
export function createReactiveObject(target, proxyMap, proxyHandlers) { // 创建响应式的函数
// 判断target是不是一个引用类型
if (typeof target !== 'object' || target === null) { // 不是对象就不给操作
return target
}
}
既然reactive是将一个引用类型变成响应式,那么就需要先进行判断下数据类型。如何判断一个变量是否为引用类型,可以直接用typeOf,typeof可以判断除null之外的原始类型,另外还可以判断function
我们可以看下yyx这里是怎么写的,yyx专门写了个工具函数用来判断对象

yyx也是这么写的

避免二次响应式
比如下面的代码
ini
const state = reactive({
count: 1
})
const state2 = reactive(state)
一个对象已经响应式了,为何再次响应式,这是多此一举
所以我们需要避免有小可爱这样写代码
如何判断一个对象是否曾经被代理过?我们可以用一个参数来存放被代理过的对象,这里用WeakMap创建这个参数,然后作为实参传到函数createReactiveObject中去
WeakMap是弱化版的map,而map又是es6提供的类数组,它是个强版本的对象,其key可以是任意类型,WeakMap对内存的回收会更友好13. Set 和 Map 数据结构 - WeakMap - 《阮一峰 ECMAScript 6 (ES6) 标准入门教程 第三版》 - 书栈网 · BookStack
javascript
export const reactiveMap = new WeakMap() // 保存被代理过的对象
export function createReactiveObject(target, proxyMap) { // 创建响应式的函数
// 判断target是不是一个引用类型
if (typeof target !== 'object' || target === null) { // 不是对象就不给操作
return target
}
// 该对象是否已经被代理过(已经是响应式对象)
const existingProxy = proxyMap.get(target) // WeakMap读取值用get方法
if (existingProxy) {
return existingProxy
}
}
接下来才是核心代码了,也就是将target变成响应式
将target变成响应式
将对象变成响应式主要依靠es的Proxy方法,变成响应式就是代理它
proxy就是拦截一个对象,然后对对象进行一系列操作
new Proxy接收两个参数,第一个参数是你要代理的对象,当这个对象被读取到时,第二个参数中的get就会被触发,修改时set函数会被触发,判断是否有值时,has会被触发......总共有13个方法

所以这里核心代码如下,下面的proxy就是代理后的对象
javascript
const proxy = new Proxy(target, {
get: function () {
// ......
},
// ......
})
第二个参数中的13个函数全部写这里会很丑,我就拿出去写,作为参数传进来,这个参数的作用就是当target被读值,设置值,判断值等等操作时会触发对应的一系列函数
参数二的这些函数我放入reactivity/baseHandlers.js中,如下
arduino
export const mutableHandlers = {
get,
set
}
然后reactive.js中引入,作为实参传入。
好了,已经有了代理后的对象proxy了,那么就需要将其存入到WeakMap中,其方法为set,最终reactive.js完整代码如下
javascript
import { mutableHandlers } from './baseHandlers.js'
// 保存被代理过的对象
export const reactiveMap = new WeakMap() // new Map() // new WeakMap 对内存的回收更加友好
export function reactive(target) { // 将target变成响应式
return createReactiveObject(target, reactiveMap, mutableHandlers)
}
export function createReactiveObject(target, proxyMap, proxyHandlers) { // 创建响应式的函数
// 判断target是不是一个引用类型
if (typeof target !== 'object' || target === null) { // 不是对象就不给操作
return target
}
// 该对象是否已经被代理过(已经是响应式对象)
const existingProxy = proxyMap.get(target)
if (existingProxy) {
return existingProxy
}
// 执行代理操作(将target处理成响应式)
const proxy = new Proxy(target, proxyHandlers) // 第二个参数的作用:当target被读取值,设置值,判断值等等操作时会触发的函数
// 往 proxyMap 增加 proxy, 把已经代理过的对象缓存起来
proxyMap.set(target, proxy)
return proxy
}
代码写到这里,其核心代码就是一个
new Proxy一行代码......其实这里你也就可以明白为什么
reactive是能代理引用类型了,因为proxy只能代理引用类型
好了,接下来就来写,对象如何被代理,完善baseHandlers.js
baseHandlers.js
vue响应式的效果其实就是读值,修改值,所以仅需要完善13个函数中的get和set即可
javascript
export const mutableHandlers = {
get: function () {
console.log('读值');
},
set: function () {
console.log('修改值');
}
}
我们可以先测试下,在两个函数中放个打印,然后写个测试文件去修改值,看是否能够打印,text.html如下
xml
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<script type="module">
// ESModule语法,这样浏览器才能读懂模块化
import { reactive } from './reactive.js'
const state = reactive({
name: 'dolphin',
age: 18
})
setInterval(() => {
state.age++ // 赋值前会读值,因此按道理一定打印两个值
}, 2000)
</script>
</body>
</html>

没有问题,这里报错是因为读值读不到,因为get没有返回值给你读,另外也无法作修改,set还没写
get中有三个参数,分别为目标对象,目标对象修改的key和代理后的对象
vbnet
get: function (target, key, receiver) {}
我们需要读到state里面的age,也就是对象的key,所以就是state[key]
因此get如下
vbnet
export const mutableHandlers = {
get: function (target, key, receiver) {
return target[key]
}
}
set中有四个参数,原对象,原对象修改的key,key对应的value值和代理后的对象
vbnet
set: function (target, key, value, receiver) {}
想要实现age++,就必须要实现修改,修改之后还需要将值赋回去
所以我们可能会加上个target[key] = value
但是实现响应式的核心不是仅仅告诉v8,我们的值变了,还需要浏览器去更新试图
其实读取值这里更好是用es6新方法Reflect
reflect其实就是新版本的Object,为何要新搞一个reflect,这是因为Object身上已经有很多属性了,更新到es6的时候还想着给Object身上挂方法,考虑到维护性和优雅性,包括es6以及es6之后想要给Object新增的方法都统统挂在了reflect身上了当然,新搞一个reflect的原因还有就是原属于
Object身上的方法有些是由小瑕疵的,诸如Object.defineProperty的报错问题,而Reflect.defineProperty不会出现程序性问题
Reflect有个get方法,就是用来读取对象的,还有个get方法,就是用来修改值的,通常搭配proxy一起使用。Reflect.get有三个参数,分别为target,key,receiver和proxy中的参数二函数get的参数一致,get同理
因此两个函数get和set方法用上Reflect如下
vbnet
export const mutableHandlers = {
get: function(target, key, receiver) {
const res = Reflect.get(target, key, receiver)
return res
},
set: function(target, key, value, receiver) {
const res = Reflect.set(target, key, value, receiver)
return res
}
}
好了,此时的age++实现起来就不会报错了

为了让代码更加优雅,我将这两个函数分出去写,写成闭包的形式
vbnet
const get = createGetter()
const set = createSetter()
export const mutableHandlers = {
get,
set
}
function createGetter() {
return function get(target, key, receiver) {
console.log('target对象被读取值了');
const res = Reflect.get(target, key, receiver)
return res
}
}
function createSetter() {
return function set(target, key, value, receiver) {
console.log('target对象被修改值了', key, value);
const res = Reflect.set(target, key, value, receiver)
return res
}
}
还是最开始的那个使用场景,如果我添加上computed和watch,如下
xml
<template>
<div>
<p>{{state.count}}</p>
<p>{{num}}</p>
<button @click="() => state.count++">add</button>
</div>
</template>
<script setup>
import { reactive, computed, watch } from 'vue'
const state = reactive({ // 响应式的对象
count: 1
})
const num = computed(() => {
return state.count * 10
})
watch(
() => state.count,
(newVal, oldVal) => {
console.log(newVal, oldVal);
}
)
</script>
<style lang="css" scoped>
</style>
如果我点击按钮,那么computed计算属性也是会重新执行的,watch监听器同理,那么这个效果是如何实现的呢
其实说这个就是要讲副作用函数,一个响应式的数据,是有可能被像是computed这样的东西去使用的,如果一个响应式数据没有任何人去使用,那么像是computed就不需要去执行了
用到了这个响应式数据的函数就是副作用函数,比如常见的computed,watch
还有个
watchEffect函数,watch想要初次加载需要设置immediate为true,但是watchEffect默认行为就是初次加载,并且一个很大区别是watchEffect不需要监听什么东西就会执行,而watch必须要监听到数据的变更才能执行,当然watchEffect同样可以监听一个数据,变更后同样执行,并且watchEffect还可以在变量发生变更的时候再做些什么,等到dom渲染完成可以触发onTrack和onTrigger函数,这两个函数称之为调度函数,调度函数充当了副作用函数,二者不能同时触发
收集副作用就叫做依赖收集,需要记录下来此时是哪一个key(就是state[key])的值变更了,再去通知其他依赖该值的函数生效,这有点像是发布订阅,订阅变量的函数才会触发
因此这里我们就需要在get函数中写一个发布,其实依赖收集就是触发每一个被修改的属性身上的副作用函数
依赖收集是vue2的说法,后面的解释是vue3的说法
因此我们先要需要清楚这个响应式数据身上有哪些地方被用到了,之后再去触发它
副作用函数收集为何放在get中去完成,因为有些响应式数据,可能并没有出现在其他地方使用,因此这个效果就是哪里用上了就给它去收集,而get就是读值的操作
副作用收集这里我专门拿出来写,reactivity/effect.js
effect.js
同样,用WeakMap来收集,并且需要避免二次收集
scss
const targetMap = new WeakMap() // 和Map本质区别就是一个性能问题,key能写成其他类型
let activeEffect = null // 副作用函数
// 为某个属性添加 effect 副作用
export function track(target, key) { // 为某个属性添加effect函数
// 避免二次收集
let depsMap = targetMap.get(target)
if (!depsMap) { // 初次读取到值 收集effect
depsMap = new Map()
targetMap.set(target, depsMap)
}
let deps = depsMap.get(key)
if (!deps) { // 该属性还未添加effect
deps = new Set() // set可保证不会为重复收集
}
if (!deps.has(activeEffect) && activeEffect) { // 存入一个effect函数
deps.add(activeEffect)
}
depsMap.set(key, deps)
}
这个track函数就是添加副作用函数,读一次值就会调用一次
因此把它引入到baseHandlers.js中,放到get里面
vbnet
import { track } from './effect.js'
const get = createGetter()
const set = createSetter()
export const mutableHandlers = {
get,
set
}
function createGetter() {
return function get(target, key, receiver) {
console.log('target对象被读取值了');
const res = Reflect.get(target, key, receiver)
track(target, key)
return res
}
}
function createSetter() {
return function set(target, key, value, receiver) {
console.log('target对象被修改值了', key, value);
const res = Reflect.set(target, key, value, receiver)
return res
}
}
但是目前effect.js的track收集到的副作用还是null
其实vue里面还真有个effect函数,这个函数你在vue文档中是找不到的,因为它是打造给自己的源码使用的,effect同computed一样,需要引入,又有点像是钩子,一开始就会执行,并且同监听器一样,响应式数据发生变更也会执行,并且里面还可以写调度函数,依赖响应式数据变化而执行,调度函数执行了副作用函数不会执行
javascript
import { effect } from 'vue'
effect(
() => { // 类似生命周期,一开始就会执行,但是这个东西yyx是作依赖收集用的,官方文档没有解释 ,参二若为true,不执行,可控
console.log('effect', state.count);
},
{
lazy: false,
scheduler: () => {
console.log('调度器依赖响应式变化而执行');
}
}
)
因此这里我们需要给副作用函数activeEffect赋值
javascript
const targetMap = new WeakMap() // 和Map本质区别就是一个性能问题,key能写成其他类型
let activeEffect = null // 副作用函数
export function effect(fn, options={}) {
const effectFn = () => {
try {
activeEffect = effectFn
return fn()
} finally {
activeEffect = null
}
}
if (!options.lazy) { // lazy为false才会触发
effectFn()
}
return effectFn
}
// 为某个属性添加 effect 副作用
export function track(target, key) { // 为某个属性添加effect函数
// 避免二次收集
let depsMap = targetMap.get(target)
if (!depsMap) { // 初次读取到值 收集effect
depsMap = new Map()
targetMap.set(target, depsMap)
}
let deps = depsMap.get(key)
if (!deps) { // 该属性还未添加effect
deps = new Set() // set可保证不会为重复收集
}
if (!deps.has(activeEffect) && activeEffect) { // 存入一个effect函数
deps.add(activeEffect)
}
depsMap.set(key, deps)
}
try finally不同于try catch,finally不管try执行与否都会执行
写到这里,effect还有个效果没有体现,就是数据发生变更也能执行,目前只能执行一遍,因此需要再写一个触发函数,触发所有的副作用函数,因此去遍历即可,另外再添上调度器函数的执行,最终effect.js如下
javascript
// 副作用收集相关代码
const targetMap = new WeakMap() // 和Map本质区别就是一个性能问题,key能写成其他类型
let activeEffect = null // 得是一个副作用函数
export function effect(fn, options={}) { // fn是箭头函数
const effectFn = () => {
try {
activeEffect = effectFn
return fn()
} finally {
activeEffect = null
}
}
if (!options.lazy) {
effectFn()
}
effectFn.scheduler = options.scheduler
return effectFn
}
// 为某个属性添加 effect 副作用
export function track(target, key) {
// 避免二次收集
let depsMap = targetMap.get(target)
if (!depsMap) { // 初次读取到值 收集effect
depsMap = new Map()
targetMap.set(target, depsMap)
}
let deps = depsMap.get(key)
if (!deps) { // 该属性还未添加effect
deps = new Set()
}
if (!deps.has(activeEffect) && activeEffect) { // 存入一个effect函数
deps.add(activeEffect)
}
depsMap.set(key, deps)
// console.log(depsMap);
}
// 触发某个属性effect
export function trigger(target, key) {
const depsMap = targetMap.get(target)
if (!depsMap) { // 当前对象所有key中都没有依赖,也就是从来没有使用过
return
}
const deps = depsMap.get(key)
if (!deps) { // 这个属性没有依赖
return
}
deps.forEach(effectFn => {
if (effectFn.scheduler) { // 调度函数不能与副作用函数同时触发
effectFn.scheduler()
} else {
effectFn()
}
})
}
触发副作用函数的时机就是修改值的时候,因此这个触发副作用trigger需要引入到set中
最终baseHandlers.js如下
vbnet
import { track, trigger } from './effect.js'
const get = createGetter()
const set = createSetter()
export const mutableHandlers = {
get,
set
}
function createGetter() {
return function get(target, key, receiver) {
console.log('target对象被读取值了');
const res = Reflect.get(target, key, receiver)
track(target, key)
return res
}
}
function createSetter() {
return function set(target, key, value, receiver) {
console.log('target对象被修改值了', key, value);
const res = Reflect.set(target, key, value, receiver)
trigger(target, key)
return res
}
}
其实从用法和效果上来看,effect和watch以及computed是一样的,其实watch和computed的源码确实是这样的,核心就是effect函数
最终reactive源码目录如下

总结
一段话描述reactive源码:用Proxy代理了对象,在代理函数get中对使用了的属性做副作用函数收集,在代理函数set中对修改了的属性做副作用函数的触发。这样你也就明白了为什么用到了reactive的数据,数据发生变更后,computed和watch也能够自动执行
ref源码
首先我们要明白,ref通常是给原始数据类型作响应式的,但是只要是被ref过了,那么这个数据一定是个对象,比如我写const age = ref(18),那么age.value是18,但是age是个对象

再来一个小知识,js的对象是有getter和setter的能力,比如下面的对象,我想要打印18,就需要写成obj.age()
javascript
let obj = {
name: 'Tom',
age() { // get age() {}
return 18
}
}
console.log(obj.age())
如果我给函数前面加一个get关键字,那么直接obj.age就可以读取到,修改一个对象中的值时,如果这个值前有set关键字是个函数时,这个值可以当成属性来修改
好了,这个时候你就明白了,age.value的这个value其实就是用的get
将数据变成响应式需要用构造函数
kotlin
export function ref (val) {
return createRef(val)
}
function createRef(val) { // 将原始数据类型变成响应式
// 避免二次响应
// 将val变成响应式
return new RefImpl(val)
}
class RefImpl { // es6构造函数class写法
constructor(val) {
this.__v_isRef = true // 给每一个被ref操作的属性添加标记
this._value = val // _开头的属性通常表示私有属性,源码自用
}
get value() { // 函数前一个get表示,让一个函数不用()就可以调用,如果没这个get,那么就是age.value()才能拿到值
return this._value
}
}
其实我们很希望ref后的age,拿到其数据,直接是age,而不是age.value,这样感觉很难看,yyx团队也无法解决这个问题~,对象访问必须加一个.
ref目前的写法是没有实现响应式的,我们需要为this进行依赖收集和触发依赖,this就是代表实例对象,这两个功能这里就可以套用reactive里面写好的track和trigger函数,如下
kotlin
import { track, trigger } from './effect.js'
export function ref (val) {
return createRef(val)
}
function createRef(val) { // 将原始数据类型变成响应式
// 避免二次响应
// 将val变成响应式
return new RefImpl(val)
}
class RefImpl { // es6构造函数class写法
constructor(val) {
this.__v_isRef = true // 给每一个被ref操作的属性添加标记
this._value = val // _开头的属性通常表示私有属性,源码自用
}
get value() { // 函数前一个get表示,让一个函数不用()就可以调用,如果没这个get,那么就是age.value()才能拿到值
// 为this对象作依赖收集
track(this, 'value') // 参二key是人为加的
return this._value
}
set value(newVal) {
if (newVal !== this._value) {
this._value = newVal
trigger(this, 'value') // 触发掉'value'上的所有副作用函数
}
}
}
但是,我前面说了,ref只是通常用作响应式原始类型,引用类型也是可以的,比如下面我这样用,打印下看看
xml
<template>
<div>
<p>{{age.num}}</p>
<button @click="() => age.num++">add</button>
</div>
</template>
<script setup>
import { ref } from 'vue'
const age = ref({num: 18})
console.log(age.value)
</script>
<style lang="css" scoped>
</style>

啊?是个Proxy,这不就是相当于ref里面内置了个reactive吗,这么看来ref的能力是要强点的
我说这句话会不会被骂?先别骂
好了,既然如此,那我只要是碰到引用类型就让reactive帮忙就可以,另外完善避免二次响应,最终ref完整代码如下
kotlin
import { track, trigger } from './effect.js'
import { reactive } from './reactive.js'
export function ref (val) {
return createRef(val)
}
function createRef(val) { // 将原始数据类型变成响应式
// 避免二次响应
if (val.__v_isRef) {
return // 已经响应式不予以响应
}
// 将val变成响应式
return new RefImpl(val)
}
class RefImpl { // es6构造函数class写法
constructor(val) {
this.__v_isRef = true // 给每一个被ref操作的属性添加标记
this._value = convert(val) // _开头的属性通常表示私有属性,源码自用
}
get value() { // 函数前一个get表示,让一个函数不用()就可以调用,如果没这个get,那么就是age.value()才能拿到值
// 为this对象作依赖收集
track(this, 'value') // 参二key是人为加的
return this._value
}
set value(newVal) {
if (newVal !== this._value) {
this._value = convert(newVal)
trigger(this, 'value') // 触发掉'value'上的所有副作用函数
}
}
}
function convert(val) {
if (typeof val !== 'object' || val === null) { // 不是对象
return val // 原始类型就用自身
} else {
return reactive(val) // 引用类型喊reactive帮忙
}
}
总结
ref通常将原始类型变成响应式,依赖收集和触发依赖用的是js对象自带的get和set语法,但是如果ref接收到的是引用类型,依旧是靠reactive使用
最后
面试官:请你说收ref和reactive的区别
reactive用于响应式引用类型,靠的是es6的Proxy代理方法,这个方法的参数二自带get和set方法分别用于依赖收集和依赖触发,依赖收集就是收集其副作用函数,依赖触发就是触发其副作用函数,依赖收集和依赖触发才能实现,谁用了这个响应式数据,谁就能立马发生变化,从而实现试图的更新,而ref通常用于响应式原始类型,这靠的是原生js对象的getter和setter效果为属性添加副作用函数和触发副作用函数,如果需要用它响应引用类型,ref会喊reactive来帮忙
如果你对春招感兴趣,可以加我的个人微信:
Dolphin_Fung,我和我的小伙伴们有个面试群,可以进群讨论你面试过程中遇到的问题,我们一起解决
另外有不懂之处欢迎在评论区留言,如果觉得文章对你学习有所帮助,还请"点赞+评论+收藏"一键三连,感谢支持!