
这个系列刷到最后一章恰好落在了“顶部导航栏与用户功能”上。最终章的内容看起来就是加一个顶栏、做一套登录授权但真正动手写的时候才发现导航高亮、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 classnav-menu router-link to/ exact-active-classactive首页/router-link router-link to/category active-classactive分类/router-link router-link to/topic active-classactive专题/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-outsidecloseMenu classmobile-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 :srcuserInfo?.avatar || defaultAvatar :onerrorhandleAvatarError classuser-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代码量反而少了三分之一。如果你正在练类似的实战项目我建议把最终章当成一个状态管理的综合练习题来做而不是当作“最后一个组件”去抄。这样练完收获会完全不同。