直接从开发那几天说起吧。这个“Vue day10 完整购物网页(登录页,首页,搜索)”,说白了就是我用 Vue 练手到第 10 天时做的一个全套小项目。当时给自己的要求是:不用后台服务,用 Mock 数据也能把登录、首页展示、关键词搜索这三件套跑通,同时把 Vue Router、组件通信、状态管理、样式布局这些零散知识点全部串起来。项目做下来,收获比看十天教程都大,今天把整个实现思路、核心代码和踩过的坑完整复盘一遍。
这个项目最适合谁看?一类是刚学完 Vue 基础语法、想看一个完整项目长什么样的新手;另一类是准备面试、需要把“Vue 项目实战经验”讲清楚的同学。购物网页的模块边界很清晰:登录页负责表单处理和路由守卫,首页负责数据拉取和组件渲染,搜索负责交互逻辑和状态同步,三个模块基本上覆盖了 Vue 在真实业务中九成以上的日常操作。我会按实际开发顺序来写,尽量把为什么这么做也讲明白,而不是简单贴一段代码。
1. 项目定位与整体设计思路
1.1 为什么购物网页是最合适的第 10 天练手项目
很多人在 Vue 入门之后会陷入一个尴尬期:语法都看懂了,组件会写了,但一打开编辑器就不知道从哪下手。我当时的破局方式就是找一个业务完整度适中、规模不能太小也不能太大的项目。购物网页恰好合适。
先说业务复杂度。登录页、首页、搜索页,这是电商类应用最典型的三个入口场景。登录页涉及表单校验、状态持久化和路由守卫;首页涉及数据请求、列表渲染、轮播交互;搜索涉及输入防抖、历史记录、结果跳转。这三块内容彼此独立又能相互串联,做完以后你会自然理解“页面之间是怎么协作的”。
再说工程复杂度。购物网页不需要复杂的权限系统,不需要多级路由嵌套,不需要复杂的动画编排,但又不至于只有一两个页面。对于第 10 天这个阶段,正好能把基本功练扎实,又不会因为业务复杂导致半途而废。
最后是演示和改造空间。做完以后既可以拿给朋友看,也可以在此基础上加购物车、加商品详情、加个人中心,每一步都是一次独立的功能迭代。相比做一个毫无业务逻辑的功能列表页,购物网页的“完成感”和“延伸感”都强得多。
1.2 技术栈选型与目录结构规划
我在这个项目里选的是 Vue 2 + Vue Router 3 + Vuex 3 + Axios,没有用 Vue 3。原因很简单:当时是第 10 天,Vue 2 的教程和踩坑案例最多,而且公司项目里 Vue 2 存量还很大。现在如果是从零学习,我更建议直接上 Vue 3 + Vue Router 4 + Pinia,思路完全一致,但 API 更新。这个后面单独说。
目录结构遵循了一个很常见的划分方式,也是大多数 Vue 项目模板的基础形态:
src ├── api // 接口请求封装 │ ├── login.js │ ├── home.js │ └── search.js ├── assets │ ├── images │ └── styles ├── components // 公共组件 │ ├── Header.vue │ ├── Footer.vue │ ├── ProductCard.vue │ ├── SearchBar.vue │ └── Carousel.vue ├── router │ └── index.js ├── store │ ├── index.js │ ├── modules │ │ ├── user.js │ │ └── search.js ├── views │ ├── Login.vue │ ├── Home.vue │ └── SearchResult.vue ├── utils │ ├── request.js │ └── auth.js ├── App.vue └── main.js这个结构的核心原则是“按业务域分文件夹,按职责分文件”。第 10 天的时候也许意识不到,但等你回头维护时会发现:想改登录逻辑去api/login.js和store/modules/user.js,想改搜索规则去store/modules/search.js,思路非常清楚。
1.3 路由设计:登录、首页、搜索怎么串起来
路由是整个项目的地基。我一开始就规划了三个顶层路由,登录页是独立页面,首页和搜索结果是带公共布局的页面:
const routes = [ { path: '/login', name: 'Login', component: () => import('@/views/Login.vue'), meta: { title: '登录' } }, { path: '/', component: () => import('@/layout/MainLayout.vue'), children: [ { path: '', name: 'Home', component: () => import('@/views/Home.vue'), meta: { title: '首页' } }, { path: 'search', name: 'SearchResult', component: () => import('@/views/SearchResult.vue'), meta: { title: '搜索' } } ] }, { path: '*', redirect: '/' } ]这里有几个当时踩坑后总结出来的点。
第一个是component: () => import()这种写法。这是路由懒加载,作用是让打包后的 js 按页面拆分成多个小块,用户打开首页时不会一次性下载全部代码。开发阶段不会感觉到区别,但正式上线后首屏速度差距很大。第 10 天养成这个习惯,后面做任何项目都能直接用。
第二个是嵌套路由的用法。首页和搜索结果页都有顶部导航和底部栏,我抽了一个MainLayout.vue作为父组件,里面用<router-view />渲染子页面。这样导航栏只需要写一次,切换页面时不会重复渲染,比在每个页面里复制粘贴导航代码干净得多。
第三个是通配路由path: '*'。用户的输入地址不一定合法,加一个兜底重定向能避免白屏。虽然项目小,但这个习惯值钱。
2. 登录页到底怎么做才算“完整”
2.1 表单校验:不能只靠 HTML 自带提示
登录页最基础的要求是能输入账号密码然后提交。但一个“完整”的登录页,至少要有两大部分:前端校验和后端逻辑对接。我们项目里没有真实后端,所以用 Mock 数据模拟了接口。
表单校验我当时用了直接手写的方式,没有引入async-validator,原因是项目规模不大。写下来以后你会发现,手写一遍反而能加深对校验规则的理解:
<template> <div class="login-container"> <el-form ref="loginFormRef" :model="loginForm" :rules="loginRules" label-width="80px" > <el-form-item label="账号" prop="username"> <el-input v-model.trim="loginForm.username" placeholder="请输入账号" /> </el-form-item> <el-form-item label="密码" prop="password"> <el-input v-model="loginForm.password" type="password" show-password placeholder="请输入密码" @keyup.enter="handleLogin" /> </el-form-item> <el-button type="primary" :loading="loading" @click="handleLogin" > 登录 </el-button> </el-form> </div> </template> <script> export default { name: 'Login', data() { return { loginForm: { username: '', password: '' }, loginRules: { username: [ { required: true, message: '请输入账号', trigger: 'blur' }, { min: 3, max: 12, message: '账号长度在 3 到 12 个字符', trigger: 'blur' } ], password: [ { required: true, message: '请输入密码', trigger: 'blur' }, { min: 6, max: 20, message: '密码长度在 6 到 20 个字符', trigger: 'blur' } ] }, loading: false } } } </script>实际开发中你大概率会直接用组件库的表单组件,我用的 Element UI。这里有一个很容易被新手忽略的点:校验规则里必须写trigger。blur是失焦时触发,change是内容变化时触发。如果用默认的change,用户还没输完就不断报错,体验很差;如果用blur,则是输入完再提示,更友好。
还有一点,v-model.trim这个修饰符对账号非常有用,可以避免用户不小心复制了一个空格进来导致登录失败。密码不能用trim,因为密码本身可能包含空格。
2.2 接口请求封装与 Mock 登录逻辑
前端页面不能凭空判断“登录成功”,它需要请求接口。但我们没有后端,所以当时做了两层处理。
第一层是封装request.js,基于 Axios 统一设置基础路径和超时时间:
import axios from 'axios' const service = axios.create({ baseURL: '/api', timeout: 10000 }) service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) service.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { return Promise.reject(new Error(res.message || 'Error')) } return res }, error => { return Promise.reject(error) } ) export default service第二层是在api/login.js里模拟真实接口。原理是构造一个 Promise,延迟 500ms 后返回结果,这样在浏览器里看到的调用方式与实际后端完全一致:
import request from '@/utils/request' export function login(data) { return new Promise((resolve, reject) => { setTimeout(() => { if (data.username === 'admin' && data.password === '123456') { resolve({ code: 200, message: '登录成功', data: { token: 'mock-token-' + Date.now(), nickname: '管理员', avatar: '' } }) } else { reject({ code: 400, message: '账号或密码错误' }) } }, 500) }) } export function logout() { return request({ url: '/logout', method: 'post' }) }从长远来看,项目上线时只需要把login.js里的 Mock Promise 换成真实的request({ url: '/login', method: 'post', data })就行,其余的组件逻辑完全不用动。这就是“接口层封装”的意义。
2.3 token 存储与路由守卫
登录成功后要做三件事:保存 token、记录用户信息、跳转首页。我当时把用户相关信息都放到了 Vuex 里,同时把 token 存到了localStorage,并用utils/auth.js做了存取删的封装:
const TOKEN_KEY = 'vue_shop_token' export function getToken() { return localStorage.getItem(TOKEN_KEY) } export function setToken(token) { localStorage.setItem(TOKEN_KEY, token) } export function removeToken() { localStorage.removeItem(TOKEN_KEY) }为什么 token 要存localStorage而不是只放 Vuex?因为 Vuex 里的数据是内存数据,页面一刷新就没了。刷新后需要有一个持久化的凭证,才能向后端标明“我是已登录用户”。存localStorage相对简单直接,当然如果项目对安全要求高,会考虑cookie或者更复杂的方式,但第 10 天阶段先把这个链路跑通最重要。
路由守卫是这个登录流程里最关键的一环。它的作用是拦截未登录的访问:
router.beforeEach((to, from, next) => { const token = getToken() if (to.path === '/login') { if (token) { next('/') } else { next() } } else { if (token) { next() } else { next(`/login?redirect=${encodeURIComponent(to.fullPath)}`) } } })这个redirect参数是后来我自己加上的。一开始没有,用户直接访问首页时被踢到登录页,登录成功后却永远回到首页。后来改成记录用户原本想去的地址,登录成功后跳回原目标,体验立刻合理了很多。
2.4 登录状态的展示与退出
用户登录之后,顶栏导航通常会显示头像和昵称,并提供退出按钮。这一步涉及组件内读取 Vuex 状态和触发 mutation/action。
我在Header.vue里这样处理:
<template> <div class="header"> <div class="logo">Vue Shop</div> <SearchBar v-if="isLogin" /> <div class="user-info"> <template v-if="isLogin"> <span>{{ nickname }}</span> <el-button type="text" @click="handleLogout">退出</el-button> </template> <router-link v-else to="/login">登录</router-link> </div> </div> </template> <script> import { mapGetters, mapActions } from 'vuex' export default { name: 'Header', computed: { ...mapGetters(['isLogin', 'nickname']) }, methods: { ...mapActions(['logoutAction']), handleLogout() { this.logoutAction() this.$router.push('/login') } } } </script>退出登录时不要只清localStorage,Vuex 里的用户信息也要同步清掉。所以我当时的user.js模块大概是这样的:
const state = { token: getToken() || '', userInfo: {} } const mutations = { SET_TOKEN(state, token) { state.token = token }, SET_USER_INFO(state, userInfo) { state.userInfo = userInfo } } const actions = { loginAction({ commit }, data) { return new Promise((resolve, reject) => { login(data) .then(res => { const { token, ...userInfo } = res.data commit('SET_TOKEN', token) commit('SET_USER_INFO', userInfo) setToken(token) resolve(res) }) .catch(err => reject(err)) }) }, logoutAction({ commit }) { commit('SET_TOKEN', '') commit('SET_USER_INFO', {}) removeToken() } }3. 首页开发:组件拆分和数据渲染
3.1 首页先想清楚“要展示什么”
首页看起来简单,其实是最容易写乱的页面。如果全部代码都堆在Home.vue里,随随便便就有几千行。我在开工之前先画了一个简单的模块清单,然后按模块拆组件。
我的首页分为四个区块:顶部导航(抽成公共 Header)、轮播图(Banner)、分类导航(8 个入口)、商品列表(商品卡片)。加一个底部栏(Footer)。组件拆分以后,Home.vue的主模板干干净净:
<template> <div class="home"> <Header /> <div class="home-body"> <Carousel :banners="banners" /> <CategoryNav :categories="categories" /> <div class="product-list"> <ProductCard v-for="item in products" :key="item.id" :product="item" /> </div> </div> <Footer /> </div> </template>这里有一个新手特别容易犯的错:一个页面里所有内容全写在当前组件中。这样做不是不行,但后续维护会非常痛苦。比如商品卡片在某些页面要显示价格,在其他页面要隐藏价格,如果你把卡片逻辑写死在页面里,就没法复用了;抽成ProductCard组件后,只需要加一个showPrice的 prop 就行。
3.2 数据请求放在 created 还是 mounted
首页的数据请求我放在created生命周期里,而不是mounted。原因是created阶段组件实例已经创建,数据对象已经可用,此时发起请求可以更早地拿到数据;而mounted是在 DOM 挂载之后执行,多少会晚一点。
代码结构大概是这样:
export default { name: 'Home', data() { return { banners: [], categories: [], products: [] } }, created() { this.fetchBanners() this.fetchCategories() this.fetchProducts() }, methods: { async fetchBanners() { const res = await getBanners() this.banners = res.data }, async fetchCategories() { const res = await getCategories() this.categories = res.data }, async fetchProducts() { const res = await getProducts() this.products = res.data } } }这里要注意一个问题:如果三个接口没有依赖关系,应该并行请求。我当时手滑写成了一个 Promise 嵌套一个 Promise 的顺序请求,结果首页加载明显变慢,后来改成了上面这种独立调用。接口有依赖时才考虑串联,没有依赖千万别自己制造依赖。
3.3 轮播图手写还是用插件
轮播图是首页最常见的交互组件。我当时没有直接用 Element UI 的轮播组件,而是手写了一个,目的很简单:练原理。现在回想起来,这个决定非常值。
一个最简单的轮播图核心是:一个容器,一组图片,一个当前索引,结合 CSS 的transform做位移:
<template> <div class="carousel"> <div class="carousel-wrapper" :style="{ transform: `translateX(-${currentIndex * 100}%)` }" > <div v-for="(item, index) in banners" :key="index" class="carousel-slide" :style="{ backgroundImage: `url(${item.imageUrl})` }" > </div> </div> <button v-for="(item, index) in banners" :key="'dot-' + index" :class="{ active: index === currentIndex }" @click="currentIndex = index" ></button> </div> </template>配套的 CSS 关键点:
.carousel { overflow: hidden; position: relative; width: 100%; height: 400px; } .carousel-wrapper { display: flex; width: 100%; height: 100%; transition: transform 0.5s ease; } .carousel-slide { flex-shrink: 0; width: 100%; height: 100%; background-size: cover; background-position: center; }自动播放、无限循环这些功能属于进阶需求。第 10 天能实现手动切换和自动播放就够了。自动播放的实现方式是在组件加载后起一个定时器,每隔 3 秒把currentIndex加一,到末尾时回到 0;组件销毁前用clearInterval清除,不然会报内存泄漏。
3.4 商品列表的渲染与空状态
商品列表用v-for渲染。我当时只写了正常列表,没有考虑空状态和加载状态,后来加上以后才发现这些状态处理也是项目完整度的重要表现。
加载中显示骨架屏或 loading,加载完展示数据,没有数据显示空状态提示。初次进入页面时接口还没返回,如果列表区域一片空白,用户会以为页面坏了。我加了一个loading变量来控制显示:
<div v-if="loading" class="loading">加载中...</div> <div v-else-if="products.length === 0" class="empty">暂无商品</div> <div v-else class="product-list"> <ProductCard v-for="item in products" :key="item.id" :product="item" /> </div>这个写法要配合在请求方法里设置loading = true和loading = false。实际项目里我通常会再包一层Promise.finally,确保无论成功失败都能把 loading 关掉:
methods: { async fetchProducts() { this.loading = true try { const res = await getProducts() this.products = res.data } finally { this.loading = false } } }3.5 首页样式与响应式布局
首页的样式我用了 Scoped CSS,也就是在<style>标签上加scoped。这个属性代表当前组件的样式只在本组件内生效,不会污染全局。第 10 天前后我吃了不少样式污染的亏,后来就养成了所有组件默认写scoped的习惯。
响应式布局方面,我用了简单的媒体查询和 flex 布局。商品列表是重点,本质上是一个网格,我这样处理:
.product-list { display: flex; flex-wrap: wrap; justify-content: space-between; } .product-card { width: calc(25% - 10px); margin-bottom: 20px; } @media (max-width: 768px) { .product-card { width: calc(50% - 10px); } } @media (max-width: 480px) { .product-card { width: 100%; } }手机上 2 列、平板上 4 列、大屏上可以 5-6 列,通过calc()计算宽度并留出间隙,比直接用百分比更精确。
4. 搜索功能:从输入框到结果页的完整链路
4.1 搜索入口:顶部搜索框的交互
搜索功能我做了两级入口。第一级是 Header 里的搜索框,用户输入关键词后可以直接按回车跳转到搜索结果页;第二级是首页的分类导航里也可以点入预置的关键词搜索。
Header 里的搜索框是核心,代码如下:
<template> <div class="search-bar"> <el-input v-model.trim="keyword" placeholder="搜索商品" clearable @keyup.enter="handleSearch" @clear="handleClear" /> <el-button type="primary" @click="handleSearch">搜索</el-button> </div> </template> <script> export default { name: 'SearchBar', data() { return { keyword: '' } }, methods: { handleSearch() { if (!this.keyword) { this.$message.warning('请输入搜索关键词') return } this.$router.push({ path: '/search', query: { keyword: this.keyword } }) } } } </script>第 10 天时我犯过一个非常典型的问题:搜索框的 input 值没有绑定到 Vuex,而是放在组件内部,搜索结果页需要关键词时只能从路由 query 里拿。一开始我用 Vuex 管理关键词,结果刷新页面后 Vuex 清空了,关键词也丢了。后来改成路由 query 传参,刷新后从$route.query.keyword读取,问题天然解决。这背后的原则是:需要共享或者需要刷新生效的数据,优先放 URL;仅仅在页面内部临时使用的状态,放组件 data 就够。
4.2 搜索防抖:避免每次按键都发请求
真实项目里通常会做“输入时实时推荐”或“输入后自动搜索”,这就产生了频繁的接口调用问题。如果用户每敲一个字母就发一次请求,既浪费资源又可能响应错乱。解决方式是防抖。
防抖的思路是:当事件被触发后,延迟 n 秒再执行回调;如果在 n 秒内再次触发,则重新计时。我写了一个常见的防抖函数:
export function debounce(fn, delay = 300) { let timer = null return function(...args) { if (timer) clearTimeout(timer) timer = setTimeout(() => { fn.apply(this, args) }, delay) } }在组件中的使用方式是:
methods: { handleInput() { this.debouncedSearch() }, doSearch() { // 真正发送搜索请求的逻辑 this.$store.dispatch('searchModule/fetchSearchResult', this.keyword) } }, created() { this.debouncedSearch = debounce(this.doSearch, 300) }理解这个原理的价值在于:你会明白防抖不是组件库专属功能,它只是一个普通的 JavaScript 函数。面试里经常被问到“搜索框怎么做防抖”,照着上面这段代码变形就能答得不错。
4.3 搜索历史:localStorage 持久化
搜索结果页我加了一个“搜索历史”功能。很多购物网站都有这个交互,用来记录用户的搜索记录,方便下次快速点击。实现方式并不复杂,核心就是对 localStorage 进行读写:
const HISTORY_KEY = 'search_history' const MAX_HISTORY = 10 export function getSearchHistory() { const history = localStorage.getItem(HISTORY_KEY) return history ? JSON.parse(history) : [] } export function addSearchHistory(keyword) { let history = getSearchHistory() history = history.filter(item => item !== keyword) history.unshift(keyword) history = history.slice(0, MAX_HISTORY) localStorage.setItem(HISTORY_KEY, JSON.stringify(history)) }存入时先过滤重复项,再把新关键词放到数组最前面,最后只保留最近 10 条。读取的时候要注意JSON.parse,因为 localStorage 只能存字符串。
一个容易出问题的点是:关键词可能有空白字符,所以我在搜索框里用了v-model.trim,在写入历史的时候再次trim()一次,双保险。
4.4 搜索结果页的展示逻辑
搜索结果页的主要工作是接收路由参数,然后根据关键词去“模拟接口”查询数据:
<template> <div class="search-result"> <Header /> <div class="result-header"> 搜索“{{ keyword }}”的结果 <span>{{ resultList.length }} 条</span> </div> <div v-if="loading">加载中...</div> <div v-else-if="resultList.length === 0" class="empty"> 没有找到相关商品,换个关键词试试吧 </div> <div v-else class="product-list"> <ProductCard v-for="item in resultList" :key="item.id" :product="item" /> </div> <Footer /> </div> </template> <script> export default { name: 'SearchResult', data() { return { keyword: '', resultList: [], loading: false } }, watch: { '$route.query.keyword': { handler(newVal) { if (newVal !== this.keyword) { this.keyword = newVal || '' this.fetchSearchResult() } }, immediate: true } }, methods: { async fetchSearchResult() { if (!this.keyword) return this.loading = true try { const res = await searchProducts(this.keyword) this.resultList = res.data addSearchHistory(this.keyword) } finally { this.loading = false } } } } </script>这里有一个很重要的点:为什么用watch监听$route.query.keyword?因为如果用户在当前搜索结果页再次搜索,URL 会发生变化,但页面组件本身没有重新创建,这时候只有在 watch 里响应路由变化才能触发新的搜索。加上immediate: true后,组件首次加载时都会执行一次 handler,所以不需要额外在created里调用搜索方法。
Mock 搜索接口的实现不需要太复杂,就是按关键词过滤商品数据:
export function searchProducts(keyword) { return new Promise(resolve => { setTimeout(() => { const list = allProducts.filter(item => item.name.toLowerCase().includes(keyword.toLowerCase()) ) resolve({ code: 200, data: list }) }, 200) }) }4.5 关键词高亮与用户反馈
搜索结果页我还做了一个关键词高亮,让用户一眼看到搜索结果与关键词的匹配位置。思路是用v-html配合一个简单的替换方法,但要注意v-html的安全性,实际开发中如果内容是后台返回的,一定要做转义处理。我这个项目里商品名是静态 Mock 数据,风险不大。
高亮的实现方法:
methods: { highlight(text) { if (!this.keyword) return text const regex = new RegExp(`(${this.keyword})`, 'gi') return text.replace(regex, '<span class="highlight">$1</span>') } }在商品卡片组件里,商品名用v-html渲染:
<span class="product-name" v-html="highlight(product.name)"></span>这个功能看似简单,但对“搜索页完整度”的提升非常明显。一个小细节是:RegExp的gi标志表示全局匹配且忽略大小写,所以搜索“手机”时能同时匹配“Phone”和“phone”,如果做成大小写敏感,用户会觉得很奇怪。
5. 开发与调试中的高频问题和排查方法
5.1 路由跳转后页面不更新的经典坑
这是在搜索功能中非常容易遇到的一个问题:在搜索结果页上改一下关键词,再次搜索,页面数据没变。原因上面已经讲到——组件复用了,created不会再次执行,而路由参数变了。解决方案就是第 4.4 节里的watch,或者是给<router-view>加:key="$route.fullPath"强制重新渲染。
两种方案各有适用场景。加key的方式简单粗暴,页面会整体重建,适合页面数据都依赖路由参数的场景;但代价是组件内状态也会被重置,比如搜索历史下拉框打开状态、滚动位置等。用watch则更精细,可以只更新依赖参数的部分。实际项目里我优先用watch,只有在新手项目里图省事才会用key方案。
5.2 样式失灵的排查顺序
开发过程中遇到最常见的三类样式问题:
一是组件样式被全局样式覆盖。排查方式是用浏览器开发者工具查看生效的样式,确认是否被其他同名类名覆盖,然后给组件scoped标签并提高选择器优先级。
二是scoped导致子组件内部样式进不去。比如在父组件里想改el-input的内部样式,直接写类名发现不生效,因为scoped会给选择器加上属性选择器,而子组件内部元素的属性对不上。解决办法是用/deep/或::v-deep穿透:
.search-bar ::v-deep .el-input__inner { border-radius: 20px; }三是样式能生效但布局错乱,常见原因是 flex 容器里的子元素没有设置最小宽度为 0,或者calc()里运算符两侧没有空格。calc(100% - 10px)是有效的,calc(100% -10px)则会被忽略。这个坑我当时花了半小时才定位到。
5.3 接口跨域怎么处理
如果项目接的是真实后端,前端开发时会遇到跨域问题。Vue CLI 脚手架的解决方式是在vue.config.js里配置 devServer 代理:
module.exports = { devServer: { proxy: { '/api': { target: 'http://localhost:3000', changeOrigin: true, pathRewrite: { '^/api': '' } } } } }这样前端请求/api/login时,webpack-dev-server 会代为转发到http://localhost:3000/login,浏览器角度看到的始终是同源请求,从而避免跨域。
上线以后跨域由后端或网关处理,前端一般只需要把baseURL改成正式环境的域名。如果后端支持 CORS,也可以不配置代理,但开发和线上环境不同,容易混淆。我当时的原则是:本地开发和本地后端联调用代理,线上统一正式域名,环境变量区分地址。
5.4 打包后布局异常
开发环境下页面正常,npm run build打包部署后却出现样式丢失、图片 404、路由白屏等问题。这类问题我们项目里确实遇到过。
图片 404 的根本原因是资源路径不对。默认 Vue CLI 的publicPath是/,如果部署在服务器根目录没问题;一旦部署在子目录/shop/下,所有绝对路径都会错。解决方案是在vue.config.js里设置:
module.exports = { publicPath: './' }路由白屏又是另一种情况,使用history模式时,本地没问题,部署后刷新子路由页面 404 或白屏。这是因为服务器没有配置 fallback,导致所有请求都指向静态文件路径,而服务器找不到对应的文件。解决办法有两种:后端配置try_files $uri $uri/ /index.html,或者前端改用hash模式。新手阶段用hash模式更稳,但地址栏会多一个#号。
5.5 Vue 版本差异与面试准备
这个项目做完以后,建议花一天时间把 Vue 2 和 Vue 3 的差异对照着看一遍。Vue 3 的写法变化主要体现在:data变成ref和reactive,methods和computed变成setup里的普通函数,Vuex变成Pinia,路由 API 从new VueRouter变成了createRouter。
如果面试时被问到“你印象最深的一个项目”,完全可以把本项目的搜索防抖、路由守卫、组件复用、接口封装思路当作素材来讲。面试官通常会追问:
- 为什么要做搜索防抖?防抖和节流的区别是什么?
- 登录状态是怎么持久化的?
- 路由懒加载是怎么回事?
- 组件通信用了哪些方式?
- 打包体积优化做了什么?
这些问题在这个项目里都有对应的落地点。只要不是背答案,而是结合代码逐层讲清楚,说服力会强很多。
6. 进阶方向与个人实操体会
到了第 10 天,做完这个项目之后,后续提升空间其实非常大。简单列几个我觉得比较值得立刻尝试的方向。
第一个是加购物车功能,涉及 Vuex 模块拆分、商品数量的加减、购物车 badge 联动、本地持久化。第二个是加商品详情页,涉及动态路由/product/:id、路由参数读取、同页面不同数据的切换加载。第三个是接入真实接口,把 Mock 数据替换成后端服务,此时你会真正体验一次前后端联调的完整过程。第四个是迁移到 Vue 3 + TypeScript,正好检验一下自己对 Vue 3 生态的掌握程度。
说回我个人实操中的体会。很多人学 Vue 时喜欢看视频、看文档,一看就会,一写就废。这个项目的价值不在于功能多复杂,而在于强行逼你把“登录 -> 首页 -> 搜索”这条链路完整走了一遍。你会发现真正花时间的不是写代码,而是想清楚“登录状态存在哪里”“搜索关键词放在 URL 还是 Vuex”“接口请求放在哪个生命周期”这些问题。每一个问题都没有标准答案,但一旦想明白了,后续写任何功能都会顺手很多。
最后再分享一个小技巧。做完项目以后,可以专门花半天时间把所有console.log删掉、把所有注释补上、把所有组件命名规范检查一遍,然后跑一次npm run build,看打包后的体积。这一步看起来跟功能无关,但它才是真正的“项目收尾”。一个能正常构建的项目,和一堆能跑的代码文件,是两回事。