主题
自定义指令(一)
本页对应 自定义指令,共 7 段视频,约 1 小时 32 分。
学习目标
- 能编写 Vue 自定义指令,并通过指令值实现可配置行为,完成
v-loading指令的封装与使用。 - 能区分并使用默认插槽、后备内容(默认值),在组件中正确渲染插槽内容。
- 能在线配置 ECharts 图表,并在 Vue 页面中完成安装、导入、初始化及图表创建。
- 能根据接口文档封装 API 请求,获取数据后更新 ECharts 图表,并说明
created与mounted中请求和初始化的执行顺序。 - 能使用 Element UI 表格组件配置数据源、列定义、表头、边框和斑马纹,完成列表数据渲染。
- 能封装面经列表接口并传入分页参数,获取数据后保存列表与总数,验证渲染结果和参数变化效果。
知识点
本节说明与学习路径
本节笔记包含四块:自定义指令、插槽、商品列表综合案例、路由入门。需要先说明:本节笔记未附图片,因此没有图片可保留;下面的代码是围绕笔记知识点给出的最小示例。转写稿主要围绕项目回顾、ECharts、表格和面经列表,与本笔记主题并不一致,所以不能把 ECharts 配置、面经接口等硬套进来。可迁移的调试经验是:导入错误、单词拼写、路径错误是高频问题,先看终端报错,再查官方文档,不要凭记忆猜配置。
自定义指令:把重复的 DOM 操作封装起来
为什么需要:像自动聚焦、按钮权限、加载遮罩,本质都是“拿到 DOM 后做同一件事”。写在每个组件里会重复,封装成指令更合适。怎么想:全局指令放在 app.directive,局部指令写在组件 directives 中;指令值通过 binding.value 读取;常用钩子是 mounted、updated、unmounted。怎么做:
js
// 全局注册:main.js
app.directive('focus', {
mounted(el) { el.focus() }
})js
// 局部注册
export default {
directives: {
focus: { mounted(el) { el.focus() } }
}
}html
<input v-focus />指令值示例:
js
app.directive('color', {
mounted(el, binding) { el.style.color = binding.value },
updated(el, binding) { el.style.color = binding.value }
})html
<p v-color="'red'">文字</p>v-loading 封装思路:指令挂载时创建遮罩,updated 根据布尔值切换显示,卸载时移除遮罩。
js
app.directive('loading', {
mounted(el, binding) {
el.style.position = 'relative'
const mask = document.createElement('div')
mask.className = 'loading-mask'
mask.textContent = '加载中...'
mask.style.cssText =
'position:absolute;inset:0;background:rgba(255,255,255,.7);display:flex;align-items:center;justify-content:center'
el.appendChild(mask)
el._loadingMask = mask
mask.style.display = binding.value ? 'flex' : 'none'
},
updated(el, binding) {
if (el._loadingMask) {
el._loadingMask.style.display = binding.value ? 'flex' : 'none'
}
},
unmounted(el) {
el._loadingMask?.remove()
}
})html
<div v-loading="loading">商品内容</div>易错点:注册指令名不要写 v-;局部指令要写在 directives 而不是 components;遮罩定位依赖父元素定位,必要时给父元素加 position: relative;更新逻辑用 updated,不是 update。
插槽:让组件结构可配置
为什么需要:组件外壳可复用,但内部标题、按钮、表格单元格内容经常不同。插槽就是把“结构中的空白”交给使用者填写。怎么想:默认插槽放主体内容;具名插槽用 name 区分区域;作用域插槽由子组件把数据传给父组件,让父组件决定怎么渲染。怎么做:
html
<!-- MyCard.vue -->
<template>
<div class="card">
<header><slot name="header">默认标题</slot></header>
<main><slot /></main>
</div>
</template>html
<MyCard>
<template #header>商品列表</template>
<p>这里是默认插槽内容</p>
</MyCard>作用域插槽示例:
html
<!-- MyTable.vue -->
<template>
<table>
<tr v-for="row in data" :key="row.id">
<td v-for="col in columns" :key="col.prop">
<slot :name="col.prop" :row="row">
{{ row[col.prop] }}
</slot>
</td>
</tr>
</table>
</template>html
<MyTable :data="list" :columns="cols">
<template #price="{ row }">¥{{ row.price }}</template>
</MyTable>易错点:v-slot 只能写在 <template> 上;默认插槽可简写,但具名插槽要写 #名字;作用域插槽的数据方向是“子传父”,不要误写成父传子。
综合案例:商品列表中的 MyTag 与 MyTable
为什么需要:商品状态标签、表格列展示在列表页反复出现,封装后页面只关心数据和列配置。怎么想:MyTag 只负责“文本 + 类型样式”;MyTable 负责“数据 + 列配置 + 插槽扩展”。怎么做:
html
<!-- MyTag.vue -->
<template>
<span class="tag" :class="'tag-' + type">
<slot>{{ text }}</slot>
</span>
</template>
<script>
export default {
props: { text: String, type: { type: String, default: 'default' } }
}
</script>html
<MyTag text="在售" type="success" />MyTable 可沿用上面的作用域插槽写法,父组件传入 data 和 columns,特殊列用插槽覆盖。易错点:v-for 要写 key;列配置的 prop 要和数据字段一致;不要直接修改 props,需要变更时让父组件改数据。
路由入门:从单页应用到 VueRouter
为什么需要:单页应用切换页面时不刷新整页,只替换视图区域,体验更接近原生应用。怎么想:把“路径 → 组件”的对应关系写成路由表,用 router-view 显示匹配组件,用 router-link 导航。怎么做:
js
// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import Home from '../views/Home.vue'
import Goods from '../views/Goods.vue'
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', component: Home },
{ path: '/goods', component: Goods }
]
})
export default routerjs
// main.js
import router from './router'
app.use(router)html
<router-link to="/">首页</router-link>
<router-link to="/goods">商品</router-link>
<router-view />易错点:忘记 app.use(router);router-view 没写导致页面空白;history 模式部署时要配置服务器回退到 index.html;组件导入路径写错时先看终端报错。
调试与易错点复盘
转写稿里反复出现“导入错误、单词拼写、路径错误、先看终端”的提醒,这些习惯同样适用于本节:指令注册名、插槽名、路由路径、组件导入路径都要逐字核对。遇到空白页先查控制台和终端;遇到指令不生效先确认全局/局部注册位置;遇到插槽不显示先确认子组件是否写了 <slot>;遇到路由不切换先确认 router-view 和路由表。不要靠猜配置,查文档、看报错、做最小示例验证,是最稳的调试路径。
从内置指令到自定义指令
为什么需要:Vue 已经提供了 v-html、v-if、v-bind、v-on 等内置指令,日常绑定、渲染、事件都能直接完成。但有些需求本质上是“对 DOM 做一段固定操作”,例如页面加载后让输入框自动聚焦。每次都写原生 DOM 代码会重复,也不利于复用。
怎么想:把“重复的 DOM 操作”抽成一个指令,哪里需要就写 v-指令名。内置指令不够用时,就注册自定义指令。
怎么做:先明确指令名,再注册,最后在模板中使用。注册时不用加 v-,使用时一定要加 v-。
易错点:一定先注册再使用,否则会报错;不要把注册名写成 v-focus,注册名应是 focus,使用才是 v-focus。
自定义指令的作用:封装 DOM 操作
为什么需要:自定义指令的概念是“自己定义的指令”,可以封装一些 DOM 操作,扩展额外功能。它适合处理“元素插入后要做什么”“元素需要什么特殊行为”这类问题。
怎么想:不要把它理解成替代组件。组件封装结构、样式和数据;自定义指令更偏向封装一段针对 DOM 的行为。
怎么做:在指令配置项中写钩子函数,例如 inserted,在元素插入父节点时执行逻辑。
易错点:不要把指令写成组件;也不要在指令里承担过多业务逻辑。指令应尽量小而明确。
全局注册:在 main.js 中写
为什么需要:如果多个页面都要用同一个 DOM 行为,全局注册可以避免每个组件重复注册。
怎么想:全局注册写在入口文件 main.js 中,通过 Vue.directive('指令名', { ... }) 注册。
怎么做:保留笔记代码如下:
js
//在main.js中
Vue.directive('指令名', {
"inserted" (el) {
// 可以对 el 标签,扩展额外功能
el.focus()
}
})易错点:inserted 要能拿到 el 参数;如果写成 inserted () 却在函数体使用 el,就会因为 el 未定义而报错。调试时可沿用转写稿里看报错的思路:先看“谁调用了什么、谁是 undefined”,再回代码对照参数是否接住。
局部注册:只在当前组件可用
为什么需要:如果指令只在某个组件中使用,就没必要全局注册,局部注册更清晰。
怎么想:在 Vue 组件的配置项中写 directives,把指令名作为键。
怎么做:笔记代码如下:
vue
//在Vue组件的配置项中
directives: {
"指令名": {
inserted () {
// 可以对 el 标签,扩展额外功能
el.focus()
}
}
}这里要特别注意:上面这段是笔记原样代码,实际书写时 inserted 应接收 el,即 inserted (el) { el.focus() },否则 el 没有来源。
易错点:局部注册只影响当前组件;拼写 directives 不要写错;指令名不要带 v-。
使用指令与前缀规则
为什么需要:注册好的指令要在模板中触发,必须遵循 Vue 的指令写法。
怎么想:注册名是“指令名”,使用时加 v- 前缀。例如注册 focus,使用就是 v-focus。
怎么做:使用语法是 v-指令名,如:
vue
<input type="text" v-focus />笔记中 App.vue 示例为:
vue
<div>
<h1>自定义指令</h1>
<input v-focus ref="inp" type="text">
</div>易错点:注册时加 v-、使用时忘加 v-,都会出错;先注册再使用,顺序不能反。
inserted 与 el:时机和对象
为什么需要:要让输入框聚焦,必须等元素已经插入页面,否则拿不到可操作的 DOM。
怎么想:inserted 是“被绑定元素插入父节点时调用的钩子函数”;el 是“使用指令的那个 DOM 元素”。所以聚焦逻辑应放在 inserted 中,对 el 调用 focus()。
怎么做:inserted (el) { el.focus() }。
易错点:在错误的生命周期或错误的钩子里操作 DOM,可能拿不到元素;el 参数漏写是常见错误。
代码示例:页面加载让输入框聚焦
需求:当页面加载时,让元素获取焦点。笔记特别提示:autofocus 在 Safari 浏览器有兼容性,因此用自定义指令更可控。
怎么做:先注册 focus 指令,再在输入框上写 v-focus。App.vue 结构保留如下:
vue
<div>
<h1>自定义指令</h1>
<input v-focus ref="inp" type="text">
</div>易错点:不要只写模板不注册;不要忘记 inserted 接收 el;ref="inp" 与指令聚焦不是一回事,ref 是拿元素引用,指令是封装行为。
小结与自测
自定义指令的作用是:自己定义指令,封装 DOM 操作,扩展额外功能。使用步骤可以概括为两步:先注册(全局或局部),再在模板中以 v-指令名 使用。
自测:1. 自定义指令的作用是什么?2. 使用自定义指令的步骤是哪两步?
学习时按转写稿中反复强调的“多写几次、对照示例写、写错就看报错”的方式练习,比死记更稳。
一、为什么需要“指令的值”
前面写自定义指令时,指令的行为往往是写死的:比如一个 v-color 指令,内部固定写成 el.style.color = 'red',那么页面上所有用到它的标签都只能是红色,这个指令基本没有复用价值。
真实场景里我们希望的是:同一个指令,用在不同标签上能给出不同效果。第一个标题要红色,第二个标题要蓝色,靠的就是在“使用指令的那一刻”把具体值传进去。这就是本节要解决的问题——指令不只是“做什么”,还要能接收“对谁、用什么参数做”。
所以这一节的核心只有两件事:怎么把值传给指令,以及值变了之后怎么让页面跟着变。
二、怎么把值传进去:等号 + binding.value
在绑定指令时,通过“等号”的形式为指令绑定具体的参数值:
html
<div v-color="color">我是内容</div>这里要理解一个关键点:v-color="color" 里的 color 不是字符串 'color',而是一个表达式,Vue 会去当前组件的 data 里找同名变量,把它的值传给指令。如果写成 v-color="'red'",那才是直接传字符串 'red'。
值传进去之后,指令内部通过 binding.value 拿到它。binding 是 Vue 传给指令钩子的第二个参数,除了 value,它还带着 name、expression、arg、modifiers 等信息,本节只用到 value:
js
directives: {
color: {
inserted (el, binding) {
el.style.color = binding.value
},
update (el, binding) {
el.style.color = binding.value
}
}
}el 是指令绑定的那个真实 DOM 元素,binding.value 就是等号右边表达式算出来的值。两者一拼,就是“把这个元素的文字颜色设成传进来的颜色”。
三、为什么一个钩子不够:inserted 与 update 的分工
这是本节最容易漏的地方。很多人只写 inserted,页面初次打开颜色是对的,于是以为大功告成,结果一改数据颜色就不动了。
原因是两个钩子触发时机完全不同:
inserted:元素被插入到父节点时调用,只在首次渲染时执行一次。这时 DOM 已经存在,可以安全地操作el.style。这一点和组件生命周期里“created阶段还拿不到页面元素、要等到mounted才能拿到 DOM”是同一个道理——操作 DOM 一定要等元素真正挂载。update:指令所在组件的 VNode 更新时调用。指令值被修改,就会触发update函数。也就是说,data里的color1一旦变化,v-color拿到的binding.value就是新值,必须在这里重新赋一次样式,页面才会跟着变色。
两个钩子里的代码一模一样,看着重复,但职责不同:inserted 负责“第一次上色”,update 负责“后面每次改色”。少一个都不完整。
四、代码示例
App.vue
vue
<template>
<div>
<!--显示红色-->
<h2 v-color="color1">指令的值1测试</h2>
<!--显示蓝色-->
<h2 v-color="color2">指令的值2测试</h2>
<button>
改变第一个h1的颜色
</button>
</div>
</template>
<script>
export default {
data () {
return {
color1: 'red',
color2: 'blue'
}
}
}
</script>
<style>
</style>注意示例里的按钮目前只有文字、还没有绑定点击事件,它是留给“验证 update 是否生效”用的。你可以自己补上一个事件,比如点击时把 color1 改成 'green':如果 update 写对了,第一个标题会立刻由红变绿;如果只写了 inserted,点完之后页面毫无反应——这就是最直接的调试手段:先看首次渲染对不对,再看改数据后变不变,两步分别验证两个钩子。
另外,指令要写在组件的 directives 选项里(注意是复数、带 s,和 components 一样是复数形式),注册完才能在模板中以 v-color 的形式使用。
五、易错点
- 等号右边写成了裸字符串。
v-color="red"会被当成变量red去data里找,找不到就报警告、颜色设置不上,而且不会提示你“这里应该加引号”。要么传data里的变量,要么写成v-color="'red'"。 - 只写
inserted不写update。初始颜色正常,数据一变就失效,这是最典型的“看起来能用其实是坏的”。 - 在
inserted里操作 DOM 之外的事情。inserted只保证元素已插入,不保证父节点一定存在、也不保证只调用一次(元素被移动时可能再次触发),所以里面别做与 DOM 无关的副作用逻辑。 - 把
binding.value写成binding.expression。expression拿到的是字符串形式的表达式(如"color1"),不是它的值,赋给style.color是无效的。 - 指令值来源要清楚。
binding.value的值来自组件自己的data,指令只负责“读并应用到 DOM”,不要反过来在指令里改组件数据——遵循“谁的数据谁修改”,指令保持单向使用即可。
场景:请求等待时页面空白
实际开发中,发送请求需要时间。数据还没回来时,页面往往先渲染出一个空盒子,用户看不到内容,也不知道系统是不是还在工作。
为什么需要处理这个问题?因为空白状态会让用户误以为页面坏了或操作没生效,体验不好。
怎么想?既然等待是客观存在的,就不能假装它不存在,而要在这个等待阶段给用户一个明确反馈:正在加载。
怎么做?在数据请求期间显示 loading 效果,请求完成后再移除。
易错点:不要只在“成功”时关闭 loading;从笔记的分析看,请求完毕就应关闭 loading 状态,否则蒙层可能残留。
需求:封装一个 v-loading 指令
本节需求很明确:封装一个 v-loading 指令,实现加载中的效果。
为什么需要封装成指令?因为 loading 的交互模式重复:某个盒子在请求期间加蒙层,请求结束去掉蒙层。如果每个页面都手写状态和类名,代码会重复。
怎么想?把“显示/隐藏蒙层”的能力抽成一个可复用的指令,页面只需要按指令语法使用。
怎么做?笔记给出的路线是:先准备 loading 类,再通过添加/移除类开启关闭 loading 状态,最后结合自定义指令语法进行封装复用。
易错点:指令封装的目标是复用“添加/移除类”的动作,不要把每个页面的请求逻辑都塞进指令里;请求何时开始、何时结束仍由业务代码决定。
分析:loading 本质是覆盖盒子的蒙层
笔记分析得很清楚:
- 本质 loading 效果就是一个蒙层,盖在了盒子上;
- 数据请求中,开启 loading 状态,添加蒙层;
- 数据请求完毕,关闭 loading 状态,移除蒙层。
为什么用蒙层?因为它能遮住盒子里尚未渲染的内容,同时展示加载图,告诉用户“这里正在加载”。
怎么想?蒙层要覆盖在目标盒子上,所以目标盒子需要能作为定位参照;蒙层本身用绝对定位铺满。
怎么做?笔记用伪元素 :before 实现蒙层:设置 content: ""、position: absolute、left/top: 0、宽高 100%,再用背景图显示 loading.gif。
易错点:目标盒子如果没有 position: relative,绝对定位的伪元素可能相对更外层定位,蒙层就会盖错位置;伪元素必须写 content: "",否则不显示。
实现:准备 loading 类
css
.loading:before {
content: "";
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
background: #fff url("./loading.gif") no-repeat center;
}为什么这样写?position: absolute 让蒙层脱离文档流并覆盖盒子;width/height: 100% 铺满定位父级;背景图居中显示加载动画。
怎么做?业务上只需要给盒子添加 loading 类,就出现蒙层;移除 loading 类,蒙层消失。
易错点:loading.gif 路径要正确;蒙层可能需要配合 z-index 防止被内容遮挡;蒙层只覆盖最近的定位父级,所以父盒子要设置 position: relative。
准备代码:新闻列表与延迟渲染
笔记中的准备代码用新闻列表模拟请求等待,并用 setTimeout 延迟 2 秒把数据更新到 list 中,便于观察空白状态。
html
<template>
<div class="main">
<div class="box">
<ul>
<li v-for="item in list" :key="item.id" class="news">
<div class="left">
<div class="title">{{ item.title }}</div>
<div class="info">
<span>{{ item.source }}</span>
<span>{{ item.time }}</span>
</div>
</div>
<div class="right">
<img :src="item.img" alt="新闻配图">
</div>
</li>
</ul>
</div>
</div>
</template>
<script>
// 安装axios => yarn add axios || npm i axios
import axios from 'axios'
// 接口地址:http://hmajax.itheima.net/api/news
// 请求方式:get
export default {
data () {
return {
list: [],
isLoading: false,
isLoading2: false
}
},
async created () {
// 1. 发送请求获取数据
const res = await axios.get('http://hmajax.itheima.net/api/news')
setTimeout(() => {
// 2. 更新到 list 中,用于页面渲染 v-for
this.list = res.data.data
}, 2000)
}
}
</script>
<style>
.loading:before {
content: '';
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
background: #fff url('./loading.gif') no-repeat center;
}
.box2 {
width: 400px;
height: 400px;
border: 2px solid #000;
position: relative;
}
.box {
width: 800px;
min-height: 500px;
border: 3px solid orange;
border-radius: 5px;
position: relative;
}
.news {
display: flex;
height: 120px;
width: 600px;
margin: 0 auto;
padding: 20px 0;
cursor: pointer;
}
.news .left {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
padding-right: 10px;
}
.news .left .title {
font-size: 20px;
}
.news .left .info {
color: #999999;
}
.news .left .info span {
margin-right: 20px;
}
.news .right {
width: 160px;
height: 120px;
}
.news .right img {
width: 100%;
height: 100%;
object-fit: cover;
}
</style>为什么准备这段代码?它保留了真实请求和延迟渲染,能清楚看到“数据未回来时页面空白”的问题。
怎么想?先不加 loading,观察问题;再加 loading 类观察蒙层;最后把添加/移除类的动作交给 v-loading 指令复用。
易错点:.box 上已有 position: relative,这是蒙层能正确覆盖的关键;data 里的 isLoading、isLoading2 是预留状态,笔记准备代码中未实际使用,不要误以为它们已经完成了指令封装。
封装思路:把添加/移除类交给自定义指令
为什么用自定义指令?因为 loading 的核心动作是“给目标元素加类、移除类”,这属于 DOM 操作,适合放在指令里。
怎么想?v-loading 可以根据绑定值切换:需要加载时添加 loading 类,不需要时移除。笔记只给到“结合自定义指令的语法进行封装复用”,没有展开具体指令代码,因此这里不编造额外实现。
怎么做?按笔记路线:先有 .loading:before 蒙层样式,再在指令中控制目标元素 classList 的添加/移除,业务页面用 v-loading 复用。
易错点:目标元素必须有定位;指令控制的是类名,不要忘记样式类名与指令中使用的类名一致;请求结束后要确保移除类。
易错点与调试检查
第一,检查蒙层是否相对正确的盒子定位:父盒子要有 position: relative。
第二,检查 .loading:before 是否生效:content、宽高、背景图路径都不能少。
第三,检查类名是否添加/移除成功:可在开发者工具中观察目标盒子的 class 变化。
第四,检查请求完成后的关闭动作:笔记分析要求“请求完毕,关闭 loading 状态,移除蒙层”,不要只关注请求成功分支。
第五,图片 alt 已改为中文“新闻配图”,保留代码结构不变。
作用:让组件内部结构支持自定义
默认插槽解决的是“组件结构复用”问题。转写稿里反复强调:可复用的组件放在 components 中,使用时先导入、注册,再写标签。对话框就是典型可复用组件:标题、按钮、遮罩样式可以固定,但中间提示内容不能固定。
为什么需要:同一个对话框,今天提示“确定删除吗”,明天提示“确定退出吗”。如果内容写死,每换一句话就要改组件或复制新组件。怎么想:数据不同用 props,结构不同就用插槽。怎么做:在组件内部不确定的结构处写 <slot></slot> 占位,使用组件时把自定义结构写在组件标签内部。

易错点:插槽传的是“结构”,不是普通数据;不要把 <slot> 当成 props 使用。
需求与问题:对话框内容不能写死
需求:将需要多次显示的对话框封装成 MyDialog 组件。问题:组件的内容部分不希望写死,希望使用时自定义。比如笔记代码中:
vue
<div class="dialog-content">
您确定要进行删除操作吗?
</div>这一行就是“不确定结构”。怎么想:把这一行替换成插槽占位,谁使用对话框,谁决定中间显示什么。怎么做:组件内改成 <slot></slot>,父组件使用时在 <MyDialog> 标签内部传入内容。易错点:如果使用 <MyDialog /> 自闭合写法,标签内部没有位置放内容,默认插槽就传不进去。
基本语法:三步完成默认插槽
- 组件内需要定制的结构部分,改用
<slot></slot>占位。 - 使用组件时,在
<MyDialog></MyDialog>标签内部传入结构替换slot。 - 给插槽传入内容时,可以传入纯文本、HTML 标签、组件。

为什么需要:默认插槽是最常用的匿名插槽,适合“一个组件只有一个自定义区域”的场景。怎么想:子组件留坑,父组件填坑。怎么做:先保证 MyDialog 已导入注册,再把内容写在组件标签内部。易错点:默认插槽不需要写 name;传入内容必须写在组件开始标签和结束标签之间;传入 HTML 或组件时要注意标签闭合和组件注册。
代码示例:先保留组件,再改造内容区
笔记中的 MyDialog.vue 原始代码如下,注意其中 dialog-content 里的提示语就是后续要改成 <slot></slot> 的位置:
vue
<template>
<div class="dialog">
<div class="dialog-header">
<h3>友情提示</h3>
<span class="close">✖️</span>
</div>
<div class="dialog-content">
您确定要进行删除操作吗?
</div>
<div class="dialog-footer">
<button>取消</button>
<button>确认</button>
</div>
</div>
</template>
<script>
export default {
data () {
return {
}
}
}
</script>
<style scoped>
* {
margin: 0;
padding: 0;
}
.dialog {
width: 470px;
height: 230px;
padding: 0 25px;
background-color: #ffffff;
margin: 40px auto;
border-radius: 5px;
}
.dialog-header {
height: 70px;
line-height: 70px;
font-size: 20px;
border-bottom: 1px solid #ccc;
position: relative;
}
.dialog-header .close {
position: absolute;
right: 0px;
top: 0px;
cursor: pointer;
}
.dialog-content {
height: 80px;
font-size: 18px;
padding: 15px 0;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
}
.dialog-footer button {
width: 65px;
height: 35px;
background-color: #ffffff;
border: 1px solid #e1e3e9;
cursor: pointer;
outline: none;
margin-left: 10px;
border-radius: 3px;
}
.dialog-footer button:last-child {
background-color: #007acc;
color: #fff;
}
</style>App.vue 原始使用如下:
vue
<template>
<div>
<MyDialog>
</MyDialog>
</div>
</template>
<script>
import MyDialog from './components/MyDialog.vue'
export default {
data () {
return {
}
},
components: {
MyDialog
}
}
</script>
<style>
body {
background-color: #b3b3b3;
}
</style>改造时,把 MyDialog.vue 的内容区改成 <slot></slot>;在 App.vue 的 <MyDialog> 标签内部写自定义内容,例如纯文本或 <p> 标签。调试顺序:先确认组件能正常显示,再替换成 slot,最后在标签内部传入结构,观察是否替换成功。
总结:场景与步骤
场景:组件内某一部分结构不确定,想要自定义。步骤:组件内用 <slot></slot> 占位;使用组件时在标签内部传入结构;传入内容可以是纯文本、HTML 标签、组件。易错点:自闭合标签传不了内容;内容写错位置;把默认插槽和具名插槽混淆;组件未注册却直接传入。按“先导入注册、再使用组件”的套路检查,和转写稿中组件复用的思路一致。
从“空白插槽”这个问题出发

为什么需要:前面用插槽完成内容定制,父组件传什么,子组件就显示什么。但如果不传内容,插槽位置就是空白,页面结构会显得缺一块。
怎么想:既然插槽是“预留位置”,那这个位置在没有外部内容时,能不能先显示一段默认文字?这样组件单独使用时也不至于空着。
怎么做:先保留原来的插槽结构,观察“不传内容”的效果;再考虑给 <slot> 内部放一段默认内容。这个默认内容就是本节要讲的“后备内容”。
易错点:不要把“不传内容”理解成组件坏了。空白是插槽没有收到内容时的正常表现,解决方向是给插槽准备默认值,而不是在父组件里硬写一段占位文字。
后备内容是什么
为什么需要:封装组件时,插槽是给使用者预留的。使用者可能传内容,也可能不传。为了让组件在不传内容时仍然有可展示的信息,需要给插槽设置后备内容,也叫默认内容。
怎么想:后备内容不是父组件传进来的,而是子组件在定义插槽时自己准备的“兜底方案”。外部有内容就用外部的,没有才用后备内容。
怎么做:在封装组件时,为预留的 <slot> 插槽提供后备内容。也就是把默认显示内容写在 <slot> 标签内部。
易错点:后备内容是写在子组件的 <slot> 里,不是写在父组件使用组件的地方。父组件负责“传不传”,子组件负责“没传时显示什么”。
语法:把默认内容放进 slot 标签内部

为什么需要:知道要有默认值后,还要明确写在哪里。Vue 的插槽语法允许把内容放在 <slot> 标签内部,作为没有传入内容时的后备显示。
怎么想:可以把它理解成“插槽的默认填充物”。外部没有提供内容时,插槽就显示标签内部的内容;外部提供了内容,就由外部内容替换。
怎么做:在 <slot> 和 </slot> 之间放置内容,作为默认显示内容。写法就是让默认内容成为 <slot> 的子内容。
易错点:默认内容必须放在 <slot> 标签内部。如果放到 <slot> 外面,它就不是后备内容,而会变成普通结构的一部分,可能一直显示,无法被外部内容替换。
两种效果:不传显示默认,传了整体替换


为什么需要:后备内容的核心价值体现在两种使用场景:不传内容时有兜底,传内容时又能定制。必须把这两种效果都验证到。
怎么想:外部使用组件时,不传东西,slot 会显示后备内容;外部传东西了,slot 整体会被换掉。注意是“整体替换”,不是默认内容和传入内容拼接。
怎么做:用同一个组件分别写两次:第一次不写插槽内容,观察默认显示;第二次在组件标签中间写内容,观察替换效果。通过对比确认后备内容只在“没传”时出现。
易错点:不要以为传了内容后默认内容还会保留。插槽的替换关系是:有传入内容就显示传入内容;没有传入内容才显示后备内容。
代码示例与阅读方式
vue
<template>
<div>
<MyDialog></MyDialog>
<MyDialog>
你确认要退出么
</MyDialog>
</div>
</template>
<script>
import MyDialog from './components/MyDialog.vue'
export default {
data () {
return {
}
},
components: {
MyDialog
}
}
</script>
<style>
body {
background-color: #b3b3b3;
}
</style>为什么需要:这段代码同时展示了“不传”和“传”的用法。第一个 <MyDialog></MyDialog> 没有写内容,第二个 <MyDialog> 中间写了“你确认要退出么”。
怎么想:阅读时要把注意力放在组件标签中间有没有内容。第一个没有内容,就依赖 MyDialog.vue 内部 <slot> 的后备内容;第二个有内容,就会把插槽位置替换成“你确认要退出么”。
怎么做:在 MyDialog.vue 内部预留 <slot>,并在 <slot> 标签内写默认内容;然后在 App.vue 中分别使用不传内容和传内容的写法进行验证。
易错点:示例只给出了 App.vue,MyDialog.vue 内部的插槽写法不能丢。否则父组件传了内容也没有接收位置。调试时先看子组件里有没有 <slot>,再看默认内容是否写在 <slot> 内部,最后看父组件使用处有没有传内容。转写稿主要回顾组件注册、父子传值等内容,没有直接展开本节插槽后备内容,因此这里按笔记中的“问题—语法—两种效果”顺序验证即可。
课件图示








代码示例
代码示例 1:自定义指令 —— v-focus 与 v-color(指令的值)
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>自定义指令:v-focus / v-color</title>
</head>
<body>
<div id="app">
<h1>自定义指令</h1>
<!-- 使用指令:一定要加 v- 前缀 -->
<input v-focus type="text" placeholder="页面加载后自动获取焦点" />
<h2 v-color="color1">指令的值1测试</h2>
<h2 v-color="color2">指令的值2测试</h2>
<button @click="color1 = 'green'">改变第一个 h2 的颜色</button>
</div>
<script src="https://cdn.jsdelivr.net/npm/vue@2.7.16/dist/vue.js"></script>
<script>
// 1. 全局注册:注册时不用加 v- 前缀
Vue.directive('focus', {
inserted(el) {
// el 就是使用指令的那个 DOM 元素
el.focus()
}
})
new Vue({
el: '#app',
data() {
return {
color1: 'red',
color2: 'blue'
}
},
// 2. 局部注册(写在组件配置项 directives 中)
directives: {
color: {
// 指令的值第一次生效
inserted(el, binding) {
el.style.color = binding.value
},
// 指令值被修改时触发
update(el, binding) {
el.style.color = binding.value
}
}
}
})
</script>
</body>
</html>关键行与易错点
- 注册时写
'focus'、'color',不加v-;使用时必须写v-focus、v-color,先注册再使用,否则报错。 inserted是「被绑定元素插入父节点时」调用的钩子,el就是使用指令的那个 DOM 元素,所以el.focus()能生效。autofocus在 Safari 浏览器有兼容性问题,所以用自定义指令封装el.focus()。- 指令的值通过等号传入:
v-color="color1";在指令里用binding.value拿到,注意是binding.value,不是el.value。 - 指令值修改不会重新触发
inserted,必须再写update钩子,否则改颜色无效——这是本节最常见的坑。 data里必须返回color1、color2,模板中用到的变量没定义会渲染失败。
代码示例 2:自定义指令封装 v-loading
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>自定义指令封装 v-loading</title>
<style>
.box {
width: 600px;
min-height: 300px;
border: 3px solid orange;
border-radius: 5px;
/* 蒙层定位的参照,必须有 */
position: relative;
}
/* 笔记原版写法:用 loading.gif 当背景
.loading:before {
content: "";
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
background: #fff url("./loading.gif") no-repeat center;
}
*/
/* 不依赖图片的等价写法:伪元素铺满做蒙层 */
.loading::before {
content: "";
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.8);
}
/* 纯 CSS 转圈,替代 loading.gif */
.loading::after {
content: "";
position: absolute;
left: 50%;
top: 50%;
width: 32px;
height: 32px;
margin: -16px 0 0 -16px;
border: 4px solid #007acc;
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.news { display: flex; height: 120px; padding: 20px 0; }
.news .left { flex: 1; display: flex; flex-direction: column; justify-content: space-between; padding-right: 10px; }
.news .left .title { font-size: 20px; }
.news .left .info { color: #999999; }
.news .left .info span { margin-right: 20px; }
</style>
</head>
<body>
<div id="app">
<!-- 本质:给盒子动态添加 / 移除 loading 类 -->
<div class="box" v-loading="isLoading">
<ul>
<li v-for="item in list" :key="item.id" class="news">
<div class="left">
<div class="title">{{ item.title }}</div>
<div class="info">
<span>{{ item.source }}</span>
<span>{{ item.time }}</span>
</div>
</div>
</li>
</ul>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/vue@2.7.16/dist/vue.js"></script>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script>
// 结合「指令的值」语法封装:值为 true 加类,为 false 移除类
Vue.directive('loading', {
inserted(el, binding) {
binding.value ? el.classList.add('loading') : el.classList.remove('loading')
},
update(el, binding) {
binding.value ? el.classList.add('loading') : el.classList.remove('loading')
}
})
new Vue({
el: '#app',
data() {
return {
list: [],
isLoading: false
}
},
async created() {
// 1. 请求前:开启 loading 状态(添加蒙层)
this.isLoading = true
try {
// 接口地址:http://hmajax.itheima.net/api/news 请求方式:get
const res = await axios.get('http://hmajax.itheima.net/api/news')
// 2. 更新到 list 中,用于页面渲染 v-for
this.list = res.data.data
} finally {
// 3. 请求结束:关闭 loading 状态(移除蒙层)
this.isLoading = false
}
}
})
</script>
</body>
</html>关键行与易错点
- loading 效果本质就是一个蒙层盖在盒子上;开启 / 关闭 loading,本质只是添加 / 移除类名。
.loading::before用了position: absolute,所以父盒子.box必须写position: relative,否则蒙层会跑到页面别处——这是最容易踩的坑。content: ""不能少,伪元素没有内容不渲染;width/height: 100%才能铺满。- 指令里一定要同时写
inserted和update:inserted处理初始值,update处理isLoading变化后重新渲染。 - 用
binding.value取值,指令写成v-loading="isLoading",不要写成字符串v-loading="isLoading"之外的静态值(如v-loading="true"不会随数据变化)。 - 笔记原版用
url("./loading.gif"),图片路径错误或图片缺失时蒙层会变成纯白,看不出加载效果;示例里用纯 CSS 转圈替代,行为等价。 axios请求跨域 / 网络不通时,要保证isLoading能被关掉,所以放在finally中;否则蒙层会一直转。- 接口返回结构是
res.data.data(axios 包了一层),取错层级v-for渲染不出数据。
代码示例 3:默认插槽 + 后备内容(MyDialog 组件)
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>插槽:默认插槽 + 后备内容</title>
<style>
body { background-color: #b3b3b3; }
.dialog {
width: 470px;
height: 230px;
padding: 0 25px;
background-color: #ffffff;
margin: 40px auto;
border-radius: 5px;
}
.dialog-header {
height: 70px;
line-height: 70px;
font-size: 20px;
border-bottom: 1px solid #ccc;
position: relative;
}
.dialog-header .close {
position: absolute;
right: 0px;
top: 0px;
cursor: pointer;
}
.dialog-content {
height: 80px;
font-size: 18px;
padding: 15px 0;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
}
.dialog-footer button {
width: 65px;
height: 35px;
background-color: #ffffff;
border: 1px solid #e1e3e9;
cursor: pointer;
outline: none;
margin-left: 10px;
border-radius: 3px;
}
.dialog-footer button:last-child {
background-color: #007acc;
color: #fff;
}
</style>
</head>
<body>
<div id="app">
<!-- 1. 不传内容:显示 slot 内的后备内容 -->
<my-dialog></my-dialog>
<!-- 2. 传内容:slot 整体被替换掉 -->
<my-dialog>
<span style="color: red;">你确认要退出么</span>
</my-dialog>
</div>
<script src="https://cdn.jsdelivr.net/npm/vue@2.7.16/dist/vue.js"></script>
<script>
// 相当于 MyDialog.vue 的 <template> + <style scoped>
const MyDialog = {
template: `
<div class="dialog">
<div class="dialog-header">
<h3>友情提示</h3>
<span class="close">✖️</span>
</div>
<div class="dialog-content">
<!-- 组件内需要定制的结构,用 slot 占位 -->
<!-- 标签内写的内容 = 后备内容(默认值) -->
<slot>您确定要进行删除操作吗?</slot>
</div>
<div class="dialog-footer">
<button>取消</button>
<button>确认</button>
</div>
</div>
`
}
new Vue({
el: '#app',
// 局部注册组件
components: {
MyDialog
}
})
</script>
</body>
</html>关键行与易错点
- 插槽三步:① 组件内把要定制的结构换成
<slot></slot>占位;② 使用组件时在<MyDialog></MyDialog>标签内部传入结构;③ 传入内容可以是纯文本、HTML 标签或组件。 - 后备内容写在
<slot>标签内部:不传内容显示默认值,传了内容则 slot 整体被换掉(是替换,不是追加)。 - 传入内容写在组件标签内部,不是写在标签属性上;写在属性上不会生效。
- 组件名
MyDialog在 DOM 模板里要写成<my-dialog>(kebab-case),驼峰标签名在浏览器里会被转成小写而匹配不到。 - 组件内样式想「只作用于自己」需要
scoped;示例是 CDN 单文件写法,全局样式下注意类名冲突。 - 组件必须先导入 / 定义再在
components中注册,未注册就使用标签会渲染失败(此处直接用对象定义MyDialog)。
常见错误
指令注册报错 / 指令不生效
- 现象:控制台提示
Failed to resolve directive: focus,或页面上指令被当成普通属性渲染。 - 原因:注册时写了
v-focus,使用时写focus;或局部注册写在了components里而不是directives里;或先使用后注册。 - 修法:注册去前缀、使用加前缀(
Vue.directive('focus', ...)/v-focus),局部注册放在directives配置项,且注册代码必须在使用之前执行。
- 现象:控制台提示
指令的值改了,页面样式不跟着变
- 现象:
v-color="color1"初次渲染正常,点击按钮修改color1后颜色没变。 - 原因:只写了
inserted,它只在元素插入父节点时执行一次,后续数据变化不会再次触发。 - 修法:同时补上
update(el, binding),并在其中用binding.value重新赋值。
- 现象:
v-loading 蒙层不显示或盖不住盒子
- 现象:加了
.loading类,但页面看不到遮罩,或遮罩只覆盖了一小块、跑到了别处。 - 原因:
::before是绝对定位,父容器没有position: relative,于是相对更外层定位;或父元素本身没有尺寸。 - 修法:给被遮罩的盒子加
position: relative并确保有宽高,蒙层用left/top: 0+width/height: 100%。
- 现象:加了
插槽内容传了却不显示
- 现象:
<MyDialog>内容</MyDialog>里写了东西,弹窗里仍是空白或仍是默认文字。 - 原因:组件内部忘了写
<slot>;或具名插槽两边名字对不上(name="header"对应#header);或把内容写在了自闭合标签上<MyDialog />。 - 修法:确认组件模板有对应
slot占位、名字完全一致,使用组件时写成成对标签,内容放在标签内部。
- 现象:
el-table 表格空白或整列没数据
- 现象:表头出来了,行内全是空白;或一行都不渲染。
- 原因:
:data绑定的变量名与data中定义的不一致(如模板写tableData、数据里写tableabled);el-table-column的prop与数据字段名拼写不一致;或数据是异步拿的却写死成静态数组。 - 修法:
prop与后端字段名逐字对照;data里先给空数组占位,请求回来再this.list = res.data.data.rows赋值。
ECharts 报
Cannot read properties of undefined (reading 'init')- 现象:图表区域空白,控制台报通过
undefined调用了init。 - 原因:
import echarts from 'echarts'默认导入拿不到东西(包本身没有默认导出);或包名装错成echar/echart。 - 修法:改为
import * as echarts from 'echarts';安装时确认包名是echarts,npm i echarts后到node_modules里核对目录名。
- 现象:图表区域空白,控制台报通过
图表容器拿不到 DOM / 数据更新后图表不变
- 现象:
this.$refs.xxx为undefined;或接口数据已拿到,图表还是旧的假数据。 - 原因:初始化写在
created里,此时 DOM 还没挂载;更新数据时直接改数组却没通知图表实例。 - 修法:初始化放
mounted;数据回来后再调一次this.myChart.setOption(option),只保留要变的series.data、xAxis.data,其余配置可省略。
- 现象:
axios 传参语法写崩
- 现象:
params那行直接语法报错,或请求地址拼出一堆undefined。 - 原因:
request.get(url, params)少了外层对象大括号;或params里的键名与接口文档不一致。 - 修法:写成
request.get(url, { params }),键名严格按接口文档;不确定时用request({ url, method: 'get', params })的完整写法。
- 现象:
练习
指令值 + 更新:封装
v-color指令,要求支持传入颜色值、支持默认值(不传时用#333),并写一个按钮修改绑定的颜色变量,验证update是否被触发。v-loading 指令:把笔记中的
.loading蒙层改造成自定义指令,语法为v-loading="isLoading",要求isLoading为true时加类、为false时移除类(提示:inserted和update里都要处理,注意初始值的判断)。再用一个按钮手动切换验证效果。插槽三连:封装一个
MyCard组件,要求:① 默认插槽放正文;② 具名插槽header放标题,不传时显示后备内容"默认标题";③ 作用域插槽把组件内部的list数据回传给父组件,父组件用#default="{ list }"自行渲染成ul。表格 + 接口:用
el-table渲染面经列表,要求:data初始为空数组;在created中调接口,把res.data.data.rows存入list、res.data.data.total存入total;接口函数支持current/pageSize两个参数,并在页面上展示"共 X 条记录"。图表联调:在
mounted中初始化 ECharts 折线图(只写空series),在created中请求接口,拿到数据后用map提取日期数组和两组数值数组,再通过setOption更新图表。要求能口述这段代码的真实执行顺序。
原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | 反馈和回顾.mp41266.txt.mp4 | 12:22 |
| 2 | 反馈和回顾 | 12:45 |
| 3 | 在线配置echarts图表 | 13:02 |
| 4 | 页面创建echarts图表 | 10:45 |
| 5 | 获取接口数据并应用到图表 | 20:00 |
| 6 | 表格的使用 | 12:11 |
| 7 | 获取面经列表数据 | 11:23 |
小结
- 自定义指令的本质是"可复用的 DOM 操作封装",凡是靠
querySelector反复做的同一件事,都可以考虑抽成指令。 inserted管首次、update管变化——只要指令的值可能被修改,两个钩子就必须成对出现,这是最容易漏的一环。- 插槽解决的是"结构由使用者决定",
slot是占位符,后备内容只在使用者什么都不传时兜底;作用域插槽则是把组件内部数据"反向"交给使用者。 el-table是"数据驱动列"的结构::data决定有哪些行,el-table-column的prop决定每列取哪个字段,二者任一拼错都表现为"空白"而非报错,排查时要先对照字段名。- ECharts 的使用可固化为三步:
init(容器)→setOption(配置)→ 数据变化时再次setOption,其中第一步必须在mounted之后。 - 异步请求与生命周期的关系要按"事件循环"理解,而不是按代码书写顺序:
created里await之后的代码会排到mounted之后执行,因此"先建图表、后填数据"是必然结果,不是 bug。 - 接口函数一律加参数(
current、pageSize等),即使当前用不上——这样分页、筛选接进来时只需改调用处,不用回头改封装。 - 遇到"看起来没错却不生效"的情况,先刷新页面再排查;真正的报错八成集中在导入路径、包名拼写、字段名不一致这三类,读报错时优先看"谁调用了谁"。