
一个自相矛盾的需求
站点上了静态缓存(缓存命中时 nginx 直接返回 HTML,根本不执行 PHP),但文章页又需要显示真实的阅读量。
这两件事天然冲突:计数逻辑写在 PHP 里,而缓存命中的请求不进 PHP。
一、症状:阅读量退化成"每小时 +1"
主题自带的计数挂在 wp_head:
function pandapro_record_visitors() {
if ( is_singular() ) {
// 把当前文章的 views +1
}
}
add_action( 'wp_head', 'pandapro_record_visitors' );
它只在生成缓存页的那一次请求里执行过。之后所有访客拿到的都是那份静态 HTML,计数不再发生。
我实测过一个 URL:
缓存未命中 → views +1
缓存命中(0.039 秒直出)→ views +0
而缓存的有效期是 1 小时。于是阅读量实际变成了 "每个 URL 每小时最多 +1"——和真实访问量完全脱钩。
二、思路:把"服务端执行"换成"客户端上报"
既然服务端不执行了,那就让浏览器来上报。三件事:
- 摘掉主题的
wp_head计数(否则缓存未命中时会 +2); - 在页面底部输出一段极小的埋点脚本——它会随页面一起被写进静态缓存文件,所以缓存命中时也会执行;
- 上报走
admin-ajax.php(这个地址不会被缓存),服务端把views+1。
关键点:埋点必须"能进缓存"
这是整个方案的巧妙处——我们不缓存计数结果,我们缓存"触发计数的动作"。埋点是一段静态的 JS,它对所有访客都一样,因此和页面缓存完全兼容。
三、代码
<?php
/** ① 摘掉主题的 wp_head 计数(不改主题文件) */
add_action( 'wp_head', function () {
remove_action( 'wp_head', 'pandapro_record_visitors' );
}, 0 );
/** ② 单篇页输出埋点 */
add_action( 'wp_footer', function () {
if ( is_admin() || is_preview() || is_feed() ) { return; }
if ( ! is_singular( array( 'post', 'news' ) ) ) { return; }
$post_id = (int) get_queried_object_id();
if ( $post_id <= 0 ) { return; }
?>
<script id="view-beacon">
( function () {
try {
var fd = new FormData();
fd.append( 'action', 'my_view' );
fd.append( 'post_id', <?php echo (int) $post_id; ?> );
fd.append( '_nonce', <?php echo wp_json_encode( wp_create_nonce( 'my_view' ) ); ?> );
var url = <?php echo wp_json_encode( admin_url( 'admin-ajax.php' ) ); ?>;
if ( navigator.sendBeacon ) {
navigator.sendBeacon( url, fd ); // 不阻塞页面卸载
} else {
var xhr = new XMLHttpRequest();
xhr.open( 'POST', url, true );
xhr.send( fd );
}
} catch ( e ) {}
} )();
</script>
<?php
}, 999 );
/** ③ 计数入口 */
add_action( 'wp_ajax_my_view', 'my_view_increment' );
add_action( 'wp_ajax_nopriv_my_view', 'my_view_increment' );
function my_view_increment() {
check_ajax_referer( 'my_view', '_nonce' );
$post_id = isset( $_POST['post_id'] ) ? (int) $_POST['post_id'] : 0;
if ( $post_id <= 0 ) { wp_die( '0' ); }
$post = get_post( $post_id );
if ( ! $post instanceof WP_Post
|| wp_is_post_revision( $post_id )
|| ! in_array( $post->post_type, array( 'post', 'news' ), true )
|| ! is_post_publicly_viewable( $post ) ) {
wp_die( '0' );
}
update_post_meta( $post_id, 'views', (int) get_post_meta( $post_id, 'views', true ) + 1 );
wp_die( '1' );
}
四、三个必须做的校验
这个接口是 未登录访客也能调用 的(wp_ajax_nopriv_),不设防会被滥用:
check_ajax_referer():必须带 nonce,挡住跨站伪造的批量请求;- 校验文章真实存在 + 类型在白名单内:
update_post_meta()对不存在的 ID 也会建行,不校验的话,别人可以遍历 ID 空间把你的wp_postmeta表撑大; is_post_publicly_viewable():草稿、私密文章不计数。
五、两个附带的好处
- 不执行 JS 的爬虫天然不计数——比"按 IP 去重"简单得多,也不会误伤;
navigator.sendBeacon():页面卸载时也能发出请求,不会拖慢跳转,也不会被浏览器取消。
六、怎么验证
# 1) 缓存命中的页面里,应该能看到埋点(说明它被写进静态文件了)
curl -s https://www.example.com/123.html | grep -c 'view-beacon'
# 2) 连续访问几次,看 views 是否真的在涨
# (在后台或数据库里看 wp_postmeta 的 views 值)
再顺便确认一件事:缓存未命中时不要 +2——把埋点输出和 wp_head 计数同时留着就会双计,所以第 ① 步的 remove_action 不能省。
一句话总结:静态缓存和"服务端计数"天生冲突,解法不是绕过缓存,而是把计数从服务端搬到客户端——缓存里存"触发计数的动作"(一段静态 JS),请求再走不会被缓存的 admin-ajax.php。前提是接口要做 nonce + 存在性 + 可见性三重校验。

评论 (0)