Vue3 从ref使用到原理浅析

引言

在Vue3中,refreactive是我们最常见的两个API,使用的方式也是非常的简单。那么这两个API是如何实现Vue3中的响应式的呢,今天我们来分析分析。

本篇统一采用vue3的setup语法糖 + ts写法进行说明。

使用方法

这两个API的使用方式都非常简单:

typescript 复制代码
import { ref } from 'vue'

const count = ref(0)
// const count = ref<number>(0)  ts写法

function increment() { 
    // 在 JavaScript 中需要 .value 
    count.value++ 
}

const user = reactive({
    name: 'aaa',
    age: 18
})

// ts写法
// const user = reactive<{name: string; age: number}>({
//     name: 'aaa',
//     age: 18
// })

function setUser() {
    user.name = 'bbb'
    user.age = 22
}

区别在于:

ref相当于是以下这种方式进行了处理并且赋予了变量响应式,

typescript 复制代码
// 约等于
function ref(val) {
   return { value: val }
}

// 约等于
function reactive(val) {
    return val
}

那么我们在使用这个变量的时候,就需要使用.value对他的值进行获取。而我们的reactive并不需要.value

唯一注意的一点 const a = reactive({aaa: 123}) 你不能直接替换整个a变量(这样会失去响应式),你只能修改a.aaa = 456这种方式

那么为什么在<template>中使用ref变量的时候不需要.value呢?

是因为vue在解析模板的时候,识别到你在template中 写的 count是一个ref变量,此时,vue会自动去获取count.value的值,所以你在template中写的时候,不需要使用.value

记住!

核心区别: 是不是在 template中使用ref变量,如果是,则不需要写.value,如果是在script中使用的话,就需要写

原因: vue会对template中的ref做处理识别,但不会对script中的ref做处理。

Setup模式的区别

这里顺带提一下,<script setup>这种模式,其实本质上就是:

  1. 不用再写setup() { xxx } ,你写的所有代码 就是setup函数中应该写的代码
  2. 去掉你需要return的那部分,vue会帮你return出去你定义的变量和函数

最佳实践

其实很多同学在使用这两个API的时候,并不清楚什么时候使用ref、什么时候使用reactive,这就像在React中不知道什么时候使用useStateuseReducer一样。

在此处我直接给出我推荐的结论,在平时的开发中统一使用ref 。(很多同学可能会说对象的时候使用reactive,可以但没必要)。

在你封装通用hooks或者包的时候,可以考虑 使用reactive ,参考Pinia,在Pinia中使用useStore之后,解构出来的变量不需要使用.value,原因就是Pinia对返回的变量多包了一层reactive热知识:用reactive包装后的ref变量使用的时候不需要.value


到此,关于refreactive的使用部分就讲完了,接下来会分析响应式的原理,感兴趣的同学可以接着往下看。

响应式原理浅析

直接看源码会有点上头,本次讲解会借鉴春阳老师的《Vue.js设计与实现》的方式,深入浅出为大家讲解响应式原理,并加上我自己的总结,不会涉及太深的源码,但是力求能让大家了解是怎么个方式实现响应式的。

前置知识:ProxyReflect,希望大家在看本文之前就了解其作用,不然可能会跟不。

副作用

副作⽤函数指的是会产⽣副作⽤的函数,如下⾯的代码所⽰:

js 复制代码
let val = 1
function effect1() { 
    document.body.innerText = 'hello vue3' 
}

function effect2() { 
    val = 2 
}

当我们的effect1执行的时候,修改的DOM的文本内容,这个操作可能会导致其他代码在获取的时候产生影响,这就是典型的副作用函数。effect2在执行的时候,修改了全局变量,也产生了副作用。这就是我们所说的副作用函数的概念:直接或间接影响了其他函数的执行。

如果理解纯函数的同学,这时候就可以这么说:大多数副作用函数都是非纯函数(有些非纯函数可能不产生副作用,但它们可能会依赖于外部状态或随机性,这也会增加代码的不稳定性和难以测试性)。

响应式需求

我们现在有如下代码:

js 复制代码
const obj = { text: 'hello world' } 
function effect() { 
    // effect 函数的执⾏会读取 obj.text
    document.body.innerText = obj.text
}

我们期望,当我们修改obj.text的时候,body上的DOM内容也能同时修改,也就是调用effect函数,如果能实现这个目的,那这种行为就是响应式

实现最初级的响应式

那么我们如果能在改变obj.text的时候做到调用effect函数从而实现响应式呢?

熟悉Vue2响应式的同学可能已经给出答案了,在取值的时候设置依赖的副作用函数,在修改obj的值的时候触发依赖的副作用函数

拦截对象的读取和设置

那么这个时候就需要我们对 目标对象 的读取设置 进行拦截了。在Vue2中使用的是Object.defineProperty,在Vue3中使用的是Proxy,此处我们不再对两种方式的优缺点进行讨论,感兴趣的同学可以自行查阅。

保存依赖的副作用函数及读取

我们此时需要一个桶(bucket)来保存我们的副作用函数。

js 复制代码
// 存储副作用函数的桶
const bucket = new WeakMap()

// 原始数据
const data = { foo: 1 }
// 对原始数据的代理
const obj = new Proxy(data, {
  // 拦截读取操作
  get(target, key) {
    // 将副作用函数添加到存储副作用函数的桶中
    track(target, key)  // bucket.add(effect)
    // 返回属性值 此处应该是使用Reflect不然会有this指向的问题,此处暂时不提太多,容易混
    return target[key]
  },
  // 拦截设置操作
  set(target, key, newVal) {
    // 设置属性值
    target[key] = newVal
    // 把副作用函数从桶里取出并执行
    trigger(target, key)  bucket.forEach(fn => fn())
  }
})

由此,我们写了一个最小的响应式,当data中的字段被读取时,会触发添加effect的逻辑,设置 时会取出effect并执行。在注释中我分别写了两行代码bucket.addbucket.forEach(fn => fn()),但其实远没有这么简单,只是为了大家能够最小化的理解响应式。

和完整的响应式比缺些什么东西?

其实到此,我不准备再为大家更深度地讲解完整的响应式系统是如何实现的,因为笔者觉得意义不大,我可以大概用几句话进行总结一下:在最基础的响应式上,打补丁处理各种问题,从而形成完整的响应式系统。

只是我会大概提一下,从最小的响应式系统到完整的响应式系统还缺一些什么东西?

  • 分支切换:比如const a = b ? c : d,如果b从true修改成false之后,c仍然会一直存在于a的依赖中,这是不正确的。
  • 嵌套effect:如果存在父组件调用子组件,那么大概率会出现effect嵌套调用的情况。
  • 自增导致的无限循环:比如我们有一个obj.foo = obj.foo + 1,同时触发读取设置,就会出现循环。
  • 执行时机:我们都知道watch还可以设置post执行,这种方式也需要处理。以至于后续的computedsetup其实都只是执行的调度方式不一样的effect
  • 不仅仅只是getset:想一想我们如果for循环这个变量的时候,又是怎么触发的,或者Object.keys这些操作的读取都是我们需要考虑的,不仅仅只是getset
  • ArraySetMap的拦截......
  • ......

诸如此类,我也不准备列举完。完整的响应式系统十分复杂,要处理各种情况。总而言之,就是:基础响应式+打补丁=完整响应式系统。(至于各种问题怎么处理的,可以自行看书或者源码)。

回到refreactive的原理

我们先分析一下reactive的源码实现方案(为什么先reactive是因为ref底层最终也是调用的reactive的实现)

reactive

ts 复制代码
export function reactive<T extends object>(target: T): UnwrapNestedRefs<T>
export function reactive(target: object) {
  // if trying to observe a readonly proxy, return the readonly version.
  // 如果目标对象是一个只读的响应数据,则直接返回目标对象
  if (target && (target as Target).__v_isReadonly) {
    return target
  }

  // 否则调用  createReactiveObject 创建 observe
  return createReactiveObject(
    target, 
    false,
    mutableHandlers,
    mutableCollectionHandlers
  )
}

createReactiveObject 创建 observe。

ts 复制代码
function createReactiveObject(
  target: Target,
  isReadonly: boolean,
  baseHandlers: ProxyHandler<any>,
  collectionHandlers: ProxyHandler<any>
) {
  // 如果不是对象
  if (!isObject(target)) {
    return target
  }

  // 如果目标对象已经是个 proxy 直接返回
  if (target.__v_raw && !(isReadonly && target.__v_isReactive)) {
    return target
  }
  // target already has corresponding Proxy
  if (
    hasOwn(target, isReadonly ? ReactiveFlags.readonly : ReactiveFlags.reactive)
  ) {
    return isReadonly ? target.__v_readonly : target.__v_reactive
  }
  // only a whitelist of value types can be observed.

  // 检查目标对象是否能被观察, 不能直接返回
  if (!canObserve(target)) {
    return target
  }

  // 使用 Proxy 创建 observe 
  const observed = new Proxy(
    target,
    collectionTypes.has(target.constructor) ? collectionHandlers : baseHandlers
  )

  // 打上相应标记
  def(
    target,
    isReadonly ? ReactiveFlags.readonly : ReactiveFlags.reactive,
    observed
  )
  return observed
}

// 可以被观察的值类型
const isObservableType = /*#__PURE__*/ makeMap(
  'Object,Array,Map,Set,WeakMap,WeakSet'
)

其实这段代码就是在为传入的数据做判断处理,核心逻辑就是此处:

ts 复制代码
 const observed = new Proxy(
    target,
    collectionTypes.has(target.constructor) ? collectionHandlers : baseHandlers
  )

根据上文其实我们知道响应式是通过Proxy进行拦截实现的,那么VueProxy的操作进行了封装,区分了集合类型和其他类型,分别使用其对应的封装好的参数。由此实现了响应式。

ref

我们再来看看ref的实现:

ts 复制代码
export function ref(value?: unknown) {
  return createRef(value)
}

/**
 * @description: 
 * @param {rawValue} 原始值 
 * @param {shallow} 是否是浅观察 
 */
function createRef(rawValue: unknown, shallow = false) {
  // 如果已经是ref直接返回
  if (isRef(rawValue)) {
    return rawValue
  }

  // 如果是浅观察直接观察,不是则将 rawValue 转换成 reactive ,
  let value = shallow ? rawValue : convert(rawValue)

  // ref 的结构
  const r = {
    // ref 标识
    __v_isRef: true,
    get value() {
      // 依赖收集
      track(r, TrackOpTypes.GET, 'value')
      return value
    },
    set value(newVal) {
      if (hasChanged(toRaw(newVal), rawValue)) {
        rawValue = newVal
        value = shallow ? newVal : convert(newVal)
        // 触发依赖
        trigger(
          r,
          TriggerOpTypes.SET,
          'value',
          __DEV__ ? { newValue: newVal } : void 0
        )
      }
    }
  }
  return r
}

// 如是是对象则调用 reactive, 否则直接返回 
const convert = <T extends unknown>(val: T): T =>
  isObject(val) ? reactive(val) : val

其实可以看到ref的逻辑非常简单,createRef 先判断 value 是否已经是一个 ref, 如果是则直接返回,如果不是接着判断是不是浅观察,如果是浅观察直接构造一个 ref 返回,不是则将 rawValue 转换成 reactive 再构造一个 ref 返回。

这里有一个点需要注意一下,Proxy不能代理普通类型的变量 ,这也是为什么不直接将ref中基础变量的值直接进行代理的原因,而是包装了一层{ value }

这就是ref的实现了,简单来说就是:封装一层对象,转化为响应式

结语

具体的响应式系统其实异常复杂,实现起来也需要考虑诸多问题,甚至你需要去查阅ECMA标准才能完整实现。

本文主要是从浅析的角度去分析响应式的原理,不涉及过多实现的细节,让大家能够对响应式原理有个基本的认知。

最后,

顺颂时祺,秋绥冬禧。

相关推荐
2401_882727572 分钟前
低代码配置式组态软件-BY组态
前端·后端·物联网·低代码·前端框架
NoneCoder5 分钟前
CSS系列(36)-- Containment详解
前端·css
anyup_前端梦工厂16 分钟前
初始 ShellJS:一个 Node.js 命令行工具集合
前端·javascript·node.js
5hand20 分钟前
Element-ui的使用教程 基于HBuilder X
前端·javascript·vue.js·elementui
GDAL38 分钟前
vue3入门教程:ref能否完全替代reactive?
前端·javascript·vue.js
六卿38 分钟前
react防止页面崩溃
前端·react.js·前端框架
z千鑫1 小时前
【前端】详解前端三大主流框架:React、Vue与Angular的比较与选择
前端·vue.js·react.js
m0_748256142 小时前
前端 MYTED单篇TED词汇学习功能优化
前端·学习
小白学前端6663 小时前
React Router 深入指南:从入门到进阶
前端·react.js·react
苹果醋33 小时前
React系列(八)——React进阶知识点拓展
运维·vue.js·spring boot·nginx·课程设计