1. 内容整体设计与思路拆解
1.1 为什么第37天讲 MVC
先把话说在前面:MVC 不是什么高深莫测的架构,它就是 Web 开发里最老牌、最经典的一套代码组织思路。到今天为止,各种框架换了多少代,Spring MVC、Ruby on Rails、Django、Laravel、Express 里都能看到它的影子。你哪怕不用框架、原生写 PHP 或者 Node.js,只要页面和数据逻辑分开写,其实已经在往 MVC 的路子上走了。
标题里这个“Day37”,更像是一个学习路线上的节点。前面如果已经写过接口、写过页面、操作过数据库,那这个节点就特别合适:把过去散落的代码重新归类,搞清楚哪块归哪块管。如果你刚入门,还没写过完整项目,我建议先拿本教程里的一个小 Demo 跑通,再回来看这篇,感受会完全不同。
这一篇我们要做的事很明确:先吃透 MVC 三个角色的职责边界,然后不依赖任何框架,手写一个能跑起来的简易 MVC 框架。为什么强调手写?因为你只有亲手拆过一遍轮子,才知道框架替你省了多少事,也才能够在框架出问题时,不至于一脸懵地瞎猜。
1.2 从“一堆文件”到“三层结构”
很多刚开始写 Web 项目的同学,代码组织往往是这样的:一个 index.php 或者 app.js 里,数据库查询写在前面,HTML 拼接在中间,乱七八糟的工具函数丢在后面,一个文件几百行甚至上千行。最痛苦的不是写的时候,而是三个月之后你自己回来看,都不记得某个变量是干嘛的。
MVC 要解决的,就是这种混乱。
它把应用程序拆成三层:
| 角色 | 全称 | 核心职责 |
|---|---|---|
| M | Model(模型) | 负责数据。操作数据库、处理业务规则、校验数据,都算 Model 的事 |
| V | View(视图) | 负责展示。把数据渲染成 HTML、JSON、XML,只做输出,不做逻辑 |
| C | Controller(控制器) | 负责调度。接收请求,调用 Model 拿数据,再决定让哪个 View 去渲染 |
一句话总结:Controller 是大脑,Model 是仓库,View 是门面。三者各司其职,代码才能越写越清晰。
当然,纯理论说一万遍不如上手跑一遍。接下来我们就用原生 PHP 从零开始搭一个微型 MVC 框架,全程不引入任何依赖,你只要本地有 PHP 环境和一个小型数据库(哪怕是 SQLite 都行),就能完整跟着做下来。
2. 环境准备与前置知识
2.1 需要准备的工具
手写 MVC 框架,最怕的就是环境折腾半天还没开始写正题。我的建议是先把这些备齐:
- PHP 8.1 以上版本(7.4 也行,但强类型特性用起来没那么爽)
- Composer(其实不用也行,但建议装上,后面扩展方便)
- 一个 Web 服务器:内置的
php -S开发服务器就完全够用,没必要上 Nginx / Apache - 数据库建议先用 SQLite,零配置、单文件,跑通逻辑之后再换 MySQL
我实测下来,PHP 自带的开发服务器是最省心的:
php -S localhost:8080 -t public用这个命令启动之后,所有请求都会先进public/index.php,正好适合做前端控制器。
2.2 路由的基本思想
在动手写框架之前,得先把“路由”这个概念讲透。你访问一个 URL,比如:
http://localhost:8080/index.php?controller=user&action=show&id=1这种写法虽然能用,但丑,而且不好扩展。RESTful 一点的现代框架,路由长这样:
http://localhost:8080/user/show/1我们要做的,就是把后面这段user/show/1解析出来,拆成三部分:控制器名、方法名、参数。这就叫路由解析。
在框架内部,我们会维护一张路由表,把 URL 里面的路径映射到具体的Controller@Method上。这一步做到了,后面所有请求都会以统一的入口进来,框架才算真正立起来。
2.3 目录结构设计
动手写代码之前,先把项目结构定下来。我的设计是这样:
my-mvc/ ├── app/ │ ├── controllers/ │ ├── models/ │ ├── views/ │ └── core/ ├── config/ ├── public/ │ └── index.php └── .htaccess(Apache 环境下用)app/controllers/放控制器,统一继承BaseControllerapp/models/放数据模型,统一继承BaseModelapp/views/放视图模板,推荐用原生 PHP 做模板引擎app/core/放框架核心类:Router、Controller、Model、View、Databasepublic/是唯一对外可见的目录,其他目录都封闭起来,安全性好很多
这样设计的好处是:开发的时候,你闭着眼都知道某段代码该放哪;上线之后,也只用把public目录设置为站点根目录,其他文件外人根本访问不到。
3. 框架核心代码实现
3.1 前端控制器(入口文件)
先写入口文件,它是所有请求的必经之路:
<?php // public/index.php declare(strict_types=1); const ROOT_PATH = dirname(__DIR__); spl_autoload_register(function (string $class): void { $prefix = 'App\\'; $baseDir = ROOT_PATH . '/app/'; if (strpos($class, $prefix) === 0) { $relativeClass = substr($class, strlen($prefix)); $file = $baseDir . str_replace('\\', '/', $relativeClass) . '.php'; if (file_exists($file)) { require $file; } } }); require ROOT_PATH . '/app/core/Router.php'; require ROOT_PATH . '/app/core/Request.php'; require ROOT_PATH . '/app/core/Response.php'; use App\Core\Router; use App\Core\Request; $request = new Request(); $router = new Router(); // 这里加载路由配置,后续会把具体规则抽到 routes.php 里 $router->add('GET', '/user/show/{id}', 'UserController@show'); try { $route = $router->dispatch($request->method(), $request->path()); $controllerName = 'App\\Controllers\\' . $route['controller']; $actionName = $route['action']; $controller = new $controllerName(); if (!method_exists($controller, $actionName)) { throw new RuntimeException("Action not found: {$actionName}"); } $response = call_user_func_array([$controller, $actionName], $route['params']); if ($response instanceof Response) { $response->send(); } else { echo $response; } } catch (Throwable $e) { http_response_code(500); echo '500 Internal Server Error: ' . htmlspecialchars($e->getMessage()); }这里用了自动加载,省去每个文件都手动 require 的麻烦。入口文件只干一件事:解析请求,分发给控制器,拿到结果直接输出。这就是 MVC 里的“前端控制器”模式。
注意:生产环境不建议把异常详情直接打到页面上,这样会泄露框架内部结构。演示阶段为了排查方便,先这样处理;上线前要换成日志记录加友好报错页面。
3.2 路由系统实现
路由是框架的“翻译官”,我们要把/user/show/1这种路径,转成一个数组['controller' => 'UserController', 'action' => 'show', 'params' => [1]]。
<?php // app/core/Router.php namespace App\Core; class Router { private array $routes = []; public function add(string $method, string $path, string $handler): void { $this->routes[] = [ 'method' => $method, 'path' => $path, 'handler' => $handler, ]; } public function dispatch(string $method, string $uri): array { foreach ($this->routes as $route) { if ($route['method'] !== $method) { continue; } $pattern = preg_replace('/\{(\w+)\}/', '(?P<$1>[^/]+)', $route['path']); $pattern = '#^' . $pattern . '$#'; if (preg_match($pattern, $uri, $matches)) { $params = array_filter($matches, 'is_string', ARRAY_FILTER_USE_KEY); [$controller, $action] = explode('@', $route['handler']); return [ 'controller' => $controller, 'action' => $action, 'params' => $params, ]; } } throw new RuntimeException('No route found for ' . $method . ' ' . $uri); } }核心技巧是这行正则替换:
$pattern = preg_replace('/\{(\w+)\}/', '(?P<$1>[^/]+)', $route['path']);它会把{id}自动变成(?P<id>[^/]+),也就是说,你的路由规则里想定义几个参数都行,代码闭着眼睛都能适配。这就是“约定优于配置”的雏形:你只需要像写自然语言一样声明路径,规则自动生成。
3.3 请求与响应封装
有了路由,还得让控制器用起来顺手。Request 类就是干这个的:
<?php // app/core/Request.php namespace App\Core; class Request { public function method(): string { return $_SERVER['REQUEST_METHOD'] ?? 'GET'; } public function path(): string { $uri = $_SERVER['REQUEST_URI'] ?? '/'; $path = parse_url($uri, PHP_URL_PATH); $path = '/' . trim($path, '/'); return $path === '/' ? '/' : rtrim($path, '/'); } public function query(string $key, $default = null) { return $_GET[$key] ?? $default; } public function input(string $key, $default = null) { return $_POST[$key] ?? $default; } }Response 类也不复杂,关键在于统一出口,以后想加缓存头、跳转、JSON 序列化都方便:
<?php // app/core/Response.php namespace App\Core; class Response { private int $statusCode = 200; private array $headers = []; private string $content = ''; public function setStatus(int $code): self { $this->statusCode = $code; return $this; } public function setHeader(string $name, string $value): self { $this->headers[$name] = $value; return $this; } public function setContent(string $content): self { $this->content = $content; return $this; } public function json(array $data): self { $this->setHeader('Content-Type', 'application/json'); $this->setContent(json_encode($data, JSON_UNESCAPED_UNICODE)); return $this; } public function send(): void { http_response_code($this->statusCode); foreach ($this->headers as $name => $value) { header("{$name}: {$value}"); } echo $this->content; } }我故意把json()做成了返回$this的形式,这样在控制器里就能链式调用:
return $response->json(['data' => $list]);3.4 基础控制器与模型
BaseController 在设计上保持极简:注册一个 View 实例,控制器通过$this->view()来渲染模板。
<?php // app/core/BaseController.php namespace App\Core; abstract class BaseController { protected View $view; public function __construct() { $this->view = new View(); } protected function view(string $template, array $data = []): Response { return $this->view->render($template, $data); } }View 类很简单,它其实是个“模板加载器”:
<?php // app/core/View.php namespace App\Core; class View { private string $viewPath = ROOT_PATH . '/app/views/'; public function render(string $template, array $data = []): Response { extract($data, EXTR_SKIP); ob_start(); require $this->viewPath . $template . '.php'; $content = ob_get_clean(); $response = new Response(); $response->setContent($content); return $response; } }这里用到了两个 PHP 内置函数:extract()把关联数组变成变量,ob_start()搭配ob_get_clean()把模板文件的内容捕获到缓冲区,而不是直接打印。这两个函数是简化模板渲染的大杀器,原理其实就是“输出缓冲”,你只要记得:缓冲区里的一切最终会被我们打包成 Response 再统一输出。
BaseModel 这里我做得更狠——直接只用 PDO 做一层薄封装。为什么不用复杂的 ORM?因为我们要拆解原理,ORM 反而把 SQL 的细节藏起来了:
<?php // app/core/BaseModel.php namespace App\Core; use PDO; abstract class BaseModel { protected PDO $db; public function __construct() { $this->db = new PDO('sqlite:' . ROOT_PATH . '/database.sqlite'); $this->db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); } protected function query(string $sql, array $params = []): \PDOStatement { $stmt = $this->db->prepare($sql); $stmt->execute($params); return $stmt; } protected function fetchAll(string $sql, array $params = []): array { return $this->query($sql, $params)->fetchAll(PDO::FETCH_ASSOC); } protected function fetch(string $sql, array $params = []): ?array { $row = $this->query($sql, $params)->fetch(PDO::FETCH_ASSOC); return $row === false ? null : $row; } protected function execute(string $sql, array $params = []): bool { return $this->query($sql, $params)->rowCount() > 0; } }SQLite 的 PDO 连接就一句话的事,连配 置文件都省了。但要注意,如果换成 MySQL,连接字符串就得改成这样:
$this->db = new PDO('mysql:host=127.0.0.1;dbname=dbname;charset=utf8mb4', 'root', 'password');其余代码不用动,因为 BaseModel 的上层接口(fetch/fetchAll/execute)和具体数据库无关。
4. 核心业务:用户模块实战
4.1 搭建数据表
理论讲完,直接上一个完整的用户模块,走一遍“请求 → 控制器 → 模型 → 视图”的完整链路。
先初始化数据库:
<?php // 自动建表的脚本,只跑一次,建议放到 install.php $db = new PDO('sqlite:' . __DIR__ . '/database.sqlite'); $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $db->exec(" CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, email TEXT NOT NULL UNIQUE, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) "); echo "数据库初始化成功";4.2 用户模型 UserModel
模型层的规则是:只负责数据逻辑,不关心页面展示。这里我写一个 UserModel:
<?php // app/models/UserModel.php namespace App\Models; use App\Core\BaseModel; class UserModel extends BaseModel { public function all(): array { return $this->fetchAll('SELECT id, name, email, created_at FROM users ORDER BY id DESC'); } public function find(int $id): ?array { return $this->fetch('SELECT id, name, email, created_at FROM users WHERE id = ?', [$id]); } public function create(string $name, string $email): bool { $email = filter_var($email, FILTER_VALIDATE_EMAIL); if ($email === false) { throw new \InvalidArgumentException('邮箱格式不正确'); } return $this->execute( 'INSERT INTO users (name, email) VALUES (?, ?)', [$name, $email] ); } public function update(int $id, string $name, string $email): bool { return $this->execute( 'UPDATE users SET name = ?, email = ? WHERE id = ?', [$name, $email, $id] ); } public function delete(int $id): bool { return $this->execute('DELETE FROM users WHERE id = ?', [$id]); } }你发现没有,这里的方法全部用“动作命名”,all、find、create、update、delete,一看就知道这个模型能干什么,比直接写一串 SQL 可读性高多了。这就是把数据操作收敛到模型层最直观的收益。
4.3 用户控制器 UserController
控制器是调度中心,它的任务就是:接收用户输入、调用模型拿数据、把数据交给视图去渲染。注意,控制器里不应该出现直接拼 SQL 或者拼 HTML 的行为。
<?php // app/controllers/UserController.php namespace App\Controllers; use App\Core\BaseController; use App\Core\Request; use App\Core\Response; use App\Models\UserModel; class UserController extends BaseController { private UserModel $userModel; public function __construct() { parent::__construct(); $this->userModel = new UserModel(); } public function index(): Response { $users = $this->userModel->all(); return $this->view('user/list', ['users' => $users]); } public function show(Request $request, int $id): Response { $user = $this->userModel->find($id); if ($user === null) { $response = new Response(); $response->setStatus(404); $response->setContent('用户不存在'); return $response; } return $this->view('user/show', ['user' => $user]); } public function create(Request $request): Response { return $this->view('user/create'); } public function store(Request $request): Response { $name = $request->input('name', ''); $email = $request->input('email', ''); try { $this->userModel->create($name, $email); $response = new Response(); $response->setStatus(302); $response->setHeader('Location', '/user/index'); return $response; } catch (\InvalidArgumentException $e) { $response = new Response(); $response->setStatus(400); $response->setContent($e->getMessage()); return $response; } } public function edit(Request $request, int $id): Response { $user = $this->userModel->find($id); if ($user === null) { $response = new Response(); $response->setStatus(404); $response->setContent('用户不存在'); return $response; } return $this->view('user/edit', ['user' => $user]); } public function update(Request $request, int $id): Response { $name = $request->input('name', ''); $email = $request->input('email', ''); $this->userModel->update($id, $name, $email); $response = new Response(); $response->setStatus(302); $response->setHeader('Location', '/user/show/' . $id); return $response; } public function destroy(Request $request, int $id): Response { $this->userModel->delete($id); $response = new Response(); $response->setStatus(302); $response->setHeader('Location', '/user/index'); return $response; } }我特别留意了一件事:show、edit、update、destroy都要求传$id,所以它们的路由定义里一定要有{id}这个占位符。注册路由的时候、写模板链接的时候,少传一个都会直接 404,这个非常容易踩坑。
4.4 视图模板
视图里最重要的原则:只做输出,不做业务。哪怕是一个if判断,我都尽量保持简单,复杂的计算全部丢给控制器提前算好。
用户列表模板:
<?php // App/views/user/list.php ?> <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>用户列表</title> <style> body { font-family: sans-serif; padding: 2rem; } table { border-collapse: collapse; width: 100%; max-width: 800px; } th, td { border: 1px solid #ddd; padding: 8px; text-align: left; } th { background-color: #f4f4f4; } a { margin-right: 8px; } </style> </head> <body> <h1>用户列表</h1> <a href="/user/create">新增用户</a> <table> <thead> <tr> <th>ID</th> <th>姓名</th> <th>邮箱</th> <th>创建时间</th> <th>操作</th> </tr> </thead> <tbody> <?php if (empty($users)): ?> <tr><td colspan="5">暂无用户</td></tr> <?php else: ?> <?php foreach ($users as $user): ?> <tr> <td><?= (int)$user['id'] ?></td> <td><?= htmlspecialchars($user['name'], ENT_QUOTES, 'UTF-8') ?></td> <td><?= htmlspecialchars($user['email'], ENT_QUOTES, 'UTF-8') ?></td> <td><?= htmlspecialchars($user['created_at'], ENT_QUOTES, 'UTF-8') ?></td> <td> <a href="/user/show/<?= (int)$user['id'] ?>">查看</a> <a href="/user/edit/<?= (int)$user['id'] ?>">编辑</a> <a href="/user/destroy/<?= (int)$user['id'] ?>" onclick="return confirm('确定删除?')">删除</a> </td> </tr> <?php endforeach; ?> <?php endif; ?> </tbody> </table> </body> </html>注意两个细节:第一,所有用户输出的地方都套了htmlspecialchars(),这是防 XSS 的基本底线;第二,id我做了强转(int),即使传入一个奇怪的字符串也不会出问题。
新增表单模板:
<?php // App/views/user/create.php ?> <form method="post" action="/user/store"> <label>姓名</label> <input type="text" name="name" required> <label>邮箱</label> <input type="email" name="email" required> <button type="submit">提交</button> </form>4.5 路由注册
最后把路由表补全,让整个应用转起来:
// 在 public/index.php 里添加路由 $router = new Router(); $router->add('GET', '/user/index', 'UserController@index'); $router->add('GET', '/user/create', 'UserController@create'); $router->add('POST', '/user/store', 'UserController@store'); $router->add('GET', '/user/show/{id}', 'UserController@show'); $router->add('GET', '/user/edit/{id}', 'UserController@edit'); $router->add('POST', '/user/update/{id}', 'UserController@update'); $router->add('POST', '/user/destroy/{id}', 'UserController@destroy');到这里,一个能增删改查的完整 MVC 应用就成型了。你启动php -S localhost:8080 -t public,然后打开浏览器访问/user/index,就能看到整个流程在跑。
5. 常见问题与排查技巧实录
5.1 我踩过的坑:响应头已发送导致跳转失败
第一次写 Response 类的时候,我在header()调用前不小心 echo 了一个空格,结果 302 跳转直接失效。经验教训是:Response 所有的头信息必须在任何输出之前设置。在入口文件里,send()方法做完header()之后才 echo 内容,这个顺序绝对不能反。
如果你用的是内置开发服务器,遇到这类问题先检查有没有 BOM 头、有没有多余空格,这是非常隐蔽的问题。
5.2 路由匹配不到:URL 尾斜杠坑
我一开始的path()方法没有做尾斜杠处理,结果/user/show/1/和/user/show/1匹配不上。解决办法就是:
$path = '/' . trim($path, '/'); $path = $path === '/' ? '/' : rtrim($path, '/');注意顺序:去掉头尾斜杠之后,如果根路径就补一个/回去,否则parse_url会返回空字符串,直接 404。
5.3 模型查询结果是 null 还是空数组
find()方法我特意设计成“查不到就返回 null”,而all()是“查不到就返回空数组”。这个区分意义很大:控制器里可以用=== null来判断资源是否存在,从而决定是渲染 404 还是正常页面。
| 场景 | 返回值 | 控制器处理 |
|---|---|---|
| 列表为空 | [] | 视图里显示“暂无数据” |
| 单条记录不存在 | null | 返回 404 |
5.4 绝对路径与相对路径混乱
刚开始写模板里的链接时,我用了/user/show/1这种绝对路径,所以没问题。如果你用了相对路径比如user/show/1,那么当下级目录出现时,链接就会指向错误的位置。建议统一使用根路径开头的绝对路径,这样无论当前页面在哪个层级,都不会出错。
5.5 表单重复提交与刷新问题
用POST /user/store新增用户后,如果直接刷新页面,浏览器会提示“是否重新提交表单”,这是原生 HTML 表单的问题。我的解决思路是 PRG 模式(Post/Redirect/Get):
// store 方法末尾 $response = new Response(); $response->setStatus(302); $response->setHeader('Location', '/user/index'); return $response;先 POST 处理数据,然后 302 跳转到 GET 页面,这样用户刷新时就只会刷新 GET 请求,不会重复写入数据。这个模式在所有 Web 框架里几乎都是标配。
5.6 原生 SQL 注入风险
虽然我们用 PDO 预处理,已经基本规避了注入风险,但如果你习惯用字符串拼接 SQL,迟早出事:
// 危险写法,千万不要学 $sql = "SELECT * FROM users WHERE id = " . $_GET['id'];预处理的关键在于,SQL 结构和数据是分开发送的,值永远不会被当成 SQL 代码执行。在 BaseModel 里推荐全部走prepare()+execute()的方式。
6. 框架的局限与下一步扩展方向
6.1 简易框架有哪些不足
既然是手写 Demo,我首先要坦率承认,这个框架非常“玩具”。
它没有中间件机制,认证、日志、跨域这些通用问题都得在每个控制器里自己重复写;没有依赖注入容器,每个控制器里手动new模型,管理起来会越来越累;路由不支持正则规则、没有缓存;模板没有布局继承,每个页面都必须重复写头部尾部 HTML。这些在平时开发时感受不明显,一旦项目真正上线、访问量上来,都会变成瓶颈。
但也正因为这样,手写框架是最适合理解 MVC 本质的教材。你可以看到:路由、请求、响应、模型、视图、控制器,每个环节都是“看得见、摸得着”的代码,没有任何黑盒。
6.2 如果想升级,优先级排序
如果你不满足于这个 Demo,想往工程化方向走,我个人建议按这个顺序扩展:
第一,加入依赖注入容器。让控制器不用手动new依赖,而是由容器自动创建并注入,代码的可测试性会大幅提升。
第二,把路由抽成独立文件,比如config/routes.php,支持注解或属性路由。这样新增一个功能就不再需要改动入口文件,符合“开闭原则”。
第三,引入中间件机制。认证、日志、CSRF 防护都可以做成中间件,挂在路由上按需启用,既省代码又安全。
第四,模板引擎增强。哪怕不引入第三方库,你也可以手动实现模板继承:layout留下content区块,子视图填充内容。PHP 的ob_start嵌套其实就能做到。
6.3 和其他框架的对照
学完手写框架,再看那些工业级框架,你会有一种“原来如此”的顿悟感。以某个流行的 PHP 框架为例,它的核心就是一个:
| 手写框架 | 工业级框架 |
|---|---|
Router手动匹配正则 | 自带路由缓存、资源路由、别名路由 |
Request读取超全局变量 | PSR-7 标准接口,可测试、可替换 |
BaseModel封装 PDO | Eloquent ORM 等,支持模型关联、迁移 |
View手动加载模板 | 编译型模板引擎,带组件与布局系统 |
| 没有中间件 | 全局/路由级中间件,管道式处理 |
对照完你会明白,框架本身并不是魔法,只是在你手写的这套骨架上,把每一个环节都做深做透了而已。
6.4 实际项目中使用 MVC 的几个建议
根据我自己的经验,MVC 设计模式本身解决不了所有问题,但它给团队协作带来的收益是实实在在的:
给新来的同事分任务时,你只需要说“你去改 Model 层的 UserModel,加一个统计方法”,对方就能精准定位。代码 Review 时,改动范围一目了然:视图层改了就是调样式,模型层改了就是动数据逻辑,控制器改了就是改调度规则。这种“位置即职责”的好处,只有项目代码上到一定规模之后才体会得到。
另一个建议是:即使你不打算自己做框架,也值得在项目里刻意练习一次 MVC 分层。比如先在小项目里用原生代码写,再切换到框架开发,心态会完全不一样。框架里的“自动路由”“依赖注入”“模型关联”这些高级功能,你在手写版本里找到了对应位置,学起来就不容易迷路。
7. 手动实现 MVC 的关键收获
回到 Day37 这个节点,手动实现简易 MVC 框架,最有价值的并不是那一堆能跑通的代码,而是对分层思想的彻底理解。
之前写项目,数据库语句、页面 HTML、逻辑判断混在一起,代码像一碗浆糊。分清楚 Model、View、Controller 之后,最大的变化是大脑负担变轻了:打开一个文件,你不需要同时记住数据怎么查、页面怎么渲染、请求怎么分发,只需要专注于当前这一层要做的事。
我自己的体会是,手写框架至少有三件事是文档上学不到的:第一,路由正则解析的细节,比如占位符转具名分组,不通读源码根本不知道框架帮你处理了多少边界;第二,输出缓冲在模板渲染里的妙用,多一个空格都会破坏响应头顺序;第三,控制器如何组织代码才能既保持简洁、又不把业务逻辑漏到视图里,这个小项目就是最好的练习场。
如果你接着往下学,手头这个项目的代码别丢,试着给它加一个登录功能、加一个布局模板、加一个简单的容器,每加一个功能,你对 MVC 的掌控力都会更上一层。你可能永远不会在生产环境里再用这个手写框架,但你没白写,因为接下来无论你用哪个框架,都会比没写过的人多一层“知其所以然”的底气。