手写vue3源码——reactive, effect ,scheduler, stop 等

简介: 在上面的测试用例中,有两个关键的函数reactive和effect,一个是创建响应式对象,另一个则是收集依赖,这个测试用例有点大,一次性实现不太方便,咋们可以把这任务拆分为更小的模块(任务拆分),分别写两个测试用例来测试reactive和effect

reactive, effect 大家都清除 ,但是对于scheduler, stop等方法是需要看源码咋们才能明白的😃😃😃,在上一节中,咋们用 pnpm 搭建了一个和vue3一样的monorepo,这一节中,就使用这个方式在里面填充vue3的源码吧!本节的源码请查看


目标


本次目标主要是实现,reactive,effect stop, onstop, scheduler 等


为了方便大家的理解,这一次咋们就从 测试用例的角度,来写出源码,vue3的响应式相信大家都用过,那么用测试用例来描述则是这样的。


test('响应式数据测试', () => {
     // 创建一个响应式对象
    const origin = reactive({ num: 1 })
    let newVal;
    // 依赖收集
    effect(() => {
      newVal = origin.num
    })
    expect(newVal).toBe(1)
    // update 更新阶段
    origin.num = 2
    expect(newVal).toBe(2)
  })


在上面的测试用例中,有两个关键的函数reactive和effect,一个是创建响应式对象,另一个则是收集依赖,这个测试用例有点大,一次性实现不太方便,咋们可以把这任务拆分为更小的模块(任务拆分),分别写两个测试用例来测试reactive和effect


reactive


看到reactive 想必都不陌生,传入一个对象,返回一个代理对象即可。那测试用例如下:


test('测试reactive', () => {
    let obj = { num: 1 }
    const proxyObj = reactive(obj)
    expect(obj).not.toBe(proxyObj)
    expect(proxyObj.num).toBe(1)
    // update set
    proxyObj.num = 2
    expect(obj.num).toBe(2)
  })


需求: 根据测试用例可以看出,调用reactive后,返回的结果和原对象不是同一个,并且将代理对象数据发生改变后,原对象的数据也会相应改变


export function reactive(obj) {
  if (!isObj(obj)) return obj;
  return new Proxy(obj, {
    get(target, key, receiver) {
      const value = Reflect.get(target, key, receiver)
      // todo 依赖收集
      if (isObj(value)) {
        return reactive(value)
      }
      return value
    },
    set(target, key, value, receiver) {
      const result = Reflect.set(target, key, value, receiver)
      // todo 触发依赖
      return result
    },
  })
}


根据上面代码,可以运行测试用例,发现是没有问题的ヾ(≧▽≦*)o,但是在这里还有两个todo没有实现,分别是依赖收集和触发依赖


effect


effect函数可能有小伙伴不清除,这里解释下它的作用:调用effect后,里面的函数会立马执行一次哦,根据这个需要咋们写出以下测试用例:


test('effect是接受一个函数,当执行effect的时候,内部的函数会执行', () => {
    const fn = jest.fn();
    effect(fn)
    expect(fn).toBeCalledTimes(1)
  })


根据需要来实现下effect函数


export function effect(fn){
  fn()
}


上面的函数运行测试用例是没有问题的,但是咋们在深入一点,effect的作用是在trigger的时候来收集当前的fn,并且对外提供一个run函数,我想啥时候调用就啥时候调用,那么咋们是不是可以对fn进行包装一下。


class EffectReactive {
  fn: Function
   constructor(fn) {
    this.fn = fn
  }
  run(){
    this.fn()
  }
}
export function effect(fn){
  const _effect = new EffectReactive(fn)
  _effect.run()
}


对于effect的需要先到这儿,既然effect可以进行run函数了,接下来实现下trigger 和 track


trigger 和 track


需求:


1.trigger是在get到时候进行依赖收集

2.track 是在set的时候进行依赖触发,执行每一个fn


依赖收集收集的是fn, 那么在执行run的时候是不是可以来进行收集呢?,所以定义一个全局变量activeEffect,来保存,方便后续进行收集。


在class EffectReactive 里面的run 加上:

activeEffect = this;


然后在来实现trigger和track


// 用于保存每一个target,提高效率
const targetMap = new WeakMap();
/**
 * 收集依赖 target(map) ---> key(map) ---> fn(set)
 * @param target 
 * @param key 
 */
export function trigger(target, key) {
  let depsMap = targetMap.get(target);
  if (!depsMap) {
    depsMap = new Map();
    targetMap.set(target, depsMap);
  }
  // 获取key
  let deps = depsMap.get(key);
  if (!deps) {
    deps = new Set();
    depsMap.set(key, deps);
  }
  // 如果activeEffect不存在就不需要进行收集了
if (!activeEffect) return
  // 收集依赖
  deps.add(activeEffect)
}
/**
 * 依赖触发
 * @param target 
 * @param key 
 * @returns 
 */
export function track(target, key) {
  let depsMap = targetMap.get(target)
  if (!depsMap) {
    return
  }
  let deps = depsMap.get(key);
  if (!deps) {
    return
  }
  // 依赖触发的时候进行变量每一个fn,进行执行,就可以完成响应式的数据更新
  deps.forEach(effect => {
   effect.run()
  })
}


到了这一步,就可以发现咋们一开始的那个测试用例就可以通过啦😄


返回runner


在effect函数中,咋们可以返回一个runner函数,runner可以进行手动调用,并且拿到runner里面函数的结果,测试用例如下:


test('effect 有返回值', () => {
    let num = 10;
    // effect有返回值
    const runner = effect(() => {
      num++;
      return 'num'
    })
    // effect 在一开始的时候会调用
    expect(num).toBe(11)
    // 执行runner,并且拿到返回值
    const r = runner()
    // effect内部也会执行
    expect(num).toBe(12)
    // 验证返回值
    expect(r).toBe('num')
  })


咋们来改造下代码,对于effect函数需要返回值,是不是直接在effect里面做这样的操作


return  _effect.run.bind(_effect)


注意: 在class EffectReactive 中存在this绑定,所以出处需要使用bind来绑定this


面试的话,一般到这里就结束了,但是咋们是手写源码,肯定还需要往下走😎🤞😎


scheduler


scheduler的意思是调度者,作用是 当scheduler存在的时候,一开始scheduler不执行,当数据改变到时候,scheduler执行,run函数不执行,当手动调用scheduler里面的run函数的时候,直接看测试用例


test('scheduler 调度器', () => {
    let dummy;
    let run;
    const scheduler = jest.fn(() => {
      run = runner;
    });
    const obj = reactive({ foo: 1 });
    const runner = effect(
      () => {
        dummy = obj.foo;
      },
      { scheduler }
    );
    expect(scheduler).not.toHaveBeenCalled();
    expect(dummy).toBe(1);
    // should be called on first trigger
    obj.foo++;
    expect(scheduler).toHaveBeenCalledTimes(1);
    // // should not run yet
    expect(dummy).toBe(1);
    // // manually run
    run();
    // // should have run
    expect(dummy).toBe(2);
  })


根据需求来改造现有代码


在effect当中新增一个参数options,并且需要控制run函数的执行,run函数咋们是在track中进行执行的,所以咋们需要把scheduler传入到 EffectReactive 里面,给this进行绑定


// effect 函数
export function effect(fn, options: any = {}) {
  const _effect = new EffectReactive(fn, options.scheduler)
  ... 省略其他
  }
  // class EffectReactive 中做以下修改
  constructor(fn, public scheduler?) {
    this.fn = fn
    // 把scheduler 绑定在this当中,方便track中调用
    this.scheduler = scheduler
  }
  // track 函数做以下修改
  deps.forEach(effect => {
    if (effect.scheduler) {
      effect.scheduler()
    } else {
      effect.run()
    }
  })


这样的话,scheduler 的测试用例就能通过了, scheduler 的作用个人觉得可以用于 频繁修改数据,需要响应式,有点类似节流操作


stop


stop的作用是 停止数据响应,只有手动触发run的函数,数据才能够完成响应

,请查看测试用例


test("stop 停止响应", () => {
    let dummy;
    const obj = reactive({ prop: 1 });
    const runner = effect(() => {
      dummy = obj.prop;
    });
    obj.prop = 2;
    expect(dummy).toBe(2);
    stop(runner);
    obj.prop = 3
    expect(dummy).toBe(2);
    // stopped effect should still be manually callable
    runner();
    expect(dummy).toBe(3);
  });


看到stop是需要传入一个runner,这个runner是啥,就是咋们的effect函数的返回值,所以先改造下effect函数


  const runner = _effect.run.bind(_effect)
  return runner


还需要一个stop函数


/**
 * 停止响应式更新
 * @param runner 
 */
export function stop(runner) {
// 这里临时代码
  runner.stop()
}


这里请思考, runner 是effect, 控制run 是在 class EffectActive中,所以咋们还需要来改造下effect函数,把effect绑定在runner上


  const runner = _effect.run.bind(_effect)
  // 这样就可以在class EffectActive中 进行stop控制了
  runner.effect = _effect
  return runner


对于对外暴露的stop也需要做相应的变化


 runner.effect.stop()


接下来在class EffectActive中 实现stop函数,请分析下stop函数应该怎么实现?


1.需要控制run函数的执行,是不是只需要把trigger中收集到的依赖进行清空哇,😄


2.trigger中只会收集依赖,咋们怎么进行反向收集呢? 只需要在class EffectActive中用一个数组来接收,然后在trigger中来进行反向收集


3.在class EffectActive 中来实现清空操作即可


修改代码


// 在class EffectActive 中增加以下代码
export class EffectReactive {
  fn: Function;
  // 保存正则执行的effect,用于清除
  deps = []
  // 省略构造函数和run方法
  stop(){
   effect.deps.forEach(effectSet => {
    effectSet.delete(effect)
  })
  effect.deps.length = 0
  }
 }
 // 在trigger中进行反向收集
 // 收集依赖
  deps.add(activeEffect)
  activeEffect.deps.push(deps)


这样的话就可以完成测试用例了😄


在这里还可以进行优化下stop的调用,就是说在同一个 EffectActive实例中只调用一次,解决办法则是在class 中加一个变量(active)锁即可,详情查看


插曲


这里有一个问题,如果测试用例的 obj.prop = 3 改成 obj.prop++,测试用例就会有问题啦🙄🙄🙄,分析下问题,obj.prop = 3 和 obj.prop++的区别是,前者只触发set方法,而后者是先触发get方法,然后在触发set方法,触发了get方法是不是又会触发trigger来收集依赖哇,所以 obj.prop++ 在测试用例是会报错的哦!


那么如何解决呢?


咋们一起来分析下:


1.咋们是不是需要在trigger中来控制是否需要依赖收集,这里是不是可以定义一个全局变量(shouldTrack)默认是false


2.在 class EffectActive 中的run方法里面来控制变量,在run之前需要,run完之后就不需要了,如果是调用了stop后调用run就直接执行fn即可


改造源码


// 在track中加上一个控制条件
 if (!shouldTrack) return
 // 修改 class EffectActive 
 // 调用stop后不需要收集依赖
    if (!this.active) {
      activeEffect = this;
      return this.fn()
    }
    // 收集依赖
    shouldTrack = true;
    activeEffect = this;
    const result = this.fn();
    // 执行fn后停止收集依赖
    shouldTrack = false;
    return result


这样就可以通过测试用例了


onStop


onStop 是一个stop后的回调函数,这个功能我把测试用例写出来,实现留个各位看官老爷


test("events: onStop", () => {
    const onStop = jest.fn();
    const runner = effect(() => {}, {
      onStop,
    });
    stop(runner);
    expect(onStop).toHaveBeenCalled();
  });


结果


f9d24f3981f33f5637a61239e9940442.png


所有测试都完成通过

相关文章
|
21天前
|
开发工具 iOS开发 MacOS
基于Vite7.1+Vue3+Pinia3+ArcoDesign网页版webos后台模板
最新版研发vite7+vue3.5+pinia3+arco-design仿macos/windows风格网页版OS系统Vite-Vue3-WebOS。
181 11
|
5月前
|
缓存 JavaScript PHP
斩获开发者口碑!SnowAdmin:基于 Vue3 的高颜值后台管理系统,3 步极速上手!
SnowAdmin 是一款基于 Vue3/TypeScript/Arco Design 的开源后台管理框架,以“清新优雅、开箱即用”为核心设计理念。提供角色权限精细化管理、多主题与暗黑模式切换、动态路由与页面缓存等功能,支持代码规范自动化校验及丰富组件库。通过模块化设计与前沿技术栈(Vite5/Pinia),显著提升开发效率,适合团队协作与长期维护。项目地址:[GitHub](https://github.com/WANG-Fan0912/SnowAdmin)。
780 5
|
5天前
|
JavaScript 安全
vue3使用ts传参教程
Vue 3结合TypeScript实现组件传参,提升类型安全与开发效率。涵盖Props、Emits、v-model双向绑定及useAttrs透传属性,建议明确声明类型,保障代码质量。
52 0
|
2月前
|
缓存 前端开发 大数据
虚拟列表在Vue3中的具体应用场景有哪些?
虚拟列表在 Vue3 中通过仅渲染可视区域内容,显著提升大数据列表性能,适用于 ERP 表格、聊天界面、社交媒体、阅读器、日历及树形结构等场景,结合 `vue-virtual-scroller` 等工具可实现高效滚动与交互体验。
304 1
|
2月前
|
缓存 JavaScript UED
除了循环引用,Vue3还有哪些常见的性能优化技巧?
除了循环引用,Vue3还有哪些常见的性能优化技巧?
159 0
|
3月前
|
JavaScript
vue3循环引用自已实现
当渲染大量数据列表时,使用虚拟列表只渲染可视区域的内容,显著减少 DOM 节点数量。
102 0
|
5月前
|
JavaScript API 容器
Vue 3 中的 nextTick 使用详解与实战案例
Vue 3 中的 nextTick 使用详解与实战案例 在 Vue 3 的日常开发中,我们经常需要在数据变化后等待 DOM 更新完成再执行某些操作。此时,nextTick 就成了一个不可或缺的工具。本文将介绍 nextTick 的基本用法,并通过三个实战案例,展示它在表单验证、弹窗动画、自动聚焦等场景中的实际应用。
438 17
|
6月前
|
JavaScript 前端开发 算法
Vue 3 和 Vue 2 的区别及优点
Vue 3 和 Vue 2 的区别及优点
|
6月前
|
存储 JavaScript 前端开发
基于 ant-design-vue 和 Vue 3 封装的功能强大的表格组件
VTable 是一个基于 ant-design-vue 和 Vue 3 的多功能表格组件,支持列自定义、排序、本地化存储、行选择等功能。它继承了 Ant-Design-Vue Table 的所有特性并加以扩展,提供开箱即用的高性能体验。示例包括基础表格、可选择表格和自定义列渲染等。
441 6
|
5月前
|
JavaScript 前端开发 API
Vue 2 与 Vue 3 的区别:深度对比与迁移指南
Vue.js 是一个用于构建用户界面的渐进式 JavaScript 框架,在过去的几年里,Vue 2 一直是前端开发中的重要工具。而 Vue 3 作为其升级版本,带来了许多显著的改进和新特性。在本文中,我们将深入比较 Vue 2 和 Vue 3 的主要区别,帮助开发者更好地理解这两个版本之间的变化,并提供迁移建议。 1. Vue 3 的新特性概述 Vue 3 引入了许多新特性,使得开发体验更加流畅、灵活。以下是 Vue 3 的一些关键改进: 1.1 Composition API Composition API 是 Vue 3 的核心新特性之一。它改变了 Vue 组件的代码结构,使得逻辑组
1526 0