EP158-159. “自定义 REST 端点复用 PHP 模板做编辑器实时预览”
🔒 登录后可标记已读📌 说明:EP158 的 transcript 原文在句子中途被截断("We only need to load that HTML for the specific one that you choose. Or click on."),EP159 开头又从同一句话原样重复接续——这是 sources 里 transcript 文件切分的巧合,两个文件其实是同一讲实操内容的连续两段,因此这里合并成一篇笔记,不拆开写。
给编辑器里的下拉框接上「选完立刻看到跟前台一样的预览卡片」这个功能。核心决策是:不重新用 JSX 再写一遍 HTML 模板(那样会导致同一份布局要在 PHP 和 JS 里各写一次,以后改版式要改两个地方),而是继续复用 EP157 已经写好的 generateProfessorHTML() 这个 PHP 函数,只是这次通过一个新建的自定义 REST API 端点把渲染好的 HTML 字符串传给 React,再用 React 的 dangerouslySetInnerHTML 把这段字符串当真正的 HTML 插入页面(而不是被转义成纯文字显示)。用专门的端点、只在真正选中某位 Professor 时才去请求这一份 HTML,而不是让 useSelect 一次性把所有 Professor 的完整 HTML 都塞进属性列表里——这是为了避免几十上百位 Professor 时,每个列表项都白白带着一大段用不到的 HTML 字符串。这一讲最后也顺带提了一句 dangerouslySetInnerHTML 带来的 XSS 风险,以及两种可选的转义/清理方式。
涉及文件
wp-content/plugins/featured-professor/featured-professor.php(修改,新增 REST 端点)wp-content/plugins/featured-professor/inc/generateProfessorHTML.php(修改,教授姓名转义处理)wp-content/plugins/featured-professor/src/index.js(修改,请求端点 + 渲染预览)
代码实现
featured-professor.php:新增 rest_api_init 钩子注册自定义 REST 端点:
class FeaturedProfessor {
function __construct() {
add_action('init', [$this, 'onInit']);
add_action('rest_api_init', [$this, 'profHTML']);
}
function profHTML() {
register_rest_route('featuredProfessor/v1', 'getHTML', array(
'methods' => WP_REST_SERVER::READABLE,
'callback' => [$this, 'getProfHTML']
));
}
function getProfHTML($data) {
return generateProfessorHTML($data['profId']);
}
function onInit() {
wp_register_script('featuredProfessorScript', plugin_dir_url(__FILE__) . 'build/index.js', array('wp-blocks', 'wp-i18n', 'wp-editor'));
wp_register_style('featuredProfessorStyle', plugin_dir_url(__FILE__) . 'build/index.css');
register_block_type('ourplugin/featured-professor', array(
'render_callback' => [$this, 'renderCallback'],
'editor_script' => 'featuredProfessorScript',
'editor_style' => 'featuredProfessorStyle'
));
}
function renderCallback($attributes) {
if ($attributes['profId']) {
wp_enqueue_style('featuredProfessorStyle');
return generateProfessorHTML($attributes['profId']);
} else {
return NULL;
}
}
}
src/index.js:用 apiFetch 请求端点,useEffect 监听 profId 变化,dangerouslySetInnerHTML 渲染预览:
import "./index.scss"
import {useSelect} from "@wordpress/data"
import {useState, useEffect} from "react"
import apiFetch from "@wordpress/api-fetch"
wp.blocks.registerBlockType("ourplugin/featured-professor", {
title: "Professor Callout",
description: "Include a short description and link to a professor of your choice",
icon: "welcome-learn-more",
category: "common",
attributes: {
profId: {type: "string"}
},
edit: EditComponent,
save: function () {
return null
}
})
function EditComponent(props) {
const [thePreview, setThePreview] = useState("")
useEffect(() => {
async function go() {
const response = await apiFetch({
path: `/featuredProfessor/v1/getHTML?profId=${props.attributes.profId}`,
method: "GET"
})
setThePreview(response)
}
go()
}, [props.attributes.profId])
const allProfs = useSelect(select => {
return select("core").getEntityRecords("postType", "professor", {per_page: -1})
})
if (allProfs == undefined) return <p>Loading...</p>
return (
<div className="featured-professor-wrapper">
<div className="professor-select-container">
<select onChange={e => props.setAttributes({profId: e.target.value})}>
<option value="">Select a professor</option>
{allProfs.map(prof => {
return (
<option value={prof.id} selected={props.attributes.profId == prof.id}>
{prof.title.rendered}
</option>
)
})}
</select>
</div>
<div dangerouslySetInnerHTML={{__html: thePreview}}></div>
</div>
)
}
inc/generateProfessorHTML.php:给「XX teaches:」这行的教授姓名做转义处理:
<p><?php echo wp_strip_all_tags(get_the_title()) ?> teaches:
<?php foreach($relatedPrograms as $key => $program) {
echo get_the_title($program);
if ($key != array_key_last($relatedPrograms) && count($relatedPrograms) > 1) {
echo ', ';
}
} ?>.
</p>
关键改动点:
- 为什么新建一个专属 REST 端点,而不是让
useSelect那次查询顺便把 HTML 也带回来:useSelect一次性拿到的是全部 Professor 的列表(给下拉框用),如果借这个机会顺便让每个对象都带上自己完整渲染好的 HTML(可能好几 KB),当 Professor 数量上到几十上百个的时候,每次打开编辑器都要多下载一大堆根本没被选中、用不到的 HTML——所以改成「只有真正选中某一位时,才单独发一次请求去只拿这一位的 HTML」 add_action('rest_api_init', [$this, 'profHTML']):rest_api_init是 WordPress 初始化 REST API 路由体系时触发的专属 hook,专门用来注册自定义端点,不要跟通用的init混用register_rest_route('featuredProfessor/v1', 'getHTML', array(...)):第一个参数是「命名空间/版本」(自己起名,格式上大家习惯写成插件名/v1),第二个参数是这个端点具体的路由名,两者拼起来就是完整的 URL 路径(/wp-json/featuredProfessor/v1/getHTML)——WordPress 会自动帮生成的 JSON 里的反斜杠转义等细节都处理好'methods' => WP_REST_SERVER::READABLE:声明这个端点只接受 GET 请求(对应「读取数据」的语义),不是 POSTgetProfHTML($data):$data参数是 WordPress 自动传入的请求数据对象,$data['profId']就是从 URL 查询字符串(比如?profId=43)里取出来的值——直接把它转手喂给已经写好的generateProfessorHTML(),函数体内没有任何新逻辑,纯粹是把 EP157 写好的 HTML 生成函数原样复用了一遍- 前端调用端点:用
@wordpress/api-fetch提供的apiFetch工具,而不是自己手写fetch()/引入 Axios/jQuery——apiFetch已经封装好了域名前缀(不需要自己拼https://域名/wp-json/...,只需要从/命名空间/...开始写)、也会自动带上 WordPress 需要的身份验证 nonce path: \/featuredProfessor/v1/getHTML?profId=${props.attributes.profId}\`:用模板字符串(反引号)拼出带查询参数的路径,${}里插入当前选中的profId`useEffect(callback, [props.attributes.profId]):只监听profId这一个依赖——只要用户切换选择的 Professor,这个副作用就会重新执行一次,重新发起请求拿新的 HTMLuseEffect内部不能直接用async:useEffect的回调函数本身不支持直接标记成async,标准做法是在回调函数体内部另外定义一个async function go() {...},把所有await逻辑写在这个内部函数里,最后在useEffect回调的最底部手动调用一次go()const response = await apiFetch({path: ..., method: "GET"}):await让代码「暂停」在这一行,直到网络请求真正有结果才继续往下执行,写法上比.then()链式调用更接近同步代码、更好读setThePreview(response):请求成功后把结果(也就是 PHP 那边渲染好的完整 HTML 字符串)存进状态thePreview,触发组件重新渲染dangerouslySetInnerHTML={{__html: thePreview}}:React 默认会把塞进 JSX{}里的字符串当成纯文字显示、自动转义特殊字符(防止意外执行恶意脚本),如果想让一段字符串被当成真正的 HTML 渲染(保留标签、样式等),必须用这个特意取得很显眼、名字里带「dangerously」的 prop——参数格式很特殊,必须是一个带有__html这个精确属性名的对象,不能直接传字符串- 为什么这个 prop 名字故意起得这么吓人:这是 React 官方刻意的设计,提醒开发者「这里绕过了 React 默认的防护,你要自己对这段 HTML 的安全性负责」——只有在你真正信任这段 HTML 来源(这里是站内管理员自己录入的教授资料,不是访客提交的内容)时才应该用
Hook / Function 速查
| 名称 | 类型 | 用途 |
|---|---|---|
rest_api_init | WP hook | WordPress 初始化 REST API 路由体系时触发,用来注册自定义端点 |
register_rest_route($命名空间, $路由名, $选项数组) | WP 内建 function | 注册一个自定义 REST API 端点 |
WP_REST_SERVER::READABLE | WP 内建常量 | 表示这个端点只接受 GET 请求 |
apiFetch(选项对象)(@wordpress/api-fetch) | WordPress 官方 JS 工具 | 发送带认证的 REST API 请求,自动处理域名前缀和 nonce,返回 Promise |
async function + await | JS 语法 | 用同步风格的写法处理异步操作,useEffect 内需要包一层内部 async 函数才能使用 |
dangerouslySetInnerHTML={{__html: 字符串}} | React 特殊 prop | 让 React 把字符串当作真正的 HTML 渲染,而不是转义成纯文字显示 |
wp_strip_all_tags($字符串) | WP 内建 function | 去除字符串里的所有 HTML/PHP 标签,常用于清理不受信任的输出内容 |
esc_html($字符串) | WP 内建 function | 把字符串里的特殊字符转义成 HTML 实体,让潜在的标签变成纯文字显示但仍然可见 |
常见坑
- 想着「反正 React 组件能拿到 JS 的所有能力,干脆用 JSX 把 HTML 布局在前端重新写一遍」——会导致同一套版式要在 PHP 和 JSX 里各维护一份,以后改版式容易漏改、不同步
- 让
useSelect一次性查询时就顺便给每个 Professor 对象都带上完整渲染好的 HTML——数据量一大(几十上百个 Professor)会让编辑器每次加载都下载大量根本用不到的 HTML,浪费带宽 useEffect回调函数本身标记成async——React 不支持这样用,必须在回调内部另外定义一个async函数再手动调用- 直接把请求回来的字符串塞进 JSX 的
{}——React 会把它当成纯文字显示、自动转义,看不到真正的 HTML 效果,必须改用dangerouslySetInnerHTML - 用
dangerouslySetInnerHTML渲染的内容如果来自不受信任的输入(比如访客能编辑的字段),没有做任何转义/清理——存在被注入恶意<script>脚本执行的 XSS 风险;这一讲实测过教授姓名字段被塞进<script>alert('Hello')</script>后,前台真的会执行这段脚本,验证了不做处理时的风险有多真实。当前示例里的解法是用wp_strip_all_tags()把姓名里的所有标签都剥掉再输出(也可以换成esc_html(),效果是把标签转义成看得见的纯文字而不是完全移除,两种做法怎么选取决于你想不想让恶意标签「看起来还在但不会执行」)
[截图:Gutenberg 编辑器里选中某位 Professor 后,下拉框下方立刻出现跟前台一样的实时预览卡片]
Sources
Udemy:
- Become a WordPress Developer: Unlocking Power With Code — Section 26, EP158, EP159