1. 电商前端基础第八章上篇的核心定位与整体设计思路1.1 这个章节到底在解决什么问题电商项目的前端基础走到第八章基本上已经脱离了“写个按钮、调个颜色”的阶段。上篇通常承担着一个承上启下的角色——前面几章把页面结构、样式布局、基础交互都铺完了到了这一章要开始处理真正让电商前端区别于普通展示型网站的东西商品数据的动态渲染与列表交互。说白了电商前端最核心的命脉就是“货架”。用户进来是看商品的运营改价格、改库存、改活动标签前端得能接得住、渲染得出来、交互得顺畅。第八章上篇一般就是围绕这个“货架”展开的涉及商品列表的渲染逻辑、数据绑定方式、筛选排序的前端实现以及列表性能的初步优化。我带过几个刚入行的前端他们普遍有个误区觉得列表渲染不就是v-for或者map一下嘛有什么好讲的。但真到了电商场景一个列表页可能同时挂着几百个 SKU每个卡片上有图片懒加载、价格格式化、促销标签、库存状态、加购按钮的防抖处理这些东西堆在一起写法和写法之间的差距就出来了。这一章的价值就是把这些看似零散的点串成一条可复用的工程思路。1.2 为什么电商列表不能“随便写写”普通企业官网的产品展示十个八个条目怎么写都行。电商不一样它的列表有几个硬性特征数据量大且动态商品数据来自后端接口分页、筛选、排序都会触发重新请求前端要管理好“请求中”“空状态”“错误状态”这几套 UI。交互密度高一个商品卡片上可能同时存在点击跳详情、点击加购、点击收藏、hover 显示促销信息等多个交互点事件冒泡和默认行为处理不好就互相打架。性能敏感图片多、DOM 节点多滚动卡顿直接影响转化率。有数据表明列表页滚动帧率掉到 30fps 以下用户加购率会有可感知的下滑。状态复杂筛选条件、排序方式、分页页码、已选规格这些状态要同步到 URL 上用户刷新或分享链接后还能还原现场。所以第八章上篇的设计思路我理解应该是先建立“数据驱动视图”的稳定心智模型再在这个模型上叠加电商特有的交互与性能处理。选型上如果是 Vue 技术栈核心就是v-for配合key的合理使用、computed做派生数据、watch处理筛选条件变化如果是 React 技术栈则是map渲染、useMemo缓存派生数据、useEffect响应依赖变化。两条路殊途同归核心都是“状态变视图变中间不要手动碰 DOM”。1.3 上篇与下篇的边界划分一般这种分上下篇的章节上篇聚焦“静态渲染与基础交互”下篇才进入“性能深水区与复杂状态管理”。上篇要达成的目标很明确能把后端返回的商品数组正确渲染成卡片列表能实现基础的筛选比如按分类、按价格区间和排序销量、价格升降能处理加载中、空列表、请求失败三种边界状态列表项的关键交互加购、跳转能正常工作且互不干扰。下篇大概率会讲虚拟列表、图片懒加载的进阶方案、滚动位置记忆、筛选条件与 URL 同步这些更重的内容。所以上篇的代码结构要为下篇留好扩展点别写死了。2. 商品列表渲染的核心细节与实操要点2.1 数据结构设计前端拿到什么怎么接后端返回的商品列表数据结构通常长这样我按常见电商接口的字段习惯整理{ code: 0, data: { list: [ { id: 10001, title: 某品牌纯棉短袖T恤, price: 7900, // 单位分 originPrice: 12900, // 划线价单位分 cover: https://example.com/img/10001.jpg, sales: 2341, stock: 56, tags: [包邮, 七天无理由], categoryId: 3 } ], total: 128, page: 1, pageSize: 20 } }这里有几个坑我踩过不止一次价格用分存储。后端给的是整数分前端展示要除以 100 并保留两位小数。千万别在模板里直接写price / 100因为浮点数运算会出现0.1 0.2 0.30000000000000004这类问题。正确做法是写一个格式化函数function formatPrice(cents) { if (typeof cents ! number || isNaN(cents)) return 0.00; return (cents / 100).toFixed(2); }toFixed会做四舍五入电商价格展示一般能接受。如果对精度要求极高比如涉及优惠券分摊计算那就得上decimal.js这类库但纯展示用toFixed足够了。tags 是数组。渲染时不要假设它一定有值接口可能返回null或空数组。模板里用v-ifitem.tags item.tags.length包一层否则空数组渲染出一排空标签很难看。stock 为 0 的处理。库存为 0 时加购按钮要置灰同时卡片上最好有个“已售罄”的蒙层。这个判断逻辑建议放在computed里统一处理而不是在模板里写一堆三元表达式。2.2 列表渲染的 key 到底怎么选这是老生常谈但每次都能看到有人写错的地方。v-for的key绝对不能用 index原因在电商列表里尤其明显当你做筛选或排序时列表顺序变了如果用 index 做 keyVue 会复用错误的 DOM 节点导致输入框里的内容、图片的加载状态、甚至加购按钮的动画状态全部错位。正确做法是用商品唯一 iddiv v-foritem in productList :keyitem.id classproduct-card !-- 卡片内容 -- /div如果后端返回的数据里没有唯一 id极少见但遇到过那就得在请求回来后手动给每条数据补一个稳定唯一值比如用Symbol()或者时间戳加随机数但绝不能图省事用 index。注意有些场景下商品 id 可能重复比如不同规格被拆成多条记录这时候要用id skuId组合成复合 key确保唯一性。2.3 筛选与排序的前端实现逻辑筛选和排序有两种实现路线前端本地过滤和后端接口过滤。上篇一般先讲前端本地过滤因为数据量小的时候比如一页 20 条本地处理响应更快用户体验更顺滑。本地筛选的核心是维护一个filterState对象data() { return { filterState: { categoryId: null, minPrice: null, maxPrice: null, sortBy: default // default | sales_desc | price_asc | price_desc }, rawList: [] // 接口返回的原始列表 }; }然后用computed派生最终展示的列表computed: { displayList() { let list [...this.rawList]; const { categoryId, minPrice, maxPrice, sortBy } this.filterState; if (categoryId ! null) { list list.filter(item item.categoryId categoryId); } if (minPrice ! null) { list list.filter(item item.price minPrice * 100); } if (maxPrice ! null) { list list.filter(item item.price maxPrice * 100); } if (sortBy sales_desc) { list.sort((a, b) b.sales - a.sales); } else if (sortBy price_asc) { list.sort((a, b) a.price - b.price); } else if (sortBy price_desc) { list.sort((a, b) b.price - a.price); } return list; } }这里有个细节sort会改变原数组所以必须先[...this.rawList]浅拷贝一份再排。我见过有人直接this.rawList.sort()结果原始数据顺序被打乱切换排序方式时数据越排越乱。另外价格筛选时用户输入的是元而数据里是分所以比较前要乘以 100。这个单位换算建议在filterState更新时就做好而不是在computed里反复乘减少心智负担。2.4 三种边界状态的 UI 处理电商列表页最容易被忽略的就是边界状态。用户网络慢、接口挂了、筛选后没结果这些情况不处理页面就是一片空白用户直接走人。加载中用一个骨架屏或者简单的 loading 动画。骨架屏比转圈圈体验好因为用户能预判内容布局。上篇可以先实现一个简单的 loading 文字下篇再升级骨架屏。空列表筛选后无结果时显示“没有找到相关商品试试调整筛选条件”并给一个“重置筛选”的按钮。这个按钮的点击事件就是把filterState恢复初始值。请求失败显示“加载失败请重试”配一个重试按钮点击后重新调用接口。这里要注意防抖别让用户狂点重试按钮导致请求风暴。div v-ifloading classlist-loading加载中.../div div v-else-iferror classlist-error p加载失败请重试/p button clickfetchList重新加载/button /div div v-else-ifdisplayList.length 0 classlist-empty p没有找到相关商品/p button clickresetFilter重置筛选/button /div div v-else classproduct-list !-- 商品卡片 -- /div这三个状态的判断顺序不能乱先判断 loading再判断 error再判断空最后才是正常列表。顺序写反了会出现“加载中同时显示空列表”这种诡异现象。3. 商品卡片交互的完整实操流程3.1 卡片整体跳转与内部按钮的冲突解决商品卡片通常整块可点击跳详情但卡片内部又有“加购”按钮。如果直接给卡片绑click跳转给按钮绑click加购点按钮时会同时触发两个事件——先加购再跳走用户一脸懵。解决方案是在加购按钮的点击处理里调用event.stopPropagation()div classproduct-card clickgoDetail(item.id) img :srcitem.cover :altitem.title / h3{{ item.title }}/h3 p classprice{{ formatPrice(item.price) }}/p button click.stopaddToCart(item)加入购物车/button /div.stop修饰符就是event.stopPropagation()的语法糖。但要注意如果加购按钮内部还有子元素比如图标加文字点击子元素时事件目标会变.stop依然有效因为它绑在按钮本身。另一个方案是用click.self绑在卡片上只有点击卡片自身不是子元素才跳转。但这样用户点标题、点图片都不会跳体验反而差。所以还是推荐.stop方案。3.2 加购按钮的防抖与状态反馈加购是个写操作用户手抖连点两下就会发两次请求库存扣两次用户收到两件商品。所以加购按钮必须做防抖或节流。我一般用“请求中禁用按钮”的方案比单纯防抖更直观methods: { async addToCart(item) { if (item._adding) return; this.$set(item, _adding, true); try { await api.addCart({ productId: item.id, count: 1 }); this.$toast(已加入购物车); } catch (e) { this.$toast(加购失败请重试); } finally { this.$set(item, _adding, false); } } }这里用$set是因为_adding是动态添加的属性Vue 2 直接赋值不会触发响应式更新。Vue 3 用reactive或ref就没这个问题。按钮文案也要跟着状态变正常时显示“加入购物车”请求中显示“加入中...”库存为 0 时显示“已售罄”并禁用。3.3 图片懒加载的基础实现上篇可以先做一个基于 IntersectionObserver 的简易懒加载不用上第三方库// 在 mounted 里初始化 mounted() { this.observer new IntersectionObserver((entries) { entries.forEach(entry { if (entry.isIntersecting) { const img entry.target; img.src img.dataset.src; this.observer.unobserve(img); } }); }, { rootMargin: 100px }); this.$nextTick(() { this.$el.querySelectorAll(img[data-src]).forEach(img { this.observer.observe(img); }); }); }模板里图片写成img :data-srcitem.cover src占位图地址 :altitem.title /rootMargin: 100px表示提前 100px 开始加载用户滚动到图片附近时图片已经加载好了不会看到明显的白块。这个值根据卡片高度调整一般卡片高度 300px 左右的话100px 到 200px 都合适。注意列表重新渲染后比如筛选切换新出现的图片要重新 observe。所以$nextTick里的 observe 逻辑要封装成方法在列表更新后调用。3.4 价格与促销标签的渲染细节价格区域是电商卡片的信息重心排版上一般分三行现价大号红色、划线原价小号灰色带删除线、促销标签小号带背景色。div classprice-area span classcurrent-price{{ formatPrice(item.price) }}/span span v-ifitem.originPrice item.price classorigin-price {{ formatPrice(item.originPrice) }} /span /div div classtag-area v-ifitem.tags item.tags.length span v-fortag in item.tags :keytag classtag{{ tag }}/span /div划线价的显示条件要判断originPrice price否则原价和现价一样还划什么线。这个判断放在模板里或者computed里都行但建议在数据预处理阶段就给每条商品加一个hasDiscount布尔字段模板里直接v-ifitem.hasDiscount逻辑更清晰。促销标签的key用标签文字本身就行因为同一商品不会有两个相同文字的标签。但如果后端可能返回重复标签那就得用tag index组合不过这种情况应该在后端去重前端不用过度防御。4. 常见问题与排查技巧实录4.1 列表渲染后图片不显示或显示错位这个问题我遇到的最多。现象是筛选切换后部分卡片的图片变成了上一批商品的图片。原因就是key用了 indexVue 复用了 DOM 节点但src绑定的是新数据浏览器缓存了旧图片导致视觉错位。排查步骤打开开发者工具检查v-for的key是不是item.id如果 key 没问题检查图片src是否绑定了正确的字段如果都对检查是否有浏览器缓存问题给图片 URL 加个时间戳参数测试。解决方案就是确保 key 唯一且稳定同时图片懒加载的>// 错误写法 this.filterState.categoryId 3; // 如果 filterState 是普通对象且 categoryId 是新增属性不会触发更新正确做法是初始化时就声明所有字段或者用$setthis.$set(this.filterState, categoryId, 3);Vue 3 的reactive对象直接赋值就能触发但如果你解构了reactive对象解构出来的变量会失去响应式这也是个高频坑。4.3 加购按钮点击后页面跳转前面提过用.stop修饰符解决。但如果加购按钮是组件比如一个自定义的AddCartButton.stop要绑在组件根元素上或者组件内部 emit 事件时带上event参数父组件再stopPropagation。排查时可以在卡片的goDetail方法里打印日志看点击加购时是否也触发了goDetail。如果是就说明事件冒泡没拦住。4.4 价格显示为 NaN 或 undefined原因通常是后端返回的price字段为null或字符串。前端格式化函数里加一层防御function formatPrice(cents) { const num Number(cents); if (isNaN(num)) return --; return (num / 100).toFixed(2); }显示--比显示NaN体面得多。同时要在接口联调阶段就确认字段类型别等到上线才发现。4.5 常见问题速查表问题现象可能原因排查方法解决方案图片错位key 用 index检查 v-for key改用 item.id列表不更新直接赋值非响应式属性检查数据是否在 data 中声明用 $set 或提前声明加购后跳转事件冒泡在 goDetail 打日志加 .stop 修饰符价格 NaN字段为 null 或字符串打印接口返回数据格式化函数加防御筛选无结果单位换算错误检查分与元的转换统一在 filterState 中换算滚动卡顿图片未懒加载检查 img 标签加 IntersectionObserver4.6 几个我踩过的坑和对应技巧坑一筛选条件变化时重复请求接口。如果筛选是本地过滤那没问题但如果筛选条件变化要重新请求后端一定要做防抖。用户快速切换分类时可能发出五六个请求最后返回的顺序还不一定对。解决方案是用一个requestId标记每次请求只处理最新一次请求的响应。坑二空状态和加载状态同时出现。前面说过判断顺序问题但还有一种情况是接口返回了空数组但loading还没置为 false。所以loading的置 false 操作要放在finally里确保无论成功失败都会执行。坑三商品标题过长导致卡片高度不一致。用 CSS 的-webkit-line-clamp限制两行超出省略.product-title { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }这样每张卡片高度一致列表整齐也避免了因为标题长度不同导致的布局抖动。坑四加购成功后购物车数量角标不更新。这是状态管理的问题上篇可以先通过事件总线或简单的全局状态来同步下篇再引入更完善的状态管理方案。最简单的做法是加购成功后触发一个全局事件购物车组件监听这个事件并重新拉取数量。5. 为下篇预留的扩展点与代码组织建议5.1 组件拆分粒度上篇的代码不要全写在一个页面组件里建议拆成ProductList负责数据请求、筛选状态管理、列表渲染ProductCard负责单个商品的展示和交互FilterBar负责筛选和排序的 UIEmptyState/LoadingState/ErrorState三个边界状态组件。这样下篇做性能优化时可以直接对ProductCard做memo或v-once处理对ProductList做虚拟滚动改造不用大动干戈。5.2 数据预处理的统一入口建议在接口返回数据后统一走一个normalizeProduct函数把价格格式化、标签处理、库存状态、折扣标记都算好再存入rawList。这样模板里只做展示不做逻辑判断代码干净且容易维护。function normalizeProduct(raw) { return { ...raw, priceText: formatPrice(raw.price), originPriceText: formatPrice(raw.originPrice), hasDiscount: raw.originPrice raw.price, soldOut: raw.stock 0, tags: Array.isArray(raw.tags) ? raw.tags : [] }; }这个函数也是下篇做数据缓存和增量更新的基础。5.3 筛选状态与 URL 同步的预留上篇可以先不做 URL 同步但filterState的结构要设计好方便下篇直接序列化到 query 参数。建议filterState的字段名和 URL 参数名保持一致比如categoryId、sortBy、page这样下篇写同步逻辑时直接遍历对象拼 query 就行。我在实际项目里习惯把filterState的更新统一走一个updateFilter(key, value)方法而不是在模板里直接v-model绑到filterState的某个字段。这样下篇加 URL 同步时只需要在这一个方法里加一行this.syncToUrl()所有筛选入口都生效不用一个个改。5.4 性能监控的埋点预留电商列表页的滚动性能、图片加载耗时、加购点击响应时间这些数据对后续优化很有价值。上篇可以在关键节点加一些简单的performance.now()打点下篇再接入完整的监控体系。比如在fetchList开始和结束时记录时间差在图片onload时记录加载耗时。这些埋点代码不要散落在业务逻辑里统一封装成trackEvent(name, data)方法内部先console.log下篇替换成真实上报即可。5.5 移动端适配的注意事项电商流量大部分来自移动端列表页在手机上的交互和桌面端差异很大。上篇至少要处理卡片点击区域足够大加购按钮不小于 44x44 像素筛选栏在移动端通常收进一个抽屉或弹层不要平铺列表滚动用-webkit-overflow-scrolling: touch提升 iOS 上的顺滑度图片用srcset或根据屏幕宽度请求不同尺寸的图别在手机上加载 2000px 宽的大图。这些细节不影响功能但直接影响转化率。我见过一个项目因为移动端加购按钮太小误触率高达 15%改大之后加购率提升了 8 个百分点。5.6 代码规范与团队协作建议最后说点工程上的事。第八章上篇的代码是要给团队其他人看的也是下篇的基础所以方法命名用动词开头fetchList、resetFilter、addToCart别用listData、filterThing这种名词接口请求统一走api模块不要在组件里直接写axios.get样式用 scoped避免污染全局关键逻辑加注释尤其是单位换算、边界判断这些容易忘的地方。我个人的体会是电商前端列表页的代码写出来容易写好难。难的不是技术深度而是对细节的持续关注——每一个边界状态、每一次交互冲突、每一处性能损耗单独看都是小事但堆在一起就是用户体验的鸿沟。第八章上篇把这些基础打牢下篇的性能优化才有意义否则就是在沙子上盖楼。