Vue3 项目初始化与路由配置完整笔记
本笔记在保留全部原始代码的基础上,把「从零搭建一个 Vue3 项目」过程中涉及的每一个知识点都展开做了详细讲解:包括清理脚手架、安装并配置各种依赖、目录结构设计、Header 组件、以及路由 / RouterView 嵌套渲染机制(这是本篇重点)。
目录
- 项目清理:清空脚手架默认内容
- 安装依赖(逐个讲清用途)
- Tailwind CSS 配置
- main.ts 入口配置
- TypeScript 类型提示配置
- 目录结构设计
- 编写 Header 组件
- 路由配置
- RouterView 与嵌套路由渲染机制(重点)
- 知识点速查总结
一、项目清理:清空脚手架默认内容
1.1 要做什么
用 create-vue(Vue 官方脚手架)生成的项目里,自带了一堆「示例代码」(欢迎页、示例组件、示例样式)。真正开发前,先把这些「样板 / demo」清掉,得到一个干净的起点。具体操作:
- 打开
assets目录:删除logo.svg,清空main.css、base.css两个文件(是清空内容,不是删文件,后面base.css还要写 Tailwind 引入)。 - 删除
components/下面的所有文件(那些是脚手架自带的示例组件,如HelloWorld.vue、TheWelcome.vue等)。 router/index.ts:把routes数组清空(示例路由不要了,后面重写)。views/下的两个文件删除(HomeView.vue、AboutView.vue示例页面)。App.vue:删除所有内容,重新初始化(清成一个空壳,后面重写)。
1.2 为什么要先清理
- 避免干扰:示例代码里有很多引用关系(App 引 components、router 引 views……),不清理会一直报错、也影响阅读。
- 明确起点:从空白开始按自己的架构组织代码,思路清晰。
- 注意先后顺序:因为文件之间有引用,清理时容易出现「A 删了但 B 还在引 A」的报错。合理顺序是从「被引用方」往「引用方」清,或者干脆一次性全清完再重写。比如
App.vue引了 router,router 引了 views,那清理时把它们一起处理,最后统一重建。
App.vue重新初始化大致长这样(一个最小空壳):
1 2 3 4 5<script setup lang="ts"></script> <template> <RouterView /> </template>只保留一个
<RouterView />(一级路由出口),其余留白,后面再填充。
二、安装依赖(逐个讲清用途)
所有依赖都用 pnpm 安装。先说 pnpm,再逐个讲每个包。
2.1 包管理器 pnpm
- 包管理器是什么:Node 生态里用来「下载、安装、管理第三方库(npm 包)」的工具。常见的有 npm、yarn、pnpm 三个。
- pnpm 的特点:
- 快:安装速度通常比 npm/yarn 快。
- 省空间:它用「硬链接 + 全局内容寻址存储」的方式管理依赖——同一个版本的包在你电脑上只存一份,各个项目通过链接指向它,不像 npm 每个项目都复制一份,能省下大量磁盘。
- 依赖更严格(防幽灵依赖):pnpm 的
node_modules结构是「非扁平」的,只有你在package.json里明确装过的包才能被import。npm 的扁平结构会把间接依赖也提到顶层,导致你可能「不小心 import 了没装过的包」(叫「幽灵依赖 / phantom dependency」),换个环境就崩。pnpm 从结构上避免了这个问题。
pnpm add xxx:安装一个包并写进package.json的dependencies。(等价于 npm 的npm install xxx。)
2.2 tailwindcss + @tailwindcss/vite
| |
tailwindcss:一个原子化 / 工具类优先(utility-first) 的 CSS 框架。它不给你封装好的「组件」,而是给你成千上万个「小工具类」(如flex、p-4、text-center、bg-white),你在 HTML 里直接组合这些类来写样式,几乎不用另写 CSS 文件。- 好处:不用给每个元素起 class 名、不用在 HTML 和 CSS 文件间来回跳、样式就近可见、天然响应式、打包时会自动删掉没用到的类(体积小)。
@tailwindcss/vite:Tailwind 官方的 Vite 插件。这是 Tailwind v4 的用法(v4 把和构建工具的集成做成了官方插件,配置比 v3 简单很多,不再需要tailwind.config.js和postcss.config.js那一套繁琐配置)。
2.3 three + @types/three
| |
three:即 Three.js,基于 WebGL 的 3D 图形库(上一篇笔记里登录页左侧的 3D 模型就是用它做的)。用来在网页里渲染 3D 场景、模型、动画。@types/three:three 的 TypeScript 类型声明包。- 为什么要单独装类型? 有些库本身是用纯 JavaScript 写的,不自带类型定义。TypeScript 项目里用它们时,编辑器就没有类型提示、也无法做类型检查。社区维护了一个叫 DefinitelyTyped 的仓库,专门为这些库补充类型声明,发布成
@types/xxx的包。装上@types/three后,写 Three.js 代码就有完整的类型提示和检查了。 - 约定:凡是名字形如
@types/xxx的,都是「给 xxx 这个库补类型的」,一般装在devDependencies(只开发时用,打包不需要)。
- 为什么要单独装类型? 有些库本身是用纯 JavaScript 写的,不自带类型定义。TypeScript 项目里用它们时,编辑器就没有类型提示、也无法做类型检查。社区维护了一个叫 DefinitelyTyped 的仓库,专门为这些库补充类型声明,发布成
2.4 axios
| |
axios:最流行的 HTTP 请求库,基于 Promise,浏览器和 Node 都能用。用来向后端发接口请求(GET / POST 等)。- 相比浏览器原生的
fetch,axios 的优势:自动把响应转成 JSON、更方便的错误处理、请求/响应拦截器(可统一加 token、统一处理错误)、请求取消、超时设置等。项目里通常会基于 axios 封装一个统一的请求实例(对应目录结构里的apis/index.ts)。
2.5 @microsoft/fetch-event-source
| |
@microsoft/fetch-event-source:微软开源的一个库,用来处理 SSE(Server-Sent Events,服务器发送事件) 这种「服务器持续、单向地往客户端推送数据」的流式场景。- 用在哪? 典型就是 AI 对话的「打字机效果」——你问 AI 一个问题,回答是一个字一个字「流」出来的,而不是等全部生成完才一次性显示。这背后就是服务器用 SSE 把生成的内容一段段推过来。(对应目录里的 AI 聊天页
smart/chat。) - 为什么不用浏览器原生的
EventSource? 原生EventSource只支持 GET 请求、不能自定义请求头(没法带Authorizationtoken)、不能发 POST body。而 AI 对话通常需要 POST 提交问题、还要带鉴权头。@microsoft/fetch-event-source基于fetch实现,支持 POST、自定义 header、更灵活的重连控制,正好补足原生的短板。
2.6 marked
| |
marked:一个把 Markdown 文本转换成 HTML 的解析库,速度快、体积小。- 用在哪? 还是 AI 对话场景。大模型返回的内容通常是 Markdown 格式(带
#标题、**加粗**、代码块、列表等)。直接显示这些原始符号很难看,用 marked 把 Markdown 转成 HTML 再渲染,就能得到排版漂亮的富文本(标题、代码高亮、列表等)。
安全提醒:把「转换后的 HTML」用
v-html插入页面时要注意 XSS(跨站脚本攻击) 风险——如果内容里混入了恶意<script>,可能被执行。生产环境建议配合 DOMPurify 之类的库先对 HTML 消毒过滤后再渲染。
2.7 element-plus + @element-plus/icons-vue
| |
element-plus:面向 Vue 3 的一套桌面端 UI 组件库(Element UI 的 Vue3 版)。提供大量开箱即用的组件:按钮、表单、输入框、弹窗、表格、消息提示等。上一篇登录页用到的el-form、el-input、el-button、ElMessage都来自它。用它能快速搭出美观、统一的界面,不用自己从零写组件。@element-plus/icons-vue:Element Plus 的官方图标库,把图标做成了一个个 Vue 组件(如HomeFilled、Setting、Star)。用的时候import进来当组件渲染。注意它是单独的包,需要单独安装。
2.8 pinia-plugin-persistedstate
| |
- 背景:Pinia 是 Vue3 的官方状态管理库,存放全局共享状态(如登录用户信息)。但 Pinia 的状态是存在内存里的——页面一刷新,内存清空,状态就没了(比如刷新后「登录用户」又变回未登录)。
pinia-plugin-persistedstate:Pinia 的一个持久化插件。它能自动把指定 store 的状态同步保存到localStorage(或sessionStorage),页面刷新后再自动读回来恢复。这样用户信息、token 等就能在刷新后保持,不用重新登录。- 用法上:先在 main.ts 里
pinia.use(piniaPluginPersistedstate)启用插件,然后在需要持久化的 store 里开启persist: true即可。
小结这一节:这套依赖组合起来就是一个「AI 英语学习 App」的完整技术栈——Tailwind(样式)+ Element Plus(UI 组件)+ Three.js(3D 展示)+ axios(普通接口)+ fetch-event-source(AI 流式对话)+ marked(渲染 AI 返回的 Markdown)+ Pinia 及持久化插件(状态管理 + 刷新不丢失)。
三、Tailwind CSS 配置
分三步:安装(上面已装)、配置 Vite 插件、写 CSS 入口。
3.1 配置 Vite 插件
在 vite.config.ts 里把 Tailwind 的插件加进 plugins 数组:
| |
逐点解释:
vite.config.ts是什么:Vite(本项目的构建/开发工具)的配置文件。项目怎么启动、用哪些插件、路径别名、代理等都在这里配。defineConfig(...):Vite 提供的一个工具函数。它本身不做特别的事,主要作用是提供类型提示——把配置对象包进去,编辑器就能对里面的字段自动补全和类型检查。plugins: [...]:Vite 的插件数组。插件用来扩展 Vite 的能力。tailwindcss():调用刚装的@tailwindcss/vite插件(注意要调用,加括号)。加进去后,Vite 在构建时就会处理 Tailwind 的那些工具类,把你用到的类编译成真正的 CSS。- 注意:实际项目里这个数组通常还有 Vue 插件等,比如
plugins: [vue(), tailwindcss()]。这里为了聚焦 Tailwind 只写了一个。
Tailwind v3 vs v4 的差别(顺带一提,避免看老教程混淆):v3 时代要装
postcss、autoprefixer,还要手写tailwind.config.js配 content 路径、写postcss.config.js。v4(本项目用的) 把这些都简化了——一个官方 Vite 插件搞定,配置极少。所以如果你看到教程里让你建tailwind.config.js,那是 v3 的做法。
3.2 CSS 入口文件
在 assets/base.css(之前清空的那个)里写:
| |
逐行解释:
@import 'tailwindcss';:把 Tailwind 的全部样式引入进来。这是 Tailwind v4 的写法——一行@import 'tailwindcss'就把「基础重置样式 + 所有工具类」都引进来了。(v3 时代是写三行@tailwind base; @tailwind components; @tailwind utilities;,v4 合并成了一行 import。)这一行必须放最前面,后面才能用 Tailwind 的功能。html,body,#app { ... }:一条普通的 CSS 选择器,同时选中html、body和id="app"的元素(#app就是 Vue 应用挂载的根节点)。@apply h-full w-full;:@apply是 Tailwind 的指令,作用是「在 CSS 里套用 Tailwind 的工具类」。它把工具类对应的样式「展开」写进这条 CSS 规则里。h-full=height: 100%,w-full=width: 100%。- 所以这段等价于给 html/body/#app 都设
height: 100%; width: 100%;。 - 为什么要这么做? 默认情况下
html、body、#app的高度是「由内容撑开」,并不占满整个屏幕。想让页面布局(尤其是「满屏高度」的布局,如侧边栏、fixed 定位)正常工作,需要让这三层容器都撑满视口的宽高,形成一条「100% 高度」的传递链。这是做整页布局的经典前置设置。
@apply的意义:它让你能把「一串常用的工具类」封装进一个语义化的 CSS 类里复用,而不用在 HTML 里重复写一长串。不过日常大多数场景直接在 HTML 里写工具类即可,@apply用于这种「全局基础样式」或「需要复用的样式组合」。
别忘了在 main.ts 里引入这个 css 文件(见下一节
import '@/assets/base.css'),否则样式不会生效。
四、main.ts 入口配置
main.ts 是整个 Vue 应用的入口文件——应用从这里启动,所有全局的东西(插件、样式、UI 库)都在这里注册。
4.1 完整代码
| |
4.2 三类 import 分别是什么
① 引入 CSS(副作用导入)
| |
- 这种「只写路径、不解构任何东西」的 import 叫副作用导入。它不导入具体的变量,只是「执行这个文件」——对 CSS 来说,就是把这份样式加载进项目、让它生效。
@/assets/base.css:我们自己写的 Tailwind 入口(@是指向src的路径别名)。element-plus/dist/index.css:Element Plus 组件的样式。必须引入,否则 el-button 等组件会「没有样式、丑陋错乱」。
② 引入功能函数 / 库
| |
③ 引入本项目模块
| |
import router from './router':注意路径是目录./router,没写文件名。这是模块解析的默认规则——引入一个目录时,会自动去找该目录下的index.ts(或index.js)。所以它实际引的是router/index.ts里export default出去的路由实例。
④ 中文语言包
| |
- Element Plus 默认是英文(比如分页、日期选择器、表格里的文字都是英文)。这里引入它自带的中文(简体)语言包
zhCn,后面配置给组件库,界面文字就变成中文了。这属于 i18n(国际化,internationalization) 的一部分。
4.3 创建应用与 Pinia
| |
createApp(App):Vue3 的应用创建函数。传入根组件App,返回一个应用实例app。之后所有全局配置(装插件、注册全局组件等)都通过这个app来做。这是 Vue3 相比 Vue2 的变化——Vue3 是「每个应用一个独立实例」,配置都挂在实例上,不再是全局污染。createPinia():创建一个 Pinia 实例(状态管理的「总仓库」)。pinia.use(piniaPluginPersistedstate):给这个 Pinia 实例安装持久化插件。装完后,各个 store 才能使用persist持久化能力。注意顺序:必须先给 pinia 装插件,再把 pinia 交给 app(下一步)。
4.4 注册插件(app.use)与挂载
| |
app.use(插件, 选项?):Vue 的插件安装方法。把一个插件注册到应用上,让它的功能在整个应用中可用。app.use(pinia):启用状态管理。app.use(ElementPlus, { locale: zhCn }):启用 Element Plus,第二个参数是配置项,{ locale: zhCn }就是把语言设为前面引入的中文包。这样组件内置文字全变中文。app.use(router):启用 Vue Router,之后才能用<RouterView>、<RouterLink>、useRouter()等。
app.use支持链式调用(每次返回 app 自身),也可以写成app.use(pinia).use(ElementPlus,...).use(router)。分开写更清晰。app.mount('#app'):挂载。把整个 Vue 应用「装」到页面上id="app"的那个 DOM 元素里(就是index.html里的<div id="app"></div>)。执行这一步后,页面才真正渲染出来。mount 必须放最后——所有插件都 use 完了再挂载。
记住这个固定套路:
createApp→ 创建各种实例/插件 → 一堆app.use(...)→ 最后app.mount(...)。顺序不能乱,尤其 mount 一定在最后。
五、TypeScript 类型提示配置
在 tsconfig.app.json 里添加一项配置:
| |
tsconfig.app.json是什么:TypeScript 的配置文件之一。Vite + Vue 项目通常把 TS 配置拆成几个:tsconfig.json(总入口,引用其他两个)、tsconfig.app.json(管应用源码src)、tsconfig.node.json(管vite.config.ts这类 Node 环境代码)。这里改的是管src源码的那个。"types": ["element-plus/global"]:- 作用:让 Element Plus 的组件(
<el-button>、<el-form>等)在模板里有全局类型提示和检查。加了这个之后,编辑器能识别这些标签、提示它们的属性(props)、发现用错的地方。 - 为什么需要:Element Plus 组件是全局注册使用的(模板里直接写
<el-xxx>),TypeScript 默认不知道这些全局标签对应什么类型。element-plus/global这个类型声明就是告诉 TS「这些全局组件长什么样」。 - 一个坑:
types字段一旦手动指定,TypeScript 就只加载你列出的这些类型包,而不再自动加载node_modules/@types下的全部。所以如果项目还依赖别的全局类型(如node),可能需要一并写进数组:"types": ["element-plus/global", "node"]。本例只列了 element-plus/global,按需补充即可。
- 作用:让 Element Plus 的组件(
六、目录结构设计
清理干净后,按功能职责规划 src 目录:
| |
6.1 为什么要这样分目录
「按职责分层」是工程化项目的基本功。 目录清晰,团队协作、后期维护、找代码都更高效。每个目录的职责边界要明确——一看目录名就知道该放什么、该去哪找。逐个说明:
apis/:接口层。所有和后端通信的请求都集中在这里管理,而不是散落在各个组件里。index.ts里通常会创建并配置 axios 实例(设置 baseURL、超时、请求/响应拦截器等),再按业务模块拆分成user.ts、word.ts等。这样接口地址变了、要统一加 token、要统一处理错误时,只改这一层即可。assets/:静态资源。图片、全局样式(如 base.css)、字体等。(注意与public/的区别:assets里的资源会被构建工具处理和打包;public里的原样输出——上一篇讲过。)components/:全局通用组件。那些跨页面复用、不属于某个具体业务页面的组件放这(如上一篇的登录弹窗Login)。hooks/:自定义组合式函数。以use开头、封装可复用逻辑的函数(如上一篇的useLogin)。layout/:布局层。放「页面的整体框架骨架」,即那些每个页面都共用的结构(顶栏、侧边、内容区外壳)。里面又细分:Header/:顶栏。Content/:主体内容区(内部藏着第二层路由出口RouterView,见第九节)。Profile/:用户信息区。index.vue:布局入口,把上面这些拼成完整布局。
router/:路由配置。定义「URL ↔ 页面组件」的映射关系。stores/:状态管理。Pinia 的各个 store(如用户 store)。views/:业务页面。真正对应各个路由的「页面级组件」(主页、词库页、AI 页等)。它和components/的区别:views是「页面」(和路由一一对应,是路由的落点);components是「零件」(被页面或布局组合使用,不直接对应路由)。App.vue:根组件,整棵组件树的顶点。
一句话记忆:
views装页面、components装零件、layout装骨架、apis管接口、stores管状态、hooks管复用逻辑、router管跳转映射。职责单一、各就各位。
七、编写 Header 组件
Header 是顶栏(layout/Header/index.vue),包含 Logo、导航菜单、积分显示、用户头像。
7.1 完整代码
| |
7.2 脚本部分
| |
- 引入图标:从
@element-plus/icons-vue按需引入用到的图标组件(HomeFilled主页、MagicStick魔法棒代表 AI、Notebook笔记本代表词库、Reading阅读代表课程、Setting设置、Sunny太阳、Star星星)。引入后就能在模板里当组件用。 useRouter():拿到路由器实例,用于编程式导航(在代码里跳转,而不是点链接)。
7.3 <el-icon> 图标容器
| |
el-icon是什么:Element Plus 提供的图标容器组件。它本身不是具体图标,而是一个「包裹层」,负责给里面的图标统一控制尺寸、颜色、对齐等。- 为什么图标要套一层
el-icon:@element-plus/icons-vue里的图标本质是 SVG 组件。直接用可能大小/颜色不好控制、和文字对齐也不方便。套上el-icon后,Element Plus 会帮你处理好这些(默认继承字体大小和颜色、垂直居中对齐文字),用起来更省心、更统一。 - 用法就是把具体图标组件作为默认插槽放进去:
<el-icon><具体图标 /></el-icon>。
7.4 编程式导航 router.push
| |
@click="...":Vue 的事件绑定(@click是v-on:click的简写),点击时执行里面的表达式。router.push('/xxx'):编程式导航——用代码控制页面跳转到指定路由。这里点哪个导航项就push到对应路径。router.pushvs<router-link>(两种导航方式):<router-link to="/xxx">是声明式导航,写在模板里,渲染成<a>标签,适合「就是个链接」的场景。router.push('/xxx')是编程式导航,写在 JS 里,适合「需要在逻辑中跳转」的场景(如登录成功后跳首页、点击某个 div 跳转)。这里因为导航项是<div>而非链接、且是点击触发,所以用router.push。
router.push的其他形式:除了传字符串路径,还能传对象router.push({ name: 'home' })(按路由名跳)、router.push({ path: '/user', query: { id: 1 } })(带查询参数)。相关的还有router.replace()(替换当前历史记录,不留返回)、router.back()(后退)等。
7.5 关键 Tailwind 类详解
① <header> 上的类
| |
flex:弹性布局。items-center:交叉轴(垂直方向)居中——让 header 内元素垂直居中。justify-center:主轴(水平方向)居中——让里面那个 1200px 宽的容器整体水平居中。h-20:高度5rem(80px)。Tailwind 的间距单位里1=0.25rem= 4px,所以20= 80px。border-b:只加下边框(bottom border)。border-gray-200:边框颜色浅灰。合起来是顶栏底部一条浅灰分割线。sticky top-0:粘性定位(重点)。position: sticky+top: 0的组合意思是:正常情况下 header 跟着页面走,但当页面向下滚动、header 即将滚出视口顶部时,它会「粘」在顶部top: 0的位置不动了。效果就是顶栏始终吸附在页面顶部可见(常见的「滚动时导航栏固定」效果)。bg-white:白色背景(很重要——粘住后如果没背景色,滚动时下面的内容会透过来叠在一起)。z-10:z-index: 10,层叠层级。保证 header 浮在下方内容之上(滚动时内容不会盖住吸顶的 header)。
stickyvsfixed的区别:fixed是「彻底脱离文档流、死死钉在视口某处」;sticky是「平时正常在文档流里,滚到临界点才吸附」。做吸顶导航用sticky top-0更自然(它会占位、不会像 fixed 那样把下面内容顶上去遮住)。
② 内层容器
| |
w-[1200px]:固定宽度 1200px([]任意值语法)。这是定宽居中布局——内容限制在 1200px 内,两侧留白,大屏上不至于拉得太宽。mx-auto:margin-left: auto; margin-right: auto,水平居中这个定宽容器(左右外边距自动均分)。flex items-center justify-between:内部弹性布局、垂直居中;justify-between让子元素「两端对齐、间隔均分」——Logo 靠左、用户头像靠右,中间导航项均匀分布。
③ Logo 方块
| |
text-2xl:字号1.5rem。font-bold:加粗。bg-indigo-700:靛蓝背景。text-white:白字。rounded-[10px]:圆角 10px。px-2 py-1:水平内边距0.5rem、垂直内边距0.25rem(p= padding,x/y分别是水平/垂直)。w-10 h-10:宽高各 40px,做成一个正方形。flex items-center justify-center:让里面的字母「E」水平垂直都居中。
④ 导航项通用类
| |
flex items-center:让图标和文字在一行垂直居中排列。gap-2:flex/grid 子元素之间的间距0.5rem(图标和文字之间留点空)。gap比用 margin 更省心。cursor-pointer:鼠标悬停时变成「手型」指针,提示这个 div 可以点击(因为它不是原生<a>/<button>,默认不会有手型,需要手动加)。text-gray-500:文字灰色。
⑤ 积分徽标(药丸形)
| |
rounded-full:完全圆角(半径极大),把矩形变成「胶囊/药丸」形状。配合内边距形成一个圆润的徽标。bg-blue-200 text-blue-700:浅蓝底 + 深蓝字(一种协调的配色手法:同色系,背景浅、文字深)。旁边那个用bg-amber-200 text-amber-700(琥珀/橙色系)同理。text-sm:小号字。{{ 0 }}:Vue 的插值语法(双大括号),把里面的表达式的值渲染成文本。这里暂时写死0(占位),将来会替换成从 store 里取的真实数据(如打卡天数、单词数)。
⑥ 用户头像区
| |
border-l:只加左边框(left border),border-gray-200浅灰。pl-4:左内边距1rem。合起来是在头像区左侧加一条竖分割线并留出间距,把它和前面的导航区分隔开。<img>上的w-10 h-10 rounded-full:40×40 的圆形头像(rounded-full把方图裁成圆)。ml-2 mr-2:左右外边距。{{ '未登录' }}:同样是插值,暂时写死「未登录」占位,登录后会换成用户名。
本组件目前是「静态占位」版:积分
{{ 0 }}、用户名{{ '未登录' }}都是写死的。后续接入 Pinia 用户 store 后,会把它们换成真实的响应式数据({{ userStore.user?.dayNumber }}、{{ userStore.user?.name ?? '未登录' }}之类)。
八、路由配置
路由采用模块化拆分:每个业务模块(home、word-book……)单独一个路由文件,最后在 router/index.ts 里合并。
8.1 layout 布局组件(index.vue)
| |
layout是所有页面共用的「布局外壳」,由Header(顶栏)和Content(内容区)组成。- 关键:
layout自己不含RouterView,它只是把 Header 和 Content 拼起来。真正的「二级路由出口」藏在Content组件内部(见第九节)。 - 因为它是每个页面都要的外壳,所以在路由里被设为父路由的 component,各个页面作为它的
children。
8.2 Home 路由(静态引入)
| |
- 这个文件
export default一个路由记录数组(本模块的路由)。 - 结构是「一个父路由 + children 子路由」:父路由
path: '/'用layout布局,子路由也是'/'渲染Home。访问/时,先渲染 layout,再在其内部渲染 Home。 - 注意 Home 是「静态引入」:
import Home from '@/views/Home/index.vue'在文件顶部直接 import。这意味着 Home 组件的代码会被打包进首屏的主包、应用一启动就加载。
8.3 WordBook 路由(动态引入 / 懒加载)——重点
| |
- 结构和 home 一样(父 layout + 子页面),但子路由的 component 写法不同:
- Home:
component: Home(静态,顶部 import 好的组件)。 - WordBook:
component: () => import('@/views/WordBook/index.vue')(动态引入,一个返回 import() 的箭头函数)。
- Home:
什么是「动态引入 / 路由懒加载」?
() => import('...')是一个函数,里面用了 动态import()。动态 import 返回一个 Promise,会在被调用时才去异步加载那个模块。- 在路由里,component 传这样一个函数,Vue Router 就会等真正访问这个路由时,才去下载对应组件的代码,而不是一开始就全部下载。
- 构建工具(Vite)看到动态 import,会把这个组件单独拆成一个 JS 文件(chunk),实现代码分割(code splitting)。
为什么 Home 用静态、WordBook 用动态?(原文的关键点)
- Home 是「一进来就展示」的首屏页面。它反正一开始就要用,懒加载没意义(甚至反而多一次请求延迟)。所以直接静态引入,打进主包。
- WordBook 不是首屏必需——用户不一定会点词库。如果也打进主包,会白白增大首屏体积、拖慢首屏加载。用动态引入后,词库的代码被单独拆出去,只有用户真的点进词库时才下载。这样能减轻首屏加载负担,首屏更快。
一句话总结策略:首屏必用的页面 → 静态引入(打进主包,秒开);非首屏、按需访问的页面 → 动态引入懒加载(拆包,用到才下载,减小首屏体积)。这是前端性能优化里非常常用的手段。
8.4 router/index.ts 汇总
| |
8.5 createRouter 与 createWebHistory
| |
createRouter(配置):Vue Router 4(配 Vue3)创建路由实例的函数。配置对象里最核心的两项是history(路由模式)和routes(路由表)。history: createWebHistory(...):指定路由的历史模式。createWebHistory表示使用 HTML5 History 模式(下一节详解)。import.meta.env.BASE_URL:import.meta.env是 Vite 提供的环境变量入口。BASE_URL是应用部署的「基础路径」(在 vite 配置的base决定,默认是/)。- 把它传给
createWebHistory,是为了让路由知道应用挂在什么根路径下。如果你的应用不是部署在域名根目录(比如部署在https://xxx.com/app/下),改了 base,路由才能正确工作。默认/时就是根目录。
8.6 History 模式 vs Hash 模式
Vue Router 有两种主要的历史模式:
createWebHistory(History 模式,本项目用) | createWebHashHistory(Hash 模式) | |
|---|---|---|
| URL 形式 | https://xxx.com/word-book/index | https://xxx.com/#/word-book/index |
| 好看程度 | 干净、无 # | 带 #,略丑 |
| 原理 | 用 HTML5 History API(pushState)改 URL | 用 URL 里 # 后面的 hash 部分 |
| 服务器配置 | 需要后端配合:把所有路径都指回 index.html,否则刷新子路由会 404 | 不需要额外配置(# 后面的内容不会发给服务器) |
createWebHistory的注意点:因为 URL 是真实路径(没有#),用户在/word-book/index刷新页面时,浏览器会拿这个路径去请求服务器。而这是个单页应用,服务器上并没有/word-book/index这个真实文件,会返回 404。解决办法是在部署时配置服务器(Nginx、Vercel 等),让所有未匹配的路径都返回index.html,交给前端路由处理。这是 History 模式部署时最常见的坑,务必记住。- Hash 模式没这个问题(
#后面的部分浏览器不会发给服务器),但 URL 带#不够美观。现代项目多用 History 模式 + 服务器配置。
8.7 嵌套路由 children
| |
children让路由形成一棵树,而不是一份平铺的列表。父路由(layout)下面挂子路由(具体页面)。- 路径拼接规则:子路由的
path不以/开头时是「相对路径」,会拼在父路径后面。这里父path: '/word-book'+ 子path: 'index'→ 实际访问路径是/word-book/index。 - 如果子路由
path以/开头(像 home 里子路由写的'/'),则是绝对路径,不拼接父级。(home 里父子都是'/',所以最终就是/。) - 为什么要嵌套? 因为多个页面(Home、WordBook)共享同一个外壳(layout:Header + Content),把 layout 作为父、页面作为 children,就能实现「外壳固定、内容区随路由切换」——这正是第九节要深入讲的 RouterView 嵌套机制。
8.8 展开运算符合并路由
| |
home和wordBook各自export default的是数组。...(展开运算符) 把数组里的元素「摊平」拼进新数组。[...home, ...wordBook]就是把两个模块的路由记录合并成一份完整的routes列表。- 好处:路由模块化。每个业务模块的路由独立在自己的文件里维护,新增一个模块只需:新建该模块路由文件 → 在 index 里 import 并
...进 routes。互不干扰、清晰可扩展。想象一下如果所有路由都堆在一个文件里,几十个页面会非常难维护。
九、RouterView 与嵌套路由渲染机制(重点)
这一节把「路由是怎么一层层渲染出来的」彻底讲清楚。理解它,你就明白为什么切换页面时顶栏 Header 一直不动、只有内容区在换。
9.1 路由表长什么样
| |
两个模块各自导出一个「路由记录数组」,在 router/index.ts 里用展开运算符 ...home、...wordBook 合并成一份完整的 routes 列表,交给 createRouter 生成路由实例。
关键点:每一条路由记录都可以有 children,形成一棵树,而不是一份平铺列表。layout 是所有页面共用的父级,Home/WordBook 是挂在它下面的子路由。
9.2 RouterView 是「按层级对应」的插槽
先建立核心概念:<RouterView /> 是一个占位插槽,它的作用是「在这里渲染当前路由匹配到的组件」。而当路由有嵌套(children)时,就需要多层 RouterView——组件树里有几层嵌套的 <RouterView/>,路由匹配到的链路就能渲染几层。
本项目是两层:
| |
特别注意一个容易误解的点:layout 本身不含 RouterView,它只是布局壳(Header + Content)。真正的第二层插槽藏在 Content 组件内部。所以视觉上看起来像是「layout 里有 RouterView」,实际上是它的子组件 Content 代为持有这个二级路由出口。
也就是说,Content 组件内部大致长这样:
| |
而根组件 App.vue 里是第一层:
| |
层级对应关系(务必记住):
- App.vue 里的 RouterView → 渲染匹配链的第 0 层(父路由
layout)。 - Content 里的 RouterView → 渲染匹配链的第 1 层(子路由,如
Home/WordBook)。
有几层嵌套路由,就要在对应的组件里放几层 RouterView,一一对应。
9.3 匹配过程:一次性解析整条链,而不是逐层触发
这是理解嵌套路由最关键的一点:访问一个 URL 时,vue-router 不是「先匹配第一层、渲染出来,再去找第二层该渲染什么」这种「走一步看一步」的方式;而是先把整条匹配链路一次性算出来,再自上而下渲染。
以访问 /word-book/index 为例,完整过程:
- router 遍历路由表,找到
path: '/word-book'这条记录,前缀匹配上了。 - 继续在它的
children里找剩余路径index,找到{ path: 'index', component: WordBook }。 - 得到匹配链:
[layout, WordBook]。这个数组的顺序,就对应 RouterView 的嵌套层级。 - 渲染时(自上而下):
- 第 1 层
RouterView(在App.vue)拿链路里的第 0 个 → 渲染layout。 layout渲染出Header+Content。- 第 2 层
RouterView(在Content里)拿链路里的第 1 个 → 渲染WordBook。
- 第 1 层
访问 / 的情况完全一样,只是匹配链是 [layout, Home]。
重点结论:不存在「先展示 layout、过一会儿才补上 WordBook」的先后顺序——两层是在同一次渲染里一起确定、一起挂载的。router 是先把「这条 URL 要渲染哪几层、分别是什么组件」整条算清楚,再一次性把整棵子树渲染出来。
为什么强调这点? 很多人误以为嵌套路由是「父先渲染、触发子再渲染」的链式过程,从而在时序上产生错误理解(比如担心「子路由拿不到父的数据」「会闪一下」)。实际上匹配是一次性解析全链,渲染是同步的整体渲染,心里要有这个正确模型。
9.4 为什么切页面时 Header 不会闪烁 / 重建
这是嵌套布局最实用的好处,也是原理的直接体现。
从 / 跳到 /word-book/index 时:
- 旧匹配链:
[layout, Home] - 新匹配链:
[layout, WordBook]
对比两条链,第 0 层组件都是同一个 layout(同一个组件定义的引用)。Vue Router 发现「父层组件没变,只是子层变了」,于是只会:
- 保留
layout组件实例不动(连同它里面的Header一起,实例不销毁、不重建)。 - 只把
Content内部第 2 层RouterView渲染的内容从Home换成WordBook。
这就是为什么点击词库按钮,Header 始终存在、只有内容区在切换——本质是同一棵组件树,只重新渲染发生变化的那一层插槽,而不是整个页面推倒重来。
带来的实际收益:
- 性能好:Header(及其内部状态、事件监听、可能的定时器)不会被反复销毁重建。
- 体验好:顶栏不闪烁、不跳动,滚动位置等也能保持。
- 状态保持:如果 Header 里有本地状态(如某个下拉菜单的展开状态),切页面时不会丢失。
延伸:如果连子组件也想在切换时「保活」(不销毁、保留状态),可以用
<KeepAlive>把RouterView包起来:<RouterView v-slot="{ Component }"><KeepAlive><component :is="Component" /></KeepAlive></RouterView>。这样被切走的页面组件会被缓存而非销毁,再切回来时保持原状。本项目未用,了解即可。
9.5 一张图总结
| |
① 对应链路第 0 层,② 对应链路第 1 层,两层同时由一次路由匹配结果驱动。
- 换页面(
/→/word-book/index)时,① 处的layout不变(Header 稳如泰山),只有 ② 处从Home换成WordBook。 - 这套「父布局固定 + 子内容随路由切换」的结构,是中后台管理系统、多页应用里最经典、最常用的布局方案。
十、知识点速查总结
依赖与工具
| 名称 | 一句话说明 |
|---|---|
| pnpm | 快、省磁盘(硬链接共享)、依赖严格(防幽灵依赖)的包管理器 |
pnpm add xxx | 安装包并写入 dependencies |
| tailwindcss | 原子化 CSS 框架,HTML 里直接组合工具类写样式 |
| @tailwindcss/vite | Tailwind v4 官方 Vite 插件(配置极简) |
| three / @types/three | 3D 图形库 / 它的 TS 类型声明包 |
@types/xxx | DefinitelyTyped 为纯 JS 库补充的类型声明 |
| axios | Promise 版 HTTP 请求库,支持拦截器 |
| @microsoft/fetch-event-source | SSE 流式(支持 POST/自定义头),做 AI 打字机效果 |
| marked | Markdown 转 HTML,渲染 AI 返回内容(注意 XSS) |
| element-plus / icons-vue | Vue3 UI 组件库 / 官方图标包 |
| pinia-plugin-persistedstate | Pinia 状态持久化到 localStorage,刷新不丢 |
配置相关
| 知识点 | 一句话说明 |
|---|---|
vite.config.ts + defineConfig | Vite 配置文件,defineConfig 提供类型提示 |
plugins: [tailwindcss()] | 注册 Vite 插件(记得加括号调用) |
@import 'tailwindcss' | v4 引入 Tailwind(放最前面) |
@apply | 在 CSS 里套用 Tailwind 工具类 |
html,body,#app { h-full w-full } | 撑满视口高度链,整页布局前置设置 |
副作用导入 import 'xxx.css' | 只执行文件(加载样式),不导入变量 |
import router from './router' | 引入目录默认找其 index.ts |
| ElementPlus 中文 | 引入 zhCn,app.use(ElementPlus,{locale:zhCn}) |
"types":["element-plus/global"] | 让 el- 组件有全局类型提示(会覆盖自动加载) |
Vue 应用启动
| 知识点 | 一句话说明 |
|---|---|
createApp(App) | 用根组件创建应用实例 |
createPinia() + pinia.use(...) | 创建状态库并装插件(先装插件再交给 app) |
app.use(插件, 选项) | 注册插件,可链式;第二参为配置 |
app.mount('#app') | 挂载到页面,必须最后执行 |
| 启动套路 | createApp → 建实例/装插件 → 多个 use → mount |
路由核心
| 知识点 | 一句话说明 |
|---|---|
createRouter({history, routes}) | 创建路由实例 |
createWebHistory() | History 模式,URL 无 #,部署需服务器回退到 index.html |
createWebHashHistory() | Hash 模式,URL 带 #,无需服务器配置 |
import.meta.env.BASE_URL | Vite 环境变量,应用基础路径 |
children 嵌套路由 | 路由成树,父布局 + 子页面 |
| 子 path 拼接 | 不以 / 开头相对拼父级;以 / 开头是绝对路径 |
...home 合并路由 | 展开运算符合并各模块路由,实现模块化 |
静态引入 component: Home | 打进主包,首屏必用页面用它 |
动态引入 () => import(...) | 懒加载/代码分割,非首屏页面用它减小首屏体积 |
router.push('/x') | 编程式导航(代码里跳转) |
<router-link to> | 声明式导航(模板里的链接) |
<RouterView /> | 路由出口插槽,按层级渲染匹配链对应组件 |
| 嵌套渲染机制 | 一次性解析整条匹配链,自上而下同步渲染 |
| 父层不变则复用 | 切页面时同一 layout 实例保留,只换变化的子层(Header 不闪) |
<KeepAlive> | 缓存被切走的组件、保活状态(延伸) |
Tailwind 常用类(本篇出现)
| 类 | 含义 |
|---|---|
flex / items-center / justify-center / justify-between | 弹性布局与对齐 |
gap-2 | 子元素间距 0.5rem |
h-20 / w-10 / h-10 | 高 80px / 宽高 40px(1=4px) |
w-[1200px] / rounded-[10px] | 任意值(方括号语法) |
mx-auto | 水平居中定宽容器 |
sticky top-0 | 粘性定位,滚动吸顶 |
z-10 | 层叠层级 |
border-b / border-l | 下 / 左 单边边框 |
rounded-full | 完全圆角(药丸 / 圆形头像) |
cursor-pointer | 手型指针(给非链接元素加可点提示) |
bg-blue-200 / text-blue-700 | 浅底深字同色系配色 |
px-2 py-1 / pl-4 / ml-2 | 内边距 / 单向内边距 / 外边距 |
几个「为什么」的重点回顾
- 为什么先清理脚手架? 去掉示例干扰、明确起点;清理注意引用先后顺序。
- 为什么 three 要另装
@types/three? 它是纯 JS 库,需要单独的类型声明才有 TS 提示。 - 为什么 AI 对话用 fetch-event-source 而非原生 EventSource? 需要 POST + 自定义头 + 鉴权,原生不支持。
- 为什么先给 pinia 装插件再交给 app? 插件要在 store 使用前就绪,顺序不能反。
- 为什么
app.mount放最后? 要等所有插件 use 完,应用配置齐全再渲染上屏。 - 为什么 Home 静态、WordBook 动态引入? 首屏必用的静态打进主包秒开;非首屏的懒加载减小首屏体积。
- 为什么 History 模式刷新会 404? URL 是真实路径会请求服务器,需配置服务器回退到 index.html。
- 为什么切页面 Header 不闪? 嵌套匹配链父层 layout 不变,实例复用,只重渲染变化的子层。