文章点赞和浏览数统计实现

文章点赞和浏览数统计实现

May 19, 2024
编码 , 工具箱 , 分享 ,

前段时间大发哥 TG 频道说要发一个用 CFWorker 实现文章点赞和浏览数统计的功能,很快啊,星期二教程就出来了: Hugo Cloudflare Worker

整体方案是使用 CfWorker 实现 api 请求,再用 D1 做数据持久化,效果还是很赞的。

本来我是想照着教程完全一步步来的,但是想到我前文 将博主 PC 上使用的应用信息实时显示到博客 在折腾时搭了一个 API 服务,就不想再去用 CFWorker 了,打算直接在自己服务器上实现这个功能,顺便再实践一下用 Express 搭配数据库工具。

所以打算稍改一下,结构如下:

  • 前端照搬
  • API 结构实现部分照搬
  • 数据库方面结构、读写操作照搬
  • 数据库持久化用 LokiJS

前端

前端我这里用的 Alpinejs 做驱动,前文 用 Alpinejs 完成主题切换功能 介绍过这个库,在不使用 Vue 等工具下普通 HTML 的一个替代方案,好用。

  • 在模板中渲染的时候设置文章 ID (这里我因为用的 Ghost 做数据源,所以自带一个 MD5 的 ID,用一个固定唯一值就好)
  • 设置 x-data 为后面 js 中初始化的 post_action 对象
  • 通过 x-show 调用 initViewsinitLike 实现组件数据初始化
  • 通过 @click 绑定 like 函数实现点赞

HTML

<!-- 点赞按钮 -->
<div x-data="post_action" class="post_action flex flex-wrap justify-center">
    <div class="post_like" x-show="initLike('{{ post.id  }}')" data-postid="{{ post.id  }}" @click="like('{{ post.id  }}')">
        {% svg 'like', '0 0 26 26' %}
        <div class="post_like_desc">
            「 还好我们还有文字... 」
        </div>
    </div>
</div>

<!-- 文章页脚数据显示 -->
<div>
  <a class="flex align-center" href="#" title="Last modified by" rel="noopener">
    {% svg 'like' %}
    <span class="post_likes" ></span>
  </a>
</div>


<div>
  <a class="flex align-center" href="#" title="Last modified by" rel="noopener">
    {% svg 'views' %}
    <span class="post_views" x-data="post_action" x-show="initViews('{{ post.id  }}')"></span>
  </a>
</div>

页面结构

CSS 部分

之前做那个 PC 上 APP 状态显示的时候使用了一下 CSS 动画,效果还不错,这里再次借鉴一下那个思路,点赞后实现了一个心脏跳动的感觉。

.post_action {
    .post_like {
        align-items: center;
        cursor: pointer;
        display: flex;
        height: 6rem;
        position: relative;
        text-align: center;
        flex-direction: column;
        justify-content: center;

        svg {
            fill: none;
            stroke: var(--body-font-color);
            height: 1.5em;
            margin-inline-end: 00;
            width: 1.5em;
        }
        /** 点赞按钮的激活效果 **/
        &.active svg{
            fill: var(--hint-color-danger);
            stroke: var(--hint-color-danger);

            animation: growAndFade 1s 1;
            transform-origin: center;
        }
    }
    .post_like_desc{
        font-style: oblique;
        padding: 8px 0px;
        color: var(--gray-500);
        font-size: 14px;
    }

    
}

/** 点赞动画 **/
@keyframes growAndFade {
    0% {
      transform: scale(1);
      opacity: 1;
    }
    50% {
      transform: scale(2);
      opacity: 0.3; /* 这里调整透明度,以符合动画结束立即恢复的需求 */
    }
    100% {
      transform: scale(1);
      opacity: 1;
    }
  }

JS 中 Alpinejs 的设置部分

💡
我的代码写的应该不是很规范,因为没有系统性学过,感觉很多语法、使用方式都存在问题,希望有看到不合理、错误的地方的大佬能指点一下。
var apiUrl = "https://yourapidomain.com";
Alpine.data("post_action", () => ({
    apiUrl: apiUrl,
    // 点赞函数
    like: (post_id) => {
        // 初始化一些元素变量
        var likelist = localStorage.getItem("lieklist") || "";
        var likeButton = document.querySelector(".post_like");
        var likeText = document.querySelector(".post_likes");
        
        // 每次点赞都去本地数据中匹配一下
        // 我这里将用户浏览的数据拼成字符串处理的,以后应该会有性能问题
        if (likelist.indexOf(post_id + ",") != -1) {
            console.log("你已经点过赞了");
            likeButton.classList.add("active");
        } else {
            // 如果没有就发起POST请求更新数据
            fetch(`${apiUrl}/post/${post_id}/like`, { method: "post" })
                .then((res) => {
                    return res.json();
                })
                .then((data) => {
                    // 成功了就做一些页面数据同步的工作
                    console.log("点赞成功" + JSON.stringify(data));
                    localStorage.setItem("lieklist", likelist + post_id + ",");
                    likeButton.classList.add("active");
                    likeText.innerText = data.likes
                })
                .catch((error) => {
                    console.error(
                        "There was a problem with the fetch operation:",
                        error
                    );
                });
        }
    },
    // 后续的代码逻辑上都差不多,我就不一一写备注了。
    initLike: (post_id) => {
        var likelist = localStorage.getItem("lieklist") || "";
        var likeButton = document.querySelector(".post_like");
        var likeText = document.querySelector(".post_likes");
        if (likelist.indexOf(post_id + ",") != -1) {
            likeButton.classList.add("active");
        }
        fetch(`${apiUrl}/post/${post_id}/like`)
            .then((res) => {
                return res.json();
            })
            .then((data) => {
                if (data.likes) {
                    likeButton.dataset.like = data.likes;
                    likeText.innerText = data.likes;
                }else{
                    likeButton.dataset.like = 0;
                    likeText.innerText = 0; 
                }
            }).catch((error) => {
                likeButton.dataset.like = 0;
                likeText.innerText = 0;
                console.error(
                    "There was a problem with the fetch operation:",
                    error
                );
            });
        return true;
    },
    initViews: (post_id) => {
        var viewlist = localStorage.getItem("viewlist") || "";
        var viewText = document.querySelector(".post_views");

        if (viewlist.indexOf(post_id + ",") != -1) {
            fetch(`${apiUrl}/post/${post_id}/views`)
                .then((res) => {
                    return res.json();
                })
                .then((data) => {
                    if (data.Views) {
                        viewText.innerText = data.Views;
                    }else{
                        viewText.innerText = 0; 
                    }

                });
        } else {
            fetch(`${apiUrl}/post/${post_id}/views`, { method: "post" })
                .then((res) => {
                    return res.json();
                })
                .then((data) => {
                    console.log("浏览量" + JSON.stringify(data));
                    localStorage.setItem("viewlist", viewlist + post_id + ",");
                    viewText.innerText = data.Views;
                })
                .catch((error) => {
                    viewText.innerText = 0;
                    console.error(
                        "There was a problem with the fetch operation:",
                        error
                    );
                });
        }

        return true;
    },
}));

后端

后端的实现其实还是和之前一样,只不过这次多了一个数据库的持久化操作。

数据持久化

因为是个轻量型的服务,我也不想搞什么 MySQL 之类的服务,甚至连 SqLite 也不想用,所以想到之前在使用 Twikoo 的时候,发现它用的一个数据服务是一个以 JSON 为结构的数据库:LokiJS,特性如下,应付这个功能应该绰绰有余了。

  1. Fast performance NoSQL in-memory database, collections with unique index (1.1M ops/s) and binary-index (500k ops/s)
  2. Runs in multiple environments (browser, node, nativescript)
  3. Dynamic Views for fast access of data subsets
  4. Built-in persistence adapters, and the ability to support user-defined ones
  5. Changes API
  6. Joins
yarn add -D lokijs

安装 LokiJS 依赖

LokiJS 基础操作

Loki 的数据操作很简单、方便,会用 JavaScript 就可以,对于我来说上手难度更低。

// LojiJS基础操作
// 新建数据库
var db = new loki("blog.db");
// 添加一个「表」(集合)
db.addCollection("posts");
//通过id查找
var post = posts.findOne({ post_id: post_id });
// 插入数据
posts.insert({
    post_id: post_id,
    likes: 1,
    Views: 0,
});
// 操作更新到数据库中
posts.update(post);

另外,

Loki 中 addCollection 这个操作是会覆盖你原有的数据的,我之前用的时候以为打开自动加载、自动保存就好了,没有判断表存不存在,导致了每次服务器重启都会新添加一个表进去,导致原表数据被覆写。

另外 Express 的接口代码和前端的 JS 代码一样没有做结构整理,图方便全部写了一遍,后期是有很大优化空间的。

// 创建数据库
// 开启自动加载,自动保存
var db = new loki("blog.db", {
    autoload: true,
    // 数据库加载完毕后回调
    autoloadCallback: loadHandler,
    autosave: true,
});

let posts;

function loadHandler() {
    posts = db.getCollection("posts");
    // 如果posts不存在才创建
    if (posts === null) {
        db.addCollection("posts");
    }
}

app.get("/post/:key/like", (req, res) => {
    const post_id = req.params.key;
    try {
        let post = posts.findOne({post_id:post_id})
        if(post){
            res.status(200).json(post)
        }
    } catch (e) {
        res.status(500).json({ error: e });
    }
});

app.post("/post/:key/like", (req, res) => {
    const post_id = req.params.key;
    try {
        let post = posts.findOne({ post_id: post_id });
        if (!post) {
            posts.insert({
                post_id: post_id,
                likes: 1,
                Views: 0,
            });
            post = posts.findOne({ post_id: post_id });
        } else {
            post.likes += 1;
        }
        posts.update(post);
        res.status(200).json(post);
    } catch (e) {
        res.status(500).json({ error: e });
    }
});

app.post("/post/:key/views", (req, res) => {
    const post_id = req.params.key;
    try {
        let post = posts.findOne({ post_id: post_id });
        if (!post) {
            posts.insert({
                post_id: post_id,
                likes: 0,
                Views: 1,
            });
            post = posts.findOne({ post_id: post_id });
        } else {
            post.Views += 1;
        }
        posts.update(post);
        res.status(200).json( post );
    } catch (e) {
        res.status(500).json({ error: e });
    }
});

app.get("/post/:key/views", (req, res) => {
    const post_id = req.params.key;
    try {
        let post = posts.findOne({post_id:post_id})
        if(post){
            res.status(200).json(post)
        }
    } catch (e) {
        res.status(500).json({ error: e });
    }
});


Enjoy~

弄完这些也算是对 NodeJS 发布一个拥有完整功能的网站有了些基础了解,不过这种了解暂时还很片面,在架构、性能、安全性方面肯定还有很多需要学习的地方,之后如果碰到了以上问题了再做学习、优化。

在折腾中学习、进步,这种感觉真棒按。

「 还好我们还有文字... 」

加入评论