PocketBase Docker 部署与 JS 扩展实现博客点赞计数器
Contents
使用 Docker 部署 PocketBase 作为博客后端,通过其内置的 JSVM 扩展能力实现点赞计数器的服务端校验,解决客户端 localStorage 防重复点赞的局限性。本文涵盖 Docker 配置、数据库设计、JS 钩子编写及 Hugo 前端集成的完整流程。
目录
1. 背景与方案选型
1.1 问题分析
博客原有的点赞功能存在以下问题:
- 防重复点赞完全依赖浏览器
localStorage,清除缓存即可重复点赞 - 点赞数存储在单条记录的
count字段,任何人可通过 API 直接 PATCH 篡改 - 无服务端校验机制
1.2 方案对比
| 方案 | 优点 | 缺点 |
|---|---|---|
| Node.js + JSON 文件 | 简单轻量 | 需额外维护进程,无数据库能力 |
| Supabase / Firebase | 功能丰富 | 对博客场景过重,需注册外部服务 |
| PocketBase | 单二进制部署,内置 JS 扩展,SQLite 存储 | 社区相对较小 |
最终选择 PocketBase:单容器部署,内置 JSVM 支持钩子扩展,SQLite 文件持久化,对个人博客场景刚好合适。
1.3 最终架构
┌─────────────┐ ┌──────────────────────┐
│ Hugo 博客 │─────▶│ PocketBase (Docker) │
│ 前端 JS │ API │ - BlogLikes 集合 │
└─────────────┘ │ - JS 钩子校验 │
│ - SQLite 持久化 │
└──────────────────────┘2. Docker 部署 PocketBase
2.1 目录结构
pocketbase/
├── docker-compose.yml # Docker 编排配置
├── .env # 环境变量(不提交到 Git)
├── pb_data/ # 数据库、日志、文件存储(自动生成)
├── pb_hooks/ # JS 扩展脚本
│ └── BlogLikes.pb.js # 点赞钩子
└── pb_migrations/ # 数据库迁移文件(可选)2.2 docker-compose.yml
services:
pocketbase:
image: ghcr.io/muchobien/pocketbase:${VERSION}
container_name: ${CONTAINER_NAME}
deploy:
resources:
limits:
cpus: ${CPUS}
memory: ${MEMORY_LIMIT}
restart: always
ports:
- ${HOST_IP}:${WEB_HTTP_PORT}:8090
environment:
- PB_ADMIN_EMAIL=${PB_ADMIN_EMAIL}
- PB_ADMIN_PASSWORD=${PB_ADMIN_PASSWORD}
- TZ=Asia/Shanghai
volumes:
- ${APP_PATH}/pb_data:/pb_data
- ${APP_PATH}/pb_hooks:/pb_hooks
- ${APP_PATH}/pb_migrations:/pb_migrations
networks:
- your_network
networks:
your_network:
external: true2.3 环境变量 (.env)
VERSION=latest
CONTAINER_NAME=pocketbase
HOST_IP=0.0.0.0
WEB_HTTP_PORT=8090
CPUS=0
MEMORY_LIMIT=0MB
APP_PATH=/your/server/path/pocketbase
PB_ADMIN_EMAIL=admin@your-domain.com
PB_ADMIN_PASSWORD=your-strong-password2.4 启动服务
# 创建目录
mkdir -p /your/server/path/pocketbase/{pb_data,pb_hooks,pb_migrations}
# 启动
docker compose up -d
# 查看日志
docker logs -f pocketbase启动后访问 http://your-server:8090/_/ 进入管理后台。
2.5 PocketBase JSVM 说明
PocketBase 内置的 JS 引擎是 goja(Go 实现的 ECMAScript 5.1+),不是 Node.js:
| 特性 | Node.js | PocketBase JSVM |
|---|---|---|
| ES 版本 | ES2022+ | ES5.1 + 部分 ES6+ |
import/export | ✅ | ❌ |
| Node.js API | ✅ | ❌ |
| npm 包 | ✅ | ❌(需 bundler 打包) |
fetch | ✅ | ✅(内置) |
对于博客点赞这种简单场景,纯 JS 即可满足,无需引入 npm 包。
3. 数据库表设计
3.1 BlogLikes 集合
在 PocketBase 管理后台创建 BlogLikes 集合,字段如下:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
url | URL | ✅ | 文章完整链接 |
count | Number | ✅ | 点赞数(整数,最小 0) |
created | Autodate | — | 创建时间 |
updated | Autodate | — | 更新时间 |
3.2 访问规则
| 规则 | 设置 | 说明 |
|---|---|---|
| List rule | "" | 公开可读(查询点赞数) |
| View rule | "" | 公开可读 |
| Create rule | "" | 公开可写(由钩子校验) |
| Update rule | "" | 公开可写(由钩子校验) |
| Delete rule | null | 禁止删除 |
3.3 设计思路
采用一个 URL 一条记录的模型:
- 每篇文章对应一条
BlogLikes记录 count字段存储累计点赞数- 通过 JS 钩子保证
count只能 +1,防止篡改 - 客户端
localStorage记录用户是否已点赞(体验优化)
4. JS 扩展钩子
钩子文件放在 pb_hooks/ 目录下,文件名以 .pb.js 结尾。
4.1 创建前校验
// pb_hooks/BlogLikes.pb.js
onRecordBeforeCreateRequest((e) => {
const url = e.record.get("url");
if (!url) {
throw new BadRequestError("url is required");
}
const count = e.record.getFloat("count");
if (count !== 0 && count !== 1) {
// 自动修正为 1(首次创建即点赞)
e.record.set("count", 1);
}
}, "BlogLikes");作用:确保 url 必填,count 初始值只能是 0 或 1。
4.2 更新前校验
onRecordBeforeUpdateRequest((e) => {
const dao = $app.dao();
// 获取数据库中当前记录
const original = dao.findRecordById("BlogLikes", e.record.id);
const oldCount = original.getFloat("count");
const newCount = e.record.getFloat("count");
// 校验:count 只能增加 1
if (newCount !== oldCount + 1) {
throw new BadRequestError(
"count can only be incremented by 1 (expected " +
(oldCount + 1) +
", got " +
newCount +
")",
);
}
// 校验:不允许修改其他字段
if (e.record.get("url") !== original.get("url")) {
throw new BadRequestError("url cannot be modified");
}
}, "BlogLikes");作用:
count只能 +1,不能跳跃增加(防止直接 PATCH 为任意值)- 不允许修改
url字段 - 即使绕过前端直接调用 API,也无法篡改数据
4.3 钩子生效机制
- 修改
pb_hooks/中的文件后,PocketBase 自动重新加载,无需重启容器 - 日志可通过
docker logs -f pocketbase查看
5. Hugo 前端集成
5.1 配置 (hugo.toml)
[params.page.like]
enable = true
apiUrl = "https://your-pocketbase-domain.com"5.2 点赞按钮模板
核心逻辑:
- 页面加载:查询
BlogLikes记录获取count - 读取 localStorage:判断当前用户是否已点赞
- 点击点赞:
- 已点赞 → 不处理
- 未点赞且无记录 → POST 创建记录(count=1)
- 未点赞且有记录 → PATCH 更新 count+1
- 乐观更新:先更新 UI,失败则回滚
{{- $like := .Site.Params.page.like | default dict -}} {{- if $like.enable -}}
<button class="like-btn" id="like-btn" onclick="toggleLike()" aria-label="点赞">
<i class="far fa-thumbs-up like-icon"></i>
<span class="like-count" id="like-count">0</span>
</button>
<script>
(function () {
var apiUrl = "{{ $like.apiUrl }}";
var path = "{{ .Permalink }}";
var storageKey = "liked:" + path;
var btn = document.getElementById("like-btn");
var countEl = document.getElementById("like-count");
var icon = btn ? btn.querySelector(".like-icon") : null;
if (!btn || !countEl || !icon) return;
var liked = localStorage.getItem(storageKey) === "1";
var recordId = null;
var currentCount = 0;
function render(count) {
currentCount = count;
countEl.textContent = count;
if (liked) {
icon.className = "fas fa-thumbs-up like-icon";
btn.classList.add("liked");
} else {
icon.className = "far fa-thumbs-up like-icon";
btn.classList.remove("liked");
}
}
// 查询 BlogLikes 记录
function fetchLike() {
var filter = '(url="' + path + '")';
fetch(
apiUrl +
"/api/collections/BlogLikes/records?filter=" +
encodeURIComponent(filter),
)
.then(function (r) {
return r.json();
})
.then(function (data) {
if (data.items && data.items.length > 0) {
recordId = data.items[0].id;
render(data.items[0].count || 0);
} else {
render(0);
}
})
.catch(function () {
render(0);
});
}
fetchLike();
// 点赞(只增不减)
window.toggleLike = function () {
if (liked) return;
liked = true;
localStorage.setItem(storageKey, "1");
render(currentCount + 1); // 乐观更新
if (recordId) {
// 已有记录 → count +1
fetch(apiUrl + "/api/collections/BlogLikes/records/" + recordId, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ count: currentCount }),
})
.then(function (r) {
return r.json();
})
.then(function (data) {
render(data.count || currentCount);
})
.catch(function () {
liked = false;
localStorage.removeItem(storageKey);
render(currentCount);
});
} else {
// 首次点赞 → 创建记录
fetch(apiUrl + "/api/collections/BlogLikes/records", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: path, count: 1 }),
})
.then(function (r) {
return r.json();
})
.then(function (data) {
recordId = data.id;
render(data.count || 1);
})
.catch(function () {
liked = false;
localStorage.removeItem(storageKey);
render(0);
});
}
};
})();
</script>
{{- end -}}6. 部署与验证
6.1 部署清单
- PocketBase 容器正常运行
-
pb_hooks/BlogLikes.pb.js已上传到服务器 -
BlogLikes集合已创建,访问规则已配置 - Hugo 配置中
apiUrl指向 PocketBase 地址 - CORS 设置允许博客域名跨域访问
6.2 验证步骤
# 1. 健康检查
curl https://your-pocketbase-domain.com/api/health
# 2. 查询点赞记录
curl "https://your-pocketbase-domain.com/api/collections/BlogLikes/records?filter=(url='https://your-blog.com/posts/test/')"
# 3. 创建点赞记录
curl -X POST https://your-pocketbase-domain.com/api/collections/BlogLikes/records \
-H "Content-Type: application/json" \
-d '{"url":"https://your-blog.com/posts/test/","count":1}'
# 4. 验证钩子:尝试篡改 count(应返回 400)
curl -X PATCH https://your-pocketbase-domain.com/api/collections/BlogLikes/records/RECORD_ID \
-H "Content-Type: application/json" \
-d '{"count":999}'7. 常见问题
7.1 CORS 跨域错误
PocketBase 管理后台 → Settings → API Rules → 允许你的博客域名。
7.2 URL 域名验证失败
如果博客 baseURL 带 www(如 https://www.your-blog.com),需要在 BlogLikes.url 字段的 onlyDomains 中同时添加:
your-blog.comwww.your-blog.com
7.3 钩子不生效
- 确认文件名以
.pb.js结尾 - 查看容器日志:
docker logs -f pocketbase - 修改钩子文件后 PocketBase 自动重载,无需重启
7.4 数据持久化
所有数据存储在 pb_data/ 目录中的 SQLite 文件。备份该目录即可完整备份数据。
总结
通过 PocketBase 的 JS 扩展能力,我们用极低的运维成本实现了:
- 服务端防篡改:钩子校验 count 只能 +1
- 数据持久化:SQLite 文件存储,Docker volume 挂载
- 零额外依赖:单容器部署,无需 Node.js 或其他运行时
对于个人博客场景,PocketBase 是一个轻量且够用的 BaaS 选择。
0 次查看