主题
实现排序与搜索框优化(Lesson17 2/3)
本页对应 Lesson17,共 5 段视频,约 1 小时 8 分。
学习目标
- 能够将阿里图标库的字体文件与样式引入项目,并通过对应类名在排序控件中正确渲染升序、降序图标。
- 能够依据接口
order参数规则实现综合排序与价格排序,完成默认排序、点击切换升降序、路由参数同步及刷新后状态回显。 - 能够为搜索框表单实现回车提交与按钮提交,使用
submit事件收集关键字并阻止表单默认提交行为。 - 能够将分页区域抽离为独立静态组件,并在项目入口注册为全局组件后于搜索页面复用。
- 能够通过
props向分页组件传递总条数、当前页、每页条数等参数,并使用计算属性计算总页数。 - 能够根据当前页与总页数计算连续页码的起始与结束位置,并完成连续页码的渲染。
知识点
先理解排序要解决什么问题
为什么需要:搜索页有“综合”和“价格”两种排序,接口通过 order 参数接收排序方式,例如 "1:desc"。其中 type 表示排序类型:1 是综合,2 是价格;flag 表示排序标识:asc 是升序,desc 是降序。页面必须让用户点击后改变排序,并且刷新后还能保持当前排序状态。
怎么想:排序状态由两个值共同决定,一个是“按什么排”,一个是“升序还是降序”。点击时先判断点击的类型是否等于当前类型:相同就只切换 flag,不同就更新 type 并把 flag 重置为 desc。最后把状态拼成 order=type:flag 写回路由,让接口请求跟着变化。
怎么做:在 src/pages/Search/index.vue 中维护 type 和 flag,模板中用 active 控制选中样式,用 upOrDown 控制箭头图标。笔记代码如下:
vue
<template>
<ul class="sui-nav">
<li @click.prevent="orderSearch(1)" :class="{active:type==1}">
<a href="#">综合<i v-show="type==1" :class="upOrDown"></i></a>
</li>
<li @click.prevent="orderSearch(2)" :class="{active:type==2}">
<a href="#">价格<i v-show="type==2" :class="upOrDown"></i></a>
</li>
</ul>
</template>
<script>
export default {
data(){
// console.log("order",this.$route.query.order);
// 1
// let type = 1;
// let flag = "desc";
// // 判断是否有排序的参数,如果有那么重置type,以及flag
// if(this.$route.query.order){
// [type,flag] = this.$route.query.order.split(":");
// }
// 2
const [type,flag]= (this.$route.query.order || "1:desc").split(":");
return {
// 排序方式
// 排序类型(type) 1: 综合 2: 价格
// 排序标识(flag) asc: 升序 desc: 降序
// 示例: "1:desc"
type,// 综合
flag
}
},
methods:{
orderSearch(type){
// 当前的排序类别与选中的类别是否相同
if(this.type === type){
this.flag = this.flag==="desc"?"asc":"desc";
}else{
this.type = type;
this.flag = "desc";
}
this.$router.push({
path:"/search",
query:{
...this.$route.query,
order:this.type+":"+this.flag
}
})
}
.....
},
}
</script>易错点:@click.prevent 不能漏,否则 <a href="#"> 可能触发默认跳转;路由 push 时要用 ...this.$route.query 合并原查询参数,避免把关键字等参数丢掉;split(":") 得到的是字符串,若后面用 === 和数字比较,需要把 type 转成数字,或者比较时用 ==。
初始化状态:默认综合降序,刷新后读路由
为什么需要:接口在没有传 order 时通常有默认排序,但页面上的选中状态和箭头必须和真实排序一致。转写稿里先不传 order 调试,发现接口默认就是综合降序;把 order 改成 "1:asc" 后结果变化,改成 "2:desc" 后按价格降序。说明 order 能控制排序。
怎么想:页面初始状态不能写死成 1:desc,因为用户刷新时地址栏可能已经有 order=2:asc。所以初始化时优先读 this.$route.query.order,没有才用默认值 "1:desc"。
怎么做:笔记里给了两种写法。注释 1 是先定义 type=1、flag="desc",再判断路由参数并重新赋值;注释 2 更简洁:
js
const [type,flag]= (this.$route.query.order || "1:desc").split(":");易错点:this.$route.query.order 可能不存在,不能直接 .split;默认值要写成字符串 "1:desc";如果从路由解析出的 type 是字符串,模板里 type==1 能正常工作,但方法里 this.type === type 可能因为类型不同判断失败,调试时要注意。
点击切换:同类型切升降序,不同类型重置降序
为什么需要:用户点击“综合”或“价格”时,不只是选中样式变化,还要真正改变接口参数。转写稿中反复调试:点击价格后要按价格排,第一次点价格应为降序,再点价格才切升序;从综合切到价格时,不能沿用综合的升降序。
怎么想:把点击逻辑拆成两种情况。第一种,this.type === type,说明点的是当前已选类型,只切换 flag:desc 变 asc,asc 变 desc。第二种,类型不同,更新 this.type = type,并把 this.flag = "desc",保证新类型第一次点击是降序。
怎么做:orderSearch(type) 中完成状态修改后,统一 this.$router.push 到 /search,并写入 order: this.type + ":" + this.flag。这样路由变化会触发搜索请求,页面状态也能从路由恢复。
易错点:不要只改 data 不写路由,否则接口参数不会变;也不要在切换类型时保留旧 flag;orderSearch(type) 的参数名和 data 里的 type 同名,方法内用 this.type 访问当前状态,别写成 type === type。
模板绑定与箭头图标
为什么需要:用户需要直观看到当前选中哪个排序、是升序还是降序。笔记模板用 :class="{active:type==1}" 和 :class="{active:type==2}" 控制选中,用 v-show="type==1"、v-show="type==2" 只让当前排序显示箭头,再用 :class="upOrDown" 绑定箭头类名。
怎么想:upOrDown 应该由 flag 决定:降序显示向下箭头,升序显示向上箭头。转写稿里通过计算属性判断 flag 是否为 desc 来返回对应图标类名。图标资源来自阿里图标库,转写稿建议下载到本地再引入,避免依赖外部链接。
怎么做:模板保持笔记写法;在组件中补充 upOrDown 计算属性,根据 this.flag 返回对应图标类名。样式上通过 active 让当前项高亮。
易错点:v-show 和 :class 不要写反;图标类名要和引入的图标库一致;如果只改 flag 但 upOrDown 没有依赖它,箭头不会更新。
调试顺序与验证
为什么需要:排序涉及“页面状态、路由参数、接口参数”三处,必须逐层验证。
怎么想:先验证接口:手动改 order 看结果是否变化;再验证页面状态:点击后 active 和箭头是否变化;最后验证刷新:地址栏带 order 刷新后选中状态是否恢复。
怎么做:按转写稿过程,先不传 order 观察默认综合降序;再传 "1:asc"、"2:desc" 验证参数;然后实现点击;最后刷新页面检查 data 是否从 $route.query.order 初始化。
易错点:如果点击后接口没变,先看路由 query.order 有没有变;如果刷新后状态丢失,检查初始化是否读取了路由;如果点击价格后箭头或结果不对,检查同类型/不同类型的判断和 flag 重置。
表单包裹:让回车和按钮走同一条提交路径
为什么需要:搜索框既支持点击“搜索”按钮,也支持输入关键词后按回车。若只给按钮绑 click,回车不会触发;若把输入框和按钮都单独绑事件,逻辑容易重复。用 form 包裹后,回车和 type="submit" 按钮都会触发 submit 事件,搜索入口统一。
怎么想:把“收集关键词并搜索”收敛到 goSearch 一个方法里,表单只负责触发提交,不直接做业务。输入框保留 name="keyword",既符合表单字段语义,也方便调试时确认提交字段;ref="keyword" 用于组件内取输入框值。
怎么做:本节代码把 input 和 button 放在同一个 form 中,button 使用 type="submit",表单监听 @submit.prevent="goSearch":
vue
<form @submit.prevent="goSearch" autocomplete="off" class="searchForm">
<input name="keyword" placeholder="请输入搜索的关键词" type="text" ref="keyword"
id="autocomplete" class="input-error input-xxlarge"/>
<button class="sui-btn btn-xlarge btn-danger" type="submit">搜索</button>
</form>易错点:button 在表单内默认就是提交按钮,若写成 type="button" 就不会触发表单提交;不要同时给按钮加 click 又依赖 submit,否则可能重复执行。输入框必须在 form 内部,否则回车不会走表单提交。
@submit.prevent:阻止原生提交刷新
为什么需要:原生 form 提交默认会按 action/method 发起请求,页面可能刷新或跳转。搜索是前端路由跳转或接口请求,不应让浏览器整页提交。
怎么想:先让表单的 submit 事件触发,再在事件上阻止默认行为,最后执行 goSearch。Vue 的 .prevent 就是调用 event.preventDefault() 的修饰符,写法比在方法里手动阻止更直观。
怎么做:保留 @submit.prevent="goSearch"。调试时可先临时在 goSearch 里打印日志:输入关键词按回车,若日志出现且页面不刷新,说明 submit 与 prevent 都生效;再检查点击按钮是否也进入同一方法。
易错点:只写 @submit="goSearch" 不写 .prevent,回车后可能刷新页面;把 .prevent 拼错或写到方法名后面不会生效;阻止默认提交后,仍要保证按钮是 type="submit",否则点击按钮不会触发 submit。
autocomplete="off":减少浏览器自动填充干扰
为什么需要:搜索框常被浏览器记录历史输入,下拉提示可能遮挡内容,也可能让用户误选旧关键词。搜索场景通常不需要自动填充。
怎么想:这是体验优化,不是安全措施。加在 form 上可对表单内字段做提示关闭;不同浏览器支持程度不完全一致,所以不能把它当成绝对保证。
怎么做:按笔记在 form 上保留 autocomplete="off"。若发现某些浏览器仍提示,可再在 input 上补充同样属性,但不要因此改动业务逻辑。
易错点:不要认为 autocomplete="off" 能清除已保存的表单历史;它只影响自动完成提示。也不要为了关闭提示随意改 name,name="keyword" 与搜索参数语义相关。
ref、name、id:各司其职
为什么需要:ref 用于 Vue 组件内访问输入框,name 表示表单字段名,id 用于标签关联或浏览器自动完成定位。三者混用会让代码难维护。
怎么想:取值的核心是 this.$refs.keyword,所以 ref 名称要和方法中一致;name="keyword" 与搜索关键词对应;id="autocomplete" 是页面元素标识,不应拿来当数据源。
怎么做:保持 ref="keyword"、name="keyword"、id="autocomplete"。在 goSearch 中通过 this.$refs.keyword.value 取输入值,再走路由或请求。若后续改用 v-model,也要保证不再重复从 DOM 取值。
易错点:ref 拼写不一致会取不到;在 v-for 中同名 ref 会得到数组;不要把 id 当作关键词变量;输入框没有 name 时,表单原生提交的字段名会缺失,调试时容易误判。
调试过程:先确认“谁触发了提交”
为什么需要:搜索框优化最常见的问题是“回车没反应”或“回车后页面刷新”。先定位是没触发 submit,还是触发了但默认行为没被阻止。
怎么想:按转写稿的排查顺序:先看 input 是否在 form 内;再给表单加 @submit.prevent;在 goSearch 中打印日志,分别测试回车和点击按钮;最后检查按钮 type 是否为 submit。
怎么做:输入关键词按回车,若页面跳转/刷新,说明原生提交未被阻止;加上 .prevent 后应执行 goSearch 且不刷新。点击“搜索”按钮也应进入同一方法,因为 type="submit" 会提交表单。若回车无效,检查 form 是否包住输入框、button 是否在表单内、@submit 是否写在 form 上。
易错点:把 @submit 写到 input 或 button 上不会监听表单提交;在方法里只写 return false 不能替代 .prevent;用 type="button" 后回车仍可能提交,但按钮点击不走 submit,行为不一致。调试时先保证“回车”和“点击”都只执行一次 goSearch。
图标资源与排序入口准备
为什么需要:接口只支持“综合”和“价格”两种排序,页面里原有的销量、新品、评价等入口没有对应参数,留着会误导用户。默认箭头样式也不够灵活,需要用图标库替换。
怎么想:先按接口裁剪排序项,只保留综合、价格;再用阿里图标库的上下箭头表达升序、降序。图标不要依赖在线链接,下载到本地更稳妥,避免图标库服务器失效导致项目不可用。
怎么做:在阿里图标库选好箭头,加入项目后下载压缩包,解压把字体和 CSS 放到 src/assets/icon 一类目录;在 src/index.js 或入口文件引入样式。模板中用 iconfont 类名使用图标,例如:
html
<i class="iconfont icon-arrow-up"></i>综合、价格用 active 控制选中态,箭头根据排序状态显示。
易错点:不要直接引在线链接;图标 CSS 路径要引对;删排序项时不要把接口需要的 order 参数逻辑删掉;选中态和箭头显示要分开判断,避免两个箭头同时出现。
排序状态与切换
为什么需要:排序由“排序类型”和“升序/降序”共同决定。只改类型不改方向,或只改方向不改类型,都会导致点击行为混乱。
怎么想:在搜索页 data 中维护两个状态:type 表示综合/价格,flag 表示 ASC/DESC。默认按综合降序,即 type: 1、flag: 'DESC'。点击时先判断当前 type 与点击的 type 是否相同:不同则切换类型并默认降序;相同则只切换 flag。
怎么做:
js
data() {
return {
type: 1,
flag: 'DESC'
}
},
methods: {
changeOrder(type) {
if (this.type === type) {
this.flag = this.flag === 'DESC' ? 'ASC' : 'DESC'
} else {
this.type = type
this.flag = 'DESC'
}
this.$router.push({
name: 'search',
query: {
...this.$route.query,
order: `${this.type}:${this.flag}`
}
})
}
}模板中综合、价格分别调用 changeOrder(1)、changeOrder(2),并用 type 控制 active 和箭头显示。
易错点:从路由参数解析出来的 type 可能是字符串,直接 === 比较会失败,可用 == 或 parseInt;切换类型时不要忘记重置 flag;箭头显示要跟 type 绑定,选中综合时价格箭头不必显示。
排序参数与路由同步
为什么需要:刷新页面后排序状态要保留,否则地址栏有 order 但页面状态丢失,会出现“数据排序正确、箭头不对”的问题。
怎么想:排序参数格式是 type:flag,例如 1:DESC。初始化时从 $route.query.order 读取,按冒号拆成数组,分别赋给 type 和 flag;没有参数时使用默认值。
怎么做:
js
created() {
const order = this.$route.query.order
if (order) {
const [type, flag] = order.split(':')
this.type = parseInt(type)
this.flag = flag
}
}请求接口时把 order 拼进参数,例如 order: ${this.type}😒{this.flag}``。
易错点:split(':') 后是字符串,type 要转数字;没有 order 时要有默认值;刷新后要同时恢复 type 和 flag,不能只恢复一个。
搜索框回车
为什么需要:用户习惯输入关键词后直接回车搜索,只支持点击按钮体验不好。
怎么想:利用表单的 submit 行为。form 上监听 @submit.prevent="goSearch",按钮用 type="submit",这样点击按钮和回车都会触发同一个搜索方法;.prevent 阻止表单默认跳转。
怎么做:
html
<form @submit.prevent="goSearch">
<input v-model="keyword" />
<button type="submit">搜索</button>
</form>如果按钮是 type="button",它不会提交表单,需要单独写 click;用 submit 后点击和回车可共用逻辑。
易错点:忘记 .prevent 会刷新/跳转页面;button 默认类型在表单中可能触发提交,要明确写 type;搜索方法里要收集关键词并跳转路由。
分页静态组件
为什么需要:分页不仅搜索页会用,订单页等也可能用。直接写在搜索页里复用性差,代码也臃肿。
怎么想:把分页抽成通用组件,放在 src/components/Pagination,在入口文件全局注册。组件只负责展示和触发页码,不直接读 Vuex,数据由父组件通过 props 传入,这样换页面也能用。
怎么做:新建 Pagination/index.vue,把分页结构和样式搬过去;在入口文件注册:
js
import Pagination from '@/components/Pagination'
Vue.component('Pagination', Pagination)搜索页使用 <Pagination :total="total" :pageSize="pageSize" :pageNo="pageNo" />。
易错点:分页组件不要直接读 store,否则就退化成“搜索专用分页”;样式要一起迁移;全局注册后组件名要一致。
分页参数传递与总页数计算
为什么需要:分页需要总条数、每页条数、当前页等数据;总页数可以由总条数和每页条数算出来,不一定依赖接口返回。
怎么想:接口结果里有 total、pageSize、pageNo 等信息,可存入 Vuex 的搜索结果;分页组件通过 props 接收 total、pageSize、pageNo,用计算属性算总页数。
怎么做:
js
props: ['total', 'pageSize', 'pageNo'],
computed: {
totalPage() {
return Math.ceil(this.total / this.pageSize)
}
}父组件传参:
html
<Pagination :total="total" :pageSize="pageSize" :pageNo="pageNo" />易错点:总页数要向上取整,否则最后一页会丢;pageSize 不能写死,要由父组件传;分页组件只接收参数,不要从 store 里硬拿,保证复用性。
1. 先明确:本节只做“静态分页组件”
为什么需要:分页在搜索页会出现,订单页、商品列表页也可能出现。如果直接把分页结构写在搜索页里,后面复制、修改、多人协作都容易冲突。转写稿里反复强调:通用型组件要放到 components 下,并且做成全局组件,其他页面才能直接复用。
怎么想:先不要接接口、不要接 Vuex,也不要把总条数、当前页写成动态参数。本节的目标是“静态组件”:把结构、样式、按钮状态先搭出来,能看见效果,再在下一节通过 props 传参。转写稿里“封印页、分印页”都是同音误写,实际是“分页”;“群序组建”实际是“全局组件”。
怎么做:在 src/components/Pagination/index.vue 中创建组件,模板里先写死“上一页、1、···、14~18、···、21、下一页、共 103 条”等展示数据。易错点:不要把分页组件放进 search 目录里,否则它容易被当成“搜索页私有组件”,失去通用性。
2. 创建 Pagination/index.vue 的模板与基础结构
为什么需要:先把 DOM 结构固定下来,才能对照样式调按钮间距、激活态、省略号、总条数文案。怎么想:分页由按钮和文本组成,当前页用 active 标记,禁用态用 [disabled] 预留。怎么做:新建目录 src/components/Pagination,文件 index.vue,把下面代码原样放入。易错点:name 写成 "index" 与文件名一致即可;按钮类名 active 不要拼错;静态数字只是占位,不要误以为已经接上真实数据。
vue
<template>
<div class="pagination">
<button>上一页</button>
<button>1</button>
<span>···</span>
<button>14</button>
<button>15</button>
<button class="active">16</button>
<button>17</button>
<button>18</button>
<span>···</span>
<button>21</button>
<button>下一页</button>
<span>共 103 条</span>
</div>
</template>
<script>
export default {
name: "index"
}
</script>3. 把分页样式一起迁移到组件内
为什么需要:分页的按钮高度、最小宽度、间距、激活色、禁用色都属于组件自身样式,留在搜索页里会污染页面,也不利于复用。怎么想:转写稿说“样式也拿过来,剪切放到组件里”,并使用 less 和 scoped 限制作用域。怎么做:把下面样式补到 index.vue 的 <style lang="less" scoped> 中。易错点:scoped 不要漏;&.active 和 &[disabled] 的嵌套写法要保留;按钮 border: 0、box-sizing: border-box 等细节不要删,否则视觉会变。
vue
<style lang="less" scoped>
.pagination {
text-align: center;
button {
margin: 0 5px;
background-color: #f4f4f5;
color: gray;
outline: none;
border-radius: 2px;
padding: 0 4px;
vertical-align: top;
display: inline-block;
font-size: 13px;
min-width: 35.5px;
height: 28px;
line-height: 28px;
cursor: pointer;
box-sizing: border-box;
text-align: center;
border: 0;
&[disabled] {
color: #c0c4cc;
cursor: not-allowed;
}
&.active {
cursor: not-allowed;
background-color: #c81623;
color: #fff;
}
}
span {
display: inline-block;
line-height: 28px;
font-size: 14px;
color: gray;
vertical-align: middle;
}
}
</style>4. 在 src/index.js 注册为全局组件
为什么需要:分页是通用型组件,搜索页能用,订单页也可能用。转写稿明确说“千万别只在 search 里局部注册”,否则每个页面都要重复引入、注册。怎么想:全局组件在根实例创建前注册,放在 beforeCreate 里,使用 Vue.component("Pagination", Pagination)。怎么做:先 import Pagination from "@/components/Pagination";,再注册。易错点:组件注册名 Pagination 要和模板里使用的标签一致;路径 @/components/Pagination 默认会找 index.vue;注册必须发生在使用之前。调试时如果页面不显示,先查控制台是否有“组件未注册”的警告,再查路径和大小写。
js
import Pagination from "@/components/Pagination";
new Vue({
...
beforeCreate() {
// 全局组件
Vue.component("Pagination",Pagination);
...
},
...
})5. 本节调试顺序与后续提醒
为什么需要:静态组件最容易出的问题不是逻辑,而是“没注册、路径错、样式没带过来”。怎么想:先看组件是否能单独渲染,再看全局注册是否生效,最后看页面里标签是否写对。怎么做:按“建文件 → 放模板 → 迁样式 → 全局注册 → 页面使用”的顺序做;本节写死的 103、16、21 只是静态展示。易错点:转写稿里提到后续不要把总条数等直接写死,也不要让分页组件直接去 store 里拿数据,否则组件只能服务搜索页;下一步应通过 props 传 total、pageSize、currentPage 等参数。本节先把静态结构和全局注册做稳,再进入传参逻辑。
先明确目标:分页组件要通用,不能绑死搜索页
为什么需要:分页在搜索页、订单页等场景都可能出现。如果分页组件直接从 store 里取搜索数据,它就只能服务搜索页,封装的意义就没了。转写稿里明确说:封装是为了适用于多种场景,所以“不要从 store 里面拿,给我传参”。
怎么想:把分页组件当成通用组件,由使用它的父组件通过 props 把 total、pageSize 等数据传进来;store 只负责保存搜索页自己的结果数据。
怎么做:先在 src/store/product.js 的搜索结果里保存分页相关字段:
js
// 搜索的结果
searchProductResult:{
trademarkList:[],
// 当前页
pageNo:1,
// 每页显示的条数
pageSize:1,
// 总条数
total:1,
// 总页数
totalPages:10
}易错点:不要因为搜索页有 store,就让分页组件内部直接读 store。这样组件和业务页面耦合,换到订单页就不能复用。这里的初始值只是占位,真实数据要等接口返回后更新。
在 Search 页面给分页组件传参
为什么需要:Search 页面是分页组件的使用方,应该由它把当前搜索结果的分页信息传给组件。转写稿的调试思路是:先传假数据验证组件显示,再换成真实数据。
怎么想:先不要一次传太多,先把 total、pageNo、pageSize 这些关键参数传过去。转写稿中先用 total=103、pageNo=16、pageSize=5 测试,算出的总页数是 21。
怎么做:在 src/pages/Search/index.vue 中使用组件并传参:
vue
<!-- 分页组件-->
<Pagination :continue="5" :total="103" :pageNo="16" :pageSize="5"></Pagination>易错点:: 表示绑定表达式,不能写成普通字符串;属性名要和组件接收的 props 对齐。当前笔记里的 Pagination 只声明了 total、pageSize,pageNo 和 continue 还没有在组件里接,这是后续实现页码高亮等功能时要补的。先传假数据是为了确认“共多少条”和总页数计算是否正确。
在 Pagination 中接收 props 并计算总页数
为什么需要:分页组件不应该自己知道“总条数是多少”,它只负责根据外部传入的 total 和 pageSize 计算并展示。转写稿说总页数“不传也没事,可以自己算出来”。
怎么想:总页数 = 总条数 ÷ 每页条数,再向上取整。比如 103 条、每页 5 条,Math.ceil(103 / 5) 就是 21 页。
怎么做:在 src/components/Pagination/index.vue 中接收 props,并用计算属性 pageSum 计算总页数:
vue
<template>
<div class="pagination">
<button>上一页</button>
<button>1</button>
<span>···</span>
<button>14</button>
<button>15</button>
<button class="active">16</button>
<button>17</button>
<button>18</button>
<span>···</span>
<button>{{pageSum}}</button>
<button>下一页</button>
<span>共 {{total}} 条</span>
</div>
</template>
<script>
export default {
props:["total","pageSize"],
name: "index",
computed:{
pageSum(){
return Math.ceil(this.total/this.pageSize)
}
}
}
</script>易错点:props 是只读的,不要在子组件里直接修改 total 或 pageSize;总页数要用 Math.ceil 向上取整,否则 103/5 会得到小数;模板里要用 pageSum,不要误写成未定义变量。当前模板中的 14、15、16 等页码还是静态写死的,本节重点是先把参数传递和总页数算对,页码动态渲染是下一步。
调试顺序:先看接口,再验组件
为什么需要:分页参数不是凭空写的,真实值来自接口返回。转写稿提醒:接口一旦有问题,第一件事先看网络请求。
怎么想:先确认接口结果里有没有 total、pageNo、pageSize、totalPages 等信息;再把它们保存到 store;最后在 Search 页面传给分页组件。
怎么做:调试时先在 Search 中写死 total=103、pageNo=16、pageSize=5,观察分页组件是否显示“共 103 条”和总页数 21;确认无误后,再把假数据替换成 store 中 searchProductResult 的真实字段。
易错点:如果总页数显示不对,先检查 total 和 pageSize 是否传对;如果组件显示 NaN,检查 props 是否接到、属性名是否写错;如果直接让分页组件读 store,虽然搜索页能跑,但组件就失去通用性,这是本节最需要避免的问题。
为什么需要计算起始与结束页码
分页组件不能把总页数全部渲染出来,否则页码会非常长;通常只显示当前页附近固定数量的连续页码。这个固定数量就是 continue,当前页是 pageNo。组件需要先算出连续页码区间的起点 start 和终点 end,模板才能根据这个区间生成页码。
为什么需要:分页组件要通用,不能只服务搜索页,所以 total、pageSize、pageNo、continue 都通过 props 传入,组件内部只做计算。转写稿里也反复强调:分页组件不要直接去 store 里取数据,否则就变成“只适用于搜索页的分页”,失去封装意义。
怎么想:先有总条数和每页条数,才能知道总页数;再有当前页和连续显示数量,才能知道当前页左右各显示几个页码。
怎么做:在 Pagination.vue 中保留两个计算属性:pageSum 算总页数,startAndEnd 算连续页码区间。
易错点:total、pageSize 可能是字符串,计算前最好确认是数字;pageSize 不能为 0;continue 表示“显示几个页码”,不是总页数。
怎么想:当前页居中,左右各一半
连续页码一般以当前页为中心。假设 continue = 5,当前页是 16,那么左右各显示 2 个,区间就是 14 到 18,共 5 个页码:14、15、16、17、18。
为什么需要:如果直接写死 pageNo - 2 到 pageNo + 2,组件就只能支持 5 个页码,不够通用。
怎么想:先去掉当前页本身,剩下 continue - 1 个位置,左右平分,所以每边是 (continue - 1) / 2。
怎么做:起点等于当前页减去左边数量,终点等于当前页加上右边数量。
易错点:continue 应该是奇数,否则 (continue - 1) / 2 会出现小数,页码会变成 13.5 这类错误值。笔记里的注释 pageNo = 16, start==>14 end=18 对应的就是 continue = 5。
怎么做:在 Pagination.vue 的 computed 中实现
笔记代码位于 src->pages->components->Pagination.vue,保留如下:
vue
<script>
export default {
props:["total","pageSize","continue","pageNo"],
name: "index",
computed:{
pageSum(){
return Math.ceil(this.total/this.pageSize)
},
// 计算起始与结束位置
startAndEnd(){
let start = 0;
let end = 0;
// pageNo = 16,start==>14 end=18
start = this.pageNo-(this.continue-1)/2;
end = this.pageNo + (this.continue-1)/2
return {start,end}
}
}
}
</script>为什么需要:pageSum 负责总页数,startAndEnd 负责连续页码区间,两者职责分开,后续做边界判断时也更清楚。
怎么想:startAndEnd 只依赖 pageNo 和 continue,不依赖总页数,所以先算出“理论区间”。
怎么做:用 let start = 0; let end = 0; 声明变量,再按公式赋值,最后返回 {start,end} 对象。
易错点:pageSum 要用 Math.ceil 向上取整,例如 103 条、每页 5 条,103/5 = 20.6,总页数应为 21;如果直接取整会少一页。startAndEnd 里不要漏掉 this.,否则拿不到 props。
调试与验证:用 103 条、每页 5 条、当前页 16 推导
转写稿里用假数据测试过:总条数 total = 103,每页条数 pageSize = 5,当前页 pageNo = 16。注意转写稿里的“兔豆”就是 total,“配置袋子/配射袋”就是 pageSize,“配置 some”就是 pageSum。
为什么需要:只看公式容易觉得抽象,代入真实数字才能发现边界问题。
怎么想:先算总页数 pageSum = Math.ceil(103 / 5) = 21;再假设 continue = 5,左右各 2 个。
怎么做:start = 16 - (5 - 1) / 2 = 16 - 2 = 14,end = 16 + 2 = 18,得到区间 14 到 18,正好 5 个页码。
易错点:如果 pageNo = 1、continue = 5,按原公式会得到 start = -1、end = 3,模板就会渲染出 -1、0 这类不存在的页码。这说明当前笔记代码只完成了“理论区间计算”,还没有处理边界。后续应在使用区间前判断:start < 1 时裁剪到 1,end > pageSum 时裁剪到总页数;continue 大于总页数时也要特殊处理。
易错点:奇数、边界、类型与组件复用
为什么需要:分页计算一旦出错,用户会看到不存在的页码、页码数量不对,或者刷新后当前页与页码区间不一致。
怎么想:把问题分成三类——公式本身、边界裁剪、数据来源。公式要求 continue 为奇数;边界要求 start 不小于 1、end 不大于 pageSum;数据来源要求全部通过 props 传入。
怎么做:保留笔记中的公式作为核心,后续在模板使用前补边界逻辑;total、pageSize、pageNo、continue 由父组件传入,不从 store 直接取。
易错点:pageNo 可能是字符串,比较和计算时要注意类型;continue 传偶数会产生小数页码;pageSize 为 0 会导致 pageSum 为 Infinity;pageNo 超过 pageSum 时也要纠正。转写稿里强调“分页组件要适用于不同场景”,所以这些参数必须由外部传进来,组件内部只负责把区间算对。
先明确分页组件要接收哪些数据
为什么需要:分页组件不能只服务于当前搜索页。转写稿里反复强调,封装组件是为了在订单、商品列表等不同场景复用,所以不能直接在分页组件里从 Vuex 取搜索数据,而应由父组件通过 props 传进来。
怎么想:一个连续页码分页至少需要四个参数:总条数 total、每页条数 pageSize、连续页码数量 continue、当前页 pageNo。总页数可以由前两者算出来,不需要父组件重复传。
怎么做:在 Pagination.vue 中声明 props,并计算总页数:
js
props: ["total", "pageSize", "continue", "pageNo"],
computed: {
pageSum() {
return Math.ceil(this.total / this.pageSize)
}
}易错点:pageSum 要用 Math.ceil 向上取整;如果父组件传进来的是字符串,后续比较可能出问题,必要时用 Number() 转换。转写稿里排序部分就出现过“拿到的是字符串”导致比较不对的调试过程,分页参数也要注意类型。
连续页码的区间推导
为什么需要:如果只渲染“1、2、3……总页数”,用户无法快速看到当前页附近的页码;如果把所有页码都铺开,页数多时又会很长。所以需要以当前页为中心,渲染一段连续页码。
怎么想:假设 continue = 5,表示当前页左右一共展示 5 个连续页码。以当前页 pageNo = 16 为例,左右各扩展 (5 - 1) / 2 = 2 页,因此起始页是 16 - 2 = 14,结束页是 16 + 2 = 18。笔记中的注释也是这个推导:pageNo = 16, start==>14 end=18。
怎么做:在计算属性里算 startAndEnd:
js
startAndEnd() {
let start = 0;
let end = 0;
start = this.pageNo - (this.continue - 1) / 2;
end = this.pageNo + (this.continue - 1) / 2;
return { start, end }
}易错点:continue 一般应取奇数,否则左右扩展会出现小数;起始页可能小于 1,结束页可能大于总页数,笔记这一版只完成了连续页码渲染,边界还需要在后续逻辑中处理。
三种写法的取舍
为什么需要:v-for 需要生成一段页码,但页码不是从 1 开始,而是从 start 到 end,所以要想办法把区间映射成可循环的数据。
怎么想:笔记里保留了三种尝试。第一种用 v-for="page in (end - start + 1)",再显示 start + page - 1;第二种用 v-for="(_, index) in (...)",显示 start + index;第三种直接 v-for="page in end",再用 v-show="page >= start" 过滤。
怎么做:最终采用第三种,因为模板最直观:先循环出 1 到 end,只显示大于等于 start 的页码。代码如下,保留笔记原代码:
vue
<template>
<div class="pagination">
<button>上一页</button>
<button>1</button>
<span>···</span>
<!--1-->
<!-- <button :class="{active:(pageNo===startAndEnd.start+page-1)}" -->
<!-- v-for="page in (startAndEnd.end - startAndEnd.start+1)">-->
<!-- {{startAndEnd.start+page-1}}-->
<!-- </button>-->
<!--2-->
<!-- <button :class="{active:(pageNo===startAndEnd.start+index)}"-->
<!-- v-for="(_,index) in (startAndEnd.end - startAndEnd.start+1)">-->
<!-- {{startAndEnd.start+index}}-->
<!-- </button>-->
<!--3-->
<button :class="{active:pageNo===page}" v-show="page >=startAndEnd.start" v-for="page in startAndEnd.end" :key="page">
{{page}}
</button>
<span>···</span>
<button>{{pageSum}}</button>
<button>下一页</button>
<span>共 {{total}} 条</span>
<div>
<h3>
{{startAndEnd}}
</h3>
</div>
</div>
</template>
<script>
export default {
props:["total","pageSize","continue","pageNo"],
name: "index",
computed:{
pageSum(){
return Math.ceil(this.total/this.pageSize)
},
// 计算起始与结束位置
startAndEnd(){
let start = 0;
let end = 0;
// pageNo = 16,start==>14 end=18
start = this.pageNo-(this.continue-1)/2;
end = this.pageNo + (this.continue-1)/2
return {start,end}
}
}
}
</script>易错点:第三种写法中 v-for 实际仍然生成了 1 到 end 的按钮,只是用 v-show 隐藏了前面的页码,DOM 里仍然存在;如果要求只渲染区间,可以后续改成计算属性返回数组再循环。key 要用页码本身,不要用 index。当前页高亮用 :class="{active: pageNo === page}",注意 pageNo 与 page 的类型一致。
调试顺序与常见问题
为什么需要:分页逻辑涉及父组件传参、总页数计算、区间计算和模板渲染,直接接真实接口容易把问题混在一起。
怎么想:转写稿里的做法是先用假数据把组件跑通,再换成真实数据。比如先传 total = 103、pageSize = 5、pageNo = 16、continue = 5,观察 pageSum 是否为 21、startAndEnd 是否为 {start:14,end:18},再检查按钮显示是否正确。
怎么做:在父组件使用分页组件时传入参数,例如 :total="total" :pageSize="pageSize" :continue="5" :pageNo="pageNo";如果页码不显示,先看 props 是否接到、pageSum 是否为数字、startAndEnd 是否返回了对象。
易错点:v-show 条件写成 page >= startAndEnd.start,不要写成 page > start;continue 不要传偶数;当前页靠近第一页或最后一页时,要额外处理 start < 1 和 end > pageSum 的情况,否则会出现 0、负数或超出总页数的页码。
课堂画面





代码示例
示例 1:搜索页排序(src/pages/Search/index.vue)
vue
<template>
<div class="search">
<!-- 排序条:综合 / 价格,只有这两项接口支持 -->
<ul class="sui-nav">
<li @click.prevent="orderSearch(1)" :class="{ active: type == 1 }">
<a href="#">综合<i v-show="type == 1" :class="upOrDown"></i></a>
</li>
<li @click.prevent="orderSearch(2)" :class="{ active: type == 2 }">
<a href="#">价格<i v-show="type == 2" :class="upOrDown"></i></a>
</li>
</ul>
</div>
</template>
<script>
export default {
name: "Search",
data() {
// 排序方式
// 排序类型(type) 1: 综合 2: 价格
// 排序标识(flag) asc: 升序 desc: 降序
// 示例: "1:desc"
// 有 order 参数就以地址栏为准,没有就给默认值 "1:desc"(综合降序)
const [type, flag] = (this.$route.query.order || "1:desc").split(":");
return {
type, // 综合
flag // 降序
};
},
computed: {
// 根据 flag 决定箭头朝上还是朝下
upOrDown() {
return this.flag === "desc"
? "iconfont icon-angle-down"
: "iconfont icon-angle-up";
}
},
methods: {
orderSearch(type) {
// 1. 当前排序类别与点击的类别是否相同
if (this.type === type) {
// 相同:只切换升降序
this.flag = this.flag === "desc" ? "asc" : "desc";
} else {
// 不同:切换类别,并重置为降序
this.type = type;
this.flag = "desc";
}
// 2. 同步到地址栏,刷新后排序状态不丢
this.$router.push({
path: "/search",
query: {
...this.$route.query,
order: this.type + ":" + this.flag
}
});
}
}
};
</script>关键行与易错点
(this.$route.query.order || "1:desc").split(":"):这一行同时做了两件事:没有order参数时给默认值"1:desc",有参数时按:拆成[type, flag]。写成const [type, flag] = ...时前面不能加let type = 1之类的重复声明,否则会报“重复声明”的错误。- 类型问题最容易出错:从地址栏拆出来的
type是字符串"2",而模板里点击传的是数字2。所以判断时必须用==(笔记里就是type == 1),或者用===时先Number(type)转一下,否则"2" === 2永远为 false,选中态和高亮全失效。 v-show="type == 1"/v-show="type == 2":箭头只在被选中的那一项上显示,未选中项不显示箭头(因为它的升降序没有意义)。@click.prevent必须加:<a href="#">不阻止默认行为会改地址栏 hash,导致路由状态被干扰。{ ...this.$route.query }不能漏:搜索页的keyword、categoryName等参数要原样带过去,只覆盖order。- 刷新后排序“恢复正常”正是靠
data()里读取$route.query.order这一句。
示例 2:搜索框回车提交(src/components/Header/index.vue)
vue
<template>
<header class="header">
<!-- autocomplete="off" 关掉浏览器历史下拉,避免遮挡 -->
<!-- @submit.prevent 阻止表单默认提交(页面跳转/刷新) -->
<form @submit.prevent="goSearch" autocomplete="off" class="searchForm">
<input
name="keyword"
placeholder="请输入搜索的关键词"
type="text"
ref="keyword"
id="autocomplete"
class="input-error input-xxlarge"
/>
<button class="sui-btn btn-xlarge btn-danger" type="submit">搜索</button>
</form>
</header>
</template>
<script>
export default {
name: "Header",
methods: {
goSearch() {
// 收集输入框中的关键词
const keyword = this.$refs.keyword.value.trim();
const query = { ...this.$route.query };
if (keyword) {
query.keyword = keyword;
} else {
delete query.keyword;
}
// 跳转到搜索页
this.$router.push({ path: "/search", query });
}
}
};
</script>关键行与易错点
@submit.prevent="goSearch"是这一节的核心:表单里回车会触发submit事件,加.prevent才能拦住默认的“提交表单 → 刷新页面”。笔记里演示过:不加.prevent,回车后页面会直接跳走(地址栏拼上?keyword=xxx),Vue 的路由和状态全丢。- 按钮写
type="submit"(不是默认的type="button"):这样点击按钮和输入框回车走的是同一条路——都触发form的submit,只写一个goSearch就够了。若把按钮写成type="button"再加@click,则回车和点击要写两套逻辑。 ref="keyword"用于拿原生 DOM 的值;name="keyword"是原生表单字段名,两者作用不同,别混。autocomplete="off":防止浏览器历史输入下拉框盖住页面自己的联想列表。this.$refs.keyword.value.trim():ref取到的是原生 input 元素,要.value;不trim()会出现空关键词搜索。
示例 3:分页组件(src/components/Pagination/index.vue)
vue
<template>
<div class="pagination">
<button>上一页</button>
<button>1</button>
<span>···</span>
<!-- 连续页码:从 startAndEnd.start 开始,渲染到 startAndEnd.end -->
<button
:class="{ active: pageNo === page }"
v-show="page >= startAndEnd.start"
v-for="page in startAndEnd.end"
:key="page">
{{ page }}
</button>
<span>···</span>
<button>{{ pageSum }}</button>
<button>下一页</button>
<span>共 {{ total }} 条</span>
</div>
</template>
<script>
export default {
name: "index",
// 全部由父组件传进来,组件自身不碰 store / 路由,才能通用
props: ["total", "pageSize", "continue", "pageNo"],
computed: {
// 总页数 = 总条数 / 每页条数 向上取整
pageSum() {
return Math.ceil(this.total / this.pageSize);
},
// 计算连续页码的起始与结束位置
// pageNo = 16, continue = 5 ==> start = 14, end = 18
startAndEnd() {
let start = 0;
let end = 0;
start = this.pageNo - (this.continue - 1) / 2;
end = this.pageNo + (this.continue - 1) / 2;
return { start, end };
}
}
};
</script>
<style lang="less" scoped>
.pagination {
text-align: center;
button {
margin: 0 5px;
background-color: #f4f4f5;
color: gray;
outline: none;
border-radius: 2px;
padding: 0 4px;
vertical-align: top;
display: inline-block;
font-size: 13px;
min-width: 35.5px;
height: 28px;
line-height: 28px;
cursor: pointer;
box-sizing: border-box;
text-align: center;
border: 0;
&[disabled] {
color: #c0c4cc;
cursor: not-allowed;
}
&.active {
cursor: not-allowed;
background-color: #c81623;
color: #fff;
}
}
span {
display: inline-block;
line-height: 28px;
font-size: 14px;
color: gray;
vertical-align: middle;
}
}
</style>全局注册(src/index.js):
js
import Pagination from "@/components/Pagination";
new Vue({
beforeCreate() {
// 通用型组件注册成全局组件,其他页面也能直接用 <Pagination />
Vue.component("Pagination", Pagination);
}
});父组件使用(src/pages/Search/index.vue):
vue
<Pagination :continue="5" :total="103" :pageNo="16" :pageSize="5"></Pagination>关键行与易错点
props: ["total","pageSize","continue","pageNo"]一定要用 props 传,不要在分页组件里直接读 store:一旦耦合 store,这个组件就只服务于搜索页,“封装通用组件”的目的就没了。pageSum用Math.ceil而不是Math.floor/round:103 条、每页 5 条要 21 页,向下取整会少一页。v-for="page in startAndEnd.end"是从 1 遍历到 end,所以要用v-show="page >= startAndEnd.start"把前面不该显示的页码藏掉。写成v-show而不是v-if:这样page与渲染出来的数字能保持一致(笔记里前两种写法靠start+index拼数字,容易算错,第三种写法最省事)。(this.continue - 1) / 2里continue必须是奇数(如 5),否则start/end会出现小数,页码就会渲染成 13.5 之类。:key="page"不能省,否则切换排序或翻页时页码 DOM 复用会错乱。- 传给组件的
total、pageNo、pageSize先用假数据(103 / 16 / 5)验证渲染,再把 store 里的真实数据接上;pageNo、pageSize、total、totalPages这类字段应放在searchProductResult里统一维护。
常见错误
- 刷新后排序选中态/箭头丢失 → data 初值写死,未读取
$route.query.order,或读到字符串后与数字type用===比较 → 初始化时解析order并统一类型,确保type/flag与 URL 同步。 - 点击排序后筛选条件丢失 → 跳转时只写
order,没有合并原 query → 使用...this.$route.query后再覆盖order。 - 连续点击同一排序项不切换升降序,或切换类别后仍沿用旧 flag → 同类别/不同类别分支判断错误,字符串与数字混用 → 同类别翻转
flag,不同类别重置为desc,比较前统一类型。 - 搜索框回车后页面刷新或地址错误 →
form默认提交且未阻止,按钮默认submit→ 表单加@submit.prevent="goSearch",或明确按钮type并复用同一搜索方法。 - 分页组件复用性差 → 组件直接从 store 取业务数据 → 改为通过 props 传入
total/pageSize/pageNo/continue,页码点击通过事件派发。 - 连续页码出现 0、负数或超过总页数 →
start/end未做边界裁剪 → 计算后限制在[1, totalPages],并处理总页数小于continue的情况。 - 分页组件报未注册或样式错乱 → 忘记全局注册,或图标/分页样式路径引入错误 → 在入口注册全局组件,确认 CSS 与字体文件路径正确。
- 页码按钮渲染多余或 active 错位 →
v-for范围/索引偏移写错,key 重复 → 先计算页码列表,再用页码作为 key,active 直接比较pageNo === page。
练习
- 将排序状态完全路由化:设计
order缺省值、非法值(如3:abc)的兜底,并保证刷新、前进、后退时状态一致。 - 按转写稿留的作业,把面包屑导航抽离成组件,说明需要哪些 props、哪些事件,以及原页面方法如何迁移。
- 给
Pagination增加页码点击事件,父组件接收后更新路由 query 中的页码,同时保留order、关键字等原有 query。 - 改造连续页码显示:分别处理总页数小于
continue、当前页在首页附近、当前页在尾页附近三种情况,写出start/end规则。 - 搜索框同时支持回车和按钮提交,要求不重复触发搜索,并说明
@submit.prevent与button type各自的作用。
原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | 使用阿里图标库资源 | 11:31 |
| 2 | 实现排序 | 30:24 |
| 3 | 搜索框回车功能 | 05:18 |
| 4 | 创建分页静态组件 | 09:15 |
| 5 | 向分页组件传递参数 | 12:11 |
小结
- 排序状态由“类别 + 升降序”组成,必须与 URL query 保持一致,刷新和回退才可靠。
- 同类别点击只翻转升降序,不同类别应重置默认排序标识;比较前先统一数据类型。
- 路由跳转要合并原 query,避免丢失关键字、分类、品牌等筛选条件。
- 表单回车本质是提交表单,阻止默认行为后即可复用同一个搜索方法。
- 分页组件应做成通用展示组件:数据靠 props 输入,页码靠事件输出。
- 连续页码必须做边界裁剪,并考虑总页数不足、当前页在首尾等场景。
- 通用分页适合注册为全局组件,但注册位置和样式引入要在入口统一处理。
- 图标库建议本地化引入,图标 class 由状态计算,避免把展示逻辑写死在模板中。