Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

简单写一个打字测速 app #22

Open
hacker0limbo opened this issue Jul 17, 2021 · 0 comments
Open

简单写一个打字测速 app #22

hacker0limbo opened this issue Jul 17, 2021 · 0 comments
Labels
react react 笔记整理 typescript typescript 笔记整理

Comments

@hacker0limbo
Copy link
Owner

关于 Online IDE

每次想做有关 React + TS 的小项目或者 demo, 都需要用 npx create-react-app --template typescript 开一个项目到本地, 既耗费时间又占用资源. 能直接写 React + TS 的 Online IDE 目前只找到 StackBlitzCodeSandbox. 前者关于 ts 的类型提示还是很有问题, 但是速度倒是挺快的. 而且最近新出了一个 feature 能直接运行 Node.js 程序. 后者我电脑带不动...Hot Reload 啥的延迟很高, 经常写着写着就报错, 过一会又自己好了.

目前没有别的好办法, 要想比较好的测试开发体验还是只能老老实实本地开个脚手架然后用 vscode. 有考虑用 code-server 啥的部署一个, 但是又要花钱买服务器啥的就算了...

在不换电脑的前提下有比较靠谱的 Online IDE 可以推荐一下

效果

demo

源码: https://stackblitz.com/edit/typing-speed-app

需求与分析

结合 Demo 可以看到, 当开始打字的时候上面的示例文字会实时显示所打的每个字母出否正确, 下面有三个数据显示. 第一个为总时间, 可以认为是一个时钟, 当开始打字的时候触发. 直到打字结束即字数和示例文字一样的时候时钟停止. 此时也无法再继续往输入框内输入文字. WPM 即 word per minutes, 每分钟多少个字. 这里的 word 定位为 1 word = 5 characters. 最后显示的是正确的字母数, 外加一个按钮可以重新开始.

需求明确了可以思考需要哪些基本状态, 以及对应的衍生状态, 这里直接列出来了, 一共可以需要 4 个基本状态:

const initialState = {
  text: '',
  input: '',
  seconds: 0,
  timerId: undefined,
}

解释一下, 由于上述的需求, 我们需要一个 text 规定示例文字, 其实这个不作为状态也可, 因为示例文字原则上是不会变的, 这里为了方便就归在状态里了, input 代表用户输入的文字, 是实时改变的. seconds 是定时器状态, 当计时器开始的时候每一秒会自动增加 1. timerId 是定时器 id, 因为我们虚监控定时器. 比如当用户开始打字的时候我们设置一个定时器. 此时 timerId 是存在的. 当打字结束或者用户点击了 reset 之后 timerId 需要被重设为 undefined

衍生状态就有很多, 比如 correctCharacters 就可以由 textinput 得出. WPM 又可以由 correctCharactersseconds 得出. 规定好了基本状态, 衍生状态都可以直接按需计算得出, 而无需放在初始状态里.

实现

关于状态管理部分打算使用 useReducer + useContext, 会和 redux 有点像. 不过类型部分应该不会写的非常严谨.

types

该文件存放所有类型定义, 主要有 action, state, reducer. 如下

// ./store/types.ts

export type TypingState = {
  text: string;
  input: string;
  seconds: number;
  timerId?: number;
};

export enum TypingActionTypes {
  CHANGE_INPUT,
  SET_TIMER,
  TICK,
  RESET_TICK
}

export type TypingAction<T> = {
  type: TypingActionTypes;
  payload?: T;
};

export type TypingReducer = (
  state: TypingState,
  action: TypingAction<any>
) => TypingState;

关于 actionpayload 类型这里简略的就用 any 替代了, 严格上所有定义的 action 都应该有关于其 payload 的精确的类型, 然后通过 union 合并成一个总的类型, 例如这样:

type TypingInputAction = {
  type: 'TYPING_INPUT',
  payload: string
}

type TypingSetTimerAction = {
  type: 'TYPING_SET_TIMER',
  payload?: number
}

// other action types

export type TypingAction = TypingInputAction | TypingSetTimerAction

类型部分会有点像 redux, 更多可以直接参考 redux 源代码是怎么定义相关工具类型的. 或者参考我之前写过的文章: 简单用 React+Redux+TypeScript 实现一个 TodoApp

这里用枚举一共定义了 4 种 action 类型, 具体为:

  • CHANGE_INPUT: 当用户开始输入会不断触发 onChange 事件, 该 action 也会不断被触发, 需要实时获取文本框即用户的输入
  • SET_TIMER: 设置定时器的动作, 当开始输入时设置定时器的 id, 结束时设回 undefined
  • TICK: 时钟动作, 初始为 0, 定时器开始后每一秒触发一次, 每次加一, 代表定时器的时间
  • RESET_TICK: 重设时钟, 重设为 0

reducer

有了类型和 action, 就可以完善 reducer, 即状态是如何根据 action 变化的:

// ./store/reducers.ts
import { TypingReducer, TypingActionTypes } from './types';

export const typingReducer: TypingReducer = (state, action) => {
  switch (action.type) {
    case TypingActionTypes.CHANGE_INPUT:
      return {
        ...state,
        input: action.payload
      };
    case TypingActionTypes.SET_TIMER:
      return {
        ...state,
        timerId: action.payload
      };
    case TypingActionTypes.TICK:
      return {
        ...state,
        seconds: state.seconds + 1
      };
    case TypingActionTypes.RESET_TICK:
      return {
        ...state,
        seconds: 0
      };
    default:
      return state;
  }
};

注意这里的 reducer 是结合 useReducer 这个 hook 一起使用的, 不像 redux 里可以直接给参数赋值声明初始状态. 即 useReducer(reducer, initialState). reducer 只需要负责状态的改变的逻辑部分即可.

context

关于 context 部分, 需要明确我们需要把什么作为全局数据传入到组件中. 由于是结合 useReducer, 直接将 useReducer 的返回值即 [state, dispatch] 传入即可. 当然类型需要明确一下. 同时自定义一个 Provider 作为容器存放全局数据. 整体架构大致如下:

// ./store/context.tsx

const initialState: TypingState = {
  text: 'Lorem Ipsum is simply dummy text of the printing and typesetting industry.',
  input: '',
  seconds: 0,
  timerId: undefined
};

export const typingContext = createContext<
  [TypingState, Dispatch<TypingAction<any>>]
>([{} as TypingState, () => {}]);

export const TypingProvider: React.FC = ({ children }) => {
  const value = useReducer(typingReducer, initialState);
  return (
    <typingContext.Provider value={value}>{children}</typingContext.Provider>
  );
};

然后在根组件声明 TypingProvider:

// ./components/App.tsx

export function App() {
  return (
    <div>
      <TypingProvider>
        {/* components */}
      </TypingProvider>
    </div>
  )
}

TypingProvider 下的任何组件, 都可以通过 useContext(typingContext) 获得全局数据 [typingState, dispatch], 前者为当前的状态, 后者可用于发送 action 修改状态.

这里深入一点, 业务逻辑比如对应的方法可以放到组件里写, 也可以在选择自定义一个 hook 暴露出需要的方法, 组件只需要用这个 hook 即可.

回顾 demo 需要整个流程大致是这样的:

  • 当文本输入框第一次有输入时候, 设置一个定时器, 即 SET_TIMER action. 同时在定时器, 也就是 setInterval 的回调里面不断触发 TICK action. 保证每秒都记录下时间. 这里注意如何去辨别第一次输入, 正常情况下只能 onChange 事件的监听只存在与输入是否有变化, 判断是否为第一次需要加上两个条件:
    • 当前状态里是否有 timerId
    • 当前用户输入的文字长度是否小于示例文字, 即打字是否完成
  • 定时器开始后用户不断开始打字, 此时 onChange 事件继续不断被监听, 回调函数需要不断触发 CHANGE_INPUT action
  • 当用户打字结束(这里简单定义为用户输入的字数长度和示例文字长度相同), 定时器销毁, 同时状态中的 timerId 设回 undefined
  • reset 按钮需要将所有状态初始化, 包括 timerId, input, seconds

context 部分完整代码如下:

// ./store/context.tsx

import React, { createContext, useReducer, useContext, Dispatch } from 'react';
import { typingReducer } from './reducers';
import { TypingState, TypingAction, TypingActionTypes } from './types';

const initialState: TypingState = {
  text: 'Lorem Ipsum is simply dummy text of the printing and typesetting industry.',
  input: '',
  seconds: 0,
  timerId: undefined
};

export const typingContext = createContext<
  [TypingState, Dispatch<TypingAction<any>>]
>([{} as TypingState, () => {}]);

export const useTypingContext = () => {
  const [state, dispatch] = useContext(typingContext);

  const onInput = (value: string) => {
    if (value.length < state.text.length && !state.timerId) {
      startTimer();
    }

    if (value.length >= state.text.length && state.timerId) {
      stopTimer();
    }

    dispatch({
      type: TypingActionTypes.CHANGE_INPUT,
      payload: value
    });
  };

  const startTimer = () => {
    const timerId = setInterval(
      () => dispatch({ type: TypingActionTypes.TICK }),
      1000
    );
    dispatch({ type: TypingActionTypes.SET_TIMER, payload: timerId });
  };

  const stopTimer = () => {
    clearInterval(state.timerId);
    dispatch({ type: TypingActionTypes.SET_TIMER });
  };

  const onReset = () => {
    stopTimer();
    dispatch({ type: TypingActionTypes.CHANGE_INPUT, payload: '' });
    dispatch({ type: TypingActionTypes.RESET_TICK });
  };

  return { state, onInput, onReset };
};

export const TypingProvider: React.FC = ({ children }) => {
  const value = useReducer(typingReducer, initialState);
  return (
    <typingContext.Provider value={value}>{children}</typingContext.Provider>
  );
};

组件

一共有三个组件

  • Preview 组件用户展示示例文字, 包括用户输入和示例文字的差异也会用颜色在示例文字上标注
  • UserInput 组件渲染文本框, 供用户输入
  • SpeedInfo 组件展示用户打字的各种数据

Preview

textinput 状态均为两个字符串, 不同的是 text 是静态的, 而 input 会随着用户的输入而动态变化. 对于 text 上的每一个字母, 其索引位置如果有对应的 input 的字母, 则进行比较并进行 class 的标注, 否则保持不变. 具体代码如下

// ./components/Preview.tsx

import React from 'react';
import { useTypingContext } from '../store/context';

export const Preview: React.FC = () => {
  const {
    state: { text, input }
  } = useTypingContext();

  return (
    <div>
      {text.split('').map((c, i) => (
        <span
          key={`${c}-${i}`}
          className={i < input.length ? (c === input[i] ? 'green' : 'red') : ''}
        >
          {c}
        </span>
      ))}
    </div>
  );
};

UserInfo

这个组件比较简单, 唯一需要注意的是当用户打字完成后需要将输入框变成 readonly 状态, 判断条件则是之前所说的当 input 的长度和 text 的长度一样, 具体代码如下:

// ./components/UserInfo.tsx

import React from 'react';
import { useTypingContext } from '../store/context';

export const UserInput: React.FC = () => {
  const {
    state: { input, text },
    onInput
  } = useTypingContext();

  return (
    <textarea
      cols="60"
      rows="3"
      readOnly={input.length >= text.length}
      value={input}
      onChange={e => onInput(e.target.value)}
    />
  );
};

SpeedInfo

该组件需要渲染当前用户打字速度的状态. 比如 WPM, 正确的字母数. 关于这些数据的计算方法不多描述, 均写在了 utils.ts

// ./utils.ts

export const words = (c: number) => c / 5;

export const minutes = (s: number) => s / 60;

export const wpm = (c: number, s: number) =>
  words(c) === 0 || minutes(s) === 0 ? 0 : Math.round(words(c) / minutes(s));

export const countCorrectCharacters = (text: string, input: string) => {
  const tc = text.replace(' ', '');
  const ic = input.replace(' ', '');
  return ic.split('').filter((c, i) => c === tc[i]).length;
};

组件也只需按需拿状态和方法即可

// ./components/SpeedInfo.tsx

import React from 'react';
import { useTypingContext } from '../store/context';
import { countCorrectCharacters, wpm } from '../utils';

export const SpeedInfo = () => {
  const {
    state: { input, seconds, text },
    onReset
  } = useTypingContext();

  const correctCharacters = countCorrectCharacters(text, input);

  return (
    <div>
      <div>Total time: {seconds} s</div>
      <div>WPM: {wpm(correctCharacters, seconds)}</div>
      <div>Correct characters: {correctCharacters}</div>
      <button onClick={onReset}>Reset</button>
    </div>
  );
};

至此该 Demo 算是完成了

参考

@hacker0limbo hacker0limbo added react react 笔记整理 typescript typescript 笔记整理 labels Jul 17, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
react react 笔记整理 typescript typescript 笔记整理
Projects
None yet
Development

No branches or pull requests

1 participant