掘金总是有人抵触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
,我和我的小伙伴们有个面试群,可以进群讨论你面试过程中遇到的问题,我们一起解决
另外有不懂之处欢迎在评论区留言,如果觉得文章对你学习有所帮助,还请"点赞+评论+收藏"一键三连,感谢支持!