38.Vue 分页
- 本系列文章为
laracasts.com
的系列视频教程——Let's Build A Forum with Laravel and TDD 的学习笔记。若喜欢该系列视频,可去该网站订阅后下载该系列视频, 支持正版 ;- 视频源码地址:github.com/laracasts/Lets-Build-a-...;
- 本项目为一个 forum(论坛)项目,与本站的第二本实战教程 《Laravel 教程 - Web 开发实战进阶》 类似,可互相参照。
本节说明
- 对应视频教程第 38 小节:Laravel and Vue Pagination
本节内容
首先我们来看一个 Bug:
是的,我们未能在组件中删除选定的回复。修复的方法很简单,我们为每个回复绑定一个独特的id
即可:
forum\resources\assets\js\components\Replies.vue
<template>
<div>
<div v-for="(reply ,index) in items" :key="reply.id">
<reply :data="reply" @deleted="remove(index)"></reply>
</div>
<new-reply :endpoint="endpoint" @created="add"></new-reply>
</div>
</template>
.
.
我们再来一次:
我们再来优化一下我们的日期显示。我们利用 Moment.js 插件来进行优化,首先进行安装:
$ npm install moment --save
接着我们在Reply.vue
组件中应用:
<template>
.
.
<h5 class="flex">
<a :href="'/profiles/'+data.owner.name"
v-text="data.owner.name">
</a> said <span v-text="ago"></span>
</h5>
.
.
</template>
<script>
import Favorite from './Favorite.vue';
import moment from 'moment';
.
.
computed: {
ago() {
return moment(this.data.created_at).fromNow() + '...';
},
.
.
</script>
现在再来看一下页面效果:
好了,现在我们正式开始本节的内容:实现 Vue 分页功能。首先修改视图:
forum\resources\views\threads\show.blade.php
.
.
<replies @added="repliesCount++" @removed="repliesCount--"></replies>
</div>
.
.
接着修改组件:
forum\resources\assets\js\components\Replies.vue
.
.
<script>
import Reply from './Reply';
import NewReply from './NewReply';
export default {
components: { Reply,NewReply },
data() {
return {
items:[],
endpoint: location.pathname+'/replies'
}
},
created() {
this.fetch();
},
methods: {
fetch() {
axios.get(this.url)
.then(this.refresh);
},
refresh(response) {
// To Do
},
add(reply){
this.items.push(reply);
this.$emit('added');
},
remove(index) {
this.items.splice(index,1);
this.$emit('removed');
flash('Reply has been deleted!');
}
}
}
</script>
我们重新定义了视图和组件,目的是为了实现利用Ajax
的方式给回复进行分页。我们在组件中发送了请求,但是我们现在还没有相应的路由。让我们来添加路由:
forum\routes\web.php
.
.
Route::get('threads/{channel}','ThreadsController@index');
Route::get('/threads/{channel}/{thread}/replies','RepliesController@index');
Route::post('/threads/{channel}/{thread}/replies','RepliesController@store');
.
.
我们利用了index()
方法来获取当前页话题的数据,但是目前我们的方法还没有定义。在开始编写我们的功能代码之前,让我们按照开发模式,先建立测试:
forum\tests\Feature\ReadThreadsTest.php
.
.
/** @test */
public function a_user_can_request_all_replies_for_a_given_thread()
{
$thread = create('App\Thread');
create('App\Reply',['thread_id' => $thread->id],2);
$response = $this->getJson($thread->path() . '/replies')->json();
dd($response);
}
}
运行测试:
显示报错,原因是我们利用Auth
中间件进行了权限控制:
forum\app\Http\Controllers\RepliesController.php
.
.
class RepliesController extends Controller
{
public function __construct()
{
$this->middleware('auth');
}
.
.
我们的index()
不要要登录就能访问,所以我们修改一下:
.
.
class RepliesController extends Controller
{
public function __construct()
{
$this->middleware('auth',['except' => 'index']);
}
.
.
再次测试:
ok,接下来的工作:建立index()
方法。
forum\app\Http\Controllers\RepliesController.php
.
.
class RepliesController extends Controller
{
public function __construct()
{
$this->middleware('auth',['except' => 'index']);
}
public function index($channelId,Thread $thread)
{
return $thread->replies()->paginate(1);
}
.
.
再次运行测试:
既然已经可以看到返回的全部信息,那么我们就可以逐步完善测试:
.
.
/** @test */
public function a_user_can_request_all_replies_for_a_given_thread()
{
$thread = create('App\Thread');
create('App\Reply',['thread_id' => $thread->id],2);
$response = $this->getJson($thread->path() . '/replies')->json();
$this->assertCount(1,$response['data']);
$this->assertEquals(2,$response['total']);
}
.
.
再次运行测试:
注:为了功能的推进,我们对
index()
方法只是暂时定义,后面还需要进行修改。
现在我们继续修改Replies.vue
组件:
forum\resources\assets\js\components\Replies.vue
.
.
methods: {
fetch() {
axios.get(this.url())
.then(this.refresh);
},
url() {
return `${location.pathname}/replies`;
},
refresh(response) {
console.log(response);
},
.
.
我们先来看一下response
的内容:
接着继续完善组件:
forum\resources\assets\js\components\Replies.vue
.
.
<script>
import Reply from './Reply';
import NewReply from './NewReply';
export default {
components: { Reply,NewReply },
data() {
return {
dataSet:false,
items:[],
endpoint: location.pathname+'/replies'
}
},
created() {
this.fetch();
},
methods: {
fetch() {
axios.get(this.url())
.then(this.refresh);
},
url() {
return `${location.pathname}/replies`;
},
refresh({data}) {
this.dataSet = data;
this.items = data.data;
},
.
.
}
}
</script>
再次刷新页面:
现在我们已经可以看到回复区域,现在我们可以修改ThreadsController.php
:
forum\app\Http\Controllers\ThreadsController.php
.
.
public function show($channel,Thread $thread)
{
return view('threads.show',compact('thread'));
}
.
.
按照正常逻辑,下一步我们应该加上分页链接。但是在此之前,我们先来做一些重构:我们将组件的add
和remove
方法抽取出去。类似于 Trait,我们将add
和remove
方法放在Collection.js
文件中:
forum\resources\assets\js\mixins\Collection.js
export default {
data() {
return {
items: []
};
},
methods: {
add(item){
this.items.push(item);
this.$emit('added');
},
remove(index) {
this.items.splice(index,1);
this.$emit('removed');
flash('Reply has been deleted!');
}
}
}
然后我们在组件中引入:
forum\resources\assets\js\components\Replies.vue
<template>
<div>
<div v-for="(reply ,index) in items" :key="reply.id">
<reply :data="reply" @deleted="remove(index)"></reply>
</div>
<new-reply :endpoint="endpoint" @created="add"></new-reply>
</div>
</template>
<script>
import Reply from './Reply';
import NewReply from './NewReply';
import collection from '../mixins/Collection';
export default {
components: { Reply,NewReply },
mixins: [collection],
data() {
return {
dataSet:false,
endpoint: location.pathname+'/replies'
}
},
created() {
this.fetch();
},
methods: {
fetch() {
axios.get(this.url())
.then(this.refresh);
},
url() {
return `${location.pathname}/replies`;
},
refresh({data}) {
this.dataSet = data;
this.items = data.data;
}
}
}
</script>
我们来测试一下之前的功能:
让我们继续完成分页功能。我们把分页部分定义成一个可复用的组件,因为在话题显示页面我们也会用到这个组件:
forum\resources\assets\js\components\Paginator.vue
<template>
<ul class="pagination" v-if="shouldPaginate">
<li v-show="prevUrl">
<a href="#" aria-label="Previous" rel="prev" @click.prevent="page--">
<span aria-hidden="true">« Previous</span>
</a>
</li>
<li v-show="nextUrl">
<a href="#" aria-label="Next" rel="next" @click.prevent="page++">
<span aria-hidden="true">Next »</span>
</a>
</li>
</ul>
</template>
<script>
export default {
props: ['dataSet'],
data() {
return {
page:1,
prevUrl:'',
nextUrl:''
}
},
watch: {
dataSet() {
this.page = this.dataSet.current_page;
this.prevUrl = this.dataSet.prev_page_url;
this.nextUrl = this.dataSet.next_page_url;
},
page() {
this.broadcast().updateUrl();
}
},
computed: {
shouldPaginate() {
return !! this.prevUrl || !! this.nextUrl;
}
},
methods: {
broadcast() {
return this.$emit('changed',this.page);
},
updateUrl() {
history.pushState(null,null,'?page=' + this.page);
}
}
}
</script>
在我们的组件中,当shouldPaginate
为true
时才会显示分页区域。我们给 Previous 按钮赋值为prevUrl
,同时绑定了click
事件,当该事件触发时,page
变量的值减 1;给 Next 按钮赋值nextUrl
,同时绑定了click
事件,当该事件触发时,page
变量的值加 1。
我们利用 侦听器(watch) 来监控dataSet
属性,一旦属性值发生变化,我们会给page
、prevUrl
和nextUrl
重新赋值,然后会触发page
函数。在该函数中,我们依次执行broadcast
和updateUrl
:在broadcast
中,我们绑定changed
事件,以便让父组件监听到,然后进行翻页相关的动作;在updateUrl
中,我们将page
参数发送给父组件。
接着我们注册该组件:
forum\resources\assets\js\app.js
.
.
Vue.component('flash', require('./components/Flash.vue'));
Vue.component('paginator', require('./components/Paginator.vue'));
Vue.component('thread-view', require('./pages/Thread.vue'));
.
.
接下来我们需要修改父组件,即Replies.vue
组件:
forum\resources\assets\js\components\Replies.vue
<template>
<div>
<div v-for="(reply ,index) in items" :key="reply.id">
<reply :data="reply" @deleted="remove(index)"></reply>
</div>
<paginator :dataSet="dataSet" @changed="fetch"></paginator>
<new-reply @created="add"></new-reply>
</div>
</template>
<script>
import Reply from './Reply';
import NewReply from './NewReply';
import collection from '../mixins/Collection';
export default {
components: { Reply,NewReply },
mixins: [collection],
data() {
return { dataSet:false }
},
created() {
this.fetch();
},
methods: {
fetch(page) {
axios.get(this.url(page)).then(this.refresh);
},
url(page) {
if (! page) {
let query = location.search.match(/page=(\d+)/);
page = query ? query[1] : 1;
}
return `${location.pathname}/replies?page=${page}`;
},
refresh({data}) {
this.dataSet = data;
this.items = data.data;
}
}
}
</script>
我们取消了为NewReply.vue
绑定endpoint
属性,所以我们要进行相应修改:\resources\assets\js\components\NewReply.vue
.
.
<script>
export default {
data() {
return {
body:'',
};
},
computed: {
signIn() {
return window.App.signIn;
}
},
methods: {
addReply() {
axios.post(location.pathname + '/replies',{ body:this.body })
.then(({data}) => {
this.body = '';
flash('回复已提交!');
this.$emit('created',data);
});
}
}
}
</script>
在Replies.vue
组件中,我们为Paginator.vue
组件绑定了dataSet
属性,并且监听changed
事件:
<paginator :dataSet="dataSet" @changed="fetch"></paginator>
一旦监听到changed
事件,就会触发fetch
方法。在fetch
方法中,我们根据page
参数来获取需要的内容并刷新回复区域。
注意:我们没有给
fetch
方法传入page
参数,所以page
会通过以下代码获取:if (! page) { let query = location.search.match(/page=(\d+)/); page = query ? query[1] : 1; }
而我们在
Paginator.vue
组件中的updateUrl
方法已经更新了正确的page
值,所以我们通过以上代码获取到的page
值就是我们想要的值。所以url
方法会返回正确的url
。
目前我们的代码编写告一段落,接下来让我们测试一下成果:
分页功能已经实现,现在可以把回复数设置为 20:
forum\app\Http\Controllers\RepliesController.php
.
.
class RepliesController extends Controller
{
public function __construct()
{
$this->middleware('auth',['except' => 'index']);
}
public function index($channelId,Thread $thread)
{
return $thread->replies()->paginate(20);
}
.
.
最后,运行一下全部测试:
可以看到我们有几个测试失败了,这是因为我们改变了获取回复的方式,我们将在以后的章节中进行修复。