本文约 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;不要用「把管理员密码写进前端配置」这种做法。
常见踩坑
-
忘了
WP_HOME和WP_SITEURL分离:REST 接口的 URL 全指向前端域名,前端自己去请求自己,形成死循环。 -
案例: 某站 headless 改造后,联系表单直接 POST 到
admin-ajax.php。上线后 100% 失败,控制台报 CORS。原因有两个:admin-ajax.php不带 CORS 头,且它依赖 Cookie + nonce,跨域请求默认不发送 Cookie。改成自定义 REST 端点 + 正确 CORS 配置后才通。教训:headless 下不要用admin-ajax.php,统一走 REST。 -
缓存插件把 REST 响应也缓存了:REST 接口默认应返回
Cache-Control: no-store,某些缓存插件会给全站加缓存头,导致预览草稿被公开缓存。 -
SEO 插件的数据取不到:Yoast/Rank Math 的字段存在 postmeta 里,不在默认 REST 响应中。必须
register_rest_field补充,或用对应的 GraphQL 扩展。 -
媒体地址指向后台域名:
_embed里带回来的图片 URL 是wp.example.com,前端直接用会跨域且不利于 SEO。用wp_get_attachment_image_src的钩子或在前端做域名替换。 -
nonce 被 CDN 缓存:预览用的 nonce 一旦进缓存,24 小时后所有用户拿到过期 nonce,预览集体失败。
-
永久链接改了但没刷:REST 路由依赖重写规则,
wp rewrite flush之后才能正常访问/wp-json/。 -
以为 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 预检与固定链接配置。
