产物与部署
前置:编译渲染、组件;本页:pure compile 产物、pure check 契约检查、无依赖视图与部署缓存。
磁盘缓存仍会在每个请求中重建 Shape 树。若要在部署时完全不构建 Shape,可以用 pure 命令提前编译。
预编译产物
vendor/bin/pure compile src每个 *.cmp.php 单元会被编译成相邻的 *.pure.php 产物。产物带有 Shape 指纹并返回一个 Renderer,因此既不需要 Shape 树,也不需要编译缓存:
<?php
$page = require __DIR__ . '/page.pure.php';
echo $page->render(['title' => 'Users']);
$page->save(__DIR__ . '/out.html', ['title' => 'Users']);进阶:独立的 *.shape.php 模板
除单元外,pure compile 也会发现返回 Shape 的 *.shape.php 文件——没有调用函数的模板。 组件才是推荐形态;只有当没有组件可以承载该模板时才使用 shape 文件。
pure compile <路径>...接受文件与目录(递归查找),同时发现*.cmp.php单元与*.shape.php模板,并跳过内容已最新的文件:shape 仍会被加载与编译(因此它引用的任何文件 发生变化都能被感知),但内容一致的文件会以unchanged:报告而不是重写。--list不编译,直接打印实际类型:name -> file (component)、file (shape),以及带#[Template]的name -> file (template)。pure compile --check不写入任何 文件,当产物过期或缺失时以退出码 1 结束,适合放在 CI 步骤中。--plain会额外写出下文的 「无依赖视图」,--check --plain同时校验两种形态。典型输出是Card -> components/Card.cmp.php (component);独立模板会显示为views/page.shape.php (shape),带#[Template]的构建器会显示为pageShape -> views/page.cmp.php (template)。- 产物的渲染结果与运行时编译器完全一致,并且读起来就像模板: 标记仍是标记,动态值写成
<?= ... ?>,控制流使用替代语法,闭包只定义一次并导入类的短名。 HTML 片段承载的是精确的渲染字节,因此不会被重新缩进。产物的Renderer::$source为空——文件本身就是源码。 - 动态值通过
TemplateRuntime读取 Slot,编译语义集中在一处:必填 Slot 缺失时抛出MissingSlotException(必填的值 Slot 与 raw Slot 显式传入null也会失败,属性 Slot 则保持 按null省略),default:提供可选 Slot 的编译期默认值,转义与强制转换与平铺渲染器完全一致; 仅当 Slot 路径与键名不同时才出现path:。 - 产物还携带根作用域 Slot 清单(
Renderer::$slots),因此开发守卫无需重建 Shape 树就能报告 模板从未读取的 binding。
下面是生成文件的示意源码,不是应用 API。TemplateRuntime 的 import 由 pure compile 生成;应用代码仍应调用 Renderer::render():
<?php
$pureBody = static function (array $v): string {
ob_start();
try { ?><div class="card"><h1><?= TemplateRuntime::text($v, 'title') ?></h1><ul><?php
foreach (TemplateRuntime::items($v, 'items') as $item1):
$v2 = TemplateRuntime::scope($item1, 'items[]'); ?><li><?= TemplateRuntime::text($v2, 'label', path: 'items[].label') ?></li><?php
endforeach; ?></ul></div><?php
} finally {
$out = (string)ob_get_clean();
}
return $out;
};没有自动的文档声明:树按原样渲染,整份文档的文档声明(HTML 根是 <!DOCTYPE html>,XML/SVG 根是 XML 声明)由调用方拼接——标签树或组件调用交给 renderHTML() / renderXML(),直接渲染 Renderer 时用根标签的 documentHeader()。 examples/bootstrap 的组件都是建立在其上的单元:
<?php
use Pure\Component\Call;
use Pure\Core\Slot;
use function Pure\Component\{component, register};
use function Pure\SVG\{svg, svgUse};
function Icon(): Call
{
return component(__FUNCTION__);
}
register(Icon(...),
factory: static fn () =>
svg(
svgUse()->href(Slot::value('href'))
)->class(Slot::value('class')),
prepare: static function (string $href, string $class = 'bi'): array {
return ['href' => $href, 'class' => $class];
}
);<?php
use Pure\Component\Binds;
use Pure\Component\Call;
use Pure\Core\Slot;
use function Pure\Component\{component, register};
use function Pure\HTML\{body, head, html, title};
use function Pure\Utils\renderHTML;
function Features(): Call
{
return component(__FUNCTION__);
}
register(Features(...),
factory: static fn () =>
html(
head(title(Slot::value('title'))),
body(Slot::raw('content'))
),
prepare: #[Binds('title', 'content')] static fn (): array => [
// 页面决定有哪些区块;每个区块取自己的记录。
'title' => FeaturesService::pageTitle(),
'content' => FeaturesBody(),
]
);
function featuresPage(): string
{
// renderHTML() 补上文档声明;树本身不带文档声明。
return renderHTML(component('Features'));
}每个区块都从 service 层取自己的记录(如上面的 FeaturesService::pageTitle() 和 FeaturesBody()),因此页面函数不携带页面数据,给组件加一个 prop 也永远不需要改页面。
子组件渲染出的字符串直接进入 raw Slot——无需 (string) 强制转换——它们组成的列表按顺序拼接。
公共 component() 辅助函数会在单元或 shape 文件旁边存在产物、且产物不早于源文件时直接 加载产物,否则调用注册的工厂(每个编译 generation 一次)或编译 shape 文件(磁盘缓存仍然 生效)。它只产出片段——要文档声明就由调用方自己拼接。底层的 Registry::component() binder 属于内部细节,应用代码不应依赖其签名。
bootstrap 示例的 PlainFeaturesController 把同一份 bindings 交给示例自带的 plain() 助手(一个应用 函数:它 require 视图文件并展开数据);单一入口 public/index.php 为每个页面同时提供两种形态:/pure/features、/pure/pricing 走页面函数, /plain/features、/plain/pricing 走普通视图,开发时可以对照。
- 请使用与生产环境相同的 PHP 次版本号构建产物:
Shape::id()的盐值包含PHP_MAJOR_VERSION.PHP_MINOR_VERSION,产物头部也记录 PHP 版本;不要跨 PHP 次版本复制 产物或缓存。 - 产物是构建输出:修改 Shape 后需要重新构建。加载时不会校验 Shape 树,因此请用
--check发现过期产物。 - 产物还带有它的
Compile::CACHE_VERSION:加载由库的其他版本写出的产物时,抛出的是带pure compile提示的异常,而不是在产物所描述的Renderer签名上报错。版本号提升本身就会 使Compile::cachePath()里的渲染器失效,却绝不会使模板旁的产物失效,所以pure compile是升级流程的一部分。*.plain.php视图只在注释中携带版本号、没有可执行守卫,升级后会 静默输出过期内容,直到pure compile --check --plain发现不一致。 - 新鲜度有两套规则,并不是同一个检查。请求时只要产物的
filemtime()不比源单元旧就直接 使用:整秒粒度意味着与单元在同一秒写入的产物已经可用,tar、rsync 或 git checkout 造成的touch式时间偏移也不会丢弃它并逐请求重新编译。pure compile --check则是更严格的构建期 闸门——它重新生成期望的源码,再与产物逐字节比较。 - 两个会写出同一个产物的源文件(
a.shape.php紧邻a.cmp.php)会被pure compile一并 拒绝并返回退出码 1,因此a.pure.php归属哪个模板不会由发现顺序决定。 - 加载 Shape 文件时产生的输出会被丢弃;
pure compile只输出构建信息。
契约检查
pure check 校验每个单元的契约,让不匹配在 CI 中失败,而不是等到渲染时。它不是沙箱: 为了检查单元,它会 require 该文件,并在存在时执行注册的 factory 来构建 Shape。只应在隔离的 CI 进程中对可信源码运行;它不会写入产物。
vendor/bin/pure check src- 模板读取的 Slot 与单元返回的 bindings:模板不读取的 binding 是错误(并给出
did you mean建议),必填 Slot 没有被绑定也是错误。prepare()钩子把 props 变成绑定: 返回字面量数组时,这些键会被解析;返回的是...bindings()助手结果或运行时计算值时, 用#[Binds(...)]声明键名,否则将报告为info。 prepare()钩子的参数类型与 Slot 种类:列表 Slot 需要可迭代值、child 作用域需要数组、 值 Slot 需要可字符串化值、raw Slot 两者皆可。必填 Slot 对应的可空参数是警告(绑定null会抛出MissingSlotException);既未被函数使用、也不是模板 Slot 的参数同样是警告。- 没有
prepare()的单元改为检查调用点:props 就是模板 Slot,setter 必须匹配;旁边的 函数若不返回Pure\Component\Call会报错。 - 声明注解用来表达签名说不出的信息。
#[Prop]带slot(该 prop 绑定的 Slot 名)、item(列表 prop 的每一项在Slot::each的 item Shape 里填的 Slot)、required(调用方 义务)与deprecated(迁移提示);#[Trusted]标记携带已渲染标记的 prop,它必须绑定 raw Slot,且调用方传入的不是Pure\Core\Markup时开发守卫会告警;#[Binds]用在 hook 或...bindings()辅助函数上,在字面量数组读不出来时声明返回的键。声明会与签名和 模板比对;当prepare()返回的不是一个可读的字面量数组时,模板的必填 Slot 改为与声明 比对;调用点绑定了已废弃的 prop 会得到警告。 - 每个被检查文件里的链式调用:目标不接受的
->prop(...)是错误(附did you mean建议),->children(...)会提示应把 children 传给调用本身,从变量展开的 prop 集合会 被跳过。绑定为数组字面量的列表 prop 会逐项与 Slot 的 item Shape 比对(缺少必填键、读取了 Shape 不认识的键都是错误)。目标必须在被检查的文件中,才能得知它接受哪些 props。 - 同一个模板把一个 Slot 同时用作标量(value/raw)与作用域(child/each)是错误;
*.shape.php模板也会做这项检查。
有错误时退出码为 1,带 --strict 时警告也返回 1。pure check 不看产物—— 产物新鲜度由 pure compile --check 负责。
如何阅读输出
每条诊断都是一行 path:line: level: message,正是编辑器和 CI 注解解析器能识别的 形式。行号指向真正需要改动的那一行:链式调用指向 ->prop(...) 这个 setter, 单元级诊断只指向文件、不带行号。
components/Page.cmp.php:21: error: component 'Card': the call binds 'titel', which the target does not accept (did you mean 'title'?)checked N unit(s): E error(s), W warning(s) 汇总与本次运行的结果对账;本次运行 打印了 info 发现时,会追加 , X note(s)——这些提示不会让命令失败,但它们说明了 哪些内容没能被检查。
结果与问题走不同的流。ok: 行、info 提示和 checked N unit(s) 汇总属于结果, 留在 stdout;错误、警告,以及 pure compile --check 的 missing: / stale: 属于问题,写入 stderr。于是 pure check . 2>/dev/null 留下的是结果——并不完全等于 "只留通过的部分",因为汇总行仍会报出计数;pure check . 1>/dev/null 留下的是问题, 其中包含非 --strict 下不会让命令失败的警告。请以退出码作为门禁,用汇总行读计数。
三个退出码含义互不相同,脚本可以据此区分:
| 退出码 | 含义 |
|---|---|
0 | 没有任何问题,或 --check 下没有过期产物 |
1 | 有错误、--strict 下有警告、产物过期,或某个文件无法读取 |
2 | 命令行本身写错了:未知命令、未知选项,或缺少路径 |
组件产物与缓存策略
每个组件都是一个 *.cmp.php 单元,因此 pure compile 会像其他模板一样为它构建产物。绑定器会比较产物与单元文件的 mtime:产物较新就直接加载(不调用 工厂、不建树、不算指纹),过期或缺失则调用注册的工厂或编译 shape 文件。CI 中用 pure compile --check 可以发现过期产物(退出码 1)。
Compile::cachePath() 会以 0700 创建缺失目录,并拒绝组或其它用户可写的权限;当 POSIX 属主 API 可用时也会拒绝属主不符的目录,但不会检查路径是否位于 Web 根目录之外。请把缓存放在 文档根目录之外的专用目录,并使用严格的权限。
开启哪些取决于部署形态:
- PHP-FPM——开启
Compile::cachePath()并构建产物。没有产物时,每个请求都要为该组件 重建 Shape 树、遍历指纹,然后才渲染。产物会把这部分工作替换为一次require。 - 长驻 worker(RoadRunner、Swoole、FrankenPHP)——开启
Compile::cachePath()并保留 内部绑定器的路径缓存(内联树用static $render);renderer 常驻内存,产物可选。 opcache.preload——preload 只把代码常驻内存,不会让 static 变量跨请求保留(PHP preload RFC 已明确说明),因此不能替代上面两种做法。
开启 opcache 后,生产环境可以直接加载已编译的组件代码;选择缓存布局前, 请在实际部署中自行实测。
无依赖导出(可选)
pure compile --plain 会在产物旁边额外写出 *.plain.php:只有标记与原生 PHP, 渲染时不需要安装 purephp。加载时把数据展开成局部变量,并把展开操作放进隔离闭包,避免覆盖调用方的局部变量:
<?php
$render = static function (array $viewData): string {
extract($viewData, EXTR_SKIP);
ob_start();
try {
require 'views/index.plain.php';
$output = (string) ob_get_clean();
} catch (\Throwable $error) {
ob_end_clean();
throw $error;
}
return $output;
};
$html = $render($data);EXTR_SKIP 不会覆盖已经存在的变量;如果数据键与 viewData 等局部变量同名,该键会被 跳过。在旧式的顶层加载写法中,data、html 等键可能与加载器自身冲突。请检查生成的 视图并显式映射这些键(只有在意图明确的可信隔离作用域中才使用 EXTR_OVERWRITE)。
需要让视图脱离库运行时才用 --plain:例如部署只带 public/ 与 views/, 或把模板目录交给其他人。常规数据下无依赖视图逐字节等于产物,并且仅当其根是文档根 (<html> 或 XML 树)时前面补上文档声明:页面保留自己的 <!DOCTYPE html> / XML 声明, 而片段(div、内联 SVG 图标)视图直接以标记开头,因此被 include 时绝不会把文档声明 插进文档中间。而且它是最快的形态:值直接进入 htmlspecialchars(),没有运行时访问器调用。 但它是普通视图而非编译组件,严格 Slot 语义仍由产物提供——语义差异与 @var 注解说明见无依赖视图注意事项。
无依赖视图注意事项
无依赖视图是标记 + 原生 PHP——脱离 purephp 也能渲染,但它不携带编译渲染器的 严格 Slot 语义:
- 必填 Slot 缺失是未定义变量,不再抛出
MissingSlotException; - 必填 Slot 显式传入
null时渲染为空,而不是像产物那样(值 Slot 与 raw Slot)失败; - 文档根的视图会带上
<!DOCTYPE html>/ XML 声明,而片段视图以标记开头; null属性输出为空值,而不是整个属性消失;- 列表 Slot 不再校验可迭代性,值的字符串化交给 PHP 而不是
SlotRuntime; - raw Slot 与产物一样用
implode('')拼接可字符串化值的可迭代集合;PHP 无法字符串化的元素 会被强转(警告 +Array),而不是像产物那样抛InvalidArgumentException。
顶层 Slot 读取为普通变量,嵌套 Slot 读取为它所在的数组,转义直接内联,因此它和手写 模板一样可移植。
使用组件时,控制器会先渲染组件、再把它们的标记作为 raw bindings 传给页面 Shape,因此视图文件仍然无依赖,而请求处理器会用到库。
视图会按 Shape 结构为每个顶层 Slot 生成 @var 注解,静态分析器无需排除规则或额外 配置即可读取这些展开的局部变量:
<?php
/**
* @var scalar|null|\Stringable $title
* @var array{columns: ...} $content
*/值 Slot 是 scalar|null|\Stringable(即 htmlspecialchars() 可接受的类型),条件 Slot 是 mixed,child 与列表作用域会推导成 array shape 及它们的 iterable;特殊 Slot 名声明在加载器的 $data 数组上。这些注解只是注释,不增加任何输出字节。唯一仍会告警的 是没有数组默认值的可选容器 Slot:生成的读取会回退到 null,注解如实反映这一点。
视图是 include,请在生产开启 opcache:关闭时每次渲染都会重新解析文件。
可信产物与 CSP
*.pure.php 是由源码树生成的可执行 PHP。源码与生成产物都应视为可信代码:在受控 CI 中 构建并验证产物,限制写入权限,不要把产物作为公开静态文件提供,也不要接受不可信用户上传的 产物。无依赖视图虽然运行时不依赖库,但同样属于生成代码。
Raw::of()、Slot::raw() 与组件 Markup 子节点都会绕过转义,不会替你校验 URL、事件处理器、 样式或脚本。请在应用边界校验这些输入;页面有意包含活动标记时,还应发送合适的浏览器 Content Security Policy,例如:
Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'请按应用可信资产调整策略;CSP 是纵深防御,不能替代输入校验。
下一步
- 编译渲染 - Shape 编译、运行时缓存与限制
- 组件 - Component:Shape 的包装与高级用法
- Compile API -
Compile、Shape、Renderer类参考