结合 Bootstrap + Vue 组件在 Laravel 中实现异步分页功能


通过前面的系列教程,我们已经介绍完了 Laravel 框架支持的所有对数据库相关基础功能。在日常开发中,对数据库查询结果进行分页也是一个非常常见的需求,我们可以基于之前介绍的查询方法和前端 HTML 视图实现分页功能,不过从 Laravel 5.3 开始,Laravel 框架就已经为我们提供了非常完整的分页解决方案,包括后端 API 和前端视图。不管你使用查询构建器还是 Eloquent 模型类,都可以在一分钟内完成分页功能,Laravel 还为我们提供了丰富的自定义支持,不管是后端的分页器,前端的分页链接,还是整个分页视图,都可以按需进行定制化开发,非常方便。

关于如何使用 Laravel 自带的分页功能进行分页,可以参考官方文档中的分页章节,说的非常清楚,在这篇教程中我们就不再一一演示了,不过 Laravel 自带的分页器实现的分页链接是动态 URL,不利于 SEO,如果你想要实现伪静态的分页链接,可以参考这篇教程:通过自定义分页器实现伪静态分页链接以利于 SEO

这篇教程我们将着重探讨如何结合 Bootstrap 和 Vue 组件实现异步分页功能,补充官方文档中没有实现的细节。

定义后端 API 接口

由于我们要实现的是基于 Vue 的异步分页组件,所以我们需要在后端定义好分页数据获取 API 接口。以文章首页列表为例,先准备好一个资源控制器 PostController 并定义好对应路由,而这些工作我们已经在控制器教程中已经做好。

除此之外,还要在 PostController 控制器中定义一个 fetch 方法用于异步获取分页数据:

public function fetch()
{
    // 每页显示6篇文章,如果页码太多,当前页码左右只显示2个页码
    $posts = Post::paginate(6)->onEachSide(2)->withPath(url('post'));
    // 处理页码及对应分页URL(页码过多,部分页码省略)
    $window = UrlWindow::make($posts);
    $pages = array_filter([
        $window['first'],
        is_array($window['slider']) ? '...' : null,
        $window['slider'],
        is_array($window['last']) ? '...' : null,
        $window['last'],
    ]);
    return response()->json([
        'paginator' => $posts,
        'elements' => $pages
    ]);
}

我们会在分页组件中显示分页链接,所以调用 paginate 方法进行分页,每页显示 6 篇文章,然后调用 onEachSide 方法指定页码过多时,只在当前页码左右各显示两个页码,做后我们还要通过 withPath 方法指定真正的分页请求路由。此外,我们参考了 Laravel 自带分页器显示分页链接的方法,将其逻辑移到这里,主要用于处理页码及对应分页 URL,以及页码过多时,隐藏部分页码。最后,我们将分页数据以 JSON 格式返回给调用方进行处理。

然后,我们在 routes/api.php 中定义一个指向该控制器方法的 API 路由:

Route::get('/posts/fetch', 'PostController@fetch');

这样,我们就可以测试下后端这个 API 接口了,在浏览器中请求 http://blog.test/api/posts/fetch,返回 JSON 格式数据如下:

paginator 对应字段描述信息如下:

  • current_page:当前页,默认为1
  • data:当前页文章数据数字,遍历该字段在列表页渲染文章数据
  • first_page_url:第一页链接
  • last_page_url:最后一页链接
  • pre_page_url:上一页链接(没有则为 null)
  • next_page_url:下一页链接(没有则为 null)
  • path:页面 URL(不带请求参数)
  • last_page:最后一页的页码(循环设置分页码时用到)
  • per_page:每页显示文章数
  • from:当前页起始文章 ID
  • to:当前页终止文章 ID
  • total:文章总数量

elements 中包含的是页面与对应页面URL之间的映射关系,如果页码很多时,返回数据格式如下(中间部分页码省略):

我们在 Vue 组件中将基于以上 JSON 数据渲染文章列表和分页挂件。

然后我们在该控制器的文章首页列表方法 index 中,返回一个视图用于渲染文章列表:

public function index()
{
    return view('post.index');
}

这样,后端接口和路由都已经准备好了,接下来我们到前端编写视图文件和 Vue 组件。

创建文章列表视图

首先我们来创建文章列表视图,在 resources/views 目录下创建一个子目录 post,然后在该目录下创建视图文件 index.blade.php,并编写视图代码如下:

<!doctype html>
<html lang="{{ app()->getLocale() }}">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="csrf-token" content="{{ csrf_token() }}">

    <title>Laravel分页组件</title>

    <link href="{{ asset('css/app.css')  }}" rel="stylesheet">
</head>
<body>
    <div id="app">
        <pagination-component page-type="posts"></pagination-component>
    </div>
    <script src="{{ asset('js/app.js') }}"></script>
</body>
</html>

在这个视图中,我们初始化了页面布局,并且引入 /css/app.css/js/app.js 文件,最后在主体部分通过以下代码引入 Vue 分页组件:

<div id="app">
    <pagination-component page-type="posts"></pagination-component>
</div>

我们通过 pagination-component 引入分页组件,并且从当前页面传递参数 page-type 到组件中,从而提高了组件的复用性,实际上,除了文章列表之外,你还可以将这个组件应用到评论、用户等所有其它需要分页的地方。另外,div#app 元素不能省略,因为 Vue 组件默认配置为挂载到 #app 元素上。

目前,我们在视图文件中没有编写任何可视化的代码,所有文章渲染和分页链接功能都将集成到 Vue 组件中完成,接下来,就让我们来编写这个 Vue 组件。

创建 Vue 分页组件

resources/js/components 目录下创建一个新的 Vue 组件 PaginationComponent.vue,并初始化代码如下:

<style scoped>

</style>

<template>
    
</template>

<script>
    export default {}
</script>

然后,我们在 resources/js/app.js 中注册这个组件,以便在视图文件中生效:

Vue.component('pagination-component', require('./components/PaginationComponent.vue'));

此时编译前端资源,文章列表页还是空的,因为我们的组件还没有渲染任何内容,回到 PaginationComponent.vue,编写组件代码如下:

<style scoped>
    .container .row{
        margin-top: 10px;
    }
</style>

<template>
    <div class="container">
        <div class="row">
            <ul class="posts list-group">
                <li v-for="post in paginator.data" class="list-group-item">
                    {{ post.title }}
                </li>
            </ul>
        </div>

        <div class="row text-center">
            <ul class="pagination" role="navigation" v-if="paginator">

                <li class="page-item" v-if="paginator.prev_page_url">
                    <a class="page-link" v-bind:href="paginator.prev_page_url" rel="prev">‹</a>
                </li>
                <li class="page-item disabled" aria-disabled="true" v-else>
                    <span class="page-link" aria-hidden="true">‹</span>
                </li>

                <template v-for="element in elements">
                    <template v-if="element === '...'">
                    <li class="page-item disabled" aria-disabled="true">
                        <span class="page-link">{{ element }}</span>
                    </li>
                    </template>
                    <template v-for="(url, page) in element" v-else>
                        <li class="page-item active" aria-current="page" v-if="page == paginator.current_page">
                            <span class="page-link">{{ page }}</span>
                        </li>
                        <li class="page-item" v-else-if="page > 0">
                            <a class="page-link" v-bind:href="url">{{ page }}</a>
                        </li>
                    </template>
                </template>

                <li class="page-item" v-if="paginator.next_page_url">
                    <a class="page-link" v-bind:href="paginator.next_page_url" rel="next">›</a>
                </li>
                <li class="page-item disabled" aria-disabled="true" v-else>
                    <span class="page-link" aria-hidden="true">›</span>
                </li>
            </ul>
        </div>
    </div>

</template>

<script>
    export default {
        props: ['pageType'],
        data() {
            return {
                paginator: {},
                elements: []
            }
        },
        created() {
            this.fetchPaginationData();
        },
        methods: {
            fetchPaginationData() {
                axios.get('/api/' + this.pageType + '/fetch', {
                    params: {
                        page: this.getQueryString('page')
                    }
                }).then(function (response) {
                    this.paginator = response.data.paginator;
                    this.elements = response.data.elements;
                }.bind(this)).catch(function () {
                    console.log('获取分页数据失败');
                });
            },
            getQueryString(name) {
                let reg = `(^|&)${name}=([^&]*)(&|$)`;
                let r = window.location.search.substr(1).match(reg);
                if (r != null)
                    return unescape(r[2]);
                return 1;
            }
        }
    }
</script>

由于我们引入了 Bootstrap CSS 框架(Bootstrap 4),所以编写模板代码的时候,都遵循了 Bootstrap 的默认约定,以便渲染的时候生效。关于 Vue 组件的基本结构,我们在编写第一个Vue组件教程中已经讨论过,这个分页组件比我们之前编写的 Vue 组件都要复杂一些,我们在这个组件中应用了更多的 Vue 特性,包括从父视图中传入属性,定义模型属性,在模板中动态绑定数据,以及列表渲染等。下面我们简单讲一下这几个功能特性。

使用prop传递属性

我们在父视图中声明组件的时候传递了一个属性 page-type 到组件,用于标识该组件应用的页面类型,然后在组件中,我们可以通过 props 声明从父视图/组件中传递进来的属性(转化为驼峰格式,以便在 JavaScript 代码中使用)。这样,就可以在组件中通过对应的属性名访问属性值了,在 JavaScript 代码中使用需要加上 this. 前缀。比如在此例中,我们将该属性用于请求分页数据接口 URL 的拼接,获取对应资源的分页数据。

动态设置组件模型属性

我们可以将组件用到的动态数据设置为模型属性,这些属性值发生变更后会实时更新引用它的视图元素,反之视图元素输入值的变更也会同步到模型属性,这称之为双向绑定,通过这个特性可以大大提高编写客户端代码的效率。在本例中,我们就用到这个特性,比如我们设置了两个模型属性 paginatorelements,分别用于装载接口返回分页数据和组装分页页码及对应URL数据。

我们会在组件 created 阶段调用 fetchPaginationData() 方法初始化这两个属性,代码比较简单,需要注意的是,这里我们会根据当前页面 URL 中的 page 参数动态获取分页数据,因为不同页码返回的分页数据是不一样的。

通过列表渲染显示分页数据和链接

在设置好 paginatorelements 属性值之后,就可以在模板中通过列表渲染和动态绑定显示文章信息和分页信息了,具体可以查看 template 标签中的代码,这部分的功能和 Laravel 自带的分页视图 vendor/laravel/framework/src/Illuminate/Pagination/resources/views/bootstrap-4.blade.php 实现功能完全一致,只不过将其转化为 Vue 组件来实现。如果你对相应的 Vue 语法不熟悉,请参考 Vue 中文文档

测试异步分页组件

接下来,我们来测试下这个分页组件。在此之前,先运行 npm run dev 重现编译前端资源让分页组件生效。然后在浏览器访问 http://blog.test/post,页面显示如下:

访问其它页码:

F12 打开开发者工具栏,可以在「Network」中看到对分页数据接口的异步请求:

或者在「Vue」中查看 Vue 组件数据:

如果调整为每页显示3篇文章,则可以测试下页码过多时的显示效果:

至此,我们的异步分页组件就编写完成了,你还可以将其复用到其他资源的异步分页功能中。


点赞 取消点赞 收藏 取消收藏

<< 上一篇: 进阶篇(九):Eloquent 模型关联关系(下)

>> 下一篇: 在 Laravel 中通过自定义分页器分页方法实现伪静态分页链接以利于 SEO