通过 PHP 原生代码实现视图模板引擎的解析和渲染
引言
上篇教程学院君给大家简单介绍了什么是 MVC 设计模式,并演示了如何基于原生 PHP 代码编写简单的 HTTP 控制器,控制器对应 MVC 模式中的 C(Controller),今天,我们一起来看下 MVC 模式中另一个模块 —— 视图(View,对应 MVC 模式中的 V),并且基于原生 PHP 代码实现简单的视图模板引擎。
在此之前,我们的视图渲染实现比较简单粗暴,就是直接通过 include
语句引入对应的 PHP 视图模板,然后在当前作用域内有效的变量会在引入的视图模板中生效,以博客应用首页为例,对应的视图引入代码是这样的(代码位于 HomeController.php
中):
public function index()
{
$albums = $this->connection->table('albums')->selectAll();
$pageTitle = $siteName = $this->container->resolve('app.name');
$siteUrl = $this->container->resolve('app.url');
$siteDesc = $this->container->resolve('app.desc');
include __DIR__ . "/../../../views/home.php";
}
当前控制器方法中设置的变量在 home.php
视图模板中可以直接使用,因为 include
的本质就是把对应的 PHP 脚本导入到当前位置。
在 PHP 中,之所以可以直接这样渲染 HTML 视图,得益于 PHP 脚本和 HTML 文档可以混合编程,PHP 本身就看作是一种视图模板引擎,而不需要像其他语言那样(比如 Java、Go、Python),要引入额外的视图模板语言才能在 HTML 文档中动态引入变量进行渲染。
虽然 PHP 生态也提供了很多第三方扩展包作为独立的视图模板引擎,以便以工程化的方式构建更加复杂的应用,比如 Smarty、twig、Blade 等,不过这里为了简化系统,我们直接使用 PHP 本身作为 HTML 视图的模板语言。
不过为了让上述视图渲染实现代码更加优雅、便于维护和扩展,我们以面向对象风格的代码对其进行重构,并且将其调整为支持其他模板引擎。
编写 PHP 视图引擎实现代码
我们在 app
目录下新建一个 view
子目录,用于保存视图模板解析和渲染相关代码,然后在 view
目录下新建 engine
子目录,用来保存视图模板引擎代码。
在 engine
目录下新建一个 ViewEngine
接口作为所有 PHP 模板引擎实现的契约:
<?php
namespace App\View\Engine;
interface ViewEngine
{
public function extract($path, $data): string;
}
接下来,在同级目录下新建一个实现了 ViewEngine
接口的 PhpEngine
类作为 PHP 原生视图模板引擎的实现:
<?php
namespace App\View\Engine;
class PhpEngine implements ViewEngine
{
public function extract($path, $data): string
{
ob_start();
extract($data, EXTR_SKIP);
try {
include $path;
} catch (\Throwable $e) {
throw new \Exception('解析视图模板出错:' . $e->getMessage());
}
return ltrim(ob_get_clean());
}
}
在 PhpEngine
的 extract
实现中,我们通过 PHP 自带的输出控制函数 ob_start 打开输出控制缓冲,然后调用 extract 函数将从外部传入的数组变量导入当前符号表(即在当前作用域内以数组键名作为变量名,以对应键值作为变量值),接下来调用 include
引入指定路径的视图文件到缓冲区,这样,从外部传入的变量就可以在视图文件中生效了,如果引入文件或者变量解析出错,则抛出异常,最后,我们调用 ob_get_clean 函数将当前缓冲区内执行过 PHP 脚本代码并完成变量渲染的视图文件内容(标准的 HTML 文档)以字符串形式返回,后续这部分内容将作为 HTTP 响应的响应实体返回给客户端。
编写视图管理器代码
以上只是最底层视图模板引擎解析 PHP 变量、返回 HTML 格式视图文件内容的实现代码,如果你想要基于第三方 PHP 引擎扩展包构建更复杂的自定义模板引擎解析实现,可以自行实现 ViewEngine
接口并完成相应的视图模板解析逻辑。
接下来,我们在 view
目录下编写上层的视图模板引擎管理器和相应的服务提供者。前者用来管理不同的模板引擎实现类,根据应用配置获取当前使用的模板引擎,并完成视图响应的渲染,后者用来将这个视图管理器实例注册到服务容器中,以便在应用代码中需要渲染视图模板的时候从服务容器获取并使用。
首先来编写视图管理器,在 view 目录下新建 View.php
并初始化代码如下:
<?php
namespace App\View;
use App\Http\Response;
use App\View\Engine\ViewEngine;
class View
{
/**
* @var ViewEngine
*/
protected $engine;
/**
* @var string
*/
protected $basePath;
public function __construct(ViewEngine $engine, $basePath)
{
$this->engine = $engine;
$this->basePath = $basePath;
}
public function render($path, $data)
{
$response = new Response();
try {
$content = $this->getContent($path, $data);
} catch (\Throwable $e) {
$response->setStatusCode(500);
$response->setContent($e->getMessage());
$response->send();
return;
}
$response->setContent($content);
$response->setStatusCode(200);
$response->send();
}
protected function getContent($path, $data): string
{
$path = $this->basePath . $path;
if (!file_exists($path)) {
throw new \Exception('对应的视图文件不存在!');
}
return $this->engine->extract($path, $data);
}
}
在视图管理器 View
类中,定义了两个属性,$engine
表示模板引擎对象,basePath
则表示视图模板的根路径,这两个属性都是在实例化 View
时从外部传入的,我们马上会看到实例化 View
的代码。
重点看下 render
方法,该方法用于被上层代码调用完成视图模板的解析和渲染,在这个方法中,我们通过 getContent
方法调用系统当前使用的模板引擎实例 $engine
的 extract
方法(比如当前使用的是 PhpEngine
,则调用该对象的 extract
方法)完成视图模板的解析和 PHP 变量替换,然后将其返回的字符串格式 HTML 文档作为 Response
对象的响应实体随着 $response->send()
方法一起发送给客户端,完成视图渲染的闭环,如果解析视图模板过程中出错(比如视图文件不存在,变量解析出错),则返回 500 响应。
编写视图服务提供者代码
接下来,在 view
目录下新建 ViewProvider.php
,并编写服务提供者实现代码如下(其用途前面已经提及):
<?php
namespace App\View;
use App\Core\Container;
use App\View\Engine\PhpEngine;
use App\View\Engine\ViewEngine;
class ViewProvider
{
/**
* @var Container
*/
protected $container;
public function __construct($container)
{
$this->container = $container;
}
public function register()
{
$this->container->bind('view', function () {
$config = $this->container->resolve('view.engine');
$method = 'register' . ucfirst($config) . 'Engine';
if (!method_exists($this, $method)) {
throw new \Exception('对应的视图模板引擎暂不支持!');
}
$engine = call_user_func([$this, $method]);
$basePath = $this->container->resolve('view.path');
return new View($engine, $basePath);
});
}
public function registerPhpEngine()
{
return new PhpEngine();
}
}
我们在其 register
方法实现中将 View
对象实例绑定到全局服务容器中,在初始化 View
对象的时候,需要先初始化 ViewEngine
对象,这里,我们通过配置文件配置系统使用的模板引擎:
'view.engine' => 'php', // 视图模板引擎
目前只有 PhpEngine
一个实现,所以我们将 view.engine
配置为 php
,如果后续支持其他模板引擎,在实现了对应的引擎类 XxxEngine
后,还要在这里实现对应的注册方法 registerXxxEngine
,最后在配置文件中配置 view.engine
值为 xxx
才可以使其生效。
另外,我们还在 app/config/app.php
新增配置 view.path
作为视图模板的根路径:
'view.path' => __DIR__ . '/../../views/', // 视图模板根路径
有了模板引擎实例和视图模板根路径后,就可以将它们传入视图管理器 View
的构造函数对其进行初始化了。
代码实现比较简单,不再逐一解释了。
最后,还要在 app/config/app.config
的 providers
中注册视图提供者:
'providers' => [
\App\Store\StoreProvider::class,
\App\Printer\PrinterProvider::class,
\App\View\ViewProvider::class,
]
以便在应用启动时调用其 register
方法注册 View
实例。
另外,为了让新增的 view.engine
和 view.path
配置生效,需要在 app/bootstrap.php
的 initConfig
方法中新增这两个配置的注册:
function initConfig(Container $container) {
...
$container->bind('view.engine', $config['view.engine']);
$container->bind('view.path', $config['view.path']);
}
重构配置文件及注册逻辑
为了免于后续新增配置需要频繁修改这里的代码,还可以通过 foreach
循环来重构这段注册代码,为此,我们需要先调整 app/config/app.config
:
<?php
return [
'app' => [
'name' => '学院君的个人网站',
'desc' => '让学习与进取者不再孤独',
'url' => 'https://laravel.geekai.co',
'store' => [
'default' => 'mysql',
'drivers' => [
'array' => [
],
'mysql' => [
'host' => '127.0.0.1',
'port' => 3306,
'dbname' => 'blog',
'charset' => 'utf8mb4',
'user' => 'root',
'password' => 'root',
]
]
],
'editor' => 'markdown', // 支持html和markdown
'providers' => [
\App\Store\StoreProvider::class,
\App\Printer\PrinterProvider::class,
\App\View\ViewProvider::class,
]
],
'view' => [
'engine' => 'php', // 视图模板引擎
'path' => __DIR__ . '/../../views/', // 视图模板根路径
]
];
这样一来,可读性更好,而且随着应用复杂度增高,配置项增多,也便于后期维护和拆分。
然后重构 bootstrap.php
中的 initConfig
方法实现如下:
function initConfig(Container $container) {
$configs = require __DIR__ . '/config/app.php';
foreach ($configs as $module => $config) {
foreach ($config as $key => $val) {
$container->bind($module . '.' . $key, $val);
}
}
}
在控制器中使用新的视图渲染方法
最后,我们需要重构所有控制器方法代码,使用新的视图模板渲染方法返回视图响应。
在此之前,先要在控制器基类 Controller
中新增一个 $view
属性,然后在构造函数中对其进行初始化:
<?php
namespace App\Http\Controller;
use App\Core\Container;
use App\Http\Request;
use App\Store\StoreContract;
use App\View\View;
class Controller
{
...
/**
* @var View
*/
protected $view;
public function __construct()
{
...
$this->view = $this->container->resolve('view');
}
}
接下来在各个控制器中重构视图渲染代码,将原来通过 include
语句引入视图模板改为通过 $this->view->render($path, $data)
返回视图响应:
// 首页:HomeController
class HomeController extends Controller
{
public function index()
{
...
$this->view->render('home.php', [
'albums' => $albums,
'pageTitle' => $pageTitle,
'siteName' => $siteName,
'siteDesc' => $siteDesc,
'siteUrl' => $siteUrl
]);
}
}
// 专辑页:AlbumController
class AlbumController extends Controller
{
public function list()
{
...
$this->view->render('album.php', compact('album', 'posts', 'pageTitle', 'siteName', 'siteUrl'));
}
}
// 文章页:‘
class PostController extends Controller
{
public function show()
{
...
$this->view->render('post.php', compact('post', 'album', 'pageTitle', 'siteUrl'));
}
}
可以看到 render
方法的第一个参数是视图模板路径,由于根路径已经通过配置文件设置并在底层生效,所以只需要传入相对根路径的相对路径即可,第二个参数是数组格式的、需要传入视图模板的 PHP 变量,这些变量可以通过数组形式定义传入,也可以通过 compact 函数组合当前作用域内的变量传入(以变量名作为键,变量值作为值构建关联数组,组合结果和前一种形式完全一样)。
验证重构结果
至此,我们就完成了视图模板引擎的编写和所有代码重构工作,运行 composer dump-auto
让上述代码修改引起的命名空间与目录映射变更生效,在浏览器访问应用所有页面都正常,则表示代码重构成功。
完成 MVC 中的 V(iew) 和 C(ontroller),下篇教程,我们一起来看看如何在原生 PHP 代码中引入 M(odel),即模型类的编写,并基于模型类实现数据库的查询,包括关联查询。
注:本篇教程的完整代码可以在 Github 中查看:https://github.com/nonfu/master-laravel-code/tree/v0.8/practice/blog,你可以在拉取源码后,通过
git checkout v0.8
切换到该分支。
No Comments