主题
自定义指令、插槽与路由入门(二)
本页对应 自定义指令、插槽与路由入门,共 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 不要重复;{{ index + 1 }} 不要写成 {{ index + 1 }} 之外的表达式;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 多页面应用
为什么需要: 只背定义容易忘,对比能帮助区分两类应用。
怎么想: 把“单页应用”和“多页面应用”放在同一张图里对照,先看承载方式,再看笔记给出的网站类型。
怎么做: 结合下图复习:
易错点: 笔记没有展开“优缺点”的具体条目,总结中的“单页面应用优缺点”是复习问题,不是已经给出的结论。学习时不要凭空补成确定答案;应先掌握定义、示例和场景,再在此基础上归纳。
单页应用与多页应用的适用场景
为什么需要: 知道“是什么”之后,还要知道“用在哪”,才能形成选型意识。
怎么想: 按笔记直接归类,不把某一类场景绝对化。
怎么做:
- 单页应用类网站:系统类网站、内部网站、文档类网站、移动端站点。
- 多页应用类网站:公司官网、电商类网站。
易错点: 笔记给出的是“类网站”的典型归类,不要扩大成“所有系统都必须是单页”“所有电商都只能多页”。同时不要把“单页”与“多页”简单等同于“好”与“坏”,笔记没有给出这种结论。
总结与自测
为什么需要: 本节最后列了三个问题,用来检查是否真正理解,而不是只记住名词。
怎么想: 围绕“定义—优缺点—场景”三个层次回忆。
怎么做: 自测时回答:
- 什么是单页面应用程序?——SPA 指所有功能都在一个 HTML 页面上实现。
- 单页面应用优缺点?——笔记未展开具体条目,应回到定义和示例归纳,不背未给出的结论。
- 单页应用场景?——系统类网站、内部网站、文档类网站、移动端站点;多页应用类网站则包括公司官网、电商类网站。
易错点: 不要把总结问题当成已经背过的答案;也不要把转写稿里的组件通信、ref、自定义指令等内容混入本节,它们不属于“单页应用程序介绍”的笔记范围。
先说明本节边界
本节笔记只介绍“路由是什么、为什么需要路由”,没有给出 Vue Router 的安装、配置和调试代码。转写稿主要讲组件通信、ref/refs、自定义指令和案例,没有出现路由配置或路由调试过程,因此下面不虚构路由代码和控制台输出,只按笔记把概念讲透。图片保留原路径,alt 改为中文。
从单页面应用的“按需更新”说起
为什么需要: 单页面应用程序之所以开发效率高、性能好、用户体验好,最大的原因是“页面按需更新”。比如点击【发现音乐】和【关注】时,只是更新下面部分内容,头部并不更新。如果每次操作都整页刷新,就失去了单页面应用的优势。
怎么想: 看到页面时,不要把它当成一个整体,而要拆成“稳定区域”和“变化区域”。头部通常稳定,下面内容区随导航变化。变化区域对应一个组件,访问不同路径时切换不同组件。
怎么做: 先观察示例页面:点击【发现音乐】和【关注】,只更新下面部分,头部不动。由此推导出:要实现按需更新,必须先知道“当前访问路径”对应“哪个组件”。
易错点: 不要把“按需更新”理解成所有内容都重新渲染;也不要只盯着组件,却忽略访问路径。路径和组件缺一不可。

明确“访问路径—组件”的对应关系
为什么需要: 既然页面要按需更新,就必须明确:访问哪个路径,显示哪个组件。否则浏览器不知道内容区该换什么。
怎么想: 访问路径是用户当前所处的位置,组件是页面中要展示的内容块。把“路径”与“组件”建立对应关系,才能在切换路径时只替换目标区域。
怎么做: 在理解层面,先列出路径与组件的对应表:例如“发现音乐”路径对应发现音乐内容组件,“关注”路径对应关注内容组件。笔记没有给出具体路由配置代码,所以这里不展开安装和写法,只强调映射关系要先想清楚。
易错点: 只写组件、不定义路径,或路径与组件错配;多个路径复用同一组件时,也要明确对应关系,不能靠猜。
用生活路由类比理解 Vue 路由
为什么需要: “路由”这个词比较抽象,直接记概念容易混。生活中有类似映射关系,可以借助类比理解。
怎么想: 生活中的路由是“设备和 IP 的映射关系”;Vue 中的路由是“路径和组件的映射关系”。两者本质都是“一个东西对应另一个东西”。
怎么做: 把生活路由中的“设备→IP”替换成 Vue 路由中的“路径→组件”。看到路径,就找到对应组件;找到组件,就渲染到页面指定区域。
易错点: 不要把 Vue 路由等同于某个组件,也不要以为路由只负责页面跳转。本节强调的是映射关系:路径对应组件。


总结:什么是路由,Vue 中的路由是什么
为什么需要: 本节最后要能用自己的话回答两个问题:什么是路由?Vue 中的路由是什么?这是后续学习路由配置的基础。
怎么想: 路由可以理解为一种映射关系。生活中的路由是设备和 IP 的映射;Vue 中的路由是路径和组件的映射。单页面应用要按需更新,就需要这种映射来告诉页面:当前路径该显示哪个组件。
怎么做: 复述时抓住三个关键词:单页面应用、按需更新、路径与组件映射。先说明为什么需要路由,再说明路由是什么,最后落到 Vue 中的具体含义。
易错点: 不要把“路由”和“路由器设备”混为一谈;也不要把“路径”和“组件”割裂开。本节没有给出路由代码,学习时不要凭空补写未讲过的配置,先把概念关系记牢。
课件图示








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