搜索全站
插件 20 分钟阅读

无头(Headless)架构下的 WordPress 插件适配判断

改成前后端分离后,插件还能用吗?本文按「输出方式」把插件分成四类,讲清哪些能直接适配、哪些要改造,并给出 CORS、预览、表单提交的可用代码。

U
UFO_PF 2019-12-06 · 阅读 20 分钟
无头 插件
阅读提示
本文约 7,826 字,预计阅读 20 分钟。文中命令与配置均经过实测,可直接参考执行。

引言:前端换成 Next.js 之后,插件集体失灵

站点改成 headless:WordPress 只当后台,前端用 Next.js/Nuxt 渲染。文章列表、详情都跑通了,然后发现——联系表单提交没反应、SEO 插件设的标题没生效、编辑器点「预览」跳到一个不存在的地址、评论区打不开。

这不是插件坏了,而是headless 改变了 WordPress 的输出位置。传统模式下插件在 PHP 里生成完整 HTML,浏览器直接拿到成品;headless 模式下 WordPress 只输出 JSON,HTML 由前端生成。插件那些「往 the_content 里插东西」「往 wp_head 里插 meta」的动作,全都在一个没人看的 HTML 里发生了。

原理分析:按输出方式把插件分四类

类型 工作方式 headless 下是否可用 例子
数据型 读写数据,不负责展示 可用(需 API 暴露) ACF、自定义文章类型、WooCommerce
API 型 通过 REST / GraphQL 暴露数据 直接可用 WPGraphQL、WooCommerce REST
输出型 钩进 the_content/wp_head 生成 HTML 前端看不到输出 SEO 插件、短代码类、页面缓存
交互型 依赖 Cookie / Nonce / admin-ajax.php 需改造 表单插件、评论、投票

判断一个插件能不能用,就问一句:它的产出是「数据」还是「HTML」? 产出数据的,只要 API 层能取到就没问题;产出 HTML 的,要么在前端重写一遍,要么把 HTML 当字符串传给前端渲染(可行但不优雅)。

几个具体结论:

  • SEO 插件(Yoast/Rank Math):它们写的标题、描述、canonical 在 REST 响应里默认拿不到。需要装 WPGraphQL 的 Yoast/Rank Math 扩展,或自己在 REST 响应里加字段。
  • 缓存插件:页面缓存对 headless 意义不大(缓存的是没人看的 HTML),但对象缓存(Redis)非常有用,因为它缓存的是 REST 查询。
  • 表单插件:Gravity Forms 有官方 REST API(v2,需在设置里开启并创建 API Key);自定义方案是注册自己的 REST 端点。
  • 页面构建器:Elementor 的输出是 HTML,前端只能原样渲染(可行,但等于放弃了 headless 的意义)。
  • WooCommerce:有完整 REST API,购物车需要自建(官方的 Store API 在 WooCommerce Blocks 里提供 /wc/store/v1/cart)。

保姆级实操

第一步:把后台与前端域名分开

// wp-config.php
// WP_HOME   = 前端地址(访客访问)
// WP_SITEURL= WordPress 后台地址
define( 'WP_HOME',    'https://www.example.com' );
define( 'WP_SITEURL', 'https://wp.example.com' );

这样后台在 wp.example.com/wp-admin,REST 接口在 wp.example.com/wp-json,前端在 www.example.com。注意:REST 接口的域名是 WP_SITEURL,前端请求它时必须处理跨域。

第二步:配置 CORS

<?php
// wp-content/mu-plugins/headless-cors.php
add_action( 'rest_api_init', function () {
    remove_filter( 'rest_pre_serve_request', 'rest_send_cors_headers' );
    add_filter( 'rest_pre_serve_request', function ( $value ) {
        $allowed = [
            'https://www.example.com',
            'https://preview.example.com',   // 预览环境
        ];
        $origin = get_http_origin();
        if ( $origin && in_array( $origin, $allowed, true ) ) {
            header( 'Access-Control-Allow-Origin: ' . esc_url_raw( $origin ) );
            header( 'Access-Control-Allow-Methods: GET, POST, OPTIONS' );
            header( 'Access-Control-Allow-Headers: Authorization, Content-Type, X-WP-Nonce' );
            header( 'Access-Control-Allow-Credentials: true' );
        }
        return $value;
    } );
}, 15 );

get_http_origin() 是 WordPress 内置函数,读取 Origin 头。带凭证(Allow-Credentials: true)时 Allow-Origin 不能是 *,必须回显具体来源。

第三步:给 REST 响应补上 SEO 字段

add_action( 'rest_api_init', function () {
    register_rest_field( [ 'post', 'page' ], 'seo', [
        'get_callback' => function ( $post_arr ) {
            // Yoast 的字段(Rank Math 换成 rank_math_title / rank_math_description)
            return [
                'title'       => get_post_meta( $post_arr['id'], '_yoast_wpseo_title', true ),
                'description' => get_post_meta( $post_arr['id'], '_yoast_wpseo_metadesc', true ),
                'canonical'   => get_permalink( $post_arr['id'] ),
                'noindex'     => (bool) get_post_meta( $post_arr['id'], '_yoast_wpseo_is_cornerstone', true ),
            ];
        },
        'schema' => [
            'description' => 'SEO 元数据',
            'type'        => 'object',
        ],
    ] );
} );

前端拿 GET /wp-json/wp/v2/posts?slug=xxx 时,每个对象里会多出一个 seo 字段。

第四步:让「预览」可用

默认的预览链接指向 WP_HOME + ?preview=true,前端根本没有这个路由。改掉它:

add_filter( 'preview_post_link', function ( $link, $post ) {
    if ( ! $post ) {
        return $link;
    }
    $frontend = 'https://www.example.com';
    $nonce    = wp_create_nonce( 'wp_rest' );

    return add_query_arg( [
        'slug'     => $post->post_name,
        'type'     => $post->post_type,
        'id'       => $post->ID,
        'preview'  => 'true',
        'token'    => $nonce,
    ], $frontend . '/api/preview' );
}, 10, 2 );

前端的 /api/preview 路由拿到参数后,用带 X-WP-Nonce 的请求去取草稿内容并渲染。注意 nonce 有效期 24 小时,且预览页面绝不能被 CDN 缓存。

第五步:表单提交的自定义端点

add_action( 'rest_api_init', function () {
    register_rest_route( 'mmf/v1', '/contact', [
        'methods'             => 'POST',
        'callback'            => function ( WP_REST_Request $req ) {
            $name    = sanitize_text_field( $req['name'] );
            $email   = sanitize_email( $req['email'] );
            $message = sanitize_textarea_field( $req['message'] );

            if ( ! is_email( $email ) ) {
                return new WP_Error( 'bad_email', '邮箱格式不正确', [ 'status' => 400 ] );
            }

            // 用 transient 做简单频率限制
            $ip  = sanitize_text_field( $_SERVER['REMOTE_ADDR'] ?? '' );
            $key = 'mmf_contact_' . md5( $ip );
            if ( get_transient( $key ) ) {
                return new WP_Error( 'too_many', '提交过于频繁,请稍后再试', [ 'status' => 429 ] );
            }
            set_transient( $key, 1, 30 );

            $sent = wp_mail(
                get_option( 'admin_email' ),
                '新询盘:' . $name,
                $message,
                [ 'Content-Type: text/plain; charset=UTF-8', 'Reply-To: ' . $email ]
            );

            return new WP_REST_Response( [ 'ok' => $sent ], $sent ? 200 : 500 );
        },
        'permission_callback' => '__return_true',   // 公开端点
        'args'                => [
            'name'    => [ 'required' => true ],
            'email'   => [ 'required' => true ],
            'message' => [ 'required' => true ],
        ],
    ] );
} );

前端调用:

// 前端(Next.js / 任意框架)提交表单
const res = await fetch( 'https://wp.example.com/wp-json/mmf/v1/contact', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify( { name, email, message } ),
  credentials: 'omit',   // 公开端点不需要带 Cookie
} );

const data = await res.json();
if ( ! res.ok ) {
  // WordPress 的错误结构是 { code, message, data: { status } }
  throw new Error( data.message || '提交失败' );
}

// 拉取带 SEO 字段的文章
const posts = await fetch(
  'https://wp.example.com/wp-json/wp/v2/posts?per_page=10&_fields=id,slug,title,excerpt,seo'
).then( r => r.json() );

_fields 参数能裁剪响应字段,减少传输量;_embed 能一次性带上作者、特色图等关联资源。

第六步:认证方式选择

# 服务端对服务端:Application Passwords(WP 5.6+ 内置,需 HTTPS)
# 后台 → 用户 → 个人资料 → 应用程序密码 → 生成
curl -s https://wp.example.com/wp-json/wp/v2/users/me \
  -u 'admin:xxxx xxxx xxxx xxxx xxxx xxxx'

# 验证接口是否可达、CORS 是否正确
curl -s -i -X OPTIONS https://wp.example.com/wp-json/wp/v2/posts \
  -H 'Origin: https://www.example.com' \
  -H 'Access-Control-Request-Method: GET' | head -15

# 检查固定链接(REST 依赖重写规则)
wp option get permalink_structure

浏览器场景(预览、登录用户)用 Cookie + X-WP-Nonce;服务端渲染场景用 Application Passwords;不要用「把管理员密码写进前端配置」这种做法。

常见踩坑

  1. 忘了 WP_HOME 和 WP_SITEURL 分离:REST 接口的 URL 全指向前端域名,前端自己去请求自己,形成死循环。

  2. 案例: 某站 headless 改造后,联系表单直接 POST 到 admin-ajax.php。上线后 100% 失败,控制台报 CORS。原因有两个:admin-ajax.php 不带 CORS 头,且它依赖 Cookie + nonce,跨域请求默认不发送 Cookie。改成自定义 REST 端点 + 正确 CORS 配置后才通。教训:headless 下不要用 admin-ajax.php,统一走 REST。

  3. 缓存插件把 REST 响应也缓存了:REST 接口默认应返回 Cache-Control: no-store,某些缓存插件会给全站加缓存头,导致预览草稿被公开缓存。

  4. SEO 插件的数据取不到:Yoast/Rank Math 的字段存在 postmeta 里,不在默认 REST 响应中。必须 register_rest_field 补充,或用对应的 GraphQL 扩展。

  5. 媒体地址指向后台域名:_embed 里带回来的图片 URL 是 wp.example.com,前端直接用会跨域且不利于 SEO。用 wp_get_attachment_image_src 的钩子或在前端做域名替换。

  6. nonce 被 CDN 缓存:预览用的 nonce 一旦进缓存,24 小时后所有用户拿到过期 nonce,预览集体失败。

  7. 永久链接改了但没刷:REST 路由依赖重写规则,wp rewrite flush 之后才能正常访问 /wp-json/。

  8. 以为 GraphQL 一定比 REST 快:GraphQL 解决的是「按需取字段」,但服务端解析成本更高,且插件生态(WPGraphQL 的扩展)不如 REST 完善。选型看团队熟悉度。WordPress REST API 自定义接口

总结

判断标准:插件产出数据 → 可用(补 API 字段即可);产出 HTML → 前端重写;依赖 Cookie/nonce → 改造为 REST + Application Passwords。

行动清单:把 WP_HOME(前端)与 WP_SITEURL(后台)分开;rest_api_init 里配置精确来源的 CORS(带凭证时不能用 *);用 register_rest_field 把 SEO 插件的 meta 补进响应;用 preview_post_link 把预览指到前端路由并给预览页加 no-store;表单类交互改写成自定义 REST 端点 + transient 限流;认证按场景选 Cookie+nonce(浏览器)或 Application Passwords(服务端);上线前用 curl 验证 OPTIONS 预检与固定链接配置。

U
UFO_PF作者

专注 WordPress 生态的实战作者,内容以「可复制、可验证」为第一原则。

留下你的想法

邮箱地址不会被公开。带 * 的为必填项。