Nova PJAX / ORM 迁移指南
Note
适用对象:基于 Nova 的业务项目(wiki / book / 后台等)。
涉及子模块:nova/plugin/orm、nova/plugin/tpl、nova/plugin/login、app/static/framework。
参考时间窗:2026-07(PJAX / ORM)、2026-08-07(article[hidden] seed)。实现以仓库源码为准。
PJAX:article[hidden] 首屏直出
<article id="page" hidden> + 悬停预取。改控制器与 layout。
ORM:输出边界转义
去掉 getNoEscape / Dao 层 htmlspecialchars。JS 拼 HTML 必须 $.escapeHtml。
为什么必须迁
旧模型在 ORM 读库时 就 htmlspecialchars。结果是 API 被 " 污染、二次拼 HTML 双重编码、Agent/MCP 吃脏数据。
新模型:数据层保持原文,输出边界再转义。
PJAX 侧砍掉「首屏空 layout + 再发一次 PJAX」的双请求。layout 把当前页片段塞进 <article id="page" hidden>,PjaxUtils.takeSeed() 认 tagName === "ARTICLE",消费后零往返。
PJAX 首屏直出与预取
tpl / framework / login:首屏 seed、Preloader、悬停预取、X-PJAX-Prefetch。
2026-07-27
ORM 移除 HTML 转义
orm:删除 getNoEscape 与 fromDb 时的 htmlspecialchars;业务前端统一 $.escapeHtml。
2026-07-31
seed 定为 article[hidden]
废弃 <template id="page"> 与 <noscript><article>。统一为 <article id="page" hidden>。同步删除 #hiddenBody / body{display:none} 闪屏掩盖。
2026-08-07
一、ORM 迁移
破坏点
| 旧行为 | 新行为 |
|---|---|
fromDb 时字符串自动 htmlspecialchars | 字符串保持原文 |
Model::getNoEscape(): array 白名单 | API 已删除,覆盖方法必须删掉 |
JS 可假设「Dao 已转义」用 .html(title) | 禁止假设;拼 HTML 当场 escape,纯文本用 .text() |
模板 {$var} 转义 | 不变,仍由 ViewCompile 转义 |
Caution
升 ORM 子模块后若不改前端,用户可控字段(标题、标签、站点名等)拼进 HTML 就是 XSS。这不是可选优化。
迁移步骤
升级子模块
将
nova/plugin/orm升到已移除 ORM 层 HTML 转义的版本。全仓库搜getNoEscape/inNoEscape,必须为零。删除全部
getNoEscapepublic function getNoEscape(): array{ return ['content', 'html', 'description'];}审计前端动态 HTML
凡是把 Model / API 字段拼进 HTML 字符串的地方,统一转义(
$.escapeHtml在framework/utils/Toaster.js):const esc = (v) => $.escapeHtml(v == null ? '' : String(v));$('#doc-title').html(`<span>${esc(doc.title)}</span>`);$('#title').text(doc.title);$('#doc-title').html(`<span>${doc.title}</span>`);$title.html(doc.title);分清输出路径
路径 谁负责转义 模板 {$var}框架(已转义) 模板需要原文 HTML 项目既有「不转义」语法(如 {~ $html})JS 拼接 HTML / 属性 你自己 $.escapeHtmlJSON API / Agent / MCP 不要转义,保持原文
验收清单
- 库里存
Tom & Jerry/a"b,API JSON 里仍是原文 - 页面标题、标签、确认弹窗里的用户输入已 escape
- 搜索
innerHTML/.html(+ 模型字段,无裸插
旧代码若在 JS 里又对「已转义」字段 escapeHtml 一次,升完后会变成正确的一次转义——这是预期。
.html() vs .text()新模型用 .text(title);若必须 .html(),先 escapeHtml。
Tree 等若提供 escapeTitles,默认应开启;自定义 renderLabel 须自己 escape。
二、PJAX 迁移
现行数据流
四个锚点(PjaxUtils.SELECTORS)不可缺:
| 选择器 | 作用 |
|---|---|
#title | 页面标题 |
#style | 页面级 CSS |
#container | 页面主体 |
#script | 页面 JS 入口 |
破坏点
| 旧行为 | 新行为 |
|---|---|
init() 非 PJAX 时 return asTpl('layout') | init() 只决定套不套 layout,始终 return null,由 action asTpl |
layout 用 <template id="page"> | layout 用 <article id="page" hidden>(takeSeed 校验 ARTICLE) |
layout 用 <noscript><article id="page"> | 已废弃。noscript 子树在启用 JS 时不是元素节点,takeSeed 拿不到 |
#hiddenBody + body{display:none} 防闪屏 | 已删除。靠 article[hidden] 隐藏 seed,不再整页藏 body |
| 无悬停预取 | [data-pjax-item] 悬停约 100ms 预取;带 X-PJAX-Prefetch: true |
PJAX 执行片段内全部 <script> | 仅执行 JS 类型;application/json 等跳过 |
| 侧栏常显 | 子页可设 window.pageNeedsSidebar |
迁移步骤
升级子模块
app/static/framework(≥0a4ba49):takeSeed认ARTICLE、article[hidden]seed、已删#hiddenBody、悬停预取、脚本类型过滤、pageNeedsSidebarnova/plugin/tpl:ViewResponse注入$__template_filenova/plugin/login:识别HTTP_X_PJAX_PREFETCH
改控制器
init()public function init(): ?Response{ $this->viewResponse = new ViewResponse(); $this->viewResponse->init('', [...]); if (!$this->request->isPjax()) { return $this->viewResponse->asTpl('layout'); } return null;}public function init(): ?Response{ $ret = parent::init(); if ($ret) { return $ret; } $this->viewResponse = new ViewResponse(); $this->viewResponse->init($this->request->isPjax() ? '' : 'layout', [ 'title' => $siteName, 'nav' => $navbar, ]); return null;}layout 写入 article[hidden] seed
{if isset($__template_file) && $__template_file} <article id="page" hidden> {include file=$__template_file} </article>{/if}<script id="script"></script>{include file="publicScript.tpl"}takeSeed() { const seed = document.getElementById("page"); if (!seed || seed.tagName !== "ARTICLE") { return null; } const html = seed.innerHTML; seed.remove(); return html;}Warning
- seed 必须是
<article id="page" hidden>。<template>与<noscript><article>均已废弃。 article在活动 DOM里(只是hidden)。片段内的#title/#container/#script/#page-data会和 layout 锚点同文档共存,直到takeSeedremove()。takeSeed必须先于switchContent:先拆掉 seed,再querySelector四锚点,否则抢到错误节点。- 初始化
PjaxUtils的脚本必须排在 layout 的<script id="script">之后,否则会报missing #script。 - 业务 layout(
app/view/*/layout.tpl)必须与 framework 同步改,不要一半noscript、一半hidden。
- seed 必须是
子页声明生命周期与侧栏
<title id="title">{$title}</title><style id="style"></style><div id="container" class="container">...</div><script id="script" src="/static/js/xxx.js?v={$__v}"></script>window.pageLoadFiles = [];window.pageNeedsSidebar = true; // false 则隐藏 drawer 与开关window.pageOnLoad = function () { window.pageOnUnLoad = function () {}; return false;};引入
Preloader.js(业务 JSON 预取)const docPreloader = new Preloader((cleanPath) => new Promise((resolve, reject) => { docHttp.get('/path/' + encodeURIComponent(cleanPath), apiParams, resolve, reject);}));el.addEventListener('mouseover', () => docPreloader.prefetch(path));docPreloader.fetch(path).then(render);后端识别预取,去掉写副作用
if (($_SERVER['HTTP_X_PJAX_PREFETCH'] ?? '') === 'true') { return;}Important
给 GET 加任何写副作用之前,先想清楚它被预取时会怎样。 漏改症状:划过菜单 A、点开 B,登录后却跳到 A。
数据脚本标明非 JS type
<script type="application/json" id="page-data">{~ $pageData}</script>
验收清单
- layout 源码是
<article id="page" hidden>,无<template id="page">、无<noscript>包 seed -
publicHeader.tpl/publicScript.tpl已无#hiddenBody -
takeSeed校验tagName === "ARTICLE" - 硬刷新子页:有 seed 时 Network 无对当前 pathname 的二次
X-PJAX - PJAX 切换后
pageOnLoad/ 前进后退正常 - 悬停约 100ms 后出现
X-PJAX-Prefetch: true - 未登录划过 A 再点 B,登录后落到 B
-
application/json数据岛不报 JS 语法错误 -
pageNeedsSidebar = false的页面不显示侧栏开关
与业务 Preloader 的区别
PJAX 悬停预取是单槽抢跑:只保留最近一个 href,取走即清空。Preloader 是通用 Promise 缓存,适合 API/JSON;别把整页 HTML 塞进长期 Map。
JSON.parse($('#page-data').text()) 报 Unexpected non-whitespace character after JSON。mdui $ 的 .text() 会把多个匹配节点的文本直接拼接,两段合法 JSON 粘成 {...}{...} 正好触发该错误。
article[hidden] 进活动 DOM 后,若 takeSeed 未先 remove(),或业务又在别处留了一份 #page-data,文档里就会有两个同 id 节点。
保证只在 takeSeed → switchContent 之后读 #page-data;用 document.getElementById('page-data') 只取一个;全页搜重复的 id="page-data"。
三、SEO:片段 SSR + article[hidden]
旧 <template> 不进活动 DOM,无 JS 爬虫只能看到空壳。<noscript> 包 seed 能给无 JS 客户端读正文,但启用 JS 时子树不是元素,takeSeed 失效并回退成二次 X-PJAX。
现行契约:整页片段进活动 DOM,用 hidden 隐藏,供爬虫读源码、供 takeSeed 零往返消费。
片段内 SSR
<h1 id="doc-title">{if isset($doc_title)}{$doc_title}{/if}</h1><div id="markdown" class="leading-loose">{if isset($article_html) && $article_html}<div class="penna-theme-default"><div class="penna-render">{~ $article_html}</div></div>{/if}</div>layout 用 article[hidden] 承载整页片段
{if isset($__template_file) && $__template_file} <article id="page" hidden> {include file=$__template_file} </article>{/if}控制器别把大 HTML 塞进 JSON
$articleHtml = (string)($doc['html'] ?? '');unset($doc['html']);return $this->viewResponse->asTpl("", [ 'doc_title' => $doc['title'], 'article_html' => $articleHtml, 'pageData' => Json::encode([ 'doc' => $doc, 'sidebar' => $sidebar, 'can_edit' => $this->roleAllows($this->domainId . ':write'), ]),], self::VARY);
Warning
不要再加回 <noscript> 包 seed,也不要恢复 #hiddenBody。hidden 只隐藏视觉;HTML 源码里标题与正文仍在,爬虫可读。
首屏防闪依赖 seed 的 hidden + PJAX 尽快 takeSeed/switchContent,不是整页 body{display:none}。
PJAX seed
<article id="page" hidden> · takeSeed 消费 · 校验 ARTICLE
SEO
片段内 SSR · 活动 DOM 可读 · 无 noscript 依赖
四、推荐升级顺序
先升
framework(含0a4ba49)+tpl+login;业务 layout 改成article#page[hidden],删掉 noscript /#hiddenBody再升
orm,删getNoEscape、补前端 escape内容站确认片段 SSR +
article[hidden]通道,避免空壳索引回归搜索
rg 'getNoEscape|inNoEscape' -g '*.php'rg 'asTpl\(["'\'']layout' -g '*.php'rg 'isPjax\(\)' -g '*Controller*.php'rg 'article id="page" hidden|tagName !== "ARTICLE"' -g '*.{tpl,js}'rg 'template id="page"|<noscript>|hiddenBody' -g '*.{tpl,js,md}' # 应只剩历史说明或未迁文件
Warning
不要只升 ORM 不改 JS。生产宁可晚一天,也不要半套迁移。
五、子模块与源码入口
| 位置 | 说明 |
|---|---|
app/static/framework/pjax/PjaxUtils.js | takeSeed(ARTICLE)/ 预取 / 四锚点 / pageNeedsSidebar |
app/static/framework/layout.js | data-pjax-item 点击与 hover-intent |
app/static/framework/layout.tpl / layout2.tpl | <article id="page" hidden> |
app/static/framework/publicHeader.tpl | 已移除 #hiddenBody |
app/view/*/layout.tpl | 业务侧同步 article[hidden] |
nova/plugin/tpl/ViewResponse.php | $__template_file 注入 |
nova/plugin/login/LoginManager.php | 预取跳过 setRedirectUri |
app/static/framework/DEVELOPMENT.md | §5.4 / §5.5 / $.escapeHtml(若仍写 template,以源码为准) |
NovaPHP Framework
框架总览与仓库入口