前言:随着 Vue3 越来越稳定了,各位小伙伴都应该都开始在新项目中使用它了,但是当你使用 Vue3 越来越熟练,不知道你对 Vue3 的新 API ref 是否有所疑问️。
为啥我在 template 中可以直接使用 ref 定义的变量,在 script 中却要通过打点 value 来获取响应式?
今天,我们就来研究研究。
在使用 Vue3 的 ref API 的时候,不知道你有没有疑问,那就是为什么在 template 标签中使用 ref 可以直接使用 ref 变量,但是在 script 中去修改或读取 ref 定义的变量却要通过打点 value 来实现。
ref 的使用
ref 主要有两个作用:
- 响应基本数据类型
- 获取 DOM 的一种方式
我用 Vue3 语法写了个加/减法器,和一个获取 DOM 聚焦功能的组件,来演示下 ref 的两个用法。
源代码如下:
{{count}}
动图演示效果如下:
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
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 提出了标签语法。