主题
路由与通用组件封装(一)
本页对应 路由与通用组件封装,共 7 段视频,约 1 小时 4 分。
学习目标
- 能够按照“一级路由 / 二级路由”的设计规则,在
router/index.ts中为登录页配置懒加载路由,并在App.vue中放置路由出口,通过访问/login验证页面正常渲染。 - 能够安装并配置
unplugin-vue-components与 Vant 解析器,实现 Vant 组件与components目录下组件的自动注册,并通过dts: false、importStyle: false消除类型声明文件与样式重复引入的问题,且能在不写 import 的情况下正常使用按钮、复选框组件。 - 能够基于 Vant 导航组件二次封装
cp-nav-bar,通过fixed、left-arrow、title、right-text属性搭建结构,并使用:deep()深度选择器将左侧箭头改为黑色 18px、标题改为 15px,使样式与设计稿一致。 - 能够使用
defineProps声明可选的title、rightText属性实现标题与右侧文字的动态设置,并在点击右侧文字时通过defineEmits触发click-right自定义事件(无文字时不触发),在父组件中验证事件回调被正确执行。 - 能够利用
history.state.back判断是否存在访问历史,在click-left回调中分别执行router.back()或router.push('/'),并分别在“有历史”与“直接访问”两种场景下验证回退结果。 - 能够通过
declare module 'vue'扩展GlobalComponents并用typeof 组件为全局组件补充类型,使自动注册的cp-nav-bar获得属性提示与类型校验;同时能引入全局重置样式、用 CSS 变量定制 Vant 表单项间距/复选框尺寸/按钮字号,并清理App.vue多余容器、跳转链接及router/index.ts中的调试打印。
知识点
先明确:优医问诊的路由规则怎么设计
为什么需要:进入页面开发前,必须先知道“哪些页面整页切换、哪些页面只切换局部”。否则组件放哪、路由出口放哪都会乱。
怎么想:登录页→个人中心,是整页替换,设计成一级路由;个人中心→首页时,底部 TabBar 不动,只换上方内容,设计成二级路由。于是形成“一级布局容器 + 二级内容页”的嵌套路由。
怎么做:按笔记中的路由规则表配置:登录页、结束问诊页等属于一级路由;布局容器是一级路由,里面承载首页、健康百科、消息通知、个人中心等二级路由。
易错点:不要把登录页塞进带 TabBar 的布局容器里;一级路由出口要放在 App.vue,二级路由出口要放在布局容器组件里。
登录页路由与路由出口
为什么需要:只有组件没有路由,浏览器访问不到页面;只有路由没有出口,组件也不会显示。
怎么想:先在 views/login/index.vue 准备登录组件,再到 router/index.ts 增加 /login 规则,最后在 App.vue 放一级路由出口。
怎么做:
ts
// router/index.ts
{
path: '/login',
component: () => import('@/views/login/index.vue')
}vue
<!-- App.vue -->
<template>
<router-view />
</template>调试时访问 /login,如果看不到页面,先检查 App.vue 是否还有旧测试代码、是否写了 router-view。
易错点:路由懒加载要写在 component 里;App.vue 是一级出口,不要误放到登录组件内部。
配置 Vant 组件自动注册
为什么需要:每个 Vant 组件都手写 import 很繁琐。自动注册后,模板里可直接使用。
怎么想:用 unplugin-vue-components 插件,在 vite.config.ts 的 plugins 中启用,并配置 VantResolver。
怎么做:安装插件后配置解析器;再处理两个优化:importStyle: false 避免与 main.ts 中已导入的 Vant 样式重复;dts: false 避免自动生成重复类型声明文件。
调试过程:先手写导入按钮、复选框,页面正常;配置自动注册后删掉导入,页面仍正常。检查元素发现同一组件样式出现两份,开启 importStyle: false 后只剩一份;删除自动生成的 components.d.ts 后不再反复生成。
易错点:不是去 main.ts 做全局注册;插件配置在 vite.config.ts,不是 main.ts。
ts
// vite.config.ts 关键配置示意
Components({
resolvers: [VantResolver()],
importStyle: false,
dts: false
})封装 cp-nav-bar 的结构与样式
为什么需要:多个页面都有顶部通栏,且样式接近,应封装成通用组件。
怎么想:基于 van-nav-bar 二次封装,结构用 Vant 属性实现:固定定位、左箭头、标题、右侧文字;再用深度选择器覆盖样式。
怎么做:在 components 下新建 cp-nav-bar.vue,模板使用 van-nav-bar,加上 fixed、left-arrow、title、right-text。样式覆盖左箭头颜色和字号、标题字号:
vue
<van-nav-bar fixed left-arrow title="登录" right-text="注册" />
<style scoped lang="scss">
:deep(.van-nav-bar__arrow) {
color: var(--cp-text1);
font-size: 18px;
}
:deep(.van-nav-bar__title) {
font-size: 15px;
}
</style>易错点:覆盖 Vant 内部样式要用 :deep();选择器要找对,箭头和标题分别是不同类名。
标题、右侧文字与右侧点击事件
为什么需要:通用导航不能写死“登录/注册”,要由使用方动态传入;右侧文字点击后要能执行不同业务。
怎么想:标题和右侧文字用 props,都可选;右侧点击用 emit 抛出 click-right。
怎么做:
ts
const props = defineProps<{
title?: string
rightText?: string
}>()
const emit = defineEmits<{ (e: 'click-right'): void }>()模板中绑定 :title="title"、:right-text="rightText",监听 @click-right="emit('click-right')"。调试时先打印,确认有右侧文字才触发;去掉文字后不触发,符合要求。
易错点:rightText 在模板属性中写 right-text;没有右侧文字时不要强行触发事件。
回退功能:判断 history.state.back
为什么需要:点击左箭头要能返回;若直接访问登录页,没有历史记录,不能盲目 back()。
怎么想:history.state.back 有值说明有历史,正常回退;没有则跳首页。
怎么做:
ts
const router = useRouter()
const onClickLeft = () => {
if (history.state?.back) router.back()
else router.push('/')
}调试时直接访问 /login,back 为空,点击回首页;从 /home 跳登录后,back 有值,点击能回 /home。
易错点:加可选链,避免 history.state 为空报错。
给自动注册组件补类型
为什么需要:自动注册组件不显式导入,默认类型可能是 unknown,没有属性提示和校验。
怎么想:在类型声明文件中扩展 Vue 的 GlobalComponents,用 typeof import(...)['default'] 取组件类型。
怎么做:
ts
declare module 'vue' {
interface GlobalComponents {
CpNavBar: typeof import('@/components/cp-nav-bar.vue')['default']
}
}
export {}配置后模板中组件变绿,写 right-text 有提示,传 number 会报错。
易错点:这是全局/自动注册组件的类型扩展;局部组件显式导入后通常可自动推导。
登录页布局、Vant 表单变量与清理代码
为什么需要:业务开发前要先有稳定页面结构和全局样式。
怎么想:全局重置样式放 main.scss;登录页分头部、表单、底部;表单用 Vant,但间距、复选框大小、按钮文字大小用 CSS 变量覆盖。
怎么做:复制笔记中的全局重置样式;在 views/login/index.vue 搭结构;把 QQ 图标放入 assets;在 main.scss 覆盖 Vant 变量处补三个变量。清理 App.vue 中多余的 .app 容器和测试跳转链接,删除 router/index.ts 中多余的 import.meta 打印。
易错点:Vue3 的 app.mount('#app') 是把根组件插入 #app,不是像 Vue2 替换,所以不要再套一层多余容器。
本节要解决的问题
转写稿里多次把 Vant 说成“w”,这里统一纠正为 Vant。本节的目标是:在登录页真正写业务之前,先配置 unplugin-vue-components,让 Vant 组件在模板里直接使用,不再每次手动 import。同时处理两个容易忽略的重复问题:样式重复引入、类型声明文件重复生成。
为什么需要组件自动注册
为什么需要: 登录页会大量使用 Vant 的按钮、复选框、表单等组件。如果每用一个组件都先导入,代码会变得零碎。转写稿中先在 views/login/index.vue 里演示了手动写法:
vue
<script setup lang="ts">
import { Button as VanButton, Checkbox as VanCheckbox } from 'vant'
</script>
<template>
<van-button>按钮</van-button>
<van-checkbox>复选框</van-checkbox>
</template>怎么想: 这种写法本身没错,但页面组件一多,导入语句会反复出现。自动注册的目标不是“全局注册所有组件”,而是在当前组件里不写导入,也能直接使用模板中的 Vant 组件。
怎么做: 安装插件:
bash
pnpm add -D unplugin-vue-components易错点: 这是开发依赖,不要装成生产依赖;也不要误以为要在 main.ts 里手动全局注册所有 Vant 组件。
在 Vite 中接入插件与 Vant 解析器
为什么需要: 只安装插件还不够,Vite 配置里要启用插件,并告诉它“遇到 van-* 标签时去解析 Vant 组件”。
怎么想: 配置分两步:先导入 Components 插件,再导入 VantResolver 解析器。插件负责自动注册,解析器负责识别 Vant。
怎么做: 在 vite.config.ts 中配置:
ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import Components from 'unplugin-vue-components/vite'
import { VantResolver } from 'unplugin-vue-components/resolvers'
export default defineConfig({
plugins: [
vue(),
Components({
resolvers: [VantResolver({ importStyle: false })],
dts: false
})
]
})易错点: Components 放在 plugins 数组里;VantResolver 放在 resolvers 数组里。不要把两个配置项放错层级。
优化一:关闭自动导入组件样式
为什么需要: 转写稿中在浏览器里检查按钮和复选框时,发现同一个组件出现了两份相同样式,上面的样式覆盖下面。原因是项目已经在 main.ts 里导入了 Vant 全部样式,而自动注册插件注册组件时又导入了一次组件样式。
怎么想: 既然全局样式已经引入,就不需要解析器再自动导入组件样式。关闭它即可避免重复。
怎么做: 在 VantResolver 中加 importStyle: false:
ts
VantResolver({ importStyle: false })易错点: 这个配置放在 VantResolver 里,不是放在 Components 顶层。关闭后要确认 main.ts 中确实已经引入了 Vant 样式,否则页面会缺样式。
优化二:关闭自动生成类型声明文件
为什么需要: 插件运行后会在项目里生成 components.d.ts,用于给自动注册组件做类型声明。但 Vant 自身已经有类型声明,这个文件会重复。转写稿中删除它后,改代码又会自动生成。
怎么想: 这个文件不是业务代码,删除后仍会生成,所以要从插件配置上关闭。
怎么做: 在 Components 配置中加:
ts
Components({
dts: false,
resolvers: [VantResolver({ importStyle: false })]
})易错点: dts: false 放在 Components 里;importStyle: false 放在 VantResolver 里。两者不要写反。
验证与调试过程
配置完成后,回到 views/login/index.vue,删掉手动导入,只保留模板:
vue
<template>
<van-button>按钮</van-button>
<van-checkbox>复选框</van-checkbox>
</template>刷新浏览器,按钮和复选框正常显示,说明自动注册生效。再打开元素面板检查按钮和复选框样式,发现重复样式消失,说明 importStyle: false 生效。删除 components.d.ts 后修改代码,文件不再自动生成,说明 dts: false 生效。
易错点: 如果页面报“组件未解析”,先检查插件是否安装、vite.config.ts 是否重启生效、标签名是否写对。另外,转写稿提到一个彩蛋:配置该插件后,components 目录下的组件也会自动注册,后续封装通用组件时会用到。
本节目标:封装通用顶部通栏
登录页第一个要完成的区域是顶部通栏。转写稿里先明确了一个判断:几乎每个页面都有顶部通栏,而且长得差不多,所以不要在每个页面里重复写,而是封装成通用组件 cp-nav-bar。本节只完成“结构和样式”,功能(动态标题、右侧文字、点击事件、回退)留到后续小节。
为什么需要先做结构?因为顶部通栏是页面骨架的一部分,结构不对,后面的功能和页面布局都会返工。怎么想?基于 Vant 的 van-nav-bar 二次封装:结构交给 Vant,样式再按设计稿覆盖。怎么做?先建组件、在登录页使用,再补 fixed、left-arrow、title、right-text,最后用深度选择器改样式。易错点:不要把 cp-nav-bar 当成普通页面内容直接写一堆 div,也不要在本节提前实现 props/emit。
创建组件并验证自动注册
为什么需要先建组件?因为顶部通栏要被多个页面复用,必须先有独立组件文件。怎么想?在 src/components 下新建 cp-nav-bar.vue,登录页直接使用 <cp-nav-bar />。怎么做?组件先初始化基本结构,然后到 src/views/login/index.vue 中写上:
vue
<template>
<div class="login">
<cp-nav-bar />
</div>
</template>保存后到浏览器看登录页,如果页面上出现组件内容,说明 components 下的组件已被自动注册。转写稿在这里专门验证过:没有手动 import,组件也能使用。易错点:自动注册依赖上一节配置;如果没配置,仍需显式导入。组件名、文件名要一致,避免大小写或路径写错。
用 van-nav-bar 搭出通栏结构
为什么不用自己写 div?因为 Vant 已经提供了导航栏的结构和交互基础,二次封装更省事。怎么想?把组件里的 div 换成 van-nav-bar,再用属性控制结构。怎么做?组件模板写成:
vue
<template>
<van-nav-bar
fixed
left-arrow
title="登录"
right-text="注册"
/>
</template>这里的对应关系是:fixed 做固定定位,页面滚动时通栏不动;left-arrow 显示左侧返回箭头;title 设置标题;right-text 设置右侧文字。转写稿中先写死“登录”和“注册”,是为了先看结构,后面再改成动态。易错点:left-arrow 不要写成 left-alone;right-text 不要写成 rightText 属性;fixed 加上后可能遮住下面按钮,这是固定定位的正常现象,不是样式坏了。
浏览器调试:先找选择器再覆盖
为什么不能直接凭感觉写样式?因为 Vant 内部元素有固定类名,必须先确认选择器。怎么想?先看现象:左侧箭头是绿色且偏小,标题偏大。怎么做?打开浏览器开发者工具,检查箭头元素,看到它对应 .van-nav-bar__arrow;标题对应 .van-nav-bar__title。转写稿的调试过程就是先定位选择器,再回 VS Code 覆盖。易错点:不要覆盖 .van-nav-bar 整个组件,否则会影响布局;箭头和标题的选择器都带双下划线 __,不要漏写。
用 :deep() 覆盖 Vant 内部样式
为什么需要 :deep()?因为组件样式通常写在 scoped 中,而箭头、标题是 Vant 子组件内部元素,普通选择器作用不到。怎么想?用深度作用选择器穿透到子组件内部。怎么做?在 cp-nav-bar.vue 中写:
vue
<style scoped lang="scss">
:deep(.van-nav-bar__arrow) {
color: var(--cp-text1);
font-size: 18px;
}
:deep(.van-nav-bar__title) {
font-size: 15px;
}
</style>--cp-text1 是项目里定义的黑色变量,箭头改成黑色并放大到 18px;标题改成 15px。保存后回浏览器验证:箭头变黑变大,标题变小。易错点::deep() 不要写成 :dep();括号不要漏;CSS 变量名不要拼错;font-size 要带单位。
小结与易错点
本节完成的是 cp-nav-bar 的结构和样式:建组件、自动注册验证、使用 van-nav-bar 加 fixed/left-arrow/title/right-text、通过开发者工具找选择器、用 :deep() 覆盖箭头和标题样式。最容易错的是把结构和功能混在一起写;本节先写死文字是合理的过渡。另一个易错点是固定定位后遮挡内容,后续页面布局再处理间距。完成后再提交代码,下一节再实现标题、右侧文字和点击事件等功能。
代码示例
1. 登录页路由规则 + 路由出口
ts
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: [
// 一级路由:整页切换
{
path: '/login',
component: () => import('@/views/login/index.vue')
}
]
})
export default routervue
<!-- src/App.vue -->
<script setup lang="ts"></script>
<template>
<!-- 一级路由出口,放在 App 根组件里 -->
<router-view />
</template>关键行与易错点
path: '/login'与笔记里的路由规则表保持一致,访问地址就是/login。component: () => import('@/views/login/index.vue')是路由懒加载,返回一个 import 函数而不是直接import Login from ...后赋值。- 配完路由规则必须加路由出口,否则页面白屏——出口写在
App.vue里,因为这里是一级路由(整页替换)。 - 转写稿特别强调:
App.vue里原来的测试代码要全部删掉,只保留<router-view />,否则会和新页面叠在一起。
2. Vite 组件自动注册配置(unplugin-vue-components)
ts
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import Components from 'unplugin-vue-components/vite'
import { VantResolver } from 'unplugin-vue-components/resolvers'
export default defineConfig({
plugins: [
vue(),
// 第一步:组件自动注册插件
Components({
// 第二步:配置 Vant UI 组件库解析器
resolvers: [
VantResolver({
// 优化一:不再自动导入组件样式(main.ts 里已全量引入,避免样式重复)
importStyle: false
})
],
// 优化二:不生成 components.d.ts(Vant 自带类型声明,避免类型声明文件重复)
dts: false
})
]
})关键行与易错点
- 两个解析器/选项的位置不能放错:
importStyle是VantResolver()的参数,dts是Components()的参数。 importStyle: false的原因:main.ts里已经把 Vant 全部样式引入了,插件再自动导入一份就会出现「上面一份样式、下面一份一模一样样式」的重复。dts: false的原因:插件默认自动生成components.d.ts做类型声明,但 Vant 本身已有类型声明,属于重复文件。- 配置完成后,组件里不要再写
import { Button } from 'vant',直接写<van-button>即可;components/目录下的自定义组件也会被自动注册(后面封装通用组件时会用到这个彩蛋)。
3. cp-nav-bar 组件:结构样式 + 功能 + 类型声明
vue
<!-- src/components/cp-nav-bar.vue -->
<script setup lang="ts">
import { useRouter } from 'vue-router'
// 1. props:标题、右侧文字,都是可选的
defineProps<{
title?: string
rightText?: string
}>()
// 2. 自定义事件:点击右侧文字
const emit = defineEmits<{
(e: 'click-right'): void
}>()
const router = useRouter()
// 3. 回退功能:有历史记录就 back,没有就回首页
const onClickLeft = () => {
if (history.state?.back) {
router.back()
} else {
router.push('/')
}
}
// 4. 右侧文字点击 → 抛出事件给使用方
const onClickRight = () => {
emit('click-right')
}
</script>
<template>
<van-nav-bar
fixed
left-arrow
:title="title"
:right-text="rightText"
@click-left="onClickLeft"
@click-right="onClickRight"
/>
</template>
<style scoped lang="less">
/* 深度作用选择器,覆盖 Vant 内部元素样式 */
:deep() {
.van-nav-bar__arrow {
color: var(--cp-text1); /* 箭头变黑 */
font-size: 18px; /* 箭头变大 */
}
.van-nav-bar__title {
font-size: 15px; /* 标题小一号 */
}
}
</style>ts
// src/types/components.d.ts —— 给自动注册的全局组件补类型
import CpNavBar from '@/components/cp-nav-bar.vue'
declare module 'vue' {
interface GlobalComponents {
// 从组件对象中取出类型,赋给全局组件名
CpNavBar: typeof CpNavBar
}
}
export {}vue
<!-- src/views/login/index.vue 中的使用方式 -->
<template>
<cp-nav-bar title="登录" right-text="注册" @click-right="fn" />
</template>关键行与易错点
fixed、left-arrow、:title、:right-text都是van-nav-bar的官方属性,结构不用自己写 div 拼。defineProps用泛型写法时属性名是驼峰rightText,模板里绑定时写成:right-text;传参时写right-text="注册"。- 右侧文字没传值的时候
click-right不会触发(Vant 内部行为),所以不用额外判断空值。 - 回退判断用
history.state?.back:转写稿中提到history.state的类型是 any,加可选链?.防止直接报错。 - 覆盖 Vant 内部样式必须用
:deep(),否则 scoped 样式打不到组件内部的.van-nav-bar__arrow/.van-nav-bar__title。 - 类型声明文件里:
declare module 'vue'和interface GlobalComponents都是固定写法,不能改名;typeof CpNavBar是从组件这个 JS 变量里取类型(TS 的typeof用法)。不写这份声明,鼠标悬停组件会显示any,right-text传数字也不会报错。
常见错误
配了
/login但页面空白
现象:地址能访问,页面无内容。
原因:App.vue没保留一级路由出口,或旧测试代码/容器遮挡。
修法:在App.vue只保留<router-view />,删除旧测试结构与内容。自动注册后 Vant 组件仍报未注册,或样式出现两份
现象:按钮/复选框不识别,或浏览器里同一组件样式重复。
原因:只加了Components插件,没配VantResolver;或未关闭自动样式导入、未关闭dts生成。
修法:在 Vite 配置中同时配置Components({ resolvers: [VantResolver({ importStyle: false })], dts: false }),并确认main.ts已统一引入 Vant 样式。点击右侧文字没有触发自定义事件
现象:父组件绑定了事件但点击无反应,或右侧文字为空时也期望触发。
原因:事件名拼写不一致;未defineEmits后emit;右侧文字为空时 Vant 本身不会触发click-right。
修法:子组件监听click-right后emit('click-right'),父组件统一事件名;需要空文字可点击时不要依赖 Vant 的click-right。返回按钮行为不符合预期
现象:直接访问/login点返回没回首页,或从其他页进入后没有正常回退。
原因:只调用router.back(),未判断history.state.back。
修法:有history.state.back时router.back(),否则router.push('/');访问state时加可选链避免报错。CpNavBar样式覆盖不生效
现象:箭头颜色、字号、标题字号改不动。
原因:scoped样式无法直接作用 Vant 内部元素,或选择器类名写错。
修法:使用:deep(.van-nav-bar__arrow)、:deep(.van-nav-bar__title)等实际类名覆盖,先在浏览器确认选择器。自动注册组件没有类型提示
现象:CpNavBar属性无提示,传错类型也不报错。
原因:自动注册组件没有全局组件类型声明。
修法:在components.d.ts中扩展vue的GlobalComponents,用typeof CpNavBar赋给组件名,并确保组件名、导入路径一致。登录页出现多余
#app嵌套或控制台多余日志
现象:DOM 多一层容器,控制台持续打印。
原因:沿用 Vue2 挂载认知保留了外层div.app;路由入口测试日志未清理。
修法:删除App.vue外层多余容器,保留路由出口;清理router/index.ts中的测试打印和测试链接。
练习
- 配置
/login路由并使用路由懒加载;分别验证“直接访问/login”和“从/home跳转到/login”两种场景。 - 配置
unplugin-vue-components+VantResolver,在登录页使用van-button、van-checkbox,检查组件可用、样式只有一份、不再生成多余类型声明文件。 - 为
CpNavBar增加title、right-text两个可选 props,并实现click-right自定义事件;父组件验证:有右侧文字时点击触发,无右侧文字时不触发。 - 实现
CpNavBar返回逻辑:有历史记录时回退,无历史记录时跳首页;用直接访问和带跳转访问两种方式各测一次。 - 为
CpNavBar添加全局组件类型声明,验证right-text有提示,且传入number时出现类型错误。
课堂画面






原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | 路由规则和准备登录页面 | 06:26 |
| 2 | 组件自动注册配置 | 13:40 |
| 3 | cp-nav-bar组件-结构和样式 | 09:52 |
| 4 | cp-nav-bar功能标题和右侧文字与右侧文字自定义事件 | 08:58 |
| 5 | cp-nav-bar功能-回退 | 08:32 |
| 6 | cp-nav-bar组件类型 | 08:37 |
| 7 | 登录页面布局和清理代码 | 08:19 |
小结
- 先定路由层级再写页面:一级路由管整页切换,二级路由管布局容器内局部切换。
- 自动注册不是“装插件就完事”,
resolver、dts、importStyle三个配置点要一起检查。 - 通用导航组件按“结构样式 → props/emit 功能 → 类型声明”推进,边界更清晰。
- 覆盖 Vant 内部样式必须用
:deep,选择器以浏览器实际类名为准。 - 回退逻辑要区分有无历史记录,不能无脑
router.back()。 - 自动注册组件必须补全局类型声明,否则提示和类型校验会退化。
- Vue3 根组件是插入
#app,不要保留多余外层容器;测试代码和日志要及时清理。 - 每个功能点都用浏览器验证:路由可达、样式单份、事件触发、类型报错、直接访问行为。