【Laravel系列5.1】Blade模板开发

楚涵润
2023-12-01

Blade模板开发

对于早期的 PHP 开发来说,直接输出页面是 PHP 最早称霸 WEB 领域的法宝。不像现在的前后端分离,最早我们开发 PHP 的时候很多情况下都是直接在 HTML 中嵌入 PHP 代码来生成动态网页的。在那个时候,也没有专门的前端这个职位,当时我们的前端一般也会叫做是 “切图仔” 。当他们把设计给的分层 PSD 切成静态页面之后,就会直接把这些静态 HTML 文件给到我们,然后我们通过套页面的方式,也就是在这些静态页面中插入 PHP 代码的方式来完成动态网页的开发。不得不说,还是挺怀念那个时代的。

之后前端工程化以后,特别是 SAP 应用开始普及之后,相对来说,我们 PHP 的工作量就减少了很多,起码后台大部分都已经是前后分离的开发方式了。这个时候我们只需要出接口供前端调用就好了,同样的,因为前后端分离,反而我们大部分 PHP 变成了 “接口仔” 。虽说是时代的进步,但是套页面这个事吧,还是在很多网站中普遍存在,因为毕竟搜索引擎的 SEO 对于原生直出的页面爬取还是效果最好的。

早期,我们其实也有一些模板工具,比如说鼎鼎大名的 Smarty ,这个东西早几年接触 PHP 的小伙伴应该还是比较熟悉的。包括很多框架也是使用 Smarty 来做为默认的前端模板工具。Yii 是提倡原生和前端组件化,所以默认情况下它写前端不用什么模板。而在 Laravel 中,有一套它自己比较经典的模板框架,也就是我们今天要学习的 Blade 。

一个简单的页面

首先,我们定义一个路由。

Route::get('blade/test', function(){
    $title = '测试文章';

    $content = <<<EOF
<div>
<p>
  “半决赛就是我的决赛。”中国男子短跑名将苏炳添在东京奥运会男子100米预赛结束后曾这样说。昨日站上半决赛的赛场,苏炳添也的确拼出了全力,并以9.83秒的成绩刷新亚洲纪录,排名半决赛首位成功闯入决赛。决赛场,第一次有中国选手站上奥运会男子100米决赛的跑道。苏炳添最终以9.98秒的成绩获得第六名,创造了中国田径新的历史。赛后他身披国旗,十分激动地说:“我完成了自己的梦想!”</p>

<p>
  苏炳添8月29日就将迎来自己32岁的生日,虽然去年一度受到伤病的困扰,但他还是逐步恢复状态,在今年初再度跑进10秒之内。出战东京奥运会,苏炳添的第一个目标就是站上男子100米决赛跑道,因此他在昨日傍晚的半决赛中也拼出了全力。9.83秒的成绩不仅让他稳稳进入决赛,也创造了黄种人跑进9.9秒的新纪录。两小时之后,苏炳添站在了奥运会男子飞人决赛的跑道上。或许是半决赛拼得太凶,或许是被决赛中英国选手抢跑打乱了节奏,苏炳添的起跑反应时并不理想,最终以9.98秒的成绩获得第六名。但这依然书写了属于他、属于中国田径的崭新历史。</p>

<p>
  完成决赛,苏炳添身披国旗在东京新国立竞技场内享受着属于自己的荣耀时刻。他说:“对我来说,通过这么多年的努力,终于可以站在奥运会100米决赛的跑道上,我完成了自己的梦想,也实现了中国田径历代前辈对我们的期待与祝福。半决赛到决赛这么短时间还能突破10秒,我已经非常开心了,达到了我自己的目标,这将是我一辈子最好的回忆。我拿到了奥运会第六名,我希望以此给予年轻运动员一个非常大的鼓励。比赛成绩应该说没有让大家失望,我也希望在几天后的接力比赛中继续展现中国速度。”</p>
</div>
EOF;

    $menu = ["首页", "文章", "视频", "评论", "留言", "关于"];


    return view('Blade.test', [
        'title' => $title,
        'content' => $content,
        'menu'=>$menu
    ]);
});

在这个路由中我们定义了几个变量,分别是一个短的字符串 ,一个长的字符串content ,然后还有一个数组变量。接着我们通过 return 一个 view() 方法来指定要加载的模板。在这里我们指定的是 Blade.test 这个模板,并给 view() 方法的第二个参数通过数组的方式传递我们定义好的数据。

接下来在 resources/views 目录下,我们再创建一个 Blade 目录,然后在这个目录下面新建一个 test.blade.php 文件。这个文件,就是一个 Balade 的模板文件,在 Laravel 中,所有的模板文件都要放在 resources/views 这个目录下面。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>{{$title}}</title>
</head>
<body>

<h1>{{$title}}</h1>

{{$content}}

{!! $content !!}

<ul>
@foreach($menu as $v)
    <li>{{$v}}</li>
@endforeach
</ul>
</body>
</html>

相信你也看出来了,我们在上面路由中的 view() 方法指定的模板,其实就是通过那个点来表示目录路径的。在这个模板文件中,{{}} 用于输出数据,@foreach 这种则是语言标记。另外你还会注意到一个问题,那就是 $content 我们用了两种方式来进行输出。在使用的普通的 {{}} 时,会将内容中的 HTML 代码直接输出,而另一种方式 {!! !!} 则会正常执行 HTML 代码。关于这个问题之前我们在其它的文章中其实也学习过,是为了安全和避免 XSS 攻击,由此,我们暂时可以推断,{{}} 这种形式的写法应该是使用了 htmlentities() 或者 htmlspecialchars() ,具体是哪个呢?我们后面分析源码的时候再看。

至于 @foreach 或者 @if 这些的我也不多说了,毕竟官方文档上已经写得很清楚了。接下来,我们看一下在 Laravel 中如何定义母版。

母版嵌套

母版其实就是这样一种情况,我们在开发网站的时候,有些网站的头部和底部在所有页面都是一样的。在这个时候我们就可以定义一个母版,有固定的头和尾,只是中间的内容部分会产生变动。这个就是母版的作用。

我们在 resoureces/views 目录下新建一个 layouts 目录,然后在下面新建三个文件。

// resources/views/layouts/header.blade.php
<ul>
    @foreach($menu as $v)
        <li>{{$v}}</li>
    @endforeach
</ul>

// resources/views/layouts/footer.blade.php
<ul style="display: flex;">
    @foreach($menu as $v)
        <li>{{$v}}</li>
    @endforeach
</ul>

// resources/views/layouts/template.blade.php
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>@yield('title')</title>
</head>
<body>
@include('layouts.header')

@yield('content')

@include('layouts.footer')
</body>
</html>

最后这个 template.blade.php 就是我们要使用的母版文件。在这个文件中,我们通过 @include 加载了头和尾文件。这两个文件里面只是简单地输出了我们的菜单内容。头文件是直接 ul 列表输出的,而尾文件中我们简单地给 ul 加了个 flex 效果让它横向展示。最后,在母版文件的内容区域使用了一个 @yield 来定义内容区域。

接下来,我们定义一个 content.balde.php 文件,继承自母版,并且在这里面写入 content 内容。

// resources/views/Blade/content.blade.php
@extends('layouts.template')

@section('title', $title)

@section('content')
    {!! $content !!}
@endsection

从代码中可以看出,@extends 是用于继承另一个模板文件的标记代码,@section 块可以覆盖制定 @yield 部分的内容。

然后就是定义一个路由来测试这个页面了。

Route::get('blade/test2', function(){
    $title = '测试文章2';

    $content = <<<EOF
<div>
<p>
  “半决赛就是我的决赛。”中国男子短跑名将苏炳添在东京奥运会男子100米预赛结束后曾这样说。昨日站上半决赛的赛场,苏炳添也的确拼出了全力,并以9.83秒的成绩刷新亚洲纪录,排名半决赛首位成功闯入决赛。决赛场,第一次有中国选手站上奥运会男子100米决赛的跑道。苏炳添最终以9.98秒的成绩获得第六名,创造了中国田径新的历史。赛后他身披国旗,十分激动地说:“我完成了自己的梦想!”</p>

<p>
  苏炳添8月29日就将迎来自己32岁的生日,虽然去年一度受到伤病的困扰,但他还是逐步恢复状态,在今年初再度跑进10秒之内。出战东京奥运会,苏炳添的第一个目标就是站上男子100米决赛跑道,因此他在昨日傍晚的半决赛中也拼出了全力。9.83秒的成绩不仅让他稳稳进入决赛,也创造了黄种人跑进9.9秒的新纪录。两小时之后,苏炳添站在了奥运会男子飞人决赛的跑道上。或许是半决赛拼得太凶,或许是被决赛中英国选手抢跑打乱了节奏,苏炳添的起跑反应时并不理想,最终以9.98秒的成绩获得第六名。但这依然书写了属于他、属于中国田径的崭新历史。</p>

<p>
  完成决赛,苏炳添身披国旗在东京新国立竞技场内享受着属于自己的荣耀时刻。他说:“对我来说,通过这么多年的努力,终于可以站在奥运会100米决赛的跑道上,我完成了自己的梦想,也实现了中国田径历代前辈对我们的期待与祝福。半决赛到决赛这么短时间还能突破10秒,我已经非常开心了,达到了我自己的目标,这将是我一辈子最好的回忆。我拿到了奥运会第六名,我希望以此给予年轻运动员一个非常大的鼓励。比赛成绩应该说没有让大家失望,我也希望在几天后的接力比赛中继续展现中国速度。”</p>
</div>
EOF;

    $menu = ["首页", "文章", "视频", "评论", "留言", "关于"];


    return view('Blade.content', [
        'title' => $title,
        'content' => $content,
        'menu'=>$menu
    ]);
});

这个路由的内容和我们上面测试的路由内容是一样的,只是换了一下加载的 view 模板。

模板编译

对于这种前端使用标记进行模板嵌套的框架来说,大部分基本上都是通过对标记的字符替换来实现的,Blade 也不例外。在这里,模板转换成动态页面也是一个编译的过程,因此我们称这一步为模板编译。接下来,我们就从 view() 这个方法看起。

view() 方法位于 laravel/framework/src/Illuminate/Foundation/helpers.php 中,是一个全局的工具方法。在这个方法里面,我们会通过服务容器实例化一个 vendor/laravel/framework/src/Illuminate/View/Factory.php 对象,并调用它的 make() 方法。

function view($view = null, $data = [], $mergeData = [])
{
    $factory = app(ViewFactory::class);

    if (func_num_args() === 0) {
        return $factory;
    }

    return $factory->make($view, $data, $mergeData);
}

在 make() 方法里面,会继续调用 viewInstance() 方法,这个方法会返回一个 vendor/laravel/framework/src/Illuminate/View/View.php 对象。这个对象,就是模板组件的一个核心对象,其实从名字就可以看出来了。注意,View 类实现的 ViewContract 接口实际上是 vendor/laravel/framework/src/Illuminate/View/View.php 这个接口文件,而它,又继承自一个 vendor/laravel/framework/src/Illuminate/Contracts/Support/Renderable.php 接口。讲了这么深,是要干嘛呢?

因为我们在 view() 方法中,返回的就只是一个 View 对象了,接下来就进入到请求流程的 Response 部分了。在 vendor/laravel/framework/src/Illuminate/Http/Response.php 的 setContent() 方法中,就是用于判断设置响应返回的方法里面,有进行是否使用模板相关的判断。

public function setContent($content)
{
    $this->original = $content;

    if ($this->shouldBeJson($content)) {
        $this->header('Content-Type', 'application/json');

        $content = $this->morphToJson($content);

        if ($content === false) {
            throw new InvalidArgumentException(json_last_error_msg());
        }
    }
    elseif ($content instanceof Renderable) {
        $content = $content->render();
    }

    parent::setContent($content);

    return $this;
}

注意看第二个 elseif 中对于 $content 的判断,使用的就是看它是否是属于 Renderable 接口的扩展对象。如果是的话,就调用它的 render() 方法。到这里,我们又回到了 vendor/laravel/framework/src/Illuminate/View/View.php 中,通过 render() 方法继续向下探索会发现一个 getContents() 方法。

protected function getContents()
{
    return $this->engine->get($this->path, $this->gatherData());
}

这里就是通过编译引擎对模板文件和传递过来的参数进行编译了。这个 engine 又是哪里来的呢?其实是我们在 Factory 中实例化 View 对象时传递到 View 的构造参数中的。我们再回过头去看一下 Factory 中的 viewInstance() 方法。

protected function viewInstance($view, $path, $data)
{
    return new View($this, $this->getEngineFromPath($path), $view, $path, $data);
}

第二个参数调用的 getEngineFromPath() 方法就是根据路径返回编译引擎。

public function getEngineFromPath($path)
{
    if (! $extension = $this->getExtension($path)) {
        throw new InvalidArgumentException("Unrecognized extension in file: {$path}.");
    }

    $engine = $this->extensions[$extension];

    return $this->engines->resolve($engine);
}

protected $extensions = [
    'blade.php' => 'blade',
    'php' => 'php',
    'css' => 'file',
    'html' => 'file',
];

根据文件的名称,会在 $extensions 找到对应的编译引擎是 blade ,接着再调用 engines 属性的对应引擎对象,这个 engines 属性是在服务容器实例化 Factoy 时注入过来的。在 Laravel 一开始运行的时候,一部分内部常用类就加载到了服务容器中,这个我们后面会在核心架构中详细说明。在这里,我们获得的模板引擎是 vendor/laravel/framework/src/Illuminate/View/Engines/CompilerEngine.php 。然后我们来到这个引擎的 get() 方法中。

public function get($path, array $data = [])
{
    $this->lastCompiled[] = $path;

    if ($this->compiler->isExpired($path)) {
        $this->compiler->compile($path);
    }

    $results = $this->evaluatePath($this->compiler->getCompiledPath($path), $data);

    array_pop($this->lastCompiled);

    return $results;
}

在这里,我们会发现它又使用了一个 compiler ,从名字就可以看出这个对象是一个编译者,同样地,这个对象也是通过服务容器依赖注入过来的,暂时先不深究,我们直接到空的实例化对象的模板,也就是类文件 vendor/laravel/framework/src/Illuminate/View/Compilers/BladeCompiler.php 。在这个里面找到 compile() 方法,并一路跟踪到 parseToken() 方法。

protected function parseToken($token)
{
    [$id, $content] = $token;

    if ($id == T_INLINE_HTML) {
        foreach ($this->compilers as $type) {
            $content = $this->{"compile{$type}"}($content);
        }
    }

    return $content;
}

protected $compilers = [
    // 'Comments',
    'Extensions',
    'Statements',
    'Echos',
];

这个 token 是哪里来的?其实它是在上一级我们使用 token_get_all() 获得的 HTML 标记信息。关于 token_get_all() 其实是 PHP 中的一个语法分析组件中的函数,可以分析 PHP 及相关文件中包含的 PHP 语法信息。我们要确认获得的标记是 T_INLINE_HTML 类型的,然后调用下面 compilers 数组中定义的方法。其实也就是 compileExtensions()、compileStatement()、compileEchos() 这三个方法。它们是在哪里定义的呢?BladeCompiler 最上方引用的那一堆特性文件中。

好了,到这里其实就已经很清晰了,接下来我们就看一下最简单的 compileEchos() 编译方法。这个方法位于 vendor/laravel/framework/src/Illuminate/View/Compilers/Concerns/CompilesEchos.php 这个特性文件中。

trait CompilesEchos
{
    public function compileEchos($value)
    {
        foreach ($this->getEchoMethods() as $method) {
            $value = $this->$method($value);
        }

        return $value;
    }

    protected function getEchoMethods()
    {
        return [
            'compileRawEchos',
            'compileEscapedEchos',
            'compileRegularEchos',
        ];
    }

    protected function compileRawEchos($value)
    {
        $pattern = sprintf('/(@)?%s\s*(.+?)\s*%s(\r?\n)?/s', $this->rawTags[0], $this->rawTags[1]);

        $callback = function ($matches) {
            $whitespace = empty($matches[3]) ? '' : $matches[3].$matches[3];

            return $matches[1] ? substr($matches[0], 1) : "<?php echo {$matches[2]}; ?>{$whitespace}";
        };

        return preg_replace_callback($pattern, $callback, $value);
    }

    protected function compileRegularEchos($value)
    {
        $pattern = sprintf('/(@)?%s\s*(.+?)\s*%s(\r?\n)?/s', $this->contentTags[0], $this->contentTags[1]);

        $callback = function ($matches) {
            $whitespace = empty($matches[3]) ? '' : $matches[3].$matches[3];

            $wrapped = sprintf($this->echoFormat, $matches[2]);

            return $matches[1] ? substr($matches[0], 1) : "<?php echo {$wrapped}; ?>{$whitespace}";
        };

        return preg_replace_callback($pattern, $callback, $value);
    }

    protected function compileEscapedEchos($value)
    {
        $pattern = sprintf('/(@)?%s\s*(.+?)\s*%s(\r?\n)?/s', $this->escapedTags[0], $this->escapedTags[1]);

        $callback = function ($matches) {
            $whitespace = empty($matches[3]) ? '' : $matches[3].$matches[3];

            return $matches[1] ? $matches[0] : "<?php echo e({$matches[2]}); ?>{$whitespace}";
        };

        return preg_replace_callback($pattern, $callback, $value);
    }
}

compileEchos() 方法循环调用 getEchoMethods() 中返回的方法名,实际上就是将这个特性中剩下的三个方法都调用一遍。我们就看一下第一个 compileRawEchos() 方法在干什么。它先去获取 rawTags 变量,并格式化成为一个正则表达式规则,这个变量是什么东西,在哪里呢?它定义在 BladeCompiler 中。

protected $rawTags = ['{!!', '!!}'];

是不是很熟悉了,这就是我们前端模板上的输出标识符呀,而且是输出原始语句的。那么替换之后我们获得的 $patten 会是一个什么东西呢?没错,现在就是 /(@)?{!!\s*(.+?)\s*!!}(\r?\n)?/s 这样一段内容。然后通过下面的 preg_replace_callback() 进行回调形式的正则替换。如果你不清楚 sprintf() 和 preg_replace_callback() 是干什么的,那么就赶紧去查查函数说明吧。

这里,我们模板文件中的 {!! $content !!} 其实就已经被替换成了 。在下面的 compileEscapedEchos() 方法中,我们可以看到最后替换出来的 PHP 代码稍微有一点点不一样,它是 ,多了一个 e() 方法的调用。这个方法是一个全局帮助方法,在 vendor/laravel/framework/src/Illuminate/Support/helpers.php 中。

function e($value, $doubleEncode = true)
{
    if ($value instanceof DeferringDisplayableValue) {
        $value = $value->resolveDisplayableValue();
    }

    if ($value instanceof Htmlable) {
        return $value->toHtml();
    }

    return htmlspecialchars($value ?? '', ENT_QUOTES, 'UTF-8', $doubleEncode);
}

很明显,它为我们的输出添加了 htmlspecialchars() ,可以有效预防 XSS 攻击。这一下,我们文章开头的疑惑就解开了吧,{{}} 在最后调用了 e() 方法对输出添加了 htmlspecialchars() ,而 {!! !!} 则是直接 echo 数据。

总结

今天我们简单地讲解了 Blade 模板的编译过程,但其实它要比我们看到的还要复杂许多。比如说我们写完模板文件后第一次编译完是会生成一个缓存文件的,这个文件会保存在 storage/framework/views 下的。有兴趣的同学去打开看一看就会发现这就是就一个传统的 嵌套式的代码文件。如果我们不对模板文件进行修改,那么 Laravel 就会直接使用这个已经编译好的文件,这样就不用每次都去执行编译从而节约资源加快效率。至于它是如何缓存的,就靠大家自已去源码中发现学习啦!这一篇文章中有很多基础方面的知识,比如 XSS 攻击、token_get_all()函数、sprintf()函数、preg_replace_callback() 函数、正则表达式等等,不记得或者不知道这些内容的小伙伴要好好再去看看文档巩固一下哦。

关于前端的内容就这一篇,主要原因一是因为现在这种套页面的开发相对来说比较少了,如果是做纯网站开发的可能还会用到。而且即使用到也基本就是前端的那些标记语句的应用,说实话,对着文档使用并不难。因此,也没有必要再照搬文档了,大家平常用得多自然也就会记住这些标记了。

参考文档:

https://learnku.com/docs/laravel/8.x/blade/9377

 类似资料: