字节面试题:请你谈谈vue的响应式原理(一)(万字修改版)

77,239 阅读21分钟

关于其他的字节面试文章已经更新得差不多了,希望能够帮助到各位

字节面试题:webpack的loader和plugin有什么区别? - 掘金 (juejin.cn)

字节面试题:为什么vite更快 - 掘金 (juejin.cn)

手撕深拷贝?这一篇就够了! - 掘金 (juejin.cn)

字节面试题:请你谈谈vue的响应式原理(一) - 掘金 (juejin.cn)

字节面试题:请你谈谈vue的响应式原理(二) - 掘金

字节面试题:没有koa的cors你怎么处理跨域? - 掘金

前言

在之前写到的字节面试中,有一道题堪称经典中的经典,那就是vue的响应式原理。通常我们在使用vue3的响应式的时候,并不会特别去纠结用ref还是reactive,因为大部分人都是遵循一个方法:引用类型的用reactive,绝对类型的用ref。但是二者的区别又是什么?为什么不能用同一个。依稀记得当时我战战兢兢地问:“是vue2的还是vue3的?”,面试官淡淡回了句:“说你会的”。当时虽然比较完整地回答了出来,但是显然磕磕绊绊的回答和写满脸的紧张并没有让面试官非常满意。为了避免各位看官老爷和我一样,这里给各位详细讲讲vue3的响应式原理。  

正文

根据鄙人的经验,面试官通常会根据你回答问题时开头的几句话去判断你对这个知识点掌握的深度,所以最好的回答就是“代理”  

代理(proxy)是vue3框架中reactive实现响应式的基本原理。什么是响应式,就是当数据更新的时候页面能够随时做出反应,而不需要刷新。在vue3中,想要让数据做到这一点,通常使用reactive或ref去实现。那也就是说如果我们能够手写一个reactive或者ref的话,所谓的响应式原理也就不在话下了,所以本文不仅仅会给各位讲讲vue3的响应式原理,同时还会带各位搓一个reactive出来。

首先还是来一个小demo

<template>
  <div>
    <p>{{ state.count }}</p>
    <button @click="() => state.count++">add</button>
  </div>
</template>
<script setup>
import { reactive, watch } from "vue";

const state = reactive({
  count: 0,
});
</script>

1.png

在这段代码中,state就是一个响应式的对象,其中的count作为state的一个键值对,自然也有响应式的特性。当我们点击button的时候,

count不仅会自增,同时还会刷新页面显示的数据。

作为“响应式”最重要的两个特性,二者缺一不可。首先我们来看看,如何实现点击button的时候让count自增。

数据响应式

1. 类型判断

在这里我们新建一个同样名为reactive的文件用于代替官方的reactive。既然前面也提到过大多数人都是通过reactive去将引用类型的值转为响应式,那么这里的第一步自然就是进行类型判断,如果不是引用类型的话直接返回原值,也没必要进行下一步了。

export function reactive(target) {
  //   判断是否是引用类型,源码也是这么用的,若为原始类型,则直接返回
  if (typeof target !== "object" || target === null) {
    return target;
  }
 }

2. 防止重复代理

接下来,还有一个大家可能不会特别在意的地方,那就是重复代理的行为,例如:  

<script setup>
import { reactive, watch } from "vue";

const state = reactive({
  count: 0
})
const state = reactive({
  count: 0
})
</script>

虽然要是有人这么写代码给你,那包被打的,但就像上面的类型判断,本着“防呆”的设计理念,我们同样要处理这种行为。我们可以创建一个对象,把对象名作为键,true作为值,然后一旦被代理就将这个键值对存进对象,实不相瞒,我最开始就是这么想的。

但是这样一来会存在三个很大的问题,首先对象里面查找键值对是一种非常消耗性能的行为,刷过一部分算法题或者了解过for-in和for-of区别的小伙伴应该了解这一点。其次,一旦创建对象,那么就会一直存在,哪怕我们的代码从始至终都没有用到过这个对象。 我们是要为了每一丁点的内存做考虑的。最后,一旦我们真的遇到了重复代理的问题,我们怎么拿到被代理过的对象,因为很明显的一点就是响应式对象和源对象不是同一个东西,我们不能把源对象当做响应式对象返回,而是要返回代理过的对象。

所以,这里最好的解决方法还是用weakmap去储存被代理过的对象,因为一旦weakmap没有被用到,那么整个weakmap都会被垃圾回收机制处理掉,不仅降低了内存占用,同时作为map类型的值,查找起来也是非常快捷的,可以说是两全其美了。不仅如此,weakmap和map类型的值一样,都能够把对象作为键名。这样一来刚刚提到的三个问题就通通迎刃而解了。最后,整理一下,将创建响应式的部分单拎出去写。如此一来,代码就变成这样

 

import { mutableHandlers } from "./baseHandle.js";

// 用weakMap 存储已经被代理的对象
const reactiveMap = new WeakMap();

export function reactive(target) {
  //   判断是否是引用类型,源码也是这么用的,若为原始类型,则直接返回
  if (typeof target !== "object" || target === null) {
    return target;
  }
  //该对象是否已经被代理,如果是则不重复代理行为
  if (reactiveMap.has(target)) {
    return reactiveMap.get(target);
  }
  //该对象未被代理,则先代理随后存入weakmap
  // 将target 转为响应式对象
  return createReactiveObject(target, reactiveMap, mutableHandlers);
}

3. 响应式数据

前面铺垫了这么多,终于到了最重要的时刻。接下来的目标就很明确了,只需要把createReactiveObject函数写出来就ok。这就不得不回到我们刚刚讲到的proxy了。可以看到,在官方文档中,proxy可以接受两个参数,分别是被代理的对象和代理的方法。而在代理的方法中有足足13种。同时,文档中也明确提出了proxy只能代理引用类型的值,这也就回答了我们最开始提到的问题——“为什么不能单用一个reactive”。考虑到代理函数可能会有很多,所以我们将代理方法再次拎出去写

首先来一个最简单也是最常用的“查”,文档中也写明了,get接受三个参数,第一个是源对象,第二个是对象的键名,第三个的作用我们待会再说。

首先我们需要知道的是一旦对象被proxy代理成响应式对象,那么只要对数据进行了操作而代理方法中有对应的函数,那就一定会被触发。例如我们就写一个最简单的console.log

function createrGetter() {
  return function get(target, key, receive) {
    // target是被代理的对象,key是访问的属性,receive是proxy对象
    console.log("target has been checked");
   return "对不起,你是个好人"
  };
}

1.png

1.png

可以看到立即打印了我们预设的内容,但是,没有打印原有的内容。 这是因为,代理这个过程就像是谈恋爱,我被代理了,就相当于我谈恋爱了,当一个肤白貌美,明眸皓齿,倾国倾城,身材窈窕的学姐约我出去吃饭时,出不出得去得看我对象的脸色。 就好比在这里,我的对象不让我去,那我只能回答学姐“对不起,你是个好人”。如此一来,“学姐”就会得到。

 

function createrGetter() {
  return function get(target, key, receive) {
    // target是被代理的对象,key是访问的属性,receive是proxy对象
    console.log("target has been checked");
    return "对不起,你是个好人"
  };
}

1.png

不过绝大多数情况下,我们还是会返回原本的值的。

function createrGetter() {
  return function get(target, key, receive) {
    // target是被代理的对象,key是访问的属性,receive是proxy对象
    console.log("target has been checked");
    return target[key];
  };
}

 

这样一来,关于“查”我们就能够明白了。同理,写一个“改”不过就是照猫画虎。区别在于“改”接受四个参数,多出来的那个是修改的值。

 

function createrSetter() {
  return function set(target, key, value, receive) {
    console.log("target has been set,the " + key + " is" + value);
    target[key] = value;
    const res = Reflect.set(target, key, value, receive);
    return res;
  };
}

  1.png

这里各位读者姥爷可能发现我返回的res不是常用的读值方法,而是用了一个Reflect其实这个东西和对象是一模一样的,和proxy一同被打造出来,目的在于解决一些对象方法的不足之处,例如之前我们讲深拷贝的时候,有时会通过defineProperty去判断一个属性是显示具有还是隐式具有,但问题在于这个东西会直接导致程序崩溃,需要用try-catch去捕获错误,而Reflect则解决了这个问题,不在需要去手动捕获错误,而是会直接返回false。

这样,之前的createReactiveObject就能写出来了

function createReactiveObject(target, proxyMap, proxyHandlers) {
  //第二个参数的作用就是当target被增删改查或者判断时会触发的函数
  const proxy = new Proxy(target, proxyHandlers);
  //此时proxy就是被代理的对象,将其存入weakmap
  proxyMap.set(target, proxy);

  return proxy;
}

如此一来,当我再次点击button的时候,就能看到count变化了,这可以说十分有趣,更有趣的是,浏览器显示的count稳如老狗,完全不鸟你点到飞起的鼠标。这就涉及到响应式的第二个关键点——更新显示。

3.png

4. 依赖收集和触发

在谈及更新视图之前,我们还需要仔细聊聊非常重要的一个问题——依赖收集和触发。什么叫依赖呢,我们来看看下面这段代码

<template>
  <div>
    <p>{{ state.count }}</p>
    <p>{{ num }}</p>
    <button @click="() => state.count++">add</button>
  </div>
</template>

<script setup>
// import { reactive } from "./reactivity/reactive.js";
import { reactive, watch, computed } from "vue";

const state = reactive({
  count: 0,
});

const num = computed(() => state.count * 2);

watch(
  () => state.count,
  (newValue, oldValue) => {
    console.log("watch", newValue, oldValue);
  });
</script>

很显然,我们点击button之后虽然只会导致count的值发生变更,但是num也会随之变化并且刷新视图,并且watch中的打印也需要执行,就像是多米诺骨牌,那么在我们return res之前,需要触发被修改属性的每一个副作用函数,这就叫依赖触发,而在这之前,当然得记录下来我们修改的值还在哪些地方被用到了,这一过程就是依赖收集。

在上面的代码中,无论是computed还是watch,其中的函数都是依赖函数,而关键的相同点都在于读取了state.count的值,这样一来我们只需要在get函数中做一些处理就能够很好地去收集state.count的依赖函数了。

依赖收集

  1. 首先定义两个变量,targetMap用于存储每个变量以及所拥有的副作用函数,而activeEffect则是具体的副作用函数,初始值为null
const targetMap = new WeakMap();
let activeEffect = null;//得是一个副作用函数
  1. 接下来就是看某个键值对是否被读取从而绑定了某个副作用函数,如果有的话就存入targetMap,我们可以用一个名为track的函数做到这一点
export function track(target, key) {
  // 判断target是否收集过该属性
  let depsMap = targetMap.get(target)

  if (!depsMap) {
    // 初次读取到值,新建map存入targetMap
    depsMap = new Map()
    targetMap.set(target, depsMap)
  }

  let deps = depsMap.get(key)

  if (!deps) {
    // 该属性没做过依赖收集
    // 设置为set避免重复收集
    deps = new Set()
  }

  if (!deps.has(activeEffect) && activeEffect) {
    // 存入一个effect函数
    deps.add(activeEffect)
  }
  depsMap.set(key, deps)
}

首先我们需要清楚地知道是哪个对象的哪个键被读取,所以二者需要作为形参传入。

接下来声明depsMap,并尝试从targetMap中找到目前读取的键的值,如果depsMap存在的话 的话就说明target收集过依赖,就可以在对应的值中直接存入目前键的依赖,如果没有的话就需要新建一个map类型的值并存入targetMap。

随后声明deps并尝试赋值为depsMap中key对应的值。这里和上一步的逻辑基本相同,如果deps存在的话就说明targ[key]已经收集过依赖函数了,如果不存在,就新建set类型,作为targ[key]的值存入。这里之所以用set类型,就是set类型的元素都是唯一的,这就可以再次避免重复收集依赖函数吗,并且set类型不论是读取还是写入都更高效。

这样一来,track的任务就完成了,其目的就在于将响应式对象的键值对所有的依赖函数收集起来,等待这个键值对被触发的时候,再去一一触发里面的依赖函数。为了避免各位看官老爷被一堆deps,又是weakMap又是map又是set绕晕了,下面是整个depsMap的结构示意图

1.png

  1. 既然收集好了所有的依赖函数,那么就等着触发了。什么时候触发?相信各位已经有了很明确的答案了:set。那么接下来我们再定义一个名为trigger的函数,用于触发一个键值对身上所有的依赖函数
export function trigger(target, key) {
  const depsMap = targetMap.get(target)
  if (!depsMap) return

  const deps = depsMap.get(key)

  if (!deps) return

  deps.forEach(dep => {
  //这里的dep就是set类型中的值,每一个dep都是一个具体的函数(既依赖函数),我们需要触发这些函数
   dep()
  })
}

和track一样,都需要将具体的对象和键名传入,确保我们不会乱触发。接下来,在targetMap中查找有没有target对象所对应的值,有的话就说明这个对象做过依赖收集,没有的话就可以直接寄了

当然,只是对象做过还完全不够,当我们能读到target对象所对应的值(注意是一个map类型)之后,应该再去看看是否也存在对应的值,没有的话同样是GG,如果有的话那就好办了,通过forEach循环一遍,统统调用掉就好了。

vue3中的computed和watch的实现

到这里我们似乎已经基本实现了响应式数据的依赖收集和触发,但是实际上这样子做还是远远不够的。举个十分常见的例子:

computedwatch

这里来一个小demo简单为各位读者姥爷展示二者区别

<template>
  <div>
    <p>{{ state.count }}</p>
    <button @click="() => state.count++">add</button>
  </div>
</template>
<script setup>
import { reactive, effect, watch, computed } from 'vue';

// 创建一个响应式对象
const state = reactive({
  count: 0
});
const cValue = computed(() => {
  console.log('我计算了count!现在count的2倍是' + state.count * 2);
  return state.count * 2
})
watch(state, (newValue, oldValue) => {
  console.log('我监听了state!它的newValue是:' + newValue.count + ',oldValue是' + oldValue.count);
  if (state.count % 2 === 0) {
   console.log(cValue.value);
  }
})
// 修改响应式数据
</script>

1.png

代码很简单,用watch监听count的值,当count是偶数时打印一个cValue,cValue是count的两倍,执行效果如下:

1.png

为什么说刚刚那样直接遍历deps然后执行还不够呢?原因就在这里,在computed中有一行代码

 console.log('我计算了count!现在count的2倍是' + state.count * 2);

这算不算是count这个属性的依赖函数?同样的,在watch中也有一行代码

console.log('我监听了state!它的newValue是:' + newValue.count + ',oldValue是' + oldValue.count);

这又算不算是count的依赖函数?

很显然,无论是在watch中还是computed中,只要用到的某个响应式数据,那就是这个数据的依赖函数

那为什么watch里面的依赖立刻触发而computed不会立刻触发?倘若我们刚刚直接用forEach遍历deps,然后拿着里面的元素直接调用,还能实现这样的效果吗?显然是不行的。

这里简单说说watch和computed的区别,以免有些读者姥爷不太明白,大佬可以直接跳过这一段了。

watch这个东西相对来说稍微简单一点,就好比“惊弓之鸟”,它的效果是“只要...变了,我就...”

watch(state, (newValue, oldValue) => {
  console.log('state发生变化啦!!!');
})

当然这里真要细聊的话还得分浅监听深监听,这里就不深究了。而computed这里就得详细讲讲了,举个很形象的例子:

把computed比做一个摸鱼的洗碗工,洗碗工面前有一个牌子,值为true/false用来标记是否有脏盘子(脏数据)当数据被修改了,就相当于洗碗工面前有了一个脏盘子,此时洗碗工会看看牌子写的什么,再决定要不要修改牌子上的值为true。同时他不会去立刻洗盘子,而是继续摸鱼,当有人要盘子的时候(数据被读取或者被使用)的那一刻,他会立刻看看牌子上的值是true还是false,如果是false说明没有脏盘子,就把此前洗好的盘子直接递过去,如果是true说明有脏盘子,就把所有盘子全部洗掉再递过去。并且如果在洗完之后一直有人要盘子,洗碗工会像魔术师一样一直拿出干净的盘子,而不是继续洗一遍然后再拿出来(数据缓存)

发现了吗,重点就在于你有没有让computed继续摸鱼,这很重要,因为我们之前的代码是没有给computed摸鱼的时间的,这就好比你上班的时候不让摸鱼,这简直就是精神损伤,所以为了体现我们人性的一面,我们应该给computed摸鱼的时间(话说代码都能摸鱼了,真不至于还有人不能摸鱼吧)。

相信到这里各位读者老爷都能发现computed和watch似乎就是大同小异,只不过computed的“摸鱼”特性能够节省一点性能消耗罢了,而事实也确实如此,在vue3的源码中computed和watch都是基于一个effect实现的

effect函数的实现

effect简单来说就两个作用

  • 自动追踪依赖:当副作用函数读取响应式数据时,会自动收集依赖。
  • 自动触发更新:当依赖的数据变化时,副作用函数会自动重新执行。

effect是在vue3中允许使用的一个函数,他接收两个参数,

第一个是回调函数,第二个是对象,对象中有两个键,分别为lazy,值为true或false。

还有一个为scheduler,值为函数,而这个参数就是调度函数,当lazy为false的时候,回调函数会在vue实例创建的时候就被触发,否则只有在对应的值发生修改才会触发。而调度函数一旦被执行,则回调函数不会再被触发。

前面我们已经讲到过了收集和触发了,接下来我们就整合一下,具体实现effect函数,根据上面提到的内容我们知道两点

  • effect是一个函数
  • effect接收两个参数

由此我们可以得到

function effect(fn, options = {}) {
  const { lazy = false, scheduler } = options;

  // 创建一个 runner 对象,包含 run 和 stop 方法
  const runner = {
    run: () => {
      fn(); // 执行传入的函数
    },
    stop: () => {
      // 停止逻辑可扩展,例如清理依赖
    }
  };

  // 将 scheduler 附加到 runner 上,以便在依赖变化时调用
  runner.scheduler = scheduler;

  // 如果不是 lazy,立即执行
  if (!lazy) {
    runner.run();
  }

  return runner;
}

可能看到这里会有点蒙,什么是scheduler,这是干什么的?runner和run又是什么?lazy有什么用?不要急,我们一步步来解释

首先讲讲scheduler,这个东西就是让computed能够摸鱼的重点,上面派活下来的时候正是scheduler压着,才让computed能够实现摸鱼的效果。我们可以通过用effect代替computed来解释,或者说我们手写一个computed

// 实现 computed
function myComputed(getter) {
//computed接受一个函数作为参数,所以这里的getter是一个函数
//这里之所以参数是一个getter,是因为此时我们操作的是响应式数据的值,这个值已经被代理了
//所以要通过getter去拿,可以触发之前设置的proxy.get,从而进行收集依赖函数
  let value;       // 缓存值
  let dirty = true; // 标记为脏数据

  const effectRunner = effect(() => {
    value = getter(); // 执行 getter 计算值
    dirty = false;    // 标记为已更新,此时数据不脏了,所以更新为false
  }, {
    lazy: true, // 设置为懒执行,避免像watch那样一碰就触发
    scheduler: () => {
      dirty = true; // 依赖变化时,标记为“脏”
    }
  });

  const computed = {
    get value() {
      if (dirty) {// 如果是“脏”状态,说明刷盘子工一直在摸鱼,需要立刻刷盘子
        effectRunner.run(); 
      }
      return value; // 返回缓存值
    }
  };

  return computed;
}

effect看不明白?不要紧!

又来个computed雪上加霜更是一头雾水?没关系!

接下来我将结合二者,给各位清清楚楚明明白白解释出来!还是来一个小demo

const state = reactive({
  count: 0
});
const cValue = computed(() => {
  console.log('我计算了count!现在count的2倍是' + state.count * 2);
  return state.count * 2
})

demo很简单,接下来结合我们手搓的effect和myComputed来看。

  1. 首先在我声明cValue的时候,myComputed就开始执行了,其中myComputed的参数为
() => { console.log('我计算了count!现在count的2倍是' + state.count * 2); return state.count * 2 }

此时在myComputed内部,声明value,但是没有赋值,随后,将dirty设置为true,说明只要computed访问过哪怕没有修改数据,computed也会将响应式变量标记为脏数据。

  1. 随后,effectRunner作为闭包被保留下来,并将新的computed返回出来此时
cValue = {
  get value() {
    if (dirty) {
    // 如果是“脏”状态,则重新计算 
      effectRunner.run();
    }
    return value; // 返回缓存值 
  }
};
  1. 这个时候cValue的声明和赋值都结束了,紧接着就是执行console.log(cValue.value);,此时访问了cValue.value,立刻触发cValue的getter函数,也就是
value() {
  if (dirty) {
    effectRunner.run(); // 如果是“脏”状态,则重新计算 
  }
  return value; // 返回缓存值
}

这个时候先进行判断,dirty应该是true,这里应该也是作为闭包保留下来了,所以可以成功通过判断并执行 effectRunner.run(),之前我提到过 effectRunner同样是闭包被保留,所以我们看看 effectRunner具体是什么

effect(() => {
    value = getter(); // 执行 getter 计算值
    dirty = false;    // 标记为已更新
  }, {
    lazy: true, // 设置为懒执行,避免像watch那样一碰就触发
    scheduler: () => {
      dirty = true; // 依赖变化时,标记为“脏”
    }
  });//这里的effect函数立刻执行,getter函数也要执行,这里的getter函数就是我之前调用computed时传入的参数,也就是
() => {
  console.log('我计算了count!现在count的2倍是' + state.count * 2);
  return state.count * 2
}
  1. 但是我们上面拿到的只是effect的函数,具体执行的run还得看effect中是如何定义run函数的
 run: () => {
      fn(); // 执行传入的函数
    },

也就是说会立刻执行effectRunner中第一个函数

() => {
    value = getter(); // 执行 getter 计算值
    dirty = false;    // 标记为已更新
  }
  //这里的getter函数就是我之前调用computed时传入的参数,也就是
() => {
  console.log('我计算了count!现在count的2倍是' + state.count * 2);
  return state.count * 2
}

执行过程中value=getter的返回值,也就是说此时value被修改为state.count * 2,并且脏数据标签被修改成false

  1. 这个时候函数执行完毕,回到此前第3步中的if判断的下一行去,我们可以看到是
return value; // 返回缓存值

此时,我们在去console.log(cValue.value);的时候拿到的就是之前在第4步被计算出来,在第5步被返回出来的 state.count * 2了,数据没有问题,并且脏数据标签也成功修改成了false。computed函数执行完毕。

通常,effect有以下几个用处1.视图更新:当响应式数据变化时,自动更新视图。2. 计算属性:基于响应式数据计算派生数据。3.副作用管理:处理副作用操作,如网络请求、日志记录等。

所以我们才会大费周章介绍effect,目的就在于视图更新,以及检查是否有调度函数需要处理。这里我就简单写了一个副作用管理和计算属性,视图更新其实大差不差了,并且effect中具体的细节处理要比这个更加复杂。这里也就不多做展开了。

总结

在这篇文章中我为各位详细解释了一下vue3中reactive的原理及简化版的代码。可能很多人会有一个疑问就是为什么不讲讲ref,首先呢,我本人其实不太有耐心去完整看完一篇技术型长文,所以我相信大多数人无论是学生还是正儿八经的程序员,也不会有太多的时间和耐心去看完,因此我宁愿用更长的篇幅和更细致的描写,让读者能完全了解一个原理,而不是简单一笔带过所有。其次,ref中其实也包含了reactive,至于为什么会有reactive这个“多余”的家伙,我会在下一篇文章中给各位讲明白。最后,祝各位读者老爷0 waring(s),0 error(s)!