这个系列刷到最后一章,恰好落在了“顶部导航栏与用户功能”上。最终章的内容看起来就是加一个顶栏、做一套登录授权,但真正动手写的时候才发现,导航高亮、token 存储、权限守卫、退出清理、用户信息回显这些东西全部挤在几个组件里,彼此又环环相扣,信息量比前面任何一章都大。我前后完整敲了三遍,才把这块从“会写”变成“明白为什么这么写”。这篇文章不打算做课程流水账,只把最终章最核心的几个点——顶栏布局选型、菜单高亮方案、用户状态管理、路由守卫收尾,以及几个调试现场——整理成可以复用的经验,给正在练同类实战项目的朋友一个参照。
1. 最终章的定位与整体思路
1.1 为什么顶栏和用户功能适合放在最后一章
这套实训课整个项目是一个资讯展示类的实战应用,前面章节已经把首页列表、详情页、发布编辑、分类切换这些主业务都做完了。到最终章之前,页面之间有一个很尴尬的现象:每跳转一个页面,只能靠路由和自己的模块导航进进出出,整个应用没有一个统一的“外壳”,也没有用户体系。所谓最终章,就是把这一层外壳和身份体系补齐。
顶部导航栏看着只是信息架构的收口,实际上它是全站复用度最高的公共组件。任何一个页面打开,顶栏都要存在,都要知道自己当前处于哪个路由,还要根据登录状态决定右侧显示“登录/注册”还是头像下拉菜单。也就是说,顶栏组件从挂载那一刻起,就同时依赖了路由实例、用户 store、权限判断三件事。这个时机放在主业务完成之后,是非常合理的:前面章节已经把数据请求、路由跳转、组件通信都练过了,最后一章才有能力把这些能力全部串起来。
用户功能放在最后则更顺理成章。用户体系一旦引入,会立刻影响所有页面的数据获取权限、评论展示、发布入口、个人主页访问。如果课程一开始就做用户体系,会让学员在还没掌握基础请求封装时被迫接触一堆状态管理的概念,反而学得稀碎。放在最终章,相当于把项目从“可以演示的页面集合”升级成“真正有访问控制的应用”,对整套教程来说是最合适的收尾姿势。
1.2 最终章的价值不只是“补一个组件”
很多人容易低估这章。从代码量上看,顶栏不过一个组件,用户功能不过一个登录弹窗加一个 store,最多再配一条路由守卫,似乎半天就能写完。但这一章真正的难度在于状态流,而不是组件渲染。
用户从首页点“登录”,输入手机号和密码,拿到 token,刷新页面后要能让顶栏立即恢复登录态;用户没登录时访问“个人中心”,路由守卫要把他拦回登录页,登录成功后再带回原来的目标页;用户点退出,不止要清掉 token,还要把 store 里的用户信息一起清掉,否则回到首页还残留着上一个账号的昵称。这些状态流转逻辑一旦没想清楚,就会出现很多“看似正常、实则处处是洞”的体验问题。
所以我把这个最终章当成一次状态管理的综合练习来做,而不是单纯地“照着课示例敲一个 Header”。下面每部分我都会把方案选型、代码写法和踩坑经验揉在一起讲。
1.3 技术栈与前置铺垫说明
这套课的主技术栈是 Vue3 + Vite + Vue Router 4 + Pinia + axios,样式方案用的原生 SCSS。如果你用的还是 Vue2 + Vuex 的旧组合,不要紧,大部分逻辑挪一挪也能用,无非是选项式写法和组合式写法的差异,状态管理的核心思路完全一致。
在写最终章之前,默认你已经掌握了 axios 请求封装、Vue Router 基本路由配置、Pinia 的基础定义方式。这些在前面章节都会反复用到,缺一补一即可。
2. 顶部导航栏:布局、固定定位与响应式细节
2.1 Header 布局方案与间距设定
顶栏布局我就直接用最常见的左侧 logo、中间导航、右侧用户区三段式结构。外层容器设height: 60px,内部用 flex 排布,关键样式如下:
.app-header { position: fixed; top: 0; left: 0; right: 0; z-index: 100; height: 60px; background: #fff; border-bottom: 1px solid #eee; display: flex; align-items: center; padding: 0 24px; transition: box-shadow 0.2s; &.is-scrolled { box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); } }为什么用fixed而不是sticky?这个点我在练的时候专门对比过。position: sticky实现起来更简单,不脱离文档流,不需要给主体内容额外加padding-top,但它在某些嵌套滚动容器里的表现不稳定,而且一旦顶部结构复杂(比如顶栏里有多个定位元素)会冒出各种诡异的层级问题。对资讯类应用来说,顶栏必须始终固定,内容再长也要保证顶部导航可点击,所以用fixed是最保守、最可控的选择。
用fixed之后有一个必须处理的副作用:顶栏脱离文档流了,首屏内容会被遮住。我的做法是给外层内容容器统一加一个padding-top: 60px,等价于占位,而不是单独给每个页面都要写一遍。
间距上,24px的左右内边距在移动端有点大,我会在媒体查询里改成12px。顶栏高度 60px 是移动端和桌面端都比较舒服的点击区域高度,低于 56px 在手机上容易误触,高于 64px 又会让页面有效内容变短。这些数值看起来随意,其实都是按手指点击热区的基本标准来定的。
2.2 导航菜单高亮的三种常见做法
导航菜单高亮是顶栏最容易出岔子的地方。我见过不少项目,首页高亮永远不对,因为它用了/路径,而router-link的激活判定默认是“当前路径是否以目标路径开头”,所以只要你停留在任何页面,/都会被认为是激活状态。这一点新手最容易踩。
第一种做法:直接用router-link的active-class和exact-active-class。
<nav class="nav-menu"> <router-link to="/" exact-active-class="active">首页</router-link> <router-link to="/category" active-class="active">分类</router-link> <router-link to="/topic" active-class="active">专题</router-link> </nav>首页必须用exact-active-class,如果只写active-class,那么访问/category时首页也会高亮,因为/是/category的前缀。自己理解原理之后,这个坑以后就不会再踩了。
第二种做法:手动监听当前路由,用计算属性判断高亮。适合菜单项需要带参数或者要做权限过滤的场景。
<script setup> import { computed } from 'vue' import { useRoute } from 'vue-router' const route = useRoute() const menuList = [ { name: '首页', path: '/' }, { name: '分类', path: '/category' }, { name: '专题', path: '/topic' }, ] const activePath = computed(() => { if (route.path === '/') return '/' return '/' + route.path.split('/')[1] }) </script>这种做法的核心思路是“只取一级路径做粗粒度匹配”,像/category/123这样的详情路由也能正确高亮到“分类”这一项。它比router-link自带方案更灵活,因为你可以根据业务逻辑做完全自定义的激活条件,比如某些菜单在特定页面下要同时高亮两项。
第三种做法是配置化渲染,把菜单数据抽到一个统一配置里,模板只负责遍历输出。我最终选的就是这个,因为后续要加权限控制时,只要在菜单数据里加一个requiresAuth字段,再结合用户 store 过滤一遍就行,不需要改模板结构。
const menuList = [ { text: '首页', path: '/', exact: true }, { text: '分类', path: '/category' }, { text: '专题', path: '/topic' }, { text: '个人中心', path: '/profile', requiresAuth: true }, ]2.3 滚动阴影与细节优化
顶栏是白色背景,如果页面滚动时顶部没有边界,内容会和导航糊在一起,阅读体验很差。我加的滚动阴影很简单:监听 window 的 scroll 事件,滚动距离大于 0 时给 header 增加一个类名,阴影用 CSS 过渡慢慢浮现。
const scrolled = ref(false) const onScroll = () => { scrolled.value = window.scrollY > 0 } onMounted(() => { window.addEventListener('scroll', onScroll, { passive: true }) }) onUnmounted(() => { window.removeEventListener('scroll', onScroll) })这里有两个细节值得说。第一,监听器要加passive: true,因为滚动事件触发频率很高,尤其是在移动端,不加 passive 浏览器无法确定你是否会在监听器里阻止默认行为,滚动性能会打折扣。第二,在组件卸载时要移除监听器,不然页面反复切换,滚动事件会越积越多,造成内存泄漏和诡异的多次触发。
我见过有人用节流来做滚动阴影,实际测下来完全没必要。阴影切换本身只改一个类名,不是高频 DOM 操作,滚动事件即便一秒触发六十次也没有体力瓶颈,加节流反而让阴影出现得“卡顿”。真正需要节流的是那种滚动到某个位置动态加载数据的场景,不要所有滚动逻辑都套同一个模板。
2.4 移动端菜单折叠与点击外部关闭
顶栏在移动端不能直接展示所有菜单,否则一行放不下。常规方案是:宽度小于 768px 时,隐藏中间导航,右侧显示一个汉堡按钮,点击后展开一个下拉面板。
展开面板之后,马上会遇到一个问题:点击页面其他区域,面板不会自动关闭。因为这个下拉面板是自定义组件,Vue 没有天然的事件去监听“点击到了组件外部”。我当时自己封装了一个clickOutside指令来解这个问题。
const clickOutside = { mounted(el, binding) { el._clickOutsideHandler = (event) => { if (!el.contains(event.target)) { binding.value() } } document.addEventListener('click', el._clickOutsideHandler) }, unmounted(el) { document.removeEventListener('click', el._clickOutsideHandler) }, }指令的用法:
<div v-click-outside="closeMenu" class="mobile-menu"> <!-- 菜单面板 --> </div>这个指令化的封装有一个好处:用户下拉菜单也可以复用同一个指令,不需要每个组件都重复写一遍全局监听逻辑。我的实际经验是,不要为了省事直接给document加一个全局点击事件然后到处判断目标元素,那样代码会非常散。封装成指令,既符合 Vue 的组合式思维,后续维护也轻松。
3. 用户功能:登录状态管理与信息展示
3.1 token 的存储方案与状态恢复
用户功能底层是登录状态。登录接口返回一个 token,前端必须把它持久化,否则一刷新页面就回到未登录状态,整个应用就没法用了。我采用的是课程里最主流的方法:token 存localStorage,同时写进 Pinia 的 user store。
// store/user.js export const useUserStore = defineStore('user', { state: () => ({ token: localStorage.getItem('APP_TOKEN') || '', userInfo: null, }), getters: { isLogin: (state) => !!state.token, }, actions: { async login(loginForm) { const { data } = await request.post('/api/login', loginForm) this.token = data.token localStorage.setItem('APP_TOKEN', data.token) await this.fetchUser() }, async fetchUser() { const { data } = await request.get('/api/user/profile') this.userInfo = data }, logout() { this.token = '' this.userInfo = null localStorage.removeItem('APP_TOKEN') }, }, })为什么 token 存localStorage而不是sessionStorage?因为资讯类应用通常希望用户关闭浏览器再打开,还能保持登录状态。用sessionStorage的话,关掉标签页就失效了,体验像一个始终不会记住你的网站。当然,localStorage也有它的缺点,任何 XSS 漏洞都有可能直接拿走 token。但作为实战课程项目,后端又没有提供 httpOnly cookie 的登录方案,用localStorage是最合理的选择。
有一件事必须时刻提醒自己:token 只是“登录凭证”,不等于“用户信息”。页面一刷新,localStorage里的 token 还在,不能只凭 token 就让页面相信“用户已登录”,必须重新调一次getUserInfo把用户信息拉回来。这个逻辑我放在 App.vue 的初始化流程里,通过 Pinia 的 action 统一触发。
3.2 登录与注册入口的 UI 跳转
这一章我不打算重造轮子,登录弹窗和注册页的样式用普通表单控件就能完成。但有一个交互层的问题很关键:顶栏右右侧在没有登录时要展示“登录/注册”,点击后是跳独立页面还是弹窗?
我最后选择了独立路由页。原因有两个:第一,课程项目的登录页本来就要承载注册、忘记密码、第三方登录等一堆入口,做成弹窗会让弹窗组件越来越臃肿;第二,路由守卫拦截未登录用户时,跳转目标必须是一个可跳转的路由地址,独立页面在这条链路里最自然。
登录表单的校验我复用了一个简单的校验函数集合。提醒一句:前端校验只是体验优化,真正安全由后端兜底。不要因为前端做了非空校验就以为万事大吉,接口参数在后端同样要校验。这一点在实战项目里尤其重要,面试时也会被问到。
登录成功后,不是简单跳回首页,而是要支持“从哪里来回哪里去”。用户未登录点进个人中心,被守卫带到登录页,登录完成后如果直接回首页,用户还得再点一次个人中心,体验很断裂。所以我处理成登录页从route.query.redirect里读出目标地址,登录成功后跳回去。
3.3 用户信息渲染与头像兜底
登录成功后,顶栏右侧从“登录/注册”切换成头像加昵称。头像尺寸 32px,圆形,我用border-radius: 50%配合object-fit: cover,保证不管后端返回什么比例的头像图片都不会拉伸变形。
头像最常遇到的坑是图片资源失效,接口返回一个 404,页面就裂一个图。我的兜底方案是给img加@error事件,加载失败时替换成本地默认头像:
<img :src="userInfo?.avatar || defaultAvatar" :onerror="handleAvatarError" class="user-avatar" /> <script setup> const handleAvatarError = (event) => { event.target.src = defaultAvatar } </script>这里要防止死循环,如果本地默认头像也加载失败,onerror会再次触发。我在处理函数里加一个判断,如果当前 src 已经等于默认头像,就不再替换。这个细节没处理,在弱网环境下会出现反复请求同一张破图的尴尬情况。
3.4 下拉菜单与退出登录的完整链路
头像旁边放一个下拉菜单,习惯上把“个人中心”“账号设置”“退出登录”放在一起。下拉触发方式我选了点击触发,而不是 hover 触发。因为 hover 在触屏设备上天然不稳定,既然要做,干脆从一开始就用对移动端友好的交互。
退出登录是一个需要完整走链路的功能,我在最终章里严格按照三步处理:
第一,弹一个确认框,防止用户误触。确认框我用自己的轻量组件实现,避免引UI库只是为了一个弹窗。
第二,请求退出接口。有些项目后端不做退出鉴权,退出只需要前端把 token 清掉即可。但正规一点的接口会把这个 token 列入黑名单,所以我的做法是:如果调退出接口失败,仍然执行本地清理,不让后端异常卡住用户的退出操作。
第三,调用userStore.logout(),统一清掉 store 里的 token、userInfo 以及localStorage,然后跳转首页。这里特别强调统一入口,因为我第一次写的时候,直接在组件里localStorage.removeItem('APP_TOKEN')然后router.push('/'),结果 store 里的 userInfo 还是旧数据,顶栏瞬间变成“头像还在但接口全 401”的中间态。后来把所有清理逻辑收敛到logoutaction 里,组件只负责调用和跳转,问题才彻底解决。
4. 路由守卫与全局权限收尾
4.1 白名单设计与守卫代码
用户功能做完后,权限控制由路由守卫来收尾。资讯类应用的特点是部分页面公开,部分页面必须登录,所以守卫不能一刀切。我的设计是维护一个白名单数组,公开页面直接放行,其余页面未登录时跳到登录页。
const WHITE_LIST = ['/login', '/register', '/home'] router.beforeEach((to, from, next) => { const userStore = useUserStore() if (userStore.token) { next() } else if (WHITE_LIST.includes(to.path)) { next() } else { next({ path: '/login', query: { redirect: to.fullPath } }) } })这几个白名单页面的选择本身就是业务判断。首页是资讯列表,游客可看;登录和注册页面显然不能要求已登录;其余的发布文章、个人中心、设置页全都需要登录。
有一个容易被忽略的点:白名单匹配不是死板的includes就可以,如果登录页还带了/login?redirect=/profile,它的to.path仍然是/login,所以没问题。但如果以后出现类似/article/edit/123这样需要动态判断的页面,就得写更细的规则,不能用简单的数组匹配一刀切。
4.2 登录后的回跳逻辑
前面提到登录成功要跳回redirect地址,守卫这边要保证这个地址是可用的。我的登录页在onMounted里从route.query.redirect取值,如果这个值存在且是以/开头的同源地址,就把它存起来,登录成功后router.replace(redirect);如果不存在,默认跳首页。
之所以用replace而不是push,是为了不把登录页留在历史记录里。用户登录成功后按浏览器返回按钮,不应该回到那个已经没意义的登录表单页。这个细节很多课程不会讲,但实际体验提升非常明显。
4.3 接口 401 的统一处理与全局拦截
路由守卫解决的是“进入页面前的拦截”,但还有一种情况是“进入后 token 过期失效”。比如用户一直停留在页面上,token 在后端已过期,用户再点一次发布,接口返回 401。如果每个页面都单独写处理逻辑,代码会爆炸。正确做法是在 axios 响应拦截器里统一处理。
service.interceptors.response.use( (response) => response.data, (error) => { if (error.response && error.response.status === 401) { const userStore = useUserStore() userStore.logout() const currentRoute = router.currentRoute.value if (currentRoute.path !== '/login') { router.push({ path: '/login', query: { redirect: currentRoute.fullPath } }) } } return Promise.reject(error) } )401 处理里有一个并发请求的坑。假设用户同时发出三个请求,三个都因为 token 过期返回 401,拦截器会执行三次logout和三次router.push,出现重复跳转、甚至把目标地址覆盖成非预期的字符串。我的做法是在拦截器外面加一个简单的防重标记,第一次处理 401 时把“正在跳转登录页”的标志置为 true,后续重复触发直接跳过。
这个场景在真实项目中非常常见,尤其是页面里有好几个并行请求的时候。不处理的话,轻则登录页刷新两次,重则 redirect 参数变成上一次的旧地址。最终章做到这个层面,才算把用户功能做完整了。
5. 常见问题与排查实录
5.1 问题速查表
最终章踩过的坑,我整理成一个速查表,绝大多数同类项目都会遇到。
| 现象 | 常见原因 | 解决方案 |
|---|---|---|
| 刷新后用户昵称消失 | 只有 token 持久化,没有重新拉取用户信息 | App 初始化时调用userStore.fetchUser() |
| 首页菜单一直高亮 | /路径匹配所有路由前缀 | 首页使用exact-active-class或自定义匹配逻辑 |
| 固定顶栏遮挡首屏内容 | 顶栏脱离文档流,没有占位 | 主体容器加padding-top: 60px |
| 退出后首页还显示旧用户 | 只清了 token,没清 store 的 userInfo | 统一在logoutaction 中清理所有状态 |
| 下拉菜单点其他区域不关闭 | 没有全局点击外部监听 | 封装clickOutside指令 |
| 多个接口同时 401 重复跳登录页 | 拦截器没有防重标记 | 增加 isRedirecting 标志位 |
5.2 几个值得记录的调试现场
我印象最深的是“刷新丢用户信息”这个问题。第一次实现时,我在登录页登录成功后把用户信息拉回来,顶栏显示一切正常。然后我刷新页面,顶栏登录态还在,因为 token 从 localStorage 恢复了,但昵称和头像消失,只剩下一个空的默认头像。排查后发现,App.vue 里没有做用户信息恢复。token 是恢复了,但 store 的 userInfo 是 null,组件拿不到值就不会渲染。解法是在 App 的初始化流程里根据 token 是否存在来决定要不要调fetchUser。
另一个现场是登录页回跳地址的“脏数据”。有一个测试账号登录后,发现它总是被带到上一次的发布页,而不是当前应该去的页面。后来查了代码,发现是登录页用一个模块级变量保存 redirect,没有在每次进入页面时从route.query里刷新。第二次登录时,旧值还留在模块变量中。改成在onMounted里重新读取就稳定了。
5.3 避坑心得
在最终章里,我发现这类公共组件的最大问题不是“写不出来”,而是“组件之间状态同步的时机”。顶栏的渲染依赖用户 store,用户 store 的变化又来自登录、退出、刷新恢复三个入口,这三个入口如果不在同一套 action 里流转,就会产生各种中间态。
我后来给自己定了一条规则:所有涉及登录状态的变更,一律走 user store 的 action,组件里不要出现直接修改 token、直接操作 localStorage 的散装代码。这条规则执行后,调试难度大幅下降。
6. 最终章的实操心得与扩展建议
6.1 三遍学习法
第一遍,我跟着课程示例抄,目标是把每个 API 的用法跑通。第二遍,我关掉示例,只凭记忆默写整块功能,这里就暴露了很多“以为懂了其实没懂”的细节,比如exact-active-class的匹配原理、登录后要立刻fetchUser、退出时要清空 store。第三遍,我给这个项目加了一个新需求:未登录用户点“个人中心”时,登录成功后要回到个人中心而不是首页——这个回跳逻辑就是我在第三遍加的,直接模拟了真实业务。
这套方法比单纯多看几遍视频有效得多。最终章尤其适合这么练,因为它的逻辑环多,一遍根本绕不清楚。
6.2 扩展练习设计
做完标准功能之后,我建议你试着往里面加几个真实项目里大概率会遇到的需求。第一个是“记住我”选项,勾选时 token 存 localStorage,不勾选时存 sessionStorage;第二个是“账号被顶下线”的处理,后端通过某种长连接通知前端登录态失效时,前端要执行和 401 一样的清理逻辑;第三个是菜单权限过滤,管理员和普通用户进入项目后看到的顶栏菜单项不同,需要把菜单列表和用户角色关联起来。
练到最后,你会发现自己对状态管理、路由守卫、公共组件的理解已经上了一个台阶。
6.3 面试中怎么讲这个模块
如果面试时让你讲项目里的用户体系,不要只背“我用 Pinia 存了 token”。更好的讲法是描述状态流转:未登录访问受保护页面时被守卫拦截 → 登录页读取 redirect 参数 → 登录成功后写入 token 并拉取用户信息 → 跳回目标页 → 后续请求在 axios 拦截器统一注入 token → 遇到 401 时统一清理登录态并回到登录页。
这个流程讲清楚,面试官就能判断你不是背代码,而是真正理解了完整链路。
我最后想单独说一条自己反复踩的教训:最终章这几个功能块,一定不要分散做、最后再拼。导航栏的高亮依赖路由,用户区的展示依赖登录态,登录态又影响路由守卫,三者是典型的环形依赖。如果不用一个 store 把它们串起来,后面每一步都在补窟窿。我第二遍重写时,把所有状态全部收拢到 user store,代码量反而少了三分之一。
如果你正在练类似的实战项目,我建议把最终章当成一个状态管理的综合练习题来做,而不是当作“最后一个组件”去抄。这样练完收获会完全不同。