简单写一个打字测速 app
hacker0limbo opened this issue · 0 comments
关于 Online IDE
每次想做有关 React + TS 的小项目或者 demo, 都需要用 npx create-react-app --template typescript
开一个项目到本地, 既耗费时间又占用资源. 能直接写 React + TS 的 Online IDE 目前只找到 StackBlitz 和 CodeSandbox. 前者关于 ts 的类型提示还是很有问题, 但是速度倒是挺快的. 而且最近新出了一个 feature 能直接运行 Node.js 程序. 后者我电脑带不动...Hot Reload 啥的延迟很高, 经常写着写着就报错, 过一会又自己好了.
目前没有别的好办法, 要想比较好的测试开发体验还是只能老老实实本地开个脚手架然后用 vscode. 有考虑用 code-server 啥的部署一个, 但是又要花钱买服务器啥的就算了...
在不换电脑的前提下有比较靠谱的 Online IDE 可以推荐一下
效果
源码: 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
就可以由 text
和 input
得出. WPM
又可以由 correctCharacters
和 seconds
得出. 规定好了基本状态, 衍生状态都可以直接按需计算得出, 而无需放在初始状态里.
实现
关于状态管理部分打算使用 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;
关于 action
的 payload
类型这里简略的就用 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
text
和 input
状态均为两个字符串, 不同的是 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 算是完成了