36. Blade 模板抽取成 Vue 组件
- 本系列文章为
laracasts.com
的系列视频教程——Let's Build A Forum with Laravel and TDD 的学习笔记。若喜欢该系列视频,可去该网站订阅后下载该系列视频, 支持正版 ;- 视频源码地址:github.com/laracasts/Lets-Build-a-...;
- 本项目为一个 forum(论坛)项目,与本站的第二本实战教程 《Laravel 教程 - Web 开发实战进阶》 类似,可互相参照。
本节说明
- 对应视频第 36 小节:Extracting Components With Blade Functionality
本节内容
我们来看一下我们的话题详情页面:
对于每一个回复,我们都有一个Reply.vue
组件。本节我们将整个详情页面都抽取成 Vue 的组件:首先我们需要一个Thread.vue
组件,用来表示完整的话题详情页面;接着我们将每个回复组件放进Replies.vue
组件,表示所有回复。
首先新建Thread.vue
组件:
forum\resources\assets\js\pages\Thread.vue
<script>
import Replies from '../components/Replies';
export default {
components: { Replies }
}
</script>
接着注册Thread.vue
组件,同时取消注册Reply.vue
组件,因为我们会在Replies.vue
组件中引用它:
forum\resources\assets\js\app.js
.
.
Vue.component('flash', require('./components/Flash.vue'));
Vue.component('thread-view', require('./pages/Thread.vue'));
.
.
然后新建Replies.vue
组件:
forum\resources\assets\js\components\Replies.vue
<template>
<div>
<div v-for="reply in items">
<reply :data="reply"></reply>
</div>
</div>
</template>
<script>
import Reply from './Reply';
export default {
props: ['data'],
components: { Reply },
data() {
return {
items:this.data
}
}
}
</script>
修改Reply.vue
组件:
<template>
<div :id="'reply'+id" class="panel panel-default">
<div class="panel-heading">
<div class="level">
<h5 class="flex">
<a :href="'/profiles/'+data.owner.name"
v-text="data.owner.name">
</a>said {{ data.created_at }}...
</h5>
<!--@if(Auth::check())-->
<!--<div>-->
<!--<favorite :reply="{{ $reply }}"></favorite>-->
<!--</div>-->
<!--@endif-->
</div>
</div>
<div class="panel-body">
<div v-if="editing">
<div class="form-group">
<textarea class="form-control" v-model="body"></textarea>
</div>
<button class="btn btn-xs btn-primary" @click="update">Update</button>
<button class="btn btn-xs btn-link" @click="editing = false">Cancel</button>
</div>
<div v-else v-text="body"> </div>
</div>
<!--@can('update',$reply)-->
<!--<div class="panel-footer level">-->
<!--<button class="btn btn-xs mr-1" @click="editing = true">Edit</button>-->
<!--<button class="btn btn-xs btn-danger mr-1" @click="destroy">Delete</button>-->
<!--</div>-->
<!--@endcan-->
</div>
</template>
<script>
import Favorite from './Favorite.vue';
export default {
props: ['data'],
components: { Favorite },
data() {
return {
editing: false,
id: this.data.id,
body: this.data.body
};
},
methods:{
update() {
axios.patch('/replies/' + this.data.id,{
body:this.body
});
this.editing = false;
flash('Updated!');
},
destroy() {
axios.delete('/replies/' + this.data.id);
$(this.$el).fadeOut(300, () => {
flash('Your reply has been deleted!');
});
}
}
}
</script>
我们把reply
视图中内容放到了组件当中,最后我们在详情页面引入thread-view.vue
和replies.vue
组件:
forum\resources\views\threads\show.blade.php
@extends('layouts.app')
@section('content')
<thread-view inline-template>
<div class="container">
<div class="row">
<div class="col-md-8">
<div class="panel panel-default">
<div class="panel-heading">
<div class="level">
<span class="flex">
<a href="{{ route('profile',$thread->creator) }}">{{ $thread->creator->name }}</a>
{{ $thread->title }}
</span>
@can('update',$thread)
<form action="{{ $thread->path() }}" method="POST">
{{ csrf_field() }}
{{ method_field('DELETE') }}
<button type="submit" class="btn btn-link">Delete Thread</button>
</form>
@endcan
</div>
</div>
<div class="panel-body">
{{ $thread->body }}
</div>
</div>
<replies :data="{{ $thread->replies }}"></replies>
{{--@foreach ($replies as $reply)--}}
{{--@include('threads.reply')--}}
{{--@endforeach--}}
{{--{{ $replies->links() }}--}}
@if (auth()->check())
<form method="post" action="{{ $thread->path() . '/replies' }}">
{{ csrf_field() }}
<div class="form-group">
<textarea name="body" id="body" class="form-control" placeholder="说点什么吧..."rows="5"></textarea>
</div>
<button type="submit" class="btn btn-default">提交</button>
</form>
@else
<p class="text-center">请先<a href="{{ route('login') }}">登录</a>,然后再发表回复 </p>
@endif
</div>
<div class="col-md-4">
<div class="panel panel-default">
<div class="panel-body">
<p>
<a href="#">{{ $thread->creator->name }}</a> 发布于 {{ $thread->created_at->diffForHumans() }},
当前共有 <span v-text="repliesCount"></span> 个回复。
</p>
</div>
</div>
</div>
</div>
</div>
</thread-view>
@endsection
编译后我们刷新页面:
可以看到我们的组件已经应用成功,接下来我们来完善组件。我们知道,每个reply
都是一个组件,且包含在父组件Replies.vue
中。那么我们现在的回复删除动作就可以更改为:给自己绑定删除事件,让父组件监听到,然后父组件重新渲染区域,达到删除回复的目的。首先我们去掉删除按钮的注释,并且绑定删除事件:
forum\resources\assets\js\components\Reply.vue
.
.
<div class="panel-footer level">
<button class="btn btn-xs mr-1" @click="editing = true">Edit</button>
<button class="btn btn-xs btn-danger mr-1" @click="destroy">Delete</button>
</div>
.
.
destroy() {
axios.delete('/replies/' + this.data.id);
this.$emit('deleted',this.data.id);
// $(this.$el).fadeOut(300, () => {
// flash('Your reply has been deleted!');
// });
}
.
.
接着在父组件中进行监听:
forum\resources\assets\js\components\Replies.vue
<template>
<div>
<div v-for="(reply ,index) in items">
<reply :data="reply" @deleted="remove(index)"></reply>
</div>
</div>
</template>
<script>
import Reply from './Reply';
export default {
props: ['data'],
components: { Reply },
data() {
return {
items:this.data
}
},
methods: {
remove(index) {
this.items.splice(index,1);
flash('Reply has been deleted!');
}
}
}
</script>
测试一下:
接下来我们来关联侧边栏的统计数据:
forum\resources\views\threads\show.blade.php
@extends('layouts.app')
@section('content')
<thread-view :initial-replies-count="{{ $thread->replies_count }}" inline-template>
.
.
<replies :data="{{ $thread->replies }}" @removed="repliesCount--"></replies>
.
.
我们给Thread.vue
组件绑定了initialRepliesCount
属性,用来初始化回复的数量,同时给Replies.vue
组件绑定了removed
事件,一旦该事件被监听到,initialRepliesCount
就会减少 1 个。为组件设置属性:
forum\resources\assets\js\pages\Thread.vue
<script>
import Replies from '../components/Replies';
export default {
props: ['initialRepliesCount'],
components: { Replies },
data() {
return {
repliesCount:this.initialRepliesCount
}
}
}
</script>
监听removed
事件:
forum\resources\assets\js\components\Replies.vue
<template>
<div>
<div v-for="(reply ,index) in items">
<reply :data="reply" @deleted="remove(index)"></reply>
</div>
</div>
</template>
<script>
import Reply from './Reply';
export default {
props: ['data'],
components: { Reply },
data() {
return {
items:this.data
}
},
methods: {
remove(index) {
this.items.splice(index,1);
this.$emit('removed');
flash('Reply has been deleted!');
}
}
}
</script>
注:为了与教程一致,现将
Carbon
的中文支持取消。
测试一下:
别忘了我们的点赞功能。我们在之前的设定是,点赞动作只能登录用户进行。所以我们还要做权限控制:
forum\resources\views\layouts\app.blade.php
.
.
<script>
window.App = {!! json_encode([
'csrfToken' => csrf_token(),
'signIn' => Auth::check()
]) !!};
</script>
.
.
现在我们可以根据window.App
的signIn
属性来判断用户是否登录:
接下来在引入favorite.vue
组件时加上权限控制:
forum\resources\assets\js\components\Reply.vue
<template>
<div :id="'reply'+id" class="panel panel-default">
<div class="panel-heading">
<div class="level">
<h5 class="flex">
<a :href="'/profiles/'+data.owner.name"
v-text="data.owner.name">
</a> said {{ data.created_at }}...
</h5>
<div v-if="signIn">
<favorite :reply="data"></favorite>
</div>
</div>
</div>
<div class="panel-body">
<div v-if="editing">
<div class="form-group">
<textarea class="form-control" v-model="body"></textarea>
</div>
<button class="btn btn-xs btn-primary" @click="update">Update</button>
<button class="btn btn-xs btn-link" @click="editing = false">Cancel</button>
</div>
<div v-else v-text="body"> </div>
</div>
<div class="panel-footer level" >
<button class="btn btn-xs mr-1" @click="editing = true">Edit</button>
<button class="btn btn-xs btn-danger mr-1" @click="destroy">Delete</button>
</div>
</div>
</template>
<script>
import Favorite from './Favorite.vue';
export default {
props: ['data'],
components: { Favorite },
data() {
return {
editing: false,
id: this.data.id,
body: this.data.body
};
},
computed: {
signIn() {
return window.App.signIn;
},
},
methods:{
update() {
axios.patch('/replies/' + this.data.id,{
body:this.body
});
this.editing = false;
flash('Updated!');
},
destroy() {
axios.delete('/replies/' + this.data.id);
this.$emit('deleted',this.data.id);
}
}
}
</script>
进行测试,已成功生效:
接着我们给回复的编辑、删除按钮加上权限控制:
forum\resources\views\layouts\app.blade.php
.
.
<script>
window.App = {!! json_encode([
'csrfToken' => csrf_token(),
'user' => Auth::user(),
'signIn' => Auth::check()
]) !!};
</script>
.
.
如下可见:
在组件中加上权限控制:
forum\resources\assets\js\components\Reply.vue
.
.
<div class="panel-footer level" v-if="canUpdate">
<button class="btn btn-xs mr-1" @click="editing = true">Edit</button>
<button class="btn btn-xs btn-danger mr-1" @click="destroy">Delete</button>
</div>
.
.
computed: {
signIn() {
return window.App.signIn;
},
canUpdate() {
return this.data.user_id == window.App.user.id;
}
},
.
.
刷新页面可以看到权限控制已经加上:
但是我们现在所做的权限控制不利于扩展。例如,对于Admin
而言,应该拥有所有权限。如果要引入Admin
的概念,我们必须重写所有权限控制的代码。我们来进行一下改造:
forum\resources\assets\js\bootstrap.js
.
.
window.Vue = require('vue');
Vue.prototype.authorize = function (handler) {
// if Admin,just return true
return handler(window.App.user);
};
.
.
在组件中应用:
forum\resources\assets\js\components\Reply.vue
.
.
computed: {
signIn() {
return window.App.signIn;
},
canUpdate() {
return this.authorize(user => this.data.user_id == user.id);
}
},
.
.
我们调用authorize
方法,并传递user => this.data.user_id == user.id
参数,然后函数返回处理规则的结果,即此处的this.data.user_id == user.id
。但是现在还有一个问题,那就是对于未登录用户而言,此处的代码会报错:
我们增加未登录用户的处理逻辑:
.
.
Vue.prototype.authorize = function (handler) {
// if Admin,just return true
let user = window.App.user;
if(! user) return false;
return handler(user);
};
.
.
还可以更简洁一些:
.
.
Vue.prototype.authorize = function (handler) {
// if Admin,just return true
let user = window.App.user;
return user ? handler(user) : false;
};
.
.
最后,让我们进行测试: