Vue 3 核心概念学习笔记

阅读提示:本文从 Java 开发者视角出发,结合一个真实的全栈项目(Vue 3 + Spring Boot + Element Plus)代码,层层拆解 Vue 3 核心概念。


目录

  1. Composition API —— 新的组织方式
  2. ref vs reactive —— 响应式基础
  3. toRef vs toRefs —— 解构不丢响应式
  4. Proxy 响应式原理 —— Vue 如何监听对象和数组变化
  5. Vue 生命周期
  6. Composable 函数 —— 组合式逻辑复用
  7. 组件通信 —— Props, Emits, $attrs, v-bind
  8. 插槽 Slots
  9. 动态组件 & 异步组件
  10. keep-alive —— 缓存组件状态
  11. 事件修饰符 & .sync
  12. nextTick —— 等待 DOM 更新完成
  13. 递归组件 & 组件循环引用
  14. Pinia vs Vuex —— 状态管理对比
  15. 路由模式 & 路由守卫
  16. CSS 样式隔离 & :deep()
  17. 渲染函数 —— 模板底层的真身
  18. 虚拟 DOM —— 如何创建和更新 DOM
  19. 依赖注入 —— Vue 世界的 @Autowired

1. Composition API —— 新的组织方式

为什么需要它

**Options API(Vue 2 风格)**按类型拆分代码:

export default {
  data() {
    return {
      count: 0,
      name: '张三',
      cart: []
    }
  },
  methods: {
    add() { this.count++ },
    login() { ... },
    checkout() { ... }
  },
  computed: {
    label() { ... },
    totalPrice() { ... }
  }
}

问题:countadd 是一伙的,cartcheckout 是一伙的,但它们被分散在不同的区块里。

Composition API 按功能聚拢:

<script setup>
// ======== 计数器相关 ========
const count = ref(0)
function add() { count.value++ }

// ======== 购物车相关 ========
const cart = ref([])
function checkout() { ... }

// ======== 用户相关 ========
const user = ref(null)
function login() { ... }
</script>

Java 类比

Options API 像把所有类的属性放一起、方法放一起 → 找不到谁跟谁相关
Composition API 就是一个类里字段和方法挨着 → 一眼看出归属

实际应用

在现代 Vue 3 项目中,几乎所有 .vue 文件都使用 <script setup>。Pinia Store 也采用同样的 Composition API 写法:

// stores/auth.js
export const useAuthStore = defineStore('auth', () => {
  const token = ref(localStorage.getItem('token') || '')
  const user = ref(null)

  function login(email, password) { ... }
  function logout() { ... }

  return { token, user, login, logout }
})

2. ref vs reactive —— 响应式基础

核心区别

// ref — 包一层 .value,支持所有类型
const count = ref(0)          // 数字
const name = ref('张三')       // 字符串
const list = ref([])           // 数组
const user = ref(null)         // 对象/null

// reactive — 不用 .value,仅支持对象/数组
const form = reactive({
  email: '',
  password: ''
})
ref reactive
基本类型(string/number/boolean) ref(0) ❌ 不支持
对象/数组 ref({}) ref([]) reactive({})
访问方式 .value 直接 .属性
整体替换值 list.value = newData ❌ 赋值会断开响应式

什么时候用哪个

实践中的经验法则:

// ✅ 95% 场景:用 ref
const loading = ref(false)
const letters = ref([])
const token = ref('')
const user = ref(null)

// ✅ 仅表单:用 reactive(多属性绑一起,模板里不用 .value 更清爽)
const form = reactive({
  content: '',
  themeName: '',
  isPublic: false
})

默认用 refreactive 只留给表单这种"一组关联属性"的场景。

为什么 reactive 不能替换整个对象

let state = reactive({ count: 0 })
state = reactive({ count: 1 })  // ❌ 原 Proxy 被丢弃,视图不会更新

const state = ref({ count: 0 })
state.value = { count: 1 }      // ✅ ref 可以整个替换

3. toRef vs toRefs —— 解构不丢响应式

问题

const state = reactive({ count: 0, name: '张三' })

// ❌ 直接解构 → 丢失响应式
const { count, name } = state
count++  // 普通数字,页面不会更新!

reactive 对象是 Proxy,解构出来的是原始值,断了和响应系统的联系。

toRef — 取单个属性

const count = toRef(state, 'count')
count.value++  // ✅ 页面更新!和 state.count 指向同一个值

toRefs — 取全部属性

const { count, name } = toRefs(state)
//     ↑ Ref<number>     ↑ Ref<string>

count.value++    // ✅ 响应式
name.value = '李四'  // ✅ 响应式

实用建议

实际开发中普遍使用 ref() 而非 reactive(),每个变量本身就是 Ref,解构不会丢失响应式,所以通常不需要 toRef/toRefs


4. Proxy 响应式原理 —— Vue 如何监听对象和数组变化

Vue 2 的方式:Object.defineProperty

// Vue 2 只能拦截已知属性
Object.defineProperty(obj, 'name', {
  get() { ... },
  set(val) { ... }
})

obj.name = '张三'  // ✅ 能检测到
obj.age = 20       // ❌ 检测不到!age 未预先定义

Vue 3 的方式:Proxy

// Proxy 拦截整个对象的所有操作
const proxy = new Proxy(obj, {
  get(target, key) { return target[key] },
  set(target, key, val) {
    target[key] = val
    console.log(`${key} 改变了!`)
    return true
  }
})

proxy.name = '张三'  // ✅ 能检测
proxy.age = 20       // ✅ 也能检测!(Proxy 天然支持新增属性)

Java 类比

Object.defineProperty = 给每个字段写 getter/setter    ← 麻烦,新增字段要补
Proxy                 = Spring AOP,拦截对象的所有操作  ← 一次配置,全部拦截

带来的变化

Vue 2 Vue 3
新增属性 ❌ 需要 $set(obj, 'key', val) ✅ 直接 obj.key = val
删除属性 ❌ 需要 $delete(obj, 'key') ✅ 直接 delete obj.key
数组索引 arr[0] = 'x' 不触发更新 ✅ 触发
数组 length arr.length = 0 不触发 ✅ 触发

所以 Vue 3 中 $set 被移除了,因为 Proxy 天然支持所有操作。


5. Vue 生命周期

完整生命周期图

 创建阶段              挂载阶段              更新阶段              销毁阶段
    │                    │                    │                    │
 setup()          onBeforeMount        onBeforeUpdate       onBeforeUnmount
    │                    │                    │                    │
    ↓                    ↓                    ↓                    ↓
 初始化            挂载到 DOM            DOM 更新            卸载完成
    │                    │                    │                    │
    └──→ onMounted ──→ …… ──→ onUpdated ──→ …… ──→ onUnmounted

最常用的两个钩子

<script setup>
import { onMounted, onUnmounted } from 'vue'

// ✅ onMounted:数据获取、DOM 初始化、定时器
onMounted(() => {
  fetchData()
  timer = setInterval(refresh, 30000)
})

// ✅ onUnmounted:清理定时器、移除事件监听、销毁资源
onUnmounted(() => {
  clearInterval(timer)
})
</script>

典型代码示例

onMounted(() => {
  fetchUnread()
  timer = setInterval(fetchUnread, 30000)   // 30秒轮询
})

onUnmounted(() => {
  clearInterval(timer)                      // 防止内存泄漏
})

所有钩子一览

钩子 时机 使用频率
setup() 组件创建前(Composition API 入口) <script setup> 本身就是
onBeforeMount DOM 挂载前 较少使用
onMounted DOM 挂载完成 ⭐ 最常用
onBeforeUpdate 数据变化,DOM 更新前 较少使用
onUpdated DOM 更新完成 较少使用
onBeforeUnmount 卸载前 较少使用
onUnmounted 卸载完成 ⭐ 最常用
onActivated keep-alive 激活时 配合 keep-alive 使用
onDeactivated keep-alive 失活时 配合 keep-alive 使用

Java 类比

onMounted   = @PostConstruct  // Bean 初始化完成后
onUnmounted = @PreDestroy     // Bean 销毁前

6. Composable 函数 —— 组合式逻辑复用

是什么

把一组相关的响应式状态 + 逻辑抽成一个函数,多个组件复用。

// composables/useShakeDetector.js
export function useShakeDetector() {
  const isSupported = ref(false)
  const isShaking = ref(false)

  let lastTime = 0
  function handleMotion(event) {
    const acc = event.accelerationIncludingGravity
    const speed = Math.abs(acc.x) + Math.abs(acc.y) + Math.abs(acc.z)
    if (speed > 25 && Date.now() - lastTime > 1000) {
      isShaking.value = true
      lastTime = Date.now()
    }
  }

  onMounted(() => {
    if (window.DeviceMotionEvent) {
      isSupported.value = true
      window.addEventListener('devicemotion', handleMotion)
    }
  })

  onUnmounted(() => {
    window.removeEventListener('devicemotion', handleMotion)
  })

  return { isSupported, isShaking }  // 暴露响应式状态
}

任何组件用:

<script setup>
const { isSupported, isShaking } = useShakeDetector()
</script>

<template>
  <div v-if="isShaking">🎉 摇一摇成功!</div>
</template>

Java 类比

Composable = Java 的"组合代替继承"——不通过 extends,而是把可复用的功能注入进来。

关键特性

// 生命周期钩子在 Composable 里自动绑定到【调用它的组件】
function useTimer() {
  const count = ref(0)
  let timer

  onMounted(() => { timer = setInterval(() => count.value++, 1000) })
  onUnmounted(() => { clearInterval(timer) })

  return { count }
}

// A 组件调用 → 定时器属于 A
// B 组件调用 → 定时器属于 B
// 各自独立,谁卸载清谁的

实际项目中的 Composables 示例

以下是一个真实项目中的可复用模块列表:

文件 功能
useParticles.js Canvas 2D 浮动粒子背景
useShakeDetector.js 手机摇一摇检测
useFishAnimation.js Canvas 2D 鱼动画过渡效果
useJellyfishScene.js Three.js 水母 3D 场景
useSubmarineScene.js Three.js 潜艇舷窗框架
useRoutePreload.js 鼠标悬停时提前加载目标页面
useGuestWelcome.js 游客欢迎弹窗
useErrorMonitor.js 全局前端错误监控上报

7. 组件通信 —— Props, Emits, $attrs, v-bind

Props — 父传子

<!-- 父组件 -->
<ThemePicker :themes="themeList" :selected-id="currentId" />
<!-- 子组件 ThemePicker.vue -->
<script setup>
const props = defineProps({
  themes: { type: Array, required: true },
  selectedId: { type: Number, default: null },
  mode: { type: String, default: 'trigger' }
})
// 使用:props.themes
</script>

Java 类比:Props = 构造函数的参数,父组件传入,子组件接收,只读。

Emits — 子传父

<!-- 子组件 ThemePicker.vue -->
<script setup>
const emit = defineEmits(['update:selectedId', 'select'])

function choose(id) {
  emit('update:selectedId', id)  // 通知父组件
}
</script>
<!-- 父组件 -->
<ThemePicker @update:selected-id="currentId = $event" />

Java 类比:Emit = 回调函数,子组件调用,父组件响应。

v-bind / $attrs — 属性透传

<!-- v-bind 简写是 : -->
<img :src="imageUrl" :alt="title" />

<!-- 批量绑定:把对象的所有属性一次性绑到元素上 -->
<div v-bind="configObject" />

$attrs:包含所有未被 defineProps 声明的属性,用于透传给子组件。

<!-- 中间组件:自己不处理,全扔给下一层 -->
<ChildComponent v-bind="$attrs" />

Vue 3 中 $listeners 已移除,合并进 $attrs。事件也通过 $attrs 透传。

通信方式选择

关系 用什么
父 → 子 Props
子 → 父 Emits
跨多层级 provide / inject
全局共享(登录状态等) Pinia Store
透传属性 $attrs / v-bind="$attrs"

Props/Emits 和 Pinia 互补而非互斥:父子通信用 Props/Emits,跨组件全局共享用 Pinia。


8. 插槽 Slots

是什么

类似 Java 模板方法模式——父组件决定"放什么内容",子组件决定"放在哪"。

默认插槽

<!-- 子组件:留一个"洞" -->
<template>
  <div class="card">
    <slot />   ← 父组件的内容会插入这里
  </div>
</template>

<!-- 父组件 -->
<Card>
  <p>这是卡片内容</p>
</Card>

具名插槽(多个洞)

<!-- 子组件 -->
<div class="card">
  <header><slot name="header" /></header>
  <main><slot /></main>                <!-- 默认插槽 -->
  <footer><slot name="footer" /></footer>
</div>

<!-- 父组件 -->
<Card>
  <template #header>   ← v-slot:header 的简写
    <h2>标题</h2>
  </template>

  <p>默认内容</p>

  <template #footer>
    <button>确定</button>
  </template>
</Card>

作用域插槽(子传数据给父)

<!-- 子组件 -->
<slot :item="item" :index="index" />

<!-- 父组件 -->
<List v-slot="{ item, index }">
  <span>{{ index }}. {{ item.name }}</span>
</List>

实际使用场景

在 Element Plus 等 UI 库中大量使用具名插槽来定制组件内部:

<!-- 下拉菜单内容 -->
<template #dropdown>
  <el-dropdown-menu>...</el-dropdown-menu>
</template>

<!-- 输入框前缀图标 -->
<template #prefix>
  <el-icon><Search /></el-icon>
</template>

<!-- 标签页标题 -->
<template #label>
  <span class="tab-label"><el-icon><Timer /></el-icon> 最新</span>
</template>

<!-- 对话框底部 -->
<template #footer>
  <el-button @click="showReport = false">取消</el-button>
  <el-button type="primary" @click="submitReport">提交</el-button>
</template>

9. 动态组件 & 异步组件

动态组件:用变量决定渲染哪个组件

<template>
  <!-- currentTab 是变量,值变化时自动切换渲染的组件 -->
  <component :is="currentTab" />
</template>

<script setup>
import TabA from './TabA.vue'
import TabB from './TabB.vue'

const currentTab = ref(TabA)

function switchTab() {
  currentTab.value = TabB  // 切换到 B
}
</script>

实际使用示例

<router-view v-slot="{ Component, route: r }">
  <transition :name="r.meta.transition || 'page-fade'" mode="out-in">
    <component :is="Component" :key="r.path" />
  </transition>
</router-view>

router-view 把当前路由对应的组件作为 Component 传出来,<component :is="Component"> 动态渲染它。配合 <transition> 实现页面切换动画。

异步组件:按需加载

// 路由懒加载 —— Vue Router 自动转为异步组件
component: () => import('../views/home/HomeView.vue')

只在用户访问该路由时才下载对应 JS 文件,减小首屏体积。

还可以做预加载优化——鼠标悬停导航项时提前下载:

// composables/useRoutePreload.js
const preloadMap = {
  Square: () => import('../views/square/SquareView.vue'),
  Write: () => import('../views/letter/WriteView.vue'),
  // ... 更多页面
}

function preload(name) {
  preloadMap[name]()  // 触发 import(),不阻塞
}

就像 Java 的懒加载 + 预加载:先不加载,鼠标悬停时提前加载,点击时瞬间打开。


10. keep-alive —— 缓存组件状态

问题

<!-- 用户在 A 页面填了表单,切到 B 页面再回来,表单数据丢了 -->
<router-view />

每次切换路由,组件销毁重建,状态丢失。

解决

<keep-alive>
  <router-view />
</keep-alive>

<!-- 只缓存指定组件 -->
<keep-alive include="HomeView,SquareView">
  <router-view />
</keep-alive>

keep-alive 包裹的组件不会被销毁,而是缓存起来,切换回来时直接复用。

多了两个生命周期

onActivated()   // 从缓存中被激活时调用
onDeactivated() // 被缓存时调用

Java 类比

keep-alive = 数据库连接池 —— 不销毁重建,而是缓存复用,省去初始化开销

注意:并非所有项目都需要 keep-alive,如果每个页面数据都是实时获取的,不缓存反而是更好的选择。


11. 事件修饰符 & .sync

常用事件修饰符

修饰符 作用 示例
.stop 阻止事件冒泡 @click.stop="toggleHatch"
.prevent 阻止默认行为 @submit.prevent="onSubmit"
.self 只有点击元素自身才触发 @click.self="dismissWelcome"
.once 只触发一次 @click.once="doOnce"
.enter 按回车键 @keyup.enter="handleSearch"
.esc 按 Esc 键 @keyup.esc="close"
.exact 精确按键组合 @click.ctrl.exact="onCtrlClick"
<!-- 典型用法 -->
<div class="sonar-panel" @click.stop>        <!-- 点击面板内部不关闭 -->
<div class="overlay" @click.self="dismiss">  <!-- 只点击遮罩层才关闭 -->
<el-input @keyup.enter="handleSearch" />    <!-- 回车搜索 -->

v-model 修饰符

修饰符 作用
.trim 自动去首尾空格
.number 自动转数字
.lazy 改 change 事件而非 input(失焦才更新)
<el-input v-model.trim="name" />
<el-input v-model.number="age" />
<el-input v-model.lazy="bio" />

.sync 修饰符(Vue 2)→ 已移除

<!-- Vue 2 写法 -->
<Child :title.sync="parentTitle" />

<!-- Vue 3 等价写法 -->
<Child v-model:title="parentTitle" />

子组件用 emit('update:title', newValue) 通知父组件更新,.syncv-model:propName 完全替代。


12. nextTick —— 等待 DOM 更新完成

问题

Vue 的 DOM 更新是异步的。数据变了,DOM 不会立刻变化:

messages.value = newList     // 改了数据
scrollToBottom()             // ❌ DOM 还没更新,拿到的还是旧列表高度!

解决

import { nextTick } from 'vue'

messages.value = newList
await nextTick()             // 等 DOM 更新完成
scrollToBottom()             // ✅ 此时 DOM 已更新

实际代码示例

// 发送消息后滚动到底部
function scrollToBottom() {
  nextTick(() => {
    if (msgList.value) {
      msgList.value.scrollTop = msgList.value.scrollHeight
    }
  })
}
// 打开面板后聚焦输入框 —— DOM 出来了才能 focus
function openSonar() {
  sonarOpen.value = true
  nextTick(() => searchInputRef.value?.focus())
}

Java 类比

// 类似 Spring 的事务提交后才执行
@Transactional
public void save() { ... }
// 事务提交后:
transactionManager.afterCommit(() -> sendNotification());

数据变了 → 等 Vue 异步更新完 DOM → 再操作 DOM。这就是 nextTick 存在的意义。


13. 递归组件 & 组件循环引用

递归组件

组件模板中调用自己,用于渲染树形数据。

<!-- TreeNode.vue -->
<script setup>
defineProps({ node: Object })
</script>

<template>
  <div>
    <span>{{ node.label }}</span>
    <ul v-if="node.children">
      <li v-for="child in node.children" :key="child.id">
        <TreeNode :node="child" />   ← 调用自己!
      </li>
    </ul>
  </div>
</template>

执行过程(以二级树为例):

第1次:<TreeNode :node="根节点" />
         → 渲染根节点 label
         → 遍历 children,发现2个子节点

第2次:<TreeNode :node="子节点1" />    <TreeNode :node="子节点2" />
         → 渲染子节点1 label               → 渲染子节点2 label
         → children 为空,停止递归          → children 为空,停止递归

关键:必须有终止条件(children 为空),否则无限递归,栈溢出。

扁平列表结构的项目通常不需要递归组件。

组件循环引用

A 组件 import B,B 组件又 import A → 死循环。

// A.vue
import B from './B.vue'   // A 依赖 B

// B.vue
import A from './A.vue'   // B 依赖 A → 💥 循环依赖

解决方案

// 方案 1:异步导入(推荐)
const A = defineAsyncComponent(() => import('./A.vue'))

// 方案 2:动态 import()
const A = () => import('./A.vue')

把其中一个变成异步,打破 import 的循环依赖。


14. Pinia vs Vuex —— 状态管理对比

核心区别

Vuex 4 Pinia
写法 Options API 风格 Composition API 风格(() => {}
mutations ✅ 有(同步修改) ❌ 砍掉了,actions 直接改
TypeScript 需要额外类型声明 原生完美支持
模块 modules: { a, b } 嵌套 每个 Store 独立,自动模块化
使用 $store.state.xxx store.xxx 直接访问

代码对比

// ============ Vuex ============
const store = createStore({
  state: { count: 0 },
  mutations: { INCREMENT(state) { state.count++ } },  // 同步修改
  actions: { incrementAsync({ commit }) { commit('INCREMENT') } }  // 异步
})
store.commit('INCREMENT')

// ============ Pinia ============
export const useCounter = defineStore('counter', () => {
  const count = ref(0)
  function increment() { count.value++ }     // 直接改!
  async function incrementAsync() { await delay(); count.value++ }
  return { count, increment, incrementAsync }
})
counter.increment()

Pinia 砍掉了什么

Vuex:   state → mutation → action
Pinia:  state → action  (mutation 砍掉,action 直接改 state)

Java 类比:Vuex 像 DAO → Service → Controller 严格分层,Pinia 像直接把逻辑写在一个类里,更灵活。


15. 路由模式 & 路由守卫

路由模式

// ========== History 模式 ==========
createWebHistory()
// URL: https://example.com/square
// 优点:URL 干净
// 缺点:需要服务端配置 fallback

// ========== Hash 模式 ==========
createWebHashHistory()
// URL: https://example.com/#/square
// 优点:不需要服务端配置
// 缺点:URL 带 #,不利于 SEO

Nginx 解决 History 模式刷新 404

https://example.com/square  ← 用户刷新这个 URL

没有 try_files(❌):
  浏览器请求 /square → Nginx 找不到文件 → 404

有 try_files(✅):
  浏览器请求 /square → Nginx 找不到 → 回退到 index.html → Vue Router 接管 → 正确渲染
location / {
    try_files $uri $uri/ /index.html;
}

路由守卫

// router/index.js

// 全局前置守卫 —— 登录检查
router.beforeEach((to, from, next) => {
  cancelPendingRequests()                    // 取消旧页面的请求
  const token = localStorage.getItem('token')
  
  if (to.meta.requiresAuth && !token) {
    next('/login')                           // 未登录 → 重定向到登录
  } else {
    next()
  }
})

// 全局后置钩子 —— 设置标题
router.afterEach((to) => {
  document.title = (to.meta.title || '首页') + ' - 应用名'
})

路由 meta 字段

{
  path: '/write',
  component: () => import('../views/letter/WriteView.vue'),
  meta: {
    requiresAuth: true,              // 需要登录
    transition: 'page-slide',        // 页面切换动画
    title: '写信'
  }
}

Java 类比

beforeEach = Spring Interceptor 的 preHandle()
afterEach  = Spring Interceptor 的 postHandle()
meta       = @RequestMapping 上的注解配置

16. CSS 样式隔离 & :deep()

scoped 原理

<style scoped> 让 CSS 只作用于当前组件:

<!-- 编译前:Vue 模板 -->
<div class="my-page">
  <p>hello</p>
</div>

<!-- 编译后:Vue 自动加了唯一属性 -->
<div class="my-page" data-v-abc123>
  <p data-v-abc123>hello</p>
</div>

CSS 也被改写:

/* 原始写法 */
.my-page p { color: red; }

/* 编译后 */
.my-page p[data-v-abc123] { color: red; }

Vue 3 项目中 <style scoped> 是默认做法。

问题:子组件内部元素无法匹配

<div class="my-page" data-v-abc123>
  <el-button type="primary">提交</el-button>
  <!--   Element Plus 内部渲染:            -->
  <!--   <button class="el-button--primary" data-v-xyz999>  ← 子组件的 data-v -->
  <!--     <span>提交</span>                                     -->
  <!--   </button>                                               -->
</div>

.el-button--primary[data-v-abc123] → 匹配不到(元素身上是 data-v-xyz999)。

解决::deep()

:deep(.el-button--primary) { background: var(--accent); }

/* 编译后: */
[data-v-abc123] .el-button--primary { background: var(--accent); }
/* 只要祖先有 data-v-abc123,后代就能匹配到 */

实际使用示例

在项目中,:deep() 主要用于定制 Element Plus 等 UI 库的子组件样式:

/* 定制 el-tabs 的选中态 */
.square-tabs :deep(.el-tabs__item.is-active) { color: var(--accent); }
.square-tabs :deep(.el-tabs__active-bar) { background: var(--accent); }

/* 定制 el-radio 的选中态 */
:deep(.el-radio__input.is-checked .el-radio__inner) { background: var(--accent); }
:deep(.el-button--primary) { background: var(--accent); }

/* 定制 el-form-item 的标签颜色 */
:deep(.el-form-item__label) { color: var(--text-secondary) !important; }
:deep(.el-divider) { border-top-color: var(--border-subtle) !important; }

:deep() 的作用:告诉 Vue 跳过 scoped 属性限制,让选择器能穿透到子组件内部匹配元素。


17. 渲染函数 —— 模板底层的真身

什么是渲染函数

模板和渲染函数是同一件事的两种写法:

<!-- 模板写法 -->
<template>
  <div class="greeting">
    <h1>{{ msg }}</h1>
    <el-button @click="sayHi">点我</el-button>
  </div>
</template>

Vue 编译器把它变成渲染函数(等价写法):

import { h } from 'vue'

function render() {
  return h('div', { class: 'greeting' }, [
    h('h1', {}, msg.value),
    h('el-button', { onClick: sayHi }, '点我')
  ])
}

Java 类比

模板    = MyBatis 注解 @Select("SELECT * FROM users")  ← 声明式,简洁
渲染函数 = 原生 JDBC 写完整连接和查询                     ← 程序式,灵活

什么时候用渲染函数

场景 为什么模板不行
动态生成组件 不知道渲染什么标签,JS 变量当标签名
高度复用的逻辑 根据参数动态拼接完全不同的 DOM 结构
封装底层库 Element Plus 内部就是这么写的
// 例子:根据 level 动态渲染 h1~h6
render() {
  const tag = level === 'admin' ? 'h1' : level === 'vip' ? 'h3' : 'h5'
  return h(tag, {}, title)  // 标签名是变量,模板做不到
}

不是跳过生命周期

模板和渲染函数只是在 "描述 HTML 生成"这一步不同,生命周期照样走:

组件创建 → onBeforeMount → ╔══ 模板编译成渲染函数 ══╗ → 生成 DOM → onMounted
                            ║                      ║
                            ╚══ 直接写渲染函数     ══╝
                                  ↑
                            只有这一步不一样

模板是把 HTML 交给 Vue 编译,渲染函数是直接用 JS 描述 DOM 结构。


18. 虚拟 DOM —— 如何创建和更新 DOM

两步走

第一步:h() 创建虚拟 DOM(JS 对象,不碰浏览器)
h('div', { class: 'box' }, 'hello')

// 返回值就是一个普通 JS 对象:
{
  type: 'div',
  props: { class: 'box' },
  children: 'hello',
  el: null          // 还没挂到真实 DOM
}

Java 类比:new User("张三", 20) — 只是内存里的对象,还没进数据库。

第二步:渲染器把虚拟 DOM 转成真实 DOM
// Vue 内部简化逻辑
function mount(vnode, container) {
  const el = document.createElement(vnode.type)   // <div></div>
  el.className = vnode.props.class                // <div class="box"></div>
  el.textContent = vnode.children                 // <div class="box">hello</div>
  container.appendChild(el)                       // 插入页面
}

更新时的高效之处

数据变化 → h() 生成新虚拟DOM → 跟旧虚拟DOM diff → 只改变化的部分
                                                   ↓
                                        el.className = 'new-box'  // 只改一个属性

真实 DOM 操作很贵,虚拟 DOM 的策略是:先在 JS 里算好差异,再最小化操作真实 DOM。


19. 依赖注入 —— Vue 世界的 @Autowired

从 Java 视角理解

@Service
public class OrderService {
    @Autowired
    private UserMapper userMapper;  // Spring 自动把 UserMapper 塞进来

    public void checkout() {
        userMapper.findById(...);  // 直接用,不用 new
    }
}

依赖注入 = 需要什么,框架自动提供,不用自己 new

Vue 中的三种依赖注入

1. Pinia Store
<script setup>
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()  // 不用 new AuthStore(),不用传参
auth.login(email, password)
</script>
2. provide / inject(父传孙,跳过中间层)
App.vue —provide('theme', theme)→   孙组件
  └── 父组件                        inject('theme')
       └── 孙组件                   ← 不管隔多少层,直接拿到
<!-- 祖先 -->
<script setup>
const theme = ref('dark')
provide('theme', theme)
</script>

<!-- 曾孙(不管隔多少层) -->
<script setup>
const theme = inject('theme')  // 直接拿到
</script>
3. Vue Router(全局注册,到处可用)
const router = useRouter()
router.push('/home')  // 不用 new Router()

Java ↔ Vue 对照

Java Spring Vue
@Autowired UserMapper mapper const auth = useAuthStore()
Spring 容器管理 Bean Pinia/Router 管理实例
ApplicationContext.getBean() inject('key')
单例 Bean Pinia Store 也是单例

总结:Vue 3 核心知识图谱

Vue 3 核心
├── 组织方式
│   └── Composition API —— 按功能聚拢代码(替代 Options API)
├── 响应式系统
│   ├── ref() —— 单值,支持所有类型,需要 .value
│   ├── reactive() —— 对象,不用 .value,适合表单
│   ├── toRef() / toRefs() —— 解构 reactive 不丢响应式
│   └── Proxy —— Vue 3 底层响应式引擎,替代 Object.defineProperty
├── 生命周期
│   ├── onMounted —— 数据获取、定时器、DOM 初始化
│   └── onUnmounted —— 清理定时器、移除监听、销毁资源
├── 代码复用
│   └── Composable 函数 —— 抽离可复用逻辑 + 状态(类似 Java 组合)
├── 组件通信
│   ├── Props —— 父传子(构造函数参数)
│   ├── Emits —— 子传父(回调函数)
│   ├── $attrs / v-bind —— 属性透传
│   └── Slots —— 插槽(模板方法模式)
├── 组件进阶
│   ├── 动态组件 <component :is> —— 变量决定渲染哪个组件
│   ├── 异步组件 () => import() —— 按需加载
│   └── keep-alive —— 缓存组件状态(连接池思想)
├── 状态管理
│   └── Pinia —— 砍掉 mutations,原生 TS 支持,替代 Vuex
├── 路由
│   ├── History vs Hash 模式
│   ├── beforeEach / afterEach 守卫
│   └── Nginx try_files 解决刷新 404
├── 模板增强
│   ├── 事件修饰符 —— .stop .prevent .self .once .enter .esc
│   ├── v-model 修饰符 —— .trim .number .lazy
│   ├── .sync → v-model:propName(Vue 3 替代)
│   └── nextTick —— 等 DOM 异步更新完成
├── 组件边界
│   ├── 递归组件 —— 组件调用自己(处理树形数据)
│   └── 循环引用 —— defineAsyncComponent 打破 import 环
├── 样式
│   └── scoped + :deep() —— CSS 隔离 + 穿透子组件
├── 渲染
│   ├── 模板 —— 声明式,Vue 编译成渲染函数
│   ├── 渲染函数 h() —— 纯 JS 描述 DOM
│   └── 虚拟 DOM —— diff → 最小化更新真实 DOM
└── 依赖注入
    └── Pinia / provide-inject / Router(Java 的 @Autowired)

本文所有概念均可在一个真实的全栈项目(Vue 3 + Spring Boot + Element Plus)中找到实际印证。

更多推荐