没事造轮子没事造轮子

没事造轮子

网站动态数据显示

  • W_Z_C
  • 阅读约 9 分钟
网站动态数据显示

本篇文章主要介绍一下如何实现网站动态数据的显示。对于一个网站来说动态数据显得有点宽泛,在这里的动态数据主要指的是评分、文章的访问量、评论等等,这些数据随着时间的推移而产生改变,不像博客文章之类的内容,只是偶尔进行迭代。

这类数据的另一个特点是交互性和实时性,动态数据一般会和用户的行为进行绑定,并且这类数据很难进行缓存,因为它们具有实时性。当你为文章打了一个分数后,你显然需要实时的给出反馈,如果你一直使用缓存数据就可能会给用户带来困惑,他们会感觉自己的操作没有生效,或者是网站存在问题等判断,这会对网站的用户体验带来负面的影响。

因此这类数据相对于网站的内容需要单独的进行处理,下面就展示一下具体的实现过程。

1. 数据的存储

因为动态数据都是需要积累的,这意味着它们都需要进行持久化处理。目前咱们的博客文章属于临时存储,每次网站发布后都会清空历史文章,然后重新写入一次。对于动态数据来说,这显然不合适,否则每次部署后历史评分、评论已经访问量等等都会清空,这对网站来说是一种伤害,因此咱们需要找一个地方存储这些动态数据。

现成的存储位置就是数据库了,这里额外建立两个数据表,一个用来存储点赞值,另一个用来存储访问量,以后有了评论模块,可以再建立单独的数据表保存。下面是这两个表的结构:

schema.prisma
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

有了数据表后,还需要对数据表操作的逻辑,这里先实现写入逻辑,读取逻辑在后面显示数据的时候再一起实现。

db.mjs
//记录访客数据
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 预渲染页面的内容么?我们目前的博客系统会对所有的页面进行预渲染,这些预渲染的页面生成的时间是在网站部署的时候,而每次部署的间隔时间可能会非常久,这就会导致上面提到的缓存问题。

页面显示的数据是上次页面部署的时候生成的,这些数据并不是实时的,为了保证显示的数据是最新的,需要额外的增加一个动态获取实时数据的接口,在没获取之前,使用预渲染页面的数据,获取成功之后,更新当前页面的动态数据为最新的值,实现过程和首页的最新文章模块异曲同工。

先是读取后端的动态数据,可以使用统一的接口:

db.mjs
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 提供的结构重新组装逻辑。

然后再去修改页面预渲染的数据获取接口:

props/post.js
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

使用方法如下:

layouts/post.js
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 组件代码
PageRating.js
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-ratinganimated-number-react。前者是用来显示星星评分的,后者是用来显示文字动画的,主要是为了看起来更舒服一点。

如果点击组件给文章评分,就会触发 handleOnClick 函数,通过该函数,可以将评分结果发送到后台,进而保存到数据库中:

PageRating.js
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 函数从后端拉取实时数据:

PageRating.js
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 接口获取的,这个接口是动态数据可以正常显示的核心,它主要的作用有几点:

  1. 获取当前用户名,如果不存在则对当前用户名命。
  2. 从数据库获取动态数据并返回。
  3. 将用户的评分数据写入数据库。
  4. 将用户访问记录写入数据库。
  5. 设置网站的 cookie。

在前面的内容中,我一直对用户的问题避而不谈,因为目前咱们博客并没有用户系统,所以这个用户名显然也是不存在的,但是目前动态数据表的字段中确包含用户字段,这主要一方面是区分现有用户,另一方面也是为了以后引入用户系统做一些提前准备。

目前我的系统中,对匿名用户是这样操作的:

当用户访问我的网页,页面会自动触发 /api/score 接口,该接口会从后台读取当前文章的动态数据,以及当前用户的评分值,并将结果汇总返回到前端。当获取的用户是一个新用户,后台会生成一个新的用户名和该用户的访问记录,为了以后区分该用户,还会将该用户的名称记录到 cookie 中,等下次访问的时候,浏览器会自动将 cookie 发动到后端,这时候后端就可以从 cookie 中读取用户名称,进而区分当前访问的用户是否为新用户。

api/score 接口实现
api/score.js
//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 字段,该字段记录用户提交的分数,下面是用户提交触发的代码:

记录评分逻辑
api/score.js
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 获取数据

还有一部分的逻辑就是从后台获取最新的动态数据:

读取用户评分
score.js
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 函数用于记录访问当前页面的用户信息,和记录点赞数据不同,该数据是允许多次记录的,所以如果你刷新一回页面,就会增加一次阅读数。