Vue 3 源码解析(5)ref 的实现(上)

1,549 阅读5分钟

Vue3 源码系列文章会持续更新,全部文章请查阅我的掘金专栏

本系列的文章 demo 存放于我的 Github 仓库,推荐大家下载和调试,进而加深理解。

一. ref 的基础实现

在前三章我们介绍了引用类型的响应式实现,其底层采用了 Proxy 去拦截用户对各属性的操作。
然而 Proxy 接口只能代理引用类型,如果希望对一个原始类型实现响应式操作,只能另辟蹊径。其中一个取巧的办法,是将原始类型包裹为一个对象,再通过 getter 和 setter 的方法对其操作进行拦截:

const ref = (rawValue) => {
  return {
    get value() {  // 拦截访问操作
      // TODO: track
      console.log('这里需要追踪依赖...');
      return rawValue;
    },
    set value(newVal) {  // 拦截设置操作
      // TODO: trigger
      console.log('这里需要触发副作用函数...');
      rawValue = newVal;
    }
  }
}

const msg = ref('Hello!');
console.log(msg.value);
msg.value = 'Bye~!';
console.log(msg.value);

这里没有使用 Proxy 的原因也很简单 —— 原始类型不像引用类型那样需要操作各种属性,常规只会访问/设置其本身。

我们还需要在 getter 访问器中对原始类型进行依赖收集,并在 setter 设置其中触发收集到的副作用函数。不过这块的处理,只需要复用前几章已经封装好的 trackEffects 和 triggerEffects 方法即可。

我们进一步封装 ref 接口,让其返回一个类的示例,方便定义一些内部属性。
然后引入 trackEffects 和 triggerEffects 方法来追踪和触发依赖:

/** ref.js **/

import {
    trackEffects,
    triggerEffects
} from './effect.js'

import { hasChanged } from './shared.js'

export function ref(value) {
    return new RefImpl(value)
}

class RefImpl {
    constructor(value) {
        this._value = value;
    }

    get value() {
        trackRefValue(this);  // 依赖收集
        return this._value;
    }

    set value(newVal) {
        if (hasChanged(newVal, this._value)) {
            this._value = newVal;
            triggerRefValue(this);  // 触发收集到的副作用函数
        }
    }
}

export function trackRefValue(ref) {
    trackEffects(ref.dep || (ref.dep = new Set()))
}

export function triggerRefValue(ref) {
    triggerEffects(ref.dep)
}

为了避免重复处理传入的原始类型,可以在 ref 入口处新增判断:

/** ref.js **/

export function isRef(r) {  // 新增方法
    return !!(r && r.__v_isRef === true)
}

export function ref(value) {
    if (isRef(value)) {  // 新增判断
        return value
    }
    return new RefImpl(value)
}

class RefImpl {
    constructor(value) {
        this.__v_isRef = true;  // 新增标记
        this._value = value;
    }
    // 略...
}

如上,我们为 ref 实例(准确的说,是 RefImpl 的实例)新增了一个 __v_isRef 标记,在 ref 初始化时先判断传入值是否含有该标记,若有则表示传入值已是 ref 实例,直接返回即可。

至此我们便实现了一个最基础的原始类型响应式处理接口,读者可以点击这里获取示例代码。

二. 兼容非原始类型

2.1 RefImpl 中的兼容处理

在前面我们只考虑了对原始类型的处理,如果用户传入了一个引用类型,还需要在 RefImpl 中对其进行兼容处理,将实例属性 value 指向经由 reactive 接口处理过的代理对象。

改动如下:

/** ref.js **/

import { toRaw, toReactive } from './reactive.js'  // 新增

class RefImpl {
    constructor(value) {
        this.__v_isRef = true;
        this._rawValue = toRaw(value);  // 新增 _rawValue 属性用于保管原始值
        this._value = toReactive(value);  // 调用 toReactive
    }

    get value() {
        trackRefValue(this);
        return this._value;
    }

    set value(newVal) {
        newVal = toRaw(newVal);  // 新增
        if (hasChanged(newVal, this._rawValue)) {  // 修改 this._value 为 this._rawValue
            this._rawValue = newVal;  // 修改 this._value 为 this._rawValue
            this._value = toReactive(newVal);  // 新增
            triggerRefValue(this);
        }
    }
}

可以看到,在构造函数中,我们使用了 toRaw 接口来获取传入参数的原始值,并存入 _rawValue 属性中,方便在 setter 中对比新旧的值是否一致。
这样即使用户传入 ref 的是一个被 reactive 代理过的对象,也不会有问题。

另外 this._value 都指向了由 toReactive 处理过的值,此举也是为了兼容用户传入对象,甚至是被 reactive 接口代理过的对象的场景。

现在我们试试往 ref 里传入一个对象/响应式对象,会看到它们都能按预期正常执行:

<body>
  <div></div>
  <div></div>
</body>

<script type="module">
  import { ref } from 'https://codepen.io/vajoy/pen/mdxqzzP.js';
  import { effect } from 'https://codepen.io/vajoy/pen/ExEoxPB.js';
  import { reactive } from 'https://codepen.io/vajoy/pen/VwXywKa.js';
  
  const divs = document.querySelectorAll('div');
  const msg1 = ref({info: 'Hello!'});
  const msg2 = ref(reactive({info: 'Hello!'}));
  effect(() => {
    divs[0].innerText = msg1.value.info;
    divs[1].innerText = msg2.value.info;
  });

  setTimeout(() => {
    msg1.value = {info: 'Bye!'};
    msg2.value = {info: 'Bye!'};
  }, 1000)
</script>

点击查看 codepen 在线示例

2.2 trackRefValue 中的兼容处理

从上面的示例可以知道,如果直接重置 ref 实例的 value 方法,是可以正常执行的。
但如果用户传入了一个对象,且要修改 value 指向的响应式对象属性,会出现报错:

<body>
  <div></div>
</body>

<script type="module">

  import { ref } from './ref.js';
  import { effect } from './effect.js';

  const div = document.querySelector('div');
  const msg = ref({info: 'Hello!'});
  
  effect(() => {
    div.innerText = msg.value.info;
  });

  setTimeout(() => {
    // 直接修改响应式对象的 info 属性,
    // 会报错 Cannot read properties of undefined (reading 'deps')
    msg.value.info = 'Bye~';
  }, 1000)

</script>

这是因为我们已经把传入对象交给 toReactive 接口处理,它会经由 reactive 接口去收集依赖和触发副作用函数,当执行 msg.value.info 时,会先触发 info 属性收集到的副作用函数,并重置 activeEffect

/** effect.js **/

export let activeEffect;

class ReactiveEffect {
    // 略...
    run() {
        try {
            // 略...
        } finally {
            activeEffect = this.parent;  // 重置
        }
    }
}

紧接着 msg.value 被访问时会触发 trackEffects 方法,该方法内找不到 activeEffect.deps 进而报错。

因此我们需要在执行 trackEffects 方法前,先判断 reactive 是否已经先对传入对象做了响应处理,如果是,则不再多此一举。
改动如下:

/** ref.js **/

import {
    shouldTrack,  // 新增
    activeEffect,  // 新增
    trackEffects,
    triggerEffects
} from './effect.js'

export function trackRefValue(ref) {
    if (shouldTrack && activeEffect) {  // 新增
        trackEffects(ref.dep || (ref.dep = new Set()))
    }
}

其中判断 shouldTrack 是为了避免数组栈方法循环递归的问题,具体可以回顾《reactive 的实现(上)》3.3.2 小节的内容。

三. shallowRef 的实现

往 ref 里传入一个对象并非 Vue 所提倡的行为,因为那样一方面使用了 getter 和 setter,一方面又会调用 reative 接口使用 Proxy 去深层代理该对象。

另外,ref 代理后的实例,本质应该是结构非常简单的数据,只关注其 value 属性的获取和修改即可。
下面的写法有悖 ref 的理念:

refInstance.value.certainProp = 'xxx';

对此 Vue 提供了一个 shallowRef 的接口,当传入对象时,不再会被 Proxy 代理,也只允许用户修改 value 属性。
官方 demo 如下:

const state = shallowRef({ count: 1 })

state.value.count = 2  // 无法触发 trigger

state.value = { count: 2 }  // 可以触发 trigger

shallowRef 的实现其实很简单,只是在 RefImpl 中新增了 isShallow 参数并做判断,如果是 shallow 模式则绕过 toReactive 接口:

/** ref.js **/

class RefImpl {
    constructor(value, isShallow) {  // 新增 isShallow 参数
        this.__v_isRef = true;
        // this._rawValue = newVal;
        // this._value = toReactive(newVal);
        this._rawValue = isShallow ? value : toRaw(value);  // 新增
        this._value = isShallow ? value : toReactive(value)  // 新增
        this.__v_isShallow = isShallow;  // 新增
    }

    get value() {
        trackRefValue(this);
        return this._value;
    }

    set value(newVal) {
        // newVal = toRaw(newVal);
        newVal = this.__v_isShallow ? newVal : toRaw(newVal);  // 新增
        if (hasChanged(newVal, this._rawValue)) {
            this._rawValue = newVal;
            // this._value = toReactive(newVal);
            this._value = this.__v_isShallow ? newVal : toReactive(newVal);  // 新增
            triggerRefValue(this);
        }
    }
}

调用 RefImpl 的两个接口 ref 和 shallowRef 传入对应的 isShallow 参数即可:

/** ref.js **/

export function ref(value) {
    // if (isRef(value)) {
    //     return value
    // }
    return createRef(value, false)  // 新增 false 参数
}

// 新增
export function shallowRef(value) {
    return createRef(value, true)
}

// 新增
function createRef(rawValue, shallow) {
    if (isRef(rawValue)) {
        return rawValue
    }
    return new RefImpl(rawValue, shallow)
}

点击查看 codepen 在线示例