Skip to content

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

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

学习目标 ​

  • 能够使用 provide 与 inject 实现祖先组件向后代组件传递数据,并说明其只能向下传递、对象数据具备响应式而普通数据默认非响应式的特点及处理方式。
  • 能够使用 ref 与 this.$refs 获取当前组件中的 DOM 元素或子组件实例,完成样式修改、焦点设置、调用子组件方法等操作,并说明其与 document.querySelector 的作用域差异。
  • 能够注册全局或局部自定义指令,使用 inserted、update 等钩子函数实现 v-focus 自动聚焦指令和 v-loading 加载提示指令,并根据指令值动态添加或移除类名。
  • 能够封装 MyTable 组件,通过父组件传递数据完成动态渲染,并结合自定义结构展示表格内容。
  • 能够使用 v-model 绑定搜索关键词,通过计算属性结合 filter 实现数据筛选,并将筛选结果渲染到页面。
  • 能够封装详情组件,通过 props 接收父组件传递的当前行数据,并通过 $emit 与 .sync 修饰符控制详情弹层的显示与隐藏。

知识点 ​

先看需求:标签能“变身”成输入框 ​

这个案例的目标是做一个 MyTag 组件:平时显示一段文字(比如“茶具”),双击文字后变成一个输入框,输入框自动获得焦点;输入完点击别处(失焦)后,又变回文字显示。

看起来是个小交互,但它把这一阶段的两个知识点串在了一起:用布尔值控制两个结构的显示隐藏,以及用自定义指令封装“自动聚焦”这种复用行为。下面按“为什么需要 → 怎么想 → 怎么做 → 易错点”逐层拆开。

用 isEdit 一个布尔值控制两个分支 ​

为什么需要:同一个位置要出现两种完全不同的结构(纯文本 / 输入框),如果两个都写死,就得靠 display: none 来回切样式,DOM 一直存在,逻辑容易乱。

怎么想:既然是“二选一”,最自然的做法就是一个布尔状态 isEdit,为 true 走输入框分支,为 false 走文字分支。Vue 里控制结构切换用 v-if / v-else,而不是 v-show——因为这里是两个不同的标签,不是同一个标签的显隐。

怎么做:

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>

初始 isEdit: false,页面显示“茶具”;双击触发 handleClick,把 isEdit 改成 true,v-if 分支被渲染,输入框出现。

易错点:

  • v-if 和 v-else 必须紧挨着,中间不能插入其他元素或注释,否则 v-else 找不到配对。
  • isEdit 一定要写在 data 的 return 对象里,写成普通变量不是响应式的,改了页面不会变。
  • 模板里写 @blur="isEdit = false" 是直接改状态,不要误写成 isEdit === false(那是判断,不是赋值)。

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

为什么需要:单击太容易误触,用户可能只是想选中文字;双击是“我要编辑它”的明确信号。而退出编辑用 blur(失焦)比用回车更符合直觉——点到别处就表示改完了。

怎么想:进入编辑用 dblclick,退出编辑用 blur,两个事件刚好对应“开始”和“结束”。注意 blur 绑在 input 上,因为只有输入框才会聚焦、才会失焦。

怎么做:@dblclick="handleClick" 触发 isEdit = true;@blur="isEdit = false" 直接写表达式即可,逻辑只有一句,不必单独抽方法。

易错点:blur 事件名不要写成 blur 之外的自造名字;另外 isEdit 从 true 变回 false 后,input 会被销毁、div 重新创建,这是正常的——v-if 的切换本身就是销毁与重建,不是复用。

为什么需要自定义指令 v-focus ​

为什么需要:输入框出现的那一刻,我们希望它立刻拿到焦点,用户可以直接打字。常规写法是在组件里用 mounted 配合 this.$refs.inp.focus()。但问题是——这个“自动聚焦”的需求在很多页面、很多组件里都会出现,每处都写一遍 mounted + ref + focus,代码重复得太厉害。既然要复用,就把它抽成指令:哪里需要,哪里写一个 v-focus。

怎么想:指令本质上是“对某个 DOM 元素做一段可复用的操作”。聚焦这个操作只关心一件事:元素已经插入到页面里了,然后调 el.focus()。

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

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

注册完之后,模板里写 v-focus 就能用。

易错点:指令名 'focus' 不用带 v- 前缀,v- 是模板里的写法;如果你写成 Vue.directive('v-focus', ...),模板里就得写 v-v-focus,这是典型错误。

钩子为什么选 inserted 而不是 bind ​

为什么需要:指令对象里可以写多个钩子函数,比如 bind、inserted、update、componentUpdated、unbind,它们在不同时机自动执行。选错时机,功能就失效。

怎么想:bind 在元素渲染之前就执行了,此时元素还不在页面上,你对着它调 focus() 是没有效果的;inserted 是在元素被插入页面之后触发,这时候元素已经真实存在,调 focus() 才生效。所以“让元素获得焦点”这类行为操作,要放在 inserted 里。

怎么做:如上代码,inserted (el) { el.focus() }。每个钩子都有两个参数,第一个 el 是指令绑定的那个 DOM 元素,第二个 binding 是指令的信息对象(比如指令的值)。这里只用到 el,第二个参数可以不写。

易错点:

  • 写成 bind (el) { el.focus() },页面刷新后输入框不会聚焦,因为时机太早。
  • 指令写在 main.js 里是全局指令,任何组件都能用;如果写在某个组件的 directives 选项里,就是局部指令,只有当前组件能用。本例用全局,因为要复用。

关于 ref="inp" 的补充 ​

笔记的模板里还留了一个 ref="inp"。它的作用是通过 this.$refs.inp 拿到这个输入框元素。这里要强调它和 document.querySelector 的区别:document.querySelector 是在整个页面里找,多个组件都有 input 时很容易找错别人的元素;而 this.$refs 只找当前组件里的元素,不会串到别的组件去。另外,如果同一个组件里手写了多个相同的 ref 值,this.$refs.xxx 只拿到最后一个;如果是 v-for 循环出来的,才会拿到一个数组。

本例既然已经用 v-focus 解决了聚焦,ref 并不是必需的,留着是为了演示“还能用 ref 手动操作这个元素”。

小结 ​

这一节的两条主线:显示隐藏交给 isEdit 这个布尔状态,配合 v-if / v-else 完成结构切换;自动聚焦交给 v-focus 指令,把重复的 DOM 操作封装成可复用的能力,钩子选 inserted 保证元素已经在页面上。写的时候牢记三点:状态放在 data 里、指令名不带 v-、聚焦要等插入之后。

需求与目标 ​

这个案例要做的是一个可编辑标签组件 MyTag:平时显示文本,双击进入输入框;输入后按回车提交,提交结果通过 v-model 同步回父组件。这里的关键不是“写一个输入框”,而是理解自定义组件如何配合 v-model 工作。

为什么需要它?因为父组件只关心“标签最终是什么”,不希望自己处理双击、输入框显示隐藏、回车提交、空值校验这些细节。怎么想?把 MyTag 设计成受控组件:父组件用 v-model 传值,子组件通过 props 接收,提交时用 $emit('input', 新值) 通知父组件更新。怎么做?先看笔记代码。

App.vue:

vue
<MyTag v-model="tempText"></MyTag>
<script>
    export default {
        data(){
            tempText:'水杯'
        }
    }
</script>

MyTag.vue:

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>

先理解 v-model 的等价写法 ​

为什么需要先讲等价写法?因为很多同学把 v-model 当成“魔法”,一旦写到自定义组件上就不知道事件名该叫什么。怎么想?在 Vue 2 中,组件上的 v-model 默认等价于“绑定 value 属性 + 监听 input 事件”。所以:

vue
<MyTag v-model="tempText"></MyTag>

可以理解为:

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

怎么做?子组件必须声明 props: { value: String },提交时 this.$emit('input', 新值)。易错点:事件名写成 change、update 都不会自动更新 v-model;除非你改 model 选项,但本案例按默认约定即可。

父组件 App.vue 的写法与修正 ​

为什么父组件只需要一行 v-model?因为编辑状态、输入框、回车校验都封装在子组件里。怎么想?父组件只保存最终文本 tempText,初始为“水杯”。怎么做?原笔记中写的是:

js
data(){
    tempText:'水杯'
}

这里要纠正:data 必须返回对象,否则 tempText 不会成为响应式数据。应写成:

js
data(){
  return {
    tempText: '水杯'
  }
}

易错点:data 里直接写属性是常见笔误;另外不要父组件再手动监听 input 又写 v-model,会重复。

子组件模板:显示态与编辑态切换 ​

为什么需要两个分支?因为标签平时是文本,双击才变成输入框。怎么想?用 isEdit 控制 v-if/v-else:isEdit 为真显示 input,否则显示 div.text。怎么做?文本区域绑定 @dblclick="handleClick",点击处理里把 isEdit 设为 true。输入框用 :value="value" 而不是 v-model,因为这里要“回车才提交”,不能让输入过程实时改父组件数据。易错点:如果输入框直接写 v-model="value",会试图修改 prop,Vue 会警告;同时失去“回车提交”的控制。

v-focus 与 ref="inp" 的作用 ​

为什么输入框出现后要自动聚焦?因为双击后用户希望直接输入,不聚焦体验差。怎么想?转写稿里讲自定义指令 focus 时提到:用 mounted + this.$refs 也能让元素聚焦,但每个组件都写重复代码;做成指令后,哪里需要加 v-focus 即可复用。指令的 inserted 在元素渲染后执行,里面执行 el.focus(),正好适合“元素出现后获得焦点”。怎么做?本案例模板中已有 v-focus,它应来自前文定义的自定义指令。ref="inp" 也能拿到输入框,但当前代码没有使用它;易错点:不要以为写了 ref 就自动聚焦,必须调用 .focus();既然用了 v-focus,就不必再在 mounted 里重复操作。转写稿还强调 $refs 只找当前组件内元素,避免 document.querySelector 跨组件找错元素。

回车提交与非空校验 ​

为什么要在 keyup.enter 里提交?因为需求是“回车确认”。怎么想?提交前先校验:e.target.value.trim() === '' 时提示并返回,不关闭编辑态;否则 $emit('input', e.target.value),再 isEdit = false 回到文本态。怎么做?注意先 emit 再关闭,父组件收到新值后,文本分支显示新内容。易错点:空字符串要 trim(),否则空格会被当成合法标签;alert 后要 return,不能继续提交。

调试流程与易错点 ​

调试时按顺序看:双击文本,isEdit 是否变 true;输入框是否出现并聚焦;输入“水杯”回车,父组件 tempText 是否更新;文本是否变回显示态。若回车没反应,先查事件名是否为 input;若父组件不更新,查 props 是否叫 value;若输入框不聚焦,查 v-focus 指令是否注册、inserted 是否执行。另一个易错点是 @blur="isEdit = false":它只关闭编辑,不提交,所以输入后直接点空白处会丢失本次输入。这是当前代码的行为,若要“失焦也保存”,应在 blur 里调用提交逻辑,但要避免回车后重复提交。

本节要解决什么问题 ​

表格在后台页面里很常见:表头固定,数据行变化。如果每个页面都手写一遍 <table><thead>...<tbody>...,代码会重复,改表头还要到处改。为什么需要封装:把表格结构和数据展示逻辑收进 MyTable,父组件只负责传数据。怎么想:MyTable 负责“怎么显示”,父组件负责“显示什么”。怎么做:父组件 App.vue 引入、注册、使用 MyTable,通过 :data="goods" 把数组传进去。易错点:组件要先导入再注册;components 里注册名要和模板标签对应;:data 是绑定表达式,不能写成 data="goods",否则传过去的是字符串。

先有静态结构,再抽成组件 ​

转写稿里的推导是:先把表格的 thead、tbody 结构放到页面里,确认静态内容能显示,再把表格抽到 components/MyTable.vue。这样调试目标清楚:先保证结构对,再保证数据对。为什么需要:一上来就写动态渲染,页面空白时很难判断是结构错、数据错还是组件没注册。怎么做:MyTable.vue 保留表头,tbody 里的 tr 用 v-for 遍历 data。本节笔记中的组件代码如下:

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>

易错点:v-for 要写在 tbody 的 tr 上,不要写到 table 上;item 是当前行数据,index 是索引;props 中 data 要求是数组且必填,父组件传错类型会警告。

父组件准备数据并传参 ​

为什么需要:MyTable 不关心数据来源,只接收 data。怎么想:父组件 App.vue 的 data 必须是函数,返回一个对象,对象里放 goods 数组。转写稿中反复强调“数据放在 data 函数返回的对象里”,不能把数组直接写在 data 外面。怎么做:按笔记保留 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>

这里的 .... 处就是放 goods 数组的位置。易错点:data(){ return { ... } } 不要写成 data: { ... };goods 未定义时模板会报错;components 注册后标签写 <MyTable> 或 <my-table> 要符合项目习惯,不要一会儿一个写法。

用 v-for 动态渲染每一行 ​

为什么需要:数据条数不固定,不能手写多个 tr。怎么想:有几条数据就生成几个 tr,编号用 index + 1,名称、图片从 item 上取。怎么做:v-for="(item, index) in data",:key="item.id"。如果数据里没有 id,转写稿中也提到可以用 index 临时兜底,但本节笔记示例有 item.id,优先用稳定唯一值。易错点:key 不要重复;&#123;&#123; index + 1 &#125;&#125; 不要写成 &#123;&#123; index + 1 &#125;&#125; 之外的表达式;item.picture 要用 :src 绑定,不能写死 src="item.picture"。

图片和样式细节 ​

为什么需要:图片尺寸不统一会把表格撑乱。怎么想:给 img 固定宽高,用 object-fit: contain 保证图片完整显示,vertical-align: middle 让图片和单元格内容对齐。怎么做:样式已写在 .my-table 的 img 中。易错点:scoped 样式只作用于当前组件;如果图片不显示,先检查 item.picture 是否有值、路径是否正确,再检查 :src 是否漏了冒号。

调试与常见报错 ​

转写稿中的调试顺序可以借用:页面空白时,先看父组件 goods 有没有数据;再看 MyTable 是否收到 data;再看 v-for 是否写在 tr 上;最后看字段名是否和 item.name、item.picture 对上。组件报“找不到组件”时,检查 import 路径和 components 注册拼写。后续做筛选时,计算属性必须 return 结果,否则页面拿不到值;本节先把动态渲染跑通,再扩展标签列 MyTag、筛选和详情弹层。

小结 ​

封装 MyTable 的核心是:父组件维护 goods,子组件用 props 接收 data,模板里用 v-for 生成行,index + 1 显示编号,:src 显示图片。这样表格结构只写一次,数据变化只改数组,后续加标签、筛选、详情都围绕这个组件扩展。

需求拆解:为什么要封装 MyTable ​

这一节做的是综合案例里的表格封装。转写稿里反复强调:表格组件要“根据父组件传递过来的数据展示”,也就是说,表格的数据来源不在表格组件内部,而由使用表格的父组件负责准备。这样做的原因很直接:页面里可能有多张表,但表格的“壳子”和“循环渲染逻辑”是重复的;如果每个页面都手写 table > thead > tbody > tr > td,结构会重复,改起来也容易漏。

怎么想:把稳定的部分放进 MyTable,把不稳定的部分交给使用方。稳定的是 table、thead、tbody、v-for 循环;不稳定的是表头有哪些列、每一行每一列怎么展示。于是用两个插槽:head 放表头,body 放行内容,并且 body 通过作用域插槽把当前 item 和 index 传出去。

怎么做:父组件 App.vue 传入 :data="goods",在 #head 中写 th,在 #body="{ item, index }" 中写 td。易错点:tbody 里的 tr 已经由 MyTable 循环生成,父组件插槽里不要再包一层 tr,否则表格结构会乱。

父组件:用自定义结构组织表头与单元格 ​

为什么需要:表头列和单元格内容因业务而异,不能写死在 MyTable 里,否则组件就不通用。怎么想:父组件最了解“这一列显示编号、名称、图片还是标签”,所以让父组件通过插槽决定结构。怎么做:保留笔记中的 App.vue 代码,注意 MyTable 接收 goods,MyTag 通过 v-model 绑定 item.tag。

vue
<template>
  <div class="table-case">
    <MyTable :data="goods">
      <template #head>
        <th>编号</th>
        <th>名称</th>
        <th>图片</th>
        <th width="100px">标签</th>
      </template>

      <template #body="{ item, index }">
        <td>{{ index + 1 }}</td>
        <td>{{ item.name }}</td>
        <td>
          <img
            :src="item.picture"
          />
        </td>
        <td>
          <MyTag v-model="item.tag"></MyTag>
        </td>
      </template>
    </MyTable>
  </div>
</template>

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

易错点:#body 的解构名必须和 MyTable 里 :item="item" :index="index" 对应;index + 1 只是展示序号,不改变原数组索引;v-model="item.tag" 要求 item.tag 是可写数据,不能写成临时表达式。

MyTable:插槽设计与循环渲染 ​

为什么需要:MyTable 只负责表格骨架和循环,不负责业务列。怎么想:thead 中放一个具名插槽 head;tbody 中先 v-for 生成 tr,再在 tr 内放 body 插槽,并把 item、index 传出去。怎么做:保留笔记代码:

vue
<template>
  <table class="my-table">
    <thead>
      <tr>
        <slot name="head"></slot>
      </tr>
    </thead>
    <tbody>
      <tr v-for="(item, index) in data" :key="item.id">
        <slot name="body" :item="item" :index="index" ></slot>
      </tr>
    </tbody>
  </table>
</template>

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

易错点:props.data 必须声明为数组且必填;:key="item.id" 依赖数据里有 id,转写稿里也提醒过,若案例数据没有 id,调试时可临时用 index 或稳定字段,但正式数据最好有唯一标识;插槽名不要写成 header、content 之类与使用处不一致的名字。

调试顺序与综合验证 ​

转写稿里的案例推进思路是:先把数据放进 data() 返回对象,用 v-for 把表格行渲染出来,再继续做筛选、详情等交互。对应到本节,建议先让 App.vue 直接写死表格结构跑通,再抽成 MyTable,最后接入 MyTag。调试时先看 goods 是否有值、MyTable 是否收到数组、#body 是否拿到 item 和 index,再看 MyTag 的 v-model 是否正常更新。易错点:组件导入后忘记注册、插槽名拼错、在 body 插槽里多写 tr、图片字段名写错。按“数据 → 结构 → 插槽 → 交互”的顺序排查,问题会更容易定位。

本节说明与学习目标 ​

为什么需要: 这一节要解决的是应用形态的区分问题:什么是单页应用程序(SPA),它和多页面应用有什么不同,分别适合什么场景。
怎么想: 先抓住笔记里的核心定义——“所有的功能都在一个 HTML 页面上实现”,再用示例网站建立直观印象,最后按场景归类。
怎么做: 学习时按“概念 → 示例 → 对比 → 场景 → 总结问题”的顺序复述,不要一开始就陷入实现细节。
易错点: 不要把“单页”误解成“页面里只有一个画面”或“没有功能切换”;笔记强调的是功能在一个 HTML 页面上实现。另需说明:提供的转写稿实际记录的是组件通信、ref/refs、自定义指令和案例等内容,并非本节“单页应用程序介绍”的推导与调试记录;本节笔记也没有提供代码,因此下面不把无关内容硬拼进来,也不虚构代码。

单页应用程序的概念 ​

为什么需要: 先明确术语,后面看示例和选型才不会混淆。
怎么想: 抓关键词:“SPA【Single Page Application】”“所有的功能”“一个 HTML 页面”。
怎么做: 用自己的话复述:单页应用程序是把应用的全部功能组织在一个 HTML 页面中实现的应用形态。
易错点: 不要把它扩大解释成“只有一个文件”或“没有页面变化”。笔记只给出“一个 HTML 页面承载功能”这一核心判断,不能凭外观随便下结论。

具体示例:网易云音乐与京东 ​

为什么需要: 概念比较抽象,用笔记给出的网站对照,能快速建立直觉。
怎么想: 单页应用看网易云音乐:https://music.163.com/;多页应用看京东:https://jd.com/。
怎么做: 打开两个网站时,不要只看颜色和布局,而要回到定义:它是否体现了“所有功能在一个 HTML 页面上实现”这一特征。
易错点: 不能因为两个网站都有导航栏、都有很多内容,就认为它们属于同一种应用形态;判断依据是笔记给出的单页/多页归类,而不是页面好不好看。

单页应用 VS 多页面应用 ​

为什么需要: 只背定义容易忘,对比能帮助区分两类应用。
怎么想: 把“单页应用”和“多页面应用”放在同一张图里对照,先看承载方式,再看笔记给出的网站类型。
怎么做: 结合下图复习:
单页应用与多页应用对比图
易错点: 笔记没有展开“优缺点”的具体条目,总结中的“单页面应用优缺点”是复习问题,不是已经给出的结论。学习时不要凭空补成确定答案;应先掌握定义、示例和场景,再在此基础上归纳。

单页应用与多页应用的适用场景 ​

为什么需要: 知道“是什么”之后,还要知道“用在哪”,才能形成选型意识。
怎么想: 按笔记直接归类,不把某一类场景绝对化。
怎么做:

  • 单页应用类网站:系统类网站、内部网站、文档类网站、移动端站点。
  • 多页应用类网站:公司官网、电商类网站。
    易错点: 笔记给出的是“类网站”的典型归类,不要扩大成“所有系统都必须是单页”“所有电商都只能多页”。同时不要把“单页”与“多页”简单等同于“好”与“坏”,笔记没有给出这种结论。

总结与自测 ​

为什么需要: 本节最后列了三个问题,用来检查是否真正理解,而不是只记住名词。
怎么想: 围绕“定义—优缺点—场景”三个层次回忆。
怎么做: 自测时回答:

  1. 什么是单页面应用程序?——SPA 指所有功能都在一个 HTML 页面上实现。
  2. 单页面应用优缺点?——笔记未展开具体条目,应回到定义和示例归纳,不背未给出的结论。
  3. 单页应用场景?——系统类网站、内部网站、文档类网站、移动端站点;多页应用类网站则包括公司官网、电商类网站。
    易错点: 不要把总结问题当成已经背过的答案;也不要把转写稿里的组件通信、ref、自定义指令等内容混入本节,它们不属于“单页应用程序介绍”的笔记范围。

先说明本节边界 ​

本节笔记只介绍“路由是什么、为什么需要路由”,没有给出 Vue Router 的安装、配置和调试代码。转写稿主要讲组件通信、ref/refs、自定义指令和案例,没有出现路由配置或路由调试过程,因此下面不虚构路由代码和控制台输出,只按笔记把概念讲透。图片保留原路径,alt 改为中文。

从单页面应用的“按需更新”说起 ​

为什么需要: 单页面应用程序之所以开发效率高、性能好、用户体验好,最大的原因是“页面按需更新”。比如点击【发现音乐】和【关注】时,只是更新下面部分内容,头部并不更新。如果每次操作都整页刷新,就失去了单页面应用的优势。

怎么想: 看到页面时,不要把它当成一个整体,而要拆成“稳定区域”和“变化区域”。头部通常稳定,下面内容区随导航变化。变化区域对应一个组件,访问不同路径时切换不同组件。

怎么做: 先观察示例页面:点击【发现音乐】和【关注】,只更新下面部分,头部不动。由此推导出:要实现按需更新,必须先知道“当前访问路径”对应“哪个组件”。

易错点: 不要把“按需更新”理解成所有内容都重新渲染;也不要只盯着组件,却忽略访问路径。路径和组件缺一不可。

单页面应用按需更新示意图

明确“访问路径—组件”的对应关系 ​

为什么需要: 既然页面要按需更新,就必须明确:访问哪个路径,显示哪个组件。否则浏览器不知道内容区该换什么。

怎么想: 访问路径是用户当前所处的位置,组件是页面中要展示的内容块。把“路径”与“组件”建立对应关系,才能在切换路径时只替换目标区域。

怎么做: 在理解层面,先列出路径与组件的对应表:例如“发现音乐”路径对应发现音乐内容组件,“关注”路径对应关注内容组件。笔记没有给出具体路由配置代码,所以这里不展开安装和写法,只强调映射关系要先想清楚。

易错点: 只写组件、不定义路径,或路径与组件错配;多个路径复用同一组件时,也要明确对应关系,不能靠猜。

用生活路由类比理解 Vue 路由 ​

为什么需要: “路由”这个词比较抽象,直接记概念容易混。生活中有类似映射关系,可以借助类比理解。

怎么想: 生活中的路由是“设备和 IP 的映射关系”;Vue 中的路由是“路径和组件的映射关系”。两者本质都是“一个东西对应另一个东西”。

怎么做: 把生活路由中的“设备→IP”替换成 Vue 路由中的“路径→组件”。看到路径,就找到对应组件;找到组件,就渲染到页面指定区域。

易错点: 不要把 Vue 路由等同于某个组件,也不要以为路由只负责页面跳转。本节强调的是映射关系:路径对应组件。

生活中设备与IP的映射关系

Vue中路径与组件的映射关系

总结:什么是路由,Vue 中的路由是什么 ​

为什么需要: 本节最后要能用自己的话回答两个问题:什么是路由?Vue 中的路由是什么?这是后续学习路由配置的基础。

怎么想: 路由可以理解为一种映射关系。生活中的路由是设备和 IP 的映射;Vue 中的路由是路径和组件的映射。单页面应用要按需更新,就需要这种映射来告诉页面:当前路径该显示哪个组件。

怎么做: 复述时抓住三个关键词:单页面应用、按需更新、路径与组件映射。先说明为什么需要路由,再说明路由是什么,最后落到 Vue 中的具体含义。

易错点: 不要把“路由”和“路由器设备”混为一谈;也不要把“路径”和“组件”割裂开。本节没有给出路由代码,学习时不要凭空补写未讲过的配置,先把概念关系记牢。

课件图示 ​

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

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

课件第6页:自定义指令:自己定义的指令, 可以封装一些 dom 操作, 扩展额外功能 l 全

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

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

课件第7页:总结 小结 自定义指令的作用? 封装一些 dom 操作,扩展额外功能,例如获取焦

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

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

代码示例 ​

示例 1:MyTag 组件(双击编辑 + v-model 绑定 + focus 全局指令) ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>MyTag 组件 - 双击编辑 / v-model / focus 指令</title>
  <style>
    .my-tag { display: inline-block; }
    .my-tag .input {
      width: 100px; height: 28px; padding: 0 6px;
      border: 1px solid #069; outline: none; box-sizing: border-box;
    }
    .my-tag .text {
      display: inline-block; padding: 4px 10px;
      background: #f5f5f5; border-radius: 4px; cursor: pointer;
    }
  </style>
</head>
<body>
  <div id="app">
    <my-tag v-model="tempText"></my-tag>
    <p>父组件中的 tempText:{{ 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()
      }
    })

    Vue.component('MyTag', {
      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>
      `,
      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
        }
      }
    })

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

要点:

  • 组件上的 v-model 是语法糖:等价于 :value="tempText" + @input="tempText = $event",所以子组件 props 必须叫 value、$emit 必须是 input,名字写错就绑不上。
  • input 用 :value="value" 单向绑定,回车时 this.$emit('input', e.target.value) 把新值交回父组件;不能写 this.value = ...,直接改 props 会报警告。
  • v-focus 是全局指令,inserted 钩子在元素插入 DOM 之后执行,这时 el.focus() 才有效;写在 bind 里 DOM 还没渲染,聚焦无效。
  • @dblclick="handleClick" 把 isEdit 置 true,v-if 会销毁并重建 input,重建时会重新触发 inserted,所以每次双击都能自动聚焦。
  • 易错点:@blur="isEdit = false" 是行内表达式,别写成 this.isEdit = false;回车判空要用 e.target.value.trim(),空值必须 return,否则会把空标签 emit 出去。

示例 2:MyTable 组件(props 接收数据,v-for 动态渲染表格) ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>MyTable 组件 - 动态渲染数据</title>
  <style>
    .my-table { width: 100%; border-spacing: 0; }
    .my-table img {
      width: 100px; height: 100px;
      object-fit: contain; vertical-align: middle;
    }
    .my-table th { background: #f5f5f5; border-bottom: 2px solid #069; }
    .my-table td { border-bottom: 1px dashed #ccc; }
    .my-table td, .my-table th {
      text-align: center; padding: 10px; transition: all .5s;
    }
  </style>
</head>
<body>
  <div id="app">
    <div class="table-case">
      <my-table :data="goods"></my-table>
    </div>
  </div>

  <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
  <script>
    Vue.component('MyTable', {
      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>{{ item.tag }}</td>
            </tr>
          </tbody>
        </table>
      `,
      props: {
        data: {
          type: Array,
          required: true
        }
      }
    })

    new Vue({
      el: '#app',
      data () {
        return {
          goods: [
            {
              id: 1,
              name: '茶具',
              picture: 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><rect width="100" height="100" fill="%23dbeafe"/></svg>',
              tag: '热销'
            },
            {
              id: 2,
              name: '水杯',
              picture: 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><rect width="100" height="100" fill="%23fef3c7"/></svg>',
              tag: '新品'
            },
            {
              id: 3,
              name: '毛巾',
              picture: 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><rect width="100" height="100" fill="%23dcfce7"/></svg>',
              tag: '日常'
            }
          ]
        }
      }
    })
  </script>
</body>
</html>

要点:

  • props 用对象形式 { type: Array, required: true },父组件 :data="goods" 传数组,子组件里通过 this.data 使用。
  • v-for="(item, index) in data" :key="item.id":编号用 index + 1 展示,key 用唯一 id,别用 index。
  • 图片必须写成 :src="item.picture",写成 src="item.picture" 会被当成普通字符串路径。
  • 易错点:data 既是 props 名又是 data() 选项名,组件内部取数据是 this.data,写 this.goods 拿不到(goods 在父组件里)。
  • 笔记中的 lang="less"、scoped 是 SFC 写法,直接放进 HTML 时改成普通 <style> 和普通 CSS 选择器。

示例 3:MyTable 组件自定义结构(具名插槽 + 作用域插槽,配合 MyTag) ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>MyTable 组件 - 自定义结构</title>
  <style>
    .my-table { width: 100%; border-spacing: 0; }
    .my-table img {
      width: 100px; height: 100px;
      object-fit: contain; vertical-align: middle;
    }
    .my-table th { background: #f5f5f5; border-bottom: 2px solid #069; }
    .my-table td { border-bottom: 1px dashed #ccc; }
    .my-table td, .my-table th {
      text-align: center; padding: 10px; transition: all .5s;
    }
    .my-tag { display: inline-block; }
    .my-tag .input {
      width: 100px; height: 28px; padding: 0 6px;
      border: 1px solid #069; outline: none; box-sizing: border-box;
    }
    .my-tag .text {
      display: inline-block; padding: 4px 10px;
      background: #f5f5f5; border-radius: 4px; cursor: pointer;
    }
  </style>
</head>
<body>
  <div id="app">
    <div class="table-case">
      <my-table :data="goods">
        <template #head>
          <th>编号</th>
          <th>名称</th>
          <th>图片</th>
          <th width="100px">标签</th>
        </template>

        <template #body="{ item, index }">
          <td>{{ index + 1 }}</td>
          <td>{{ item.name }}</td>
          <td>
            <img :src="item.picture" />
          </td>
          <td>
            <my-tag v-model="item.tag"></my-tag>
          </td>
        </template>
      </my-table>
    </div>
  </div>

  <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
  <script>
    Vue.directive('focus', {
      inserted (el) {
        el.focus()
      }
    })

    Vue.component('MyTag', {
      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>
      `,
      props: {
        value: String
      },
      data () {
        return {
          isEdit: false
        }
      },
      methods: {
        handleClick () {
          this.isEdit = true
        },
        handleEnter (e) {
          if (e.target.value.trim() === '') return alert('标签内容不能为空')
          this.$emit('input',

常见错误 ​

1. 自定义 v-model 双向绑定失效

  • 现象:双击进入编辑、回车后父组件的 tempText 没变化,页面仍显示旧值。
  • 原因:子组件里 props 名不是 value,或提交时 $emit 的事件名不是 input(写成 change、update、submit 等),语法糖对不上就断链。
  • 修法:严格保持 props: { value } + this.$emit('input', 新值) 这一对约定;改名前先确认父组件写的是 v-model 还是 :value + @input。

2. 子组件直接改 prop

  • 现象:控制台报 "Avoid mutating a prop directly",或改了 item.tag 页面不更新。
  • 原因:在 MyTag 里写 this.value = xxx,或在 MyTable 里直接改 item.tag,把单向数据流当双向用。
  • 修法:所有修改通过 $emit('input', 新值) 交回父级,由父级(v-model="item.tag")去更新;item 只读。

3. v-focus 写在组件标签上或配 v-show 使用

  • 现象:输入框没获得焦点;或从隐藏切回显示时指令"失灵"。
  • 原因:指令的 el 拿到的是组件根元素而非 input;v-show 不销毁重建 DOM,inserted 只执行一次。
  • 修法:指令加在原生 input 上;切换场景用 v-if 让元素重建,或在 update 里补判断后手动 focus()。

4. 用 document.querySelector 找元素,改错组件

  • 现象:在 Test 组件里给 p 设置样式,结果 App 里的 p 变红了。
  • 原因:document 是整页查找,第一个匹配项可能来自别的组件;组件一多必然互相污染。
  • 修法:给当前组件元素加 ref,用 this.$refs.xxx 取——它只在当前组件内查找,天然隔离。

5. 只写 inserted 的 loading 指令,图片一直转

  • 现象:数据已经渲染出来了,loading 图还在。
  • 原因:inserted 只在元素插入时执行一次,数据更新(list 从 0 变 3)不会再次触发。
  • 修法:inserted + updated 写同一段逻辑,通过 binding.value(如 list.length)判断该加还是该移除类名。

6. 详情弹层点叉关不掉

  • 现象:点 × 后弹层不消失,或父组件的 visible 一直是 true。
  • 原因:子组件里写 this.visible = false(改 prop 无效),或 $emit 的事件名不是 update:visible。
  • 修法:子组件 this.$emit('update:visible', false),父组件用 :visible.sync="visible" 接收。

7. 搜索筛选没反应

  • 现象:输入框输入"张三",表格还是三条数据。
  • 原因:v-for 仍遍历原始数组 records,没用计算属性返回的结果;或计算属性缺 return(只在 if 分支里 return)。
  • 修法:模板改为遍历计算属性,并保证 if/else 两条路径都有返回值。

练习 ​

1. 给 MyTag 加"Esc 取消编辑" 要求:编辑时按 Esc 退出编辑态且不保存,原值保持不变;按 Enter 才保存。 思考并回答:为什么单纯依赖 @blur 无法实现"取消"?如果 blur 也想保存,Enter 与 blur 会冲突吗,怎么处理? 验收:Esc 后父组件数据不变、输入框恢复显示文本;Enter 后数据更新。

2. 改造 loading 指令支持自定义提示 要求:v-loading="list.length" 保持原语义,同时支持 v-loading="{ len: list.length, text: '加载中...' }" 传入文案,指令内部按 binding.value 的类型分支处理。 验收:两种写法都能正确显示/隐藏,切换数据后无残留类名。

3. MyTable 增加空状态插槽 要求:无数据时渲染"暂无数据",且该文案由使用方通过具名插槽传入;有数据时正常渲染行。 思考:#empty 插槽需不需要作用域?#body="{ item, index }" 里的 index 为什么必须在子组件用 :index="index" 显式传出? 验收:清空 goods 后显示空态,恢复数据后正常渲染。

4. 用 provide/inject 做主题切换 要求:App 提供主题数据,孙子组件读取并显示当前主题;点击按钮切换主题,所有后代组件同步变化。 思考:如果 provide 里直接写 theme: 'dark' 字符串,切换时后代为什么不变?必须怎么写? 验收:切换按钮生效,至少两级后代组件同步更新。

5. 弹层显隐的两种实现对比 要求:先用 .sync 实现"点详情 → 显示弹层、点 × → 关闭";再把方案改成父组件用 ref 调用子组件 open() / close() 方法。 对比作答:两种方案在"谁持有状态""子组件能否独立复用""数据传递方式"上各自的取舍是什么?

原视频对照 ​

#视频时长
1组件通信-依赖注入12:28
2ref和refs24:01
3自定义指令-focus指令19:21
4自定义指令-loading指令11:41
5案例-渲染数据07:26
6案例-计算属性筛选结果05:46
7案例-点详情查看详情13:49

小结 ​

  1. 组件通信要先看层级关系再选方案:父子用 props/emit,跨层级用 provide/inject,父调子用 ref,别一上来就全局方案。
  2. 自定义 v-model 不是魔法,本质是 value + input 的命名约定,任何一端改名都会静默失效——这类错误没有报错,只能靠约定纪律避免。
  3. ref 的核心价值不是"能找到元素",而是作用域隔离:它只在当前组件内查找,天然规避了多组件共用一页时的互相污染。
  4. 自定义指令适合封装重复的 DOM 行为(聚焦、loading、防抖),不适合塞业务逻辑;一旦指令里开始判断业务字段,就该考虑换组件或组合式函数。
  5. 指令钩子的选择决定了"一次"还是"多次":inserted 是一次性快照,任何依赖动态数据的指令都必须配 updated。
  6. 插槽的分工是"结构归使用方,数据归封装方":具名插槽解决位置,作用域插槽解决数据下行,两者配合才能做出真正通用的表格组件。
  7. 弹层显隐这类状态应归属于父级,子组件只有"通知权"没有"修改权"——.sync 与 update:xxx 就是这个原则的语法体现。
  8. 计算属性做筛选时,"空条件返回原数组"这条兜底分支不能省:它同时解决了首次渲染和清空搜索两个场景,也避免了 undefined 返回值。