php-via:用 OpenSwoole + Datastar 写不碰 JavaScript 的响应式 PHP 页面
项目信息
- 仓库:
- 文档与在线示例:
- Packagist:
mbolli/php-via - License:MIT
- 客户端实时层:Datastar(SSE + DOM morphing)
- 异步运行时:OpenSwoole
- 模板引擎:Twig
- 设计上受 go-via/via 启发
php-via 把响应式状态留在服务端:Datastar 负责浏览器端响应性、SSE 与 DOM morphing,Twig 负责模板,PHP 侧由 OpenSwoole 提供异步能力。整个流程没有构建步骤——无转译、无打包、无 node_modules。每个页面默认带一条 SSE 长连接,配合 Brotli 压缩开销很低。
状态作用域是它比较特别的地方:TAB、ROUTE、SESSION、GLOBAL 以及自定义作用域决定谁共享状态、谁收到广播。
环境要求
- PHP 8.4+
- OpenSwoole PHP 扩展
- Composer
- Brotli PHP 扩展(可选,
Config::withBrotli()需要)
composer require mbolli/php-via
Quick Start
withTemplateDir(__DIR__ . '/templates');
$app = new Via($config);
$app->page('/', function (Context $c): void {
$count = $c->signal(0, 'count');
$step = $c->signal(1, 'step');
$c->action(function () use ($count, $step, $c): void {
$count->setValue($count->int() + $step->int());
$c->syncSignals();
}, 'increment');
$c->view('counter.html.twig');
});
$app->start();
counter.html.twig:
Count: {{ count.int }}
Step:
Increment
跑 php app.php,然后访问 。
核心概念
Signals
服务端与客户端同步的响应式状态。$name = $c->signal('Alice', 'name'); 读取用 $name->string(),写入用 $name->setValue('Bob'),写操作会自动推送到浏览器。
Actions
由客户端事件触发的服务端函数:
$save = $c->action(function () use ($c) {
$c->sync();
}, 'save');
模板里用 Save 触发。
触发 action 必须用 @post()(或 @patch/@put/@delete)。@get() 在 /_action/… 上被禁用,GET 请求返回 405 Method Not Allowed——允许 action 走 GET 等于开放顶层跨站导航 CSRF。
Scopes
| Scope | 共享范围 | 典型用途 |
|---|---|---|
Scope::TAB | 每个标签页隔离(默认) | 个人表单、设置 |
Scope::ROUTE | 同一路由下所有用户 | 共享看板、多人协作 |
Scope::SESSION | 同一会话的所有标签页 | 跨标签页状态 |
Scope::GLOBAL | 全局所有用户 | 通知、公告 |
"room:lobby" | 该自定义作用域内所有上下文 | 聊天室、游戏大厅 |
Views 与路径参数
$c->view('dashboard.html.twig', ['user' => $user]);,模板可以是文件也可以是内联字符串。
路径参数按名字自动注入:
$app->page('/blog/{year}/{slug}', function (Context $c, string $year, string $slug): void {
// ...
});
组件与生命周期钩子
组件是可复用的子上下文,状态隔离:$a = $c->component($counterWidget, 'a');
钩子示例:
$c->onDisconnect(fn () => /* ... */);
$c->setInterval(fn () => $c->sync(), 2000);
$app->onClientConnect(fn (string $id) => /* ... */);
$app->setInterval(fn () => $app->broadcast(Scope::GLOBAL), 5000);
onDisconnect 有一个宽限期(默认 5 秒)后才触发,用来容忍页面跳转和短暂重连,用 Config::withContextCleanupDelay() 调整。标签页被放到后台足够久导致上下文销毁后,重新返回时上下文会「复活」:服务端重建等价上下文(同 ID),并重新播种客户端仍持有的信号值,而不是硬刷新丢掉本地信号、滚动位置和焦点。该行为默认开启(10 分钟窗口),用 Config::withContextRevivalWindow() 调整或关闭。复活会重跑 page handler,因此服务端 #[Persist] 状态会重置,生命周期钩子会重新触发。
路由分组与中间件
$app->group('/admin', function (Via $app) {
// ...
})->middleware(new AuthMiddleware());
广播
$c->broadcast(); // 同作用域
$app->broadcast(Scope::GLOBAL);
$app->broadcast('room:lobby');
多节点广播
默认的 InMemoryBroker 对单进程部署是正确的。要把 broadcast() 扇出到多台服务器/容器,换成 RedisBroker 或 NatsBroker:
$config->withBroker(new RedisBroker('127.0.0.1', 6379)); // 需要 ext-redis + SWOOLE_HOOK_ALL
Redis 带认证与 TLS:
new RedisBroker(host: 'redis.internal', password: $_ENV['REDIS_PASSWORD'], tls: true)
NATS 走原生 OpenSwoole socket,不需要额外扩展:
new NatsBroker('127.0.0.1', 4222);
带 token 和 TLS 同理。两个 broker 都以指数退避自动重连,1s 起、30s 封顶。错误观测:
$config->onBrokerError(fn (\Throwable $e) => error_log('Broker: ' . $e->getMessage()));
健康端点
每个 php-via 服务都自带 GET /_health,无需配置:
{"status":"ok","version":"0.12.0","broker":{"driver":"RedisBroker","connected":true},"connections":{"contexts":42,"sse":38}}
broker 处于重连退避窗口时返回 HTTP 503。
工作原理
- 浏览器请求页面 → 服务端渲染 HTML,打开 SSE 流
- 用户点击按钮 → Datastar POST 信号值 + action ID
- 服务端执行 action → 修改信号/状态
- 服务端推送补丁 → HTML 片段 + 信号更新,通过 SSE
- Datastar morphs DOM → UI 无刷新更新
开发
git clone https://github.com/mbolli/php-via
composer install
composer run dev # 网站 + PHP 热重载 + CSS watcher,需 entr
composer run test
composer run watch-test
composer phpstan # PHPStan level 6
composer cs-fix
热 PHP 重载:改动 website/src/ 中的文件,worker 自动重启(约 1s),不断开其他连接;Twig 模板本身就是实时的,无需重启。
部署
建议单一 OpenSwoole 进程加反向代理(如 Caddy)。仓库 deploy/ 目录里有 systemd 服务文件和 Caddy 配置示例。典型链路:浏览器 → Caddy(TLS + Brotli 压缩)→ OpenSwoole :3000。
适合需要实时协作、看板、通知这类交互,但不希望引入前端构建链和 JS 状态管理的团队。要注意 Scope::GLOBAL 与广播的使用范围意味着状态都压在服务端进程里,多节点部署必须换 Redis/NATS broker;另外生命周期钩子和上下文复活的重跑语义,决定了哪些状态该放在服务端持久化里。