为什么在 script 中 ref 需要打点 value

为什么在 script 中 ref 需要打点 value.png

前言:随着 Vue3 越来越稳定了,各位小伙伴都应该都开始在新项目中使用它了,但是当你使用 Vue3 越来越熟练,不知道你对 Vue3 的新 API ref 是否有所疑问️。

为啥我在 template 中可以直接使用 ref 定义的变量,在 script 中却要通过打点 value 来获取响应式?

今天,我们就来研究研究。

在使用 Vue3 的 ref API 的时候,不知道你有没有疑问,那就是为什么在 template 标签中使用 ref 可以直接使用 ref 变量,但是在 script 中去修改或读取 ref 定义的变量却要通过打点 value 来实现。

ref 的使用

ref 主要有两个作用:

  1. 响应基本数据类型
  2. 获取 DOM 的一种方式

我用 Vue3 语法写了个加/减法器,和一个获取 DOM 聚焦功能的组件,来演示下 ref 的两个用法。

源代码如下:



动图演示效果如下:

2021-07-20 16-13-29.2021-07-20 16_13_50.gif

ref 是如何实现的

OK,接下来,我来带你研究下 ref 的源码,只有清楚了原理,才能准确的使用,在剖析之前有一个前置知识是你必须要掌握的,那就是 Object.defineProperty(obj, prop, descriptor) 的使用,如果你还不会,请参考 MDN 学习,传送门

这是 Vue2 双向绑定的原理,在 Vue3 中的 ref 也使用这个 API 来做基本数据类型的双向绑定。其实我说到这,你基本就能才出来开头的灵魂提问了。

为啥我在 template 中可以直接使用 ref 定义的变量,在 script 中却要通过打点 value 来获取响应式?
答: 因为 Object.defineProperty(obj, prop, descriptor) 只能检测对象属性的读取,所以基本类型的读取和修改只能通过监听对象的 value 属性来实现。

看下 Vue3 源码的实现,代码的在线地址 https://github.com/vuejs/vue-next/blob/master/packages/reactivity/src/ref.ts,相对位置在 packages/reactivity/src/ref.ts 中。

关于 ref 的 API 总共才 341 行,我删删减减加上注释得到了下面的主逻辑代码,顺着注释的顺序看即可:

import { isArray, isObject, hasChanged } from '@vue/shared'
import { reactive, isProxy, toRaw, isReactive } from './reactive'
import { createDep, Dep } from './dep'

// 1. 一个函数重载定义 ref 函数的实现,我们看你最后一个重载函数就行了
export function ref(value: T): ToRef
export function ref(value: T): Ref>
export function ref(): Ref
export function ref(value?: unknown) {
    // 2. 看这里,调用了 createRef 函数
    return createRef(value)
}

// 如果传入的是复杂数据类型调用 reactive 来做数据劫持
const convert = (val: T): T =>
  isObject(val) ? reactive(val) : val

// 6. 来到了 RefImpl 类
class RefImpl {
    private _value: T
    private _rawValue: T

    public dep?: Dep = undefined
    public readonly __v_isRef = true

    constructor(value: T, public readonly _shallow = false) {
        this._rawValue = _shallow ? value : toRaw(value)
        // 初始值用 value 接收,赋值给 this 实例 的 _value 上
        this._value = _shallow ? value : convert(value)
    }
    // 通过类的 get set 方法侦听 value 属性(核心)
    get value() {
        trackRefValue(this)
        return this._value
    }

    set value(newVal) {
        newVal = this._shallow ? newVal : toRaw(newVal)
        if (hasChanged(newVal, this._rawValue)) {
            this._rawValue = newVal
            this._value = this._shallow ? newVal : convert(newVal)
            triggerRefValue(this, newVal)
        }
    }
}

// 3. 来到了 createRef 函数
function createRef(rawValue: unknown, shallow = false) {
    // 4. 判断传入的是否是 ref 变量,如果是直接返回
    if (isRef(rawValue)) {
        return rawValue
    }
    /* 
        5. RefImpl 是 reference implement 的缩写,
        此处调用了 RefImpl 类,返回一个引用对象的实例
    */
    return new RefImpl(rawValue, shallow)
}

看完我们能得出结论,ref 的数据劫持使用 geter 和 setter 实现的,因为 geter 和 setter 无法绑定基本类型,所以通过绑定一个对象(实例也是对象)的 value 属性来实现数据劫持。这也就是我们为什么在 script 中读取和修改 ref 定义的变量要通过打点 value 来实现。

template 对 ref 定义的变量做了什么

现在我们还有一个问题需要解决就是为什么 template 中使用 ref 定义的变量为啥不需要打点?

哈哈哈,这是因为 reactive API 在偷偷摸摸的起作用,我们知道 setup 函数里面会 return 出去一个对象,一般这个对象会以字面量的方式书写,但其实它会被 reactive API 调用,然后进行数据劫持。

所以接下来我们只需要知道 reactive 的机制就会知道为什么 template 中使用 ref 定义的变量为啥不需要打点?这个难题了。

我提供两个版本,一个简化版一个完整版,如果你不想看源码直接看简化版会就行了:

简化版

reactive 函数会通过 target 形参来接收你传入需要被数据劫持的复杂类型,然后遍历 target,通过 isRef(res) 来判断你传入的参数是否有被 ref 标记过,如果是直接返回 res.value

给个演示代码大约就是这样:

const age = ref(18);

reactive({
    age: age
})
// 可以等价为

reactive({
    age: { value: 18 }
})

// 实际经过 reactive 处理就变成了

reactive({
    age: 18
})

完整版

首先进入 packages/reactivity/src/reactive.ts 查看 reactive 函数:

import { mutableHandlers } from './baseHandlers'

// 1. 函数重载定义 reactive,所以直接看最后一个重载函数
export function reactive(target: T): UnwrapNestedRefs
export function reactive(target: object) {
    // if trying to observe a readonly proxy, return the readonly version.
    if (target && (target as Target)[ReactiveFlags.IS_READONLY]) {
        return target
    }
    // 2. 调用了 createReactiveObject 函数,注意这里的 mutableHandlers 函数
    return createReactiveObject(
        target,
        false,
        mutableHandlers,
        mutableCollectionHandlers,
        reactiveMap
    )
}

// 3. 来到 createReactiveObject 函数
function createReactiveObject(
    target: Target,
    isReadonly: boolean,
    baseHandlers: ProxyHandler,
    collectionHandlers: ProxyHandler,
    proxyMap: WeakMap
) {
    if (!isObject(target)) {
        if (__DEV__) {
            console.warn(`value cannot be made reactive: ${String(target)}`)
        }
        return target
    }
    // target is already a Proxy, return it.
    // exception: calling readonly() on a reactive object
    if (
        target[ReactiveFlags.RAW] &&
        !(isReadonly && target[ReactiveFlags.IS_REACTIVE])
    ) {
        return target
    }
    // target already has corresponding Proxy
    const existingProxy = proxyMap.get(target)
    if (existingProxy) {
        return existingProxy
    }
    // only a whitelist of value types can be observed.
    const targetType = getTargetType(target)
    if (targetType === TargetType.INVALID) {
        return target
    }
    // 4. 关键在这通过 Proxy 代理了我们传入的 target,接下来我们要去看 baseHandlers
    // 根据上下文可知 baseHandlers 就是 mutableHandlers 
    const proxy = new Proxy(
        target,
        targetType === TargetType.COLLECTION ? collectionHandlers : baseHandlers
    )
    proxyMap.set(target, proxy)
    return proxy
}


/* 

    5. mutableHandlers
    文件路径为 /packages/reactivity/src/baseHandlers.ts
*/

// 6. 使用触发的是 get 方法,所以只看 get 即可
const get = /*#__PURE__*/ createGetter()
export const mutableHandlers: ProxyHandler = {
    get,
    set,
    deleteProperty,
    has,
    ownKeys
}



/* 
    7. createGetter
    文件路径为 packages/reactivity/src/baseHandlers.ts#L80
*/

function createGetter(isReadonly = false, shallow = false) {
    return function get(target: Target, key: string | symbol, receiver: object) {
        if (key === ReactiveFlags.IS_REACTIVE) {
            return !isReadonly
        } else if (key === ReactiveFlags.IS_READONLY) {
            return isReadonly
        } else if (
            key === ReactiveFlags.RAW &&
            receiver ===
            (isReadonly
                ? shallow
                    ? shallowReadonlyMap
                    : readonlyMap
                : shallow
                    ? shallowReactiveMap
                    : reactiveMap
            ).get(target)
        ) {
            return target
        }

        const targetIsArray = isArray(target)

        if (!isReadonly && targetIsArray && hasOwn(arrayInstrumentations, key)) {
            return Reflect.get(arrayInstrumentations, key, receiver)
        }

        const res = Reflect.get(target, key, receiver)

        if (isSymbol(key) ? builtInSymbols.has(key) : isNonTrackableKeys(key)) {
            return res
        }

        if (!isReadonly) {
            track(target, TrackOpTypes.GET, key)
        }

        if (shallow) {
            return res
        }
        // 8. 真相大白时刻
        if (isRef(res)) {
            // ref unwrapping - does not apply for Array + integer key.
            const shouldUnwrap = !targetIsArray || !isIntegerKey(key)
            return shouldUnwrap ? res.value : res
        }

        if (isObject(res)) {
            // Convert returned value into a proxy as well. we do the isObject check
            // here to avoid invalid value warning. Also need to lazy access readonly
            // and reactive here to avoid circular dependency.
            return isReadonly ? readonly(res) : reactive(res)
        }

        return res
    }
}

SFC 提案

为了解决在 script 中使用 ref 定义的变量,尤大神也是没少下心思,某个夜晚尤大神苦苦思索破解方法,终于突然想到了一个简单的方法,立刻做去 github 做了一个提案,提案在此:New script setup and ref sugar ,你也可以去看完整的 full RFC 。

结果,收到社区五星差评♂️。

我们来看看这个提案给出的解决方案,代码在此:





代码的精髓就是这个 ref: count = 1,估计很多人看到这个非常的不淡定,我靠,没见过哎,不要慌,这个语法虽然陌生,但是还是属于 JavaScript 的内容的,只是你用的少而已。

而且,我认为,虽然它是个提案,但绝对是未来的解决方案。

我们来学习下这个标签语法,想想,我当年在学习 for 循环的时候,continue 关键字是了然于胸的,马上就能脱口而出它的作用,continue 语句是终止其所在最内层的循环本次迭代。

但是,如果嵌套 for 循环,我需要终止除内层外任意层的循环怎么办呢?

没错就是 label 标签语法,我们使用 label 语法给需要终止的循环语句命个名就行了,使用的时候在 continue/break 语句空格加标签名即可,打个栗子:

这是一段双层循环的代码:

outer: for (let i = 0; i < 10; i++) {
    for (let j = 0; j < 1; j++) {
        if (i === 2) {
            break;
        }
        console.log(`i value is ${i}`);
    }
}

运行之后输出:

> i value is 0
> i value is 1
> i value is 3
> i value is 4
> i value is 5
> i value is 6
> i value is 7
> i value is 8
> i value is 9

现在当 i === 2 的时候,终止最外层的循环,改写之后代码为:

outer: for (let i = 0; i < 10; i++) {
    for (let j = 0; j < 1; j++) {
        if (i === 2) {
            break outer;
        }
        console.log(`i value is ${i}`);
    }
}

输出结果:

> i value is 0
> i value is 1

最外层的循环,果然被停了。

现在你对 ref-sfc 是不是不辣么的慌了,如果你想到 vite 环境的 vue3 中尝试,我们需要配置下 vite.config.js,新增代码 script: { refSugar: true },完整的如下:

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [vue({
    script: { refSugar: true }
  })],
})

配置完,重启项目即可。

为了配置项变更,你可以在下面两个链接实时查看如何配置:

  • @vitejs/plugin-vue
  • compiler-sfc

总结

  • template 中使用 ref 定义的变量不用打点是因为 reactive 通过 isRef 帮我们做了一次判断
  • script 中使用 ref 定义的变量需要打点是因为Obejct.defineProperty 不能作用于基本类型,只能通过对象来做一层转化
  • 为了减少代码的 value,Vue 提出了标签语法。

你可能感兴趣的:(为什么在 script 中 ref 需要打点 value)