实现vue3响应式系统核心-ref相关实现

实现vue3响应式系统核心-ref相关实现
ref相关

简介

我们知道Proxy是用于拦截对象,那么针对原始值类型的数据应该怎么处理呢?

JavaScript 中,原始值是按值传递的,而非按引用传递。如果一个函数接收原始值作为参数,那么形参与实参之间没有引用关系,它们是两个完全独立的值,对形参的修改不会影响实参。

因此想要将原始值变成响应式数据,就必须对其做一层包裹,也就是我们接下来要介绍的 ref。

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

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

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

ref概念

由于 Proxy 的代理目标必须是非原始值,所以我们没有任何手段拦截对原始值的操作,只有使用一个非原始值去“包裹”原始值,例如使用一个对象包裹原始值:

const wrapper = {
  value'vue'
};

// 可以使用 Proxy 代理 wrapper,间接实现对原始值的拦截
const name = reactive(wrapper);

name.value; // vue

// 修改值可以触发响应
name.value = 'vue3';

但这样做会导致两个问题:

  • 用户为了创建一个响应式的原始值,不得不顺带创建一个包裹对象,比如 wrapper
  • 包裹对象由用户定义,而这意味着不规范。用户可以随意命名,例如 wrapper.valuewrapper.val 都是可以的。

所以 vue 做了一层封装,就算他不做,以后也会有人做,一旦各种方案都有,就会比较混乱,还是官方去做了这件事。

实现 ref

单元测试

it("ref 基础能力", () => {
  const mockFn = vi.fn();

  // 创建原始值的响应式数据
  const refVal = ref(1);

  effect(() => {
    mockFn();
    // 在副作用函数内通过 value 属性读取原始值
    console.log(refVal.value);
  });
  expect(mockFn).toHaveBeenCalledTimes(1);

  // 修改值能够触发副作用函数重新执行
  refVal.value = 2;
  expect(mockFn).toHaveBeenCalledTimes(2);
});

代码实现

// 封装一个 ref 函数
function ref(val{
  // 在 ref 函数内部创建包裹对象
  const wrapper = {
    value: val
  };
  // 将包裹对象变成响应式数据
  return reactive(wrapper);
}

运行单测

实现vue3响应式系统核心-ref相关实现
image-20240122232328263

没有任何问题。一个最基础的 ref 就实现了。

实现isRef

如何区分 refVa 到底是原始值的包裹对象,还是一个非原始值的响应式数据,如以下代码所示:

const refVal1 = ref(1)
const refVal2 = reactive({ value1 })

这段代码中的 refVal1refVal2有什么区别呢?

从我们的实现来看,它们没有任何区别。但是,我们有必要区分一个数据到底是不是 ref,因为后续会有自动脱 ref 能力。

单元测试

it("is ref", () => {
  const refVal1 = ref(1)
  const refVal2 = reactive({ value1 })

  const flag1 = isRef(refVal1)
  const flag2 = isRef(refVal2)

  expect(flag1).toBe(true)
  expect(flag2).toBe(false)
})

实现

function ref(val{
  const wrapper = {
    value: val
  };

  // 使用 Object.defineProperty 在 wrapper 对象上定义一个不可枚举的属性 __v_isRef,并且值为 true
  Object.defineProperty(wrapper, '__v_isRef', {
    valuetrue
  });

  return reactive(wrapper);
}

使用 Object.defineProperty 为包裹对象 wrapper 定义了一个不可枚举且不可写的属性__v_isRef,它的值为true,代表这个对象是一个ref,而非普通对象。

在实现一下 isRef 函数:

function isRef(refVal{
  return !!refVal['__v_isRef']
}

运行单测

实现vue3响应式系统核心-ref相关实现
image-20240122233701657

实现 toRef

想必响应式丢失问题,大家都不陌生。这里介绍一下这个现象:

export default {
  setup() {
    // 响应式数据
    const obj = reactive({ foo1bar2 });

    // 将数据暴露到模板中
    return {
      ...obj
    };
  }
};

如果我们在模板中直接这样写:

<p>{{ foo }} / {{ bar }}</p>

那么当修改数据, obj.foo = 100 时,模板并不会发生变化。

为什么会导致响应丢失呢?这是由展开运算符(…)导致的。

return {
 ...obj
}

return {
 foo1,
 bar:2
}

这 2 种写法是等价的。这其实就是返回了一个普通对象,它不具有任何响应式能力。只有经过reactive代理过的才是响应式数据。

那么解构呢?

const { foo, bar } = reactive({ foo1bar2 });

return {
 foo, 
 bar
}

解构的本质: 创建新变量 -> 枚举属性 -> 复制属性并赋值。

一样也相当于是返回了一个普通对象。

单元测试

it("toRef-1", () => {
  const mockFn = vi.fn();

  // obj 是响应式数据
  const obj = reactive({ foo1bar2 });

  // 将响应式数据展开到一个新的对象 newObj
  const newObj = {
    ...obj,
  };

  effect(() => {
    mockFn()
    // 在副作用函数内通过新的对象 newObj 读取 foo 属性值
    console.log(newObj.foo);
  });
  expect(mockFn).toHaveBeenCalledTimes(1);


  // 很显然,此时修改 obj.foo 并不会触发响应
  obj.foo = 100;
  expect(mockFn).toHaveBeenCalledTimes(2);
});

问题分析

创建一个响应式的数据对象 obj,然后使用展开运算符得到一个新的普通对象 newObj。这里的关键点在于,副作用函数内访问的是普通对象 newObj,它没有任何响应能力,所以当我们尝试修改 obj.foo的值时,不会触发副作用函数重新执行。

解决

我们修改一下单测,

it("toRef-2", () => {
  const mockFn = vi.fn();

  // obj 是响应式数据
  const obj = reactive({ foo1bar2 });

  // 将响应式数据展开到一个新的对象 newObj
  const newObj = {
    foo: {
      get value() {
        return obj.foo
      }
    },
    bar: {
      get value() {
        return obj.bar
      }
    }
  };

  effect(() => {
    mockFn()
    // 在副作用函数内通过新的对象 newObj 读取 foo 属性值
    console.log(newObj.foo.value);
  });
  expect(mockFn).toHaveBeenCalledTimes(1);


  // 很显然,此时修改 obj.foo 并不会触发响应
  obj.foo = 100;
  expect(mockFn).toHaveBeenCalledTimes(2);
});

运行看看:

实现vue3响应式系统核心-ref相关实现
image-20240122235350514

没有问题。

封装

根据上述 case 可以看出,当在副作用函数内读取newObj.foo时,等价于间接读取了obj.foo的值。这样响应式数据自然能够与副作用函数建立响应联系。于是,当我们尝试修改 obj.foo的值时,能够触发副作用函数重新执行。

于是我们可以进行一个简单的封装

function toRef(obj, key{
  const wrapper = {
    get value() {
      return obj[key];
    }
  };

  return wrapper;
}

运行单测

我们修改一下 case 如下:

it("toRef-1", () => {
  const mockFn = vi.fn();

  // obj 是响应式数据
  const obj = reactive({ foo1bar2 });

  // 将响应式数据展开到一个新的对象 newObj
  // const newObj = {
  //   ...obj,
  // };

  const newObj = {
    foo: toRef(obj, 'foo'),
    bar: toRef(obj, 'bar')
  }

  effect(() => {
    mockFn()
    // 在副作用函数内通过新的对象 newObj 读取 foo 属性值
    console.log(newObj.foo.value);
  });
  expect(mockFn).toHaveBeenCalledTimes(1);


  // 很显然,此时修改 obj.foo 并不会触发响应
  obj.foo = 100;
  expect(mockFn).toHaveBeenCalledTimes(2);
});

就可以通过了

实现vue3响应式系统核心-ref相关实现
image-20240123000000075

优化

将通过 toRef 转换后得到的结果视为真正 ref数据,为此我们需要为 toRef函数增加一层拦截:

function toRef(obj, key{
  const wrapper = {
    get value() {
      return obj[key];
    },
  };

  // 定义 __v_isRef 属性
  Object.defineProperty(wrapper, "__v_isRef", {
    valuetrue,
  });

  return wrapper;
}

在编写一个单测

it('toRef的数据是一个 ref', () => {
  const obj = reactive({ foo1bar2 });
  const foo = toRef(obj, 'foo')
  const flag = isRef(foo)
  expect(flag).toBe(true)
})

运行一下

实现vue3响应式系统核心-ref相关实现
image-20240123000631632

也是没有问题

实现toRefs

上文实现了toRef,但如果响应式数据 obj 的键非常多,我们还是要花费很大力气来做这一层转换。为此,我们可以封装 toRefs 函数,来批量地完成转换。

单元测试

it('toRefs', () => {
  const obj = reactive({ foo1bar2 });
  const refObj = toRefs(obj)
  const flag1 = isRef(refObj.foo)
  const flag2 = isRef(refObj.bar)

  expect(flag1).toBe(true)
  expect(flag2).toBe(true)
})

代码实现

function toRefs(obj{
  const ret = {};
  // 使用 for...in 循环遍历对象
  for (const key in obj) {
    // 逐个调用 toRef 完成转换
    ret[key] = toRef(obj, key);
  }
  return ret;
}

运行单测

实现vue3响应式系统核心-ref相关实现
image-20240123001112684

实现 proxyRefs

toRefs函数的确解决了响应丢失问题,但同时也带来了新的问题。由于 toRefs会把响应式数据的第一层属性值转换为 ref,因此必须通过 value属性访问值。使用过 vue3 的小伙伴,想必都知道,我们在模板中使用 ref 数据的时候并不需要 .value,这又是怎么回事呢?

这也就是我们说的:自动脱 ref。指的是属性的访问行为,即如果读取的属性是一个 ref,则直接将该 ref对应的value属性值返回。

单元测试

it('proxyRefs', () => {
  const obj = reactive({ foo1bar2 });
  const newObj = proxyRefs({ ...toRefs(obj) })

  expect(newObj.foo).toBe(1)
  expect(newObj.bar).toBe(2)
})

代码实现

function proxyRefs(target{
  return new Proxy(target, {
    get(target, key, receiver) {
      const value = Reflect.get(target, key, receiver);
      // 自动脱 ref 实现:如果读取的值是 ref,则返回它的 value 属性值
      return value.__v_isRef ? value.value : value;
    }
  });
}

proxyRefs函数,该函数接收一个对象作为参数,并返回该对象的代理对象。代理对象的作用是拦截get操作,当读取的属性是一个 ref 时,则直接返回该refvalue属性值,这样就实现了自动脱 ref

运行单测

实现vue3响应式系统核心-ref相关实现
image-20240123002017124

没有问题

优化

既然读取属性的值有自动脱 ref的能力,对应地,设置属性的值也应该有自动为 ref设置值的能力,只需要添加对应的 set 拦截函数即可。

function proxyRefs(target{
  return new Proxy(target, {
    get(target, key, receiver) {
      const value = Reflect.get(target, key, receiver);
      return value.__v_isRef ? value.value : value;
    },
    set(target, key, newValue, receiver) {
      // 通过 target 读取真实值
      const value = target[key];
      // 如果值是 Ref,则设置其对应的 value 属性值
      if (value.__v_isRef) {
        value.value = newValue;
        return true;
      }
      return Reflect.set(target, key, newValue, receiver);
    }
  });
}

这么设计旨在减轻用户的心智负担,因为在大部分情况下,用户并不知道一个值到底是不是 ref 。有了自动脱 ref的能力后,用户在模板中使用响应式数据时,将不再需要关心哪些是 ref,哪些不是 ref

运行测试

pnpm test
实现vue3响应式系统核心-ref相关实现
image-20240123003546698


原文始发于微信公众号(WEB大前端):实现vue3响应式系统核心-ref相关实现

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/225237.html

(0)
小半的头像小半

相关推荐

发表回复

登录后才能评论
极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!