Vue3核心概念学习笔记
Vue 3 核心概念学习笔记
阅读提示:本文从 Java 开发者视角出发,结合一个真实的全栈项目(Vue 3 + Spring Boot + Element Plus)代码,层层拆解 Vue 3 核心概念。
目录
- Composition API —— 新的组织方式
- ref vs reactive —— 响应式基础
- toRef vs toRefs —— 解构不丢响应式
- Proxy 响应式原理 —— Vue 如何监听对象和数组变化
- Vue 生命周期
- Composable 函数 —— 组合式逻辑复用
- 组件通信 —— Props, Emits, $attrs, v-bind
- 插槽 Slots
- 动态组件 & 异步组件
- keep-alive —— 缓存组件状态
- 事件修饰符 & .sync
- nextTick —— 等待 DOM 更新完成
- 递归组件 & 组件循环引用
- Pinia vs Vuex —— 状态管理对比
- 路由模式 & 路由守卫
- CSS 样式隔离 & :deep()
- 渲染函数 —— 模板底层的真身
- 虚拟 DOM —— 如何创建和更新 DOM
- 依赖注入 —— 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() { ... }
}
}
问题:count 和 add 是一伙的,cart 和 checkout 是一伙的,但它们被分散在不同的区块里。
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
})
默认用
ref,reactive只留给表单这种"一组关联属性"的场景。
为什么 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) 通知父组件更新,.sync 被 v-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)中找到实际印证。
更多推荐


所有评论(0)