主题
自定义指令(三)
本页对应 自定义指令,共 6 段视频,约 1 小时 8 分。
学习目标
- 能够封装 MyTable 组件,通过 props 传入数据和列配置,并使用插槽自定义列结构,在列表页中完成渲染验证。
- 能够说明 SPA 的核心特点,使用 Vue Router 完成路由基本配置,将页面组件放入 views、复用组件放入 components,并将路由配置抽离到独立模块。
- 能够使用 Element Plus 的抽屉和表单组件搭建添加表单,局部注册并使用富文本编辑器,为标题和内容配置必填规则,并通过失焦校验与提交完整校验验证效果。
- 能够封装添加 API,在表单完整校验通过后提交数据,并在成功/失败分支中完成提示、关闭抽屉、重置表单和刷新列表。
- 能够封装删除 API,在删除操作中传递当前行 ID,完成删除请求并处理成功提示与列表刷新。
- 能够创建 Vuex 的 user 命名空间模块,定义 token 状态与更新 mutation,在登录、退出及请求/响应拦截器中读写 token,并同步 localStorage 以验证刷新后登录态不丢失。
知识点
为什么要封装一个 MyTable
列表页写多了会发现一个问题:<table>、<thead>、<tbody>、<tr v-for> 这一套骨架,每个列表页都长得一模一样,真正不一样的只有两处——表头写哪几列,以及每一行每一列里放什么内容。
如果每次都把骨架复制一遍,改一个样式就要改十个文件;更麻烦的是,v-for 的循环、key 的绑定这些和业务无关的细节散落在各个页面里,很容易写漏。
所以这里要做的封装是:把「表格的结构和循环」收进 MyTable 组件,把「长什么样」通过插槽交回给使用方。骨架归组件,内容归页面,各管一摊。
怎么想:用两个具名插槽切开表头和表体
先想清楚哪些是固定的、哪些是变化的:
<table>外壳、<thead>、<tbody>是固定的 → 写在 MyTable 里;- 表头有几个
<th>、每个叫什么,是变化的 → 用一个具名插槽head顶上去; - 每一行里放几个
<td>、每个单元格渲染什么,是变化的 → 用另一个具名插槽body。
但这里有个关键点:表体插槽的内容需要用到当前行的数据和行号。普通插槽拿不到组件内部的数据,所以 body 必须是作用域插槽,由 MyTable 在渲染每一行时把 item 和 index 传出去。
怎么做:先写 MyTable 的骨架
MyTable 只负责结构,不关心业务字段,因此它唯一需要的外部输入就是 data:
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里给type: Array是类型约束,给required: true是因为没有数据这张表就没有意义,漏传应该直接报错,而不是渲染出一张空表让人排查半天。:key="item.id"绑在<tr>上,因为v-for的宿主元素是tr,不是slot。:item="item" :index="index"这两行是作用域插槽的「出口」,少写一个,使用方就拿不到对应的值。
再写 App.vue:把结构填回插槽里
使用方这边,先按课上一直强调的顺序走:导入组件 → 局部注册 → 模板里当标签用。因为 MyTable、MyTag 都只在这一个页面用,局部注册就够了,没必要挂到全局。
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>这里有几处值得停下来解释:
#head是v-slot:head的简写,名字必须和 MyTable 里<slot name="head">完全一致,大小写也要一致。#body="{ item, index }"用的是解构写法,等价于先拿到插槽作用域对象再取属性。解构的名字必须和 MyTable 里:item="item" :index="index"绑定的名字一致,改一个就对不上。- 编号列写
{{ index + 1 }},因为index从 0 开始,展示时要从 1 开始。 <MyTag v-model="item.tag">直接绑在循环项的属性上,改的就是goods里那条数据本身,父组件不需要额外处理。
调试时按这几步排查
写完先看现象,再定位:
- 表头一列都不出来 → 八成是插槽名不匹配。检查
<slot name="head">和#head是否一致,<template>有没有写在<MyTable>内部。 - 有表头但一行数据都没有 → 先看
:data="goods"有没有传,再看goods是不是空数组。如果控制台提示data缺失,说明连props声明都没对上。 - 行渲染了,但单元格里全是空 → 作用域插槽的出口没绑。回到 MyTable 检查
<slot name="body" :item="item" :index="index">,只要漏掉:item,{ item, index }解构出来就是undefined。 - 改标签改不动、或者改完刷新又还原 → 检查
v-model绑的是不是item.tag本身,而不是某个临时变量。
易错点清单
<template #body>里写多个<td>是允许的,因为插槽内容最终会被塞进同一个<tr>中;但如果把<td>写到了<template>外面,DOM 结构就会被破坏。:key一定要绑在tr上,不要试图绑在slot上。- 组件名在模板里用 PascalCase(
<MyTable>)或 kebab-case(<my-table>)都可以,但注册时的名字要和模板里用的对得上。 props的required: true只是开发期提醒,不要指望它在运行时帮你兜底空数组。- 封装的是「结构」,不是「业务」。不要把
编号/名称/图片/标签这些具体列名写进 MyTable,否则这个组件就只能服务这一个页面了。
一、概念:什么是单页应用程序(SPA)
为什么需要:先明确概念,后面判断一个网站是不是 SPA、理解课堂项目为什么这样组织,才有依据。
怎么想:抓住关键词“一个 HTML 页面”。SPA 是 Single Page Application,即单页应用程序,指所有功能都在一个 html 页面上实现。
怎么做:看页面切换时是否整页跳转。按本节定义,功能集中在一个 HTML 页面内完成,就是单页应用的典型特征。
易错点:不要把“单页”误解成“只有一个组件”或“只有一个画面”。它强调的是入口页面只有一个,页面内部仍可以有很多功能区域和交互。转写稿中讲富文本编辑器时也顺带确认:“SPA 单页应用程序……其实就是我们那个项目是不是单页应用?我们就是单页应用。”这里“单业工作”是同音误写,应纠正为“单页应用”。
二、具体示例:用网站帮助判断
为什么需要:只背定义容易空泛,用具体网站对照更直观。
怎么想:先看笔记给出的示例,再回到“是否所有功能在一个 HTML 页面实现”这个标准。
怎么做:本节给出的单页应用网站是网易云音乐:https://music.163.com/;多页应用网站是京东:https://jd.com/。判断时以笔记示例为准,不要凭感觉把“看起来现代”的网站都叫 SPA。
易错点:示例是帮助理解的,不要把它绝对化成“所有音乐网站都是 SPA、所有电商网站都是多页”。本节只要求能对应这两个例子,并理解单页与多页的差别。
三、单页应用 VS 多页面应用
为什么需要:只有对比,才能看出 SPA 的边界和应用场景。
怎么想:从“页面如何组织功能”来想:单页应用把功能放在一个 HTML 页面中;多页应用则对应多个页面。
怎么做:结合本节对比图记忆:

单页应用类网站包括:系统类网站、内部网站、文档类网站、移动端站点;多页应用类网站包括:公司官网、电商类网站。
易错点:不要只记“单页体验好、多页体验差”这种未经本节展开的结论。本节笔记的总结只提出“单页面应用优缺点”这个问题,并没有展开具体优缺点,因此这里应先把概念、示例和场景对应清楚,不额外编造结论。
四、应用场景:什么时候更像 SPA
为什么需要:学概念最终要能落到选型判断上。
怎么想:看功能是否集中在系统内部、文档阅读、移动端站点等场景中,并且强调在一个页面内完成多处功能。
怎么做:按笔记归类:系统类网站、内部网站、文档类网站、移动端站点更偏单页应用;公司官网、电商类网站更偏多页应用。课堂项目也可作为例证:转写稿中讲添加、编辑、预览时,通过按钮打开同一个抽屉,关闭时也调用关闭抽屉的方法,并“根据不同参数区分不同的类型”。这类交互是在当前页面内完成的,符合本节所说的单页应用使用方式。
易错点:不要把“有多个功能”误判成多页应用。SPA 也可以有列表、分页、添加、删除等多个功能,只是它们在一个 HTML 页面内组织。
五、总结:三个问题要能回答
为什么需要:最后用问题自查,避免只记住零散例子。
怎么想:围绕本节总结的三个问题回忆:1. 什么是单页面应用程序?2. 单页面应用优缺点?3. 单页应用场景?
怎么做:能说出 SPA 是“所有功能都在一个 html 页面上实现”;能举出网易云音乐与京东的对比;能说出系统类、内部、文档类、移动端站点等场景。优缺点部分按本节笔记只作为待回答问题保留,不擅自补充未讲内容。
易错点:不要把“单页应用”写成“单页应”;不要把“SPA”展开错。本节笔记没有给出代码示例,因此不额外补代码,只保留原图和原有链接信息。
单页面应用为什么强调“按需更新”
为什么需要: 单页面应用程序开发效率高、性能好、用户体验好,最大的原因就是“页面按需更新”。转写稿里也确认过:我们的项目就是 SPA,也就是单页应用程序,不是每次点击都整页刷新。
怎么想: 看笔记里的图,点击【发现音乐】和【关注】时,只是更新下面部分内容,头部是不更新的。也就是说,页面要拆成“稳定区域”和“变化区域”,变化区域再按当前访问路径决定显示什么。
怎么做: 先承认一个前提:按需更新不是“所有内容都换”,而是只替换需要变化的那一块。头部、导航等公共区域保持不动,内容区根据访问路径切换组件。
易错点: 把“按需更新”理解成整个页面都重新渲染;只做了视觉上的切换,却没有建立“访问路径 → 组件”的关系;忽略头部不更新这一观察,导致后面无法理解路由的作用。

先明确:访问路径和组件要建立对应关系
为什么需要: 要按需更新,首先就要明确:访问路径和组件的对应关系。否则点击【发现音乐】时,程序不知道内容区应该显示哪个组件。
怎么想: 点击导航改变的是访问路径,访问路径再决定内容区渲染哪个组件。笔记里的问题很关键:“访问路径和组件的对应关系如何确定呢?路由。”
怎么做: 在思路上先列清楚:哪个路径对应哪个组件,哪个组件放在内容区,哪些区域保持不变。这个“对应关系”不是临时判断,而是需要被明确管理起来。
易错点: 路径和组件随意绑定;多个路径对应同一个组件却没有说明;只改地址栏或只改显示内容,却没有真正形成映射关系。
路由是什么:从生活映射到 Vue 映射
为什么需要: 需要用一个统一概念来描述“对应关系”,这个概念就是路由。
怎么想: 生活中的路由是设备和 IP 的映射关系;Vue 中的路由是路径和组件的映射关系。两者本质都是“一个东西对应另一个东西”。
怎么做: 把路径当作查找依据,把组件当作匹配结果。访问某个路径时,就按映射关系找到对应组件,并把它渲染到需要更新的区域。
易错点: 把前端路由和后端接口路由混为一谈;把路由理解成“组件本身”;认为路由一定要整页刷新。笔记强调的是映射关系,不是某个具体页面。


Vue 中的路由:路径和组件的映射
为什么需要: Vue 项目要做单页面局部切换,就需要管理“访问路径”和“组件”的对应关系,否则内容区无法按需更新。
怎么想: Vue 中的路由可以概括为:路径和组件的映射关系。路由不是页面,而是一张“查表规则”:当前路径匹配到哪个组件,就显示哪个组件。
怎么做: 先明确路径与组件的对应表,再让内容区根据匹配结果渲染组件;导航变化时,优先改变访问路径,由映射关系决定显示内容。
易错点: 路径写错或组件没有正确对应;把路由配置放到无关位置;只关注点击效果,不关注“当前路径对应哪个组件”;误以为 SPA 没有路径概念。
本节总结与转写稿对照
什么是路由: 路由是映射关系。生活中的路由是设备和 IP 的映射;Vue 中的路由是路径和组件的映射。
Vue 中的路由是什么: 是“访问路径”和“组件”的对应关系,用来支撑单页面应用的按需更新。
需要说明:本节转写稿大量内容在讲添加表单、富文本编辑器、非空校验、提交数据、删除功能以及把 token 存到 Vuex 等后续实操,与“路由介绍”并不直接对应;其中可用的相关推导主要是确认“项目就是 SPA 单页应用”。转写稿里的同音字应纠正为:SPA、单页应用、富文本编辑器、非空校验、token、Vuex、拦截器、命名空间等。由于转写稿没有提供路由配置的实际调试过程,这里不编造路由代码和调试步骤,只按笔记把路由的概念、映射关系和易错点讲清楚。
本节目标与路由作用
本节要认识 Vue Router 插件,并掌握它的基本使用步骤。Vue Router 的核心作用是:修改地址栏路径时,切换显示匹配的组件。也就是说,页面不再靠整页跳转来切换内容,而是由路由根据路径决定当前渲染哪个组件。它是 Vue 官方的一个路由插件,但本身是一个第三方包,Vue 2 项目对应使用 3.x 版本,官网是 https://v3.router.vuejs.org/zh/。
为什么需要:如果只写普通组件,地址栏变化和组件显示之间没有关联;怎么想:把“路径”和“组件”建立映射关系;怎么做:先安装并注册 Vue Router,再创建路由对象并注入 Vue 实例;易错点:不要把 Vue Router 当成 Vue 内置能力,必须安装、引入、注册、创建、注入。
五个基础步骤:先让项目被路由接管
笔记把基础步骤总结为“5+2”中的 5 步。它们不需要死背,但要理解每一步解决什么问题。
- 下载 Vue Router 模块到当前工程,版本 3.6.5:
bash
yarn add vue-router@3.6.5- 在
main.js中引入 Vue Router:
vue
import VueRouter from 'vue-router'- 安装注册:
vue
Vue.use(VueRouter)- 创建路由对象:
vue
const router = new VueRouter()- 注入,将路由对象注入到
new Vue实例中,建立关联:
vue
new Vue({
render: h => h(App),
router:router
}).$mount('#app')为什么需要:Vue.use 是插件初始化,new VueRouter() 是创建路由实例,router 注入后 Vue 才知道用路由管理地址;怎么想:安装是准备能力,注册是启用能力,创建对象是配置能力,注入是让整个应用使用能力;怎么做:按顺序写进 main.js;易错点:版本不要随意混用,Vue 2 项目按笔记使用 3.6.5;router 注入名不要写错。
配置完以上 5 步后,浏览器地址栏中的路由会变成 /#/ 的形式,表示项目的路由已经被 Vue Router 管理了。

完整 main.js 示例:
vue
// 路由的使用步骤 5 + 2
// 5个基础步骤
// 1. 下载 v3.6.5
// yarn add vue-router@3.6.5
// 2. 引入
// 3. 安装注册 Vue.use(Vue插件)
// 4. 创建路由对象
// 5. 注入到new Vue中,建立关联
import VueRouter from 'vue-router'
Vue.use(VueRouter) // VueRouter插件初始化
const router = new VueRouter()
new Vue({
render: h => h(App),
router
}).$mount('#app')两个核心步骤:组件、规则、导航、出口
完成 5 步只是让路由接管项目,真正实现“路径改变,组件切换”,还要做两个核心步骤。
第一步:创建需要的组件,通常放在 views 目录,并配置路由规则。为什么需要:路由必须知道“哪个路径对应哪个组件”;怎么想:先有组件,再写路径和组件的映射;怎么做:在 views 中创建组件,在路由配置中写规则;易错点:组件路径、组件名、路径字符串要对应,不要只创建组件却忘记配置规则。

第二步:配置导航,配置路由出口。导航负责改变地址,router-view 负责显示路径匹配的组件。App.vue 示例:
vue
<div class="footer_wrap">
<a href="#/find">发现音乐</a>
<a href="#/my">我的音乐</a>
<a href="#/friend">朋友</a>
</div>
<div class="top">
<router-view></router-view>
</div>为什么需要:只有规则没有导航,用户无法触发路径变化;只有导航没有出口,组件没有显示位置;怎么想:a 标签里的 #/find 等是 hash 路径,router-view 是占位出口;怎么做:导航写 hash 地址,出口写 <router-view></router-view>;易错点:不要把 router-view 写成普通组件标签,也不要漏写出口导致路径变了但页面不显示。
转写稿中大量内容讲的是添加表单、富文本编辑器、非空校验、删除和 token 存储,并不属于本节“路由基本使用”的安装与配置过程,因此不能把那些步骤硬套成路由步骤。转写稿里“注册组件后当标签用”的说法可以帮助理解:router-view 也像一个占位组件,由路由决定它内部显示谁。转写稿中的“副文本”应纠正为“富文本”,“飞空校验”应纠正为“非空校验”,“头ken/token”应纠正为 token;这些属于后续功能开发,不是本节路由的核心代码。
调试与易错点
路由基础调试主要看两点:地址栏是否出现 /#/,以及切换导航时 router-view 位置是否显示对应组件。为什么需要:只看代码容易漏掉注入或出口;怎么想:先确认路由是否接管项目,再确认路径和组件映射,最后确认出口位置;怎么做:检查 main.js 是否 Vue.use(VueRouter)、是否 new VueRouter()、是否注入 router,再检查 App.vue 是否有导航和 <router-view>;易错点:版本不匹配、忘记 Vue.use、忘记注入、路径写错、出口漏写,都会导致“地址变了但组件不切换”或“组件根本不显示”。
小结
本节要记住两个问题:第一,如何实现路径改变、对应组件切换?使用 Vue Router 插件。第二,Vue Router 的使用步骤是什么?是“5+2”:下载、引入、安装注册、创建路由对象、注入 Vue 实例这 5 个基础步骤,再加上创建组件并配置路由规则、配置导航和路由出口这 2 个核心步骤。先让路由接管项目,再建立路径与组件映射,最后用导航和出口完成切换。
先厘清:.vue 文件本质无区别
为什么需要:初学组件目录时,最容易把“页面组件”和“复用组件”当成两种不同技术。其实不管放在哪里,它们都是 .vue 文件,写法、单文件组件结构、template/script/style 都没有本质区别。先明确这一点,才不会误以为放进 views 就“更高级”,放进 components 就“只能复用”。
怎么想:分类不是改文件类型,而是按“使用场景”和“维护职责”分类。一个 .vue 文件是页面组件还是复用组件,要看它在项目里承担什么角色:是配置路由时直接使用的页面,还是被多个组件重复使用的展示块。
怎么做:在项目里先问两个问题:它是否要出现在路由规则里?它是否会被多个组件引用?前者偏向页面组件,后者偏向复用组件。注意,这个判断是组织代码的依据,不是语法限制。
易错点:不要认为 .vue 文件放在 components 里就不能当页面用,也不要认为 views 里的文件绝对不能抽取复用。分类是约定,目的是维护方便;真正的技术本质仍然是 .vue 文件。

组件分类:页面组件与复用组件
为什么需要:项目一大,所有 .vue 文件混在一起,找文件、改页面、排查引用都会变慢。分类的目的是让“页面级入口”和“可复用块”一眼可见,降低维护成本。
怎么想:笔记把 .vue 文件分为两类:页面组件是配置路由规则时使用的组件;复用组件是多个组件中都会使用到的组件。判断时不要只看组件大小,而要看它在项目中的位置:页面组件通常对应一个路由地址,复用组件通常被别的组件引入。
怎么做:配置路由时使用的组件,归为页面组件;在多个组件中重复出现的展示块,归为复用组件。结合转写稿里的项目语境,登录页、面经列表页这类有明确页面入口的内容,应按页面组件理解;抽屉中的表单内容如果只服务当前页面,先不要急着抽成复用组件,等确认多个位置都要用时再抽。转写稿里“副文本编辑器”应纠正为“富文本编辑器”,“飞空校验”应纠正为“非空校验”,但这些属于后续功能,不是本节目录分类的重点。
易错点:不要把“组件”两个字等同于“复用组件”。页面也可以是组件,只是它主要配合路由使用。也不要为了分类而过度拆分:一个只在当前页面出现一次的表单块,硬放进 components 反而增加查找成本。
存放目录:src/views 与 src/components
为什么需要:分类只有落到目录上才有可执行性。目录是团队协作的“路标”,让后来的人知道去哪里找页面、去哪里找复用块。
怎么想:笔记给出两个目录:src/views 放页面组件,页面展示、配合路由用;src/components 放复用组件,展示数据、常用于复用。也就是说,views 更偏“路由入口”,components 更偏“被组合的零件”。
怎么做:新建页面级 .vue 文件时,优先放到 src/views;新建多个组件都会引用的展示组件时,放到 src/components。检查时可以做两步:第一,看路由配置里引入的组件是否来自 views;第二,看某个组件是否被多个页面或组件重复引用,若是,再考虑放入 components。
易错点:不要只看文件名决定目录。叫 List.vue 的不一定是页面组件,也可能是复用列表;叫 Form.vue 的也可能只属于某个页面。另一个易错点是路径写错:移动文件后要同步修改路由或引用路径,否则编译和运行会报找不到模块。转写稿中“投K/图肯/托肯”应统一理解为 token,“VUX/VVX”应统一理解为 Vuex,但这些是后续状态管理内容,本节不展开。
总结检查:分类目的与目录职责
为什么需要:本节最后要能回答两个总结问题:组件分类有哪两类、分类的目的是什么;不同分类的组件应该放在什么文件夹、作用分别是什么。能回答,才说明不是死记目录名。
怎么想:分类有两类:页面组件和复用组件;目的是更易维护。src/views 放页面组件,负责页面展示、配合路由用;src/components 放复用组件,负责展示数据、常用于复用。
怎么做:写代码前先判断角色,再放目录;写完后检查路由引用和组件引用是否一致。若发现一个“复用组件”其实只被一个页面使用,可以暂时留在页面附近;若发现一个页面内部块被多个页面复制,再抽到 components。
易错点:不要把目录约定当成硬性语法;也不要把“分类”理解成“功能不同”。本质仍是 .vue 文件,区别只在项目职责和存放位置。本节只解决存放目录问题,转写稿中后续的表单、富文本、校验、删除、token 存 Vuex 等内容,不应混入本节结论。
先问一句:所有路由配置都塞在 main.js 里合适吗
前面写路由时,最省事的做法是:main.js 里 import 组件、createRouter、写 routes 数组、app.use(router),一条龙全写完。页面少的时候看不出问题,但只要路由多几条,main.js 就会变成一个"什么都往里塞"的文件:入口逻辑、路由表、组件引入混在一起,找个路由要翻半天,改错一行还可能连带影响整个应用启动。
这就是本节要解决的问题:入口文件应该只负责"启动应用",路由表应该有自己的家。
怎么想:路由本身就是一块独立的配置
换个角度看,main.js 的职责其实只有一件事——创建应用、挂上插件、挂载到页面。而"有哪些路由、每个路由对应哪个组件"是另一件事,它有自己的生命周期,会被反复修改(加页面、改路径、加子路由)。
既然是两件事,就应该拆成两个文件。拆出去之后:
- 改路由只动路由文件,不用碰入口文件;
- 入口文件变短,一眼能看清应用是怎么启动的;
- 路由文件可以被单独阅读、单独维护。
笔记里把好处总结成一句话:拆分模块,利于维护。

怎么做:把路由搬进独立模块
在 src 下建一个路由模块文件(习惯上叫 src/router/index.js),把原来写在 main.js 里的路由配置整段搬过去,最后导出这个 router 实例:
js
// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
// 原来写在 main.js 里的路由配置,原样搬到这里
routes: [
// { path: '/xxx', component: Xxx }
]
})
export default router这里的关键动作只有两个:搬家(配置整段挪走)和导出(export default router)。配置内容本身不用改,路径、组件、子路由都保持原样,这样风险最小。
main.js 只留"注册"这一步
路由搬走之后,main.js 里跟路由相关的就只剩两行:引入 + 使用。
js
// src/main.js
import router from '@/router'
createApp(App).use(router).mount('#app')这样 main.js 恢复成"入口该有的样子":引入根组件、引入路由、创建应用、挂载。以后再加页面,直接去路由文件里加,main.js 不用再动。
顺手记住 @ 这个路径简写
抽离之后,引入路径会变长,比如从 main.js 引路由,或者在路由文件里引组件,经常要写一串 ../。脚手架环境提供了一个简写:
@ 指代 src 目录,可以用于快速引入组件。
也就是说,原来要一层层往上退的路径,现在直接从 src 开始写:
js
// 原来可能要写:import Xxx from '../../views/xxx.vue'
import Xxx from '@/views/xxx.vue'这个 @ 是脚手架配好的别名,不需要自己配置,直接用即可。它和相对路径是等价的,只是更短、更稳定——文件挪位置时,相对路径要跟着改,@ 开头的不用。
易错点
- 只搬了配置,忘了导出/引入。 路由文件末尾一定要
export default router,main.js里一定要import router from '@/router',少一个都会报错或路由失效。 main.js里还留着旧的routes数组。 搬完之后要把原来的配置删干净,否则两处配置并存,改了一处不生效,排查起来很费劲。@写成@/之外的形态。@后面要跟斜杠再接目录,写成@views这种是不对的。- 以为抽离会改变路由行为。 抽离只是"换个文件放",路径、组件、跳转逻辑都不变,如果抽离后页面打不开,先怀疑引入路径和导出写法,而不是路由配置本身。
回答笔记里的两个总结问题
- 路由模块的封装抽离的好处是什么? 拆分模块,利于维护:入口文件只负责启动,路由配置独立成文件,职责清晰、修改集中。
- 以后如何快速引入组件? 用
@指代src目录,写成@/目录/文件的形式引入。
一点说明:本节转写稿与主题不对应
需要如实说明:本节附带的转写稿实际记录的是表单搭建、富文本编辑器接入、表单校验、提交与删除、以及把 token 存进 Vuex 等内容,属于其他小节的操作过程,并没有出现路由封装抽离的讲解。因此上面的推导与代码示范,是依据本节笔记本身(问题、目标、好处、@ 简写、总结)以及路由抽离的通用做法整理的,没有从转写稿中搬运不相关的内容。转写稿中"把数据拆到 modules 里、按需导入"的模块化思路,与本节的"抽离成独立模块"在思想上是一致的,但具体写法不能直接套到路由上。
课件图示








代码示例
示例 1:封装 MyTable 组件(具名插槽 + 作用域插槽)
components/MyTable.vue
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 {
name: 'MyTable',
props: {
data: {
type: Array,
required: true
}
}
}
</script>components/MyTag.vue(笔记中仅被引用,此处补一个最简 v-model 实现,便于运行)
vue
<template>
<span class="my-tag" @click="toggle">{{ value }}</span>
</template>
<script>
export default {
name: 'MyTag',
props: {
value: { type: String, default: '' }
},
methods: {
toggle () {
this.$emit('input', this.value === '无' ? '精品' : '无')
}
}
}
</script>App.vue
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 {
// 数据项按接口返回结构补充
goods: [
{ id: 1, name: '商品A', picture: 'https://via.placeholder.com/60', tag: '无' }
]
}
}
}
</script>关键行与易错点
:data="goods":把数据交给子组件,子组件用props的data接收,required: true保证不漏传。<slot name="head">:表头是结构由父组件决定、位置由子组件决定,所以用具名插槽。<slot name="body" :item="item" :index="index">:数据在子组件(v-for 的 item/index),结构在父组件,所以必须把item、index当插槽 prop 传出去;父组件用#body="{ item, index }"解构接收。- 易错点 1:
<slot name="body">忘了写:item="item" :index="index",父组件里就取不到item,会报 undefined。 - 易错点 2:父组件插槽名写错(如
#head写成#header),插槽内容不渲染,且不报错,很难查。 - 易错点 3:
<table>内只能放tr/th/td等表格标签,不要用div包一层,否则浏览器会破坏结构。 - 易错点 4:
v-for的:key用item.id,不要用index。
示例 2:VueRouter 基本使用(5 + 2 步)
main.js
js
// 路由的使用步骤 5 + 2
// 1. 下载 yarn add vue-router@3.6.5
// 2. 引入
// 3. 安装注册 Vue.use(Vue插件)
// 4. 创建路由对象
// 5. 注入到 new Vue 中,建立关联
import Vue from 'vue'
import App from './App.vue'
import VueRouter from 'vue-router'
import Find from './views/Find.vue'
import My from './views/My.vue'
import Friend from './views/Friend.vue'
Vue.use(VueRouter) // VueRouter 插件初始化
const router = new VueRouter({
routes: [
{ path: '/', redirect: '/find' },
{ path: '/find', component: Find },
{ path: '/my', component: My },
{ path: '/friend', component: Friend }
]
})
new Vue({
render: h => h(App),
router
}).$mount('#app')App.vue
vue
<template>
<div id="app">
<div class="footer_wrap">
<a href="#/find">发现音乐</a>
<a href="#/my">我的音乐</a>
<a href="#/friend">朋友</a>
</div>
<div class="top">
<router-view></router-view>
</div>
</div>
</template>
<script>
export default {
name: 'App'
}
</script>views/Find.vue / My.vue / Friend.vue(三个页面组件结构相同)
vue
<template>
<div class="page">发现音乐</div>
</template>
<script>
export default { name: 'Find' }
</script>关键行与易错点
Vue.use(VueRouter)是插件安装注册,漏写这一步,router-view不生效、路由不工作。const router = new VueRouter({ routes })中routes是规则数组,router是路由对象,两者名字只差一个 s,极易写错。new Vue({ router })注入后,浏览器地址栏会变成/#/形式,说明路由已被 Vue-Router 接管。<router-view>是路由出口,路径匹配到的组件在这里渲染,缺了它就什么也不显示。- 易错点 1:hash 模式下导航要写
href="#/find",只写/find会跳成新页面、刷新丢失。 - 易错点 2:
component是组件对象(component: Find),不是字符串'Find'。 - 易错点 3:页面组件放
src/views,复用组件放src/components,目录分类只是为了维护方便,.vue文件本身无区别。
示例 3:路由模块封装抽离
router/index.js
js
import VueRouter from 'vue-router'
import Find from '@/views/Find.vue'
import My from '@/views/My.vue'
import Friend from '@/views/Friend.vue'
const router = new VueRouter({
routes: [
{ path: '/', redirect: '/find' },
{ path: '/find', component: Find },
{ path: '/my', component: My },
{ path: '/friend', component: Friend }
]
})
export default routermain.js
js
import Vue from 'vue'
import App from './App.vue'
import VueRouter from 'vue-router'
import router from './router'
Vue.use(VueRouter)
new Vue({
render: h => h(App),
router
}).$mount('#app')关键行与易错点
- 把路由规则从
main.js抽到router/index.js,main.js只负责引入并注入,拆分模块、利于维护。 import router from './router'默认会找router/index.js,目录下的 index 文件可以省略路径。@在脚手架环境下指代src目录,所以@/views/Find.vue等价于src/views/Find.vue。- 易错点 1:
Vue.use(VueRouter)仍需留在main.js(或写在router/index.js中),漏了插件不生效。 - 易错点 2:导出要用
export default router,引入时变量名随意但不能加花括号;若写import { router }会拿到 undefined。 - 易错点 3:
router/index.js里不要再写new Vue(...),这里只负责创建并导出路由对象。
常见错误
1. 作用域插槽拿不到 item / index
- 现象:
<template #body="{ item, index }">里item为undefined,渲染直接报错或空白。 - 原因:MyTable 内部
<slot name="body">忘了把数据"递出去",只写了插槽名没写属性。 - 修法:出口写成
<slot name="body" :item="item" :index="index"></slot>;入口用#body="{ item, index }",两边属性名必须逐字对齐(item不是row,index不是i)。
2. 表格 :key 用了 index
- 现象:删除某行后,剩下的行里
<MyTag>的选中态"串行",或图片错位。 - 原因:
v-for的 key 用索引,删除后索引整体前移,Vue 复用 DOM 时把状态留在了错误的行上。 - 修法:
:key="item.id",用数据自身唯一标识;同时确认接口返回的数据里确实有id。
3. 富文本编辑器内容不参与失焦校验
- 现象:标题失焦立刻出红字,内容区清空后失焦毫无反应;但点确认时完整校验又能拦住。
- 原因:
el-form-item的trigger: 'blur'依赖内部表单控件派发原生事件,quill 这类编辑器渲染出的不是input/textarea,事件不会冒泡给 el-form-item。 - 修法:给编辑器组件单独绑
@blur,在回调里手动校验单个字段this.$refs.form.validateField('content');rules里该字段的trigger可以删掉或保留(不影响)。
4. 只写了 :model,漏了 :rules 或 prop
- 现象:输入框旁边的小红星出现了,但无论怎么提交都能过,或者红字永远不出现。
- 原因:
el-form的:model(数据源)、:rules(规则表)、el-form-item的prop(字段名)是三角关系,缺一个校验链就断了;prop拼错大小写(contentvsContent)同样静默失效。 - 修法:三处字段名逐字核对一致;
prop的值必须是form对象里真实存在的 key。
5. 点"确认"直接调接口,没做完整校验
- 现象:什么都不填也能提交成功,列表里出现空标题的面经。
- 原因:只做了失焦校验,失焦校验是"提示性"的,用户完全不碰输入框就绕过去了。
- 修法:
onSubmit第一步this.$refs.form.validate(valid => { if (!valid) return }),通过后才await调接口;$refs记得在el-form上挂ref。
6. 关闭抽屉不重置表单
- 现象:第一次添加成功,第二次打开抽屉,上次填的标题内容还在,红字校验提示也还在。
- 原因:抽屉只是
visible = false,组件没销毁,form数据原样保留。 - 修法:在关闭抽屉的方法里
this.$refs.form.resetFields()(注意resetFields带 s,依赖el-form的ref和prop);重置放在关闭方法里,添加/编辑/取消三条路径都能复用。
7. token 只存 Vuex,一刷新就掉登录
- 现象:登录后正常,F5 刷新页面直接跳回登录页。
- 原因:Vuex 存在内存中,刷新即清空。
- 修法:
state初始值从localStorage取(token: localStorage.getItem('token') || ''),mutation更新时同步写一份localStorage;拦截器、登录、退出统一走this.$store.commit('user/updateToken', xxx),别在别处直接读写localStorage。另外namespaced: true没开时commit('user/updateToken')会报 unknown mutation。
练习
给 MyTable 加"空数据"占位:
data为空数组时,tbody显示一行"暂无数据",且这一行要跨全部列(提示:<colspan>无效,table里得用<td :colspan="列数">,列数可由#head插槽无法获取——想一想是加个columnsprop,还是用<slot name="empty">让使用者自己写)。把 MyTable 的
#body改成"默认插槽兜底":不传#body时,自动按data的 key 渲染普通文本列;传了#body则完全走自定义结构。写完后用它渲染一份不带头部插槽的简单表格验证。复刻富文本校验链路:新建一个最小组件,含
el-form+ 一个el-input(标题)+ 一个 quill 编辑器(内容),要求:标题失焦即校验、内容失焦即校验、点"确认"做完整校验并console.log通过后的表单数据。重点体会validateField与validate的区别。抽屉三态打通:同一套表单,用
mode: 'add' | 'edit' | 'view'控制——add 打开时清空、edit 打开时回填、view 打开时只读(输入框disabled、按钮区隐藏)。要求:切换态时校验红字必须干净。token 持久化改造:把现有代码里的
localStorage读写全部替换为 Vuex 模块调用,并手写一个"刷新后仍保持登录"的验证流程:登录 → 刷新 → 看store.state.user.token是否还在 → 退出 → 再刷新确认已清空。
原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | 添加的表单 | 05:23 |
| 2 | 富文本编辑器 | 13:44 |
| 3 | 添加表单校验 | 11:47 |
| 4 | 提交数据完成添加 | 12:03 |
| 5 | 删除功能 | 06:22 |
| 6 | 将token存到vuex中 | 19:26 |
小结
- 插槽是"结构外置":MyTable 负责循环与骨架,
#head/#body负责列结构,父子之间唯一的契约是插槽名 + 属性名,任何一边拼错都是静默失败。 :key不是形式主义,凡是列表里含"组件内部状态"(如 MyTag 的选中态)的场景,用 index 做 key 一定会踩到复用错位。- SPA 的本质是"按需更新";路由解决的是"路径 → 组件"的映射,
router-view是映射结果落地的唯一出口,缺了它地址变了页面不动。 - Vue2 项目必须锁
vue-router@3.x,5 个固定步骤(下载 → 引入 →Vue.use→new VueRouter→ 注入实例)+ 2 个核心步骤(配规则 → 配导航和出口),顺序错了就白屏。 - 组件分
views与components只关乎可维护性,与功能无关;路由模块从main.js抽离到独立文件、用@简写引入,是项目规模变大后的必然动作。 - Element 表单校验只有一条主链:
:model→:rules→prop,任何一环缺失都表现为"校验静默失效",排查时按这条链从上往下点。 - 失焦校验与完整校验是两套机制:前者靠
trigger自动触发,后者靠validate手动兜底;非原生控件(富文本、日期选择器等)必须手动接validateField。 - 抽屉/弹窗类表单的三件套是"打开时初始化数据、关闭时
resetFields、提交成功后再关闭",漏掉重置这一环,第二次打开必然是脏数据。 - Vuex 是内存态的"快",
localStorage是硬盘态的"久",两者配合的写法是"初始化从本地读、更新时双写",只存一个 token 时手写两行比引插件更划算。