07 | 巧妙的响应式:深入理解Vue 3的响应式机制

你好,我是大圣。在上一讲中,我给你介绍了Composition API相比于Option API 的优点,以及<script setup>的语法,这些内容能够给我们后面的开发打下了坚实的基础。

今天我带你深入了解一下Vue 3的响应式机制,相信学完今天的内容,你会对响应式机制有更深地体会。我还会结合代码示例,帮你掌握响应式机制的进阶用法,让我们正式开始学习吧!

什么是响应式

响应式一直都是Vue的特色功能之一。与之相比,JavaScript里面的变量,是没有响应式这个概念的。你在学习JavaScript的时候首先被灌输的概念,就是代码是自上而下执行的。我们看下面的代码,代码在执行后,打印输出的两次double的结果也都是2。即使我们修改了代码中的count的值后,double的值也不会有任何改变。

let count = 1
let double = count * 2
console.log(double)
count = 2
console.log(double)

double的值是根据count的值乘以二计算而得到的,如果现在我们想让doube能够跟着count的变化而变化,那么我们就需要在每次count的值修改后,重新计算double。

比如,在下面的代码,我们先把计算doube的逻辑封装成函数,然后在修改完count之后,再执行一遍,你就会得到最新的double值。

let count = 1
// 计算过程封装成函数
let getDouble = n=>n*2 //箭头函数
let double = getDouble(count)
console.log(double)

count = 2
// 重新计算double,这里我们不能自动执行对double的计算
double = getDouble(count)
console.log(double)

实际开发中的计算逻辑会比计算doube复杂的多,但是都可以封装成一个函数去执行。下一步,我们要考虑的是,如何让double的值得到自动计算。

如果我们能让getDouble函数自动执行,也就是如下图所示,我们使用JavaScript的某种机制,把count包裹一层,每当对count进行修改时,就去同步更新double的值,那么就有一种double自动跟着count的变化而变化的感觉,这就算是响应式的雏形了。

响应式原理

响应式原理是什么呢?Vue中用过三种响应式解决方案,分别是defineProperty、Proxy和value setter。我们首先来看Vue 2的defineProperty API,这个函数详细的API介绍你可以直接访问MDN介绍文档来了解。

这里我结合一个例子来说明,在下面的代码中,我们定义个一个对象obj,使用defineProperty代理了count属性。这样我们就对obj对象的value属性实现了拦截,读取count属性的时候执行get函数,修改count属性的时候执行set函数,并在set函数内部重新计算了double。

let getDouble = n=>n*2
let obj = {}
let count = 1
let double = getDouble(count)

Object.defineProperty(obj,'count',{
    get(){
        return count
    },
    set(val){
        count = val
        double = getDouble(val)
    }
})
console.log(double)  // 打印2
obj.count = 2
console.log(double) // 打印4  有种自动变化的感觉

这样我们就实现了简易的响应式功能,在课程的第四部分,我还会带着你写一个更完善的响应式系统。

但defineProperty API作为Vue 2实现响应式的原理,它的语法中也有一些缺陷。比如在下面代码中,我们删除obj.count 属性,set函数就不会执行,double还是之前的数值。这也是为什么在Vue 2中,我们需要$delete一个专门的函数去删除数据。

delete obj.count
console.log(double) // doube还是4

Vue 3 的响应式机制是基于Proxy实现的。就Proxy这个名字来说,你也能看出来这是代理的意思,Proxy的重要意义在于它解决了Vue 2响应式的缺陷。我们看下面的代码,在其中我们通过new Proxy代理了obj这个对象,然后通过get、set和deleteProperty函数代理了对象的读取、修改和删除操作,从而实现了响应式的功能。

let proxy = new Proxy(obj,{
    get : function (target,prop) {
        return target[prop]
    },
    set : function (target,prop,value) {
        target[prop] = value;
        if(prop==='count'){
            double = getDouble(value)
        }
    },
    deleteProperty(target,prop){
        delete target[prop]
        if(prop==='count'){
            double = NaN
        }
    }
})
console.log(obj.count,double)
proxy.count = 2
console.log(obj.count,double) 
delete proxy.count
// 删除属性后,我们打印log时,输出的结果就会是 undefined NaN
console.log(obj.count,double) 

我们从这里可以看出Proxy实现的功能和Vue 2 的definePropery类似,它们都能够在用户修改数据的时候触发set函数,从而实现自动更新double的功能。而且Proxy还完善了几个definePropery的缺陷,比如说可以监听到属性的删除。

Proxy是针对对象来监听,而不是针对某个具体属性,所以不仅可以代理那些定义时不存在的属性,还可以代理更丰富的数据结构,比如Map、Set等,并且我们也能通过deleteProperty实现对删除操作的代理。

当然,为了帮助你理解Proxy,我们还可以把double相关的代码都写在set和deleteProperty函数里进行实现,在课程的后半程我会带你做好更完善的封装。比如下面代码中,Vue 3 的reactive函数可以把一个对象变成响应式数据,而reactive就是基于Proxy实现的。我们还可以通过watchEffect,在obj.count修改之后,执行数据的打印。

import {reactive,computed,watchEffect} from 'vue'

let obj = reactive({
    count:1
})
let double = computed(()=>obj.count*2)
obj.count = 2

watchEffect(()=>{
    console.log('数据被修改了',obj.count,double.value)
})

有了Proxy后,响应式机制就比较完备了。但是在Vue 3中还有另一个响应式实现的逻辑,就是利用对象的get和set函数来进行监听,这种响应式的实现方式,只能拦截某一个属性的修改,这也是Vue 3中ref这个API的实现。在下面的代码中,我们拦截了count的value属性,并且拦截了set操作,也能实现类似的功能。

let getDouble = n => n * 2
let _value = 1
double = getDouble(_value)

let count = {
  get value() {
    return _value
  },
  set value(val) {
    _value = val
    double = getDouble(_value)

  }
}
console.log(count.value,double)
count.value = 2
console.log(count.value,double)

三种实现原理的对比表格如下,帮助你理解三种响应式的区别。

定制响应式数据

简单入门响应式的原理后,接下来我们学习一下响应式数据在使用的时候的进阶方式。在前面第二讲做清单应用的时候,我给你留过一个思考题,就是让你想办法解决所有的操作状态在刷新后就都没了这个问题。

解决这个问题所需要的,就是让todolist和本地存储能够同步。我们首先可以选择的就是在代码中,显式地声明同步的逻辑,而watchEffect这个函数让我们在数据变化之后可以执行指定的函数。

我们看下使用 <script setup>重构之后的todolist的代码。这段代码使用watchEffect,数据变化之后会把数据同步到localStorage之上,这样我们就实现了todolist和本地存储的同步。

import { ref, watchEffect, computed } from "vue";

let title = ref("");
let todos = ref(JSON.parse(localStorage.getItem('todos')||'[]'));
watchEffect(()=>{
    localStorage.setItem('todos',JSON.stringify(todos.value))
})
function addTodo() {
  todos.value.push({
    title: title.value,
    done: false,
  });
  title.value = "";
}

更进一步,我们可以直接抽离一个useStorage函数,在响应式的基础之上,把任意数据响应式的变化同步到本地存储。我们先看下面的这段代码,ref从本地存储中获取数据,封装成响应式并且返回,watchEffect中做本地存储的同步,useStorage这个函数可以抽离成一个文件,放在工具函数文件夹中。

function useStorage(name, value=[]){
    let data = ref(JSON.parse(localStorage.getItem(name))||value)
    watchEffect(()=>{
        localStorage.setItem(name,JSON.stringify(data.value))
    })
    return data
}

在项目中我们使用下面代码的写法,把ref变成useStorage,这也是Composition API 最大的优点,也就是可以任意拆分出独立的功能。

let todos = useStorage('todos',[])

function addTodo() {
  ...code
}

现在,你应该已经学会了在Vue内部进阶地使用响应式机制,去封装独立的函数。社区也有非常优秀的Vueuse工具库,包含了大量类似useStorage的工具函数库。在后续的实战应用中,我们也会经常对通用功能进行封装。

如下图所示,我们可以把日常开发中用到的数据,无论是浏览器的本地存储,还是网络数据,都封装成响应式数据,统一使用响应式数据开发的模式。这样,我们开发项目的时候,只需要修改对应的数据就可以了。

基于响应式的开发模式,我们还可以按照类似的原理,把我们需要修改的数据,都变成响应式。比如,我们可以在loading状态下,去修改浏览器的小图标favicon。和本地存储类似,修改favicon时,我们需要找到head中有icon属性的标签。

在下面的代码中,我们把对图标的对应修改的操作封装成了useFavicon函数,并且通过ref和watch的包裹,我们还把小图标变成了响应式数据。

import {ref,watch} from 'vue'
export default function useFavicon( newIcon ) {
    const favicon = ref(newIcon)

    const updateIcon = (icon) => {
      document.head
        .querySelectorAll(`link[rel*="icon"]`)
        .forEach(el => el.href = `${icon}`)
    }
    const reset = ()=>favicon.value = '/favicon.ico'

    watch( favicon,
      (i) => {
        updateIcon(i)
      }
    )
    return {favicon,reset}
  } 

这样在组件中,我们就可以通过响应式的方式去修改和使用小图标,通过对faivcon.value的修改就可以随时更换网站小图标。下面的代码,就实现了在点击按钮之后,修改了网页的图标为geek.png的操作。

 <script setup>
 import useFavicon from './utils/favicon'
 let {favicon}  = useFavicon()
 function loading(){
   favicon.value = '/geek.png'
 }
</script>

<template>
  <button @click="loading">123</button>
</template>

Vueuse工具包

我们自己封装的useStorage,算是把localStorage简单地变成了响应式对象,实现数据的更新和localStorage的同步。同理,我们还可以封装更多的类似useStorage函数的其他use类型的函数,把实际开发中你用到的任何数据或者浏览器属性,都封装成响应式数据,这样就可以极大地提高我们的开发效率。

Vue 社区中其实已经有一个类似的工具集合,也就是VueUse,它把开发中常见的属性都封装成为响应式函数。

VueUse 趁着这一波 Vue 3 的更新,跟上了响应式API的潮流。VueUse的官方的介绍说这是一个 Composition API 的工具集合,适用于Vue 2.x或者Vue 3.x,用起来和 React Hooks 还挺像的。

在项目目录下打开命令行里,我们输入如下命令,来进行VueUse插件的安装:

npm install @vueuse/core

然后,我们就先来使用一下VueUse。在下面这段代码中,我们使用useFullscreen来返回全屏的状态和切换全屏的函数。这样,我们就不需要考虑浏览器全屏的API,而是直接使用VueUse响应式数据和函数就可以很轻松地在项目中实现全屏功能。

<template>
  <h1 @click="toggle">click</h1>
</template>
<script setup>
import { useFullscreen } from '@vueuse/core'
const { isFullscreen, enter, exit, toggle } = useFullscreen()
</script>

useFullscreen的封装逻辑和useStorage类似,都是屏蔽了浏览器的操作,把所有我们需要用到的状态和数据都用响应式的方式统一管理,VueUse中包含了很多我们常用的工具函数,我们可以把网络状态、异步请求的数据、动画和事件等功能,都看成是响应式的数据去管理。

总结

我们来总结一下今天学到的内容,首先我给你介绍了响应式的概念以及我们为什么需要响应式,具体Vue 3的响应式源码,我会在课程第四部分带你手写一个。

然后,通过对useStorage的封装,我为你讲解了响应式机制的进阶用法,那就是可以把一切项目中的状态和数据都封装成响应式的接口,屏蔽了浏览器的API,对外暴露的就是普通的数据,可以极大地提高我们的开发效率。

接着,我带你了解了VueUse这个工具包,这也是Vue官方团队成员的作品。VueUse提供了一大批工具函数,包括全屏、网络请求、动画等,都可以使用响应式风格的接口去使用,并且同时兼容 Vue 2 和 Vue 3,开箱即用。这门课程剩下的项目中会用到很多VueUse的函数,也推荐你去GitHub 关注 VueUse的动态和功能。

思考题

最后,留给你一道思考题:你的项目中有哪些数据可以封装成响应式数据呢?

欢迎在评论区留言,我会跟你一起探究Vue 3响应式的其他用法,也欢迎你把这篇文章分享给其他人,我们下一讲见!

精选留言

  • ll

    2021-11-01 11:14:25

    1. 为什么需要响应式?
    随着页面应用的不断复杂,需要关注和管理的状态越来越多,之前靠被动的,分散的管
    理状态不现实也易出错。为了满足这个需求,出现了许多方案,其中 Vue 提出的或主打
    的解决方案的就是响应式。

    2. 怎么实现响应式?
    响应式主要解决的问题是怎么让在“语言层面上分散的独立数据”在“业务层面”上产生“联
    系或互为因果”的关系。这咋么办呢?怎么产生“联系”呢?简单,“你发生变化的时候告
    诉我一声,我应声而动”,所谓响应式我个人理解就是“你响我应”。

    但是这种需求在语言上不是“普遍需求”,属于“特殊需求”,怎么解决?这里就引入了“代
    理模式”这种设计模式。在语言层面我给你一种模式可以满足你的需求,ES5 的时代,是
    defineProperty 等,ES6 就是 Proxy。

    至于 ES6 的 Proxy 在性能或是各方面要优于 defineProperty 还是因为 Proxy 在更底
    层优化或重新实现,使用的表现“一样”,但“地基”不一样,性能各方面自然是不一样。
    正因为是“地基”的不同,Proxy 就还存在兼容性的问题, 加上业务场景的不同,
    defineProperty 还是有应用的场景,至于文中说的 Vue 3 的 ref 是用 “getter
    setter” 实现的,我的认识是,一个是“初始化”时的行为,一个是“改变行为”。

    3. useXXX 为什么会这么灵活?
    像之前的 Composition API 我理解的是 Vue 的组织单位由 “组件” 变成 “数据” 了,
    现在组件在引入 useXXX,关注点在 XXX,至于 XXX 跟什么有联系,那是你的事情,在
    你自己的 useXXX 里去实现。

    本节提到的 React Hooks 也有异曲同工的意思,Hooks 直接翻译成什么?“钩子”,用来
    做什么?“钩东西”,钩什么?那先说下这个东西出来之前有什么。

    React Hooks 出来之前 React 主要构建 App 还是用 Class Component,当然也有
    functional component,这俩区别就在于 class 有状态,functional 比较“纯粹”没有
    状态。这样复用成问题,得用什么高阶组件之类的方式。然后,在某一时刻,同样的问
    题出现了,React 复用组件的也是以带状态的 Class 组件为主,“复杂”了,不纯粹。

    应用越大,这种模式开发或维护越复杂。然后 Hooks 出现了,现在 React 都用
    “functional” 组件,但是有“状态”的,状态哪里来的 “Hook” 过来的,钩过来的。
    组件“不负责”维护状态,useXXX 去管理了。

    综上,灵活了,也好维护了
    作者回复

    总结的太棒了!

    2021-11-01 21:24:23

  • 郭纯

    2021-11-01 01:06:53

    composition API 就是把逻辑代码聚合起来. 一些工具函数都可以被封装起来 比如 websocket 解析 url parameter 滚动监听 鼠标状态监听 浏览器 reset 元素 拖动 表单验证 图片懒加载 配合css var 实现运行时主题更新 本地化持久化存储 performance 性能检测 甚至实现自定义 logger。手机端 touch 设备类型检测 陀螺仪 手势识别. 电池电量 太多太多
  • @

    2021-11-01 15:24:39

    vue的一些api用到的时候,能否带一嘴,讲讲这个api
  • xzq

    2021-11-01 10:55:45

    刚刚试了下代码: const todoList = ref([]), 然后在触发新增操作 todoList.value.push(xxxx) 之后, 在 watchEffect(() => console.log(todoList.value)) 中不会触发响应,而在 watch(todoList.value, (newVal) => console.log(new Val)) 却可以,这是为啥
  • Geek_fcdf7b

    2021-12-09 08:55:20


    大圣老师,请教一下,3.2版本之后,是不是定义响应式数据都可以用ref一把梭?我看有的文章是这样说的,ref在3.2之后性能进行了大幅度提升,所以建议使用ref,不管简单数据还是复杂数据都可以用ref,没必要用reactive
    作者回复

    现在ref确实是比reactive优先级更高一些,直接建议ref + toRefs一把梭,我碰到必须有reactive的场景的话会写个加餐对比,而且ref能够支持复杂的数据结构,也是调用了reactive

    2021-12-13 19:30:50

  • Warn

    2021-11-01 11:52:17

    useStorage函数中,ref初始赋值时应该是使用参数value代替'[]'不?
    ```
    function useStorage(name, value=[]){
    let data = ref(JSON.parse(localStorage.getItem(name)) || value)
    ...code
    }
    ```
    作者回复

    对对对 囧 写出bug了没注意

    2021-11-03 18:40:45

  • ps Sensking

    2021-11-01 11:39:45

    请问是watch watcheffect 那个性能高呀?有人说过watch性能能好一点
    作者回复

    理论上watcheffect会好一些,watcheffect做了任务队列的更新和收集

    2021-12-13 22:09:19

  • 沪上皮皮虾

    2021-11-02 15:38:45

    一周五更吧,一周三更不够看啊
  • 羊了个羊

    2021-11-02 22:18:31

    浏览器页面全屏、滚动等的封装,之前有需求就是全屏要动态调整页面的布局,在没有封装的情况下,就要每个页面需要时都写监听,而用useXXX感觉会优雅很多啊,点赞,学到了,赶紧实践实践。
    作者回复

    很棒的心得

    2021-11-03 17:12:09

  • 碎竹落棋

    2021-11-12 15:46:19

    想问一下,问啥vscode里,ctrl+左键不能跳转到函数定义,我创建的这个vue3项目里都跳转不了,拿以前写的vue2项目试了一下,可以跳转,好奇怪
    作者回复

    你换成volar插件试试?

    2021-11-13 10:21:10

  • 风一样

    2021-11-11 18:05:28

    link[rel*="icon"]
    老师请问这个是过滤什么呢?
    作者回复

    这是一个css选择器,过滤标签有rel属性的值中,包含icon的标签

    2021-11-11 21:31:34

  • 阿阳

    2021-11-01 09:22:31

    这节课好惊喜,使用use***封装成各种hook,转变为数据驱动的开发思维。其中useStorage,正好是现在做项目的困惑,没想到watchEffect还能监听localStorage。老师还会讲异步请求,axios请求封装成hook么?实际项目中离不开这些。
  • 风一样

    2021-12-03 22:07:02

    请问老师,我按照讲义,执行下面的语句是正常:
    let todos = ref(JSON.parse(localStorage.getItem('todos') || '[]'))
    watchEffect(() => {
    localStorage.setItem('todos', JSON.stringify(todos.value))
    })
    但把 localStorage.setItem('todos', JSON.stringify(todos.value)) 语句替换成 console.log(todos.value),也就是说修改成下面的语句:
    let todos = ref(JSON.parse(localStorage.getItem('todos') || '[]'))
    watchEffect(() => {
    console.log(todos.value)
    })
    当在输入框内输入字符串再回车,把输入的值添加到 todos.value 时,console.log(todos.value)语句并没有执行,而勾选条目再点击清理时,console.log(todos.value)语句 能正常执行,这是为什么呢?是因为watchEffec有什么限制吗?
  • 笑叹尘世

    2021-11-09 08:51:36

    第一次加到存储里todos:[{"title":"2222","done":false}]。

    刷新页面后存储里的tods变成了"[{\"title\":\"2222\",\"done\":false}]"
    作者回复

    storage数据取出来之前记得用JSON.parse和JSON.stringify处理一下

    2021-11-11 21:42:05

  • Estelle

    2021-11-05 13:37:56

    你好 我想问下
    let proxy = new Proxy(obj,{
    get : function (target,prop) {
    return target[prop]
    },
    set : function (target,prop,value) {
    target[prop] = value;
    if(prop==='count'){
    double = getDouble(value)
    }
    },
    deleteProperty(target,prop){
    delete target[prop]
    if(prop==='count'){
    double = NaN
    }
    }
    })
    console.log(obj.count,double)
    proxy.count = 2
    console.log(obj.count,double)
    delete proxy.count
    // 删除属性后,我们打印log时,输出的结果就会是 undefined NaN
    console.log(obj.count,double)
    执行这个时候 控制台报错:TypeError: 'set' on proxy: trap returned falsish for property 'count'。
    是什么原因 希望能解答
    作者回复

    能贴一下全部的代码妈?我本地按照你的代码三个console.log的结果如下, 并且没有报错

    undefined 2
    2 4
    undefined NaN

    2021-12-13 21:46:05

  • nabaonan

    2021-11-04 00:08:06

    希望更加深入讲一下,vue3的响应式原理,是如何进行依赖收集的,并触发响应到数据的,串一下流程,还有为什么用watchEffect而不用watch,都适用于哪些场景,各有什么优势
    作者回复

    这个在最后的手写代码章节有介绍的

    2021-12-13 21:49:13

  • 费城的二鹏

    2021-11-02 22:19:20

    应用开发的一个痛点就是散步在各处的已登陆用户信息,响应式可以解决一处修改多处自动更新ui的痛点,避免需要通知、轮询等方式解决数据不同步的问题。
  • @

    2021-11-01 15:17:13

    ```

    function useStorage(name, value=[]){
    let data = ref(JSON.parse(localStorage.getItem(name)||'[]'))
    watchEffect(()=>{
    localStorage.setItem(name,JSON.stringify(data.value))
    })
    return data
    }
    ```
    这段代码里的 参数 value 是何作用?
    作者回复

    value其实是想放在ref里面的默认值的,代码有bug ,看来得更新一下

    2021-11-03 18:40:08

  • Warn

    2021-11-01 11:50:05

    业务层封装为响应式数据的大概有用户偏好设置之类的。
  • 变现前端

    2022-04-13 17:13:20

    修改浏览器的favicon的这个useFavicon这个函数在TypeScript使用有点问题,会报useFavicon需要传一个参数,我修改了一下同样能够实现更换的操作:
    import { ref, watch } from 'vue'
    export default function useFavicon() {
    const favicon = ref('../../public/favicon.ico');
    const updateIcon = (icon: string) => {
    document.head.querySelectorAll(`link[rel*="icon"]`).forEach((el: any) => el.href = `${icon}`);
    }
    const reset = () => favicon.value = '../../public/favicon.ico';

    watch(favicon, (i: string) => {
    updateIcon(i);
    })

    return { favicon, reset }
    }

    <script setup lang="ts">
    import useFavicon from './utils/useFavicon'
    const { favicon } = useFavicon()
    function loading() {
    favicon.value = 'https://pinia.vuejs.org/logo.svg';
    }
    </script>