Vue 2 与 Vue 3 语法区别及使用技巧

Vue 2 与 Vue 3 语法区别及使用技巧

面向刚接触 Vue、需要阅读 Vue 2 老项目或开始编写 Vue 3 项目的开发者。

本文主要比较 Vue 2.6 的经典写法与 Vue 3 的现代写法。Vue 2.7 已内置部分 Composition API,Vue 3 也仍然支持 Options API,遇到不同版本时请先查看项目的 package.json

快速导航

  • 建立整体认识:第 1 至 4 节。
  • 掌握常用语法:第 5 至 16 节。
  • 了解高级差异:第 17 至 25 节。
  • 学习迁移与实战:第 26 至 31 节。
  • 日常查询:第 32 至 34 节。

1. 先用一句话理解区别

Vue 2 和 Vue 3 的共同目标都是:用数据驱动页面,并把页面拆成可复用组件

它们最大的区别不是模板完全变了,而是 Vue 3 提供了更灵活的代码组织方式、更完整的 TypeScript 支持和更高效的响应式系统。

text 复制代码
Vue 2 常见写法:Options API
把 data、methods、computed、watch 按"选项类型"分开放置

Vue 3 推荐写法:Composition API + <script setup>
把同一个业务功能的状态、计算、监听和方法放在一起

例如,一个"用户搜索"功能包含:

  • 搜索关键词。
  • 搜索结果。
  • 加载状态。
  • 请求方法。
  • 关键词监听。

Vue 2 通常会把这些内容分散到 datamethodswatch 中;Vue 3 可以把它们集中写在同一段代码中,还能进一步抽成 useUserSearch() 复用。

2. 不要混淆三个概念

2.1 Vue 版本不等于 API 风格

说法 是否正确 原因
Vue 2 只能使用 Options API 不完全正确 Vue 2.7 内置 Composition API,旧版本也可安装插件
Vue 3 只能使用 Composition API 错误 Vue 3 完整支持 Options API
Vue 3 推荐 <script setup> 正确 它更简洁,类型推导也更自然
Options API 已被废弃 错误 Vue 3 官方仍然支持

Vue 3 Options API 示例:

vue 复制代码
<script>
export default {
  data() {
    return {
      count: 0,
    }
  },
  methods: {
    increment() {
      this.count += 1
    },
  },
}
</script>

这段代码可以在 Vue 3 中正常使用。

2.2 <script setup> 是 Vue 3 的编译语法糖

以下两种 Vue 3 写法表达相似的意思。

普通 setup()

vue 复制代码
<script>
import { ref } from 'vue'

export default {
  setup() {
    const count = ref(0)

    function increment() {
      count.value += 1
    }

    return {
      count,
      increment,
    }
  },
}
</script>

<script setup>

vue 复制代码
<script setup>
import { ref } from 'vue'

const count = ref(0)

/**
 * 将计数器增加 1。
 *
 * @returns 无返回值
 */
function increment() {
  count.value += 1
}
</script>

<script setup> 中的顶层变量和函数可以直接在模板中使用,不需要手动 return

2.3 Vue、构建工具和应用框架不是一回事

名称 作用 常见版本组合
Vue 页面组件和响应式框架 Vue 2、Vue 3
Vue CLI 基于 Webpack 的项目脚手架 Vue 2 项目中常见
Vite 现代开发与构建工具 Vue 3 项目中常见,也支持 Vue 2
Nuxt 基于 Vue 的应用框架 Nuxt 2 常配 Vue 2,Nuxt 3 常配 Vue 3
Vue Router 前端路由 Vue Router 3 常配 Vue 2,4 常配 Vue 3
Vuex / Pinia 状态管理 Vue 2 常见 Vuex,Vue 3 推荐 Pinia

所以,"Vue 2 升级 Vue 3"经常还会同时涉及 Router、Vuex、UI 组件库和构建工具升级。

3. 如何确认项目使用哪个版本

先查看 package.json

json 复制代码
{
  "dependencies": {
    "vue": "^3.4.0",
    "vue-router": "^4.0.0",
    "pinia": "^2.0.0"
  }
}

常见判断依据:

特征 更可能是 Vue 2 更可能是 Vue 3
vue 版本 2.x 3.x
应用入口 new Vue() createApp()
路由版本 Vue Router 3 Vue Router 4
状态管理 Vuex 3 / 4 Pinia 或 Vuex 4
组件脚本 export default {} <script setup>
响应式 API data() ref()reactive()

不要只凭文件长相判断版本。Vue 3 也可以写 export default {},Vue 2.7 也可能出现 ref()

在运行时也可以查看:

js 复制代码
import { version } from 'vue'

console.log(version)

4. 核心语法总览

场景 Vue 2 经典写法 Vue 3 推荐写法
创建应用 new Vue() createApp()
组件状态 data() ref()reactive()
方法 methods 普通函数
计算属性 computed 选项 computed()
监听 watch 选项 watch()watchEffect()
生命周期 mounted onMounted()
Props props 选项 defineProps()
事件声明 通常不声明 defineEmits()
双向绑定 value + input modelValue + update:modelValue
逻辑复用 Mixins Composables
全局配置 Vue.use()Vue.component() app.use()app.component()
根节点 必须单根节点 支持多个根节点 Fragment
TypeScript 支持有限 原生设计重点

模板中的大部分基础语法没有变化:

vue 复制代码
<template>
  <section>
    <input v-model.trim="keyword">
    <button :disabled="loading" @click="search">
      搜索
    </button>

    <p v-if="loading">加载中...</p>
    <ul v-else>
      <li v-for="user in users" :key="user.id">
        {{ user.name }}
      </li>
    </ul>
  </section>
</template>

{``{ }}v-ifv-forv-bindv-on 和大多数修饰符在两个版本中都很相似。

5. 单文件组件结构

5.1 Vue 2 常见结构

vue 复制代码
<script>
export default {
  name: 'UserCounter',
  props: {
    initialCount: {
      type: Number,
      default: 0,
    },
  },
  data() {
    return {
      count: this.initialCount,
    }
  },
  computed: {
    doubledCount() {
      return this.count * 2
    },
  },
  methods: {
    increment() {
      this.count += 1
    },
  },
}
</script>

<template>
  <section>
    <p>当前:{{ count }}</p>
    <p>两倍:{{ doubledCount }}</p>
    <button @click="increment">增加</button>
  </section>
</template>

<style scoped>
button {
  color: #1677ff;
}
</style>

5.2 Vue 3 推荐结构

vue 复制代码
<script setup lang="ts">
import { computed, ref } from 'vue'

interface Props {
  initialCount?: number
}

const props = withDefaults(defineProps<Props>(), {
  initialCount: 0,
})

const count = ref(props.initialCount)
const doubledCount = computed(() => count.value * 2)

/**
 * 将当前计数增加 1。
 *
 * @returns 无返回值
 */
function increment() {
  count.value += 1
}
</script>

<template>
  <section>
    <p>当前:{{ count }}</p>
    <p>两倍:{{ doubledCount }}</p>
    <button @click="increment">
      增加
    </button>
  </section>
</template>

<style scoped>
button {
  color: #1677ff;
}
</style>

主要变化:

  • 不再需要 export default
  • 不再按 datacomputedmethods 分类。
  • 不需要使用 this
  • <script setup> 顶层声明可直接供模板使用。
  • TypeScript 类型可以直接服务于 Props 和事件。

6. 创建应用与挂载方式

6.1 Vue 2

js 复制代码
import Vue from 'vue'
import App from './App.vue'

Vue.config.productionTip = false

new Vue({
  render: h => h(App),
}).$mount('#app')

6.2 Vue 3

js 复制代码
import { createApp } from 'vue'
import App from './App.vue'

const app = createApp(App)

app.mount('#app')

Vue 3 把全局操作限制在某个应用实例上。一个页面创建多个 Vue 应用时,它们的插件、组件和配置不会互相污染。

使用技巧:

  • createApp() 之后先注册插件和全局能力,最后调用 mount()
  • app.mount() 返回根组件实例,但业务代码不要依赖它操控内部组件。
  • Vue 3 挂载后会保留挂载容器,组件内容渲染到容器内部;这与 Vue 2 替换挂载元素的行为不同。

7. 数据与响应式状态

7.1 Vue 2 使用 data()

vue 复制代码
<script>
export default {
  data() {
    return {
      count: 0,
      user: {
        name: '张三',
      },
      users: [],
    }
  },
}
</script>

组件的 data 必须是函数,以保证每个组件实例得到独立数据。

访问和修改时使用 this

js 复制代码
this.count += 1
this.user.name = '李四'

7.2 Vue 3 使用 ref()

ref() 适合数字、字符串、布尔值、数组、对象,以及需要整体替换的状态。

vue 复制代码
<script setup>
import { ref } from 'vue'

const count = ref(0)
const keyword = ref('')
const users = ref([])

count.value += 1
users.value = [{ id: 1, name: '张三' }]
</script>

使用技巧:

  • <script> 中读写 ref 必须使用 .value
  • <template> 中 Vue 会自动解包,直接写 count,不要写 count.value
  • 团队不确定选什么时,优先 ref(),规则更统一,整体替换也方便。
vue 复制代码
<template>
  <p>{{ count }}</p>
  <input v-model="keyword">
</template>

7.3 Vue 3 使用 reactive()

reactive() 适合结构稳定、经常修改内部字段的对象。

vue 复制代码
<script setup>
import { reactive } from 'vue'

const form = reactive({
  name: '',
  age: 18,
  enabled: true,
})

form.name = '张三'
form.age += 1
</script>

reactive() 的主要限制:

  • 只能接收对象类型,不能直接接收数字或字符串。
  • 不要随意把整个响应式对象替换为普通对象。
  • 直接解构会丢失字段与源对象的响应式连接。

错误示例:

js 复制代码
let form = reactive({ name: '' })

// form 变量现在指向普通对象,原代理失去连接
form = { name: '张三' }

需要整体替换时可以使用 ref()

js 复制代码
const form = ref({ name: '' })

form.value = { name: '张三' }

7.4 reactive 解构技巧

下面的写法会丢失响应式连接:

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

state.count += 1
console.log(count) // 仍然是解构时的普通数字

需要解构时使用 toRefs()

js 复制代码
import { reactive, toRefs } from 'vue'

const state = reactive({
  count: 0,
  keyword: '',
})

const { count, keyword } = toRefs(state)

7.5 响应式原理差异

项目 Vue 2 Vue 3
核心实现 Object.defineProperty Proxy
新增对象属性 默认无法检测,需要 $set 可以直接检测
删除对象属性 需要 $delete 可以直接检测
数组索引赋值 默认无法检测 可以直接检测
数组长度修改 默认无法检测 可以直接检测
Map / Set 支持有限 可响应式代理

Vue 2:

js 复制代码
this.$set(this.user, 'age', 18)
this.$delete(this.user, 'age')
this.$set(this.users, 0, newUser)

Vue 3:

js 复制代码
user.age = 18
delete user.age
users[0] = newUser

注意:Vue 3 的 Proxy 不能代理 IE 11,因此 Vue 3 不支持 IE 11。

8. 方法与 this

8.1 Vue 2 使用 methods

vue 复制代码
<script>
export default {
  data() {
    return {
      count: 0,
    }
  },
  methods: {
    increment(step = 1) {
      this.count += step
    },
  },
}
</script>

Vue 2 的 methods 不要使用箭头函数:

js 复制代码
methods: {
  // 错误:箭头函数没有自己的 this
  increment: () => {
    this.count += 1
  },
}

8.2 Vue 3 使用普通函数

vue 复制代码
<script setup>
import { ref } from 'vue'

const count = ref(0)

/**
 * 按指定步长增加计数。
 *
 * @param {number} step 每次增加的数值,默认值为 1
 * @returns {void} 无返回值
 */
function increment(step = 1) {
  count.value += step
}
</script>

没有 this 后,变量来源更明确:

  • count 是当前文件声明的状态。
  • increment 是当前文件声明的方法。
  • 导入的工具函数可以从 import 直接看出来源。

使用技巧:业务方法优先使用有名称的函数声明,调试堆栈更清晰;数组回调、短计算可以使用箭头函数。

9. 计算属性

计算属性适合"根据已有状态得到新值"。它会缓存结果,依赖不变化时不会重复计算。

9.1 Vue 2

vue 复制代码
<script>
export default {
  data() {
    return {
      firstName: '三',
      lastName: '张',
    }
  },
  computed: {
    fullName() {
      return `${this.lastName}${this.firstName}`
    },
  },
}
</script>

9.2 Vue 3

vue 复制代码
<script setup>
import { computed, ref } from 'vue'

const firstName = ref('三')
const lastName = ref('张')

const fullName = computed(() => {
  return `${lastName.value}${firstName.value}`
})
</script>

9.3 可写计算属性

Vue 2:

js 复制代码
computed: {
  fullName: {
    get() {
      return `${this.lastName}${this.firstName}`
    },
    set(value) {
      const [lastName, firstName] = value.split(' ')
      this.lastName = lastName
      this.firstName = firstName
    },
  },
}

Vue 3:

js 复制代码
const fullName = computed({
  get() {
    return `${lastName.value} ${firstName.value}`
  },
  set(value) {
    const [newLastName, newFirstName] = value.split(' ')
    lastName.value = newLastName
    firstName.value = newFirstName
  },
})

使用技巧:

  • 能用计算属性表达的派生值,不要再存一份状态。
  • 计算函数中避免请求接口、写入状态等副作用。
  • 需要执行副作用时使用 watch()

10. 监听状态

10.1 Vue 2 watch

vue 复制代码
<script>
export default {
  data() {
    return {
      keyword: '',
      form: {
        name: '',
      },
    }
  },
  watch: {
    keyword(newValue, oldValue) {
      console.log('关键词变化', newValue, oldValue)
    },
    form: {
      deep: true,
      immediate: true,
      handler(newForm) {
        console.log('表单变化', newForm)
      },
    },
  },
}
</script>

10.2 Vue 3 watch()

vue 复制代码
<script setup>
import { reactive, ref, watch } from 'vue'

const keyword = ref('')
const form = reactive({
  name: '',
})

watch(keyword, (newValue, oldValue) => {
  console.log('关键词变化', newValue, oldValue)
})

watch(
  form,
  (newForm) => {
    console.log('表单变化', newForm)
  },
  {
    deep: true,
    immediate: true,
  },
)
</script>

监听 reactive 对象时本身就是深层监听,但明确写出 deep 有时更便于表达意图。监听某个字段时必须传入 getter:

js 复制代码
watch(
  () => form.name,
  (newName) => {
    console.log('姓名变化', newName)
  },
)

错误写法:

js 复制代码
// 传入的是当前字符串,不是响应式数据源
watch(form.name, () => {})

10.3 Vue 3 watchEffect()

watchEffect() 会自动收集回调中用到的响应式依赖,并立即运行一次。

js 复制代码
import { ref, watchEffect } from 'vue'

const keyword = ref('')
const page = ref(1)

watchEffect(() => {
  console.log('查询条件', keyword.value, page.value)
})

选择技巧:

需求 推荐方式
明确监听一个或多个值 watch()
需要新值和旧值 watch()
需要懒执行 watch()
依赖较多且希望自动收集 watchEffect()
根据状态产生同步新值 computed()

异步搜索还要处理竞态或取消旧请求,不能只依靠监听器:

js 复制代码
watch(keyword, async (newKeyword, _oldKeyword, onCleanup) => {
  const controller = new AbortController()

  onCleanup(() => {
    controller.abort()
  })

  await fetch(`/api/users?keyword=${encodeURIComponent(newKeyword)}`, {
    signal: controller.signal,
  })
})

11. 生命周期

11.1 名称对照

业务阶段 Vue 2 Options API Vue 3 Composition API
实例创建前 beforeCreate 通常直接写在 setup 顶层
实例创建后 created 通常直接写在 setup 顶层
挂载前 beforeMount onBeforeMount
挂载后 mounted onMounted
更新前 beforeUpdate onBeforeUpdate
更新后 updated onUpdated
卸载前 beforeDestroy onBeforeUnmount
卸载后 destroyed onUnmounted
缓存组件激活 activated onActivated
缓存组件停用 deactivated onDeactivated
捕获后代错误 errorCaptured onErrorCaptured

Vue 3 Options API 中,销毁钩子也改名为 beforeUnmountunmounted

11.2 Vue 2 示例

vue 复制代码
<script>
export default {
  mounted() {
    window.addEventListener('resize', this.handleResize)
  },
  beforeDestroy() {
    window.removeEventListener('resize', this.handleResize)
  },
  methods: {
    handleResize() {
      console.log(window.innerWidth)
    },
  },
}
</script>

11.3 Vue 3 示例

vue 复制代码
<script setup>
import { onBeforeUnmount, onMounted } from 'vue'

/**
 * 输出当前浏览器窗口宽度。
 *
 * @returns {void} 无返回值
 */
function handleResize() {
  console.log(window.innerWidth)
}

onMounted(() => {
  window.addEventListener('resize', handleResize)
})

onBeforeUnmount(() => {
  window.removeEventListener('resize', handleResize)
})
</script>

使用技巧:

  • 请求首屏数据可以放在 onMounted();如果框架支持服务端渲染,应优先使用框架的数据获取 API。
  • 定时器、事件监听、观察器和第三方实例要在卸载时清理。
  • 不要在 onUpdated() 中无条件修改状态,容易造成无限更新。

12. Props:父组件向子组件传数据

12.1 Vue 2

vue 复制代码
<script>
export default {
  props: {
    title: {
      type: String,
      required: true,
    },
    count: {
      type: Number,
      default: 0,
    },
    user: {
      type: Object,
      default() {
        return {}
      },
    },
  },
}
</script>

对象和数组默认值必须由函数返回,避免多个实例共享同一个对象。

12.2 Vue 3 JavaScript 写法

vue 复制代码
<script setup>
const props = defineProps({
  title: {
    type: String,
    required: true,
  },
  count: {
    type: Number,
    default: 0,
  },
})

console.log(props.title)
</script>

12.3 Vue 3 TypeScript 写法

vue 复制代码
<script setup lang="ts">
interface User {
  id: number
  name: string
}

interface Props {
  title: string
  count?: number
  user?: User
}

const props = withDefaults(defineProps<Props>(), {
  count: 0,
  user: () => ({
    id: 0,
    name: '',
  }),
})
</script>

核心规则在两个版本中一致:Props 是只读输入,子组件不要直接修改

错误示例:

js 复制代码
props.count += 1
props.user.name = '李四'

虽然嵌套对象的修改有时不会立即报错,但它仍然修改了父组件拥有的数据,数据流会变得难以追踪。

正确做法:

  • 只需要通知父组件:发送事件。
  • 需要本地编辑:复制成表单副本,保存时发送新值。
  • 需要受控双向状态:使用 v-model 约定。

13. Emit:子组件通知父组件

13.1 Vue 2

子组件:

vue 复制代码
<script>
export default {
  methods: {
    submit() {
      this.$emit('save', {
        id: 1,
        name: '张三',
      })
    },
  },
}
</script>

父组件:

vue 复制代码
<UserEditor @save="handleSave" />

Vue 2 中可以不声明事件,直接调用 $emit()

13.2 Vue 3

子组件:

vue 复制代码
<script setup lang="ts">
interface User {
  id: number
  name: string
}

const emit = defineEmits<{
  save: [user: User]
  cancel: []
}>()

/**
 * 将编辑完成的用户数据提交给父组件。
 *
 * @returns 无返回值
 */
function submit() {
  emit('save', {
    id: 1,
    name: '张三',
  })
}
</script>

父组件写法仍然是:

vue 复制代码
<UserEditor @save="handleSave" @cancel="handleCancel" />

Vue 3 建议显式声明组件会发出的事件,优点是:

  • 事件名称和参数更清楚。
  • TypeScript 能检查参数。
  • 声明过的监听器不会作为普通属性落到组件根元素上。

组件事件不会像原生 DOM 事件那样自动向所有祖先冒泡,只会到直接父组件。

14. v-model 的重要变化

14.1 原生表单上的基本用法

两版都可以写:

vue 复制代码
<input v-model.trim="name">
<input v-model.number="age" type="number">
<input v-model="enabled" type="checkbox">

常用修饰符:

修饰符 作用
.trim 去掉输入值两端空格
.number 尝试把输入值转换为数字
.lazy input 时机改为 change 时机同步

14.2 Vue 2 自定义组件 v-model

Vue 2 默认使用:

text 复制代码
Prop:value
事件:input

父组件:

vue 复制代码
<NameInput v-model="name" />

子组件:

vue 复制代码
<script>
export default {
  props: {
    value: {
      type: String,
      default: '',
    },
  },
  methods: {
    handleInput(event) {
      this.$emit('input', event.target.value)
    },
  },
}
</script>

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

也可以通过 model 选项改名:

js 复制代码
export default {
  model: {
    prop: 'checked',
    event: 'change',
  },
  props: {
    checked: Boolean,
  },
}

14.3 Vue 3 自定义组件 v-model

Vue 3 默认使用:

text 复制代码
Prop:modelValue
事件:update:modelValue

父组件:

vue 复制代码
<NameInput v-model="name" />

子组件:

vue 复制代码
<script setup lang="ts">
defineProps<{
  modelValue: string
}>()

const emit = defineEmits<{
  'update:modelValue': [value: string]
}>()

/**
 * 将输入框的新值同步给父组件。
 *
 * @param event 原生输入事件
 * @returns 无返回值
 */
function handleInput(event: Event) {
  const target = event.target as HTMLInputElement
  emit('update:modelValue', target.value)
}
</script>

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

在支持 defineModel() 的 Vue 3 版本中可以进一步简化:

vue 复制代码
<script setup lang="ts">
const model = defineModel<string>({ required: true })
</script>

<template>
  <input v-model="model">
</template>

14.4 Vue 3 支持多个 v-model

父组件:

vue 复制代码
<UserName
  v-model:first-name="firstName"
  v-model:last-name="lastName"
/>

子组件:

vue 复制代码
<script setup lang="ts">
defineProps<{
  firstName: string
  lastName: string
}>()

defineEmits<{
  'update:firstName': [value: string]
  'update:lastName': [value: string]
}>()
</script>

Vue 2 的 .sync

vue 复制代码
<UserDialog :visible.sync="dialogVisible" />

Vue 3 应迁移为具名 v-model

vue 复制代码
<UserDialog v-model:visible="dialogVisible" />

对应子组件事件仍然是 update:visible

15. 模板语法的重要差异

15.1 v-ifv-for 优先级

同时写在一个元素上时:

  • Vue 2:v-for 优先。
  • Vue 3:v-if 优先。

下面这段代码在 Vue 3 中会因为 user 尚未进入 v-for 作用域而出错:

vue 复制代码
<li v-for="user in users" v-if="user.enabled" :key="user.id">
  {{ user.name }}
</li>

两个版本都不推荐混写。使用计算属性过滤:

js 复制代码
const enabledUsers = computed(() => {
  return users.value.filter(user => user.enabled)
})
vue 复制代码
<li v-for="user in enabledUsers" :key="user.id">
  {{ user.name }}
</li>

或者明确增加模板层级:

vue 复制代码
<template v-for="user in users" :key="user.id">
  <li v-if="user.enabled">
    {{ user.name }}
  </li>
</template>

15.2 v-forkey

Vue 3 在 <template v-for> 上使用 key

vue 复制代码
<template v-for="user in users" :key="user.id">
  <UserCard :user="user" />
  <UserActions :user-id="user.id" />
</template>

使用技巧:

  • 优先使用稳定的业务 ID。
  • 会增删、排序或包含表单状态的列表,不要使用数组下标作为 key
  • 条件分支不再需要故意使用相同 key 复用元素;Vue 3 会为分支生成唯一 key。

15.3 事件按键码被移除

Vue 2 可能出现:

vue 复制代码
<input @keyup.13="submit">

Vue 3 使用按键名称:

vue 复制代码
<input @keyup.enter="submit">

15.4 .native 修饰符被移除

Vue 2 为组件根元素监听原生事件:

vue 复制代码
<MyButton @click.native="handleClick" />

Vue 3 移除了 .native。未在子组件 emits 中声明的监听器会作为透传属性交给根元素:

vue 复制代码
<MyButton @click="handleClick" />

如果子组件声明了 click 事件,就应由子组件显式 emit('click')

15.5 v-bind 合并顺序变化

Vue 3 按书写顺序决定同名属性最终值:

vue 复制代码
<!-- id 最终是 explicit-id -->
<div v-bind="attrs" id="explicit-id" />

<!-- id 最终可能来自 attrs -->
<div id="explicit-id" v-bind="attrs" />

迁移旧代码时,不要依赖模糊的覆盖规则,明确安排属性顺序。

16. 插槽 Slot

16.1 Vue 2 常见写法

子组件:

vue 复制代码
<section>
  <header>
    <slot name="header" />
  </header>
  <main>
    <slot :user="user" />
  </main>
</section>

父组件旧语法:

vue 复制代码
<UserPanel>
  <template slot="header">
    用户信息
  </template>

  <template slot-scope="slotProps">
    {{ slotProps.user.name }}
  </template>
</UserPanel>

Vue 2.6 已引入 v-slot,推荐写法与 Vue 3 接近:

vue 复制代码
<UserPanel>
  <template #header>
    用户信息
  </template>

  <template #default="{ user }">
    {{ user.name }}
  </template>
</UserPanel>

16.2 Vue 3

Vue 3 统一使用 v-slot# 简写:

vue 复制代码
<UserPanel>
  <template #header>
    用户信息
  </template>

  <template #default="{ user }">
    {{ user.name }}
  </template>
</UserPanel>

在脚本中访问插槽:

vue 复制代码
<script setup>
const slots = defineSlots()

console.log(Boolean(slots.header))
</script>

普通 setup() 中可以使用 useSlots()setup 上下文的 slots

17. 根节点与 Fragment

Vue 2 组件模板必须只有一个根节点:

vue 复制代码
<template>
  <div>
    <header>标题</header>
    <main>内容</main>
  </div>
</template>

Vue 3 支持多个根节点:

vue 复制代码
<template>
  <header>标题</header>
  <main>内容</main>
</template>

但多根节点会影响属性自动透传。父组件写:

vue 复制代码
<PageLayout class="compact" />

如果 PageLayout 有多个根节点,Vue 不知道 class 应放到哪个节点,需要手动绑定 $attrs

vue 复制代码
<template>
  <header>标题</header>
  <main v-bind="$attrs">内容</main>
</template>
vue 复制代码
<script setup>
defineOptions({
  inheritAttrs: false,
})
</script>

使用技巧:多根节点能减少无意义容器,但不要为了少写一个 div 破坏页面语义或属性归属。

18. $attrs$listeners

Vue 2:

  • $attrs 包含未被 Props 接收的普通属性,不包含 classstyle
  • $listeners 包含父组件传入的事件监听器。
vue 复制代码
<input v-bind="$attrs" v-on="$listeners">

Vue 3:

  • $listeners 被移除并合并到 $attrs
  • classstyle 也包含在 $attrs 中。
  • 事件会表现为 onClickonInput 等属性。
vue 复制代码
<input v-bind="$attrs">

<script setup> 中可以使用:

js 复制代码
import { useAttrs } from 'vue'

const attrs = useAttrs()

attrs 适合透传,不适合作为主要响应式业务状态。需要稳定使用的输入应声明为 Props。

19. 模板引用与组件方法暴露

19.1 Vue 2

父组件:

vue 复制代码
<template>
  <UserEditor ref="editor" />
</template>

<script>
export default {
  mounted() {
    this.$refs.editor.reset()
  },
}
</script>

父组件通常可以访问子组件实例上的大部分内容,耦合较强。

19.2 Vue 3

子组件显式暴露方法:

vue 复制代码
<script setup>
import { reactive } from 'vue'

const form = reactive({
  name: '',
})

/**
 * 重置当前编辑表单。
 *
 * @returns {void} 无返回值
 */
function reset() {
  form.name = ''
}

defineExpose({
  reset,
})
</script>

父组件:

vue 复制代码
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import UserEditor from './UserEditor.vue'

interface UserEditorExpose {
  reset: () => void
}

const editorRef = ref<UserEditorExpose | null>(null)

onMounted(() => {
  editorRef.value?.reset()
})
</script>

<template>
  <UserEditor ref="editorRef" />
</template>

使用技巧:

  • 优先使用 Props 和 Emit 描述数据流。
  • 只在聚焦输入框、重置表单、调用第三方组件实例等命令式场景使用模板引用。
  • 调用前处理 null,组件挂载前引用还不存在。

20. 依赖注入 provide/inject

20.1 Vue 2

祖先组件:

js 复制代码
export default {
  provide() {
    return {
      theme: this.theme,
    }
  },
}

后代组件:

js 复制代码
export default {
  inject: ['theme'],
}

Vue 2 中需要特别注意注入值的响应式行为。

20.2 Vue 3

祖先组件:

vue 复制代码
<script setup lang="ts">
import { provide, readonly, ref } from 'vue'

const theme = ref<'light' | 'dark'>('light')

/**
 * 切换页面主题。
 *
 * @returns 无返回值
 */
function toggleTheme() {
  theme.value = theme.value === 'light' ? 'dark' : 'light'
}

provide('theme', {
  theme: readonly(theme),
  toggleTheme,
})
</script>

后代组件:

vue 复制代码
<script setup>
import { inject } from 'vue'

const themeContext = inject('theme')
</script>

大型 TypeScript 项目建议使用 InjectionKey,避免字符串冲突并获得类型推导。

使用技巧:

  • 适合当前组件树内跨很多层共享的上下文,如表单组、主题、局部注册器。
  • 全局业务数据优先使用 Pinia。
  • 状态修改方法最好由提供者一起提供,后代不要随意直接改共享状态。

21. 逻辑复用:Mixins 与 Composables

21.1 Vue 2 Mixins

js 复制代码
export const paginationMixin = {
  data() {
    return {
      page: 1,
      pageSize: 20,
    }
  },
  methods: {
    resetPage() {
      this.page = 1
    },
  },
}
js 复制代码
import { paginationMixin } from './paginationMixin'

export default {
  mixins: [paginationMixin],
}

Mixins 的主要问题:

  • 在组件中看不出某个字段来自哪个 Mixin。
  • 多个 Mixin 可能出现同名冲突。
  • 依赖关系隐藏在 this 上。
  • TypeScript 类型推导困难。

21.2 Vue 3 Composables

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

/**
 * 创建分页状态及其操作方法。
 *
 * @param initialPageSize 初始每页条数
 * @returns 分页状态和重置方法
 */
export function usePagination(initialPageSize = 20) {
  const page = ref(1)
  const pageSize = ref(initialPageSize)

  /**
   * 将当前页重置为第一页。
   *
   * @returns 无返回值
   */
  function resetPage() {
    page.value = 1
  }

  return {
    page,
    pageSize,
    resetPage,
  }
}

组件中使用:

vue 复制代码
<script setup>
import { usePagination } from './usePagination'

const {
  page,
  pageSize,
  resetPage,
} = usePagination(50)
</script>

Composables 的优势:

  • 导入来源明确。
  • 可以自由重命名返回值。
  • 可以传入参数。
  • 可以组合其他 Composable。
  • 更容易进行类型检查和单元测试。

命名技巧:组合式函数通常以 use 开头,如 usePagination()useUserSearch()

22. Filter 过滤器被移除

Vue 2:

vue 复制代码
<p>{{ price | currency }}</p>
js 复制代码
export default {
  filters: {
    currency(value) {
      return `¥${Number(value).toFixed(2)}`
    },
  },
}

Vue 3 移除了模板过滤器语法。改用普通函数或计算属性:

vue 复制代码
<script setup>
/**
 * 将数值格式化为人民币金额文本。
 *
 * @param {number|string} value 待格式化金额
 * @returns {string} 人民币金额文本
 */
function formatCurrency(value) {
  return `¥${Number(value).toFixed(2)}`
}
</script>

<template>
  <p>{{ formatCurrency(price) }}</p>
</template>

使用技巧:

  • 纯格式化逻辑抽到 utils,便于多个组件复用和测试。
  • 格式化成本高或依赖多个状态时使用 computed()
  • 日期、货币等优先使用 Intl.DateTimeFormatIntl.NumberFormat 或项目统一工具。

23. 事件总线变化

Vue 2 有实例事件 API,因此一些项目会这样创建事件总线:

js 复制代码
const eventBus = new Vue()

eventBus.$on('user-updated', handleUserUpdated)
eventBus.$emit('user-updated', user)
eventBus.$off('user-updated', handleUserUpdated)

Vue 3 移除了组件实例上的 $on$off$once。组件仍可使用 $emit 通知父组件,但不能再把 Vue 实例当通用事件总线。

替代方案:

场景 推荐方式
直接父子组件 Props + Emit
多层局部组件树 provide/inject
跨页面业务状态 Pinia
少量无状态事件 mitt 等轻量事件库

不要默认用事件总线传业务数据,它会隐藏事件来源和接收者。使用外部事件库时,组件卸载前必须移除监听。

24. 全局 API 与插件

24.1 Vue 2

js 复制代码
import Vue from 'vue'
import App from './App.vue'
import MyPlugin from './plugins/myPlugin'
import BaseButton from './components/BaseButton.vue'

Vue.use(MyPlugin)
Vue.component('BaseButton', BaseButton)
Vue.directive('focus', focusDirective)

Vue.prototype.$api = apiClient

new Vue({
  render: h => h(App),
}).$mount('#app')

24.2 Vue 3

js 复制代码
import { createApp } from 'vue'
import App from './App.vue'
import MyPlugin from './plugins/myPlugin'
import BaseButton from './components/BaseButton.vue'

const app = createApp(App)

app.use(MyPlugin)
app.component('BaseButton', BaseButton)
app.directive('focus', focusDirective)

app.config.globalProperties.$api = apiClient

app.mount('#app')

对应关系:

Vue 2 Vue 3
Vue.use() app.use()
Vue.component() app.component()
Vue.directive() app.directive()
Vue.mixin() app.mixin()
Vue.config.* app.config.*
Vue.prototype.$xxx app.config.globalProperties.$xxx

Vue 3 Composition API 中,不建议大量依赖 globalProperties。普通服务可以通过模块导入,组件树上下文可以通过插件配合 provide/inject

Vue 3 插件结构:

ts 复制代码
import type { App, InjectionKey } from 'vue'

interface ApiClient {
  get: (url: string) => Promise<unknown>
}

export const apiClientKey: InjectionKey<ApiClient> = Symbol('apiClient')

export const apiPlugin = {
  /**
   * 向 Vue 应用安装 API 客户端。
   *
   * @param app 当前 Vue 应用实例
   * @param apiClient 需要注入的 API 客户端
   * @returns 无返回值
   */
  install(app: App, apiClient: ApiClient) {
    app.provide(apiClientKey, apiClient)
  },
}

25. 自定义指令钩子变化

常见钩子对照:

Vue 2 Vue 3 说明
bind created / beforeMount 指令绑定阶段
inserted mounted 元素插入页面后
update 移除 使用其他更新钩子
componentUpdated updated 所在组件更新后
unbind unmounted 元素卸载后

Vue 3 聚焦指令:

ts 复制代码
import type { Directive } from 'vue'

export const focusDirective: Directive<HTMLInputElement> = {
  /**
   * 元素挂载后自动获得焦点。
   *
   * @param element 应获得焦点的输入元素
   * @returns 无返回值
   */
  mounted(element) {
    element.focus()
  },
}

使用:

vue 复制代码
<input v-focus>

优先用组件、Props 和普通模板表达 UI;只有需要直接操作底层 DOM 生命周期时才使用自定义指令。

26. Render 函数变化

一般业务页面优先写 <template>,只有高度动态的组件库封装才需要 Render 函数。

26.1 Vue 2

js 复制代码
export default {
  props: ['level'],
  render(h) {
    return h(`h${this.level}`, this.$slots.default)
  },
}

26.2 Vue 3

h 不再作为参数传入,需要从 Vue 导入:

js 复制代码
import { h } from 'vue'

export default {
  props: {
    level: {
      type: Number,
      required: true,
    },
  },
  setup(props, { slots }) {
    return () => h(`h${props.level}`, null, slots.default?.())
  },
}

Vue 3 的 VNode Props 更扁平:

js 复制代码
h('button', {
  class: 'primary_button',
  disabled: false,
  onClick: handleClick,
}, '保存')

从 Vue 2 迁移 JSX、Render 函数或复杂组件库时,需要单独检查 VNode 数据结构和插槽调用方式。

27. 新增内置能力

27.1 Teleport

把组件内容渲染到当前 DOM 层级之外,常用于弹窗、抽屉和全局提示:

vue 复制代码
<Teleport to="body">
  <div v-if="visible" class="dialog_mask">
    <section class="dialog">弹窗内容</section>
  </div>
</Teleport>

组件逻辑关系没有改变,Props、Emit 和注入仍按原组件树工作。

27.2 Suspense

可为异步依赖提供等待状态:

vue 复制代码
<Suspense>
  <AsyncUserPanel />

  <template #fallback>
    <p>加载中...</p>
  </template>
</Suspense>

使用前要确认当前 Vue 版本和框架对 Suspense 的支持范围。

27.3 多根节点

Vue 3 的 Fragment 允许组件返回多个根节点,已在第 17 节说明。

28. 状态管理与路由生态

这些不是 Vue 核心语法,但升级项目时经常一起变化。

28.1 Vuex 与 Pinia

Vue 2 常见 Vuex:

js 复制代码
export default new Vuex.Store({
  state: {
    count: 0,
  },
  mutations: {
    increment(state) {
      state.count += 1
    },
  },
})

Vue 3 推荐 Pinia:

ts 复制代码
import { defineStore } from 'pinia'
import { ref } from 'vue'

export const useCounterStore = defineStore('counter', () => {
  const count = ref(0)

  /**
   * 将全局计数增加 1。
   *
   * @returns 无返回值
   */
  function increment() {
    count.value += 1
  }

  return {
    count,
    increment,
  }
})

Pinia 没有 Mutation 层,Action 可以同步或异步修改状态。它也能在 Vue 2.7 或安装 Composition API 插件的 Vue 2 项目中使用,因此不能只凭 Pinia 判断 Vue 大版本。

28.2 Vue Router 常用变化

Vue Router 3 创建实例:

js 复制代码
const router = new VueRouter({
  mode: 'history',
  routes,
})

Vue Router 4:

js 复制代码
import { createRouter, createWebHistory } from 'vue-router'

const router = createRouter({
  history: createWebHistory(),
  routes,
})

组件中使用:

vue 复制代码
<script setup>
import { useRoute, useRouter } from 'vue-router'

const route = useRoute()
const router = useRouter()

/**
 * 跳转到用户详情页。
 *
 * @param {number} userId 用户 ID
 * @returns {Promise<void>} 路由跳转完成后的 Promise
 */
async function openUser(userId) {
  await router.push({
    name: 'user-detail',
    params: {
      id: userId,
    },
  })
}
</script>

不要解构 route.params 后期待它自动响应后续路由变化,必要时使用计算属性:

js 复制代码
const userId = computed(() => route.params.id)

29. 一个完整的增删改查对照示例

下面用"用户列表"展示两种风格如何组织同一功能。

29.1 Vue 2 Options API

vue 复制代码
<script>
export default {
  name: 'UserManage',
  data() {
    return {
      keyword: '',
      loading: false,
      nextId: 3,
      users: [
        { id: 1, name: '张三', enabled: true },
        { id: 2, name: '李四', enabled: false },
      ],
    }
  },
  computed: {
    filteredUsers() {
      const keyword = this.keyword.trim()

      if (!keyword) {
        return this.users
      }

      return this.users.filter((user) => {
        return user.name.includes(keyword)
      })
    },
  },
  methods: {
    addUser() {
      const name = this.keyword.trim()

      if (!name) {
        return
      }

      this.users.push({
        id: this.nextId,
        name,
        enabled: true,
      })
      this.nextId += 1
      this.keyword = ''
    },
    toggleUser(userId) {
      const user = this.users.find(item => item.id === userId)

      if (user) {
        user.enabled = !user.enabled
      }
    },
    deleteUser(userId) {
      this.users = this.users.filter(user => user.id !== userId)
    },
  },
}
</script>

<template>
  <main class="user_manage">
    <form @submit.prevent="addUser">
      <input v-model.trim="keyword" placeholder="输入姓名">
      <button type="submit" :disabled="!keyword">
        新增
      </button>
    </form>

    <p v-if="loading">加载中...</p>

    <ul v-else-if="filteredUsers.length">
      <li v-for="user in filteredUsers" :key="user.id">
        <span>{{ user.name }}</span>
        <span>{{ user.enabled ? '启用' : '停用' }}</span>
        <button type="button" @click="toggleUser(user.id)">
          切换状态
        </button>
        <button type="button" @click="deleteUser(user.id)">
          删除
        </button>
      </li>
    </ul>

    <p v-else>暂无用户</p>
  </main>
</template>

29.2 Vue 3 Composition API

vue 复制代码
<script setup lang="ts">
import { computed, ref } from 'vue'

interface User {
  id: number
  name: string
  enabled: boolean
}

const keyword = ref('')
const loading = ref(false)
const nextId = ref(3)
const users = ref<User[]>([
  { id: 1, name: '张三', enabled: true },
  { id: 2, name: '李四', enabled: false },
])

const filteredUsers = computed(() => {
  const normalizedKeyword = keyword.value.trim()

  if (!normalizedKeyword) {
    return users.value
  }

  return users.value.filter((user) => {
    return user.name.includes(normalizedKeyword)
  })
})

/**
 * 使用当前输入的姓名新增用户,并清空输入框。
 *
 * @returns 无返回值;姓名为空时不执行新增
 */
function addUser() {
  const name = keyword.value.trim()

  if (!name) {
    return
  }

  users.value.push({
    id: nextId.value,
    name,
    enabled: true,
  })
  nextId.value += 1
  keyword.value = ''
}

/**
 * 切换指定用户的启用状态。
 *
 * @param userId 需要切换状态的用户 ID
 * @returns 无返回值;用户不存在时不执行修改
 */
function toggleUser(userId: number) {
  const user = users.value.find(item => item.id === userId)

  if (user) {
    user.enabled = !user.enabled
  }
}

/**
 * 从列表中删除指定用户。
 *
 * @param userId 需要删除的用户 ID
 * @returns 无返回值
 */
function deleteUser(userId: number) {
  users.value = users.value.filter(user => user.id !== userId)
}
</script>

<template>
  <main class="user_manage">
    <form @submit.prevent="addUser">
      <input v-model.trim="keyword" placeholder="输入姓名">
      <button type="submit" :disabled="!keyword">
        新增
      </button>
    </form>

    <p v-if="loading">加载中...</p>

    <ul v-else-if="filteredUsers.length">
      <li v-for="user in filteredUsers" :key="user.id">
        <span>{{ user.name }}</span>
        <span>{{ user.enabled ? '启用' : '停用' }}</span>
        <button type="button" @click="toggleUser(user.id)">
          切换状态
        </button>
        <button type="button" @click="deleteUser(user.id)">
          删除
        </button>
      </li>
    </ul>

    <p v-else>暂无用户</p>
  </main>
</template>

模板几乎没有变化,主要区别集中在脚本:

Vue 2 Vue 3
状态放在 data() 状态由 ref() 创建
派生值放在 computed 调用 computed()
方法放在 methods 直接声明函数
通过 this 访问成员 直接使用变量,通过 .value 访问 ref
JavaScript 为主 TypeScript 类型更加自然

30. Vue 2 项目迁移 Vue 3 的推荐步骤

不要一上来就把所有 Options API 改成 Composition API。版本升级和代码风格改写是两件事,同时进行会扩大排错范围。

30.1 升级前盘点

  • 确认 Node.js 和构建工具版本。
  • 统计 Vue、Router、Vuex、UI 库和插件版本。
  • 检查第三方库是否支持 Vue 3。
  • 搜索 $on$off$once 事件总线。
  • 搜索 .native.sync、Filter 和按键码。
  • 搜索 Vue.setthis.$setVue.delete
  • 搜索全局 Vue.useVue.componentVue.prototype
  • 搜索自定义指令和 Render 函数。
  • 检查依赖 $children$listeners 的封装组件。
  • 为登录、路由、表单和核心业务补充回归测试。

30.2 推荐迁移顺序

  1. 先升级工具链和 Vue 核心,保持 Options API。
  2. 迁移 new Vue()createApp()
  3. 迁移 Router、状态管理和 UI 组件库。
  4. 修复模板废弃语法与 v-model 契约。
  5. 修复插件、全局 API、指令和 Render 函数。
  6. 完成业务回归测试。
  7. 最后按业务模块逐步引入 <script setup> 和 Composables。

30.3 可以使用兼容构建版

大型 Vue 2 项目可以评估 Vue 3 的迁移构建版本 @vue/compat,它能为部分 Vue 2 行为提供兼容并输出警告。

但它不是长期解决方案:

  • 第三方依赖仍需确认兼容性。
  • 警告需要逐项消除。
  • 已移除或行为改变的 API 仍需人工验证。
  • 最终目标应是关闭兼容模式。

30.4 每批迁移都要验证

  • 页面能否正常进入和离开。
  • 表单输入与校验是否正常。
  • 组件 v-model 是否双向同步。
  • 弹窗、下拉框等是否正确挂载。
  • 路由参数和权限守卫是否正常。
  • 列表的新增、删除、排序是否错位。
  • 事件监听与定时器是否正确清理。
  • 构建、单元测试和关键业务流程是否通过。

31. 新手学习与使用技巧

31.1 阅读 Vue 2 代码的方法

看到一个 Vue 2 组件时,按以下顺序:

  1. props:组件从父组件接收什么。
  2. data:组件自己保存什么状态。
  3. computed:哪些值由状态计算得出。
  4. watch:状态变化会触发什么副作用。
  5. methods:用户操作和业务方法在哪里。
  6. 看生命周期:什么时候请求数据、绑定和清理资源。
  7. 回到模板:事件如何触发方法,状态如何渲染。

遇到 this.xxx 时,可以在 propsdatacomputedmethods、注入内容或全局实例属性中寻找来源。

31.2 阅读 Vue 3 代码的方法

看到 <script setup> 时,按业务功能阅读:

  1. import:外部能力来自哪里。
  2. definePropsdefineEmits:组件输入和输出。
  3. refreactive:本地状态。
  4. computed:派生状态。
  5. watch:副作用。
  6. onMounted 等钩子:生命周期行为。
  7. 看普通函数:用户操作。
  8. 看模板如何消费这些变量和函数。

31.3 ref 还是 reactive

可以先记住这套实用规则:

场景 推荐
数字、字符串、布尔值 ref()
需要整体替换的数组或对象 ref()
结构固定的复杂表单 reactive()
暂时拿不准 ref()

不要在同一个项目里频繁无理由切换风格。统一性比争论某一种 API 绝对更好更重要。

31.4 computed 还是 watch

text 复制代码
想得到一个新值?        -> computed
想在变化后做一件事?    -> watch
想自动追踪多项依赖?    -> watchEffect
想响应一次点击?        -> 普通事件方法

例如:

  • 根据单价和数量算总价:computed
  • 关键词变化后请求接口:watch
  • 点击保存按钮提交表单:普通函数。

31.5 状态放在哪里

从最近、最小的范围开始:

text 复制代码
只在当前组件使用       -> ref / reactive
父子组件共享           -> Props + Emit / v-model
当前组件树跨多层共享   -> provide / inject
跨页面共享业务状态     -> Pinia
需要分享和刷新后恢复   -> 路由参数或持久化存储

不要把所有变量都放进全局 Store。弹窗显示、输入框文字、hover 状态通常只属于当前组件。

31.6 常用调试技巧

  • 使用 Vue Devtools 查看组件树、Props、事件和 Pinia 状态。
  • 在编辑器中按 F12 跳到定义,不要凭函数名猜来源。
  • 模板没有更新时,先确认修改的是响应式值。
  • Vue 3 脚本中检查是否漏写 .value
  • 组件事件无效时,检查事件名、参数和 defineEmits
  • v-model 无效时,对照 Prop 名与 update:* 事件名。
  • 列表显示错位时,检查 key 是否稳定。
  • 组件卸载后仍触发逻辑时,检查事件、定时器和请求是否清理。

31.7 不要滥用的写法

  • 不要直接修改 Props。
  • 不要同时在一个元素上写 v-ifv-for
  • 不要在计算属性里发请求或修改其他状态。
  • 不要为了共享一个局部变量就创建全局 Store。
  • 不要用模板引用代替正常的 Props / Emit 数据流。
  • 不要把所有 Options API 代码机械转换成 Composition API。
  • 不要为了追求"新语法"一次性重写稳定的老项目。

32. 常见报错与排查方式

32.1 Cannot read properties of undefined (reading 'xxx')

可能原因:

  • 异步数据尚未返回。
  • 对象初始值不完整。
  • Vue 2 的 this 指向错误。
  • Props 没有传入。

处理方式:

vue 复制代码
<p v-if="user">{{ user.name }}</p>

或在脚本中使用合理初始值和可选链:

js 复制代码
const user = ref(null)
const userName = computed(() => user.value?.name ?? '')

32.2 Vue 3 脚本中状态不变化

错误:

js 复制代码
const count = ref(0)
count += 1

正确:

js 复制代码
count.value += 1

模板中则直接写:

vue 复制代码
<button @click="count += 1">{{ count }}</button>

32.3 解构后不再响应

错误:

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

正确:

js 复制代码
const { count } = toRefs(state)

或者直接使用 state.count

32.4 v-model 没有同步

检查契约:

父组件 子组件 Prop 子组件事件
Vue 2 v-model value input
Vue 3 v-model modelValue update:modelValue
Vue 3 v-model:show show update:show

名称和大小写必须对应。

32.5 Vue 2 新增字段后页面不更新

js 复制代码
// 可能不响应
this.user.age = 18

// Vue 2 正确方式
this.$set(this.user, 'age', 18)

也可以提前在 data() 中声明完整字段。

32.6 Vue 3 多根节点的 class 没有生效

多根节点组件无法自动判断属性落点。使用 v-bind="$attrs" 明确绑定到目标根元素,并根据需要设置 inheritAttrs: false

33. 高频语法速查表

需求 Vue 2 Vue 3
声明状态 data() { return { count: 0 } } const count = ref(0)
脚本读取状态 this.count count.value
模板读取状态 {``{ count }} {``{ count }}
声明方法 methods: { save() {} } function save() {}
计算属性 computed: { total() {} } const total = computed(() => {})
监听值 watch: { count() {} } watch(count, () => {})
挂载完成 mounted() onMounted(() => {})
卸载前 beforeDestroy() onBeforeUnmount(() => {})
接收 Props props: { title: String } defineProps<{ title: string }>()
发送事件 this.$emit('save', data) emit('save', data)
默认双向 Prop value modelValue
默认双向事件 input update:modelValue
多个双向值 .sync 或自定义 多个 v-model:xxx
获取模板引用 this.$refs.editor editorRef.value
暴露子组件方法 默认实例可访问 defineExpose()
逻辑复用 Mixins Composables
新增响应字段 this.$set() 直接赋值
删除响应字段 this.$delete() delete
全局插件 Vue.use() app.use()
全局组件 Vue.component() app.component()
全局实例属性 Vue.prototype app.config.globalProperties
格式化模板值 Filter 普通函数 / computed
原生组件事件 .native 普通事件透传或显式 Emit
更新 Prop 语法 .sync v-model:propName

34. 最后记住这十条

  1. Vue 2 和 Vue 3 的模板基础语法大部分相同,脚本组织方式变化更明显。
  2. Vue 3 支持 Options API,升级版本不等于必须重写所有组件。
  3. Vue 3 新项目优先使用 Composition API、<script setup> 和 TypeScript。
  4. ref 在脚本中使用 .value,在模板中通常自动解包。
  5. reactive 不要随意整体替换,解构时注意响应式丢失。
  6. Props 只读,子组件通过 Emit 表达修改意图。
  7. Vue 3 默认 v-modelmodelValue + update:modelValue
  8. 派生值使用 computed,副作用使用 watch,用户操作使用普通方法。
  9. 局部状态留在组件中,跨页面业务状态再考虑 Pinia。
  10. 迁移老项目时先保证行为兼容,再逐步改善代码组织方式。

建议的学习顺序:

text 复制代码
HTML / CSS / JavaScript 基础
        ↓
Vue 模板语法
        ↓
ref、reactive、computed
        ↓
Props、Emit、v-model、Slot
        ↓
watch 与生命周期
        ↓
Router、Pinia、请求封装
        ↓
Composables 与 TypeScript

掌握 Vue 的关键不是背完所有 API,而是始终能回答三个问题:数据从哪里来、由谁修改、变化后影响哪些界面。只要数据流清楚,不论面对 Vue 2 还是 Vue 3,阅读和维护代码都会容易很多。

相关推荐
腻害兔1 小时前
【若依项目-产品经理视角】RuoYi-Vue-Pro 源码拆解:ERP 企业资源模块,一个轻量级进销存的完整实现?
前端·javascript·vue.js·人工智能·前端框架·产品经理·ai编程
虹科网络安全1 小时前
艾体宝新闻|从 SQL 注入到服务器接管:CVE-2026-57517 暴露 Web 管理面板的供应链与安全编码风险
服务器·前端·sql
糖果店的幽灵2 小时前
langgraph的四种state解析
java·前端·javascript·langgraph
顺颂时绥_12 小时前
前端大图片压缩与安卓/苹果设备矫正实践
前端
Allshadow2 小时前
Nest.js - 目录结构
javascript·nestjs
Csvn2 小时前
JSON.parse 大数精度丢失:一个让前后端互相甩锅的 Bug
前端
Simon_He2 小时前
AI 对话里的流式 Markdown 为什么难:从不完整 Token 到增量渲染
vue.js
张元清2 小时前
React useCookie Hook:把 Cookie 变成响应式状态(2026)
javascript·react.js
达子6662 小时前
第4章_图解HarmonyOS 的结构
vue.js