实现vue3响应式系统核心-watch

简介

今天我们来看看 watch 的实现。 watch本质就是观测一个响应式数据,当数据发生变化时通知并执行相应的回调函数。实际上,watch的实现本质上就是利用了 effect 以及 options.scheduler选项。

代码地址: https://github.com/SuYxh/share-vue3

代码并没有按照源码的方式去进行组织,目的是学习、实现 vue3 响应式系统的核心,用最少的代码去实现最核心的能力,减少我们的学习负担,并且所有的流程都会有配套的图片,图文 + 代码,让我们学习更加轻松、快乐。

每一个功能都会提交一个 commit ,大家可以切换查看,也顺变练习练习 git 的使用。

watch 实现

在一个副作用函数中访问响应式数据 obj.foo,通过前面的介绍,我们知道这会在副作用函数与响应式数据之间建立联系,当响应式数据变化时,会触发副作用函数重新执行。但有一个例外,即如果副作用函数存在 scheduler选项,当响应式数据发生变化时,会触发 scheduler调度函数执行,而非直接触发副作用函数执行。从这个角度来看,其实 scheduler调度函数就相当于一个回调函数,而 watch的实现就是利用了这个特点。

编写单测

假设obj是一个响应数据,使用 watch 函数观测它,并传递一个回调函数,当修改响应式数据的值时,会触发该回调函数执行。

js 复制代码
it("base watch", () => {
  const mockFn = vi.fn();

  // 创建响应式对象
  const obj = reactive({ foo: 100 });

  watch(obj, () => {
    mockFn()
  });

  obj.foo ++

  expect(mockFn).toHaveBeenCalledTimes(1);
});

代码实现

下面是最简单的 watch 函数的实现:

js 复制代码
// watch 函数接收两个参数,source 是响应式数据,cb 是回调函数
function watch(source, cb) {
  effect(
    // 触发读取操作,从而建立联系
    () => source.foo,
    {
      scheduler() {
        // 当数据变化时,调用回调函数 cb
        cb();
      }
    }
  );
}

运行单测

是不是很简单!

支持所有属性监听

在来看一个 case

js 复制代码
 it("watch 多个属性", () => {
    const mockFn = vi.fn();

    // 创建响应式对象
    const obj = reactive({ foo: 100, bar: 200, age: 10 });

    watch(obj, () => {
      mockFn()
    });
    
    obj.bar ++

    obj.age ++

    expect(mockFn).toHaveBeenCalledTimes(2);
  });

执行一下

修改了 2 个属性值,回调函数应该执行 2 次,但是回调函数并没有执行,这是为什么呢?

前面的watch函数中写死了 source.foo, source.bar没有进行依赖收集,自然回调函数就不会执行了。

那么就需要封装一个通用的读取操作:

js 复制代码
function traverse(value, seen = new Set()) {
  // 如果要读取的数据是原始值,或者已经被读取过了,那么什么都不做
  if (typeof value !== 'object' || value === null || seen.has(value)) return;

  // 将数据添加到 seen 中,代表遍历地读取过了,避免循环引用引起的死循环
  seen.add(value);

  // 暂时不考虑数组等其他结构
  // 假设 value 就是一个对象,使用 for...in 读取对象的每一个值,并递归地调用 traverse 进行处理
  for (const k in value) {
    traverse(value[k], seen);
  }

  return value;
}

修改 watch 如下:

js 复制代码
function watch(source, cb) {
  effect(
    // 触发读取操作,从而建立联系
    () => traverse(source),
    {
      scheduler() {
        // 当数据变化时,调用回调函数 cb
        cb();
      }
    }
  );
}

traverse 方法的作用,读取传入对象的所有属性,然后构建依赖关系,任何一个属性值发生变化,都会执行回调函数。

再次执行单测:

相关代码在 commit: (5063b6b)watch 基础实现 ,git checkout 5063b6b 即可查看。

支持函数参数

看一个 case

js 复制代码
it('支持 getter 函数', () => {
  const mockFn = vi.fn();

  // 创建响应式对象
  const obj = reactive({ foo: 100, bar: 200, age: 10 });

  watch(() => obj.age, () => {
    mockFn()
  });

  obj.age ++

  expect(mockFn).toHaveBeenCalledTimes(1);
})

运行一下

发现没有通过,因为我们之前也没有实现对函数的支持,肯定不会通过。

在 watch 中增加一个对第一个参数的判断就好:

js 复制代码
export function watch(source, cb) {
  let getter;
  if (typeof source === 'function') {
    getter = source;
  } else {
    getter = () => traverse(source);
  }

  effect(
    // 触发读取操作,从而建立联系
    () => getter(),
    {
      scheduler() {
        // 当数据变化时,调用回调函数 cb
        cb();
      },
    }
  );
}

再次运行单测

这样就通过了。

相关代码在 commit: (0acd398)watch 支持函数参数 ,git checkout 0acd398 即可查看。

获取新值与旧值

看这个 case

js 复制代码
it('get newVal and oldVal', () => {
  const mockFn = vi.fn();

  // 创建响应式对象
  const obj = reactive({ foo: 100, bar: 200, age: 10 });

  let newValue = null
  let oldValue = null

  watch(() => obj.age, (newVal, oldVal) => {
    newValue = newVal
    oldValue = oldVal
  });

  obj.age ++

  expect(newValue).toBe(11);
  expect(oldValue).toBe(10);
})

不用运行,肯定跑不过,因为我们都没有去实现。

那么如何获得新值与旧值呢?这需要充分利用 effect 函数的 lazy 选项,如以下代码所示:

js 复制代码
function watch(source, cb) {
  let getter;
  if (typeof source === 'function') {
    getter = source;
  } else {
    getter = () => traverse(source);
  }

  // 定义旧值与新值
  let oldValue, newValue;

  // 使用 effect 注册副作用函数时,开启 lazy 选项,并把返回值存储到 effectFn 中以便后续手动调用
  const effectFn = effect(
    () => getter(),
    {
      lazy: true,
      scheduler() {
        // 在 scheduler 中重新执行副作用函数,得到的是新值
        newValue = effectFn();
        // 将旧值和新值作为回调函数的参数
        cb(newValue, oldValue);
        // 更新旧值,不然下一次会得到错误的旧值
        oldValue = newValue;
      }
    }
  );

  // 手动调用副作用函数,拿到的值就是旧值
  oldValue = effectFn();
}

其中最核心的改动是使用 lazy 选项创建了一个懒执行的 effect 。注意上面代码中最下面的部分,我们手动调用 effectFn 函数得到的返回值就是旧值,即第一次执行得到的值。当变化发生并触发 scheduler 调度函数执行时,会重新调用 effectFn 函数并得到新值,这样我们就拿到了旧值与新值,接着将它们作为参数传递给回调函数 cb 就可以了。最后一件非常重要的事情是,不要忘记使用新值更新旧值:oldValue = newValue,否则在下一次变更发生时会得到错误的旧值。

运行单测

相关代码在 commit: (5ac39a6)watch 获取新值与旧值 ,git checkout 5ac39a6 即可查看。

支持 immediate

看看这个 case

js 复制代码
it('支持 immediate', () => {
  const mockFn = vi.fn();

  // 创建响应式对象
  const obj = reactive({ foo: 100, bar: 200, age: 10 });

  let newValue = undefined
  let oldValue = undefined

  watch(() => obj.age, (newVal, oldVal) => {
    mockFn()
    newValue = newVal
    oldValue = oldVal
  }, {
    immediate: true
  });

  expect(mockFn).toHaveBeenCalledTimes(1);
  expect(newValue).toBe(10);
  expect(oldValue).toBe(undefined);


  obj.age ++
  expect(mockFn).toHaveBeenCalledTimes(2);
  expect(newValue).toBe(11);
  expect(oldValue).toBe(10);
})

又是熟悉的老套路,增加一个 options,代码如下:

js 复制代码
export function watch(source, cb, options) {
  let getter;
  if (typeof source === "function") {
    getter = source;
  } else {
    getter = () => traverse(source);
  }

  // 定义旧值与新值
  let oldValue, newValue;

  // 使用 effect 注册副作用函数时,开启 lazy 选项,并把返回值存储到 effectFn 中以便后续手动调用
  const effectFn = effect(() => getter(), {
    lazy: true,
    scheduler() {
      // 在 scheduler 中重新执行副作用函数,得到的是新值
      newValue = effectFn();
      // 将旧值和新值作为回调函数的参数
      cb(newValue, oldValue);
      // 更新旧值,不然下一次会得到错误的旧值
      oldValue = newValue;
    },
  });

  if (options.immediate) {
    // 当 immediate 为 true 时立即执行 effectFn,从而触发回调执行
    newValue = effectFn();
    cb(newValue, oldValue);
    oldValue = newValue;
  } else {
    // 手动调用副作用函数,拿到的值就是旧值
    oldValue = effectFn();
  }
}

再次运行单测:

相关代码在 commit: (fd0e845)watch 支持 immediate ,git checkout fd0e845 即可查看。

重构

我们可以发现 scheduler 方法中的逻辑和 options.immediatetrue 时执行的逻辑一样,那么就可以进行封装:

js 复制代码
export function watch(source, cb, options) {
  let getter;
  if (typeof source === "function") {
    getter = source;
  } else {
    getter = () => traverse(source);
  }

  // 定义旧值与新值
  let oldValue, newValue;

  // 提取 scheduler 调度函数为一个独立的 job 函数
  const job = () => {
    newValue = effectFn();
    cb(newValue, oldValue);
    oldValue = newValue;
  }

  // 使用 effect 注册副作用函数时,开启 lazy 选项,并把返回值存储到 effectFn 中以便后续手动调用
  const effectFn = effect(() => getter(), {
    lazy: true,
    scheduler: job,
  });

  if (options.immediate) {
    // 当 immediate 为 true 时立即执行 job,从而触发回调执行
    job()
  } else {
    // 手动调用副作用函数,拿到的值就是旧值
    oldValue = effectFn();
  }
}

执行测试命令

pnpm test

我们可以看到,我们修改了代码,之前的 case 出了问题

原因是当我们没有传 options 的时候,options 相当于是 undefined, 取值自然会出错,我们添加一个默认值就好。

可以看到就全部通过了,单测为我们的代码保驾护航!

相关代码在 commit: (c0721bd)watch 代码优化 ,git checkout c0721bd 即可查看。

流程图

整体流程图如下:

一个前端小学生的学习之路,如果你喜欢前端,我们可以一起进行学习、交流、共建。可以添加好友,结伴学习,成长的路上不孤单!

相关推荐
小马哥编程15 分钟前
Function.prototype和Object.prototype 的区别
javascript
王小王和他的小伙伴38 分钟前
解决 vue3 中 echarts图表在el-dialog中显示问题
javascript·vue.js·echarts
学前端的小朱42 分钟前
处理字体图标、js、html及其他资源
开发语言·javascript·webpack·html·打包工具
outstanding木槿1 小时前
react+antd的Table组件编辑单元格
前端·javascript·react.js·前端框架
好名字08211 小时前
前端取Content-Disposition中的filename字段与解码(vue)
前端·javascript·vue.js·前端框架
摇光932 小时前
js高阶-async与事件循环
开发语言·javascript·事件循环·宏任务·微任务
胡西风_foxww2 小时前
【ES6复习笔记】Class类(15)
javascript·笔记·es6·继承··class·静态成员
布兰妮甜2 小时前
使用 WebRTC 进行实时通信
javascript·webrtc·实时通信
艾斯特_2 小时前
JavaScript甘特图 dhtmlx-gantt
前端·javascript·甘特图
飞翔的渴望2 小时前
react18与react17有哪些区别
前端·javascript·react.js