字节笔记本

2026年2月22日

Qwerty Learner useWordStats Hook 源码解析

本文介绍 Qwerty Learner 项目中 useWordStats Hook 的实现,这是一个用于统计分析用户打字练习数据的自定义 React Hook。该 Hook 从 IndexedDB 中读取练习记录,计算并返回多种维度的统计数据,包括练习次数、词数统计、WPM(每分钟字数)、正确率以及错误按键分布。

文件概述

useWordStats.ts 位于 src/pages/Analysis/hooks/ 目录下,是 Qwerty Learner 数据分析功能的核心 Hook。它接收时间范围参数,返回该时间段内的打字练习统计数据,用于生成热力图、趋势图等可视化图表。

核心接口定义

typescript
interface IWordStats {
  isEmpty?: boolean
  exerciseRecord: Activity[]      // 每日练习次数(用于热力图)
  wordRecord: Activity[]          // 每日练习词数(用于热力图)
  wpmRecord: [string, number][]   // 每日 WPM 数据
  accuracyRecord: [string, number][]  // 每日正确率数据
  wrongTimeRecord: { name: string; value: number }[]  // 错误按键统计
}

主要功能实现

1. 日期范围计算

typescript
function getDatesBetween(start: number, end: number) {
  const dates = []
  let curr = dayjs(start).startOf('day')
  const last = dayjs(end).endOf('day')
  while (curr.diff(last) < 0) {
    dates.push(curr.clone().format('YYYY-MM-DD'))
    curr = curr.add(1, 'day')
  }
  return dates
}

使用 dayjs 计算两个时间戳之间的所有日期,为数据统计建立日期索引。

2. 活跃度等级计算

typescript
function getLevel(value: number) {
  if (value === 0) return 0
  else if (value < 4) return 1
  else if (value < 8) return 2
  else if (value < 12) return 3
  else return 4
}

将数值映射为 0-4 的等级,用于热力图的颜色深浅表示。

3. Hook 主函数

typescript
export function useWordStats(startTimeStamp: number, endTimeStamp: number) {
  const [wordStats, setWordStats] = useState<IWordStats>({
    exerciseRecord: [],
    wordRecord: [],
    wpmRecord: [],
    accuracyRecord: [],
    wrongTimeRecord: [],
  })

  useEffect(() => {
    const fetchWordStats = async () => {
      const stats = await getChapterStats(startTimeStamp, endTimeStamp)
      setWordStats(stats)
    }
    fetchWordStats()
  }, [startTimeStamp, endTimeStamp])

  return wordStats
}

标准的 React Hook 模式,接收起止时间戳,返回统计数据状态。

4. 数据统计核心逻辑

typescript
async function getChapterStats(startTimeStamp: number, endTimeStamp: number): Promise<IWordStats> {
  // 从 IndexedDB 查询时间范围内的记录
  const records: IWordRecord[] = await db.wordRecords
    .where('timeStamp')
    .between(startTimeStamp, endTimeStamp)
    .toArray()

  if (records.length === 0) {
    return { isEmpty: true, ... }
  }

  // 初始化日期数据结构
  let data: { [date: string]: { 
    exerciseTime: number  // 练习次数
    words: string[]       // 练习词数组
    totalTime: number     // 总计用时
    wrongCount: number    // 错误次数
    wrongKeys: string[]   // 错误按键
  } } = {}

  // 聚合统计数据
  for (let i = 0; i < records.length; i++) {
    const date = dayjs(records[i].timeStamp * 1000).format('YYYY-MM-DD')
    data[date].exerciseTime++
    data[date].words.push(records[i].word)
    data[date].totalTime += records[i].timing.reduce((acc, curr) => acc + curr, 0)
    data[date].wrongCount += records[i].wrongCount
    data[date].wrongKeys.push(...Object.values(records[i].mistakes).flat())
  }

  // 生成各类统计记录...
}

5. 统计指标计算

练习次数统计

typescript
const exerciseRecord = RecordArray.map(([date, { exerciseTime }]) => ({
  date,
  count: exerciseTime,
  level: getLevel(exerciseTime),
}))

练习词数统计(去重)

typescript
const wordRecord = RecordArray.map(([date, { words }]) => ({
  date,
  count: Array.from(new Set(words)).length,
  level: getLevel(Array.from(new Set(words)).length),
}))

WPM(每分钟字数)计算

typescript
const wpmRecord = RecordArray.map<[string, number]>(([date, { words, totalTime }]) => [
  date,
  Math.round(words.length / (totalTime / 1000 / 60)),
]).filter((d) => d[1])

正确率计算

typescript
const accuracyRecord = RecordArray.map<[string, number]>(([date, { words, wrongCount }]) => [
  date,
  Math.round((words.join('').length / (words.join('').length + wrongCount)) * 100),
]).filter((d) => d[1])

错误按键统计

typescript
const wrongTimeRecord: { name: string; value: number }[] = []
const allWrongTime = RecordArray
  .map(([, { wrongKeys }]) => wrongKeys)
  .flat()
  .map((key) => key.toUpperCase())

allWrongTime.forEach((key) => {
  const index = wrongTimeRecord.findIndex((item) => item.name === key)
  if (index === -1) {
    wrongTimeRecord.push({ name: key, value: 1 })
  } else {
    wrongTimeRecord[index].value++
  }
})

技术亮点

  1. IndexedDB 数据查询:使用 Dexie.js 的链式 API 高效查询时间范围数据
  2. 日期数据处理:使用 dayjs 进行日期格式化和计算
  3. 数据聚合模式:通过 reduce 和 map 实现多维度统计
  4. 类型安全:完整的 TypeScript 类型定义

项目链接

分享: