larablog 系列文章 05 - 自定义视图:Blade 扩展、侧边栏和静态资源管理

本章将会继续讲如何构建前台内容的呈现。我们将调整首页,显示有关博客文章评论的信息,并通过将标题内容加入到 URL 中来提升 SEO 效果。
还将在侧边栏添加 2 个常见的博客组件标签云和最新评论。我们将了解如何为模板引擎进行扩展,以及如何管理网站中静态资源文件。

显示首页评论数

到目前为止首页会列出最新的文章列表,但并不会列出跟这些文章相关的评论。既然我们已经建立了评论模型,可以获取文章相关的数据,那么可以回到首页来呈现文章的评论信息。我们更新一下首页的模板,打开位于 resources/views/pages/index.blade.php 的文件,根据以下所示调整相应内容:

<footer class="meta">
    <p>Comments: {{ $post->comments()->count() }}</p>
    <p>Posted by <span class="highlight">{{ $post->author }}</span> at {{ $post->created_at->toDateTimeString() }}</p>
    <p>Tags: <span class="highlight">{{ $post->tags }}</span></p>
</footer>

这里 $post->comments()->count() 是通过模型关系返回的结果,count() 即是返回对应关系模型的记录数。
浏览器访问 http://localhost:8000/ 你将会看到每篇的评论数量已经能够显示出来了。

构建侧边栏

目前 larablog 的侧边栏看起来有点空。我们将要给这个侧边栏增加两个常用的部件,标签云和最新评论。

标签云

标签云是显示文章标签的一种方式,将常用的标签加粗显示。要达到这个目的,我们需要获取所有日志文章的标签。

让我们在 Post 类中新增方法来实现。更新 app/Post.php,添加如下方法:

public static function getTags()
{
    $blogTags = Post::lists('tags');

    $tags = [];
    foreach ($blogTags as $blogTag) {
        $tags = array_merge(explode(',', $blogTag), $tags);
    }

    return array_map(function ($item){ return trim($item); }, $tags);

    return $tags;
}

public static function getTagweights($tags)
{
    $tagWeights = [];

    if (empty($tags)) {
        return $tagWeights;
    }

    foreach($tags as $tag) {
        $tagWeights[$tag] = isset($tagWeights[$tag]) ? $tagWeights[$tag] + 1 : 1;
    }

    // Shuffle the tags
    uksort($tagWeights, function() {
        return rand() > rand();
    });

    $max = max($tagWeights);

    // Max of 5 weights
    $multiplier = ($max > 5) ? 5 / $max : 1;
    foreach ($tagWeights as &$tag) {
        $tag = ceil($tag * $multiplier);
    }

    return $tagWeights;
}

标签是已逗号分隔的方式存储在数据库中,我们需要一个方式将它们分离并形成一个数组。这在 getTags() 方法中实现了。然后 getTagWeights() 方法可以使用这个数组来统计标签的权重根据他们在数组中的热门程度计算展示值。标签经过随机排序以显示在页面上。

现在有能力产生标签云了,我们需要将它显示出来。我们建立一个视图组件用于呈现标签云。

视图组件就是在视图被渲染前,会被调用的闭包或类方法。如果你想在每次渲染某些视图时绑定数据,视图组件可以帮你把这样的程序逻辑都组织到同一个地方。
更多内容请参考 视图组件

新建一个服务提供者 app/Providers/ComposerServiceProvider.php,我们通过它增加相应的视图组件。其内容为:

<?php

namespace App\Providers;

use App\Post;
use Illuminate\Support\ServiceProvider;

class ComposerServiceProvider extends ServiceProvider
{
    /**
     * 在容器内注册所有绑定。
     *
     * @return void
     */
    public function boot()
    {
        view()->composer('*.sidebar', function ($view) {
            $view->with('tags', Post::getTagweights(Post::getTags()));
        });
    }

    /**
     * 注册服务提供者。
     *
     * @return void
     */
    public function register()
    {
        //
    }
}

接下来,我们要将这个新建的服务提供者加入到在 config/app.php 配置文件内的 providers 数组中。

'providers' => [
    // ...

    App\Providers\ComposerServiceProvider::class,
],

ComposerServiceProvider 中你会注意到,视图的 composer 方法可以接受 * 作为通配符,这里是对 *.sidebar 引入了侧边栏的视图附加相应的视图内容。可以看到,我们给相应的视图附加了 tags 数据。

打开侧边栏的视图 resources/views/partials/sidebar.blade.php,将内容修改为以下所示:

@section('sidebar')
    <section class="section">
        <header>
            <h3>Tag Cloud</h3>
        </header>
        <p class="tags">
            @forelse($tags as $tag => $weight)
                <span class="weight-{{ $weight }}">{{ $tag }}</span>
            @empty
                <p>There are no tags</p>
            @endforelse
        </p>
    </section>
@show

这个模板不难理解,将标签数据循环输出,根据标签的权重显示不同的粗细样式。

最后我们给标签云添加相应的样式,新建样式表 public/css/sidebar.css,内容如下:

.sidebar .section { margin-bottom: 20px; }
.sidebar h3 { line-height: 1.2em; font-size: 20px; margin-bottom: 10px; font-weight: normal; background: #eee; padding: 5px;  }
.sidebar p { line-height: 1.5em; margin-bottom: 20px; }
.sidebar ul { list-style: none }
.sidebar ul li { line-height: 1.5em }
.sidebar .small { font-size: 12px; }
.sidebar .comment p { margin-bottom: 5px; }
.sidebar .comment { margin-bottom: 10px; padding-bottom: 10px; }
.sidebar .tags { font-weight: bold; }
.sidebar .tags span { color: #000; font-size: 12px; }
.sidebar .tags .weight-1 { font-size: 12px; }
.sidebar .tags .weight-2 { font-size: 15px; }
.sidebar .tags .weight-3 { font-size: 18px; }
.sidebar .tags .weight-4 { font-size: 21px; }
.sidebar .tags .weight-5 { font-size: 24px; }

我们找到应用的基础布局模板 resources/views/layouts/app.blade.php 将侧边栏的样式引入进来:

@section('stylesheets')
    <link href="{{ asset('css/screen.css') }}" type="text/css" rel="stylesheet" />
    <link href="{{ asset('css/blog.css') }}" type="text/css" rel="stylesheet" />
    <link href="{{ asset('css/sidebar.css') }}" type="text/css" rel="stylesheet" />
@show

如果你现在刷新页面查看网站将看到标签云可以正确显示在你们面前。它根据不同的标签的权重来显示为相应的大小,你可以试试通过数据库修改文章中的标签数据来看看是否有相应的变化。

最近评论

现在标签云已经添加到了侧边栏,接下来我们要将最近评论加入其中。

Comment 类,文件 app/Comment.php 中加入以下代码:

public static function getLatest($limit = 10) 
{
    return Comment::orderBy('id', 'DESC')->take($limit)->get();
}

然后,我们和标签云的做法一样,来增加视图组件,打开 app/Providers/ComposerServiceProvider.php,修改 boot 方法:

<?php

namespace App\Providers;

use App\Post;
use App\Comment;
use Illuminate\Support\ServiceProvider;

class ComposerServiceProvider extends ServiceProvider
{
    /**
     * 在容器内注册所有绑定。
     *
     * @return void
     */
    public function boot()
    {
        view()->composer('*.sidebar', function ($view) {
            $view->with('tags', Post::getTagweights(Post::getTags()));
            $view->with('latestComments', Comment::getLatest());
        });
    }

    /**
     * 注册服务提供者。
     *
     * @return void
     */
    public function register()
    {
        //
    }
}

最后我们需要再次修改视图模板,加入最新评论的显示代码。打开文件 resources/views/partials/sidebar.blade.php,修改为如下内容:

@section('sidebar')
    <section class="section">
        <header>
            <h3>Tag Cloud</h3>
        </header>
        <p class="tags">
            @forelse($tags as $tag => $weight)
                <span class="weight-{{ $weight }}">{{ $tag }}</span>
            @empty
                <p>There are no tags</p>
            @endforelse
        </p>
    </section>

    <section class="section">
        <header>
            <h3>Latest Comments</h3>
        </header>
        @forelse($latestComments as $latestComment)
            <article class="comment">
                <header>
                    <p class="small"><span class="highlight">{{ $latestComment->user }}</span> commented on
                        <a href="/posts/{{ $latestComment->post->id }}#comment-{{ $latestComment->id }}">
                            {{ $latestComment->post->title }}
                        </a>
                        [<em>{{ $latestComment->created_at->toDateTimeString('Y-m-d h:iA') }}</time></em>]
                    </p>
                </header>
                <p>{{ $latestComment->comment }}</p>
                </p>
            </article>
        @empty
            <p>There are no recent comments</p>
        @endforelse
    </section>
@show

至此,如果你打开浏览器刷新页面,你将会看到最近的评论位于标签云的下方,也能正确的显示出来了。

模板引擎扩展

到目前为止,我们一直以标准的日期格式显示博客的评论日期。一个更好的方法将是显示评论被发布多久,如 3 小时前发布。我们可以向评论模型中添加一个方法来达到目的,但是我们在别的模型也可能要用到这个功能,因此我们通过扩充模版引擎来实现会更好。Laravel 的模板引擎 Blade 提供扩展接口可以让我们定义自己的模板命令。

我们将创建一个新的模板标记,可以使用如下:

@datetime($comment->created_at)

添加扩展

打开文件 app/Providers/AppServiceProvider.php,编辑内容如下所示:

<?php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Bootstrap any application services.
     *
     * @return void
     */
    public function boot()
    {
        \Blade::directive('datetime', function($expression) {
            return "<?php echo with{$expression}->diffForHumans(); ?>";
        });
    }

    /**
     * Register any application services.
     *
     * @return void
     */
    public function register()
    {
        //
    }
}

我们通过自定义命令,使用 directive 方法注册命令。当 Blade 编译器遇到该命令时,它将会带参数调用提供的回调函数。with 辅助函数会简单地返回指定的对象或值,并允许使用便利的链式调用。最后此命令生成的 PHP 会是:

<?php echo with($var)->diffForHumans(); ?>

更多详细内容可以阅读文档 扩充 Blade

更新视图

现在我们可以更新侧边栏上评论的显示时间,使用刚建立的模板命令来让时间更为直观。打开文件 resources/views/partials/sidebar.blade.php,将输出时间信息的标记部分更改更改为如下所示:

[<em>@datetime($latestComment->created_at)</time></em>]

如果你现在访问首页,刷新页面,你可以看到最新评论已经可以更佳直观的显示评论发布的时间。

让我们也来更新一下文章的评论列表的视图,打开位于 resources/views/comments/index.blade.php,将其内容更新为如下所示:

@forelse($comments as $i => $comment)
    <article class="comment {{ $i % 2 == 0 ? 'odd' : 'even' }}" id="comment-{{ $comment->id }}">
        <header>
            <p><span class="highlight">{{ $comment->user }}</span> commented {{ $comment->created_at->format('l, F j, Y') }}</p>
        </header>
        <p>{{ $comment->comment }}</p>
    </article>
@empty
    <p>There are no comments for this post. Be the first to comment...</p>
@endforelse()

自此我们通过扩展模板引擎让评论的发布时间显示更为直观友好了。

格式化 URL

目前每个博客文章的网址只是通过编号表示来显示,虽然从功能的角度来完全可以接受,但它对 SEO 并不好。例如 url http://localhost:8000/1 并没有提供关于博客内容的任何信息,像 http://localhost:8000/a-day-with-laravel 则会好得多。为了达到这个目的,我们将把这个博客标题格式化并将其作为这个 URL 的一部分。标题将删除所有非 ASCII 字符,并用 - 替换它们。

更新路由

首先,我们打开路由配置文件 app/Http/routes.php,增加新的文章详情页的路由配置,在 routes.php 中增加如下配置:

Route::get('{id}/{slug}', 'PostsController@show');

格式化

紧接着我们打开 Post 类,文件位于 app/Post.php 新增如下方法:

public static function slugify($text)
{
    // replace non letter or digits by -
    $text = preg_replace('#[^\\pL\d]+#u', '-', $text);

    // trim
    $text = trim($text, '-');

    // transliterate
    if (function_exists('iconv'))
    {
        $text = iconv('utf-8', 'us-ascii//TRANSLIT', $text);
    }

    // lowercase
    $text = strtolower($text);

    // remove unwanted characters
    $text = preg_replace('#[^-\w]+#', '', $text);

    if (empty($text))
    {
        return 'n-a';
    }

    return $text;
}

模型和数据迁移

由于我们要通过格式化的标题也能访问文章信息,所以我们要储相应的格式化信息,那么我们就需要给文章表增加一个新的字段 slug
我们通过一下命令建立一个新的迁移脚本,目的是向 posts 表中添加这个新的字段:

php artisan make:migration add_slug_to_posts_table --table=posts

然后打开迁移文件 xxxx_xx_xx_xxxxxx_add_slug_to_posts_table.php,更改其内容为如下所示:

<?php

use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class AddSlugToPostsTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::table('posts', function (Blueprint $table) {
            $table->string('slug');
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::table('posts', function (Blueprint $table) {
            $table->dropColumn('slug');
        });
    }
}

让我们执行脚本迁移让改动立即生效:

php artisan migrate

为了让设置文章标题时能自动添加格式化的 slug 信息,我们可以给模型 Post 定义一个属性修改器,打开 app/Post.php,添加如下方法:

public function setTitleAttribute($value)
{
    $this->attributes['title'] = $value;
    $this->attributes['slug'] = self::slugify($value);
}

我们定义了 title 的属性修改器,会当我们尝试在模型上设置 title 的值时自动调用此修改器。
我们利用这个属性修改器来自动设置 slug 的值。

接下来,我们通过数据库客户端访问数据库,将 postscomments 表清空(truncate 操作)。
然后重新执行我们之前的数据填充操作:

php artisan db:seed

这样我们的我们重新生成了 Post 数据,可以看到数据库中每条文章的 slug 字段都已经自动生成好了。

更新视图中的路由信息

接下来,我们要对首页的文章和侧边栏的文章链接进行修改,使用我们的格式化 URL 来替换原来的链接地址。
打开 resources/views/pages/index.blade.php,找到博客文章链接的位置修改为如下内容:

<header>
    <h2><a href="/{{ $post->id }}/{{ $post->slug }}">{{ $post->title }}</a></h2>
</header>

紧接着我们打开侧边栏的视图文件 resources/views/partials/sidebar.blade.php,将文章链接修改为如下所示:

<header>
    <p class="small"><span class="highlight">{{ $latestComment->user }}</span> commented on
        <a href="/{{ $latestComment->post->id }}/{{ $latestComment->post->slug }}#comment-{{ $latestComment->id }}">
            {{ $latestComment->post->title }}
        </a>
        [<em>@datetime($latestComment->created_at)</time></em>]
    </p>
</header>

最后我们还需要评论控制器 CommentsController 作出一些调整,目的是让用户发表评论后跳转到相应的评论所在位置。
打开 app/Http/Controllers/CommentsController.php,修改 store 方法为如下所示:

public function store(CommentRequest $request, $postId)
{
    $post = Post::findOrFail($postId);

    $comment = new Comment;
    $comment->user = $request->get('user');
    $comment->comment = $request->get('comment');
    $comment->post()->associate($post);
    $comment->save();

    return redirect("/{$post->id}/{$post->slug}#comment-{$comment->id}");
}

现在你可以刷新浏览器,访问首页来点击博客文章看看我们是否将格式化的标题信息加入了到了 URL 当中,同时我们在发布完评论之后,也会立即定位到所在评论信息。

资源管理

在我们的项目开发中涉及到一些静态资源的管理,比如 cssjs 的管理。这时我们可以利用到 Laravel Elixir。

Laravel Elixir 是官方推荐的静态资源管理工具,此工具合理的定义项目的开发流程,尤其针对前端开发,解决了很多通用问题,如;Sass 编译器,静态资源文件的版本与缓存清除等。

它提供了简洁流畅的 API,让你能够在你的 Laravel 应用程序中定义基本的 Gulp 任务。Elixir 支持许多常见的 CSS 与 JavaScrtip 预处理器,甚至包含了测试工具。

安装及配置

在开始使用 Elixir 之前,你必须先确定你的机器上有安装 Node.js

Node 可以通过下面的链接下载:
https://nodejs.org/en/download/

Mac 系统的用户建议通过 brew install node 进行安装。

安装完成后,可以通过下面命令查看:

node -v

接着,你需要全局安装 Gulp 的 NPM 扩展包:

npm install --global gulp

最后的步骤就是安装 Elixir,进入你项目目录,你会发现根目录有个名为 package.json 的文件。想像它就如同你的 composer.json 文件,只不过它定义的是 Node 的依赖扩展包,而不是 PHP 的。

编辑 package.json 文件,将内容修改为如下所示:

{
  "private": true,
  "devDependencies": {
    "gulp": "^3.8.8"
  },
  "dependencies": {
    "laravel-elixir": "^4.0.0"
  }
}

执行下面的命令安装依赖包:

npm install

优化 NPM 安装 Gulp 和 Laravel Elixir 的下载速度

样式表

我们将位于 public/css 目录下的 screen.cssblog.csssidebar.css 文件移动到 resources/assets/css 下。
接下来我们做的是将多个 CSS 样式合并成单个的文件,我们打开项目目录下的 gulpfile.js 定义 Elixir 任务:

elixir(function(mix) {
    mix.styles([
        'screen.css',
        'blog.css',
        'sidebar.css'
    ]);
});

styles 方法的默认读取路径为 resources/assets/css 目录,而生成的 CSS 会被放置于 public/css/all.css
可以通过传递第二个参数至 styles 方法,将生成的文件输出至指定的位置:
mix.styles(['normalize.css','main.css'], 'public/assets/css');

在项目目录下执行 gulp 来执行 Elixir 任务,我们可以看到我们生成了预期的 public/css/all.css 文件。

打开我们的视图布局模板文件 resources/views/layouts/app.blade.php,可以修改 stylesheets 部分的内容,使用我们合并后的 CSS 文件:

@section('stylesheets')
    <link href="{{ asset('css/all.css') }}" type="text/css" rel="stylesheet" />
@show

JS

至于 Javascript,虽然我们这里没有涉及到 JS 的内容,不过如果你之后也需要对 JS 有合并的需求也可以这么添加 Elixir 任务:

elixir(function(mix) {
    mix.scripts([
        'jquery.js',
        'app.js'
    ]);
});

scripts 方法假设所有的路径都相对于 resources/assets/js 目录,且默认会将生成的 JavaScript 放置于 public/js/all.js

压缩

当你应用发布到生产环境时,你可以通过下面的命令来压缩所有 CSS 及 JavaScript:

// 运行所有任务并压缩所有 CSS 及 JavaScript...
gulp --production

压缩这些静态资源的好处最明显的就是缩小其文件大小提高传输相应速度。

其它

这里我们只是使用了 Elixir 来做简单的合并和压缩,它的功能不止这些,Elixir 支持许多常见的 CSS 与 JavaScrtip 预处理器,甚至包含了测试工具。使用链式调用,Elixir 还能让你流畅地定义开发流程。这非常适合前端的开发流程化,是个不可多得好工具。
更多内容还请关注相应的文档内容 Laravel Elixir

总结

通过这个部分的实践,我们又接触到了 Laravel 的许多新内容,包括 Laravel 视图组件以及静态资源文件的管理。我么还对主页进行了改进,并在侧边栏添加了一些组件。

在下一章我们继续探讨测试相关的内容。我们将使用 PHPUnit 进行单元和功能测试。我们还会编写模拟 Web 请求的功能测试,填写表单和点击链接,然后检查返回的响应。

推荐阅读更多精彩内容