Skip to content

渲染连续页码与优化连续页码(Lesson17 3/3) ​

本页对应 Lesson17,共 5 段视频,约 1 小时 19 分。

学习目标 ​

  1. 能根据当前页、总页数和连续页码数,计算连续页码的起始位置与结束位置,并处理边界越界情况。
  2. 能使用循环渲染连续页码,并正确实现当前页高亮、首页/上一页/下一页/末页及省略号的条件显示。
  3. 能优化连续页码在总页数少于连续页码数、当前页靠前或靠后等场景下的显示逻辑。
  4. 能通过自定义事件和路由查询参数实现页码点击切换,并支持指定当前页与每页显示条数。
  5. 能定位并修复搜索条件、标签或排序变更后页码未回到第一页的 BUG。
  6. 能定位并修复点击分类时误删其他搜索条件的 BUG,确保仅移除与分类相关的查询条件。

知识点 ​

一、先明确分页组件的数据流 ​

连续页码的目标是:在“上一页、下一页、总页数”之间,渲染一段连续页码,例如当前第 16 页、连续显示 5 个,就显示 14、15、16、17、18。

为什么需要:页码不能只显示当前页,也不能把总页数全部铺开;需要根据 pageNo、continue、total、pageSize 算出连续区间。

怎么想:先算总页数 pageSum = Math.ceil(total / pageSize);再根据当前页 pageNo 和连续条数 continue 算起始 start、结束 end。continue 一般取奇数,这样当前页左右对称,标红时视觉更自然。

怎么做:当前页 16、continue=5 时,左右各 (5-1)/2=2 页,所以 start=16-2=14,end=16+2=18。公式为:

js
start = this.pageNo - (this.continue - 1) / 2
end = this.pageNo + (this.continue - 1) / 2

易错点:continue 不要传偶数;pageNo 是父组件传入的 props,子组件不要直接修改;公式里用的是“连续条数”,不是 pageSize。

二、用计算属性得到 startAndEnd ​

为什么需要:模板里要反复使用起止页码,放在 computed 里最合适,数据变化会自动重算。

怎么做:在 Pagination.vue 中定义 startAndEnd,返回 { start, end }。笔记里先用 0 初始化,再计算并返回,调试时用 <h3>{{ startAndEnd }}</h3> 看对象结果。

易错点:不要拆成两个计算属性再互相依赖,没必要;调试用的 h3 最后应删除;startAndEnd 只是计算结果,不要在这里发请求。

三、渲染连续页码的三种写法 ​

笔记保留了三种方案:

  1. v-for="page in (end - start + 1)",显示 start + page - 1。因为 page 从 1 开始,所以要减 1。
  2. v-for="(_, index) in (end - start + 1)",显示 start + index。index 从 0 开始,所以不加不减。
  3. 直接 v-for="page in startAndEnd.end",再用 v-show="page >= startAndEnd.start" 过滤。笔记最终采用这种。

为什么需要理解三种:第一种和第二种是“按数量遍历”,第三种是“先遍历到结束页,再隐藏小于起始页的按钮”。第三种能工作,但会渲染一部分被隐藏的按钮。

易错点:v-for 的 page 从 1 开始,index 从 0 开始;active 判断应写 pageNo === page;不推荐 v-for 和 v-if 同时使用,因为会先遍历再卸载,效率不高;v-show 只是隐藏,DOM 仍在。

四、边界优化:总页数少、起始小于 1、结束超过总页数 ​

为什么需要:如果总页数只有 4 页,而 continue=5,按原公式会算出 14 到 18,明显错误;当前页为 1 或最后一页时,也会算出越界区间。

怎么想:先判断 continue > pageSum,此时没有“省略号”的必要,直接 start=1、end=pageSum。否则再处理左右越界:

  • 若 start < 1:start = 1,end = start + continue - 1。
  • 若 end > pageSum:end = pageSum,start = end - continue + 1。

易错点:end = start + continue - 1 必须减 1,否则会多显示一个;右越界时 start = pageSum - continue + 1,不是减 continue。

省略号与首尾按钮的显示条件也要配套:第一页按钮在 start > 1 时显示;左省略号在 start > 2 时显示;最后一页按钮在 pageSum > end 时显示;右省略号在 pageSum > end + 1 时显示。上一页在 pageNo === 1 时禁用,下一页在 pageNo === pageSum 时禁用。

五、页码点击与父组件通信 ​

为什么需要:Pagination 是封装组件,不能把 router.push 写死在里面,否则其他页面无法复用。

怎么做:子组件点击页码时 $emit('change-page', page);上一页传 pageNo - 1,下一页传 pageNo + 1,第一页传 1,最后一页传 pageSum。父组件监听事件,在方法里跳转并保留原查询条件:

js
changePage(page) {
  this.$router.push({
    path: '/search',
    query: { ...this.$route.query, pageNo: page }
  })
}

易错点:跳转时必须展开原 query,否则标签、排序等条件会丢失;页码变化后要重新请求列表;watch 里可同步 pageNo、pageSize,但以 query 传入值为准。

六、调试中遇到的两个 bug ​

第一个 bug:搜索条件、分类、排序改变后,页码没有回到第一页。原因是跳转时没有覆盖 pageNo。解决:所有会改变搜索条件的跳转都加上 pageNo: 1。

第二个 bug:点击分类会把其他搜索条件删掉。原因是跳转时只保留了部分 query。解决:用 ...this.$route.query 保留原条件,再覆盖分类参数和 pageNo: 1;删除分类时只删分类相关参数,标签、排序等继续保留。

七、本节组件代码 ​

以下保留笔记中的 Pagination.vue 代码,调试用的 h3 可在最后删除:

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>

先明确:连续页码要解决什么问题 ​

当总页数很多时,不能把所有页码都铺出来,否则页面又长又难用。通常只显示连续的一小段页码,并让当前页尽量居中;左侧用“1 + …”提示还能回第一页,右侧用“… + 最后一页”提示还能去末尾。这里连续页码一般取奇数,转写稿中取 continue = 5,因为当前页居中时左右对称,标红位置更好看。

核心不是直接写死 1 2 3 4 5,而是根据 pageNo、continue、pageSum 算出 start 和 end。例如总页数 21、当前页 16、连续 5 个,应得到 14 15 16 17 18,即 start=14、end=18。

计算起止位置:先推公式,再补边界 ​

为什么需要:模板要知道从第几页渲染到第几页。怎么想:连续 5 个,当前页居中,左右各 (5-1)/2=2 个,所以 start=pageNo-2、end=pageNo+2。怎么做:写成 startAndEnd 计算属性,返回 {start,end}。转写稿中先让 start=0、end=0 渲染调试,看到 0/0 后代入 pageNo=16,推出 14 和 18,再补三类边界:

  1. continue > pageSum:总页数不够连续页数,直接 start=1、end=pageSum;
  2. start < 1:左边越界,令 start=1,end=start+continue-1;
  3. end > pageSum:右边越界,令 end=pageSum,start=pageSum-continue+1。

易错点:continue 一般用奇数;公式括号别写错;总页数小于连续页数时必须单独处理,否则会出现 14 15 16 17 18 但总页数只有 4 的荒唐结果。

本节保留代码 ​

vue
<template>
    <div class="pagination">
        <button :disabled="pageNo===1">上一页</button>
        <!-- 起始页数start大于1 -->
        <button v-show="startAndEnd.start>1">1</button>
        <span v-show="startAndEnd.start>2">···</span>

        <button :class="{active:(pageNo===startAndEnd.start+index)}"
                v-for="(_,index) in (startAndEnd.end - startAndEnd.start+1)">
            {{ startAndEnd.start + index }}
        </button>

        <span v-show="pageSum>startAndEnd.end+1">···</span>
        <button v-show="pageSum>startAndEnd.end">{{ pageSum }}</button>
        <button :disabled="pageNo===pageSum">下一页</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;
            // 如果连续页数大于总页数
            if (this.continue > this.pageSum) {
                start = 1;
                end = this.pageSum;
            } else {
                // pageNo = 16,start==>14 end=18
                start = this.pageNo - (this.continue - 1) / 2;
                end = this.pageNo + (this.continue - 1) / 2
                if (start < 1) {
                    start = 1;
                    end = start + this.continue - 1;
                }
                if(end>this.pageSum){
                    end = this.pageSum;
                    start = this.pageSum-this.continue+1;
                }
            }
            return {start, end}
        }
    }
}
</script>

说明:模板里的 h3 是转写稿中的调试输出,正式使用可删除。

渲染连续页码与省略号 ​

为什么需要:有起止位置后,v-for 的长度应是 end-start+1,不能直接用 end,否则会多渲染。怎么做:循环 index 从 0 开始,显示 start+index;高亮判断 pageNo === start+index。左侧“1”在 start>1 时显示,左省略号在 start>2 时显示;右侧省略号在 pageSum>end+1 时显示,最后一页在 pageSum>end 时显示。易错点:转写稿特别提醒不要 v-if 和 v-for 混在同一元素上,先遍历再卸载效率低;更推荐本笔记这种按长度遍历,或先计算过滤后的页码数组。

接入点击与真实数据 ​

页码组件不应写死 router.push,否则复用性差。转写稿中的做法是:子组件点击页码时 $emit("changePageName", 页码),父组件监听后统一跳转,并在 query 中保留原条件、设置 pageName。搜索条件、分类、排序发生变化时,要把 pageName 重置为 1,否则会出现“条件变了还在第 2 页”的 bug。分类点击时不要只保留 keyword,否则会把品牌、标签等条件全删掉;全部结果统计里还要过滤掉 pageName,避免把页码当搜索条件。

组件分工:子组件只负责发事件,父组件决定路由与请求 ​

为什么需要:分页组件要能被搜索页、订单页等复用,不能把 $router.push 写死在分页组件里,否则组件就和具体页面绑死了。
怎么想:Pagination 只接收 total、pageNo、pageSize、continue,渲染页码;用户点击时通过自定义事件 change-page-no 把目标页码交给父组件。
怎么做:在 src/components/Pagination/index.vue 中,上一页传 pageNo-1,下一页传 pageNo+1,数字页码传 startAndEnd.start + index。
易错点:不要在分页组件内部直接跳路由;事件名、参数要统一,父组件才能稳定接收。

vue
<template>
    <div class="pagination">
        <button  @click="$emit('change-page-no',pageNo-1)" :disabled="pageNo===1">上一页</button>
        <!-- 起始页数start大于1 -->
        <button  @click="$emit('change-page-no',1)" v-show="startAndEnd.start>1">1</button>
        <span v-show="startAndEnd.start>2">···</span>

        <button @click="$emit('change-page-no',startAndEnd.start + index)" :class="{active:(pageNo===startAndEnd.start+index)}"
                v-for="(_,index) in (startAndEnd.end - startAndEnd.start+1)">
            {{ startAndEnd.start + index }}
        </button>

        <span v-show="pageSum>startAndEnd.end+1">···</span>
        <button @click="$emit('change-page-no',pageSum)" v-show="pageSum>startAndEnd.end">{{ pageSum }}</button>
        <button @click="$emit('change-page-no',pageNo+1)" :disabled="pageNo===pageSum">下一页</button>

        <span>共 {{ total }} 条</span>
    </div>
</template>

连续页码的起止位置怎么算 ​

为什么需要:不能把所有页码都铺出来,也不能只显示当前页;要显示“当前页附近固定数量”的连续页码。
怎么想:设连续页码数为 continue,一般取奇数,保证当前页左右对称。转写稿中的推导是:当前页 16、连续 5 个,左边 2 个、右边 2 个,所以起始 start = 16 - (5-1)/2 = 14,结束 end = 14 + 5 - 1 = 18。
怎么做:在 Pagination 中用计算属性 startAndEnd 返回 { start, end },模板只使用 startAndEnd.start/end;总页数由 total 和 pageSize 得到 pageSum。
易错点:公式里“连续数减 1 再除以 2”容易漏括号;结束位置是 start + continue - 1,不是 start + continue,否则会多显示一页。

渲染连续页码与当前页高亮 ​

为什么需要:起止位置只是数据,还要循环渲染按钮,并让当前页有 active 样式。
怎么想:转写稿里试过三种写法,推荐用下标方案:v-for="(_, index) in (end - start + 1)",页码是 start + index。这样不用额外维护计数器。
怎么做:按钮点击时 $emit('change-page-no', startAndEnd.start + index);高亮条件写成 pageNo === startAndEnd.start + index。
易错点:不要用 v-if 和 v-for 同时控制连续页码的显示隐藏。转写稿明确提醒:这样会先渲染再卸载,效率低;应在循环范围或数据层处理边界。

边界优化:总页数少、起始小于 1、结束超过总页数 ​

为什么需要:真实数据下会出现总页数只有 4 页却要连续显示 5 页;当前页为 2 时起始可能算成 0;当前页为 20 时结束可能超过总页数。
怎么想:转写稿的调试过程是:总页数小于连续页数时,直接显示 1...pageSum;start < 1 时令 start = 1,再令 end = start + continue - 1;end > pageSum 时令 end = pageSum,再令 start = pageSum - continue + 1。
怎么做:把这三个判断放在 startAndEnd 计算属性里,模板不用堆判断。
易错点:修正 start=1 后,end 要重新算,否则会多显示一页;修正 end=pageSum 后,start 也要反推,保证仍然显示 continue 个页码。

父页面接收事件:改路由 query,再触发请求 ​

为什么需要:页码变化后要重新请求列表,并且刷新页面时还能保持当前页。
怎么想:Search/index.vue 监听 $route.query,从 store 取 pageNo、pageSize,再 dispatch("product/postProductListAsync", {...})。点击页码时用 $router.push 把新 pageNo 合并进 query。
怎么做:父组件绑定 @change-page-no="changePageNo",方法里保留原 query:...this.$route.query, pageNo。
易错点:不要直接覆盖整个 query,否则搜索关键字、品牌等条件会丢;watch 要加 immediate:true,首次进入页面也能请求。

vue
<template>
  <!-- 分页组件-->
<Pagination :continue="5"
            @change-page-no="changePageNo"
            :total="$store.state.product.searchProductResult.total"
            :pageNo="$store.state.product.searchProductResult.pageNo"
            :pageSize="$store.state.product.searchProductResult.pageSize"></Pagination>
</template>

<script>
export default {
    methods:{
        changePageNo(pageNo){
            console.log("changePageNo",pageNo);
            this.$router.push({
                path:"/search",
                query:{
                    ...this.$route.query,
                    pageNo
                }
            })
        }
    },
     watch:{
        "$route.query":{
            handler(query){
                // console.log("query",query);
                const {pageNo,pageSize} = this.$store.state.product.searchProductResult;
                // 指定页码以及每页显示的条数
                this.$store.dispatch("product/postProductListAsync",{
                    pageNo,
                    pageSize,
                    ...query
                })
            },
            immediate:true
        }
    },
}
</script>

调试补充:搜索条件变化要回到第一页 ​

为什么需要:转写稿中出现的 bug 是:已经点到第 2 页,再删除分类或切换排序,页码仍停在 2,列表可能为空或错位。
怎么想:搜索条件变了,原页码没有意义,应回到第 1 页。
怎么做:在删除分类、切换排序、切换标签等会改 query 的跳转里,合并 pageNo: 1;移除分类时只删除分类相关参数,保留品牌、标签等其他 query。展示“全部结果”时还要过滤掉 pageNo/pageSize 这类分页参数,避免被当成搜索条件。
易错点:不要为了删一个分类把整个 query 清空;也不要在条件变化后继续沿用旧页码。

问题现象:删掉分类标签后页码没有回到第一页 ​

为什么需要:搜索页的页码不是孤立状态,它依赖当前搜索条件。比如用户已经在第 15 页,此时删掉“台灯”这个分类标签,搜索结果集合已经变了,如果页码还停在 15,就可能出现空白页、页码错乱,或者用户以为“没有数据”。所以只要搜索条件发生更改,页码就应该回到第 1 页。

怎么想:不要直接改 this.$route.query。路由查询参数是当前地址的一部分,正确做法是先复制一份 query,在副本上删除与“类别标签”相关的字段,再通过 this.$router.push 跳转,并在新 query 中把 pageNo 覆盖成 1。这样既保留了关键词、品牌、排序等其他条件,又只移除当前要删掉的类别条件。

怎么做:在 src/pages/Search/index.vue 中处理移除类别标签:

js
// 移除类别标签
moveCategoryName(){
    // 1- category3Id=249&categoryName=台灯
    const query = {...this.$route.query};
    delete query.categoryName;
    delete query.category3Id;
    delete query.category2Id;
    delete query.category1Id;
    // console.log(this.$route.query)
    this.$router.push({
        path:"/search",
        query:{
            ...query,
            pageNo:1
        }
    })

},

这里的关键是 ...query 先把其他搜索条件带回来,后面的 pageNo:1 再覆盖旧页码。展开顺序不能反,否则旧页码可能又把 1 覆盖掉。

易错点:只删除 categoryName,却忘了删除 category1Id/category2Id/category3Id,URL 里仍会残留类别条件;直接写 this.$route.query.pageNo = 1,这只是改对象,不一定触发路由跳转;把整个 query 清空,会误删关键词、品牌、标签、排序等条件。转写稿里就出现过“删分类后所有条件都没了”的问题,原因是当时只保留了 keyword 一类字段,而不是“只删类别相关字段、保留其他字段”。

调试过程:为什么页码还停在第 2 页 ​

为什么需要:仅删除类别字段还不够。转写稿中演示过:先点到第 2 页,再删掉分类标签,页面仍然停在 pageNo=2,因为删除条件时没有显式重置页码。页码是 query 的一部分,不写 pageNo:1,它就会沿用旧值。

怎么想:把“条件变化”和“页码重置”看成一次完整导航:新条件 + 新页码。只要本次操作会改变结果集,就应该在跳转时把 pageNo 设为 1。

怎么做:如上面的代码,在 query 对象最后加 pageNo:1。调试时可以观察地址栏:删除前是 ?categoryName=台灯&pageNo=2,删除后应变成保留其他条件且 pageNo=1。

易错点:只在删除 categoryName 时重置页码,却漏掉删除 category3Id 等字段;或者把 pageNo 写在 ...query 前面,导致被旧值覆盖。另一个常见问题是分类点击、排序切换、搜索关键词变化时也要回第 1 页,转写稿提到排序和分类跳转同样要加 pageNo:1,否则也会出现“条件变了但页码没变”的 bug。

相关延伸:移除标签不要误删其他搜索条件 ​

为什么需要:搜索页往往同时有类别、品牌、关键词、标签、排序等条件。用户删“手机”这个类别,不应该把已经选中的品牌“vivo”或标签一起删掉。

怎么想:采用“白名单式删除”不如“黑名单式删除”稳妥:明确只删除类别相关字段,其他字段通过 ...query 保留。转写稿中对比过:早先只保留 keyword,会把其他条件全干掉;后来改成只删 categoryName 和各级 categoryId,其他条件就能保留。

怎么做:保持 const query = {...this.$route.query},只对类别字段执行 delete,再合并 pageNo:1 跳转。若分类点击也要保留其他条件,同样应在原 query 基础上合并新条件并重置页码,而不是重新构造一个只剩分类 id 的 query。

易错点:把“移除类别标签”写成“清空搜索条件”;忘记 delete 多个层级 id;跳转后没有检查地址栏和列表请求参数。调试时建议先看 URL 的 query,再看接口请求里的页码和条件,最后看列表是否回到第一页,这样能快速定位是“条件没删干净”还是“页码没重置”。

BUG现象:条件变了,页码却没回到第一页 ​

这个 BUG 的表现是:用户已经翻到第 15 页,此时删除某个分类标签、重新搜索,或点击“价格/综合”排序,列表条件已经变了,但请求里仍然带着旧的 pageNo=15,于是出现空列表、错页,或者看起来“筛选没生效”。

为什么需要:分页页码表示“在当前结果集中的第几页”。搜索词、标签、排序一旦变化,结果集就变了,旧页码没有意义。

怎么想:不要把它当成分页组件的问题,而要先看请求参数。转写稿里的复现过程很典型:在第 15 页删掉分类后,页面并没有回到第 1 页;点击排序后,页码也不是 1。根因就是条件变更时只改了搜索/标签/排序参数,没有同时重置当前页。

怎么做:先确认所有会改变查询条件的入口:搜索提交、标签添加/删除、排序切换。它们跳转或更新 query 时,都要带上当前页为 1。

易错点:只在搜索里加,漏掉标签和排序;或者只改了页面显示,没有改真正发请求的 query。

修复原则:改条件就回第一页 ​

本节笔记给出的方案很明确:

解决BUG方案:进行跳转时增加 pageNo:1

为什么需要:把规则统一成“条件变更 → 回第一页”,可以避免每个入口各写一套逻辑,也避免用户在新条件下停留在不存在的页码。

怎么想:翻页操作只改 pageNo;搜索、标签、排序属于“条件变更”,要同时把 pageNo 覆盖为 1。二者不要混在一起。

怎么做:在路由跳转时合并原 query,并覆盖 pageNo:

js
this.$router.push({
  path: '/search',
  query: {
    ...this.$route.query,
    pageNo: 1
  }
})

转写稿里说的“配置name/配置单”指的就是当前页参数,“配置s/配置袋子”指每页条数。实际项目里如果当前页参数叫 pageName,就按项目命名写,但本节笔记统一记为 pageNo:1。

易错点:对象展开顺序要注意。若写成 { pageNo: 1, ...this.$route.query },旧的 pageNo 可能把 1 覆盖掉。应让 pageNo: 1 放在展开之后。

三个入口逐一落地 ​

搜索:提交搜索词时,除了写搜索参数,还要加 pageNo: 1。

标签:添加或删除标签时也要加 pageNo: 1。转写稿还提到一个相关坑:删除标签时如果只保留 qword,会把品牌、标签等其他条件一起删掉;正确做法是只删除和当前类别相关的条件,保留其他 query,并补上 pageNo: 1。

排序:点击综合、价格等排序入口时,同样在 query 中加 pageNo: 1。示意如下,排序参数按项目实际字段填:

js
this.$router.push({
  path: '/search',
  query: {
    ...this.$route.query,
    // 这里放本次变更的搜索/标签/排序参数
    pageNo: 1
  }
})

易错点:标签删除不要误删其他筛选条件;排序切换不要只改排序字段;全部结果统计要过滤掉 pageNo、pageSize 这类分页参数,否则“全部结果”数量会被页码影响。转写稿中检查“全部结果”时也发现要把 pageNo 过滤掉。

调试与验证 ​

为什么需要:这个 BUG 很容易被“页面看起来变了”迷惑,必须看请求参数。

怎么想:按“复现 → 看 query → 看请求 → 改跳转处 → 回归三个入口”的顺序排查。

怎么做:在第 15 页删除分类,打开 Network 看请求里的 pageNo 是否变成 1;再点价格排序,看是否回第一页;再删标签,确认其他条件还在且 pageNo=1。最后点分页组件的页码,确认翻页时不会被错误重置。

易错点:分页组件点击页码是正常翻页,不应强制回 1;连续页码的边界优化不是本 BUG 的主修复,别把两件事混在一起。验证时还要注意:搜索、标签、排序三个入口都要覆盖,不能只修一个。

小结 ​

本节的修复可以压缩成一句话:凡是会改变搜索条件的跳转,都增加 pageNo: 1。这样搜索、切换标签、排序后都会从第一页重新请求,旧页码导致空列表或错页的问题就解决了。

先复现:点击分类为什么把标签和排序也删了 ​

为什么需要:分页、分类、标签、排序都放在 URL 的 query 里。点击分类时如果直接覆盖 query,其他搜索条件就会丢失,用户会看到“只删了手机,vivo、苹果、4G 全没了”。
怎么想:先按转写稿复现:选手机 → 选 vivo → 再点第二页;然后删掉手机分类。此时不仅分类没了,vivo 也没了。再试“手机 → 苹果 → 4G”,删手机后苹果和 4G 同样消失。说明不是单个字段问题,而是移除条件时把整个 query 重写了。
怎么做:打开处理“移除内部标签”的方法,检查它是否只保留了 qWord 之类的单个字段。转写稿里明确说:“当时是不是只保留了一个 Qword,其他是不是都没有保留。”
易错点:不要把“删除分类”写成“重建 query”。删除一个条件,应当是“复制原 query → 删除分类相关字段 → 跳转”,而不是 query = { qWord: ... }。

根因定位:移除条件时不能整体覆盖 query ​

为什么需要:URL query 是多个搜索条件的共同载体,分类只是其中一类。
怎么想:把 query 看成一张表:qWord、分类、品牌、标签、排序、pageName 各占一列。删分类只能删分类列,不能把整张表换掉。
怎么做:保留原 query,只删和类别相关的参数。关键代码示意如下,字段名按你项目实际替换:

js
// 移除分类条件时:保留其它搜索条件
const query = { ...this.$route.query }
// 只删除分类/类别相关字段
delete query.categoryName
// 如有多个类别字段,一并删除
// delete query.categoryId

this.$router.push({ path: '/search', query })

易错点:不要写成 this.$router.push({ query: { qWord: this.qWord } }),这样标签、排序、页码都会被清掉。转写稿里验证过:改成“只删类别相关,保留其他”后,删手机时 vivo 还在,苹果、4G 也还在。

连带问题:搜索条件变化后页码必须回到第一页 ​

为什么需要:用户在第 3 页删掉分类后,结果集已经变了,仍停在第 3 页可能空白或错位。转写稿的调试现象是:在第 2 页删条件,页码仍是 2;排序切“综合/价格”时也没有回第一页。
怎么想:凡是会改变结果集的操作——删分类、切标签、改排序、改搜索词——都应该把 pageName 重置为 1。
怎么做:在跳转时合并原 query,并显式写入 pageName: 1:

js
this.$router.push({
  path: '/search',
  query: {
    ...this.$route.query,
    pageName: 1
  }
})

排序切换同理,在原有排序参数基础上加 pageName: 1。转写稿还提到:点击分类跳转时不要只保留部分项,要写 ...query 保留原条件,再加分类参数和 pageName: 1。
易错点:只加分类参数、不写 pageName,页码不会自动回第一页;只写 pageName: 1、不写 ...query,又会把标签和排序删掉。

收尾:全部结果计数要过滤 pageName 与空值 ​

为什么需要:转写稿里删完条件后,“全部结果”区域仍显示异常,原因是计数时把 pageName 也当成了搜索条件,甚至把没有值的项也算进去。
怎么想:统计“全部结果”时,只统计真正的筛选条件;pageName 是分页参数,不算筛选条件;值为 undefined 的项也不应参与。
怎么做:在过滤逻辑中排除 pageName 和空值:

js
const keys = Object.keys(this.$route.query).filter(key => {
  return key !== 'pageName' && this.$route.query[key]
})

易错点:不要只过滤“没有值”的项,还要显式排除 pageName。转写稿的结论是:把 pageName 过滤掉后,全部结果区域才恢复正常。

作业:把面包屑导航抽成组件 ​

为什么需要:面包屑要在多个搜索页复用,继续写在页面里会重复且难维护。
怎么想:组件只负责“展示当前条件 + 触发删除事件”,不直接写死跳转逻辑;删除后由父组件统一更新 query,并补 pageName: 1。
怎么做:父组件传入当前条件列表,子组件点击某项时 $emit('remove', 条件字段);父组件收到后复制原 query、删除对应字段、跳转。
易错点:不要在面包屑组件里直接整体覆盖 query;也不要把分类删除写成只保留 qWord,否则本节 BUG3 会再次出现。

课堂画面 ​

课堂画面 1

课堂画面 2

课堂画面 3

课堂画面 4

课堂画面 5

代码示例 ​

vue
<!-- src/components/Pagination/index.vue -->
<template>
  <div class="pagination">
    <button
      :disabled="pageNo === 1"
      @click="$emit('change-page-no', pageNo - 1)"
    >
      上一页
    </button>

    <!-- 起始页大于1时显示首页;大于2时显示左侧省略号 -->
    <button
      v-show="startAndEnd.start > 1"
      @click="$emit('change-page-no', 1)"
    >
      1
    </button>
    <span v-show="startAndEnd.start > 2">···</span>

    <!-- 连续页码:startAndEnd.start + index -->
    <button
      v-for="(_, index) in (startAndEnd.end - startAndEnd.start + 1)"
      :key="startAndEnd.start + index"
      :class="{ active: pageNo === startAndEnd.start + index }"
      @click="$emit('change-page-no', startAndEnd.start + index)"
    >
      {{ startAndEnd.start + index }}
    </button>

    <!-- 右侧省略号与尾页 -->
    <span v-show="pageSum > startAndEnd.end + 1">···</span>
    <button
      v-show="pageSum > startAndEnd.end"
      @click="$emit('change-page-no', pageSum)"
    >
      {{ pageSum }}
    </button>

    <button
      :disabled="pageNo === pageSum"
      @click="$emit('change-page-no', pageNo + 1)"
    >
      下一页
    </button>

    <span>共 {{ total }} 条</span>
  </div>
</template>

<script>
export default {
  name: "Pagination",
  props: ["total", "pageSize", "continue", "pageNo"],
  computed: {
    pageSum() {
      return Math.ceil(this.total / this.pageSize);
    },
    startAndEnd() {
      let start = 0;
      let end = 0;

      // 连续页数大于总页数时,直接显示 1 ~ pageSum
      if (this.continue > this.pageSum) {
        start = 1;
        end = this.pageSum;
      } else {
        start = this.pageNo - (this.continue - 1) / 2;
        end = this.pageNo + (this.continue - 1) / 2;

        // 左侧越界:夹紧到第1页
        if (start < 1) {
          start = 1;
          end = start + this.continue - 1;
        }

        // 右侧越界:夹紧到最后一页
        if (end > this.pageSum) {
          end = this.pageSum;
          start = this.pageSum - this.continue + 1;
        }
      }

      return { start, end };
    }
  }
};
</script>

<style scoped>
.pagination button.active {
  color: red;
}
.pagination button:disabled {
  cursor: not-allowed;
}
</style>

要点解释:

  • pageSum = Math.ceil(total / pageSize) 计算总页数,是连续页码边界判断的基础。
  • startAndEnd 先按 pageNo ± (continue - 1) / 2 计算左右边界,因此 continue 一般传奇数,保证当前页左右对称。
  • 必须处理三种边界:continue > pageSum、start < 1、end > pageSum,否则会渲染出不存在的页码,例如总页数 4 却渲染 14~18。
  • v-for 使用 (_, index) 时,页码是 startAndEnd.start + index,不要直接拿 index 当页码。
  • 首尾页、省略号用 v-show 控制;转写稿特别提醒不要 v-for 与 v-if 同级混用,避免先渲染再卸载造成无用功。
  • 点击页码通过 $emit('change-page-no', ...) 交给父组件处理,分页组件内部不要直接写 $router.push,否则组件复用性变差。
vue
<!-- src/pages/Search/index.vue -->
<template>
  <div class="search-page">
    <Pagination
      :continue="5"
      @change-page-no="changePageNo"
      :total="$store.state.product.searchProductResult.total"
      :pageNo="$store.state.product.searchProductResult.pageNo"
      :pageSize="$store.state.product.searchProductResult.pageSize"
    />
  </div>
</template>

<script>
import Pagination from "@/components/Pagination/index.vue";

export default {
  name: "Search",
  components: { Pagination },
  methods: {
    changePageNo(pageNo) {
      console.log("changePageNo", pageNo);
      this.$router.push({
        path: "/search",
        query: {
          ...this.$route.query,
          pageNo
        }
      });
    },

    // 移除类别标签:只删除 category 相关参数,并回到第1页
    moveCategoryName() {
      const query = { ...this.$route.query };
      delete query.categoryName;
      delete query.category3Id;
      delete query.category2Id;
      delete query.category1Id;

      this.$router.push({
        path: "/search",
        query: {
          ...query,
          pageNo: 1
        }
      });
    }
  },
  watch: {
    "$route.query": {
      handler(query) {
        const { pageNo, pageSize } =
          this.$store.state.product.searchProductResult;

        this.$store.dispatch("product/postProductListAsync", {
          pageNo,
          pageSize,
          ...query
        });
      },
      immediate: true
    }
  }
};
</script>

要点解释:

  • @change-page-no="changePageNo" 接收分页组件抛出的页码,再通过路由 query 更新页码。
  • query: { ...this.$route.query, pageNo } 保留原有搜索条件,只覆盖 pageNo;不要直接清空 query,否则标签、排序等条件会丢失。
  • moveCategoryName 中只删除 categoryName / category3Id / category2Id / category1Id,并加 pageNo: 1;转写稿指出不能只保留 keyword,否则会把品牌、标签等其它条件一起删掉。
  • 搜索条件发生更改时页码应回到第 1 页,因此跳转时增加 pageNo: 1;排序、标签切换同理也要加 pageNo: 1。
  • watch "$route.query" 使用 immediate: true,首次进入页面也会派发请求;...query 放在后面,URL 中的 pageNo 会覆盖 store 中的默认页码。
  • 易错点:total 为 0 时 pageSum 为 0,本段沿用笔记逻辑,业务上需要额外兜底,避免显示“下一页”等异常状态。

常见错误 ​

  1. 总页数小于连续页码数时仍按居中计算
    现象→页码出现 14~18、0~4 等不合理区间。原因→未先判断 continue > pageSum。修法→直接令 start=1、end=pageSum,或先取 continue=Math.min(continue,pageSum)。

  2. 当前页靠前/靠后时区间越界
    现象→出现 0、负数页码或大于总页数的按钮。原因→只做居中计算,未钳制 start/end。修法→start<1 时令 start=1、end=start+continue-1;end>pageSum 时令 end=pageSum、start=pageSum-continue+1。

  3. 用 v-for + v-show 过滤页码
    现象→DOM 中先渲染全部页码再隐藏,调试可见大量隐藏按钮。原因→遍历完整范围后再隐藏,做了无用渲染。修法→用 startAndEnd 区间配合 v-for 只渲染连续页;不要靠 v-show 做页码过滤。

  4. 在 Pagination 内直接写 this.$router.push
    现象→组件只能在 Search 页使用,换订单页需改组件源码。原因→业务跳转写死在展示组件。修法→组件内只 $emit('change-page-no', pageNo),由父组件处理路由与请求。

  5. 搜索条件、品牌、标签、排序变化后未重置页码
    现象→结果变少后列表为空,或页码仍停在旧页。原因→合并 query 时保留旧 pageNo。修法→所有条件变更的跳转显式加 pageNo:1,或统一走 resetPageNo(query)。

  6. 移除分类标签时重建 query 或删除全部 query
    现象→删分类后品牌、标签、排序一起消失。原因→移除逻辑覆盖了整个 query。修法→const query={...this.$route.query} 后只 delete categoryName/category1Id/category2Id/category3Id,再 push({...query,pageNo:1})。

  7. “全部结果”计数未过滤分页参数与空值
    现象→条件数里多出 pageNo、pageSize 或 undefined。原因→直接统计 query 的 key。修法→过滤 pageNo/pageSize 及 undefined/null/'' 后再计数。

练习 ​

  1. 为 startAndEnd 写边界验收矩阵:total=100,pageSize=30,continue=5 时 pageNo=1/2/3/4 的期望值;total=100,pageSize=10,continue=5 时 pageNo=1/2/9/10 的期望值。要求覆盖 continue>pageSum、start<1、end>pageSum 三类情况。
  2. 把笔记中 v-for + v-show 的页码方案重构为区间 v-for,保留 active、首尾 1、首尾 pageSum、左右省略号的显示逻辑,并写出各 v-show 条件。
  3. 将 Pagination 改为受控组件:删除组件内路由跳转,只保留 change-page-no 事件;在父组件 Search 中实现 changePageNo(pageNo),跳转时保留原 query 并覆盖 pageNo。
  4. 实现统一重置页码:写一个 resetPageNo(query) 辅助函数,并列出品牌切换、标签切换、排序切换、搜索提交、移除分类标签五处需要调用它的位置。
  5. 修复“全部结果”计数:写一个过滤函数,排除 pageNo、pageSize 以及值为 undefined/null/'' 的 query 字段,并用至少 3 组 query 验证。

原视频对照 ​

#视频时长
1计算连接页码的起始与结束位置09:04
2渲染连续页码10:57
3优化连续页码25:39
4实现页码功能17:30
5解决BUG15:57

小结 ​

  1. 分页组件验收先跑边界矩阵:首页、末页、总页数小于 continue、当前页靠前、当前页靠后。
  2. startAndEnd 必须始终满足 1 <= start <= end <= pageSum,否则页码按钮一定出问题。
  3. continue 建议约束为奇数,并在组件入口做兜底,避免非整数区间。
  4. 展示组件不碰路由;页码变化只通过事件向上抛,父组件决定跳转与请求。
  5. 任何搜索条件变更都要统一重置 pageNo=1,不要依赖旧 query 残留。
  6. 省略号与首尾页按钮应由 start/end/pageSum 联动控制,不要用隐藏 DOM 的方式凑效果。
  7. 复用检查:Pagination 只依赖 total/pageSize/continue/pageNo 和 change-page-no 事件即可工作。
  8. 面包屑、条件计数等派生 UI 要过滤分页参数和空值,避免把 pageNo 当成搜索条件展示。