WP DEVELOP

EP158-159. “自定义 REST 端点复用 PHP 模板做编辑器实时预览”

首页 WordPress 开发课程 新 BLOCK 类型练习 · EP158-159
约 24 分钟· #EP158-159#新 BLOCK 类型练习
🔒 登录后可标记已读

📌 说明: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 请求(对应「读取数据」的语义),不是 POST
  • getProfHTML($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,这个副作用就会重新执行一次,重新发起请求拿新的 HTML
  • useEffect 内部不能直接用 asyncuseEffect 的回调函数本身不支持直接标记成 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_initWP hookWordPress 初始化 REST API 路由体系时触发,用来注册自定义端点
register_rest_route($命名空间, $路由名, $选项数组)WP 内建 function注册一个自定义 REST API 端点
WP_REST_SERVER::READABLEWP 内建常量表示这个端点只接受 GET 请求
apiFetch(选项对象)@wordpress/api-fetchWordPress 官方 JS 工具发送带认证的 REST API 请求,自动处理域名前缀和 nonce,返回 Promise
async function + awaitJS 语法用同步风格的写法处理异步操作,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