👉 个人博客主页 👈
📝 一个努力学习的程序猿
更多文章/专栏推荐:
HTML和CSS
JavaScript
Vue
Vue3
React
TypeScript
个人经历+面经+学习路线【内含免费下载初级前端面试题】
前端面试分享
前端学习+方案分享(VitePress、html2canvas+jspdf、vuedraggable、videojs)
前端踩坑日记(ElementUI)
【一文读懂】对闭包的理解及其衍生问题
【一文读懂】对原型链、事件循环、异步操作(Promise、async/await)、递归栈溢出的理解及其衍生问题
【一文读懂】对 Vue 原理的理解-简易版(如何处理模版和指令、虚拟DOM、双向数据绑定+监听、怎么触发生命周期、nextTick简述)
前言
如果对 Vue3 基础用法不太了解,可以参考之前的文章:
https://blog.youkuaiyun.com/qq_45613931/article/details/109471110
1、Vue 生命周期
1.1 基础概念 Vue2
Vue2 实例从创建到销毁的过程中,经历的生命周期钩子函数,主要分为以下几个阶段:
(1)创建阶段:实例初始化与数据准备:beforeCreate,created
(2)挂载阶段:DOM 渲染与挂载:beforeMount,mounted
(3)更新阶段:数据变化与 DOM 更新:beforeUpdate,updated
(4)销毁阶段:组件卸载与资源释放:beforeDestroy,destroyed
为更好的理解,感兴趣的朋友可以查看上一篇文章。通过它,你将能更理解每个生命周期为什么只能做特定的事情:https://blog.youkuaiyun.com/qq_45613931/article/details/146509791(对 Vue 原理的理解:介绍了虚拟 DOM、各生命周期触发的时机、简述了nextTick的原理及作用)
示例 Vue2:
<template>
<div>
<h1>{{ message }}</h1>
<button @click="updateMessage">更新消息</button>
<child-component ref="child"></child-component>
</div>
</template>
<script>
import ChildComponent from './ChildComponent.vue'
export default {
name: 'LifecycleExample',
components: {
ChildComponent
},
data() {
return {
message: 'Hello Vue!',
timer: null,
data: null
}
},
// 1. 创建阶段
beforeCreate() {
console.log('beforeCreate: 实例初始化之后,数据观测和事件配置之前被调用')
console.log('此时无法访问 data 和 methods:', this.message) // undefined
},
async created() {
console.log('created: 实例创建完成后被调用。此时未挂载到 DOM,无法对 DOM 做操作')
console.log('可以访问 data 和 methods:', this.message) // "Hello Vue!"
// 适合进行异步 API 接口调用
try {
this.data = await this.xxx()
} catch (error) {
console.error(error)
}
},
// 2. 挂载阶段
beforeMount() {
console.log('beforeMount: 模版编译完成,虚拟 DOM 挂载开始之前被调用')
console.log('首次渲染之前最后一次修改数据的机会')
},
mounted() {
console.log('mounted: DOM 挂载完成后被调用,此时可以访问 DOM 元素')
// 适合执行需要 DOM 的操作
console.log(this.$el.querySelector('h1').textContent)
this.$nextTick(() => {
console.log(this.$refs.child.$el)
})
// 设置定时器
this.timer = setInterval(() => {
xxx
}, 1000)
},
// 3. 更新阶段
beforeUpdate() {
console.log('beforeUpdate: 数据更新时调用,发生在虚拟 DOM 重新渲染之前')
console.log('更新之前最后一次访问现有 DOM 的机会')
},
updated() {
console.log('updated: 虚拟 DOM 重新渲染后调用,此时 DOM 已经更新完成')
// 避免在此期间更改数据,否则可能导致无限循环
this.$nextTick(() => {
// 所有更新完成后的操作
})
},
// 4. 销毁阶段
beforeDestroy() {
console.log('beforeDestroy: 实例销毁之前调用,此时依然可以访问实例')
// 适合清理定时器、事件监听器、取消订阅等
if (this.timer) {
clearInterval(this.timer)
this.timer = null
}
},
destroyed() {
console.log('destroyed: 实例销毁后调用')
console.log('所有的事件监听器已被移除,所有的子实例也已经被销毁')
},
methods: {
updateMessage() {
this.message = '消息已更新:' + new Date().toLocaleString()
}
}
}
</script>
在使用过程中,用到最多的就是 created
、mounted
、beforeDestroy
。注意事项:
(1)生命周期钩子必须使用普通函数,不能使用箭头函数。否则内部使用 this 会指向 window 或 undefined,而不是 Vue 实例;
(2)父子组件的生命周期顺序(同步引入):
- 创建和挂载:
父组件beforeCreate
、created
、beforeMount
=> 所有子组件beforeCreate
、created
、beforeMount
=> 所有子组件mounted
=> 父组件mounted
总结:父组件先创建,然后子组件创建;子组件完全挂载后,父组件挂载 - 更新过程:
父组件beforeUpdate
=>所有子组件beforeUpdate
=>所有子组件updated
=> 父组件updated
总结:父组件先触发更新钩子。子组件更新完成后,父组件才完成更新 - 销毁过程:
父组件beforeDestroy
=>所有子组件beforeDestroy
=>所有子组件destroyed
=>父组件destroyed
总结:父组件先触发销毁钩子。子组件完全销毁后,父组件才完成销毁
(3)在父组件的 mounted
里调用子组件的用法时 this.$refs.child.$el
,即使在生命周期层面一定是子组件先 mounted
,父组件再 mounted
。但实际上,父组件触发 mounted
时,子组件的渲染任务可能还在异步队列中。因此建议在需要访问子组件 DOM 的场景下,使用 nextTick 来确保安全访问。
1.2 基础概念 Vue3
Vue3 与 Vue2 生命周期的区别:
// Vue2 生命周期 Vue3 组合式 API
beforeCreate -> setup()
created -> setup()
beforeMount -> onBeforeMount
mounted -> onMounted
beforeUpdate -> onBeforeUpdate
updated -> onUpdated
beforeDestroy -> onBeforeUnmount
destroyed -> onUnmounted
Vue3 示例:
<template>
<div>
<h1>{{ title }}</h1>
<p>Count: {{ count }}</p>
<button @click="increment">增加计数</button>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeMount, onBeforeUpdate, onUpdated, onBeforeUnmount, onUnmounted, nextTick } from 'vue'
// 响应式数据
const title = ref('生命周期示例')
const count = ref(0)
// 方法
const increment = () => {
count.value++
}
// 1. 挂载阶段
onBeforeMount(() => {
console.log('onBeforeMount: 组件挂载前')
// 可以访问响应式数据
console.log('当前计数:', count.value)
})
onMounted(async () => {
console.log('onMounted: 组件挂载完成')
await nextTick()
// 可以访问 DOM
console.log('DOM 元素:', document.querySelector('h1').textContent)
})
// 2. 更新阶段
onBeforeUpdate(() => {
console.log('onBeforeUpdate: 组件更新前')
// 可以访问更新前的 DOM
console.log('更新前的计数:', document.querySelector('p').textContent)
})
onUpdated(() => {
console.log('onUpdated: 组件更新后')
// 可以访问更新后的 DOM
console.log('更新后的计数:', document.querySelector('p').textContent)
})
// 3. 卸载阶段
onBeforeUnmount(() => {
console.log('onBeforeUnmount: 组件卸载前')
// 清理工作:移除事件监听、定时器等
})
onUnmounted(() => {
console.log('onUnmounted: 组件已卸载')
})
</script>
整体用法和 Vue2 一致。需要说明的是:
(1)setup 函数替代了 beforeCreate 和 created,内部不能使用 this。
(2)父子组件生命周期的触发顺序和 Vue2 一致。
-
创建和挂载:
父组件setup
、onBeforeMount
=> 所有子组件setup
、onBeforeMount
=> 所有子组件onMounted
=> 父组件onMounted
总结:父组件先创建,然后子组件创建;子组件完全挂载后,父组件挂载 -
更新过程:
父组件onBeforeUpdate
=>所有子组件onBeforeUpdate
=>所有子组件onUpdated
=>父组件onUpdated
总结:父组件先触发更新钩子。子组件更新完成后,父组件才完成更新 -
销毁过程:
父组件onBeforeUnmount
=>所有子组件onBeforeUnmount
=>所有子组件onUnmounted
=>父组件onUnmounted
总结:父组件先触发销毁钩子。子组件完全销毁后,父组件才完成销毁
2、Vue 组件间通信
2.1 v-bind + props + $emit
v-bind 是最简单的一种通信方式,示例如下:
<!-- 父组件 -->
<template>
<child v-model="test" :title.sync="pageTitle" :msg="message" @custom-event="handleEvent"/>
<!-- .sync 等同于 -->
<!-- <child :title="pageTitle" @update:title="pageTitle = $event"></child> -->
<!-- v-model 等同于 -->
<!-- <child :value="test" @input="test = $event"></child> -->
<!-- v-bind 完整写法 -->
<!-- <child v-bind:msg="message" />-->
</template>
<!-- 子组件 -->
<script>
export default {
props: ['msg', 'title', 'value'],
methods: {
updateValue(newValue) {
this.$emit('input', newValue)
},
updateTitle() {
this.$emit('update:title', newValue)
},
sendToParent() {
this.$emit('custom-event', data)
}
}
}
</script>
说明如下:
① .sync 修饰符本质上是一个语法糖,会被扩展为上例中的形式,因此子组件才会用 this.$emit('update:title', newValue)
。
② v-model 也算是语法糖,会被扩展为上例中的形式,因此子组件才会用 this.$emit('input', newValue)
。
③ .sync 和 v-model 的区别:
特性 | .sync | v-model |
---|---|---|
绑定的 props 名称 | 自定义(如 title) | 固定为 value |
触发的事件名称 | update:<propName> (如 update:title) | 固定为 input |
使用场景 | 适用于需要双向绑定的任意 props | 适用于表单控件的绑定 |
虽然它们使用上可以等价,但最大的区别就在于使用场景,v-model 可以简化双向绑定的操作,特别适合表单控件。如下:
<template>
<input v-model="username" />
<!-- 如果不使用v-model -->
<input :value="username" @input="username = $event.target.value" />
</template>
<script>
export default {
data() {
return {
username: ''
}
}
}
</script>
④ 关于 props 的使用:
(1) 基本用法:最简单的方式是直接定义 props 为一个数组,列出接收的属性名称 props: ['title', 'content']
。缺点明显:这样无法进行类型校验或设置默认值。
(2) 对象语法(指定类型):可以将 props 定义为一个对象,指定每个属性的类型(如果父组件传递的值类型不匹配,Vue 会在开发环境下发出警告)
props: {
title: String, // 必须是字符串
count: Number, // 必须是数字
isActive: Boolean, // 必须是布尔值
items: Array, // 必须是数组
user: Object, // 必须是对象
callback: Function // 必须是函数
}
(3) 设置默认值 + 必填属性:可以通过 default
为 props 设置默认值(如果父组件没有传递对应的 props,子组件会使用默认值);可以通过 required
指定某个 props 是必填的(如果父组件没有传递必填项,Vue 会在开发环境下发出警告)。
数组和对象使用函数进行返回,是为了避免它们在所有组件实例之间共享,导致意外的状态污染。
props: {
title: {
type: String,
default: '默认标题',
required: true
},
count: {
type: Number,
default: 0
},
isActive: {
type: Boolean,
default: false
},
items: {
type: Array,
default: () => []
},
user: {
type: Object,
default: () => ({ name: '默认用户' })
}
}
(4) 自定义类型校验:可以通过 validator 自定义校验规则(如果校验失败,Vue 会在开发环境下发出警告)
props: {
count: {
type: Number,
validator(value) {
// 只接受大于 0 的数字
return value > 0
}
}
}
(5)需要注意的是:如果类型不匹配,Vue 仅会发出警告,但不会阻止代码运行;props 是响应式的,当父组件传递的值发生变化时,子组件会自动更新。
该通信方式仅适用于父子组件:
✅ 优点:
- 简单直观
- 符合单向数据流
❌ 缺点:
- 层级深时需要逐层传递
2.2 $refs / $parent / $children
该用法里,用的比较多的是 $refs。示例:
<!-- 父组件 -->
<template>
<child ref="childComp"/>
</template>
<script>
export default {
mounted() {
this.$refs.childComp.childMethod()
this.$children[0].childMethod()
}
}
</script>
<!-- 子组件 -->
<script>
export default {
methods: {
callParent() {
this.$parent.parentMethod()
}
}
}
</script>
需要说明的是:
(1) $refs
只能访问通过 ref 注册的子组件;
(2) $children
只能访问直接子组件,无法访问嵌套的子孙组件;
(3) 不推荐直接通过 $refs
、$parent
或 $children
修改 data。
该通信方式适用于父子组件:
✅ 优点:
- 访问组件实例内的所有内容
- 特别适用于父组件调用子组件方法的场景
❌ 缺点:
- 破坏数据流向(原本应该是:父组件通过props向子组件传递数据;子组件通过$emit向父组件发送事件。现在变成了双向或多向通信)
- 组件耦合度高(组件之间的依赖关系过于紧密,导致组件无法独立使用或复用 => 组件需要明确知道其他组件的结构和方法,且如果对应方法发生改变,其他组件也可能要配合修改。这种强依赖关系会导致代码的维护成本增加)
- $children 不保证顺序
2.3 provide / inject
示例:
<!-- 顶层组件 -->
<script>
export default {
provide() {
return {
theme: this.theme
}
}
}
</script>
<!-- 子组件 -->
<script>
export default {
inject: ['theme'],
mounted() {
console.log(this.theme)
}
}
</script>
✅ 优点:
- 该通信方式可跨多层级通信
- 适合组件库开发:比如在上述顶层组件提供一些全局配置(如主题、语言、样式等),子组件就可以通过 inject 获取这些配置。这种方式避免了逐层传递 props,使得组件库的使用更加简洁。
❌ 缺点:
- 难以追踪数据来源:在复杂的组件层级中,子组件无法直接知道数据是从哪个父组件提供而来,这可能导致调试困难。(该问题有解决方法,就是在 provide 新增一个字段,用于标注数据来源)
- 数据不是响应式(父组件中 provide 数据发生变化,子组件通过 inject 获取的数据不会自动更新)
接下来主要说下响应式的问题:provide / inject 的设计初衷是为了简化跨层级数据传递,而不是为了实现响应式数据共享。如果想解决这个问题,可以使用 Vue 的响应式对象。
<!-- 父组件 -->
<script>
import Vue from 'vue';
export default {
data() {
return {
theme: Vue.observable({ value: 'light' })
}
},
provide() {
return {
theme: this.theme
}
},
methods: {
changeTheme() {
this.theme.value = 'dark';
}
}
}
</script>
<template>
<button @click="changeTheme">切换主题</button>
<child />
</template>
<!-- 子组件 -->
<script>
export default {
inject: ['theme'],
mounted() {
console.log(this.theme.value); // 初始值为 'light'
}
}
</script>
<template>
<div>当前主题:{{ theme.value }}</div>
</template>
再简单点的,可以直接使用函数,返回父组件的响应式数据:
<!-- 父组件 -->
<script>
export default {
data() {
return {
theme: 'light'
}
},
provide() {
return {
theme: () => this.theme // 使用函数返回响应式数据
}
},
methods: {
changeTheme() {
this.theme = 'dark';
}
}
}
</script>
<template>
<button @click="changeTheme">切换主题</button>
<child />
</template>
<!-- 子组件 -->
<script>
export default {
inject: ['theme'],
computed: {
currentTheme() {
return this.theme(); // 调用函数获取最新值
}
}
}
</script>
<template>
<div>当前主题:{{ currentTheme }}</div>
</template>
如果是 Vue3.x 那可以直接传递 ref / reactive 响应式对象:
const count = ref(100);
provide('count-key', count);
2.4 事件总线 EventBus
使用方法参考文章:https://zhuanlan.zhihu.com/p/72777951/
博主几乎不用该方式,使用它需要注意:
✅ 优点:
- 该通信方式支持任意组件间通信
- 使用简单,代码量少
❌ 缺点:
- 难以追踪数据来源(和 provide 内的说明一样)
- 容易造成内存泄漏,至少需要在 beforeDestroy 时手动销毁监听
- 项目规模大时,事件管理复杂,维护成本高(在小型项目或简单的组件间通信场景下,使用该方式或许是不错的选择)
2.5 Vuex(全局状态管理)
使用方法参考文章:https://blog.youkuaiyun.com/qq_45613931/article/details/105903799
- 组件通过 dispatch 触发 Action。
- Action 通过 commit 调用 Mutation(可用于处理异步逻辑(如接口请求))。
- Mutation 同步修改 state。
- state 的变化会自动更新到组件。
✅ 优点:
- 集中状态管理:解决了组件间状态共享问题
- 模块化:支持将状态分割到多个模块中,方便管理
- 响应式:状态变化会自动更新到组件。
- 调试工具支持:可以通过 Chrome 插件 Vue.js devtools,对 Vuex 进行调试
- 适合大型应用
❌ 缺点:
- 小项目使用成本高,使用 Vuex 增加了代码复杂度。
综上,如果需要集中管理复杂的状态,或多个组件需要频繁共享和更新状态(比如用户信息、系统信息等),使用 Vuex 全局存储是个可以选择的方案。
Vuex 除了可以用上文的 EventBus 替代外,还有其他替代方案,在这里简单提一下,感兴趣的朋友可以了解下:
Pinia:Vuex 的轻量化替代方案,支持更简单的 API 和 TypeScript。官网:https://pinia.web3doc.top/
需要额外说明的是:现在这些全局状态管理的功能,如果要用来存储用户信息,其实不算是最佳方案。如 Vuex,它的数据存储在内存中,页面刷新就会导致内存清空,Vuex 的状态也随之丢失。如果真的要存储这类信息,刷新页面也不丢失的全局通信,可以考虑存储在浏览器缓存中(详见第 2.6 节)。
2.6 LocalStorage / SessionStorage / Cookie
该方法勉强算是全局通信的一种方式,适合简单的持久化状态管理,但是不建议滥用。比如在第 2.5 节提到,存储用户信息。当然实际场景里,不建议明文存储,可以考虑加密或者存储用户 Token(标识) 信息。
随后页面在刷新时,通过在缓存中提取 Token,调用接口拿到用户信息。如果没有 Token 则进行登录;如果有 Token 则确认正确性。
使用示例:
// LocalStorage 示例
function localStorageDemo() {
console.log("=== LocalStorage 示例 ===");
// 存储数据
localStorage.setItem('username', 'Alice');
localStorage.setItem('theme', 'dark');
// 读取数据
const username = localStorage.getItem('username');
const theme = localStorage.getItem('theme');
console.log(`LocalStorage - Username: ${username}, Theme: ${theme}`);
// 删除数据
localStorage.removeItem('username');
console.log("LocalStorage - After removing 'username':", localStorage.getItem('username'));
// 清空所有数据
localStorage.clear();
console.log("LocalStorage - After clearing:", localStorage.getItem('theme'));
}
// SessionStorage 示例
function sessionStorageDemo() {
console.log("=== SessionStorage 示例 ===");
// 存储数据
sessionStorage.setItem('sessionId', '12345');
sessionStorage.setItem('isLoggedIn', 'true');
// 读取数据
const sessionId = sessionStorage.getItem('sessionId');
const isLoggedIn = sessionStorage.getItem('isLoggedIn');
console.log(`SessionStorage - Session ID: ${sessionId}, Is Logged In: ${isLoggedIn}`);
// 删除数据
sessionStorage.removeItem('sessionId');
console.log("SessionStorage - After removing 'sessionId':", sessionStorage.getItem('sessionId'));
// 清空所有数据
sessionStorage.clear();
console.log("SessionStorage - After clearing:", sessionStorage.getItem('isLoggedIn'));
}
// Cookie 示例
function cookieDemo() {
console.log("=== Cookie 示例 ===");
// 设置 Cookie
document.cookie = "username=Alice; path=/";
const date = new Date();
date.setDate(date.getDate() + 7); // 7 天后过期
document.cookie = `theme=dark; expires=${date.toUTCString()}; path=/`;
// 读取所有 Cookie
console.log("Cookies:", document.cookie);
// 解析 Cookie
const getCookie = (name) => {
const cookies = document.cookie.split('; ');
const cookie = cookies.find(c => c.startsWith(`${name}=`));
return cookie ? cookie.split('=')[1] : null;
};
console.log("Cookie - Username:", getCookie('username'));
console.log("Cookie - Theme:", getCookie('theme'));
// 删除 Cookie => 设置过期时间为过去的时间
document.cookie = "username=Alice; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/";
console.log("Cookie - After removing 'username':", getCookie('username'));
}
它们的区别在于:
特性 | localStorage | sessionStorage | Cookie |
---|---|---|---|
生命周期 | 持久化存储,除非手动清除 | 当前标签页有效,关闭标签页后清除 | 可设置过期时间,默认会话级别 |
存储大小 | 约 5MB | 约 5MB | 约 4KB |
作用域 | 所有同源标签页 | 当前同源标签页 | 客户端和服务器共享 |
数据传输 | 不随 HTTP 请求发送 | 不随 HTTP 请求发送 | 每次 HTTP 请求都会自动携带 |
适用场景 | 长期数据(如用户偏好设置、主题等) | 临时数据(如表单状态、页面状态) | 少量数据(如会话标识、用户登录状态) |
额外总结:
(1) 不要在 Cookie 中存储敏感信息(如密码),因为 Cookie 会随请求发送,容易被窃取。如果需要存储敏感信息,建议使用 localStorage 或 sessionStorage,并结合 HTTPS 和加密技术。
(2) localStorage 或 sessionStorage 适合存储较大的数据(如 JSON 对象)。不会随 HTTP 请求发送,适合前端使用。
回到之前的问题,如果要存储用户信息:
(1) 通常前端都不会用来存储用户的详细信息,可以和后端约定 Token标识(方案可为:随机生成 + 确保唯一性。短时间不调用接口则过期 - 重新登录),并存储在 cookie 里,通过这个标识来代表用户,在需要时让后端来解析。
=> 不建议在浏览器上用 localStorage 或 sessionStorage 存储,因为持久化存储 / 会话级别存储 还是容易被 XSS 攻击窃取,且无法自动随请求发送。
(2)因为 cookie 会自动跟随请求发送,所以每次发起请求,后端都可以通过 cookie 来匹配对应用户。如果某页面需要展示用户信息,则用标识去调用接口查询。
(3) 为了 cookie 更安全,还可以配合 HttpOnly、Secure、SameSite 标志使用,防止 JavaScript 访问、XSS 和劫持,防止跨站请求伪造。
2.7 slot插槽
示例:
<template>
<div>
<!-- 使用子组件 -->
<ChildComponent>
<!-- 默认插槽内容 -->
<p>这是传递给默认插槽的内容</p>
<!-- 具名插槽内容 -->
<template v-slot:header>
<h1>这是头部内容</h1>
</template>
<template v-slot:footer>
<p>这是底部内容</p>
</template>
<!-- 作用域插槽内容 -->
<template v-slot:default="slotProps">
<p>作用域插槽 - 用户名: {{ slotProps.user.name }}</p>
<p>作用域插槽 - 年龄: {{ slotProps.user.age }}</p>
</template>
</ChildComponent>
</div>
</template>
<script>
import ChildComponent from './ChildComponent.vue';
export default {
components: {
ChildComponent
}
};
</script>
子组件 ChildComponent.vue:
<template>
<div>
<!-- 默认插槽 -->
<slot></slot>
<!-- 具名插槽 -->
<slot name="header"></slot>
<slot name="footer"></slot>
<!-- 作用域插槽 -->
<slot :user="user"></slot>
</div>
</template>
<script>
export default {
data() {
return {
user: { name: '张三', age: 25 }
};
}
};
</script>
✅ 优点:
- 灵活性高:插槽允许父组件动态地控制子组件的内容,增强了组件的复用性和灵活性
- 内容分发清晰:通过具名插槽,可以明确地定义内容插入的位置,代码结构更清晰
- 支持动态数据:作用域插槽可以将子组件的数据传递给父组件,支持动态渲染
❌ 缺点:
- 代码复杂性增加
- 如果插槽内容过于复杂,可能会对渲染性能产生一定影响
2.8 $attrs / $listeners
$attrs 和 $listeners 是两个特殊的属性,主要用于处理父组件传递给子组件的非绑定属性和事件监听器。示例:
<!-- 父组件 -->
<template>
<child-a :msg="msg" @custom="handleCustom"/>
</template>
<!-- 中间组件 -->
<template>
<child-b v-bind="$attrs" v-on="$listeners"/>
</template>
<!-- 子组件 -->
<script>
export default {
inheritAttrs: false,
created() {
console.log(this.$attrs) // { msg: xxx }
}
}
</script>
其中:
(1) $attrs 是一个对象,包含了父组件传递给子组件的非绑定属性(即没有在 props 中显式定义的属性)。这些属性会自动添加到子组件的根元素上;
(2) $listeners 是一个对象,包含了父组件传递给子组件的事件监听器(即通过 v-on 绑定的事件)。
✅ 优点:
- 可跨多层级通信
- 在封装组件时,让子组件动态地、轻松地接收父组件的属性和事件,减少硬编码
- 无需显式定义所有 props 和事件,代码更简洁
❌ 缺点:
- 使用 $attrs 和 $listeners 时,会让代码的逻辑变得不够清晰
- 因为没有 props 类型定义,所以如果父组件传递了错误的属性或事件,可能会导致子组件的行为异常
- Vue 2.4+ 才支持
2.9 使用场景建议
- 父子组件通信:
• 首选 Props/$emit
• 需要访问组件实例时用 $refs- 兄弟组件通信:
• 小项目用 EventBus
• 大项目用 Vuex- 跨层级通信:
• 组件库场景用 provide/inject
• 业务场景用 Vuex
• 中间层透传用 a t t r s / attrs/ attrs/listeners- 全局状态管理:
• 中大型项目用 Vuex
• 小项目用 EventBus
• 存用户 Token 用 Cookie
3、mixins 混入
在 Vue 中,混入是一种复用组件逻辑的方式。通过定义一个混入对象,可以将其逻辑注入到多个组件中。混入对象可以包含组件的生命周期钩子、数据、方法、计算属性等。示例:
创建一个混入文件 mixins/loggerMixin.js:
export const loggerMixin = {
data() {
return {
loggerMessage: '页面加载完成'
};
},
methods: {
formatDate(date) {
const options = { year: 'numeric', month: '2-digit', day: '2-digit' };
return new Intl.DateTimeFormat('zh-CN', options).format(date);
}
},
created() {
console.log(this.loggerMessage);
}
};
在组件中使用混入 MyComponent.vue:
<template>
<div>
<h1>混入示例</h1>
<p>当前日期:{{ formattedDate }}</p>
</div>
</template>
<script>
import { loggerMixin } from '../mixins/loggerMixin';
export default {
mixins: [loggerMixin],
data() {
return {
currentDate: new Date()
};
},
computed: {
formattedDate() {
return this.formatDate(this.currentDate);
}
}
};
</script>
需要注意,Vue 在初始化组件时,会将组件的选项与混入的选项进行合并(合并的方式在上一篇原理里介绍过,其实就是简单的 Options Merge)。不同的选项类型有不同的合并策略:
- 数据(data):
如果组件和混入都定义了 data,Vue 会合并它们,组件的 data 优先级更高。 - 生命周期钩子:
如果组件和混入都定义了同一个生命周期钩子(如 created),它们会被合并成一个数组,按顺序依次执行。 - 方法、计算属性、侦听器:
如果组件和混入定义了同名的方法或计算属性,组件的定义会覆盖混入的定义。
✅ 优点
- 逻辑复用:可以将通用的逻辑抽离到混入中,减少代码重复。
- 模块化:通过混入可以将功能模块化,便于维护和扩展。
- 灵活性:可以在多个组件中使用同一个混入,灵活注入逻辑。
❌ 缺点
- 命名冲突:如果混入和组件中定义了同名的属性或方法,可能会导致覆盖或意外行为。
- 调试困难:混入的逻辑可能会分散在多个文件中,调试时不容易定位问题。
- 可读性降低:当项目中使用了大量混入时,组件的逻辑来源可能变得不清晰。
不过在 Vue2 中,这属于不得已而为之。在 Vue3 中,推荐 使用组合式 API(Composition API) 来替代混入,提供更清晰的逻辑组织方式。