从本章开始,我们将介绍Vue中与组件相关的技术内容。由于组件的层次性、封装性和复杂性,原来通过Vue的CDN资源引入的代码编写方式已经不利于项目的开发了,因此我们需要进一步了解Vue工程化的开发方式。
本章内容包括脚手架项目的分析、ESLint与Prettier、组件样式控制、组件通信之props、组件通信之ref与defineExpose、组件通信之emits与defineEmits、组件通信之attrs、组件通信之provide与inject、组件通信之mitt、组件通信之slot、内置组件之Component、内置组件之KeepAlive、内置组件之Teleport、代码封装之自定义directive(指令)、代码封装之自定义hook(钩子)、代码封装之plugin(插件)。
本章将结合实际开发案例进行演示和讲解,以实现快速掌握组件的相关语法。
脚手架项目的分析
本节将脚手架项目的分析分为两个部分,分别是脚手架项目创建与准备工作和脚手架项目的分析步骤与流程。
脚手架项目创建与准备工作
在第1章中提到过,Vue环境除了通过引入Vue库创建,还可以通过脚手架创建。脚手架项目的创建方式可以分为两种,分别是以Webpack为基础的@vue/cli命令行接口方式和利用以vite前端自动化构建工具为基础的 "npm init vue@latest" 命令创建方式。虽然这两种创建方式都可以开发Vue3项目,但是vite是新一代的前端自动化构建工具,相较于Webpack,其编译和打包速度更快速,这种方式也是Vue官方更为推荐的方式。
我们利用 "npm init vue@latest" 命令创建一个名称为 vue3-components 的工程项目,命令如下。
npm init vue@latest vue3-components
这里注意下版本,截止我写这个文章官方最新版本要求的node版本是:{ node: '^20.19.0 || >=22.12.0' }。这里我使用的是 20.19.0 这个版本,否则如果node版本不匹配控制台会有警告。
需要注意的是,除了ESLint与Prettier这两个选项选择Yes以外,其他选项都按默认选择No。
接着会要你选择试验特性(可能有),直接回车跳过。
再就是问你是否创建一个空白(不包含示例代码)的项目,这里我选择是默认的"否",直接回车。
创建完脚手架项目后会出现如下类似的提示信息,我们可以通过提示信息运行测试项目。
正在初始化项目 D:\2025\Vue3\vue3-code\04\vue3-components...
│
└ 项目初始化完成,可执行以下命令:
cd vue3-components
npm install
npm run format
npm run dev
| 可选:使用以下命令在项目目录中初始化 Git:
git init && git add -A && git commit -m "initial commit"
依次执行下上面3个npm命令
在脚手架项目创建完成后查看其文件结构(见下图),会发现项目中包含大量的文件夹与文件。对于初学习脚手架项目的开发人员来说,开发难度大幅度上升,因此掌握项目的分析步骤和流程是更好地操作后续项目的关键。
在VSCode编辑器中打开vue3-components项目,因为在项目创建阶段选择了ESLint与Prettier两个选项,所以要在VSCode编辑器的插件扩展中安装ESLint和Prettier开发插件。此外,还需要安装Vue3的语法支持插件Vue(Official)。ESLint与Prettier的功能与作用将在下节中讲解,这里先做好前置准备工作。相关插件截图如下:
脚手架项目的分析步骤与流程
在完成前置准备工作后,就可以分析脚手架项目了,该项主要分为4个步骤。
(1)查看文档(如果有文档则一定要先查看文档)。
(2)分析项目目录结构。
(3)分析项目文件结构。
(4)自上而下,剥洋葱式地进行代码结构分析。
这4个步骤是有先后顺序的,需要依次执行。利用有序步骤进行项目的合理性分析,可以帮助开发人员在最短时间内了解和把控一个项目,下面针对这4个步骤进行具体分析。
1)查看文档
在项目根据目录中有一个README.md的markdown说明文档,这是项目的总体介绍文档。该文档主要被划分为项目名称、推荐开发工具与插件、自定义配置参考网站、项目依赖安装、支持热更新的开发环境运行命令、生产环境的打包命令,以及用于项目语法检查的ESLint检查命令。
事实上,项目在开发、调试、上线和过程中主要被划分为开发、测试、产品不同的模式,而markdown说明文档中除包含生产环境的打包命令以外,主要包含开发模式前置工作的相关说明。
下面是markdown说明文档中的几类标题。
# vue3-components # 项目名称
## Recommended IDE Setup # 推荐开发工具与插件
## Customize configuration # 自定义配置参考网站
## Project Setup # 项目依赖安装
### Compile and Hot-Reload for Development # 支持热更新的开发环境运行命令
### Compile and Minify for Production # 生产环境的打包命令
### Lint with [ESLint](https://eslint.org/) # 用于项目语法检查的ESLint运行命令
这里可以看到,Vue已经推荐使用Volar插件了,其实就是上面的 vue(Official),官网:
https://marketplace.visualstudio.com/items?itemName=Vue.volar
同时建议禁用Vetur插件,这个应该是Vue2的插件,我没有安装
通常情况下,在解读完markdown说明文档后,其会给开发人员提供一系列十分有用的帮助信息,值得一提的是,在正式开发之前,开发人员需要先对项目进行安装和启动,以确保项目可以正常运行,否则后的开发工作都是无效的。
2)分析项目目录结构
项目顶层目录结构并不复杂,主要包含node_modules、public与src 3个目录。其中,node_modules是项目的依赖文件目录,可以忽略;public目录见名知意,在该目录下通常会放置一些项目的公共资源文件,如项目图标文件等;src是source的缩写,它是项目开发的关键目录,在该目录下通过会放置有关项目核心代码的目录和文件内容。
在src目录下,包含assets的components两个子目录。其中,assets是静态资源目录,主要存放一些样式、图标、图片等静态资源;components是项目开发过程中存在公共组件的目录,当前此目录下还有一个名为icons的子目录,icons目录下存入的是有关图标的通用组件。
值得一提的是,public是公共资源目录,assets是静态资源目录,它们两者有什么差异呢?public目录不会被前端自动化构建工具编译转换,而assets目录中的静态资源文件可以被前端自动化构建工具编译转换。比如当文件小于一定大小时,前端自动化构建工具就有可能将其编译成base64字符串,并在代码中应用,以此来减少请求的数量。
这部分文件目录如下:
node_moudles # 项目的依赖文件目录,可以忽略
public # 公共资源目录
src # 项目源码目录
assets # 项目静态资源目录
compontents # 公共组件目录
icons # 图标相关组件目录
3)分析项目文件结构
对于文件结构的分析需要划分清楚文件的类型,比如项目启动主页面、项目开发配置文件、项目运行配置文件、项目入口文件、项目根组件、项目子组件、项目静态资源文件等。下面将其分为根目录下的文件和指定目录下的文件两类进行分析。
(1)根目录下的文件分析
".eslint.config.js"、".gitignore"、".prettierrc.json"、".gitattributes" 这几个以 "." 开头的文件是项目开发配置的相关文件。其中,".eslint.config.js" 主要实现ESLint语法检查的相关配置,".gitignore" 是git代码管理的忽略配置文件,".prettierrc.json" 是Prettier代码格式化配置文件。".gitattributes" 文件是一个Git版本控制系统的配置文件,用于定义路径的属性,常用于规范化尾符。
这里要注意的是,最新的Vue3脚手架是有变化的:
特别注意:.eslintrc.cjs 文件在新的脚手架中已经替换成了 eslint.config.js。另外,VSCode原生支持.editorconfig中的配置。
"index.html" 是项目启动主页面。项目在启动时会默认打开一个HTML页面,整个中只有这一个页面,它也是项目显示页面的主页面。
"package.json" 是项目描述文件。该文件主要实现项目的介绍、启动及模块依赖的声明等。
"package-lock.json" 是下载依赖时自动生成的依赖包相关信息的文件,程序员不用修改它。
"vite.config.js" 是项目启动打包配置文件。因为项目的创建是基于前端自动化构建工具vite的,所以这一配置文件主要是对vite环境的扩展,以便进一步加强项目的开发测试与打包发布操作。
"jsconfig.json" 这个文件也是新版脚手架出现的,它是现代 JavaScript 项目的重要配置文件,主要用于为 JavaScript 项目提供智能感知、代码导航和重构支持。它是 tsconfig.json的 JavaScript版本,里面我们配置了别名@路径。
以前我们用Vue2也可以在webpack中配置@路径映射,但是为什么还要 "jsconfig.json" 文件呢?
可以这么理解,这个"jsconfig.json"文件是用于辅助IDE的,由开发工具(IDE)实现。而构建工具是在打包过程中,正确处理路径。
(2)指定目录下的文件分析。
public目录下的 "favicon.ico" 是项目图标文件,它的功能是实现浏览器中项目图标的显示。
src目录下的 "main.js" 是项目入口文件,整个项目只有一个入口,也就是单入口,项目的所有操作都将从这一入口文件开始。
src目录下的 "App.vue" 则是项目根组件,整个项目只有一个根组件,而在该根组件下将会嵌套其他的子组件。
src目录下的assets子目录中的 "base.css" 和 "main.css" 存储的是项目基础样式和主样式,而 "logo.svg" 存储的是网页中显示的项目Logo文件内容。
src目录下的components子目录中的所有文件都属于Vue的子组件文件。当前components子目录下包含3个子组件文件,分别为 "HelloWorld.vue"、"TheWelcome.vue"、"WelcomeItem.vue"。而components目录的icons子目录下,还包含社区图标 "IconCommunity.vue"、文档图标 "IconDocumentation.vue"、生态系统图标 "IconEcosystem.vue"、支持 "IconSupport.vue"、工具图标 "IconTooling.vue" 等图标组件文件。
这部分文件目录如下:
project/
├── public/ # 静态资源目录(不经过构建处理)
│ └── favicon.ico # 网站图标
├── src/ # 源代码目录
│ ├── assets/ # 静态资源(会经过构建处理)
│ │ ├── base.css # 基础样式文件
│ │ ├── logo.svg # 项目 Logo
│ │ └── main.css # 主样式文件
│ ├── components/ # Vue 组件目录
│ │ ├── icons/ # 图标组件目录
│ │ │ ├── IconCommunity.vue # 社区图标组件
│ │ │ ├── IconDocumentation.vue # 文档图标组件
│ │ │ ├── IconEcosystem.vue # 生态系统图标组件
│ │ │ ├── IconSupport.vue # 支持图标组件
│ │ │ └── IconTooling.vue # 工具图标组件
│ │ ├── HelloWorld.vue # HelloWorld 示例组件
│ │ ├── TheWelcome.vue # 欢迎主组件
│ │ └── WelcomeItem.vue # 欢迎项子组件
│ ├── App.vue # 应用根组件
│ └── main.js # 应用入口文件
├── .editorconfig # 编辑器配置(统一代码风格)
├── .gitattributes # Git 属性配置(行尾符等)
├── .gitignore # Git 忽略文件配置
├── .prettierrc.json # Prettier 代码格式化配置
├── eslint.config.js # ESLint 代码检查配置
├── index.html # HTML 入口模板
├── jsconfig.json # JavaScript 项目配置(路径映射等)
├── package-lock.json # 依赖锁文件
├── package.json # 项目配置和依赖管理
├── README.md # 项目说明文档
└── vite.config.js # Vite 构建工具配置
4)分析代码结构
代码结构的分析采用 "剥洋葱式" 操作。
(1)先找到第1个开始的主页面 "index.html",然后需要明确以下3点。
网页icon设置是通过link元素使用public目录下的favicon.ico图标文件实现的。
id为 "app" 的div元素是之前链接引入方式中Vue网页元素的挂载DOM对象。
脚本的引入依旧采用script元素,只不过引入的是 "main.js" 文件,并且type类型为 "module"(模块化)。
index.html 文件代码如下。
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<!-- 使用public目录下的 favicon.ico 图标文件进行网页 icon 设置 -->
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
</head>
<body>
<!-- 网页挂载元素 -->
<div id="app"></div>
<!-- 引入 src 目录下的项目入口文件,并且以 module(模块化)的方式进行使用 -->
<script type="module" src="/src/main.js"></script>
</body>
</html>
这里可能对路径的写法会有问题,通过IDE点击并不能过去,这是因为vite有默认的处理。它这种处理中是确保了在浏览器中可以正常访问,类似于做了个映射或者说是代理。
Vite 内部映射规则: /favicon.ico → ./public/favicon.ico
(2)现在从主页面剥离到项目入口文件main.js。main.js文件中使用了ES模块化的导入模块语法 import,并利用 import 从Vue中引入了createApp函数、根组件App及主样式文件main.css。"createApp(App).mount('#app')" 的代码形式与前面章节的代码形式基本没有本质上的改变,代表最终创建的Vue应用对象被挂载到id为app的DOM元素上,只不过现在渲染的内容改成了根组件APP。
main.js文件代码如下。
import './assets/main.css'
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')
(3)现在从入口文件剥离到根组件App.vue。该文件主要包括script脚本、template模板、style样式3个层次结构。
script脚本中还多了一个属性setup,这是一个语法糖,现在在script中可以不再像之前一样编写setup函数了,而是利用import引入子组件HelloWorld和TheWelcome,不需要注册就可以在template模板层中调用组件。
因为template模板层中的标签header和main为并列关系,所以Vue3中的模板并不需要设置单一根节点,而template模板层对应的也是之前代码的 "#app" 中的内容。现在主要关注的是HelloWorld和TheWelcome两个子组件的调用,因此template模板层其实是script脚本中数据和组件内容的显示操作区。
style样式与传统CSS编写和作用是一致的,都是通过样式控制页面的布局。只不过目前style样式中设置了scoped属性,该内容在本节中多做讲解,后面会具体说明。
App.vue文件代码如下。
<script setup>
import HelloWorld from './components/HelloWorld.vue'
import TheWelcome from './components/TheWelcome.vue'
</script>
<template>
<header>
<img alt="Vue logo" class="logo" src="./assets/logo.svg" width="125" height="125" />
<div class="wrapper">
<HelloWorld msg="You did it!" />
</div>
</header>
<main>
<TheWelcome />
</main>
</template>
<style scoped>
header {
line-height: 1.5;
}
.logo {
display: block;
margin: 0 auto 2rem;
}
@media (min-width: 1024px) {
header {
display: flex;
place-items: center;
padding-right: calc(var(--section-gap) / 2);
}
.logo {
margin: 0 2rem 0 0;
}
header .wrapper {
display: flex;
place-items: flex-start;
flex-wrap: wrap;
}
}
</style>
按照 "剥洋葱式" 操作,下面该剥离到HelloWorld.vue及TheWelcome.vue文件。这里剥离的方式与前面大体相同,不多做讲解,可以自行尝试。
现在通过 "npm run dev" 命令运行项目,在浏览器中打开测试地址可以看到如下图的项目运行页面。对应前面的项目整体结构分析,基本可以预测到页面的显示效果和页面的结构。
为了更方便地查看项目各个组件的嵌套关系及结构,以及后续更方便地进行项目调试,我们可以使用第1章安装的Vue开发者调试工具。打开Google Chrome浏览器的调试面板并切换到 "Vue" 选项卡,可以清晰地看到项目中各个组件的嵌套关系及结构,还可以观察组件的相关数据。
ESLint与Prettier
在VSCode编辑器中安装了3个插件,分别是ESLint、Prettier和Vue(Official),本节分两个部分对ESLint和Prettier插件进行讲解。
ESLint语法检查
ESLint是在ECMAScript代码中识别和报告模式匹配的代码工具。语法检查是一种静态分析方式,常用于寻找有问题的模式或者代码,并且不依赖于具体的编码风格。与大多数程序语言不同,JavaScript没有编译程序,开发者无法快速调试代码。ESLint工具可以帮助开发者在编码的过程中发现问题,而不是在代码执行的过程中发现问题。
ESLint的主要特点是自由,它不会规定任何编码风格。由于不同的公司与团队开发代码的风格与习惯都是各不相同的,因此对于规则是无法进行统一的。用户可以自定义ESLint规则,并且其结果配置成warn(警告提示)或者error(错误提示)。值得一提的是,配置的每条规则都是各自独立的,用户可以根据项目情况选择开启与关闭。ESLint还有其他的特点,可以在应用过程中逐步发掘。
回看当前项目,在打开 main.js、App.vue 等程序文件时,默认情况下没有任何的提示和问题。这就说明当前文件中的代码默认符合ESLint语法检查的规则,假如为 "eslint.config.js" 文件中的ESLint添加自定义的校验规则 rules,比如强制约束项目中代码的引号是单引号,如果检测失败,则出现错误提示。当然,这里的错误等级是可以自定义的,也可以将error修改成warn,出现警告提示。
eslint.config.js文件代码如下。注意,这个不再是传统的ESLint的默认配置,是一个基于 ESLint 新配置系统(Flat Config)的 Vue.js 项目配置。
import { defineConfig, globalIgnores } from 'eslint/config'
import globals from 'globals'
import js from '@eslint/js'
import pluginVue from 'eslint-plugin-vue'
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
export default defineConfig([
{
name: 'app/files-to-lint',
files: ['**/*.{js,mjs,jsx,vue}'],
},
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
{
languageOptions: {
globals: {
...globals.browser,
},
},
},
js.configs.recommended,
...pluginVue.configs['flat/essential'],
skipFormatting,
// 约束项目中代码的引号是单引号,如果检测失败,则出现错误提示
{
rules: {
'quotes': ['error', 'single'],
// 对于 Vue 模板中的引号
'vue/html-quotes': ['error', 'single']
},
},
])
注释的部分是我自己添加的规则
此时再打开main.js文件,其中就会出现红色波浪线的错误提示,如下图,因为之前代码中的引号都是双引号,这就是ESLint的语法检查操作。至于ESLint的更多语法检查规则,可以查看ESLint官方网站,这里不多做说明。
官方文档查询
ESLint 核心规则: https://eslint.org/docs/rules/
Vue ESLint 规则: https://eslint.vuejs.org/rules/
现在打开命令行终端,输入在README.md中查看到的语法检查命令 "npm run lint",就会出现一些ESLint语法检查的检测报告,开发人员可以根据报告对指定代码做进一步的修改。
运行后发现竟然自动修复了所有的引号问题,这是因为package.json中默认添加了 --fix 参数。
Prettier代码格式化
在上节中ESLint语法检查的检测报告中,大部分文件都出现了双引号的问题,那么开发人员是否需要对发现的代码逐一进行修改呢?如果这样做,开发效率显然很低,此时可以利用VSCode编辑器的Prettier插件对代码进行格式化,整体流程分为两步。
(1).prettierrc.json配置文件在默认情况下没有具体的配置项,只有一个{}空对象,我们可以在其中添加 singleQuote属性,并设置其值为true来实现格式化。
.prettierrc.json 文件代码如下。
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"printWidth": 100
}
我给出的代码,是最新的Vue脚手架生成的默认配置
具体解释如下:
{
"$schema": "https://json.schemastore.org/prettierrc", // 可选的:提供配置文件的JSON schema,用于编辑器智能提示
"semi": false, // 不加分号:const name = 'John' 而不是 const name = 'John';
"singleQuote": true, // 使用单引号:const name = 'John' 而不是 const name = "John"
"printWidth": 100 // 每行最大100字符,超过会自动换行
}
(2)以main.js文件为例,打开main.js文件并右击,在弹出的快捷菜单中选择 "Format Document With...",选择 "Prettier-Code formatter(默认值)" 命令进行代码格式化。这时候所有双引号都修改为单引号,代码就会符合ESLint语法检查工具的需求。
补充:
① 可以默认将VSCode的格式化快捷键用Prettier格式化,可以问AI如何配置,方法很多,项目级别、工作区、全局都可以。
② 可以利用 npm run format 批量处理所有文件,但要在package.json中配置:prettier --write src/
下面我给下出package.json中script的配置:
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint . --fix",
"format": "prettier --write src/"
},
组件样式控制
本节内容主要包括组件定义与使用、全局样式控制、局部作用域样式控制和深度样式控制。
组件定义与使用
当前我们已经了解了脚手架项目开发与运行环境的整体结构和初始代码层次,并且已经熟悉了Vue组件文件的3个主要组成部分。为了方便后续的学习,以及使代码运行效果更直观,先对初始脚手架项目进行简化。
简化操作步骤如下。
(1)将src目录下的assets子目录及其文件全部删除。
(2)将components目录及其所有子目录和文件全部删除。
(3)修改main.js入口文件。将assets目录下的样式文件引入代码删除,也就是删除 import "./assets/main.css" 代码行,修改后的文件目录如下图。
4)直接重写 App.vue 程序文件代码,分为 script、template和style 3个部分。
① script:利用ES模块化导入的方式引入ref函数,声明响应式数据,并定义increment方法对响应式数据进行修改。
② template:直接渲染count和一个可以调用increment方法的按钮。
③ style:只需要编写页面开发中的CSS样式内容,template模板中的元素会直接被样式控制。
修改后的App.vue文件代码如下。
<script setup>
// 从 Vue 库中引入 ref 函数
import { ref } from 'vue'
// 创建初始value为0的响应式 ref 对象
const count = ref(0)
// 定义增加 count 值的函数
const increment = () => {
count.value++
}
</script>
<template>
<h2>App 组件标题</h2>
<!-- 显示 count -->
<p>count: {{ count }}</p>
<!-- 增加 count 值的按钮 -->
<button @click='increment'>增加</button>
</template>
<style scoped>
p {
background-color: #ccc;
}
</style>
运行项目后,此时点击 "增加" 按钮,count值可以增加1,并显示在页面。
App.vue是项目根组件文件,下面尝试定义子组件文件并嵌套在App.vue中。比如在components目录下定义HelloWorld.vue子组件文件。值得一提的是,Vue组件文件中可以缺少script、template、style中的任意一个部分,程序不会报任何错误。
components/HelloWorld.vue组件代码如下。
<template>
<div>
<h3>HelloWorld组件标题</h3>
<div>内部div内容</div>
</div>
</template>
在App.vue的script中引入components/HelloWorld.vue组件文件,并直接在template中使用HelloWorld组件标签。值得一提的是,之所以能在template中直接使用HelloWorld组件标签,是因为在import引入组件后,Vue会自动注册此组件。
修改后的 App.vue代码如下:
<script setup>
import HelloWorld from './components/HelloWorld.vue'
// 从 Vue 库中引入 ref 函数
import { ref } from 'vue'
// 创建初始value为0的响应式 ref 对象
const count = ref(0)
// 定义增加 count 值的函数
const increment = () => {
count.value++
}
</script>
<template>
<div>
<h2>App组件标题</h2>
<!-- 显示count -->
<p>count: {{ count }}</p>
<!-- 增加count值的按钮 -->
<button @click='increment'>增加</button>
<HelloWorld />
</div>
</template>
<style scoped>
p {
background-color: #ccc;
}
</style>
运行代码,打开Vue开发者调试工具中的Vue调试插件面板,则可以确认App组件下已经嵌套了一个HelloWorld子组件。
注意这个导入的语法:import HelloWorld from './components/HelloWorld.vue'
import 组件名 from 组件路径
全局样式控制
在组件中定义的样式,默认是全局有效的。也就是说,其可以作用于当前组件中的标签、子组件的根标签及外部的标签。下面我们来测试一下,给App组件的style标签添加全局样式,代码如下。
<style>
div {
border: 1px solid #aaa;
margin: 20px;
}
</style>
添加全局样式后,页面效果如下。
从上图可以看出,App组件中的样式既影响了当前组件的div,也影响了子组件的所有div和外部页面中的div。
产生该效果的原因也很简单,因为App组件的style标签中的样式,在打包后就会生成全局样式,没有额外添加其他的限制条件。
通过在浏览器中调试样式也可以看出选择器就是所有div元素。
局部作用域样式控制
刚刚介绍了Vue的style标签中的样式默认是全局的,但是如果我们只想针对当前组件内的标签进行样式控制,而不影响外部和内部子组件中标签的样式,应该如何设置呢?只需要在style标签中添加scoped属性即可,这也就是我偿常说的局部作用样式。
在App组件的style标签中添加scoped属性,不需要为其指定属性值,它的本质是 "scoped="true"" 的简写方式,在项目开发中我们采用的都是简写方式,如下所示。
<style scoped>
只修改了App组件的style标签,内部的其余样式不做任何修改,修改代码后的页面效果和代码结构如下图。
从页面效果可以看出,此时的样式只影响当前组件的div和子组件HelloWorld标签div,而不再影响子组件的子标签div和外部div。由此可以提出局部作用域样式的特点:只作用于当前组件的所有标签和子组件的根标签。
这里注意是子组件的根标签,我们是在HelloWorld组件外包了个div,如果你不包div,HelloWorld组件的div不会受影响。
到这里可能会有疑问,下面是DeepSeek说的样式控制问题。
总结:子组件需要有一个根元素来"承载"父组件的scoped样式,如果没有根元素,就需要使用
:deep()
深度选择器来穿透组件边界。其实还有另外一个问题:Vue3支持多根节点组件,应该如何抉择呢?
这里显示就会影响组件样式的控制,随着后面的学习再看最佳实践吧,目前还是有人采用Vue2的做法包一个div。不过如果用多组件,优先使用 CSS 变量 和 :deep() 选择器解决样式问题。
局部作用域原理并不复杂,其内部主要做了两件事,结合上图中的DOM和样式表也可以看出。
(1)一旦声明style为scoped,当前组件的所有标签和子组件的根标签就都会自动添加名为data-v-xxx的唯一标识属性。
(2)在项目打包运行的页面中,style中的样式选择器的最右侧添加了名为data-v-xxx的属性选择器。这就让局部作用域样式只能作用于带data-v-xxx属性的标签,而此时只有当前组件的标签和子组件的根标签带有此属性,子组件的子标签和外部标签都没有此属性,因此局部作用域样式就只能影响当前组件的标签和子组件的根标签。
深度样式控制
如何能让组件的局部样式(也就是局部作用域样式)影响子组件的子标签呢?这就需要使用Vue提供的深度作用域选择器来实现。代码其实很简单,只需要将需要进行深度选择的标签用 ":deep()" 来包含,它就能匹配并影响子组件的子标签。
:deep()
是 Vue特有的选择器,不是CSS标准伪类!
比如,我们想在App组件的局部作用域样式中,改变HelloWorld子组件的子标签h3的样式,那么可以在App组件中编写如下代码。
<style scoped>
...
...
div h3 {
font-size: 40px;
}
</style>
但运行项目后,开发者会发现标题文字并没有变大,也就是样式没有影响HelloWorld子组件的子标签h3。原因就是局部域样式是不能作用到子组件的子标签的,此时可以使用深度作用域来实现,也就是使用 ":deep()" 来包含 h3,代码如下。
<style scoped>
...
...
div :deep(h3) {
font-size: 40px;
}
</style>
此时样式已经作用到子组件的子标签h3上了,页面效果如下。
深度作用域选择器的原理是什么呢?我们来看一下打包生成的样式就可以知道了,如下所示:
div[data-v-7a7a37b1] h3 {
font-size: 40px;
}
其本质就是将属性选择器移动到了deep声明的左侧,这也就意味着整个属性选择器对目标元素没有了属性的要求,这样就可以成功匹配子组件的子标签了。
组件通信之props
本节开始讲解组件通信的相关内容。作为讲解组件通信的开始,本节先来讲解组件关系,再讲解组件通信的第1种方式props。
组件关系
在前面阐述了父子组件的嵌套情况:在App父组件(根组件)中嵌套了HelloWorld子组件。这就意味着Vue的组件是可以互相嵌套的。那么这样的嵌套是一定存在层次关系的,嵌套组件之间也一定存在沟通和信息传递。
Vue组件之间嵌套的层次和结构将会产生如下几种主要的关系模式。
父与子关系模式
在App组件中嵌套了HelloWorld子组件,那么相对于HelloWorld子组件而言,App就是它的父组件,因此父与子关系模式是从上到下的。
子与父关系模式
将App父组件与HelloWorld组件的关系换一个视角,从HelloWorld子组件的角度来看App父组件,就变成了从下到上的子与父关系模式。
祖与孙关系模式
父下会有子,而子又会包含子,因此父与孩子的孩子之间的关系就变成了祖孙关系,并且除了祖与孙,还会存在祖与曾孙、祖与玄孙等更深层次的关系。值得一提的是,只要超过了父子,孙、曾孙、玄孙都可以简化为祖与孙关系模式。
其他关系(非父子与祖孙)模式
当然父组件可能会包含一个子组件,也可能会包含多个子组件,而子组件中仍旧可能会存在孙、曾孙、玄孙等更深层级的嵌套子组件,那么从横向视角来看,子组件2与子组件1、子组件2与孙组件1、孙组件2与曾孙组件1等关系就成了更为复杂的跨越父与子、祖与孙单向顺序的非父子与祖孙关系模式。
父与子通信之props
下面来介绍组件之间的通信方式,本节先来介绍父与子的通信交互props,后面会陆续讲解其余组件的通信方式。
父与子可以通过props属性传递的方式来实现交互,其中分为简单数组接收、简单对象接收和复杂对象接收3种模式,下面分别进行介绍。
简单数组接收模式
从父组件的设置与传递属性到子组件的接收与使用属性的整个过程可以划分成两个部分。
① 在父组件中,当HelloWorld子组件被调用时设置属性并传递。比如给HelloWorld子组件设置msg与count两个,并且给msg属性传递值 "你好,Prajnalab!",count属性传递值 "0"。
此时App.vue文件代码如下。
<script setup>
import HelloWorld from './components/HelloWorld.vue'
</script>
<template>
<HelloWorld msg='你好,Prajnalab!' count='0'/>
</template>
<style scoped>
</style>
② 在子组件中需要接收父组件传递的属性msg和count。利用defineProps即可实现属性的接收,因为有多个属性,所以可以利用数组接收属性。此时可以直接在子组件中使用接收的属性值,比如通过插值表达式渲染和累加count属性的值,实现代码如下。
<template>
<div>
<p>{{ msg }}</p>
<p>count: {{ count }}</p>
<button @click='count++'>count++</button>
</div>
</template>
<script setup>
defineProps(['msg', 'count'])
</script>
这个代码ESLint都报错了,会说不期望修改prop,点击你也加不了,因为count是readonly的!
其实看到这里应该还有一个问题,那就是defineProps函数没有导入,怎么可以直接使用?
补充:
defineProps
不需要 import 是因为它是一个编译器宏(Compiler Macro),而不是一个真正的 JavaScript 函数。
Vue 3 的 <script setup>
中提供了这些编译器宏:
简单理解就是build过来会被替换成原生的JavaScript代码!
在上面的代码中,count属性值是直接在模板中进行累加的,并不是通过创建increase函数进行累加的。如果想在子组件中创建一个increase函数实现累加,那么能不能像之前一样直接编写呢?下面来尝试实现一下。
直接定义increase函数,并通过 "count.value++" 实现对count值的累加。修改后的 components/HelloWorld.vue 文件代码如下。
<template>
<div>
<p>{{ msg }}</p>
<p>count: {{ count }}</p>
<button @click='count++'>count++</button>
<button @click='increase'>increase</button>
</div>
</template>
<script setup>
defineProps(['msg', 'count'])
const increase = () => {
count.value++
}
</script>
运行代码后,会报错 "count is not defined" 的错误提示,如下图:
开发者可能会想,如果将defineProps接收的属性通过变量对象来接收,并利用对象属性的方式获取是不是可以呢?下面依旧通过代码来验证。
只修改components/HelloWorld.vue文件中的script脚本部分,代码如下。
<script setup>
const props = defineProps(['msg', 'count'])
const increase = () => {
props.count++
}
</script>
上面的代码利用 props 进行接收,但运行后页面依旧出现警告提示:
实际上ESLint都提示报错了。新版的脚手架创建的项目
eslint-plugin-vue
这个插件起作用!
那么如何在script脚本中操作defineProps接收的数据呢?答案是:先将数据中转,然后获取。比如在子组件中声明一个ref响应式数据update,将其初始值设置为 definedProps 接收的count属性值,在increase函数中通过 "update.value++" 进行累加处理,并在模板中进行渲染,实现代码如下。
<template>
<div>
<p>{{ msg }}</p>
<p>count: {{ update }}</p>
<button @click='update++'>count++</button>
<button @click='increase'>increase</button>
</div>
</template>
<script setup>
import { ref } from 'vue'
const props = defineProps(['msg', 'count'])
const update = ref(props.count)
const increase = () => {
update.value++
}
</script>
此时不会报任何错误提示和警告提示,效果成功实现。
简单对象接收模式
如果只是用简单数组接收属性,则我们是无法判别属性的类型的。在简单数组接收模式中,我们无法判别传递的count属性和msg属性的类型。显然,msg是字符串类型的,那么这是否意味着count也是字符串类型的呢?可是根据我们想得到的count值,其显然是数值类型的数据,而程序在运行的时候却没有给出任何的警告提示或者报错。
现在将子组件中的defineProps属性接收方式修改为简单对象模式,并查看效果。
将components/HelloWorld.vue文件中的script脚本部分修改为下方代码。
<template>
<div>
<p>{{ msg }}</p>
<p>count: {{ update }}</p>
<button @click='update++'>count++</button>
<button @click='increase'>increase</button>
</div>
</template>
<script setup>
import { ref } from 'vue'
const props = defineProps({
msg: String,
count: Number
})
const update = ref(props.count)
const increase = () => {
update.value++
}
</script>
此时我们将接收的类型限制为数值类型,由于没有修改App.vue中的代码默认传递的是字符串类型的数据,所以子组件在接收的时候对属性进行了类型的检测,并在控制台中给出警告提示,如下图所示。虽然运行结果没有发生变化,但类型不对应还是存在一定的隐患的。这里可以在父组件中通过v-bind指令将count属性值绑定为数值类型的值来解决该问题。
要解决这个问题很简单,直接在App.vue组件中,使用v-bind:
<HelloWorld msg='你好,Prajnalab!' :count='8'/>
复杂对象接收模式
简单数组接收模式仅能实现属性接收但无法进行类型约束,而简单对象接收模式不仅可以接收属性还能约束类型,但这样的功能是平路已经足够了呢?试想父组件在调用子组件并没有设置属性时,遗漏了某个属性的设置,比如设置为 "<HelloWorld msg='你好,Parjnalab!'>",遗漏了count属性,而在子组件中又接收和使用了count属性,此时运行程序,count属性会得到 "NaN" 的结果,但控制台不会报错。显然,运行结果与预期结果是不一致的。
此时就可以利用defineProps复杂对象接收模式进行优化。将count属性设置为对象类型,其属性值主要包括type(数据类型)、default(默认值)、required(是否必须传递)、validator(自定义校验规则)4个部分。
将上面的代码进行修改,修改后的components/HelloWorld.vue文件代码如下。
<template>
<div>
<p>{{ msg }}</p>
<p>count: {{ update }}</p>
<button @click='update++'>count++</button>
<button @click='increase'>increase</button>
</div>
</template>
<script setup>
import { ref } from 'vue'
const props = defineProps({
msg: String,
count: {
type: Number, // 约束类型
defulat: 7, // 设置默认值
required: true, // 是否必须传递
validator: val => val > 0 && val < 3, // 自定义校验规则
},
})
const update = ref(props.count)
const increase = () => {
update.value++
}
</script>
虽然父组件在调用HelloWorld子组件时没有设置和传递count属性,但由于在子组件中利用复杂对象接收模式设置了count属性的类型、默认值及是否必须传递等属性,所以页面中显示的结果不会出错,能够与预期结果达成一致。只不过控制台中还会出现 "Missing required prop: "count"" 的警告提示,这是因为count属性接收时,限制其required为 "true"。
如果在HelloWorld子组件中定义接收count属性,将其默认值设置为7,运行程序,则控制台中会出现无效属性的警告提示,如下图。因为上面的代码中同样设置了自定义校验规则,要求count属性值大于0且小于3,而当前 "7" 显然不在此范围内。
这里可以补充一个测试,我把必须传递改成false,默认值还是7,此时校验规则还是大于0且小于3,此时校验规则仍然会起作用。
组件通信之ref与defineExpose
前面提到过可以利用ref函数先给元素设置一个标识,然后利用该标识找到元素。那么是否可以利用这种方式实现父子组件之间的通信呢?答案是:可以的。我们可以先在父组件中通过ref函数找到子组件,然后控制子组件的数据和方法。下面简单演示一下这个过程。
components/HelloWorld.vue文件代码如下。
<template>
<div>
<p>count: {{ count }}</p>
<button @click='increase'>increase</button>
</div>
</template>
<script setup>
import { ref } from 'vue'
const count = ref(0)
const increase = () => {
count.value++
}
</script>
App.vue文件代码如下。
<script setup>
import { ref } from 'vue'
import HelloWorld from './components/HelloWorld.vue'
const refCount = ref(null)
console.log(refCount);
console.log(refCount.value);
</script>
<template>
<HelloWorld ref='refCount' />
</template>
运行代码后可以发现,当打印refCount对象时,输出结果中没有任何子组件数据和方法内容。当打印的是refCount.value时,则可以确认输出的是null(空内容),控制台输出结果如下图。
那父组件怎么才能获取子组件的数据和方法呢?其实上面的操作中是缺少了一步。要想获取子组件的数据和方法,还需要通过defineExpose暴露数据和方法让子组件许可和确认。
接下来在父组件中就可以查看子组件中暴露的数据与方法。在父组件中编写两个函数increaseChildCount与invokeChildIncrease,用于修改调用的子组件中的数据和方法,如果结果可以正常执行,就说明父组件与子组件之间成功实现了交互通信。
修改后的components/HelloWorld.vue文件代码如下。
<template>
<div>
<p>count: {{ count }}</p>
<button @click='increase'>increase</button>
</div>
</template>
<script setup>
import { ref } from 'vue'
const count = ref(0)
const increase = () => {
count.value++
}
// 允许其他组件使用当前组件的count与increase
defineExpose({
count,
increase
})
</script>
修改后的App.vue文件代码如下。
<script setup>
import { ref } from 'vue'
import HelloWorld from './components/HelloWorld.vue'
const refCount = ref(null)
console.log(refCount);
console.log(refCount.value);
// 利用 ref 找到子组件控制子组件的count响应式数据
const increaseChildCount = () => {
refCount.value.count++
}
// 利用 ref 找到子组件并控制子组件的 increase 函数的调用
const invokeChildInCrease = () => {
refCount.value.increase()
}
</script>
<template>
<HelloWorld ref='refCount' />
<button @click='increaseChildCount'>increaseChildCount</button>
<button @click='invokeChildInCrease'>invokeChildInCrease</button>
</template>
在此时的项目中,父组件可以成功修改子组件的数据和调用子组件的方法,这就说明父组件与子组件之间成功实现了交互通信。
组件通信之emits与defineEmits
前面两节介绍了父向子通信,其实除了父向子通信,Vue中还存在逆向通信,即子向父通信。子向父通信可以利用和自定义事件emits来实现,下面我们直接通过案例来讲解子向父通信的应用。
在子组件文件HelloWorld.vue中放置了一个按钮,通过点击按钮祥瑞父组件调用方法的操作。具体做法是,给按钮绑定点击事件click,利用$emit方法进行从下向上的事件发射。$emit方法可以接收多个参数,第1个参数是自定义事件的名称,剩余参数则是想要传递的。
将自定义事件命名为increaseParentCount,参数设置为数值2。此时components/HelloWorld.vue文件代码如下。
<template>
<div>
<button @click='$emit("increaseParentCount", 2)'>直接触发自定义事件</button>
</div>
</template>
在父组件中可以通过v-on指令监听事件。在父组件对子组件的调用上监听自定义事件,通过回调进行后续的处理。这里可以先设置一个回调函数increase,在回调函数中通过设置参数来接收子组件中自定义事件传递的参数,然后对参数进行累加即可。
修改后的App.vue文件代码如下。
<script setup>
import { ref } from 'vue'
import HelloWorld from './components/HelloWorld.vue'
const count = ref(0)
const increase = step => {
count.value += step
}
</script>
<template>
<HelloWorld @increaseParentCount='increase' />
<p>count: {{ count }}</p>
</template>
上面的代码在回调函数中设置了一个形参setp来接收子组件传递的参数,在回调函数中实现了响应式数据count的累加操作。
上面讲解的子向父通信是利用$emit方法将自定义事件直接编写在模板上。那有没有其他方式触发自定义事件呢?其实我们还可以在script脚本部分,利用defineEmits函数来生成一个用于分发指定自定义事件的事件触发函数。defineEmits函数的参数是一个字符串数组,每个字符串都是一个自定义事件的名称,后面我们就可以调用生成的事件触发函数来触发特定事件名的事件。
下面我们用emits来接收事件触 发函数,利用它来触发自定义事件并传递参数数据。对于父组件来说,不管子组件的自定义事件是通过$emit触发的,还是通过script脚本部分的emits触发的,结果都是一样的,父组件的内容不需要做任何修改。
修改后的components/HelloWorld.vue文件代码如下。
<template>
<div>
<button @click='$emit("increaseParentCount", 2)'>直接触发自定义事件</button>
<button @click='increase'>通过脚本触发自定义事件</button>
</div>
</template>
<script setup>
// defineEmits 函数的参数是一个字符串数组,每个字符串是一个自定义事件的名称
const emits = defineEmits(['increaseParentCount'])
const increase = () => {
emits('increaseParentCount', 3)
}
</script>
最佳实践:在
<script setup>
中,优先使用defineEmits
返回的函数emits
在<script>
部分触发事件,尤其是在逻辑不简单时。这能带来更好的代码组织、可维护性和开发体验。感觉这就是集中定义事件,再按需发射。
到这里我们可以稍微总结下,父与子通信、子与父通信的决策:
简单决策树:
父组件想告诉子组件一些信息? -> 使用
props
。子组件想告诉父组件发生了什么事? -> 使用
emit
。父组件想让子组件执行一个特定动作(如聚焦、重置)? -> 使用
ref
+defineExpose
(在子组件中暴露该方法)。
组件通信之attrs
父子组件之间的通信除了props、ref、defineExpose、emits等方式,还有attrs方式。attrs可以实现批量非props属性、原生事件和自定义事件的传递。
在App父组件中引入并使用子组件HelloWorld,并定义props属性msg、非props属性value和style、原生事件change、自定义事件customClick。
此时App.vue文件代码如下。
<script setup>
import HelloWorld from './components/HelloWorld.vue'
const changeHandler = () => {
console.log('changeHandler', event)
}
const customClickHandler = (msg) => {
console.log('customClickHandler: ', msg)
}
</script>
<template>
<!--
msg:定义 props 属性
value、style:非 props 属性
change:原生事件
customClick:自定义事件
-->
<HelloWorld
msg='你好,Prajnalab!'
value='Parjnalab欢迎你'
style='color: red'
@change='changeHandler'
@customClick='customClickHandler'
/>
</template>
从父组件属性设置与传递上来看,并不能区分出props属性和非props属性,此时就需要在子组件中进行区分。那么在子组件中如何实现属性和事件的获取和使用呢?
根据上节所学,在HelloWorld子组件中可以通过defineProps接收props属性。那么应该如何获取非props属性和事件监听回调函数呢?Vue为用户提供了useAttrs方法用于获取attrs对象,该对象包含所有没有在defineProps中定义的属性和事件监听回调函数。此时就解决了之前的问题,不过useAttrs方法与defineProps有些不同,该方法需要引入并调用后才能使用。此时就可以通过v-bind指令将$attrs属性绑定在输入框中进行验证了,虽然自定义事件被绑定在输入框中,但是并不会起作用,因此可以尝试指定一个button按钮,为其绑定点击事件,利用$emit触发customClick事件,父组件中已经绑定了对象的事件监听回调函数,结果能够正常输出。
修改后的components/HelloWorld.vue文件代码如下。
<template>
<div>
<input v-bind='$attrs'>
<p>{{ props.msg }}</p>
<button @click='$emit("customClick", "自定义事件")'>
自定义事件的触发
</button>
</div>
</template>
<script setup>
import { useAttrs } from 'vue';
const props = defineProps(['msg'])
const attrs = useAttrs();
console.log('props', props);
console.log('attrs', attrs);
</script>
此时运行项目可以发现,子组件中的props属性只有msg,这样attrs对象中就包含了value和style属性,以及onChange和onCustomClick事件监听回调函数。在输入框中输入内容并失去焦点后,会触发原生绑定的change事件,而change事件中可以输出event原生事件对象。点击 "自定义事件的触发" 按钮会触发$emit设置的customClick事件对象,并且输出自定义事件传递的参数,控制台输出结果如下图。
需要注意的是,页面的样式除输入框的value值变成了红色以外,p标签的字体也变成了红色。这是因为在默认情况下,子组件的根元素会自动添加父组件传递过程的非props属性。另外由于事件冒泡,你可以看到onChange调用了两次,一次是input触的,一次是HelloWorld子组件外层的div(根)上触发的。
可以理解为,原生的属性与事件都会直接传递到子组件的根元素。
如果不想让子组件的根元素受非props属性的影响,则可以在HelloWorld子组件中添加新的script脚本,将inheritAttrs属性值设置为false,刷新页面,子组件根元素的样式控制会被清除。
修改后的components/HelloWorld.vue文件代码如下。
<template>
<div>
<input v-bind='$attrs'>
<p>{{ props.msg }}</p>
<button @click='$emit("customClick", "自定义事件")'>
自定义事件的触发
</button>
</div>
</template>
<script setup>
import { useAttrs } from 'vue';
const props = defineProps(['msg'])
const attrs = useAttrs();
console.log('props', props);
console.log('attrs', attrs);
</script>
<script>
export default {
// 标识不从父组件中继承非props属性
inheritAttrs: false,
}
</script>
此时,我们调试下页面就可以看出效果了。
组件通信之provide与inject
前面我们讨论的都是父向子和子向父的通信方式,本节开始介绍祖孙组件的通信方式。现在有祖组件App、子组件Child和孙组件Grandson,假如将组件App中的一些数据传递给孙组件Grandson,要怎么实现呢?这就产生了跨层级的祖孙传递模式,其实祖孙之间的传递并不受限制,可以跨越任意多层,只需先在祖组件中利用provide方法进行数据内容的提供,然后在需要接收的孙组件中利用inject函数注入数据即可。
下面通过代码来验证,依次编写祖组件、子组件和孙组件代码。
(1)创建祖组件App.vue文件。在App组件中引入provide、ref方法,分别定义ref响应式数据count、非响应式数据msg和函数increase,并在模板中进行渲染、显示。
App.vue文件代码如下。
<script setup>
import { provide, ref } from 'vue'
import Child from './components/Child.vue'
const count = ref(0)
provide('count', count) // count 是 ref 响应式数据
const msg = '你好,Prajnalab!'
provide('msg', msg) // msg 是非响应式数据
const increase = () => {
count.value++
}
provide('increase', increase) // increase 是函数
</script>
<template>
<Child />
<p>msg: {{ msg }}</p>
<p>count: {{ count }}</p>
</template>
注意的是,为了后面代码的实现,这里还引入了子组件Child。另外,Vue3最新的脚手架创建的ESLint规则不允许以单个单词命名组件,这个我在eslint.config.js中加了配置,可以问API
// 添加这个新的配置对象来覆盖 multi-word-component-names 规则
{
name: 'vue/multi-word-component-names-override',
// 你可以选择性地指定 files,但通常应用到所有 .vue 文件
// files: ['*.vue'], // 可选:明确指定作用范围
rules: {
// 覆盖 eslint-plugin-vue 中的 multi-word-component-names 规则
'vue/multi-word-component-names': ['error', {
// 在这里添加你希望忽略的组件名称
ignores: ['App', 'Child'] // 添加 'Child' 到忽略列表
// 你也可以根据需要添加其他例外,如 'Home', 'Index'
}]
}
}
(2)创建子组件Child.vue文件。子组件Child比较简单,只需引入并使用孙组件Grandson即可。
components/Child.vue文件代码如下。
<template>
<div>
<h1>Child</h1>
<Grandson />
</div>
</template>
<script setup>
import Grandson from './Grandson.vue'
</script>
(3)创建孙组件Grandson.vue文件。在引入inject函数后,利用它注入组件提供的数据,并尝试在模板中进行渲染。
components/Grandson.vue文件代码如下。
<template>
<h2>Grandson</h2>
<!-- 非响应式数据也可以渲染 -->
<p>msg: {{ msg }}</p>
<!-- 响应式数据可以进行正常的渲染 -->
<p>count: {{ count }}</p>
<button @click='increaseCount'>increaseCount</button>
<button @click='increase'>increase</button>
</template>
<script setup>
import { inject } from 'vue'
const msg = inject('msg') // 非响应式数据
const count = inject('count') // 响应式数据
const increase = inject('increase') // 函数
const increaseCount = () => {
count.value++ // 可以实现响应式修改与渲染
}
</script>
在孙组件中可以动态显示注入的响应式数据count,也可以更新count数据,并且孙组件和祖组件都会同步更新。另外,我们也可以调用注入.的函数increase来更新祖组件的数据。
这一块强烈建议阅读官网,官网详细阐述了一些其他情况,比如多个上层组件有一样的key,会就近原则等情况:https://cn.vuejs.org/guide/components/provide-inject.html
组件通信之mitt
我们可以利用第三方类库mitt实现非父子之间的通信。本节主要介绍mitt的相关知识。
执行下方命令,在当前项目中安装依赖。
npm install mitt -save
安装依赖后,需要在当前项目中引入并创建进行事件处理的emitter对象。在src目录下创建services目录,并在其中创建emitter.js文件,在该文件中只需要引入mitt类库,得到其暴露的mitt函数,执行mitt函数产生emitter对象,并对其进行默认暴露。
src/services/emitter.js文件代码如下。
import mitt from 'mitt'
export default mitt();
emitter对象主要提供了 on、emit、off、all 4个方法,可以利用on方法监听自定义事件,利用emit方法分发自定义事件,利用off方法取消特定自定义事件,以及利用all方法取消所有事件。可以在GitHub上自行查看其相关使用有意思,这里不多做讲解,直接来看案例。
假如在父组件App中有两个子组件Child1和Child2,我们不想通过父组件App来实现两个子组件之间的通信,而是希望直接使两个子组件之间产生通信。这其实就是非父子之间通信的一种方式,按照这两个子组件的关系,其属于兄弟组件。
按照上面需求的描述,App组件只是对两个子组件进行引入,不存在属性传递和事件监听,那么App.vue文件代码就很简单了,具体如下。
<script setup>
import Child1 from './components/Child1.vue'
import Child2 from './components/Child2.vue'
</script>
<template>
<Child1 />
<Child2 />
</template>
假如我们要实现需求:Child2向Child1发送一个数值,Child1收到这个数值后,累加显示到原有的count数值上。
在Child2中我们就可以利用emitter对象的emit方法来分发事件,并指定一个特定的要增加数量。
components/Child2.vue文件代码如下。
<template>
<div>
<h1>Child2</h1>
<button @click='increase'>increase</button>
</div>
</template>
<script setup>
import emitter from '../services/emitter' // 引入事件总线
// 在组件内部分发事件
const increase = () => {
emitter.emit('increaseCount', 2)
}
</script>
在按钮的点击回调中,通过emitter对象的emit方法分发了自定义事件increaseCount,并指定了要传递的数量为2。
而Child1要利用emitter对象绑定自定义事件,指定接收数据的回调函数,在回调函数中更新要显示的count数值,并且需要在合适的时机取消绑定的事件。
components/Child1.vue文件代码如下。
<template>
<div>
<h1>Child1</h1>
<p>count: {{ count }}</p>
</div>
</template>
<script setup>
import { ref, onBeforeUnmount } from 'vue'
import emitter from '../services/emitter' // 引入事件总线
const count = ref(0)
// 绑定事件的回调函数,接收事件参数并进行使用
const increaseCountCallBack = (num) => {
count.value += num
}
// 在组件实例挂载完成时,订阅事件
// onMounted(() => {
// emitter.on('increaseCount', increaseCountCallBack)
// })
// 这个最外层就是setup函数,所以可以直接在这里订阅事件,因为这个订阅也不涉及DOM,个人觉得没有必要在onMounted里面订阅
emitter.on('increaseCount', increaseCountCallBack)
// 在组件实例完全销毁前,取消订阅事件
onBeforeUnmount(() => {
emitter.off('increaseCount', increaseCountCallBack)
})
</script>
上面的代码在onMounted挂载完成的生命周期钩子函数中绑定了自定义事件,事件名为子组件Child2中的increaseCount,事件的回调函数接收的参数num是分发事件时传递的数据,在回调函数中将其累加到count中。值得一提的是,我们通常会在组件实例完全销毁前(onBeforeUnmount生命周期钩子函数),利用emitter对象的off方法对绑定的事件进行取消。
此时可以利用Child2中的按钮来修改Child1中的count值,轻松实现非父子组件之间的通信。但这种通信方式在组件数量越来越多、需求越来越多的情况下,代码会被分散到不同的组件中,组件的通信关系会变得越来越凌乱,甚至形成蜘蛛网结构,管理起来并不方便。对于这种情况,我们考虑通过通过将在后面学习的Vuex和Pinia状态管理器解决。
组件通信之slot
插槽专门用于父组件向子组件传递标签结构,而不是单纯的数据。在使用时,一般会在子组件中通过slot来声明占位,在父组件中,通过子组件的标签体向子组件传递标签结构。插槽主要分为默认插槽、具名插槽、作用域插槽3种类型,同时,我们需要掌握插槽默认值。下面对相关内容进行讲解。
默认插槽
前面介绍的父组件与子组件之间的通信方式props主要进行的是属性设置和传递,现在如果想要设置一些HTML标签结构和属性,那么利用props属性传递的模式会有些麻烦。比如父组件想向子组件传递title、content等众多属性,其中每个属性都包含HTML标签元素(如<Quote title="<h1>标题</h1>" content="<p>内容</p>"..../>),试想子组件要如何接收并控制这些属性呢?
利用插槽的方式就可以轻松实现此类需求。在components目录下新建一个子组件文件Quote.vue,在App父组件中引入并使用子组件Quote,并在Quote组件标签的主体区域中直接设置h1和p标签内容,这样就实现了一个slot插槽内容的父组件向子组件传递的操作。
App.vue文件代码如下。
<script setup>
import Quote from './components/Quote.vue';
</script>
<template>
<quote>
<h1>标题</h1>
<p>内容</p>
</quote>
</template>
现在只需在子组件文件中Quote.vue中,利用slot插槽标签对父组件传递过来的插槽内容进行接收、渲染、显示即可。运行程序后,发现子组件中的slot组件实现的是插槽内容的占位和渲染、显示。
components/Quote.vue文件代码如下。
<template>
<h3>Quote组件</h3>
<slot></slot>
</template>
值得一提的是,slot有一个默认值为default的name属性,它会对没有具体命名或指定为default的插槽内容进行占位和渲染,我们将它称为默认插槽。
默认插槽的完整写法如下。
<slot name="default"></slot>
默认插槽内容的完整写法如下,App.vue内容:
<script setup>
import Quote from './components/Quote.vue';
</script>
<template>
<quote>
<template v-slot:default>
<h1>标题</h1>
<p>内容</p>
</template>
</quote>
</template>
你可以将Quote组件中的name改掉,那样就不会渲染了。另外注意,组件标签名可以全小写。
默认插槽渲染后的标签结构如下图:
具名插槽
在刚才的代码中,App父组件中的Quote子组件的插槽内容设置了h1和p两个标签。其实我们可以在子组件中为其设置具体的name名称,从而接收指定元素,还可以在子组件中利用具名插槽控制内容的显示。
将components/Queto.vue文件的代码改成下方代码。
<template>
<!-- content内容放置到了title的前面 -->
<slot name='content'></slot>
<slot name='title'></slot>
</template>
那么在父组件中,我们就要可以利用template块级代码和v-slot属性传递指定名称的插槽内容。
修改后的App.vue文件代码如下。
<script setup>
import Quote from './components/Quote.vue';
</script>
<template>
<quote>
<template v-slot:title>
<h1>标题</h1>
</template>
<template v-slot:content>
<p>内容</p>
</template>
</quote>
</template>
在上方代码中,"v-slot:title" 与 "v-slot:content" 已经明确对应的插槽名称是title与content,与子组件中的slot的name属性是一一对应关系。现在刷新页面将会看到,content内容在title标题的上方,因为子组件的具名插槽不仅控制了接收的元素,还明确了显示的位置 。页面显示效果如下图。
这里注意的是,template标签在页面渲染时并不会渲染成具体的元素,它只是用于包裹渲染元素的内容而已。
插槽默认值
在子组件中直接设置slot,并在对应slot中设置name属性对应父组件中的插槽内容。在刚刚代码的基础上新增一个副标题内容(name为subTitle)的设置,如果父组件没有传递subTitle,那么将直接渲染刚刚设置的插槽默认值,也就是这里所写的p标签 "副标题" 内容。
此时 components/Quote.vue 文件代码如下。
<template>
<slot name='content'></slot>
<slot name='title'></slot>
<!-- 其实 slot 有一个默认值为 default 的 name 属性 -->
<slot></slot>
<!-- 如果父组件没有进行 v-slot:subTitle 副标题的内容传递,则会䞣显示插槽默认值 "副标题" -->
<slot name='subTitle'><p>副标题内容</p></slot>
</template>
但如果在父组件中进行了具名插槽subTitle的内容设置与传递,比如下方代码中的h2标签,那么最终页面显示的是父组件传递的h2标签内容。
此时App.vue文件代码如下。
<script setup>
import Quote from './components/Quote.vue';
</script>
<template>
<quote>
<template v-slot:title>
<h1>标题</h1>
</template>
<template v-slot:content>
<p>内容</p>
</template>
<!-- 除具名插槽以外,其他都是默认插槽,包括hr与span -->
<hr />
<span>默认插槽</span>
<!-- 如果 v-slot:subTitle 进行了传递,则显示传递的值 -->
<template v-slot:subTitle>
<h2>传递的副标题</h2>
</template>
</quote>
</template>
运行代码后,通过观察页面可以发现,此时页面上显示的副标题内容是 "传递的副标题"。你可以把下面这个代码在App.vue文件中删除:
<!-- 如果 v-slot:subTitle 进行了传递,则显示传递的值 -->
<template v-slot:subTitle>
<h2>传递的副标题</h2>
</template>
此时页面中会显示 "副标题内容"。
可以总结出:插槽默认值在没有传递时是默认值,在传递时则是传递的值。
作用域插槽
在父组件中写的插槽内容需要使用子组件中的数据时,就会应用到作用域插槽。这种插槽类型通常是为了实现在父组件中进行更多页面化控制的需求。
现在有一个父组件App,在这个组件中有一个列表数据list,这个列表数据list可以进行属性传递,将数据传递至父组件App嵌套的子组件List中。
App.vue文件代码如下。
<script setup>
import { ref } from 'vue';
import List from './components/List.vue';
const list = ref(['JavaScript', 'Vue', 'React', 'Angular'])
</script>
<template>
<List :list='list'></List>
</template>
子组件List将接收父组件App传递的数组数据,并利用slot插槽标签进行循环。在循环过程中还可以对item、index等元素对象和下标内容进行slot标签组件的绑定。
components/List.vue文件代码如下。
<script setup>
defineProps(['list'])
</script>
<template>
<ul>
<li v-for='(item, index) in list' :key='item'>
<slot :item='item' :index='index'></slot>
</li>
</ul>
</template>
实际上,List组件中的slot插槽标签获取了item与index,并进行了插槽内容的回传。简单地说,就是将数据利用插槽形式回传到父组件中,而父组件则可以利用插槽调试方式直接调用子组件回传内容。
作用域插槽获取插槽内容的方法与具名插槽一样,依旧是利用template与v-slot结合获取,但v-slot获取的是子组件回传的一个对象,我们可以利用对象解构的方式直接获取item和index属性。既然获取了对象,那么在父组件中可以操控任意布局形式,比如通过取余实现类似 "斑马线" 的显示。
此时App.vue文件代码如下:
<script setup>
import { ref } from 'vue';
import List from './components/List.vue';
const list = ref(['JavaScript', 'Vue', 'React', 'Angular'])
</script>
<template>
<List :list='list'>
<!-- 不解构写法 -->
<!-- <template v-slot='scope'>
<b v-if='scope.index % 2'>{{scope.item}}</b>
<i v-else>{{scope.item}}</i>
</template> -->
<!-- 解构写法 -->
<template v-slot='{ item, index }'>
<b v-if='index % 2'>{{ item }}</b>
<i v-else>{{ item }}</i>
</template>
</List>
</template>
这里可能会有个疑问,为什么父组件传到子组件,子组件又传回父组件,有必要这么麻烦么?
下面是AI的总结:
数据可以来自父组件或子组件,取决于设计需求。
父组件提供数据 是更推荐的方式,符合组件化开发的最佳实践。
子组件定义数据 适用于独立性要求高的场景,但需注意与父组件的解耦。
作用域插槽的核心目的是让父组件控制渲染逻辑,而子组件仅负责数据传递。
可能你现在还没有遇到实际场景,但是我们目前可以直观感受到的是,由父组件来决定数据要渲染成什么样式,而数据是在子组件中处理的。具体的语法可以理解成,子组件在slot标签里传数据,父组件中使用v-slot直接接收一个对象,这个对象包含子组件中所有传递的数据。
内置组件之Component
对于组件来说,除结构和通信组件以外,内置组件也非常重要。接下来介绍Vue3提供的一些常用内置组件。
本节主要介绍内置组件Component,该组件提供了动态组件加载功能,它可以在内置组件Component占位点上将自定义组件进行指定目标的渲染。比如页面中常见的Tabs选项卡效果就可以利用动态组件加载功能轻松实现。
简单理解,我可以传一个组件给你,就能渲染出这个组件,component只是一个占位。
现在有一个需求:在父组件中通过点击按钮切换目标子组件。下面通过代码进行演示。
在项目目录components下分别新建Comp1.vue、Comp2.vue、Comp3.vue文件,其内容结果也非常简单,只有一个字符串。
components Comp1.vue文件代码如下。
<template>
<h1>Comp1</h1>
</template>
components Comp2.vue文件代码如下。
<template>
<h1>Comp2</h1>
</template>
components Comp3.vue文件代码如下。
<template>
<h1>Comp3</h1>
</template>
在父组件的script脚本部分,声明一个ref类型的响应式数据tab,将其初始值指定为Comp1,点击按钮后动态更新为指定的组件。值得一提的是,这里需要利用markRaw函数对comp进行声明,将tab的值设置为非代理对象,其目的是不对组件进行递归响应式数据代理,以便增强性能(markRaw函数的作用可以参考第3章)。
在template中,只需要在<component></component>上通过的is属性指定为要动态显示的组件标签名,为不同按钮绑定点击监听来调用changeTab函数,就能实现Comp1、Comp2、Comp3这3个组件的动态切换。
App.vue文件代码如下。
<template>
<button @click='changeTab(Comp1)'>changeComp1</button>
<button @click='changeTab(Comp2)'>changeComp1</button>
<button @click='changeTab(Comp3)'>changeComp3</button>
<component :is='tab'></component>
</template>
<script setup>
import { markRaw, shallowRef } from 'vue'
import Comp1 from './components/Comp1.vue'
import Comp2 from './components/Comp2.vue'
import Comp3 from './components/Comp3.vue'
// 设置需要切换的组件,初始为 Comp1,使用 markRaw 函数,不对组件进行递归响应式数据代理
const tab = shallowRef(markRaw(Comp1))
// 定义切换组件函数,将组件本身当成参数传递
function changeTab(comp) {
tab.value = comp
}
// 默认显示 Comp1 组件
changeTab(Comp1)
</script>
这个代码如果用ref,控制会警告,所以我改成了shallowRef,它只对最外层的对象是响应式的;而使用markRaw是一种更好的实践。
功能效果图:
内置组件之KeepAlive
Vue为动态加载的组件提供了一种性能优化方案----缓存组件,其利用内置组件KeepAlive实现。组件在加载时会经历初始、挂载、更新、销毁生命周期,对于动态组件加载来说,频繁地切换组件会不断地重复组件的初始、挂载、销毁生命周期,这意味着程序需要不断地读取和释放内存,因此将会极大地影响内容开销,从而影响项目的性。
如果可以将这样的组件暂存于内存中不做释放处理,使用的时候再从内存中取出来,就可以省去组件创建和销毁等耗费内存的操作。Vue提供了KeepAlive内置组件来实现这一目标。
实现缓存组件很简单,只需要利用KeepAlive将Componet动态组件包裹即可,但是如何才能确认缓存组件的运行呢?这需要通过子组件的生命周期才能够看出。下面通过代码过演示这个过程。
<template>
<button @click='changeTab(Comp1)'>changeComp1</button>
<button @click='changeTab(Comp2)'>changeComp1</button>
<button @click='changeTab(Comp3)'>changeComp3</button>
<keep-alive>
<component :is='tab'></component>
</keep-alive>
</template>
<script setup>
import { markRaw, shallowRef } from 'vue'
import Comp1 from './components/Comp1.vue'
import Comp2 from './components/Comp2.vue'
import Comp3 from './components/Comp3.vue'
// 设置需要切换的组件,初始为 Comp1,使用 markRaw 函数,不对组件进行递归响应式数据代理
const tab = shallowRef(markRaw(Comp1))
// 定义切换组件函数,将组件本身当成参数传递
function changeTab(comp) {
tab.value = comp
}
// 默认显示 Comp1 组件
changeTab(Comp1)
</script>
这个就是上节的代码将component嵌套在keep-alive组件中。
为Comp1、Comp2、Comp3这3个子组件添加生命周期钩子函数,并输出对应的字符串进行测试。这里以 Comp1.vue文件代码为例。
<template>
<h1>Comp1</h1>
</template>
<script setup>
import {
onBeforeMount,
onMounted,
onBeforeUpdate,
onUpdated,
onBeforeUnmount,
onUnmounted,
} from 'vue'
onBeforeMount(() => {
console.log('Comp1 onBeforeMount');
})
onMounted(() => {
console.log('Comp1 onMounted');
})
onBeforeUpdate(() => {
console.log('Comp1 onBeforeUpdate');
})
onUpdated(() => {
console.log('Comp1 onUpdated');
})
onBeforeUnmount(() => {
console.log('Comp1 onBeforeUnmount');
})
onUnmounted(() => {
console.log('Comp1 onUnmounted');
})
</script>
运行代码会发现在Comp1切换至Comp2时,两个组件的生命周期都只是执行了挂载的onBeforeMount、onMounted生命周期钩子函数。现在已经离开了Comp1,而Comp1中的onBeforeUnmount、onUnmounted生命周期钩子函数并没有触发,说明当前的组件已经被缓存了,如下图。
引入两个新的生命周期钩子函数onActivated、onDeactivated来确认缓存组件的运行结果。添加生命周期钩子函数后,初始Comp1触发的生命周期钩子函数变为3个,多了一个用于激活组件状态的生命周期钩子函数onActivated,如下图。
添加后的代码如下所示:
<template>
<h1>Comp1</h1>
</template>
<script setup>
import {
onBeforeMount,
onMounted,
onBeforeUpdate,
onUpdated,
onBeforeUnmount,
onUnmounted,
onActivated,
onDeactivated
} from 'vue'
onBeforeMount(() => {
console.log('Comp1 onBeforeMount');
})
onMounted(() => {
console.log('Comp1 onMounted');
})
onBeforeUpdate(() => {
console.log('Comp1 onBeforeUpdate');
})
onUpdated(() => {
console.log('Comp1 onUpdated');
})
onBeforeUnmount(() => {
console.log('Comp1 onBeforeUnmount');
})
onUnmounted(() => {
console.log('Comp1 onUnmounted');
})
onActivated(() => {
console.log('Comp1 onActivated');
})
onDeactivated(() => {
console.log('Comp1 onDeactivated');
})
</script>
在Com1切换到Comp2时,也没有触发onBeforeUnmount和onUnmounted生命周期钩子,而是触发了组件失活的onDeactivate生命周期钩子,如下图所示,说明Comp1已经被暂存于内存中,并没有从内存中销毁。Comp2切换到Comp3的过程同理。
到目前为止,利用KeepAlive内置组件可以对所有动态加载的组件进行缓存。如果缓存组件的内容很多,那么也会给内存带来一定的压力,因为内存的存储空间是有限的,所以控制目标组件的缓存就显得尤为重要。因此,KeepAlive内置组件提供了include和exclude两个属性来解决这个困扰。include中文翻译为 "包含",用于确认哪些组件需要缓存;exclude中文翻译为 "排除",用于排除不需要进行暂存处理的组件。这两个属性都可以设置为字符串、正则表达式与数组。
虽然KeepAlive内置组件有include和exclude属性,但是包含谁、排除谁,尚未可知。当前有Comp1、Comp2、Comp3组件,如何让include和exclude属性明确其包含与排除的目标呢?此时就需要配合组件的name名称来实现。
值得一提的是,因为在Vue3中<script setup>的脚本部分应用了组合式API,所以并没有提供给组件设置name名称的功能。如果想要给组件设置名称,那么可以在组件中添加一个script脚本,利用选项式API为其添加name属性,比如,可以为Comp1、Comp2、Comp3组件添加以下代码,以Comp1组件为例。
<script>
export default {
name: 'Comp1',
};
</script>
在<script setup>结束后添加即可,一个组件是可以有多对script脚本的。这个做法是Vue3.2及以下的做法。
在Vue3.3+可以使用下面的方式:
使用
defineOptions
宏(最推荐)
<script setup>
defineOptions({
name: 'MyComponentName'
})
// 组件的其他逻辑...
</script>
使用
defineComponent
包装
<script setup>
import { defineComponent } from 'vue'
defineComponent({
name: 'MyComponentName'
})
// 组件的其他逻辑...
</script>
此时可以通过Vue开发者工具确认当前项目的运行状态,在调试页面中可以明确KeepAlive内置组件的include和exclude属性值都是undefined(未定义),如下图所示。
下面尝试给KeepAlive内置组件设置include属性,属性值可以设置为字符串、正则表达式和数组3种模式。在通常情况下,我们会选择最容易理解的数组模式。下面展示3种模式的书写代码。
(1)字符串模式
<!-- 字符串模式 -->
<keep-alive include='Comp1,Comp2'>
<component :is='tab'></component>
</keep-alive>
include直接绑定常量,注意组件名之间不要有空格!
(2)正则表达式
<keep-alive :include='/Comp1|Comp2/'>
<component :is='tab'></component>
</keep-alive>
这个注意下include要绑定变量!
(3)数组模式
<keep-alive :include='["Comp1", "Comp2"]'>
<component :is='tab'></component>
</keep-alive>
此时,通过控制台验证Comp3组件是否被缓存,如下图。
可以通过Vue开发者调试工具来验证Comp3组件是否被缓存。我们可以观察到Vue开发者调试工具中的Comp3组件没有inactive的状态显示,因此Comp3组件没有缓存。
exclude属性与include属性的使用方法相同,这里不多做赘述。
KeepAlive内置组件还有一个属性max,用于限制缓存组件的最大数量。比如想要将内存中缓存组件的最大数量设置为5,就可以将max的值写为5。
需要注意的是,max的算法并不是将最先加入缓存的组件进行清除,而是遵循LRU(Least Recently Used,最近最少使用)算法。举个例子,有 "[Comp1、Comp2、Comp3、Comp4、Comp5]" 组件已经被缓存,现在想加入Comp6组件,但因为max的值设置为5,所以就需要根据LRU算法进行计算,比如Comp1组件使用了3次,Comp2使用了2次,Comp3使用了5次、Comp4使用了1次、Comp5使用了8次,那么会将只使用了1次的组件Comp4清除,并将Comp6组件加入缓存中。
动态内置组件Transition与TransitionGroup,配合动态加载组件Component与缓存组件KeepAlive可以实现动画效果。
以Transition组件为例,在项目根目录的index.html文件中添加下方代码,引入 animate.css 动画类库。
<link href="https://cdn.bootcdn.net/ajax/libs/animate.css/4.1.1/animate.css" rel="stylesheet">
在App组件中,调用动画内置组件Transition实现动画效果的展现。
App.vue文件代码如下:
<template>
<button @click='changeTab(Comp1)'>changeComp1</button>
<button @click='changeTab(Comp2)'>changeComp2</button>
<button @click='changeTab(Comp3)'>changeComp3</button>
<transition
enter-active-class='animate__animated animate__tada'
leave-active-class='animate__animated animate__bounceOutRight'
>
<keep-alive :include='["Comp1", "Comp2"]'>
<component :is='tab'></component>
</keep-alive>
</transition>
</template>
<script setup>
import { markRaw, shallowRef } from 'vue'
import Comp1 from './components/Comp1.vue'
import Comp2 from './components/Comp2.vue'
import Comp3 from './components/Comp3.vue'
// 设置需要切换的组件,初始为 Comp1,使用 markRaw 函数,不对组件进行递归响应式数据代理
const tab = shallowRef(markRaw(Comp1))
// 定义切换组件函数,将组件本身当成参数传递
function changeTab(comp) {
tab.value = comp
}
// 默认显示 Comp1 组件
changeTab(Comp1)
</script>
内置组件之Teleport
Teleport中文翻译为 "瞬间移动",顾名思义,Teleport内置组件可以将组件中的一部分元素移动到指定的目标元素上。
根据本章开头对页面组织的分析可以知道,Vue项目中的所有组件都被包含在App的这一根组件下,而在入口文件 main.js 中引入根组件App后会将其挂载于 "#app" 网页元素上,这就意味着Vue项目中的所有组件元素都被包含在 "#app" 网页元素中。假如现在有一个需求:
在网页的顶层显示一个模态框。如果可以实现模态框元素直接包含在body标签中,让该元素和 "#app" 网页元素同级并列,那么控制结果会变得简单,此时就可以使用具有移动功能的内置组件Teleport来实现。
补充:
框架内: 指在
#app
元素内部。优点是通信方便,缺点是样式和层级可能受父组件影响,不适合全局性UI如模态框、通知、弹窗等。框架外: 指与
#app
元素并列,通常是<body>
的直接子元素。优点是拥有独立的样式和绝对可靠的层级控制,是放置模态框等全局组件的最佳实践。需要通过<Teleport>
(Vue 3)或portal-vue
(Vue 2)等技术实现。
下面来实现这个需求,在App组件中引入一个具有弹出框的子组件GlobalAlert,App.vue文件代码如下:
<template>
<GlobalAlert />
</template>
<script setup>
import GlobalAlert from './components/GlobalAlert.vue';
</script>
<style>
#app {
text-align: center;
background-color: #2c3e50;
padding-top: 60px;
height: 100vh;
}
</style>
子组件GlobalAlert上需要有两个功能按钮和一个模态框。按钮用于切换功能,需要显示在页面上,因此会被包含在 "#app" 刚刚元素中。而带有动画效果的模态框则与 "#app" 同级并列,这样更容易控制其出现在页面的顶层。可以通过在瞬间移动组件 Teleport 中将to属性值设置为 "body" 来实现效果。
此时 components/GlobalAlert.vue文件代码如下。
<script setup>
import { ref } from 'vue';
const isOpen = ref(false) // 控制是否显示模态框
const isTeleport = ref(false) // 控制是否禁用 Teleport 组件
</script>
<template>
<!-- 按钮部分将被嵌套于 "#app" 网页元素中 -->
<button @click='isOpen = !isOpen'>
{{ isOpen ? '关闭' : '打开' }}模态框
</button>
<button @click='isTeleport = !isTeleport'>
{{ isTeleport ? '禁用' : '非禁用' }}移动功能
</button>
<!-- 利用 Teleport 内置组件将其包含的元素移动到 body 标签内 -->
<Teleport to='body' :disabled='isTeleport'>
<!-- 动画效果增强视觉 -->
<Transition mode='in-out'>
<!-- 利用 v-if 指定控制模态框的显示与隐藏 -->
<div v-if='isOpen' class='modal'>
<p>元素被移动到body标签内,与 "#app" 网页元素的div元素是并列关系</p>
<!-- 按钮点击事件控制模态框的隐藏与显示 -->
<button class='close' type='button' @click='isOpen = false'>
关闭
</button>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
button {
padding: 0.5em;
border: 0;
border-radius: 1em;
box-shadow: inset 0 -1px 4px grey, 1px 1px 4px grey;
cursor: pointer;
transition: box-shadow 0.15s ease-in-out;
}
button:hover {
box-shadow: inset -1px 2px grey, 1px 1px 2px grey;
}
.modal {
position: fixed;
isolation: isolate;
z-index: 1;
top: 2rem;
left: 2rem;
width: 20rem;
border: 1px solid grey;
padding: 0.5rem;
background-color: grey;
box-shadow: 2px 2px 4px gray;
backdrop-filter: blur(4px);
color: #f4f4f4;
}
.close {
display: block;
margin-left: auto;
}
/* 在没有设置name时可以设置统一的动画样式类名,以v-xxx方式命名 */
.v-enter-active,
.v-leave-active {
transition: all 0.25s ease-in-out;
}
.v-enter-from,
.v-leave-to {
opacity: 0;
transform: translateX(-10vw);
}
</style>
关于模态框的样式:
给模态框设置
isolation: isolate;
是一个非常巧妙且高性能的CSS技巧,它的主要目的是:通过强制创建新的堆叠上下文,来确保模态框的层级(z-index)不受其父组件层叠上下文的影响,从而能够稳定地覆盖在整个应用界面之上,从根本上避免z-index层级冲突的问题。它比疯狂地设置一个巨大的z-index: 999999
要可靠和优雅得多。这是一个写在了 CSS 里,但却实现了“层级隔离”功能的原生属性。
backdrop-filter: blur(4px);
是一个强大的CSS属性,它通过模糊元素背后的内容来创建时尚的“毛玻璃”视觉效果,常用于增强用户界面的层次感和美观度。使用时需注意浏览器兼容性和性能开销。
运行代码,点击 "打开模态框" 按钮,会发现在页面顶层显示了一个模态框。利用浏览器开发者调试工具中的Elements审查元素,可以确认 "#app" 网页元素和模态框是并列关系,模态框的div处于页面顶层body下,如下图所示。
还有一个 "禁用移动功能" 按钮,它会控制disable属性为true,从而禁止使用Teleport,点击禁用后页面中模态框的位置发生了变化,它被包含在 "#app" 网页元素下。
上面这个代码有很多地方可以学习,建议多看。
代码封装之自定义directive(指令)
Vue代码的封装方式有很多种,主要有component(组件)、filter(过滤器)、directive(指令)、mixin(混入)、hook(钩子)、plugin(插件)、library(类库)、framework(框架)等。在Vue 3中,选项式API中的Mixin作为一种代码复用方式已被组合式函数(Composition Functions,也称为Hook)所取代;同时,模板过滤器(Filter)API也已被移除,推荐使用普通方法或计算属性来替代。本节我们主要介绍directive(指令)的相关知识。
这个代码封装的过滤器貌似不是指Vue2中模板中使用过滤器,它应该特指的是一些数据操作的封装。
官网中说了minin 主要是兼容,新的Vue3代码不建议使用。
自定义指令主要包括 created、beforeMount、mounted、beforeUpdate、updated、beforeUnmount、unmounted钩子函数。可以发现的是,这些钩子函数除没有beforeCreate外,其余的和Vue的生命周期钩子函数一样。除保持了名称的一致性外,在参数方面也基本相同,主要包括目标操作元素el、数据绑定对象binding、虚拟DOM对象vnode及旧虚拟机DOM对象preVnode等。
钩子函数的参数主要使用目标元素el和数据绑定对象binding。binding中主要有参数arg、修饰符modifiers、数据值value。
现在想要利用自定义指令实现一个 "霓虹灯闪烁" 功能。首先在main.js文件中进行app.directive全局指定注册,或者在Vue3中利用directive进行局部自定义指令注册。需要注意的是,参数arg和自定义指令间需要使用冒号分隔,而修饰符modifiers则需要使用点号连接,至于数据值value则需要使用等号设置。然后在main.js文件中使用app.directive方法,该方法的第1个参数为指令名,可以指定为highlight,第2个参数为配置对象,而配置对象中则包括上面提及的自定义指令的钩子函数。在实际开发项目中,我们用得最多的是mounted钩子函数,它在组件挂载后执行,主要使用el与binding这两个参数。
此时 main.js 文件代码如下。
import { createApp } from 'vue'
import App from './App.vue'
const app = createApp(App)
app.directive('highlight', {
// 自定义指令的mounted钩子函数
mounted(el, binding) {
// 利用binding.modifiers获取delayed修饰符值进行判断
let delay = 0;
// 如果有此修饰符,则将delay值设置为3000
if (binding.modifiers.delayed) {
delay = 3000;
}
// 利用binding.modifiers获取blink修饰符值进行判断
// 如果有此修饰符,则进行闪烁效果
if (binding.modifiers.blink) {
// 利用binding.value获取主颜色以及次颜色
let mainColor = binding.value.mainColor;
let secondColor = binding.value.secondColor; // 设置当前颜色先为主色
let currentColor = mainColor; // 设置延时定时器
setTimeout(() => {
// 设置闪烁定时器
setInterval(() => {
// 三元运算确认当前颜色
currentColor == secondColor
? (currentColor = mainColor)
: (currentColor = secondColor);
if (binding.arg === 'background') {
// 背景
el.style.backgroundColor = currentColor;
} else {
// 字体
el.style.color = currentColor;
}
}, binding.value.delay); // 闪烁间隔时间
}, delay); // 延迟3秒
} else {
// 没有blink修饰符,则直接设置颜色
setTimeout(() => {
// 利用binding.arg获取background参数值进行判断
if (binding.arg === 'background') {
// 背景颜色设置
el.style.backgroundColor = binding.value.mainColor;
} else {
// 字体颜色设置
el.style.color = binding.value.mainColor;
}
}, delay); // 延迟3秒
}
},
});
app.mount('#app')
注册好全局指令后,就可以调用了。在项目的任何一个组件中只需要通过 v-highlight 就可以调用当前定义的 "霓虹灯闪烁" 效果指令。这里我们实现的效果是,在延迟3秒后,产生红绿背景颜色的切换效果,每次切换时间是500毫秒。
App.vue文件代码如下。
<template>
<!-- background为钩子函数中binding里的arg参数 -->
<!-- delayed与blink为钩子函数中binding里的modifiers修饰符内容 -->
<!-- {mainColor:'red',secondColor:'green',delay:500}为钩子函数中binding里的value值内容 -->
<p
v-highlight:background.delayed.blink='{
mainColor: "red",
secondColor: "green",
delay: 500,
}'
>
自定义指令的调用
</p>
</template>
arg
不能有多个,但虽然不能有多个arg
,但你通过arg
+modifiers
+value
的组合,完全可以实现非常复杂和灵活的指令功能。你提供的例子就是一个完美的实践。
自定义指令平时工作中我几乎没有用过,这个代码是一个很好的示例,值得反复学习理解。
代码封装之自定义hook(钩子)
Vue3推荐利用Vue的组合式API函数进行代码封装,这种封装方式统称为自定义hook。
假设现在想要实现:切换Comp1、Comp2两个组件,这两个组件的实现效果基本相同,它们都可以对msg、count数据进行处理,以及设置count的increase递增功能、count的computed计算属性和对count数据进行watch监听,唯一的区别就是Comp1中count的初始值是0,而Comp2中的初始值是2。
Comp1.vue文件代码如下,这是Vue2的编码方式,现在在这个基础上修改代码,实现自定义hook封装。
<template>
<div>
<p>{{ msg }}</p>
<p>count: {{ count }}</p>
<p>double: {{ double }}</p>
<button @click='increase'>increase</button>
</div>
</template>
<script>
export default {
data() {
return {
msg: '你好,Prajnalab!',
// Comp1中count的初始值为0,Comp2中count的初始值为2
count: 0
}
},
methods: {
increase() {
this.count++
}
},
computed: {
double() {
return this.count * 2
}
},
watch: {
count(newVal, oldVal) {
console.log('count发生变化了', newVal, oldVal)
}
}
}
</script>
直接复制Comp1.vue文件的代码,将其中count的初始值修改为2,完成Comp2.vue文件的编写。
在src目录下创建hooks目录,并在hooks目录下创建countHook.js文件。将Comp1中的逻辑脚本迁移至countHook.js文件中,并做简单修改:引入ref、computed、watch函数,将数据、方法、计算属性和监听中的内容重新声明并默认暴露,最终将msg、count、increase、double这些变量、函数、计算结果值返回。
要注意的是,由于后续会改变Comp1与Comp2的count值,因此这里使用函数传参数的方式对count的值进行设置。
下面代码export的就是一个函数
src/hooks/countHook.js文件代码如下:
import { ref, computed, watch } from 'vue'
export default (initCount = 0) => {
const msg = ref('你好,Prajnalab!')
const count = ref(initCount)
const increase = () => {
count.value++
}
const double = computed(() => count.value * 2)
watch(double, (newVal, oldVal) => {
console.log('newVal', newVal);
console.log('oldVal', oldVal);
})
return { msg, count, increase, double }
}
此时就可以在Comp1中引入countHook,并传递count初始值。如果不传递值,则默认使用hook中设置的initCount初始值。由于Comp1的初始值需要设置为0、Comp2的初始值需要设置为2,所以Comp1不需要传值,Comp2传递2即可。具体代码如下:
Comp1代码:
<template>
<div>
<p>{{ msg }}</p>
<p>count: {{ count }}</p>
<p>double: {{ double }}</p>
<button @click='increase'>increase</button>
</div>
</template>
<script setup>
import countHook from '../hooks/countHook';
const { msg, count, double, increase } = countHook()
</script>
Comp2代码:
<template>
<div>
<p>{{ msg }}</p>
<p>count: {{ count }}</p>
<p>double: {{ double }}</p>
<button @click='increase'>increase</button>
</div>
</template>
<script setup>
import countHook from '../hooks/countHook';
const { msg, count, double, increase } = countHook(2)
</script>
App代码:
<template>
<Comp1 />
<Comp2 />
</template>
<script setup>
import Comp1 from './components/Comp1.vue';
import Comp2 from './components/Comp2.vue';
</script>
在 Vue 3 中,Hook(更准确地称为 "Composable")的核心思想正是允许您将响应式逻辑(包括响应式数据、计算属性、方法、生命周期等)从组件中抽离出来,封装成可复用的函数。
代码封装之Plugin(插件)
插件是给Vue添加全局功能的代码封装方式。Vue插件有两种定义方式:一种是对象,另一种是函数。对象中有install方法,而函数本身就是安装方法,其中,函数可以接收两个参数,分别是安装它的应用实例和传递给app.use的额外选项。值得一提的是,这两种定义方式都可以设置app应用实例和options插件参数选项。
// 插件定论的第1种方式:对象。其拥有install方法。
const myPlugin = {
install(app, options) {}
}
// 插件定义的第2种方式:函数。其本身就是安装方法。
const myPlugin = function (app, options) {}
下面通过对象的定义方式演示如何定义一个插件。
在src目录下创建自定义插件的目录myPlugin,并在其中创建文件index.js来定义插件,在文件中通过全局属性app.config.globalProperties添加一个全局方法globalMethod用于实现小写字母转化,以及注册一个公共组件Header和全局指令upper。通过use方法在当前实例中安装该插件。
此时src/myPlugin/index.js文件代码如下。
import Header from '../components/Header.vue';
// 插件定义的第1种方式,对象。其拥有 install 方法。
const myPlugin = {
install(app, options) {
// 配置全局方法
app.config.globalProperties.globalMethod = function (value) {
return value.toLowerCase()
}
// 注册公共组件
app.component('MyHeader', Header) // 这里不要用Header了,ESLint会警告,因为html内置了header
// 注册公共指令
app.directive('upper', function (el, binding) {
// 通过指令参数判断调用插件的 options 的哪个可选参数选项
el.textContent = binding.value.toUpperCase()
if (binding.arg === 'small') {
el.style.fontSize = options.small + 'px'
} else if (binding.arg === 'medium') {
el.style.fontSize = options.medium + 'px'
} else {
el.style.fontSize = options.large + 'px'
}
})
}
}
export default myPlugin
也可以定义函数插件,这里自己尝试吧
同时需要新建Header.vue文件,里面的内容很简单。components/Header.vue文件代码如下。
<template>
<h1>Header 头部组件</h1>
</template>
定义好插件后,需要在main.js文件中引入并安装。main.js文件代码如下。
import { createApp } from 'vue'
import App from './App.vue'
import myPlugin from './myPlugin'
const app = createApp(App)
// 安装插件
app.use(myPlugin, {
small: 12,
medium: 24,
large: 36
})
app.mount('#app')
下面就可以在任意组件中使用插件扩展的语法了。在App.vue文件中使用公共组件Header、自定义全局指令upper及全局方法globalMethod。此时App.vue代码如下。
<template>
<!-- 公共组件使用 -->
<MyHeader></MyHeader>
<!-- 插件全局指令的使用 -->
<p v-upper:medium='"Hello Vue3"'></p>
<!-- 插件全局方法的使用 -->
<p>{{ globalMethod('hi Vue3') }}</p>
</template>
<script setup>
</script>
运行效果如下。