网站开启页面缓存或 CDN 后,文章阅读数容易出现一个问题:页面已经被静态缓存,PHP 中的阅读数不会随着访问实时更新。

解决方案是将文章页面和阅读数分开处理:

  • 首页/列表页:REST API 批量读取阅读数,不增加计数;
  • 文章详情页:通过 POST 请求使阅读数 +1;
  • 首页一次请求获取所有文章的阅读数;
  • AJAX 加载更多后,自动读取新加载文章的阅读数。

一、直接读取数据库中的阅读数

Sakura 主题原来的 get_post_views() 会经过 restyle_text() 格式化阅读数。

例如数据库中的实际数据:

22492

通过 get_post_views() 可能得到:

22

因此 REST API 中直接使用:

get_post_meta($post_id, 'views', true)

获取原始数据。

二、创建 REST API

将下面代码加入 functions.php:

// ======================================================
// Sakura 文章阅读数 REST API
//
// GET  /wp-json/sakura/v1/views?ids=1,2,3
//      批量读取阅读数,不增加
//
// POST /wp-json/sakura/v1/views/738
//      单篇文章阅读数 +1
// ======================================================

add_action('rest_api_init', function () {

    // 批量读取阅读数
    register_rest_route('sakura/v1', '/views', array(

        'methods' => 'GET',

        'callback' => function ($request) {

            $ids = $request->get_param('ids');

            if (!$ids) {
                return new WP_Error(
                    'missing_ids',
                    '缺少文章ID',
                    array('status' => 400)
                );
            }

            $ids = explode(',', $ids);
            $result = array();

            foreach ($ids as $id) {

                $post_id = intval($id);

                if (!$post_id) {
                    continue;
                }

                if (get_post_status($post_id) !== 'publish') {
                    continue;
                }

                $views = intval(
                    get_post_meta(
                        $post_id,
                        'views',
                        true
                    )
                );

                $result[$post_id] = $views;
            }

            return array(
                'views' => $result
            );
        },

        'permission_callback' => '__return_true'
    ));


    // 单篇文章阅读数 +1
    register_rest_route(
        'sakura/v1',
        '/views/(?P<id>\d+)',
        array(

            'methods' => 'POST',

            'callback' => function ($request) {

                $post_id = intval($request['id']);

                if (
                    !$post_id ||
                    get_post_status($post_id) !== 'publish'
                ) {
                    return new WP_Error(
                        'invalid_post',
                        '文章不存在',
                        array('status' => 404)
                    );
                }

                $views = intval(
                    get_post_meta(
                        $post_id,
                        'views',
                        true
                    )
                );

                $views++;

                update_post_meta(
                    $post_id,
                    'views',
                    $views
                );

                return array(
                    'post_id' => $post_id,
                    'views'   => $views
                );
            },

            'permission_callback' => '__return_true'
        )
    );

});

例如批量读取:

/wp-json/sakura/v1/views?ids=738,739,740

返回:

{
    "views": {
        "738": 22499,
        "739": 1256,
        "740": 86
    }
}

而文章详情页:

/wp-json/sakura/v1/views/738

使用 POST 请求即可让文章阅读数 +1。

三、修改文章模板

原来的阅读数不要直接使用 get_post_views(),改成占位元素:

<span
    class="post-views"
    data-post-id="<?php the_ID(); ?>"
>
    Loading...
</span>

通过 data-post-id 保存文章 ID,JavaScript 根据这个 ID 获取阅读数。

四、前端 JavaScript

首页通过一个 GET 请求批量获取所有文章的阅读数:

document.addEventListener('DOMContentLoaded', function () {

    function formatViews(views) {

        views = parseInt(views, 10) || 0;

        if (views >= 1000) {
            return (views / 1000).toFixed(1) + 'k';
        }

        return views;
    }


    var isSinglePost =
        document.querySelector('.entry-census') !== null;


    // ==================================================
    // 文章详情页:POST +1
    // ==================================================

    if (isSinglePost) {

        document.querySelectorAll('.post-views')
            .forEach(function (el) {

                var postId =
                    el.getAttribute('data-post-id');

                if (!postId) {
                    return;
                }

                fetch(
                    '/wp-json/sakura/v1/views/' + postId,
                    {
                        method: 'POST',
                        cache: 'no-store'
                    }
                )
                .then(function (response) {
                    return response.json();
                })
                .then(function (data) {

                    el.textContent =
                        formatViews(data.views) +
                        ' 次阅读';

                });

            });

        return;
    }


    // ==================================================
    // 首页 / 列表页:批量 GET
    // ==================================================

    function loadViews() {

        var elements =
            document.querySelectorAll(
                '.post-views'
            );

        var ids = [];


        elements.forEach(function (el) {

            var postId =
                el.getAttribute('data-post-id');

            if (
                postId &&
                el.getAttribute(
                    'data-views-loaded'
                ) !== '1'
            ) {
                ids.push(postId);
            }

        });


        if (!ids.length) {
            return;
        }


        ids = ids.filter(function (id, index) {
            return ids.indexOf(id) === index;
        });


        var url =
            '/wp-json/sakura/v1/views' +
            '?ids=' + ids.join(',') +
            '&t=' + Date.now();


        fetch(url, {
            method: 'GET',
            cache: 'no-store'
        })
        .then(function (response) {
            return response.json();
        })
        .then(function (data) {

            var views = data.views || {};


            elements.forEach(function (el) {

                var postId =
                    el.getAttribute('data-post-id');

                if (
                    Object.prototype.hasOwnProperty.call(
                        views,
                        postId
                    )
                ) {

                    el.textContent =
                        formatViews(views[postId]) +
                        ' 热度';

                    el.setAttribute(
                        'data-views-loaded',
                        '1'
                    );
                }

            });

        });

    }


    // 第一次加载
    loadViews();


    // ==================================================
    // 监听 AJAX 加载更多
    // ==================================================

    var timer = null;

    var observer =
        new MutationObserver(function (mutations) {

            var hasNewViews = false;


            mutations.forEach(function (mutation) {

                mutation.addedNodes.forEach(
                    function (node) {

                        if (node.nodeType !== 1) {
                            return;
                        }

                        if (
                            (
                                node.classList &&
                                node.classList.contains(
                                    'post-views'
                                )
                            ) ||
                            (
                                node.querySelector &&
                                node.querySelector(
                                    '.post-views'
                                )
                            )
                        ) {
                            hasNewViews = true;
                        }

                    }
                );

            });


            if (!hasNewViews) {
                return;
            }


            clearTimeout(timer);

            timer = setTimeout(function () {
                loadViews();
            }, 100);

        });


    observer.observe(document.body, {
        childList: true,
        subtree: true
    });

});