Skip to content

自定义指令、插槽与路由入门(一) ​

本页对应 自定义指令、插槽与路由入门,共 7 段视频,约 1 小时 43 分。

学习目标 ​

  1. 能够使用对象格式为组件 props 配置 required、default、type 校验,并说明数组/对象默认值必须写成工厂函数。
  2. 能够将 v-model 拆解为 :value 与 @input 的组合,并用原生输入框手动实现双向绑定。
  3. 能够为自定义输入框组件实现 v-model,在子组件中接收 value 并通过 $emit('input', 新值) 回传数据。
  4. 能够为自定义下拉框组件实现 v-model,使用 change 事件触发自定义 input 事件并回传选中值。
  5. 能够解释 .sync 修饰符等价于“属性绑定 + @update:属性名 事件”,并完成对话框显隐控制的父子双向同步。
  6. 能够区分 v-model 与 .sync 的命名约束和适用场景,并总结子组件配合实现双向绑定的关键代码。

知识点 ​

说明:本节材料范围 ​

给定笔记列的是“学习目标”:自定义指令、插槽、商品列表、路由入门。但转写稿实际展开的是组件通信进阶:props 完整校验、v-model 原理、组件上的 v-model、sync 修饰符。为避免编造,正文只扩写转写稿中可验证的内容;自定义指令、插槽、商品列表、路由在转写稿中尚未展开,保留为后续目标。

props 完整校验:为什么需要 ​

父组件给子组件传值,类型写错、必传项漏传时,如果只写数组式 props,错误往往到使用处才暴露。转写稿的推导是:先能接收,再对接收的数据做约定。约定包括必填 required、默认值 default、类型 type。

props 完整校验:怎么想与怎么做 ​

把 props 从数组改成对象,每个要接收的字段再写配置对象:

js
props: {
  str: {
    required: true
  },
  age: {
    type: Number,
    default: 100
  },
  list: {
    type: Array
  },
  user: {
    type: Object,
    default: () => ({})
  },
  c: {
    type: Function
  }
}

调试过程:父组件删掉 str 后,控制台报 Missing required prop: "str",说明 required: true 生效;把 age 传成字符串 "20",报期望 Number 但得到 String;user 默认值直接写 {} 会报错,因为对象/数组默认值必须用工厂函数返回。type 可写 [Number, String] 表示允许多种类型;只校验类型时也可简写 list: Array。

易错点:type: Number 不要加引号;required 和 default 通常二选一;对象/数组默认值必须写成函数;改完错误要刷新页面,旧错误不会自动清空。

v-model 的本质:为什么需要 ​

需求是不用 v-model 实现输入框双向绑定。转写稿先拆一条线:数据变,页面变;再补另一条线:输入框变,数据变。由此得到结论:v-model 相当于 :value 加 @input。

html
<input :value="username" @input="handleInput">
js
methods: {
  handleInput(e) {
    this.username = e.target.value
  }
}

怎么想::value 负责把数据写到表单元素;@input 在输入时把 e.target.value 写回数据。易错点:@input 里要取事件对象;如果看到 :value 与改同一数据的 @input 同时出现,就可合并成 v-model。

组件上的 v-model:为什么需要 ​

给自定义组件写 v-model 时,直接写不一定生效。转写稿的思路是:先拆成 :value 和 @input,分别按“父传子”和“子传父”实现,再换回 v-model。父组件:

html
<MyInput v-model="msg"></MyInput>

等价于:

html
<MyInput :value="msg" @input="msg = $event"></MyInput>

子组件必须接收 value,并在输入时触发 input:

js
props: ['value'],
methods: {
  handleInput(e) {
    this.$emit('input', e.target.value)
  }
}

易错点:子组件接收的属性名必须是 value,自定义事件名必须是 input,否则不能换回 v-model;下拉框子组件内部可用 @change,但对外仍要 $emit('input', ...),父组件仍用 @input 接收。

sync 修饰符:为什么需要 ​

对话框、抽屉等组件常通过布尔值控制显示隐藏。父组件希望把 visible 传下去,子组件点关闭时再改回来。普通写法要写“父传子 + 子传父”。sync 把这两步合成一个修饰符:

html
<MyDialog :visible.sync="visible"></MyDialog>

它等价于:

html
<MyDialog :visible="visible" @update:visible="visible = $event"></MyDialog>

子组件接收 visible,关闭时触发固定格式事件:

js
props: ['visible'],
methods: {
  close() {
    this.$emit('update:visible', false)
  }
}

调试过程:初始 visible: false,对话框隐藏;点“删除”把 visible 改成 true,对话框显示;点叉调用 close,触发 update:visible,父组件数据变 false,对话框隐藏。易错点:事件名必须是 update: 加属性名;属性名可自定义,但父子两边要一致;它和 v-model 一样需要子组件配合,不是父组件单方面能完成。

学习目标中的后续项 ​

自定义指令、插槽、商品列表、路由入门在转写稿中只作为后续任务出现,未给出具体语法与调试过程,本文不补写细节。后续学习时可沿用本节方法:先明确“为什么需要”,再拆成可验证的小步骤,最后用控制台报错和页面效果双向验证。

从内置指令到自定义指令 ​

Vue 已经提供了不少内置指令,比如 v-html、v-if、v-bind、v-on 等,日常开发中可以直接使用。但内置指令不可能覆盖所有 DOM 操作场景。转写稿里提到一个很直白的动机:如果有些指令不够用,“我想自己造一个指令,我来一个 v-hello 行不行?”——可以,这就是自定义指令。

所以,自定义指令的概念是:自己定义的指令,可以封装一些 DOM 操作,扩展额外功能。它不是替代组件,也不是替代数据绑定,而是把某类重复的、偏底层的 DOM 行为收拢成一个指令,在模板里用 v-指令名 使用。

自定义指令解决什么问题 ​

为什么需要它?因为像“页面加载后让输入框自动聚焦”这类需求,本质是对 DOM 元素做操作。如果每个页面都写一遍 ref 再 focus(),代码会重复,也不好复用。自定义指令的思路是:把 DOM 操作封装到指令的钩子函数里,让模板只负责声明“我要这个能力”。

怎么想?先判断这是不是 DOM 操作;再判断这个操作是否会在多个地方复用;最后决定注册成全局指令还是局部指令。怎么做?注册指令,然后在模板中使用。易错点是:注册时指令名不带 v-,使用时必须带 v-。

全局注册与局部注册 ​

全局注册写在 main.js 中,注册后所有组件都能用:

js
//在main.js中
Vue.directive('指令名', {
  "inserted" (el) {
    // 可以对 el 标签,扩展额外功能
    el.focus()
  }
})

局部注册写在 Vue 组件的配置项中,只对当前组件生效:

vue
//在Vue组件的配置项中
directives: {
  "指令名": {
    inserted () {
      // 可以对 el 标签,扩展额外功能
      el.focus()
    }
  }
}

使用指令时,语法是 v-指令名。例如:

vue
<input type="text" v-focus />

这里有一个关键规则:注册指令时不用加 v- 前缀,但使用时一定要加 v- 前缀。并且一定要先注册,再使用,否则会报错。全局注册适合多处复用的指令;局部注册适合只在当前组件使用的指令。

指令配置项 inserted 与 el ​

自定义指令对象里可以写钩子函数。本节重点是 inserted:被绑定元素插入父节点时调用的钩子函数。也就是说,指令绑定的那个 DOM 元素已经插入页面后,inserted 会执行。

el 表示“使用指令的那个 DOM 元素”。因此 el.focus() 就是对使用指令的元素调用聚焦方法。为什么放在 inserted 里?因为元素插入父节点后,才更适合做聚焦这类 DOM 操作。

易错点:上面局部注册示例中,inserted () 没有接收 el 形参,但函数体里却写了 el.focus(),照抄会报错。实际写法应写成 inserted (el) { el.focus() }。这不是额外知识,而是读代码时必须注意的形参对应关系。

代码示例:页面加载自动聚焦 ​

需求:当页面加载时,让元素获取焦点。笔记特别提示:autofocus 在 Safari 浏览器有兼容性,所以这里用自定义指令实现。

App.vue:

vue
  <div>
    <h1>自定义指令</h1>
    <input v-focus ref="inp" type="text">
  </div>

对应指令可以全局注册:

js
Vue.directive('focus', {
  inserted (el) {
    el.focus()
  }
})

也可以局部注册在当前组件:

vue
directives: {
  focus: {
    inserted (el) {
      el.focus()
    }
  }
}

调试时先确认指令是否注册成功,再看模板中是否写成 v-focus,最后检查 inserted 是否接收到 el。如果页面报“指令未定义”一类错误,优先检查“先注册再使用”和“使用加 v-”这两条。

使用步骤与易错点 ​

使用自定义指令可以归纳为两步:第一步注册指令,第二步在模板中使用 v-指令名。注册分全局和局部;使用时要加 v-。

易错点再强调一遍:注册名不带 v-;使用名必须带 v-;局部注册的 inserted 要接收 el;inserted 的时机是元素插入父节点时;示例中的自动聚焦是为了规避 autofocus 在 Safari 的兼容性问题。

小结 ​

自定义指令的作用是:封装一些 DOM 操作,扩展额外功能。使用步骤是:先注册,再使用。注册时不加 v-,使用时加 v-。本节用 v-focus 让输入框在页面加载后获取焦点,核心配置项是 inserted,核心参数是 el。

从“指令不够用”说起:为什么需要自定义指令的值 ​

前面我们已经用过一批内置指令,比如 v-if、v-for、v-model。它们覆盖了常见场景,但真实开发里总会出现“内置指令干不了、又反复要写同一段 DOM 操作”的情况。转写稿里举的例子很直白:既然内置指令不够用,那能不能自己造一个指令,比如 v-hello?可以,这就是自定义指令的出发点。

本节的需求很小:做一个 color 指令,传入不同的颜色,给标签设置文字颜色。

  • 为什么需要:如果不用指令,给两个标题分别设颜色,你得在 mounted 里用 ref 拿到两个元素再 style.color = ...,元素一多就是重复劳动。把它封装成指令,模板里写一句 v-color="color1" 就够了。
  • 怎么想:这个指令不是“死”的红色或蓝色,颜色要由使用方决定。所以指令必须能接收一个值,而不是把颜色写死在指令内部。
  • 怎么做:在绑定指令时用“等号”把值传进去,指令内部通过 binding.value 取出来用。

语法:等号传值,binding.value 取值 ​

绑定指令时,可以通过“等号”的形式为指令绑定具体的参数值:

html
<div v-color="color">我是内容</div>

注意这里的 color 是 data 里的变量(或表达式),不是字符串字面量 'color'。指令对象里通过 binding.value 就能拿到这个值:

js
directives: {
  color: {
    inserted (el, binding) {
      el.style.color = binding.value
    },
    update (el, binding) {
      el.style.color = binding.value
    }
  }
}
  • el:指令所在的那个真实 DOM 元素,改样式就是改它。
  • binding.value:等号后面那个值。指令值修改会触发 update 函数,这是本节最关键的结论。

为什么必须再写一个 update:inserted 只跑一次 ​

很多人第一次写自定义指令只写 inserted,页面初始颜色是对的,于是就以为完事了。问题在于:inserted 只在元素被插入父节点时执行一次。之后数据变了,指令的值虽然变了,但 inserted 不会重跑,颜色就“卡”在第一次的值上。

  • 怎么想:既然值会变,就得有一个“值变了要重新执行”的钩子,这就是 update。
  • 怎么做:把设置颜色的逻辑在 inserted 和 update 里各写一遍。初始渲染走 inserted,后续值变化走 update,两条路都覆盖到。
  • 易错点:只写 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>

运行后,第一个 h2 是红色,第二个是蓝色——这说明 binding.value 确实分别拿到了 color1 和 color2,同一个指令被复用了两次,行为却不同,这正是“指令值”的意义。

用按钮验证 update:动手补一步 ​

笔记里的按钮写着“改变第一个h1的颜色”,但还没有绑定点击事件,点了没反应。要亲眼看到 update 被触发,需要给它补上 @click,在方法里修改 color1:

html
<button @click="color1 = 'green'">改变第一个h1的颜色</button>

(也可以写成 @click="changeColor",在 methods 里做 this.color1 = 'green'。)

点一下按钮,第一个标题变绿——页面并没有重新创建这个 h2,inserted 不会再执行,是 update 把新值写进了 el.style.color。这一步是理解“指令值修改触发 update”的最好验证。

易错点小结 ​

  1. 等号后面写错:v-color="red" 里的 red 会被当成变量去 data 里找,找不到就报错。想传字面量要写 v-color="'red'",但通常我们传的就是 data 里的变量。
  2. binding.value 拼错:是 binding.value,不是 binding.val、binding.values。
  3. 漏写 update:初始颜色对、动态改颜色失效,这是最常见的坑。
  4. 把指令写在 methods 里:自定义指令要放在组件的 directives 选项中(局部指令),指令名 color 在模板里使用时写成 v-color。
  5. 两个 h2 共用一个指令对象:指令对象是共享的,不要在里面用 this.xxx 存“当前元素”这类状态,每个元素该拿到什么值,全靠 binding.value 区分。

把这一节吃透,后面遇到“某个 DOM 行为要在多个元素上重复做、而且依赖外部传入的值”时,你就会自然地想到:抽成一个带值的自定义指令。

场景:请求未返回时页面会空白 ​

为什么需要:实际开发中发送请求需要时间,数据没回来时页面没有内容,用户只看到空白,体验不好。
怎么想:既然等待不可避免,就要给用户一个“正在加载”的视觉反馈,而不是让页面无反应。转写稿里提到“有些指令不够用,可以自己造一个指令”,v-loading 就是这种复用型指令。
怎么做:请求开始时显示 loading,请求结束后关闭 loading;先别急着写指令,先把“蒙层类”的效果调出来。
易错点:转写稿中“自定义质量、纸量、指量”都是“自定义指令”的同音误写;调试时先看控制台报错开头,定位是哪个属性/方法未定义,改完刷新页面,避免旧错误干扰。

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

为什么需要:如果每个请求都手写“加类、删类”,代码会重复,还容易漏掉关闭 loading。
怎么想:把“根据布尔值给元素添加/移除 loading 类”这件事抽成指令,页面只关心 isLoading 的真假。
怎么做:元素上使用 v-loading="isLoading";指令内部根据值切换类名。笔记本节只写到“结合自定义指令的语法进行封装复用”,所以应先验证类名开关,再进入指令封装。
易错点:指令名是 v-loading,不要再写成 v-v-loading;绑定的是布尔值,不是字符串;父容器必须有定位,否则蒙层铺不满。

分析:loading 本质是蒙层 ​

为什么需要:加载效果不是把列表删掉,而是在原区域上盖一层,告诉用户“这里正在加载”。
怎么想:蒙层要覆盖盒子,最直接的是绝对定位的伪元素,宽高 100%,背景图居中。
怎么做:准备 .loading:before,设置 content:""、position:absolute、left/top:0、width/height:100%,背景用 loading.gif 居中。
易错点:只写 absolute 不写父级 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;
}

为什么需要:先把类效果单独验证,后面指令只是“加类/去类”,问题范围会小很多。
怎么想:loading.gif 是加载中动图,白底盖住原内容,动图居中提示加载。
怎么做:给 .box 加 position: relative,需要 loading 时给盒子加 loading 类。
易错点:笔记中 .box2 也是定位示例;若蒙层被内容遮挡,先检查定位和层叠关系,不要盲目复制代码。

准备代码:请求模拟与列表渲染 ​

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>

为什么需要:用真实请求加 setTimeout 模拟延迟,才能观察到“空白—加载—渲染”的过程。
怎么想:created 里发请求,数据回来后赋给 list;isLoading、isLoading2 为后续指令开关预留。
怎么做:请求前把 loading 状态置真,请求完成后再置假;笔记代码用 2 秒延迟放大等待效果。
易错点:async created 中 await 后仍要注意 this 指向;真实项目建议用 try/finally 保证请求失败也能关闭 loading。

封装思路:把“加类/去类”交给指令 ​

为什么需要:loading 类效果已经验证,下一步才是复用。
怎么想:自定义指令的核心不是重写蒙层,而是接收布尔值,在元素上执行 classList.add/remove('loading')。
怎么做:先注册指令,再在盒子上写 v-loading="isLoading";值变化时同步切换类。笔记没有展开具体指令注册代码,学习时不要跳过“先手动加类验证”的步骤。
易错点:不要把 v-loading 写成普通属性;布尔值变化后要能触发更新;指令封装后仍要保证父容器 position: relative。

1. 默认插槽的作用:让组件内部结构可自定义 ​

默认插槽作用示意

为什么需要:封装组件时,有些结构是固定的,比如对话框的标题栏、底部按钮;但中间内容区往往不确定。今天提示“您确定要进行删除操作吗?”,明天可能提示“确认退出吗?”。如果全部写死在组件里,组件复用性就差。

怎么想:父传子 props 更适合传“数据”,比如字符串、数字、布尔值;如果父组件想传的是一段“结构”,例如一段文字、一个 div、一组按钮,就应该用插槽。转写稿里也提到:传数据用 props,传 div 里带按钮这种模板结构,用插槽更合适。

怎么做:在子组件中,把需要定制的结构位置改成 <slot></slot> 占位;使用组件时,在 <MyDialog></MyDialog> 标签内部写内容,替换 slot。

易错点:slot 要写在子组件模板里;传入内容要写在组件标签内部,不是写在 <slot> 标签上;默认插槽不需要写 name。

2. 需求与问题:对话框内容不能写死 ​

为什么需要:笔记中的需求是把需要多次显示的对话框封装成组件。对话框外壳可以复用,但内容部分不希望写死。

怎么想:先观察 MyDialog.vue,标题、关闭按钮、底部按钮相对固定,中间 .dialog-content 的提示语最可能变化。这个位置就是“不确定结构”。

怎么做:保留原有对话框结构,只把 .dialog-content 内部的固定文案替换为 <slot></slot>。使用时再决定这里显示什么。

易错点:不要因为要自定义内容,就把整个对话框结构都删掉;插槽只替换占位处,组件其他固定结构仍然保留。

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

默认插槽语法示意

为什么需要:需要一套固定写法,让子组件知道“哪里留空”,父组件知道“内容往哪里放”。

怎么想:第一步在子组件需要定制的结构部分用 <slot></slot> 占位;第二步在使用组件时,在 <MyDialog></MyDialog> 标签内部传入结构;第三步理解传入内容可以是纯文本、HTML 标签、组件。

怎么做:可把 MyDialog.vue 的内容区改为:

vue
<div class="dialog-content">
  <slot></slot>
</div>

然后在 App.vue 中使用:

vue
<MyDialog>
  您确定要进行删除操作吗?
</MyDialog>

也可以传 HTML 标签或组件:

vue
<MyDialog>
  <p>您确定要进行删除操作吗?</p>
</MyDialog>

易错点:如果只把子组件改成 <slot></slot>,但父组件标签内部不传内容,该区域就没有内容;传入内容要放在组件标签内部,而不是写成普通属性。

4. 代码示例:保留原始对话框与使用代码 ​

为什么需要:通过完整代码看清插槽改哪里、组件怎么用。

怎么想:先保留原始 MyDialog.vue 结构,理解 .dialog-content 是待定制区域;再在 App.vue 中导入、注册、使用组件。

怎么做:原始代码如下,学习时可在 .dialog-content 中用 <slot></slot> 替换固定文案。

MyDialog.vue

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>

易错点:示例 App.vue 中 <MyDialog></MyDialog> 内部为空,实际演示默认插槽时要在标签内部补上要显示的结构;组件必须先导入并注册。

5. 总结:什么时候用、步骤是什么 ​

为什么需要:把插槽和 props 的适用场景区分开,避免混用。

怎么想:组件内某一部分结构不确定、想自定义时,用插槽;如果只是传数据,用 props。

怎么做:组件内用 <slot></slot> 占位;使用组件时在标签内部传入结构;传入内容可以是纯文本、HTML 标签、组件。

易错点:默认插槽的核心是“结构替换”,不是“数据传递”;不要忘记在父组件使用处真正传入内容。

先看问题:不传内容时为什么是空白 ​

插槽解决的是“组件结构由父组件决定”的问题。转写稿里说得很直白:封装组件时,传数据用 props,传模板结构就用插槽;父组件往组件标签里放什么结构,子组件预留的位置就显示什么结构。可如果父组件什么都不放,子组件里的 <slot> 就没有内容可渲染,页面自然出现空白。

插槽未传内容时显示空白

为什么需要后备内容?因为封装组件时,有些位置希望“父组件愿意传就按父组件的来,不传也别空着”。比如对话框组件预留一个内容区,父组件不传时,最好能显示一句默认提示。怎么想?把 <slot> 理解成一个占位位置:父组件传内容,就替换占位;父组件不传,就用占位里预先写好的内容兜底。怎么做?在子组件的 <slot> 标签内部放置默认内容。易错点是:后备内容必须写在 <slot> 标签内部,不是写在 <slot> 外面,也不是写成组件属性。

后备内容的含义 ​

封装组件时,可以为预留的 <slot> 插槽提供后备内容,也就是默认内容。它的作用不是替代 props,而是给“没有传入结构”的插槽一个默认显示结果。props 的默认值管的是数据,插槽的后备内容管的是模板结构,两者不要混在一起。

在 slot 标签内写默认内容

从调试角度看,遇到插槽空白时,应按父子两端排查:先看父组件有没有在组件标签的开始和结束之间写内容;再看子组件有没有在 <slot> 内部写后备内容;最后看默认内容是不是误写到了 <slot> 外面。这样排查比盯着页面猜更直接。

语法:默认内容放在 slot 标签内部 ​

语法很简短:在 <slot> 标签内放置内容,作为默认显示内容。这个内容可以是文本,也可以是其他模板结构。父组件不传内容时,<slot> 就用标签内部的内容;父组件传了内容时,插槽位置就显示父组件传入的内容。

在 slot 标签内放置默认内容

为什么这样设计?因为插槽的本质是“预留位置”,后备内容就是“没人填时先放这里”的默认结构。怎么做?在子组件模板中写成 <slot>默认内容</slot> 的形态。易错点:不要把默认内容写在 <slot></slot> 后面,那样它不会成为插槽的后备内容;也不要把默认内容当成 props 默认值来理解。

效果:不传用默认,传了整体替换 ​

效果要分两种情况看。第一种,外部使用组件时不传东西,slot 会显示后备内容。第二种,外部使用组件时传东西了,slot 整体会被换掉,显示父组件传入的内容。

不传内容时显示后备内容

传入内容后替换后备内容

为什么需要强调“整体替换”?因为插槽不是把传入内容和默认内容拼接,而是父组件给了内容后,插槽位置就渲染传入内容。怎么想?把后备内容看成“默认方案”,传入内容看成“覆盖方案”。怎么做?父组件在组件标签内部写内容;子组件用 <slot> 接收。易错点是:组件标签如果写成自闭合形式,就没有地方放传入内容;想传内容时要写成开始标签和结束标签成对出现。

代码示例怎么读 ​

笔记中的 App.vue 同时演示了“不传”和“传”的对比:

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> 没有在标签内部放内容,所以它会走子组件 <slot> 里的后备内容;第二个 <MyDialog>你确认要退出么</MyDialog> 在标签内部传入了文本,所以插槽位置显示“你确认要退出么”。读这段代码时要注意:MyDialog.vue 内部需要预留 <slot>,并把默认内容放在 <slot> 标签内部,父组件传入的内容才有位置被接收。调试时先确认父组件是否真的在组件标签内部写了内容,再确认子组件 <slot> 内部是否有后备内容,最后确认内容没有误写到属性位置或标签外部。

小结与检查清单 ​

后备内容解决的是“插槽不传就空白”的问题。记住四句话:不传内容,显示 <slot> 内部的后备内容;传入内容,插槽整体替换为传入内容;后备内容写在 <slot> 标签内部;props 默认值和插槽后备内容不是一回事。检查时按“父组件有没有传、子组件 slot 内有没有默认内容、内容位置对不对”三步走,就能定位大多数插槽默认显示问题。

课件图示 ​

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

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

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

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

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

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

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

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

代码示例 ​

示例一:自定义指令基础 + 指令的值(v-focus / v-color) ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>自定义指令 - v-focus 与 v-color</title>
  <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
</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>
    // 1. 全局注册 v-focus:注册时不用加 v- 前缀
    Vue.directive('focus', {
      inserted (el) {
        // el 就是使用指令的那个 DOM 元素
        el.focus()
      }
    })

    // 2. 全局注册 v-color:通过 binding.value 拿到指令的值
    Vue.directive('color', {
      inserted (el, binding) {
        el.style.color = binding.value
      },
      // 指令的值修改会触发 update,所以这里必须再写一次
      update (el, binding) {
        el.style.color = binding.value
      }
    })

    new Vue({
      el: '#app',
      data () {
        return {
          color1: 'red',
          color2: 'blue'
        }
      },
      // 局部注册的写法(写在组件配置项里,效果相同)
      // directives: {
      //   color: {
      //     inserted (el, binding) { el.style.color = binding.value },
      //     update (el, binding) { el.style.color = binding.value }
      //   }
      // }
    })
  </script>
</body>
</html>

关键行与易错点

  • Vue.directive('focus', {...}):注册时不加 v-,使用时必须加 v-;先注册再使用,否则报错。
  • inserted(el):被绑定元素插入父节点时调用;el 就是使用指令的那个 DOM 元素。
  • binding.value:拿到指令等号后面绑定的值;只写 inserted 不够,指令值变化走 update,两个钩子都要写,否则点击按钮改 color1 时颜色不更新。
  • 用 v-focus 而不是原生 autofocus:autofocus 在 Safari 有兼容性问题。
  • 局部注册写在组件的 directives 配置项里,语法与全局一致,只是不挂到 Vue.directive 上。

示例二:v-loading 指令封装 ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>自定义指令 - v-loading 封装</title>
  <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
  <style>
    /* 1. 用伪元素做蒙层:定位 + 宽高铺满父盒子 */
    .loading:before {
      content: '';
      position: absolute;
      left: 0;
      top: 0;
      width: 100%;
      height: 100%;
      background: #fff url('./loading.gif') no-repeat center;
    }

    .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>
</head>
<body>
  <div id="app">
    <div class="main">
      <!-- 用指令值控制蒙层的添加 / 移除 -->
      <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>
            <div class="right">
              <img :src="item.img" alt="">
            </div>
          </li>
        </ul>
      </div>
    </div>
  </div>

  <script>
    // 开启 / 关闭 loading,本质就是添加 / 移除类名
    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
        const res = await axios.get('http://hmajax.itheima.net/api/news')

        setTimeout(() => {
          // 2. 数据回来,更新 list 用于 v-for 渲染
          this.list = res.data.data
          // 3. 请求完毕,关闭 loading 状态
          this.isLoading = false
        }, 2000)
      }
    })
  </script>
</body>
</html>

关键行与易错点

  • 蒙层实现是「添加 / 移除类名」,不是自己造 DOM;classList.add('loading') 就是开启,remove 就是关闭。
  • 指令同样需要 inserted + update 两个钩子:首次渲染走 inserted,isLoading 变化走 update;只写 inserted 会导致请求回来后蒙层不消失。
  • .box 必须写 position: relative,否则 .loading:before 的 position: absolute 会相对视口定位,蒙层跑到页面左上角。
  • .loading:before 的 width/height: 100% 是相对父盒子撑满,所以父盒子必须有确定宽高(这里 min-height: 500px)。
  • background 里的 ./loading.gif 需要放在与 HTML 同级目录下,否则只看到白色遮罩没有转圈图标。
  • 接口地址 http://hmajax.itheima.net/api/news、请求方式 get、返回数据在 res.data.data。

示例三:默认插槽 + 后备内容(MyDialog 组件) ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>插槽 - 默认插槽与后备内容</title>
  <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
  <style>
    * { margin: 0; padding: 0; }
    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">
    <!-- 不传内容:显示 slot 里的后备内容 -->
    <my-dialog></my-dialog>

    <!-- 传内容:slot 整体被替换掉 -->
    <my-dialog>
      你确认要退出么
    </my-dialog>
  </div>

  <script>
    Vue.component('MyDialog', {
      template: `
        <div class="dialog">
          <div class="dialog-header">
            <h3>友情提示</h3>
            <span class="close">✖️</span>
          </div>

          <div class="dialog-content">
            <!-- 1. 需要定制的结构用 slot 占位 -->
            <!-- 2. slot 标签内放的内容就是“后备内容”(默认值) -->
            <slot>您确定要进行删除操作吗?</slot>
          </div>

          <div class="dialog-footer">
            <button>取消</button>
            <button>确认</button>
          </div>
        </div>
      `
    })

    new Vue({
      el: '#app'
    })
  </script>
</body>
</html>

关键行与易错点

  • 三步走:组件内把不确定的结构换成 <slot></slot> 占位 → 使用组件时在 <MyDialog>标签内部</MyDialog> 传入结构 → 传入的内容会替换掉整个 slot。
  • 后备内容写在 <slot>...</slot> 标签内部:不传内容时显示它,传了内容则 slot 整体被替换(不是追加)。
  • 传入的内容可以是纯文本、HTML 标签、甚至是另一个组件。
  • 组件名注册为 MyDialog,在模板里写成 <my-dialog> 是 kebab-case 写法,Vue 会自动匹配,两者不要写错大小写导致识别不到。
  • 组件模板用字符串时用反引号 `,因为模板是多行 HTML,用单引号会报错。
  • 插槽只解决「结构自定义」;要往组件里传数据仍然用 props,两者不要混用。

常见错误 ​

  1. 现象:自定义指令不生效,控制台提示指令未注册。
    原因:注册时写了 v-focus,或使用时漏写 v-。
    修法:注册名写 focus,模板中使用 v-focus。

  2. 现象:v-color 初始颜色正常,数据修改后颜色不变。
    原因:只写了 inserted,没有处理指令值更新。
    修法:补充 update,统一通过 binding.value 设置。

  3. 现象:v-loading 蒙层不显示或盖错位置。
    原因:目标容器没有定位,伪元素 absolute 失去参照;或类名未正确切换。
    修法:给目标容器加 position: relative,指令只负责添加/移除 loading 类。

  4. 现象:组件标签内写了内容,但页面不显示。
    原因:子组件内部没有用 <slot></slot> 占位。
    修法:在需要定制的位置预留 slot,使用组件时在标签内部传入结构。

  5. 现象:具名插槽内容跑到默认插槽,或作用域插槽拿不到数据。
    原因:slot name 与 #name 不一致;子组件未通过 <slot :row="item"> 绑定数据。
    修法:统一插槽名,父组件用 <template #name="scope"> 接收作用域数据。

  6. 现象:props 校验中对象/数组默认值报错。
    原因:对象或数组的 default 直接写了字面量。
    修法:改为工厂函数,如 default: () => ({})、default: () => []。

  7. 现象:组件上写 v-model 后只能父传子,子组件修改不回父组件。
    原因:子组件未接收 value,或未 $emit('input', 新值)。
    修法:按 :value + @input 的等价关系补齐子组件接收与派发。

  8. 现象:.sync 修饰符使用时父组件数据不更新。
    原因:子组件派发的事件名不是 update:属性名。
    修法:事件名固定为 update:visible 这类格式,并与父组件属性名一致。

练习 ​

  1. 封装 v-focus 与 v-color 两个自定义指令,要求 v-color 在指令值变化后页面颜色同步更新。

  2. 封装 v-loading 指令,用一个布尔值控制新闻列表容器的加载蒙层显示与隐藏。

  3. 用默认插槽、具名插槽、作用域插槽封装一个 MyTable 组件,支持表头自定义、单元格自定义,并能拿到当前行数据。

  4. 实现一个输入框组件的 v-model:父组件传入值可显示,子组件输入后父组件数据同步更新;再说明它等价于哪两个绑定。

  5. 用 .sync 修饰符实现对话框显示隐藏控制,并写出它等价展开后的属性绑定和事件监听。

原视频对照 ​

#视频时长
1今日内容介绍05:29
2props完整校验27:30
3v-model相当于value属性和input事件10:07
4实现输入框组件的v-model15:11
5实现下拉框的v-model11:18
6sync修饰符的原理17:35
7v-model和sync修饰符小结16:12

小结 ​

  1. 自定义指令适合封装可复用的 DOM 行为,注册名不带 v-,使用时必须带 v-。
  2. 指令值通过 binding.value 获取,值会变化时不能只写 inserted。
  3. v-loading 的核心是“父容器定位 + 类名开关”,指令负责复用开关逻辑。
  4. 插槽解决组件结构可定制问题;默认、具名、作用域插槽分别对应单区域、多区域、子组件向插槽传数据。
  5. props 校验中 required 与 default 通常二选一,对象/数组默认值必须用工厂函数。
  6. 组件 v-model 的本质是 :value + @input,需要子组件接收 value 并派发 input。
  7. .sync 的本质是 :属性 + @update:属性,事件名必须严格匹配。
  8. 路由入门先抓住三件事:路径映射组件、router-view 提供出口、router-link 完成跳转。