网站动态数据显示
- W_Z_C
- 共 3592 字,阅读约 9 分钟
本篇文章主要介绍一下如何实现网站动态数据的显示。对于一个网站来说动态数据显得有点宽泛,在这里的动态数据主要指的是评分、文章的访问量、评论等等,这些数据随着时间的推移而产生改变,不像博客文章之类的内容,只是偶尔进行迭代。
这类数据的另一个特点是交互性和实时性,动态数据一般会和用户的行为进行绑定,并且这类数据很难进行缓存,因为它们具有实时性。当你为文章打了一个分数后,你显然需要实时的给出反馈,如果你一直使用缓存数据就可能会给用户带来困惑,他们会感觉自己的操作没有生效,或者是网站存在问题等判断,这会对网站的用户体验带来负面的影响。
因此这类数据相对于网站的内容需要单独的进行处理,下面就展示一下具体的实现过程。
1. 数据的存储
因为动态数据都是需要积累的,这意味着它们都需要进行持久化处理。目前咱们的博客文章属于临时存储,每次网站发布后都会清空历史文章,然后重新写入一次。对于动态数据来说,这显然不合适,否则每次部署后历史评分、评论已经访问量等等都会清空,这对网站来说是一种伤害,因此咱们需要找一个地方存储这些动态数据。
现成的存储位置就是数据库了,这里额外建立两个数据表,一个用来存储点赞值,另一个用来存储访问量,以后有了评论模块,可以再建立单独的数据表保存。下面是这两个表的结构:
model likes {
id Int @id @default(autoincrement())
user String
ip String
i18n String
slug String
count Int @default(1)
score Float @default(5.0)
timestamp String
@@unique([user, ip, slug, i18n], map: "kp_unique")
}
model vistors {
id Int @id @default(autoincrement())
user String @default("")
ip String
i18n String
slug String
hit Int @default(1)
timestamp String
}
接着执行 prisma 的 push 命令,生成对应的数据表:
> npx prisma db push
Environment variables loaded from .env
Prisma schema loaded from prisma\schema.prisma
Datasource "db": SQLite database "dev.db" at "file:../dbs/dev.db"
Your database is now in sync with your schema. Done in 260ms
✔ Generated Prisma Client (3.14.0 | library) to .\node_modules\@prisma\client in 141ms
有了数据表后,还需要对数据表操作的逻辑,这里先实现写入逻辑,读取逻辑在后面显示数据的时候再一起实现。
//记录访客数据
export async function recordVisitor(guest) {
const db = getDB()
if (!db) {
console.error('获取数据库实例失败!')
return
}
try {
await db.vistors.create({data: guest})
} catch(e) {
console.error('插入访问数据失败!', e)
}
}
//记录点赞数据
export async function recordLiked(guest) {
const db = getDB()
if (!db) {
console.error('获取数据库实例失败!')
return
}
try {
await db.likes.create({data: guest})
} catch(e) {
console.error('插入访问数据失败!', e)
}
}
2. 数据的显示
目前我在每篇博客的最底部增加了一个评分插件,该插件会显示当前文章的平均分数、总评分数人数、当前用户的评分以及当前页面访问量。这些数据理论上都可以从数据库中获取,但是这里面涉及一个问题。
还记得我们前面提到的 Nextjs 预渲染页面的内容么?我们目前的博客系统会对所有的页面进行预渲染,这些预渲染的页面生成的时间是在网站部署的时候,而每次部署的间隔时间可能会非常久,这就会导致上面提到的缓存问题。
页面显示的数据是上次页面部署的时候生成的,这些数据并不是实时的,为了保证显示的数据是最新的,需要额外的增加一个动态获取实时数据的接口,在没获取之前,使用预渲染页面的数据,获取成功之后,更新当前页面的动态数据为最新的值,实现过程和首页的最新文章模块异曲同工。
先是读取后端的动态数据,可以使用统一的接口:
export async function getDynamicMeta(locale, slug) {
const db = getDB()
if (!db) {
console.error('获取数据库实例失败!')
return
}
const notFound = {
hit: 0,
score: 0,
count: 0,
}
try {
const result = await db.$queryRaw`SELECT SUM(score) AS score, SUM(count) AS count, SUM(hit) AS hit FROM ( SELECT 0 AS score, 0 AS count, SUM(hit) AS hit FROM vistors WHERE slug = ${slug} AND i18n = ${locale} UNION ALL SELECT SUM(count * score) / SUM(count) AS score, SUM(count) AS count, 0 AS hit FROM likes WHERE slug = ${slug} AND i18n = ${locale}) AS T LIMIT 1`
if(!result || result.length < 1) {
return notFound
}
return result[0] || notFound
} catch(e) {
console.error('查询数据失败!', e)
return notFound
}
}
这里使用了 prisma 提供的原始 SQL 语句查询的方式,主要是犯懒了,把以前写的语句直接拿来使用,不用再按照 prisma 提供的结构重新组装逻辑。
然后再去修改页面预渲染的数据获取接口:
export async function postProps({locale, slug}) { //...
//获取元数据 const dm = await getDynamicMeta(locale, slug) const ap = await queryAdjoinPosts(locale, slug) const payload = { content: post.content, prev: ap[0], next: ap[1], chapters: [], meta: { ...omitBy(post, 'content', 'layout'), ...dm, } }
//...}
代码中将查询的结果传递到前端组件,这样在前端渲染的时候,可以通过 meta 对象获取到文章的动态数据。
2.1 预渲染页面
用 post 布局模板举例子,显示这些动态数据,需要额外的前端代码,目前项目中使用动态数据的部分被我单独封装为一个组件,名命为 PageRating
。
使用方法如下:
export function PostLayout({ locale, trans, prev, next, meta, content }) { return ( <> //...
<div className="max-w-6xl px-4 mx-auto mb-8"> <PageRating locale={locale} i18n={{...trans.PageRating}} meta={meta} />
{ (prev || next) && ( <PageNav i18n={{ ...trans.PageNav }} prev={prev} next={next} /> ) } </div>
//...
</> )}
最后就是最核心的组件代码:
PageRating 组件代码
import StarRating from "react-svg-star-rating"
import React, { useEffect, useState } from "react"
import AnimatedNumber from "animated-number-react"
export function PageRating({ locale, i18n, meta }) {
const [dynamicData, setDynamicData] = useState({
readOnly: false,
hit: meta.hit || 0,
score: meta.score || 0,
count: meta.count || 0,
})
const handleOnClick = async (rating) => {
}
return (
<div className="mt-8 text-center">
<div className="my-6 text-2xl">{i18n.Title}</div>
<StarRating
unit="half"
size={56}
initialRating={dynamicData.score}
isReadOnly={dynamicData.readOnly}
handleOnClick={handleOnClick}
starClassName="inline-block opacity-70"
/>
<div className="text-sm my-2 divide-solid space-x-4">
<AnimatedNumber
value={dynamicData.hit < 1 ? 1 : dynamicData.hit}
formatValue={(v) => {
return `${i18n.Hit}${~~v}`
}}
delay={800}
/>
<AnimatedNumber
value={dynamicData.score}
formatValue={(v) => {
return `${i18n.Score}${v.toFixed(1)}`
}}
delay={500}
/>
<AnimatedNumber
value={dynamicData.count}
formatValue={(v) => {
return `${i18n.Count}${~~v}`
}}
delay={500}
/>
</div>
</div>
)
}
代码中使用了两个第三方组件:react-svg-star-rating
和 animated-number-react
。前者是用来显示星星评分的,后者是用来显示文字动画的,主要是为了看起来更舒服一点。
如果点击组件给文章评分,就会触发 handleOnClick 函数,通过该函数,可以将评分结果发送到后台,进而保存到数据库中:
const handleOnClick = async (rating) => {
let obj = { ...dynamicData }
obj.readOnly = true
obj.score = (obj.score * obj.count + rating) / (obj.count + 1)
obj.count = obj.count + 1
//提交到后台
await fetch('/api/score', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
locale,
slug: encodeURIComponent(meta.slug || ''),
score: rating,
})
})
setDynamicData(obj)
}
代码中向后台发送了 POST 请求,并更新了页面现有分数,因为每个用户只能评分一次,所以还额外设置 readOnly 字段,防止用户多次打分。
2.2 动态加载
上面介绍了预渲染界面的显示方式,这些页面会在编译期间进行预渲染,而文章显示的分数固定在 Nextjs 编译的那一时刻,为了让页面显示最新的数据,我们需要使用额外的代码加载当前的动态数据。
在 React 中可以使用 useEffect 函数从后端拉取实时数据:
useEffect(() => {
const abortController = new AbortController()
fetch('/api/score', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
slug: encodeURIComponent(meta.slug || ''),
}),
signal: abortController.signal,
})
.then(response => response.json())
.then((json) => {
if (json && json.success && json.meta) {
setDynamicData(org => {
let obj = {
...org,
locale,
hit: json.meta.hit || 1,
score: json.meta.score,
count: json.meta.count,
}
obj.readOnly = json.meta.userScore > 0
return obj
})
}
})
.catch(err => {
console.log(err)
})
return function cleanup() {
abortController.abort()
}
}, [meta, locale])
同样是调用 /api/score
接口,不同的地方在于 useEffect 在组件加载后才会执行,所以最终的效果变成,当页面打开的一刹那,页面显示的动态数据是 Nextjs 编译时期的老数据,但是当组件加载后会再次获取最新的数据,并将页面上的动态数据进行更新,最终我们可能会看到数据更新的动画效果。
3. 动态数据获取接口
页面上显示的动态数据是通过 /api/score
接口获取的,这个接口是动态数据可以正常显示的核心,它主要的作用有几点:
- 获取当前用户名,如果不存在则对当前用户名命。
- 从数据库获取动态数据并返回。
- 将用户的评分数据写入数据库。
- 将用户访问记录写入数据库。
- 设置网站的 cookie。
在前面的内容中,我一直对用户的问题避而不谈,因为目前咱们博客并没有用户系统,所以这个用户名显然也是不存在的,但是目前动态数据表的字段中确包含用户字段,这主要一方面是区分现有用户,另一方面也是为了以后引入用户系统做一些提前准备。
目前我的系统中,对匿名用户是这样操作的:
当用户访问我的网页,页面会自动触发 /api/score
接口,该接口会从后台读取当前文章的动态数据,以及当前用户的评分值,并将结果汇总返回到前端。当获取的用户是一个新用户,后台会生成一个新的用户名和该用户的访问记录,为了以后区分该用户,还会将该用户的名称记录到 cookie 中,等下次访问的时候,浏览器会自动将 cookie 发动到后端,这时候后端就可以从 cookie 中读取用户名称,进而区分当前访问的用户是否为新用户。
api/score 接口实现
//token 算法
function genToken(user) {
return md5(Buffer.from('SALT' + (user.trim() || '')).toString('base64'))
}
export default async function Score(req, res) {
if (req.method !== 'POST') {
return res.status(200).json({
success: true,
meta: {
score: 0,
view: 0
}
})
}
const submitSchema = Joi.object({
slug: Joi.string().min(1).max(512).required(),
score: Joi.number().positive().max(5),
locale: Joi.string().max(10).empty('').default('zh-Hans'),
})
const { error, value } = submitSchema.validate(req.body)
if (error) {
return res.status(200).json({
success: false,
message: error.message || '',
})
}
const clientIP = requestIp.getClientIp(req) || ''
const now = moment().format('YYYY-MM-DD HH:mm:ss')
const slug = decodeURIComponent(value.slug)
//查看 cookie
const parsedCookies = parseCookies({ req })
let token = parsedCookies.mszlz_token || ''
let user = parsedCookies.mszlz_user || ''
//判断用户是否存在
let existUser = false
if (token && token.length > 0 && user && user.length > 0 && genToken(user) === token) {
existUser = true
}
if (!value.score) {
//TODO: 获取动态数据
} else {
//TODO: 提交评分
}
}
接口首先校验参数,并获取当前 ip 地址、时间、用户名和token,并进行用户名校验,如果通过则用户存在,否则将看作新用户。
该接口被调用可能存在两种情况,一种是用户评分后调用,主要用于记录用户的评分结果;另一种是页面显示后调用,用于获取最新的动态数据记录。
3.1 记录评分
对于前者,提交的字段中包含 score 字段,该字段记录用户提交的分数,下面是用户提交触发的代码:
记录评分逻辑
let userScore = value.score
let needRecordScore = false
//不存在该用户
if (!existUser) {
const ipUser = await getUserByIP(locale, slug, clientIP)
//获取老用户
if (ipUser && ipUser.exist) {
user = ipUser.user
token = genToken(user)
userScore = ipUser.score
} else {
user = nanoid(10)
token = genToken(user)
needRecordScore = true
}
setCookie({ res }, 'mszlz_user', user, {
path: '/',
maxAge: 365 * 24 * 60 * 60,
})
setCookie({ res }, 'mszlz_token', token, {
path: '/',
maxAge: 365 * 24 * 60 * 60,
})
} else {
const niUser = await getScoreByNameAndIP(locale, slug, clientIP, user)
if (niUser && niUser.exist) {
userScore = niUser.score
} else {
needRecordScore = true
}
}
//记录赞
if (needRecordScore) {
await recordLiked({
user: user,
ip: clientIP,
i18n: locale,
slug: slug,
score: userScore,
timestamp: now,
})
}
res.status(200).json({
success: needRecordScore
})
代码的主要逻辑在于判断是否需要将当前分数存储到数据库中,只有用户没有给当前页面打过份的情况下才需要记录本次评分。期间,如果用户不存在,则尝试通过 IP 地址查找是否有当前用户的历史记录,如果有就将历史用户保存到 cookie 中,否则就创建一个新的用户。
3.2 获取数据
还有一部分的逻辑就是从后台获取最新的动态数据:
读取用户评分
let userScore = -1
//不存在该用户
if (!existUser) {
const ipUser = await getUserByIP(locale, slug, clientIP)
//获取老用户
if (ipUser && ipUser.exist) {
user = ipUser.user
token = genToken(user)
userScore = ipUser.score
} else {
user = nanoid(10)
token = genToken(user)
userScore = -1
}
setCookie({ res }, 'mszlz_user', user, {
path: '/',
maxAge: 365 * 24 * 60 * 60,
})
setCookie({ res }, 'mszlz_token', token, {
path: '/',
maxAge: 365 * 24 * 60 * 60,
})
} else {
const niUser = await getScoreByNameAndIP(locale, slug, clientIP, user)
if (niUser && niUser.exist) {
userScore = niUser.score
}
}
const meta = await getDynamicMeta(locale, slug)
res.status(200).json({
success: true,
meta: {
...meta,
userScore,
}
})
//记录访问信息
await recordVisitor({
ip: clientIP,
user: user,
i18n: locale,
slug: slug,
timestamp: now,
})
代码和上一节的情况类似,只不过这一次更多的是只读,单纯的查找当前页面的动态数据而已。recordVisitor 函数用于记录访问当前页面的用户信息,和记录点赞数据不同,该数据是允许多次记录的,所以如果你刷新一回页面,就会增加一次阅读数。