Skip to content

实现排序与搜索框优化(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

课堂画面 2

课堂画面 3

课堂画面 4

课堂画面 5

代码示例 ​

示例 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 里统一维护。

常见错误 ​

  1. 刷新后排序选中态/箭头丢失 → data 初值写死,未读取 $route.query.order,或读到字符串后与数字 type 用 === 比较 → 初始化时解析 order 并统一类型,确保 type/flag 与 URL 同步。
  2. 点击排序后筛选条件丢失 → 跳转时只写 order,没有合并原 query → 使用 ...this.$route.query 后再覆盖 order。
  3. 连续点击同一排序项不切换升降序,或切换类别后仍沿用旧 flag → 同类别/不同类别分支判断错误,字符串与数字混用 → 同类别翻转 flag,不同类别重置为 desc,比较前统一类型。
  4. 搜索框回车后页面刷新或地址错误 → form 默认提交且未阻止,按钮默认 submit → 表单加 @submit.prevent="goSearch",或明确按钮 type 并复用同一搜索方法。
  5. 分页组件复用性差 → 组件直接从 store 取业务数据 → 改为通过 props 传入 total/pageSize/pageNo/continue,页码点击通过事件派发。
  6. 连续页码出现 0、负数或超过总页数 → start/end 未做边界裁剪 → 计算后限制在 [1, totalPages],并处理总页数小于 continue 的情况。
  7. 分页组件报未注册或样式错乱 → 忘记全局注册,或图标/分页样式路径引入错误 → 在入口注册全局组件,确认 CSS 与字体文件路径正确。
  8. 页码按钮渲染多余或 active 错位 → v-for 范围/索引偏移写错,key 重复 → 先计算页码列表,再用页码作为 key,active 直接比较 pageNo === page。

练习 ​

  1. 将排序状态完全路由化:设计 order 缺省值、非法值(如 3:abc)的兜底,并保证刷新、前进、后退时状态一致。
  2. 按转写稿留的作业,把面包屑导航抽离成组件,说明需要哪些 props、哪些事件,以及原页面方法如何迁移。
  3. 给 Pagination 增加页码点击事件,父组件接收后更新路由 query 中的页码,同时保留 order、关键字等原有 query。
  4. 改造连续页码显示:分别处理总页数小于 continue、当前页在首页附近、当前页在尾页附近三种情况,写出 start/end 规则。
  5. 搜索框同时支持回车和按钮提交,要求不重复触发搜索,并说明 @submit.prevent 与 button type 各自的作用。

原视频对照 ​

#视频时长
1使用阿里图标库资源11:31
2实现排序30:24
3搜索框回车功能05:18
4创建分页静态组件09:15
5向分页组件传递参数12:11

小结 ​

  1. 排序状态由“类别 + 升降序”组成,必须与 URL query 保持一致,刷新和回退才可靠。
  2. 同类别点击只翻转升降序,不同类别应重置默认排序标识;比较前先统一数据类型。
  3. 路由跳转要合并原 query,避免丢失关键字、分类、品牌等筛选条件。
  4. 表单回车本质是提交表单,阻止默认行为后即可复用同一个搜索方法。
  5. 分页组件应做成通用展示组件:数据靠 props 输入,页码靠事件输出。
  6. 连续页码必须做边界裁剪,并考虑总页数不足、当前页在首尾等场景。
  7. 通用分页适合注册为全局组件,但注册位置和样式引入要在入口统一处理。
  8. 图标库建议本地化引入,图标 class 由状态计算,避免把展示逻辑写死在模板中。