Skip to content

自定义指令(一) ​

本页对应 自定义指令,共 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 router
js
// 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 的形式使用。

五、易错点 ​

  1. 等号右边写成了裸字符串。v-color="red" 会被当成变量 red 去 data 里找,找不到就报警告、颜色设置不上,而且不会提示你“这里应该加引号”。要么传 data 里的变量,要么写成 v-color="'red'"。
  2. 只写 inserted 不写 update。初始颜色正常,数据一变就失效,这是最典型的“看起来能用其实是坏的”。
  3. 在 inserted 里操作 DOM 之外的事情。inserted 只保证元素已插入,不保证父节点一定存在、也不保证只调用一次(元素被移动时可能再次触发),所以里面别做与 DOM 无关的副作用逻辑。
  4. 把 binding.value 写成 binding.expression。expression 拿到的是字符串形式的表达式(如 "color1"),不是它的值,赋给 style.color 是无效的。
  5. 指令值来源要清楚。binding.value 的值来自组件自己的 data,指令只负责“读并应用到 DOM”,不要反过来在指令里改组件数据——遵循“谁的数据谁修改”,指令保持单向使用即可。

场景:请求等待时页面空白 ​

实际开发中,发送请求需要时间。数据还没回来时,页面往往先渲染出一个空盒子,用户看不到内容,也不知道系统是不是还在工作。
为什么需要处理这个问题?因为空白状态会让用户误以为页面坏了或操作没生效,体验不好。
怎么想?既然等待是客观存在的,就不能假装它不存在,而要在这个等待阶段给用户一个明确反馈:正在加载。
怎么做?在数据请求期间显示 loading 效果,请求完成后再移除。
易错点:不要只在“成功”时关闭 loading;从笔记的分析看,请求完毕就应关闭 loading 状态,否则蒙层可能残留。

需求:封装一个 v-loading 指令 ​

本节需求很明确:封装一个 v-loading 指令,实现加载中的效果。
为什么需要封装成指令?因为 loading 的交互模式重复:某个盒子在请求期间加蒙层,请求结束去掉蒙层。如果每个页面都手写状态和类名,代码会重复。
怎么想?把“显示/隐藏蒙层”的能力抽成一个可复用的指令,页面只需要按指令语法使用。
怎么做?笔记给出的路线是:先准备 loading 类,再通过添加/移除类开启关闭 loading 状态,最后结合自定义指令语法进行封装复用。
易错点:指令封装的目标是复用“添加/移除类”的动作,不要把每个页面的请求逻辑都塞进指令里;请求何时开始、何时结束仍由业务代码决定。

分析:loading 本质是覆盖盒子的蒙层 ​

笔记分析得很清楚:

  1. 本质 loading 效果就是一个蒙层,盖在了盒子上;
  2. 数据请求中,开启 loading 状态,添加蒙层;
  3. 数据请求完毕,关闭 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 /> 自闭合写法,标签内部没有位置放内容,默认插槽就传不进去。

基本语法:三步完成默认插槽 ​

  1. 组件内需要定制的结构部分,改用 <slot></slot> 占位。
  2. 使用组件时,在 <MyDialog></MyDialog> 标签内部传入结构替换 slot。
  3. 给插槽传入内容时,可以传入纯文本、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 标签内部 ​

在 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> 内部,最后看父组件使用处有没有传内容。转写稿主要回顾组件注册、父子传值等内容,没有直接展开本节插槽后备内容,因此这里按笔记中的“问题—语法—两种效果”顺序验证即可。

课件图示 ​

课件第8页:需求:实现一个 color 指令 - 传入不同的颜色, 给标签设置文字颜色 l

课件第19页:插槽后备内容:封装组件时,可以为预留的  插槽提供后备内容(默认

课件第18页:通过插槽完成了内容的定制,传什么显示什么, 但是如果不传,则是空白 能否给插槽设

课件第10页:场景:实际开发过程中,发送请求需要时间,在请求的数据未回来时,页面会处于空白状态

课件第11页:场景:实际开发过程中,发送请求需要时间,在请求的数据未回来时,页面会处于空白状态

课件第12页:自定义指令 - v-loading 指令封装 分析: 1. 本质 loading

课件第30页:总结 小结 商品列表的实现封装了几个组件? 2个组件,标签组件 和 表格组件 封

课件第13页:总结 小结 1. 通过指令相关语法,封装了指令 v-loading 实现了请求的

代码示例 ​

代码示例 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)。

常见错误 ​

  1. 指令注册报错 / 指令不生效

    • 现象:控制台提示 Failed to resolve directive: focus,或页面上指令被当成普通属性渲染。
    • 原因:注册时写了 v-focus,使用时写 focus;或局部注册写在了 components 里而不是 directives 里;或先使用后注册。
    • 修法:注册去前缀、使用加前缀(Vue.directive('focus', ...) / v-focus),局部注册放在 directives 配置项,且注册代码必须在使用之前执行。
  2. 指令的值改了,页面样式不跟着变

    • 现象:v-color="color1" 初次渲染正常,点击按钮修改 color1 后颜色没变。
    • 原因:只写了 inserted,它只在元素插入父节点时执行一次,后续数据变化不会再次触发。
    • 修法:同时补上 update(el, binding),并在其中用 binding.value 重新赋值。
  3. v-loading 蒙层不显示或盖不住盒子

    • 现象:加了 .loading 类,但页面看不到遮罩,或遮罩只覆盖了一小块、跑到了别处。
    • 原因:::before 是绝对定位,父容器没有 position: relative,于是相对更外层定位;或父元素本身没有尺寸。
    • 修法:给被遮罩的盒子加 position: relative 并确保有宽高,蒙层用 left/top: 0 + width/height: 100%。
  4. 插槽内容传了却不显示

    • 现象:<MyDialog>内容</MyDialog> 里写了东西,弹窗里仍是空白或仍是默认文字。
    • 原因:组件内部忘了写 <slot>;或具名插槽两边名字对不上(name="header" 对应 #header);或把内容写在了自闭合标签上 <MyDialog />。
    • 修法:确认组件模板有对应 slot 占位、名字完全一致,使用组件时写成成对标签,内容放在标签内部。
  5. el-table 表格空白或整列没数据

    • 现象:表头出来了,行内全是空白;或一行都不渲染。
    • 原因::data 绑定的变量名与 data 中定义的不一致(如模板写 tableData、数据里写 tableabled);el-table-column 的 prop 与数据字段名拼写不一致;或数据是异步拿的却写死成静态数组。
    • 修法:prop 与后端字段名逐字对照;data 里先给空数组占位,请求回来再 this.list = res.data.data.rows 赋值。
  6. 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 里核对目录名。
  7. 图表容器拿不到 DOM / 数据更新后图表不变

    • 现象:this.$refs.xxx 为 undefined;或接口数据已拿到,图表还是旧的假数据。
    • 原因:初始化写在 created 里,此时 DOM 还没挂载;更新数据时直接改数组却没通知图表实例。
    • 修法:初始化放 mounted;数据回来后再调一次 this.myChart.setOption(option),只保留要变的 series.data、xAxis.data,其余配置可省略。
  8. axios 传参语法写崩

    • 现象:params 那行直接语法报错,或请求地址拼出一堆 undefined。
    • 原因:request.get(url, params) 少了外层对象大括号;或 params 里的键名与接口文档不一致。
    • 修法:写成 request.get(url, { params }),键名严格按接口文档;不确定时用 request({ url, method: 'get', params }) 的完整写法。

练习 ​

  1. 指令值 + 更新:封装 v-color 指令,要求支持传入颜色值、支持默认值(不传时用 #333),并写一个按钮修改绑定的颜色变量,验证 update 是否被触发。

  2. v-loading 指令:把笔记中的 .loading 蒙层改造成自定义指令,语法为 v-loading="isLoading",要求 isLoading 为 true 时加类、为 false 时移除类(提示:inserted 和 update 里都要处理,注意初始值的判断)。再用一个按钮手动切换验证效果。

  3. 插槽三连:封装一个 MyCard 组件,要求:① 默认插槽放正文;② 具名插槽 header 放标题,不传时显示后备内容"默认标题";③ 作用域插槽把组件内部的 list 数据回传给父组件,父组件用 #default="{ list }" 自行渲染成 ul。

  4. 表格 + 接口:用 el-table 渲染面经列表,要求:data 初始为空数组;在 created 中调接口,把 res.data.data.rows 存入 list、res.data.data.total 存入 total;接口函数支持 current / pageSize 两个参数,并在页面上展示"共 X 条记录"。

  5. 图表联调:在 mounted 中初始化 ECharts 折线图(只写空 series),在 created 中请求接口,拿到数据后用 map 提取日期数组和两组数值数组,再通过 setOption 更新图表。要求能口述这段代码的真实执行顺序。

原视频对照 ​

#视频时长
1反馈和回顾.mp41266.txt.mp412:22
2反馈和回顾12:45
3在线配置echarts图表13:02
4页面创建echarts图表10:45
5获取接口数据并应用到图表20:00
6表格的使用12:11
7获取面经列表数据11:23

小结 ​

  1. 自定义指令的本质是"可复用的 DOM 操作封装",凡是靠 querySelector 反复做的同一件事,都可以考虑抽成指令。
  2. inserted 管首次、update 管变化——只要指令的值可能被修改,两个钩子就必须成对出现,这是最容易漏的一环。
  3. 插槽解决的是"结构由使用者决定",slot 是占位符,后备内容只在使用者什么都不传时兜底;作用域插槽则是把组件内部数据"反向"交给使用者。
  4. el-table 是"数据驱动列"的结构::data 决定有哪些行,el-table-column 的 prop 决定每列取哪个字段,二者任一拼错都表现为"空白"而非报错,排查时要先对照字段名。
  5. ECharts 的使用可固化为三步:init(容器) → setOption(配置) → 数据变化时再次 setOption,其中第一步必须在 mounted 之后。
  6. 异步请求与生命周期的关系要按"事件循环"理解,而不是按代码书写顺序:created 里 await 之后的代码会排到 mounted 之后执行,因此"先建图表、后填数据"是必然结果,不是 bug。
  7. 接口函数一律加参数(current、pageSize 等),即使当前用不上——这样分页、筛选接进来时只需改调用处,不用回头改封装。
  8. 遇到"看起来没错却不生效"的情况,先刷新页面再排查;真正的报错八成集中在导入路径、包名拼写、字段名不一致这三类,读报错时优先看"谁调用了谁"。