黑马Vue_day11:Vue3的watch,ref和reactive,vue2->vue3,defineOptions、Model

第1个

Vue3的watch

javascript 复制代码
watch(state, ()=>{
    console.log('数据变化了')
  })
  const changeStateByCount = ()=>{
    // 直接修改不会引发回调执行
    state.value.count++
  } 

这句话的意思是:直接改 state.value.count 的值,watch 监听的回调函数不会被触发,控制台不会打印"数据变化了"。

要搞懂为什么"直接修改不会引发回调执行",你需要了解 Vue 3 中 watch 的监听机制。

一、 核心原因:你监听的是"引用",还是"里面的属性"?

假设你的 state 是这样定义的:

csharp 复制代码
const state = ref({ count: 0 })

当你写 watch(state, () => { ... }) 时,Vue 默认只监听 state 这个变量指向的"内存地址/引用" 是否改变。

1. 为什么 state.value.count++ 不会触发?
  • state.value.count++ 修改的只是对象内部的一个属性state.value 本身依然是同一个对象(内存地址没变)。

  • 因为 state 的引用没有变,所以 Vue 认为 state "没有发生变化",自然就不会去执行回调函数。

2. 什么才叫"非直接修改 / 改变了引用"?

如果你直接把整个 state.value 替换成一个全新的对象 ,内存地址变了,watch 就会触发:

ini 复制代码
const changeStateByCount = () => {
  // 重新赋值一个新对象(改变了引用)
  state.value = { count: state.value.count + 1 } 
  // 此时 watch(state, ...) 的回调【会】执行!
}

二、 怎么修改代码才能让它监听属性的变化?

如果你希望修改 count 时能够触发 watch,有两种标准的解决办法:

方案 1:开启深度监听 { deep: true }(推荐监听整个对象时使用)

添加第三个参数 { deep: true },告诉 Vue:"请深入到这个对象的每一个内部属性去监听"。

JavaScript

javascript 复制代码
watch(state, () => {
  console.log('数据变化了')
}, { deep: true }) // 开启深度监听

const changeStateByCount = () => {
  state.value.count++ // 此时修改 count 就会打印"数据变化了"!
}
方案 2:使用 getter 函数精确监听具体属性(推荐只关注某个属性时使用)

如果你只关心 count 这一个属性,推荐写成函数的形式(getter 函数):

javascript 复制代码
// 明确告诉 Vue 只监听 state.value.count 的变化
watch(() => state.value.count, () => {
  console.log('数据变化了')
})

const changeStateByCount = () => {
  state.value.count++ // 此时也会正常打印"数据变化了"!
}

三、 补充说明:如果是 reactive 定义的呢?

如果你使用的是 reactive 而不是 ref

javascript 复制代码
const state = reactive({ count: 0 })

// 监听 reactive 对象时,Vue 默认会自动开启 deep: true
watch(state, () => {
  console.log('数据变化了')
})

const changeStateByCount = () => {
  state.count++ // 这种情况下回调【会】正常执行!
}

总结口诀

  • 监听 ref 定义的对象,默认只看引用地址 。不加 { deep: true },只改内部属性(如 count++)就是"直接修改",不会触发监听。

第2个

Vue watch,ref和reactive坑点

在 Vue 3 中,使用 watch 监听 refreactive 对象时,由于它们的底层代理实现不同,很容易在深度监听获取新旧值(newValue / oldValue 上踩坑。

下表总结了它们的核心区别与常见陷阱:

一、 核心区别对比

监听对象类型 watch 监听的写法 是否默认开启深度监听 (deep) 新旧值 (oldValue) 是否有效
ref 基本类型 watch(num, cb) 否(基本类型无需深度监听) 有效,能准确区分新旧值
ref 对象类型 watch(obj, cb) (默认只监听 .value 的引用替换) 无效 (由于对象引用相同,newValueoldValue 指向同一地址)
reactive 对象 watch(obj, cb) (强制开启,无法手动关闭) 无效newValueoldValue 永远指向相同的新对象)
对象的某个属性 watch(() => obj.a, cb) (若属性是对象,修改其内部属性默认不触发) 视情况(若属性是基本类型则有效)

二、 最容易踩的 4 个坑点及正确写法

坑点 1:监听 ref 对象时,只修改属性不触发回调

错误示范:

JavaScript

javascript 复制代码
const user = ref({ name: '张三', age: 18 })

// 默认只监听 user.value 内存地址改变
watch(user, () => {
  console.log('用户数据变了') 
})

user.value.age = 19 // ❌ 不会触发 watch 回调!

正确解决方案:

  1. 显式开启 { deep: true }

    JavaScript

    javascript 复制代码
    watch(user, () => {
      console.log('用户数据变了')
    }, { deep: true }) // ✅ 开启后修改 age 会触发
  2. 或者监听 .value (监听 ref 内部的 Proxy 对象时,会自动开启深度监听):

    JavaScript

    javascript 复制代码
    watch(user.value, () => {
      console.log('用户数据变了')
    }) // ✅ 会触发
坑点 2:监听 reactive 对象时,oldValuenewValue 完全相等

表现:

无论你怎么修改 reactive 对象的内部属性,拿到的 oldValue 永远和 newValue 一模一样(打印出来都是修改后的值)。

JavaScript

scss 复制代码
const state = reactive({ count: 0 })

watch(state, (newVal, oldVal) => {
  console.log(newVal.count, oldVal.count) // ❌ 修改后两次打印都是最新的 count 值
})

state.count++

原因分析:

Vue 内部没有为你深拷贝修改前的旧对象。因为 state 是同一个对象的代理,数据修改后,指向的依然是同一块内存地址。

正确解决方案:

如果你必须对比新旧值的具体变化 ,请使用 Getter 函数 + 深拷贝/展开运算符

JavaScript

scss 复制代码
// 返回一个浅拷贝/深拷贝的新对象
watch(
  () => ({ ...state }), 
  (newVal, oldVal) => {
    console.log(newVal.count, oldVal.count) // ✅ 可以正常区分新旧值了
  }
)
坑点 3:直接监听对象的某个"基本类型属性"报错或无效

错误示范:

JavaScript

javascript 复制代码
const state = reactive({ count: 0 })

// ❌ 错误写法!把 state.count (相当于数字 0) 直接传给了 watch
watch(state.count, (newVal) => {
  console.log('count 变了')
})

报错/警告 :Vue 会警告 Invalid watch source,因为 watch 的第一个参数必须是 refreactive 对象、ArrayFunction

正确解决方案:

监听对象的特定属性时,必须写成 Getter 函数形式 () => state.count

JavaScript

javascript 复制代码
watch(
  () => state.count, 
  (newVal, oldVal) => {
    console.log('count 变了:', newVal) // ✅ 正常触发,且能精准拿到旧值
  }
)
坑点 4:用 Getter 监听"嵌套对象属性"时,内部属性修改失效

错误示范:

JavaScript

javascript 复制代码
const state = reactive({
  info: { name: '张三' }
})

// 使用了 getter 函数返回 state.info 对象
watch(
  () => state.info,
  () => {
    console.log('info 变了')
  }
)

state.info.name = '李四' // ❌ 不会触发!因为 state.info 对象的引用没变

原因分析:

一旦给 watch 传入的是 Getter 函数 ,Vue 默认不会 自动开启深度监听(即使 statereactive)。

正确解决方案:

需要手动追加 { deep: true }

JavaScript

javascript 复制代码
watch(
  () => state.info,
  () => {
    console.log('info 变了')
  },
  { deep: true } // ✅ 手动加上 deep,修改 name 就会触发回调了
)

三、 实战使用避坑口诀

  1. 听基本类型 ref :直接传变量名 watch(count, cb)

  2. 听对象特定属性 :一律写成函数 watch(() => obj.prop, cb)

  3. ref 对象 :记得带上 { deep: true },或者直接监听 watch(obj.value, cb)

  4. reactive 对象 :默认就是深度监听,但需要对比新旧值时,记得用 () => ({ ...state }) 做解构。

第3个

Vue2过渡到Vue3

变动速览

1. vue3新增:setup<script setup>

  • 最容易忽视的痛点 :在传统的 setup() 函数中,千万不能使用 this (此时组件实例尚未创建,thisundefined)。

  • <script setup> 隐藏优势 :导入的子组件无需在 components: {} 中手动注册 ,写了 import 直接在模板里用。

2. ref vs reactive

  • 模板自动解包ref 在 JS 里必须写 .value,但<template> 模板中不需要写 .value,Vue 会自动解包。

  • reactive 的致命陷阱

    1. 不能对 reactive 对象直接重新赋值 (如 state = { a: 1 }),这会导致响应式丢失

    2. 不能直接对 reactive 对象解构 (如 const { msg } = state),也会导致响应式丢失。

3. watch 的深浅监听逻辑

  • 监听 ref 对象 :默认是浅层监听 (只看对象的内存地址变不变)。只有加 { deep: true } 或者直接监听 watch(state.value, ...),才能监听到内部属性(如 count++)的变化。

  • 监听 reactive 对象 :默认强制开启深度监听 (不需要加 deep: true 也会生效)。

  • 监听对象的某个特定属性 :必须写成 Getter 函数 形式,例如:watch(() => state.count, (newVal) => {})

4. 生命周期函数

  • 取消了 createdbeforeCreate :原先写在 created 里的初始化代码(如 Ajax 请求),在 Vue 3 中直接写在 <script setup> 的根层级下即可

  • 销毁钩子改名 :Vue 2 的 beforeDestroydestroyed,在 Vue 3 中改名为了 onBeforeUnmountonUnmounted

5. 父子通信与组件暴露

  • 编译宏无需导入definePropsdefineEmitsdefineExposedefineOptionsdefineModel 都是编译器宏(Compiler Macros)不需要手写 import 导入,直接用。

  • <script setup> 的封闭性 :父组件通过 ref 拿到的子组件实例,默认拿不到子组件的任何变量和方法 ,子组件必须用 defineExpose 明确导出的才能被看到。

6. provideinject 最佳实践

  • 数据修改原则 :跨层级传递数据时,如果底层组件需要修改顶层组件的数据,强烈建议把修改数据的方法一并传递过去,由数据提供方(顶层组件)负责修改,确保数据流向清晰。

一、 核心思维演变:从"选项"到"组合"

在 Vue 2 中,你的思维方式是 "填空题"

把变量填进 data,把函数填进 methods,把监听器填进 watch

  • 痛点:当页面逻辑变复杂时,同一个功能的代码被硬生生拆散在不同的选项中,维护时需要频繁上下翻滚代码。

在 Vue 3 中,你的思维方式变成了 "搭积木"

不再有 data / methods 的限制,你可以把"登录逻辑"、"购物车逻辑"分别写成独立的小模块,随拿随用。

二、 演进路线:从 setup() 函数到 <script setup> 语法糖

Vue 3 的语法并不是一步到位的,理解它的演进过程能帮你彻底看懂各种新老代码。

第一阶段:原始的 setup() 函数(过渡期写法)

Vue 3 为了兼容 Vue 2,保留了 export default {} 的外壳,但引入了一个全新的入口函数:setup()

代码段

xml 复制代码
<template>
  <div>
    <p>{{ count }}</p>
    <button @click="addCount">增加</button>
  </div>
</template>

<script>
import { ref } from 'vue'

export default {
  name: 'Counter',
  // 1. setup 替代了 beforeCreate 和 created
  setup() {
    // 2. 在 setup 内部定义变量和函数
    const count = ref(0)
    const addCount = () => {
      count.value++
    }

    // 3. 致命繁琐点:必须 return,模板里才能用!
    return {
      count,
      addCount
    }
  }
}
</script>

setup() 函数的局限性:

  1. 冗余代码多 :定义了一遍变量,末尾还得在 return { ... } 里再抄一遍。

  2. this 消失 :在 setup() 内部是没有 this 的,不能再像 Vue 2 那样通过 this.xx 拿属性。

第二阶段:<script setup> 语法糖(现代 Vue 3 标准写法)

为了解决 return 的繁琐问题,官方推出了 <script setup> 编译宏语法。

原理 :在 <script> 标签上加上 setup,编译器在打包时会自动把里面的代码包裹进 setup() 函数中,并自动将所有顶层声明的变量和函数进行 return

代码段

xml 复制代码
<template>
  <div>
    <p>{{ count }}</p>
    <button @click="addCount">增加</button>
    <!-- 引入的组件直接用,无需 components 注册 -->
    <ChildHeader /> 
  </div>
</template>

<script setup>
import { ref } from 'vue'
import ChildHeader from './ChildHeader.vue' // 导入即注册!

// 声明即导出!
const count = ref(0)
const addCount = () => {
  count.value++
}
</script>

三、 响应式核心:refreactive 的本质区别

Vue 2 的 data 底层依靠 Object.defineProperty 拦截 getter/setter;而 Vue 3 改用了 ES6 的 Proxy

这就导致 Vue 3 定义响应式数据时,必须显式调用 refreactive

1. ref(万能包装盒)

  • 底层原理 :把数据包裹在一个带有 .value 属性的对象中(利用类 setter/getter 拦截)。

  • 使用规则

    • <script> :操作必须加 .value(如 count.value++)。

    • <template> :Vue 会自动解包,绝对不能写 .value (写成 {{ count }} 即可)。

2. reactive(Proxy 代理对象)

  • 底层原理 :使用 ES6 Proxy 直接代理整个对象。

  • 致命坑点(为什么大家更推荐全用 ref

    1. 不能直接重赋值 :如果 let state = reactive({ a: 1 }),后续写 state = { a: 2 } 会导致原有的 Proxy 代理断开,响应式彻底失效

    2. 不能解构 :写 const { a } = state 后,a 就变成了一个普通变量,失去了响应式。

过渡建议 :为了减少大脑负担,一律优先使用 ref 。遇到对象类型,ref({ a: 1 }) 在底层也会自动调用 reactive 处理,既安全又统一。

四、 监听与计算:computedwatch

变化的核心在于:从 Vue 2 的"配置项"变成了 Vue 3 的"函数"

1. computed

不再写在 computed: {} 对象里,而是调用 computed() 函数并传入回调:

JavaScript

csharp 复制代码
import { ref, computed } from 'vue'

const price = ref(10)
const count = ref(2)

// 返回一个只读的 ref 对象
const totalPrice = computed(() => price.value * count.value)
// 在 JS 中访问也要加 .value
console.log(totalPrice.value) // 20

2. watch

Vue 3 的 watch 变得更加精确,但监听规则需要特别注意:

  • 监听单个 ref :直接传变量名 watch(count, (newVal) => {})

  • 监听对象的某个具体属性 :必须写成 Getter 函数

    JavaScript

    javascript 复制代码
    const user = ref({ name: '张三', age: 18 })
    
    // 必须写成 () => user.value.age
    watch(() => user.value.age, (newAge) => {
      console.log('年龄变了:', newAge)
    })

五、 组件通信与生命周期的对应演进

1. 生命周期的转化

Vue 3 废弃了 beforeCreatecreated,其逻辑直接写在 <script setup> 的根层级下执行 即可(因为 <script setup> 本身就在这个时机运行)。

Vue 2 选项 Vue 3 变动说明
beforeCreate / created 直接写在 <script setup> 顶级作用域中
mounted onMounted(() => {}) 前面统一加 on
beforeDestroy onBeforeUnmount(() => {}) 改名,语义更贴合 Vue 实例卸载过程
destroyed onUnmounted(() => {}) 改名

2. 父子通信的变化

Vue 2 中的 propsthis.$emit,在 <script setup> 中被替换成了两个编译器宏(Compiler Macros):

  • defineProps:接收父组件传来的属性。

  • defineEmits:定义并触发给父组件的事件。

代码段

xml 复制代码
<!-- 子组件 Child.vue -->
<script setup>
// 1. 声明接收的 props
const props = defineProps({
  title: String
})

// 2. 声明要触发的自定义事件
const emit = defineEmits(['update'])

const handleClick = () => {
  emit('update', '新数据')
}
</script>

六、 Vue 3 核心改动对照一览

功能/概念 Vue 2 写法 Vue 3 变动与替代方案
全局挂载 Vue.prototype.$http = axios 废弃 $http,推荐导出独立的 API 函数直接 import,或使用 app.config.globalProperties
DOM 元素获取 this.$refs.box 使用 const box = ref(null) + 模板标签绑定 ref="box"
组件导出访问 默认公开所有 data/methods <script setup> 默认全部私有 ,需通过 defineExpose({ ... }) 暴露
过滤器 (Filter) {{ time 或 formatDate }} 彻底废弃! 官方建议直接改用普通方法调用或 computed
逻辑复用 Mixins(容易导致变量命名冲突、来源不明) 改用 Composables(自定义 Hook) ,利用函数闭包实现类型安全的逻辑复用

第4个

defineOptionsdefineModel

在 Vue 3.3 中引入的 defineOptionsdefineModel ,是针对 <script setup> 语法糖下两个最痛点问题的"终极补丁"。

它们都是 编译器宏(Compiler Macros) ,这意味着在 <script setup>不需要从 vueimport 导入,直接用就行!

下面用最直观的方式把这两个 API 的诞生背景、用法和原理讲透:

一、 defineOptions:解决"定义组件自身选项"的痛点

1. 为什么会有这个 API?(诞生背景)

在没有 defineOptions 之前,<script setup> 解决了很多问题,但带来了一个尴尬的局限:

无法轻松定义组件自身的选项(如组件名 nameinheritAttrs 等)。

当时如果你想给组件起个名字,只有两种繁琐的变通方案:

  • 方案 A(写两个 <script> 标签)

    代码段

    xml 复制代码
    <script>
    export default {
      name: 'MyHeader' // 为了起个名字,必须额外写一个普通 script
    }
    </script>
    
    <script setup>
    // 复杂的 setup 业务逻辑写在这
    </script>
  • 方案 B :借助第三方插件(如 vite-plugin-vue-setup-extend)。

为了彻底终结为了一个 name 属性不得不写两个 <script> 的尴尬局面,Vue 3.3 引入了 defineOptions

2. defineOptions 怎么用?

它允许你在 <script setup> 内部,直接用声明式的方式定义任意 Options API(选项式) 的配置。

基本用法:

代码段

xml 复制代码
<script setup>
// 直接定义组件的选项,不需要写外部的 export default
defineOptions({
  name: 'MyHeader',       // 1. 自定义组件名称(调试工具/递归调用时很有用)
  inheritAttrs: false,    // 2. 禁用透传属性(禁止父组件传的 class/style 自动加到根元素上)
  // 也可以写其他选项,如 customOptions 等
})

// 下面照常写组件的业务逻辑
import { ref } from 'vue'
const count = ref(0)
</script>
注意事项:

defineOptions 不能用来定义 propsemitsexposeslots,因为这些已经有专属的宏(definePropsdefineEmitsdefineExposedefineSlots)来处理了。

二、 defineModel:解决"自定义组件双向绑定 (v-model)"的繁琐

1. 为什么会有这个 API?(诞生背景)

在 Vue 3 中,如果你想让一个自定义组件支持 v-model 双向绑定,写法非常繁琐:

传统 Vue 3 写法(Vue 3.2 及之前):

父组件写:<MyInput v-model="searchText"/>

子组件必须拆成两步处理:接收固定的 modelValue 属性,并在输入时触发 update:modelValue 事件:

代码段

xml 复制代码
<!-- 子组件 MyInput.vue (传统写法) -->
<script setup>
// 1. 声明接收 modelValue
const props = defineProps(['modelValue'])
// 2. 声明触发 update:modelValue 事件
const emit = defineEmits(['update:modelValue'])

const handleInput = (e) => {
  // 3. 手动通知父组件修改数据
  emit('update:modelValue', e.target.value)
}
</script>

<template>
  <input :value="modelValue" @input="handleInput" />
</template>

可以看到,为了做一个简单的双向绑定,写了大量的模板代码,而且极易记错事件名字。

2. defineModel 怎么用?

Vue 3.3+(Vue 3.4 正式稳定)引入了 defineModel,它把上面的 props 接收、emit 定义以及手动的逻辑触发打包成了"一行代码"

现代 Vue 3 写法:

代码段

xml 复制代码
<!-- 子组件 MyInput.vue (defineModel 写法) -->
<script setup>
// 声明一个绑定,返回的是一个响应式的 ref 对象!
const model = defineModel() 
</script>

<template>
  <!-- 在子组件内部,可以直接把 model 传给原生的 v-model -->
  <input v-model="model" />
</template>

只要子组件的 input 发生变化,父组件绑定给 v-model 的变量会自动发生改变 ,完全不需要你手动写 emit('update:modelValue')

3. defineModel 的高级用法

① 绑定多个 v-model(具名 v-model)

如果你的组件需要绑定多个值(例如姓名和年龄),父组件写:

<MyForm v-model:age="userAge" v-model:name="username"/>

子组件通过传入字符串参数来绑定指定的名称:

代码段

xml 复制代码
<!-- 子组件 MyForm.vue -->
<script setup>
// 对应 v-model:name
const name = defineModel('name')
// 对应 v-model:age
const age = defineModel('age')
</script>

<template>
  <input v-model="name" placeholder="请输入姓名" />
  <input v-model.number="age" placeholder="请输入年龄" />
</template>
② 设置默认值和校验选项

可以像 defineProps 一样,为 defineModel 指定数据类型和默认值:

代码段

xml 复制代码
<script setup>
// 设置默认值和类型限制
const count = defineModel({
  type: Number,
  default: 0
})
</script>
③ 处理 修饰符 (Modifiers)

如果父组件使用了修饰符,例如 <MyInput v-model.capitalize="text"/>(首字母大写),子组件可以通过数组解构获取修饰符对象:

代码段

xml 复制代码
<script setup>
// 解构第二个参数 [modelValue, modelModifiers]
const [model, modifiers] = defineModel({
  // 可以利用 set 拦截器,在更新数据前先处理修饰符逻辑
  set(value) {
    if (modifiers.capitalize) {
      return value.charAt(0).toUpperCase() + value.slice(1)
    }
    return value
  }
})
</script>

三、 一句话总结速记

编译宏 一句话作用 解决了什么痛点
defineOptions <script setup> 里直接写 nameinheritAttrs 等配置。 彻底告别了"为了起个组件名,不得不写两个 <script>"的荒谬现象。
defineModel 一行代码搞定自定义组件的 v-model 双向绑定。 自动帮你处理 props 接收与 emit('update:xxx') 派发,写代码就像在本地操作 ref 一样自然。
相关推荐
AlloyTeamZy1 小时前
我发现,一个人做小游戏最难的,根本不是写代码
前端·人工智能·程序员
触底反弹1 小时前
🚀 20 行代码手写前端路由 + React Router 核心 API 一篇搞定!
前端·javascript·react.js
灏仟亿前端技术团队1 小时前
企业级 Agent 中台建设方案
前端
程序员黑豆1 小时前
鸿蒙应用开发:一次开发多端适配与自适应布局实战
前端·harmonyos
布兰妮甜2 小时前
Vue 状态管理选型:Pinia 完整实战,对比 Vuex,模块化持久化
前端·javascript·vue.js·pinia·vuex
神王宝宝 王者小学2 小时前
面向领域驱动架构的查询实现方式
前端·python·架构
小林ixn2 小时前
React Router 从入门到实战:一篇搞定路由配置、懒加载与嵌套路由
前端·react.js·前端框架
labixiong2 小时前
async/await 到底是不是 Generator 的语法糖?手写执行器,Babel 编译产物里藏着答案
前端·javascript·babel
windliang2 小时前
Claude Code 源码分析(六):上下文的发现、注入与压缩
前端·javascript·人工智能