Skip to content

自定义指令(二) ​

本页对应 自定义指令,共 6 段视频,约 1 小时 8 分。

学习目标 ​

  • 能够使用表格组件完成商品列表的动态渲染,正确绑定 list 数据,并配置标题、作者、点赞、浏览量、更新日期、操作等列及列宽。
  • 能够使用默认插槽和作用域插槽自定义表格最后一列,接收 row 数据并获取当前行 ID,为查看、编辑、删除操作绑定事件。
  • 能够配置分页组件的完整功能,绑定当前页、每页条数、总条数和布局,并实现页码或每页条数变化时重新请求列表数据。
  • 能够使用抽屉组件控制显示与隐藏,配置标题、打开方向、尺寸及关闭前回调,实现添加、预览、编辑共用同一个抽屉。
  • 能够通过传参区分添加、预览、编辑操作类型,并使用计算属性动态生成对应的抽屉标题。
  • 能够说明具名插槽、作用域插槽及 v-model 在 MyTag、MyTable 组件封装中的作用,并完成组件抽离、显示隐藏控制与数据动态渲染。

知识点 ​

从默认插槽的局限说起 ​

为什么需要: 一个组件内部可能有多处结构都要由外部传入标签来定制。笔记里的弹框就是典型:弹框中有三处不同位置都需要外部决定内容,但默认插槽只能定制一个位置,无法同时照顾多处。

弹框中三处需要外部定制的结构

怎么想: 既然一个默认插槽不够用,就要给每个可定制位置起一个名字,让父组件能明确说明“这段内容放到哪个位置”。这就是具名插槽的思路:先命名,再按名字分发。

怎么做: 子组件里写多个 <slot>,用 name 区分;父组件用 <template v-slot:名字> 把内容分发到对应插槽。默认插槽可以保留为没有 name 的那个。

html
<!-- 子组件内部:多个插槽用 name 区分 -->
<slot name="header"></slot>
<slot></slot>
<slot name="footer"></slot>

易错点: 不要把多个插槽都写成没有 name 的默认插槽;父组件的 v-slot:名字 必须和子组件 slot name="名字" 完全一致,拼写不一致时内容不会落到预期位置。

具名插槽的语法:先命名,再分发 ​

为什么需要: 默认插槽只能接收一份内容,遇到弹框头部、内容区、底部按钮区都要定制时,必须让插槽有名字,才能分别接收不同标签。

多个 slot 使用 name 属性区分名字

怎么想: 把组件内部“不确定的结构”列出来,每一处定义一个 name;父组件写内容时,不是直接塞进去,而是用 template 包住,并标明 v-slot:对应名字。

template 配合 v-slot:名字分发对应标签

怎么做: 子组件定义具名插槽;父组件用 template 配合 v-slot:名字 分发。没有写 name 的插槽就是默认插槽,父组件中不写 v-slot 的内容会进入默认插槽。

html
<!-- 父组件:按名字分发 -->
<my-dialog>
  <template v-slot:header>
    <h3>标题区</h3>
  </template>

  <p>默认内容区</p>

  <template v-slot:footer>
    <button>确定</button>
  </template>
</my-dialog>

易错点: v-slot 一般写在 <template> 上或组件标签上,不要随便写在普通 div 上;父组件里多个 template 的顺序不等于插槽顺序,真正决定位置的是插槽名。默认插槽也可以显式写成 v-slot:default,但普通内容直接写也能进入默认插槽。

v-slot 的简写与调试过程 ​

为什么需要: v-slot:header 写起来较长,多处使用时不够简洁,Vue 提供了 # 简写。

怎么想: 简写只是把 v-slot: 换成 #,后面的插槽名不变。调试时不要一上来就写复杂结构,可以先放文字占位,确认每个命名插槽到底落在哪里,再替换成真实标签。

怎么做:

html
<template #header>
  <h3>标题区</h3>
</template>

<template #footer>
  <button>确定</button>
</template>

转写稿里处理表格最后一列时,也是先给插槽传一个“删除”文字占位,确认位置生效后再换成图标;当不知道 Element UI 给插槽传什么时,先写方法把参数打印出来,看到 row 后再解构 row.id。这个“先占位、再确认、后替换”的调试过程,同样适用于具名插槽:先在每个命名插槽里放不同文字,确认三处分别落到哪里,再换成真实结构。

易错点: 简写时 # 和插槽名要写清楚,如 #header;不要把 v-slot:header 简写成不存在的写法。名字仍要和子组件 slot name 对应。若发现内容没出现,先检查插槽名,再检查 template 是否写在了正确父级里。

回到需求:三处定制如何落地 ​

为什么需要: 弹框三处不同,如果只用默认插槽,只能定制一个位置,另外两处无法由外部决定。

怎么想: 按弹框结构把需要定制的位置分别命名,例如头部、内容区、底部;实际名称以组件设计为准。父组件用多个 template 按名分发,默认内容放默认插槽。

怎么做: 子组件保留默认插槽并增加具名插槽;父组件用 v-slot:名字 或 #名字 分发。这样同一个组件内多处结构都能被外部定制。

易错点: 不要把所有内容都塞进默认插槽;具名插槽的内容要用 template 包住并写对名字;默认插槽和具名插槽混用时,先确认哪一段进入默认插槽,避免结构错位。

小结 ​

组件内有多处不确定结构时,用具名插槽:多个 <slot> 用 name 区分。语法是子组件写 slot name="名字",父组件用 template v-slot:名字 分发。v-slot:插槽名 可以简写成 #插槽名。调试时先放文字占位确认位置,再替换真实标签,能减少插槽名写错、内容放错位置的问题。

作用域插槽的定位:不是第三种插槽 ​

为什么需要:默认插槽解决“内容放哪”,具名插槽解决“多个位置分别放哪”,但它们都不解决“子组件把当前数据交给父组件写的插槽内容”。封装表格时尤其明显:表格结构在子组件里,操作按钮由使用方决定,而删除按钮又必须拿到当前行 id。

怎么想:插槽内容写在父组件,数据却在子组件。要让父组件拿到子组件数据,就让子组件在 <slot> 上以属性方式“传值”。所以笔记强调:插槽只有默认插槽、具名插槽两种,作用域插槽不属于插槽分类,它是“给插槽绑定数据”的能力。

怎么做:在 slot 标签上添加属性:

vue
<slot :id="item.id" msg="测试文本"></slot>

易错点:不要把作用域插槽说成第三种插槽;不要只写按钮却忘了把当前行 id 传出去。

场景:封装表格组件 ​

为什么需要:表格组件若把“操作”列写死,使用方就无法放查看、编辑、删除等不同按钮。删除还必须知道删哪一行,所以需要“插槽内容由父组件写,行数据由子组件给”。

封装表格组件场景

怎么想:子组件只负责渲染表格和提供当前行数据;父组件通过插槽决定操作列长什么样。子组件把 item.id、msg 等属性挂到 slot 上,Vue 会把它们收集成一个对象,例如:

vue
{ id: 3, msg: '测试文本' }

怎么做:父组件在 template 上用 #插槽名="obj" 接收,默认插槽名为 default:

vue
<MyTable :list="list">
  <template #default="obj">
    <button @click="del(obj.id)">删除</button>
  </template>
</MyTable>

易错点:默认插槽接收名是 default;obj 是接收对象,里面才有 id、msg,不能直接写 obj 当 id 用。

使用步骤:绑定、收集、接收 ​

为什么需要:明确“谁传、传成什么、谁接”。子组件在 slot 上绑定属性;Vue 收集为对象;父组件通过插槽作用域变量接收。

怎么想:先确定父组件需要什么数据,再在子组件 slot 上暴露什么属性。比如删除需要 id,就绑定 :id="item.id"。

怎么做:结合笔记代码,子组件模板中关键位置如下:

vue
<slot :id="item.id" msg="测试文本"></slot>

父组件:

vue
<template #default="obj">
  <button @click="del(obj.id)">删除</button>
</template>

易错点:slot 上的属性名要和接收对象里的属性名对应;父组件插槽内容必须写在组件标签内部,不能写到组件标签外面。

代码示例:MyTable 与 App ​

为什么需要:用完整示例看清“子组件暴露数据、父组件消费数据”的关系。笔记中的 MyTable.vue 负责表格结构,App.vue 传入数据并使用插槽。

怎么做:保留笔记中的核心代码结构,样式部分按原笔记保持不变。

vue
<!-- MyTable.vue -->
<template>
  <table class="my-table">
    <thead>
      <tr>
        <th>序号</th><th>姓名</th><th>年纪</th><th>操作</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td>1</td>
        <td>赵小云</td>
        <td>19</td>
        <td>
          <slot :id="item.id" msg="测试文本"></slot>
        </td>
      </tr>
      <!-- 其余行结构同理,按笔记原样重复 -->
    </tbody>
  </table>
</template>

<script>
export default {
  props: {
    data: Array
  }
}
</script>

<!-- 样式同笔记原样,略 -->
vue
<!-- App.vue -->
<template>
  <div>
    <MyTable :data="list"></MyTable>
    <MyTable :data="list2"></MyTable>
  </div>
</template>

<script>
import MyTable from './components/MyTable.vue'
export default {
  data () {
    return {
      list: [
        { id: 1, name: '张小花', age: 18 },
        { id: 2, name: '孙大明', age: 19 },
        { id: 3, name: '刘德忠', age: 17 },
      ],
      list2: [
        { id: 1, name: '赵小云', age: 18 },
        { id: 2, name: '刘蓓蓓', age: 19 },
        { id: 3, name: '姜肖泰', age: 17 },
      ]
    }
  },
  components: { MyTable }
}
</script>

易错点:笔记示例中 slot 的 id 需要来自当前行数据;实际封装时要保证 slot 在循环行内,能拿到对应 item。

调试推导:不知道插槽传出什么就先打印 ​

为什么需要:真实项目里常封装第三方表格组件,父组件并不确定默认插槽传出的对象结构。转写稿中的调试思路是:先接收,再打印,确认结构后再取值。

怎么想:不知道 obj 长什么样,就写一个方法把它输出。转写稿中打印后发现对象里有 row,row 表示当前行数据,column 表示列;要删除就取 row.id。

怎么做:可先写:

vue
<template #default="obj">
  <button @click="del(obj)">删除</button>
</template>

在方法中打印 obj,确认结构后再改为解构 row,用 row.id。删除方法接收 id,点击不同行就能拿到对应行 id。

易错点:不要凭感觉猜第三方组件传出的字段;先打印确认。转写稿里的“肉”是 row,“colum”是 column,不是数据字段名乱写。作用域插槽的关键就是:子组件把数据绑到 slot 上,父组件通过插槽作用域接收并使用。

需求拆解:先明确 MyTag 要解决什么 ​

商品列表案例效果

为什么需要:商品表格的“标签”列里,每一行都有相同的展示结构和交互需求。如果每行都复制一份 .my-tag,后续改样式、改交互会重复改很多处。抽成 MyTag 组件,是为了让表格只负责渲染数据,标签的显示与编辑交给组件。

怎么想:按笔记需求,MyTag 要覆盖四件事:双击显示输入框并聚焦、失焦隐藏输入框、回显标签信息、输入后回车修改标签信息。也就是说组件内部至少有“展示态”和“编辑态”两种状态;父组件传入标签值,组件在提交时把新值通知父组件。

怎么做:先不要急着写交互,按笔记先创建 MyTag.vue 骨架,把原来表格单元格里的 .my-tag 结构和样式搬进去;再在父组件中引入、注册、替换原来的单元格内容。这样先保证“抽离后页面不变”,再补交互。

易错点:不要一开始把“茶具”“男靴”写死在组件里;input 和展示文本应互斥显示;样式要跟着组件走,尤其是 scoped 下父组件样式不一定作用到子组件内部;回车提交和失焦隐藏要考虑是否会重复触发。

原始表格代码准备:先保留可运行基线 ​

为什么需要:抽组件前要先有一个能渲染的表格基线。否则一旦页面报错,分不清是原表格问题还是组件抽离问题。

怎么想:笔记里的 TableCase 已经给出完整表格结构、goods 数据和样式。转写稿里同一综合案例反复强调“先用调试工具确认数据有没有拿到、变量名有没有对上”,这个调试习惯同样适用于本节:先确认 goods 能正常渲染,再动结构。

怎么做:保留下面的原始代码作为基线。注意笔记中标签列还是普通 div,input 处于注释状态,说明后续才进入编辑态。

vue
<template>
  <div class="table-case">
    <table class="my-table">
      <thead>
        <tr>
          <th>编号</th>
          <th>名称</th>
          <th>图片</th>
          <th width="100px">标签</th>
        </tr>
      </thead>
      <tbody>
        <tr>
          <td>1</td>
          <td>梨皮朱泥三绝清代小品壶经典款紫砂壶</td>
          <td>
            <img src="https://yanxuan-item.nosdn.127.net/f8c37ffa41ab1eb84bff499e1f6acfc7.jpg" />
          </td>
          <td>
            <div class="my-tag">
              <!-- <input 
                class="input"
                type="text"
                placeholder="输入标签"
              /> -->
              <div class="text">
                茶具
              </div>
            </div>
          </td>
        </tr>
        <tr>
          <td>1</td>
          <td>梨皮朱泥三绝清代小品壶经典款紫砂壶</td>
          <td>
            <img src="https://yanxuan-item.nosdn.127.net/221317c85274a188174352474b859d7b.jpg" />
          </td>
          <td>
            <div class="my-tag">
              <!-- <input
                ref="inp"
                class="input"
                type="text"
                placeholder="输入标签"
              /> -->
              <div class="text">
                男靴
              </div>
            </div>
          </td>
        </tr>
      </tbody>
    </table>
  </div>
</template>

<script>
export default {
  name: 'TableCase',
  components: {},
  data() {
    return {
      goods: [
        {
          id: 101,
          picture:
            'https://yanxuan-item.nosdn.127.net/f8c37ffa41ab1eb84bff499e1f6acfc7.jpg',
          name: '梨皮朱泥三绝清代小品壶经典款紫砂壶',
          tag: '茶具',
        },
        {
          id: 102,
          picture:
            'https://yanxuan-item.nosdn.127.net/221317c85274a188174352474b859d7b.jpg',
          name: '全防水HABU旋钮牛皮户外徒步鞋山宁泰抗菌',
          tag: '男鞋',
        },
        {
          id: 103,
          picture:
            'https://yanxuan-item.nosdn.127.net/cd4b840751ef4f7505c85004f0bebcb5.png',
          name: '毛茸茸小熊出没,儿童羊羔绒背心73-90cm',
          tag: '儿童服饰',
        },
        {
          id: 104,
          picture:
            'https://yanxuan-item.nosdn.127.net/56eb25a38d7a630e76a608a9360eec6b.jpg',
          name: '基础百搭,儿童套头针织毛衣1-9岁',
          tag: '儿童服饰',
        },
      ],
    }
  },
}
</script>

<style lang="less" scoped>
.table-case {
  width: 1000px;
  margin: 50px auto;
  img {
    width: 100px;
    height: 100px;
    object-fit: contain;
    vertical-align: middle;
  }

  .my-table {
    width: 100%;
    border-spacing: 0;
    img {
      width: 100px;
      height: 100px;
      object-fit: contain;
      vertical-align: middle;
    }
    th {
      background: #f5f5f5;
      border-bottom: 2px solid #069;
    }
    td {
      border-bottom: 1px dashed #ccc;
    }
    td,
    th {
      text-align: center;
      padding: 10px;
      transition: all 0.5s;
      &.red {
        color: red;
      }
    }
    .none {
      height: 100px;
      line-height: 100px;
      color: #999;
    }
  }
  .my-tag {
    cursor: pointer;
    .input {
      appearance: none;
      outline: none;
      border: 1px solid #ccc;
      width: 100px;
      height: 40px;
      box-sizing: border-box;
      padding: 10px;
      color: #666;
      &::placeholder {
        color: #666;
      }
    }
  }
}
</style>

易错点:表格里的图片地址、goods 字段名不要抄错;scoped 样式只作用于当前组件,抽离后 .my-tag 相关样式要迁到 MyTag.vue,否则可能失效。

抽离 MyTag 组件:结构、样式一起搬 ​

为什么需要:标签列是重复结构,抽成组件后,父组件只传标签值、接收修改事件,职责更清晰。

怎么想:先把“展示结构”搬走,不改变页面效果。笔记中的 MyTag.vue 保留 .my-tag、input 注释和 .text 展示区,并迁移 .my-tag 样式。

怎么做:创建 components/MyTag.vue:

vue
<template>
  <div class="my-tag">
  <!--  <input
      class="input"
      type="text"
      placeholder="输入标签" 
    /> -->
    <div  
      class="text">
       茶具
    </div>
  </div>
</template>

<script>
export default {
 
}
</script>

<style lang="less" scoped>
.my-tag {
  cursor: pointer;
  .input {
    appearance: none;
    outline: none;
    border: 1px solid #ccc;
    width: 100px;
    height: 40px;
    box-sizing: border-box;
    padding: 10px;
    color: #666;
    &::placeholder {
      color: #666;
    }
  }
}
</style>

易错点:此时组件里的“茶具”只是骨架占位,后续必须改为由父组件传入并回显;不要忘记把 cursor: pointer 等样式一起迁移。

父组件接入:引入、注册、替换 ​

为什么需要:组件创建后不会自动可用,必须在父组件中引入并注册。

怎么想:把原来 <td> 里的 .my-tag 整块替换成 <MyTag></MyTag>,并补 import 和 components。

怎么做:按笔记修改 App.vue:

vue
<template>
  ...
 <tbody>
       <tr>
          ....
          <td>
            <MyTag></MyTag>
          </td>
       </tr>
 </tbody>
 ...
</template>
<script>
import MyTag from './components/MyTag.vue'
export default {
  name: 'TableCase',
  components: {
    MyTag,
  },
 ....
 </script>

易错点:组件名大小写、路径要一致;忘记 components 注册会在模板中不识别;替换后检查表格宽度和单元格对齐是否仍正常。

调试与后续交互提示 ​

为什么需要:抽离只是第一步,真正难点是双击、失焦、回车提交。转写稿里多次出现“不知道组件传出来什么就写方法打印测试”“事件后面必须有对应方法”的调试思路,这里同样适用。

怎么想:先在 MyTag 内部用状态控制 input 和 .text 的显示;双击进入编辑并让输入框聚焦;失焦退出;回车提交新值。父组件通过属性传入原标签,通过自定义事件接收修改结果。

怎么做:本节笔记只给到抽离骨架,交互代码未在笔记中展开,不能凭空补完整实现。按需求在 MyTag 内补状态、ref 聚焦和事件即可;提交后让父组件更新对应 goods 项的 tag,实现回显。

易错点:回车提交后失焦可能再次触发隐藏,注意避免重复提交;输入框值要从传入标签初始化;不要直接把 goods 写进子组件。转写稿中“作用异差小”应理解为“作用域插槽”,“艾Y”指 Element UI,这类同音字在整理笔记时要按真实术语纠正。

本节目标:用 isEdit 控制两种显示状态 ​

这个案例要做的是一个“标签”小组件:默认显示文字“茶具”,双击文字后变成输入框,输入框失焦后又回到文字状态。核心不是样式,而是用布尔状态控制显示隐藏。

为什么需要:标签既要能展示,又要能编辑。如果一直放一个 input,界面不像标签;如果只放文字,又没法编辑。所以需要两种形态切换。

怎么想:准备一个 isEdit,false 表示展示文字,true 表示展示输入框。模板里用 v-if / v-else 二选一。转写稿里反复提醒“调试工具今天一定要用起来”,这里同样适用:先打开 Vue Devtools 看 isEdit 有没有从 false 变 true,再判断模板有没有切换。

怎么做:先保留笔记中的完整组件代码:

vue
<template>
  <div class="my-tag">
    <input
      v-if="isEdit"
      v-focus
      ref="inp"
      class="input"
      type="text"
      placeholder="输入标签" 
      @blur="isEdit = false" 
    />
    <div 
      v-else
      @dblclick="handleClick"
      class="text">
       茶具
    </div>
  </div>
</template>

<script>
export default {
  data () {
    return {
      isEdit: false
    }
  },
  methods: {
    handleClick () {
      this.isEdit = true
    }
  }
}
</script>

易错点:v-else 必须紧跟 v-if,中间不要插别的元素;isEdit 初始值应为 false,否则一进页面就是输入框;ref="inp" 在当前代码里没有使用,可以删掉,不影响功能。

双击进入编辑、失焦退出编辑 ​

为什么需要:如果单击就进入编辑,用户正常点击标签时容易误触;双击更符合“进入编辑”的交互习惯。退出编辑用失焦,用户点页面空白处就自然结束。

怎么想:双击文字时把 isEdit 改成 true;输入框失焦时把 isEdit 改回 false。这样状态驱动视图,不需要手动操作 DOM 的显示隐藏。

怎么做:文字 div 上绑定 @dblclick="handleClick",方法里 this.isEdit = true;input 上绑定 @blur="isEdit = false"。调试时可以在 handleClick 里先 console.log(this.isEdit),双击后看控制台和 Devtools 是否变成 true;再点空白处,看是否变回 false。

易错点:不要写成 @click,否则单击就进编辑;blur 是失去焦点时触发,不是值改变时触发;如果以后要保存输入内容,应该在 blur 时一并处理,而不是只改 isEdit。

封装全局指令 v-focus 让输入框自动聚焦 ​

为什么需要:输入框是 v-if 动态插入的,刚出现时不会自动获得焦点。用户双击后还要再点一下输入框,体验不好。

怎么想:需要在 input 被插入到页面后调用 el.focus()。Vue 自定义指令的 inserted 钩子正好在元素插入页面时触发,所以封装一个 v-focus 全局指令最合适。转写稿里把“封装”说成“公装/工装”,这里按“封装全局指令”理解。

怎么做:在 main.js 中注册全局指令:

js
// 封装全局指令 focus
Vue.directive('focus', {
  // 指令所在的dom元素,被插入到页面中时触发
  inserted (el) {
    el.focus()
  }
})

模板中直接写 v-focus 即可。这样任何组件里的输入框只要加了 v-focus,插入后都会自动聚焦。

易错点:Vue.directive 要写在 new Vue(...) 之前,否则组件解析时指令还没注册;inserted 不要写成 bind,bind 时元素还不一定插入页面;v-focus 后面不要加冒号或等号;如果指令没生效,先检查 main.js 是否引入、注册顺序是否正确。

调试与验证:先让状态可见 ​

转写稿里讲表格时反复强调“先输出看看、看到就真实”,这个思路可以迁移到这里:不要只凭页面猜,先让 isEdit 可见。打开 Vue Devtools,选中 MyTag 组件,观察 isEdit;双击“茶具”,它应从 false 变 true,页面出现 input;点击空白处,它应从 true 变 false,input 消失并回到文字。若 input 没自动聚焦,就在 inserted 里打印 el,确认拿到的是 DOM 元素。易错点:blur 后元素马上被 v-if 移除,控制台日志要写在改状态之前;全局指令注册后要刷新页面再测。

先明确本节目标:让 MyTag 像表单控件一样使用 v-model ​

父组件里写 <MyTag v-model="tempText"></MyTag>,本质是希望“子组件展示父组件的数据,子组件修改后再同步回父组件”。Vue2 中 v-model 是语法糖,等价于:

vue
<MyTag :value="tempText" @input="tempText = $event"></MyTag>

所以子组件必须做两件事:用 props 接收 value,修改时用 $emit('input', 新值) 通知父组件。为什么不能直接改 this.value?因为 props 是父组件的数据,子组件直接改会破坏单向数据流,也会让数据来源不清晰。易错点是:父组件 data 必须 return 对象。笔记里 data(){ tempText:'水杯' } 写法不完整,应改为:

vue
<script>
export default {
  data () {
    return {
      tempText: '水杯'
    }
  }
}
</script>

子组件声明 value,并在回车时回传 input ​

MyTag 中先声明:

js
props: {
  value: String
}

模板中展示态用 &#123;&#123; value &#125;&#125;,编辑态输入框用 :value="value"。为什么输入框不写 v-model="value"?因为不能直接改 prop。正确思路是:输入框自己先收集用户输入,等用户按回车确认后,再通过 this.$emit('input', e.target.value) 把新值交给父组件。父组件收到 input 事件后,tempText 更新,子组件的 value 也随之更新。

handleEnter 里先做非空处理:

js
if (e.target.value.trim() === '') return alert('标签内容不能为空')
this.$emit('input', e.target.value)
this.isEdit = false

易错点:判断用了 trim(),但提交的是 e.target.value 原值;如果业务要求去掉首尾空格,应提交 e.target.value.trim()。另外事件名必须是 input,因为父组件用的是 v-model。

双击进入编辑、回车提交、失焦退出 ​

展示态和编辑态由 isEdit 控制:

vue
<input v-if="isEdit" ... />
<div v-else @dblclick="handleClick" class="text">
  {{ value }}
</div>

handleClick 把 isEdit 设为 true,输入框出现;handleEnter 提交成功后设回 false;@blur="isEdit = false" 让输入框失焦时退出编辑。为什么需要失焦退出?因为用户可能点别处放弃编辑。易错点:回车提交后输入框会失焦,blur 和 enter 可能都触发;当前代码因为先 emit 再 isEdit=false,通常能关闭,但空值时 return 不会关闭,随后失焦仍会关闭,可能让“空值提示”后编辑态消失。若要求空值必须继续编辑,应在 blur 里也做非空判断,而不是简单关闭。

v-focus 与 ref 的注意点 ​

笔记里写了 v-focus 和 ref="inp",目的是进入编辑后自动聚焦。为什么需要?双击后如果还要再点输入框,体验差。怎么做?可以注册自定义指令 v-focus,也可以在 handleClick 中:

js
this.isEdit = true
this.$nextTick(() => {
  this.$refs.inp.focus()
})

易错点:v-focus 不是 Vue 内置指令,未注册会报错;v-if 切换后 DOM 不是立刻存在,要用 $nextTick;当前代码 ref="inp" 没有实际使用,属于预留。

完整代码与调试检查 ​

保留本节 MyTag 代码:

vue
<template>
  <div class="my-tag">
    <input
      v-if="isEdit"
      v-focus
      ref="inp"
      class="input"
      type="text"
      placeholder="输入标签"
      :value="value"
      @blur="isEdit = false"
      @keyup.enter="handleEnter"
    />
    <div 
      v-else
      @dblclick="handleClick"
      class="text">
      {{ value }}
    </div>
  </div>
</template>

<script>
export default {
  props: {
    value: String
  },
  data () {
    return {
      isEdit: false
    }
  },
  methods: {
    handleClick () {
      this.isEdit = true
    },
    handleEnter (e) {
      // 非空处理
      if (e.target.value.trim() === '') return alert('标签内容不能为空')
      this.$emit('input', e.target.value)
      // 提交完成,关闭输入状态
      this.isEdit = false
    }
  }
}
</script>

调试时按顺序检查:父组件 tempText 是否初始为“水杯”;双击是否出现输入框;输入新内容回车后父组件值是否变化;输入空值是否弹提示;失焦是否退出编辑。转写稿主要记录表格、分页、抽屉等案例,其中“公装API、托作、历史、赵太”等是“公共 API、total、list、state”一类同音转写,但不属于本节 MyTag 内容,因此不混入本节代码。

先想清楚:为什么要封装 MyTable ​

表格页面里,thead 的结构、边框、图片尺寸这些样式,往往每个列表页都差不多;真正变化的只是“这一页显示哪些行、每行字段是什么”。如果每个页面都把 <table> 从头写一遍,结构重复、样式难统一,改一个边框要改很多文件。

所以思路是:把表格的“壳”封装成 MyTable,表头固定,表体由父组件传进来的数据动态渲染。父组件只关心“传什么数据”,子组件负责“怎么把数据铺成表格”。

做法就是笔记里的两个文件:App.vue 里写 <MyTable :data="goods"></MyTable>,MyTable.vue 通过 props 接收 data,并且 type: Array、required: true。这样父组件不传数据,控制台会直接警告,比表格悄悄空白要好排查。

易错点:props 的名字必须和父组件绑定的属性名一致。转写稿里就出现过类似问题:页面报“tableData 没找到”,原因是变量已经换成了 list,模板里还在用旧名字。遇到表格空白,先看控制台报错,再回到父组件确认数据变量名和 :data 后面写的是不是同一个。

数据先确认拿到,再谈渲染 ​

转写稿里反复强调“调试工具今天一定要用起来”:调完接口后,先把数据存到 list 和 total,然后打开 Vue Devtools,看 list 里有没有数据、total 有没有变。确认数据真的拿到了,再去改模板。

本节案例同理:App.vue 的 data 里要先准备好 goods,数组里每项至少要有 id、name、picture、tag 这些字段。不要一边接口没通、一边调样式,最后分不清是数据问题还是模板问题。

易错点:required: true 只保证“传了”,不保证“传的是有内容的数组”。父组件传 null 或空数组时,表格可能不报错但一片空白,所以先看数据源。

表头写死,表体用 v-for 动态渲染 ​

表头是固定的四列:编号、名称、图片、标签,直接写在 thead 里。表体行数不固定,所以 v-for 要写在 <tr> 上:

vue
<tr v-for="(item, index) in data" :key="item.id">

这里 index 用来显示序号,&#123;&#123; index + 1 &#125;&#125; 从 1 开始;不要用 item.id 当序号,因为 id 可能不连续。key 优先用 item.id,比用 index 稳定,后续删除、排序时不容易出现复用错行。

每一列再绑定当前行的字段:&#123;&#123; item.name &#125;&#125;、:src="item.picture"。图片这里必须用 : 动态绑定,不能写 src="&#123;&#123; item.picture &#125;&#125;",Vue 的属性插值不成立。

易错点:v-for 写在 <tr> 上,不是写在 <tbody> 上;key 不要漏,否则控制台会提示,而且更新时可能出问题。

图片和样式的细节 ​

图片列除了 :src,样式里还写了:

less
img {
  width: 100px;
  height: 100px;
  object-fit: contain;
  vertical-align: middle;
}

object-fit: contain 是让图片在固定宽高里完整显示、不变形;vertical-align: middle 是让图片和同一行的文字居中对齐。表格用 border-spacing: 0 去掉单元格间隙,th 加背景和下边框,td 加虚线下边框。scoped 保证样式只作用当前组件,不会污染别的表格。

易错点:图片没给宽高时,大图会把表格撑开;scoped 下想改子组件内部样式,普通选择器可能不生效,这是另一个话题,本节先保证表格自身样式写全。

最后一列为什么留插槽 ​

笔记里标签列现在写的是“标签内容”,下面注释了:

vue
<!-- <MyTag v-model="item.tag"></MyTag> -->

这说明这一列不是纯文本,而是一个业务组件。如果直接把 MyTag 写死在 MyTable 里,MyTable 就和“标签业务”绑死了;以后操作列要放删除、编辑按钮,又得改组件。更好的做法是:MyTable 只负责画出单元格,单元格里放什么,交给使用方决定,也就是用插槽。

转写稿视频2讲操作列时就是这个思路:先不急着放图标,先在组件标签中间传一个“删除”两个字测试,确认插槽位置通了,再把 Element UI 的图标拿过来。图标默认挤在一起,就外面套一个 div,加 flex 类分开。这个“先传简单内容验证插槽,再换真实内容”的过程,比一上来就写复杂结构好调试。

易错点:插槽内容是在父组件作用域里编译的,不能直接写 item.name 去访问 MyTable 内部 v-for 的 item。想用当前行数据,必须用作用域插槽把数据传出来。

作用域插槽:把当前行数据传给父组件 ​

父组件要放 <MyTag v-model="item.tag">,就需要当前行的 item。做法是在 MyTable 的插槽上把 item 传出去,例如 <slot :row="item"></slot>;父组件用 v-slot 或 #default="scope" 接收,再用 scope.row.tag。

转写稿里有一段很实用的调试过程:不知道子组件插槽传出来的是什么,就写一个方法,把插槽参数传给这个方法,在方法里 console.log 打印。打印后发现传出来的是 { row, column, $index } 这样的对象,真正的行数据在 row 里,所以要用 row.id,而不是直接 id。这个“先打印、看结构、再解构”的办法,比猜参数名可靠。

易错点:以为插槽传出来就是 item 本身,直接写 scope.id;实际要先看打印结果,通常要 scope.row.id。解构时写 { row },不要写 { item }。

本节代码回顾 ​

App.vue:

vue
<template>
  <div class="table-case">
    <MyTable :data="goods"></MyTable>
  </div>
</template>

<script>
import MyTable from './components/MyTable.vue'
export default {
  name: 'TableCase',
  components: { 
    MyTable
  },
  data(){
    return {
        ....
    }
  },
}
</script>

MyTable.vue:

vue
<template>
  <table class="my-table">
    <thead>
      <tr>
        <th>编号</th>
        <th>名称</th>
        <th>图片</th>
        <th width="100px">标签</th>
      </tr>
    </thead>
    <tbody>
      <tr v-for="(item, index) in data" :key="item.id">
       <td>{{ index + 1 }}</td>
        <td>{{ item.name }}</td>
        <td>
          <img
            :src="item.picture"
          />
        </td>
        <td>
          标签内容
         <!-- <MyTag v-model="item.tag"></MyTag> -->
        </td>
      </tr>
    </tbody>
  </table>
</template>

<script>
export default {
  props: {
    data: {
      type: Array,
      required: true
    }
  }
};
</script>

<style lang="less" scoped>

.my-table {
  width: 100%;
  border-spacing: 0;
  img {
    width: 100px;
    height: 100px;
    object-fit: contain;
    vertical-align: middle;
  }
  th {
    background: #f5f5f5;
    border-bottom: 2px solid #069;
  }
  td {
    border-bottom: 1px dashed #ccc;
  }
  td,
  th {
    text-align: center;
    padding: 10px;
    transition: all .5s;
    &.red {
      color: red;
    }
  }
  .none {
    height: 100px;
    line-height: 100px;
    color: #999;
  }
}

</style>

当前版本已经能把 goods 动态渲染成表格行,标签列还是占位。下一步就是把标签列改成插槽,让父组件决定放 MyTag 还是别的组件;如果插槽内容需要当前行数据,就再补作用域插槽,把 item 传出去。

课件图示 ​

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

课件第29页:综合案例 - 商品列表 需求说明: 1. my-tag 标签组件封装 (1) 双

课件第25页:插槽 - 作用域插槽 作用域插槽: 定义 slot 插槽的同时, 是可以传值的。

课件第3页:综合案例:商品列表 表格组件 可编辑标签组件

课件第26页:基本使用步骤: 1. 给 slot 标签, 以 添加属性的方式传值 2. 所有添

课件第22页:具名插槽语法: 1. 多个slot使用name属性区分名字 插槽 - 具名插槽

课件第23页:具名插槽简化语法: 1. 多个slot使用name属性区分名字 插槽 - 具名插

课件第27页:总结 小结 作用域插槽的作用是什么? 可以给插槽上绑定数据,供将来使用组件时使用

代码示例 ​

示例一:具名插槽 —— 一个弹框定制三处结构 ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>具名插槽 - 弹框</title>
  <style>
    .dialog { width: 420px; margin: 30px auto; border: 1px solid #ccc; border-radius: 6px; overflow: hidden; font-size: 14px; }
    .dialog-header { background: #1f74ff; color: #fff; padding: 10px 16px; }
    .dialog-main { padding: 16px; min-height: 60px; }
    .dialog-footer { padding: 10px 16px; text-align: right; border-top: 1px solid #eee; }
    .dialog-footer button { margin-left: 8px; }
  </style>
</head>
<body>
  <div id="app">
    <!-- 使用组件时,用 template + v-slot:名字 分发对应标签 -->
    <MyDialog>
      <template #header>
        <h3>提示</h3>
      </template>
      <template #main>
        <p>确定要删除这条记录吗?</p>
      </template>
      <template #footer>
        <button @click="del">删除</button>
        <button>取消</button>
      </template>
    </MyDialog>
  </div>

  <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
  <script>
    const MyDialog = {
      // 组件内用 name 属性区分多个 slot
      template: `
        <div class="dialog">
          <div class="dialog-header">
            <slot name="header"></slot>
          </div>
          <div class="dialog-main">
            <slot name="main"></slot>
          </div>
          <div class="dialog-footer">
            <slot name="footer"></slot>
          </div>
        </div>
      `
    }

    new Vue({
      el: '#app',
      components: { MyDialog },
      methods: {
        del () { alert('执行删除') }
      }
    })
  </script>
</body>
</html>

关键行与易错点

  • <slot name="header"></slot>:组件内有多处不确定结构时,用 name 区分不同插槽;不写 name 就是默认插槽 default。
  • <template #header>:#header 是 v-slot:header 的简写,template 本身不会渲染成真实标签,只负责“分发”。
  • 易错点 1:v-slot 只能写在 <template> 上或组件标签上,不能随便写在普通 div 上。
  • 易错点 2:# 后面不能有空格,写成 # header 会报错。
  • 易错点 3:如果只用默认插槽,三处结构只能定制一处,所以才需要具名插槽。

示例二:作用域插槽 —— 封装表格,子组件把行数据“回传”给父组件 ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>作用域插槽 - MyTable</title>
  <style>
    .my-table { width: 450px; text-align: center; border: 1px solid #ccc; font-size: 18px; margin: 30px auto; border-spacing: 0; }
    .my-table thead { background-color: #1f74ff; color: #fff; }
    .my-table thead th { font-weight: normal; }
    .my-table thead tr { line-height: 40px; }
    .my-table th, .my-table td { border-bottom: 1px solid #ccc; border-right: 1px solid #ccc; padding: 6px; }
    .my-table td:last-child { border-right: none; }
    .my-table tr:last-child td { border-bottom: none; }
    .my-table button { width: 65px; height: 35px; font-size: 16px; border: 1px solid #ccc; border-radius: 3px; cursor: pointer; background: #fff; }
  </style>
</head>
<body>
  <div id="app">
    <MyTable :data="list">
      <!-- 通过 #插槽名="obj" 接收,默认插槽名为 default -->
      <template #default="obj">
        <button @click="del(obj.id)">删除</button>
      </template>
    </MyTable>
  </div>

  <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
  <script>
    const MyTable = {
      props: {
        data: { type: Array, required: true }
      },
      template: `
        <table class="my-table">
          <thead>
            <tr>
              <th>序号</th>
              <th>姓名</th>
              <th>年纪</th>
              <th>操作</th>
            </tr>
          </thead>
          <tbody>
            <tr v-for="(item, index) in data" :key="item.id">
              <td>{{ index + 1 }}</td>
              <td>{{ item.name }}</td>
              <td>{{ item.age }}</td>
              <td>
                <!-- 给 slot 以添加属性的方式传值,所有属性会被收集成一个对象 -->
                <slot :id="item.id" msg="测试文本"></slot>
              </td>
            </tr>
          </tbody>
        </table>
      `
    }

    new Vue({
      el: '#app',
      components: { MyTable },
      data () {
        return {
          list: [
            { id: 1, name: '张小花', age: 18 },
            { id: 2, name: '孙大明', age: 19 },
            { id: 3, name: '刘德忠', age: 17 }
          ]
        }
      },
      methods: {
        del (id) {
          this.list = this.list.filter(item => item.id !== id)
        }
      }
    })
  </script>
</body>
</html>

关键行与易错点

  • <slot :id="item.id" msg="测试文本"></slot>:插槽上绑定属性即可传值,子组件把所有属性收集成一个对象 { id: 3, msg: '测试文本' }。
  • <template #default="obj">:obj 就是那个对象,所以删除按钮里写 obj.id,再 @click="del(obj.id)" 把 id 交给父组件的方法。
  • 作用域插槽不是插槽的第三种分类(分类只有默认插槽、具名插槽),它只是“插槽传值”的能力。
  • 易错点 1:接收时的名字必须是 #default(默认插槽),写成 #obj 之类收不到值。
  • 易错点 2:不知道子组件传了什么时,可以先在接收处打印一下,或把接收对象传给方法输出,看清结构再用(转写稿里 Element UI 的 row 就是这样试出来的)。
  • 易错点 3:#default="obj" 的解构写法 #default="{ id }" 也可用,但要与 slot 上的属性名完全一致。

示例三:MyTag 组件 —— 自定义指令 focus + 组件 v-model ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>MyTag - v-model 与自定义指令</title>
  <style>
    .my-tag { cursor: pointer; display: inline-block; }
    .my-tag .input { appearance: none; outline: none; border: 1px solid #ccc; width: 100px; height: 40px; box-sizing: border-box; padding: 10px; color: #666; }
    .my-tag .input::placeholder { color: #666; }
    .my-tag .text { height: 40px; line-height: 40px; }
  </style>
</head>
<body>
  <div id="app">
    <MyTag v-model="tempText"></MyTag>
    <p>当前标签:{{ tempText }}</p>
  </div>

  <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
  <script>
    // 封装全局指令 focus:指令所在的 dom 元素被插入页面时触发
    Vue.directive('focus', {
      inserted (el) {
        el.focus()
      }
    })

    const MyTag = {
      props: {
        value: String
      },
      data () {
        return {
          isEdit: false
        }
      },
      template: `
        <div class="my-tag">
          <input
            v-if="isEdit"
            v-focus
            class="input"
            type="text"
            placeholder="输入标签"
            :value="value"
            @blur="isEdit = false"
            @keyup.enter="handleEnter"
          />
          <div
            v-else
            @dblclick="handleClick"
            class="text"
          >{{ value }}</div>
        </div>
      `,
      methods: {
        handleClick () {
          this.isEdit = true
        },
        handleEnter (e) {
          // 非空处理
          if (e.target.value.trim() === '') return alert('标签内容不能为空')
          this.$emit('input', e.target.value)
          // 提交完成,关闭输入状态
          this.isEdit = false
        }
      }
    }

    new Vue({
      el: '#app',
      components: { MyTag },
      data () {
        return {
          tempText: '水杯'
        }
      }
    })
  </script>
</body>
</html>

关键行与易错点

  • v-if="isEdit" + v-else:双击 .text 把 isEdit 置为 true 显示输入框,失焦 @blur="isEdit = false" 隐藏输入框。
  • v-focus:全局指令在 inserted 里 el.focus(),因为输入框是 v-if 动态渲染出来的,只有插入 DOM 那一刻才能拿到并聚焦。
  • :value="value" + this.$emit('input', ...):组件上的 v-model="tempText" 会被展开成 :value + @input,所以子组件必须声明 value 这个 prop 并 $emit('input') 才能双向绑定。
  • 易错点 1:输入框里用 :value 而不是 v-model,否则会直接修改 prop(单向数据流被破坏)。
  • 易错点 2:回车后 isEdit = false 会让 input 消失,随后还会触发一次 blur,两者都只是关闭编辑态,不要在里面再重复提交。
  • 易错点 3:空内容要 return 拦掉,避免把空字符串 emit 出去覆盖原标签。

常见错误 ​

1. 作用域插槽里取不到删除用的 id,点击后 undefined

  • 现象:<template #default="obj"> 中写 obj.id 打印 undefined,删除功能失效。
  • 原因:默认按自己的想象假设子组件传出的对象结构,而 Element UI 实际传出的是 { row, column, $index },id 在 obj.row.id 上。
  • 修法:不要猜。先在插槽里挂一个临时事件把形参整体 console.log 出来,看清结构再解构,例如 #default="{ row }" 后使用 row.id。

2. 分页组件一交互就报 xxx is not a function

  • 现象:点页码或切换每页条数时控制台报错,页面数据不动。
  • 原因:@size-change、@current-change 只写了事件名,methods 里没有对应方法;或方法名拼写不一致。
  • 修法:模板中每个 @ 绑定都必须在 methods 里有同名函数;写完模板先自查“变量有没有、方法有没有”这两张清单。

3. 切换每页条数后,列表还是旧数据

  • 现象:下拉框从 10 条改成 20 条,表格仍显示 10 条。
  • 原因:只更新了 pageSize 这个视图变量,没有把它作为请求参数重新调用获取数据的方法;或先请求后改参数,顺序反了。
  • 修法:在事件回调里先改 pageSize/currentPage,再调用一次列表接口,并考虑把页码重置为 1,避免页码越界。

4. MyTag 回车后父组件数据没变,标签回显还是旧的

  • 现象:输入框里改了内容,回车后标签又变回原来的文字。
  • 原因:子组件只做了 isEdit = false,没有 $emit('input', 新值),父组件的 v-model 值从未更新。
  • 修法:props.value 只读,子组件想改值必须 $emit('input', e.target.value);同时补非空判断,空串不提交。

5. 双击后输入框出现但没有光标

  • 现象:v-focus 加了,输入框仍然要手动点一下才能输入。
  • 原因:指令写在 bind 钩子里,此时元素还没插入 DOM,el.focus() 无效。
  • 修法:用 inserted 钩子;并确认指令注册在 main.js(全局)而不是某个组件的 directives 里,否则别的组件用不到。

6. 抽屉点关闭按钮关不掉,一直提示

  • 现象:点 ×、点遮罩都没反应,只能刷新页面。
  • 原因:用了 before-close 却只弹提示、没有调用回调参数 done()。
  • 修法:确认逻辑里必须执行 done();若要取消关闭,则不调用 done,并保证 visible 状态与之一致。

7. 表格一片空白但接口有数据

  • 现象:list 在调试工具里明明有值,页面没有行。
  • 原因:props 里声明的字段名(如 tableData)和父组件传的 :data 不一致;或模板里仍写着旧的变量名。
  • 修法:父传 :data,子就 props: ['data'],模板里也统一用 data;改完立刻在调试工具中确认子组件 props 已收到值。

练习 ​

  1. 具名插槽改造弹框:写一个 MyDialog 组件,包含标题区、内容区、底部按钮区三个插槽,父组件只自定义底部按钮(用 #footer),其余用默认内容。要求:父组件不传内容时组件仍能正常显示。

  2. 作用域插槽自测:给 MyTable 的每一行通过 <slot :row="item" :index="index"> 传出数据,父组件用 #default="{ row, index }" 渲染“序号 + 删除按钮”,删除时打印该行 id。先用 console.log 确认结构再写业务代码。

  3. MyTag 完整闭环:实现双击进入编辑、失焦退出、回车提交、内容不能为空的标签组件,父组件用 v-model 绑定。补充验证:输入空白回车时提示且不关闭输入框;修改成功后父组件数组中的对应项同步变化。

  4. 分页与列表联动:基于 list 接口封装分页,要求切换页码、切换每页条数都能重新请求数据;切换条数时页码回到第 1 页;在页面上显示“共 X 条”。

  5. 抽屉复用:用同一个抽屉组件承载“添加 / 预览 / 编辑”三种操作,点击三个按钮时分别传入不同标识,抽屉标题随标识变化;点击关闭时先弹确认,确认后才真正关闭。

原视频对照 ​

#视频时长
1渲染表格05:31
2处理表格最后一列10:14
3分页组件的使用16:53
4完成分页08:56
5抽屉的使用18:32
6抽屉的标题08:50

小结 ​

  1. 插槽解决的是“结构由父组件定、数据由子组件出”的分工问题;位置固定用默认插槽,多处不同位置用具名插槽,需要子组件数据回流到父组件用作用域插槽。
  2. 作用域插槽传出的对象结构不由你决定,由组件库决定——先打印、再解构,是比背 API 更可靠的工程习惯。
  3. 组件封装的判断标准是“可变的部分抽出去,稳定的部分留下来”:表头、单元格、标题、内容都属于可变部分,应交给插槽。
  4. v-model 在组件上本质是 :value + @input 的语法糖,子组件永远不要直接改 props,只能通过事件通知父组件。
  5. 模板里每一个 : 后面必须有变量或表达式,每一个 @ 后面必须有已定义的方法;写完模板先做这两项自查,能挡掉绝大多数运行时报错。
  6. 分页不是独立组件,它与列表数据是联动关系:页码和每页条数是“请求参数”,不是纯粹的展示状态,改了参数就必须重新取数。
  7. 弹层类组件(抽屉/对话框)的核心状态只有一个“显隐布尔值”,标题、内容、按钮行为都应由调用方传入的操作类型派生,而不是复制三份组件。
  8. 本组件的复用程度越高,越要警惕“共用一个方法但无法区分场景”——传参区分操作类型,是复用前的必要一步。