我们将使用TypeScript建立一个简单的Express后端API,它将为我们提供一个从前端请求的随机单词列表。
此外,我们将为这个应用程序建立一个用户界面,在那里我们可以配置我们想使用的单词量,我们想在几分钟内写完的时间长度,以及德语和英语之间的语言切换器。此外,还将有一个可供书写的文本字段、一个计时器和一个指示器,显示我们在文本中已经使用了哪些单词。
太棒了,我们开始吧!
服务器的实现
让我们从服务器开始,因为它非常简单。我们将以Express为基础,只创建两个端点。
GET /words/de/$number- returns $number German wordsGET /words/en/$number- 返回 $number English words
我们将使用的唯一依赖性是typescript,express 和cors 。
我们将把上面提到的包含德语和英语单词的JSON文件放在./languages/de.json 和./languages/en.json 。
让我们只看一下整个服务器的代码,因为它非常简单和简短。
import express, { Application, Router, Request, Response } from 'express';
import cors from 'cors';
import * as fs from 'fs';
const de_words: String[] = JSON.parse(fs.readFileSync('./languages/de.json', {encoding:'utf8', flag:'r'}));
const en_words: String[] = JSON.parse(fs.readFileSync('./languages/en.json', {encoding:'utf8', flag:'r'}));
const app: Application = express();
const route = Router();
route.get("/words/de/:num", async(req: Request, res: Response): Promise<any> => {
const num: number = parseInt(req.params.num);
if (num > 100 || num < 1) {
res.status(400);
return res.json({
error: "number needs to be between 1 and 100"
})
}
return res.json({
words: n_random_words_from_file(de_words, num),
});
});
route.get("/words/en/:num", async(req: Request, res: Response): Promise<any> => {
const num: number = parseInt(req.params.num);
if (num > 100 || num < 1) {
res.status(400);
return res.json({
error: "number needs to be between 1 and 100"
})
}
return res.json({
words: n_random_words_from_file(en_words, num),
});
});
function rand(min: number, max: number) {
return Math.floor(
Math.random() * (max - min) + min
)
}
function n_random_words_from_file(file: String[], num: number): String[] {
const len = file.length;
const random_nums: number[] = [];
let i = 0;
while (i < num) {
let new_rand = rand(0, len);
if (random_nums.includes(new_rand)) {
continue;
}
i++;
random_nums.push(new_rand);
}
return random_nums.map((n) => file[n])
}
app.use(express.json());
app.use(cors());
app.use(route);
app.listen(8080, () => {
console.log("Server running on port 8080");
});
首先,在服务器启动之前,我们使用fs.readFileSync 和JSON.parse 将两个语言文件读入内存。然后,我们配置Express应用程序,并添加我们的两个路由/words/de 和/words/en ,其中有一个数字参数:num 。
在处理程序中,我们解析数字参数,验证它是否在1到100之间,如果不是则返回一个错误,并返回一个JSON格式的结果。
{
"words": ["one", "two"]
}
我们通过调用辅助函数n_random_words_from_file 来获得数据,我们把数字参数和我们语言文件的JSON化内容传给它。
在这个函数中,我们首先确定文件中的字数,并简单地创建唯一的n 随机数,其中n 是客户给出的数字值,将这些数字添加到一个数组中,并返回这些数字映射到语言文件中第n个位置的数组,给我们在给定语言文件中的n 随机字。
然后我们完成Express配置,添加一个默认的CORS配置,设置我们的路由器,并在端口8080 ,启动应用程序。就这样了。
为了方便起见,我们还添加了一个run.sh 脚本来编译typescript并启动服务器。
#!/bin/bash -e
./node_modules/.bin/tsc && node build/index.js
这就是API--正如我所说的,它非常简单,而且TS在这样一个简单的应用程序中的影响也很小,所以我甚至不会去讨论它,而我们将继续讨论UI实现。
客户端实现
UI的实现是基于Create React App的。你可以使用这个命令创建一个新的支持TypeScript的完全连接的React项目。
npx create-react-app ui --template typescript
我通常不太喜欢这些神奇的脚手架工具,但在这种情况下,由于我对如何用React等设置TypeScript构建过程并不感兴趣,而只是对如何用TypeScript构建一个现代React应用感兴趣,所以我认为走这条路也不错。对于任何生产级的应用程序,我总是建议100%地了解你的构建管道,并追求最小的足迹。
这在开始的时候会比较费劲,但是理解了这个工作方式之后就会有收获,你就可以重复使用你研究、配置和设置过的很多东西。总之,你做你的,这就是我在这个玩具项目中走这条路线的原因。
我们将在App.tsx ,自上而下地开始编写用户界面,建立状态处理机制和基本结构,然后实现实际用户界面所需的小型组件。在这篇文章中不会涉及到CSS和基础HTML,因为它非常简单,与本例无关。
让我们从App.tsx 中的应用状态开始。
type State = {
running: boolean,
currentTimer: number,
stopTime: Date | null,
words: WordState[],
};
export type WordState = {
word: string,
used: boolean,
};
对于这个应用程序,我们不需要太多的状态浮动。我们需要知道,应用程序是否正在运行(即定时器正在运行),定时器何时应该停止,以及剩余的时间和一个WordState ,对于我们得到的每个随机词,我们检查它是否已经被使用。这样,我们就可以向用户展示他们已经使用过的单词。
在此基础上,我们可以定义我们的行动和应用程序的初始状态。
type StartPayload = {
minutes: number,
words: string[],
};
export type Action =
| { type: 'start'; payload: StartPayload }
| { type: 'reset' }
| { type: 'updateWords', payload: WordState[] }
| { type: 'tick' };
const initialState: State = {
running: false,
currentTimer: 0,
words: [],
stopTime: null,
};
有4种类型的动作。
start- 用给定的单词启动计时器,并在几分钟内完成reset- 将所有的东西重新设置为初始状态updateWords- 在用户打字时,更新一个词的 。WordStatetick- 每秒钟发生一次,当计时器运行时,倒数剩余时间并重新渲染UI计时器。
接下来,我们来到我们的还原器--我们的状态管理的核心,在这里我们定义了基于不同的动作应该发生的状态变化。
const reducer = (state: State, action: Action): State => {
switch (action.type) {
case 'start': {
const stopTime = new Date();
stopTime.setMinutes(stopTime.getMinutes() + action.payload.minutes);
return {
running: true,
currentTimer: action.payload.minutes * 60,
words: action.payload.words.map((word: string) => ({ word, used: false })),
stopTime,
}
}
case 'tick': {
let newTimer = state.currentTimer - 1;
return {
running: newTimer === 0 ? false : state.running,
currentTimer: newTimer,
words: state.words,
stopTime: state.stopTime,
}
}
case 'updateWords': {
return {
running: state.running,
currentTimer: state.currentTimer,
words: action.payload,
stopTime: state.stopTime,
}
}
case 'reset': {
return initialState;
}
default:
return initialState;
}
};
当一个start 动作发生时,我们计算stopDate ,设置剩余时间,并从动作的有效载荷中设置单词(我们将在后面讨论数据的获取)。updateWords 动作只是用有效载荷中的新计算值覆盖了状态中的words 条目,而reset 则将我们重置到初始状态。这就是状态处理的内容。
基于这个状态,我们的顶层App 组件是非常简单的。
function App() {
const [state, dispatch] = useReducer(reducer, initialState);
const startWriting = (minutes: number, words: number, lang: String) => {
axios.get(`http://localhost:8080/words/${lang}/${words}`)
.then((response: Response) => {
const words = response.data.words;
dispatch({ type: 'start', payload: {
minutes,
words
}});
})
.catch((error: AxiosError) => {
alert(`error while fetching words: ${error}`);
});
};
const updateText = (event: ChangeEvent<HTMLTextAreaElement>) => {
const text = event.target.value;
dispatch({
type: 'updateWords',
payload: state.words.map((w: WordState) => {
return {
word: w.word,
used: text.includes(w.word),
};
})
});
};
const stopWriting = () => {
dispatch({ type:'reset' });
};
return (
<div className="App">
<div className="Inner">
<Header
startWriting={startWriting}
running={state.running}
stopWriting={stopWriting}
/>
<Words words={state.words} />
<Text updateText={updateText} running={state.running} />
<Timer
timer={state.currentTimer}
running={state.running}
dispatch={dispatch}
stopTime={state.stopTime}
/>
</div>
</div>
);
}
我们确保使用我们定义的reducer ,将我们的状态初始化为initialState 。然后我们定义startWriting 、stopWriting 和updateText 的动作。在startWriting 的动作中,我们用给定的时间、字数和语言从用户界面调用GET 。我们将把这个函数传递给Header 组件,在那里它将被调用,并带有用户给定的值。如果请求成功,我们就用给定的分钟数和来自我们的API的随机字数派遣一个start 动作。
对于stopWriting ,我们只是触发我们的reset 动作,因为在这种情况下,我们想重置到我们的初始状态,而不是其他。updateText 动作将被传递给Text 组件,并在那里调用onChange 。它基本上是通过检查文本区中的文本是否包括每个单词来更新words 的状态。这可以更有效地实现,但我们不期望任何文本或单词的大小在这里会成为一个问题。
然后,我们为我们的顶层组件定义标记。在外部风格的div中,我们从Header 组件开始,包括用于配置应用程序的表单字段,然后是Words ,其中包括单词列表和它们的状态(已使用/未使用)。之后,我们有Text 组件,它基本上只包含一个文本区,用户可以在其中输入给定的变化处理程序,最后我们有Timer 组件,它在底部向用户显示一个计时器,从用户给自己的秒数倒数到零。
让我们按顺序看一下这些组件,从标题开始。
function Header(props: {
startWriting: (minutes: number, words: number, lang: String) => void,
stopWriting: () => void,
running: boolean,
}) {
const [numberOfWords, setNumberOfWords] = useState(5);
const [numberOfMinutes, setNumberOfMinutes] = useState(7);
const [lang, setLang] = useState("de");
const handleNumberOfWordsInput = (event: ChangeEvent<HTMLInputElement>) => {
setNumberOfWords(parseInt(event.target.value || "5"));
};
const handleNumberOfMinutesInput = (event: ChangeEvent<HTMLInputElement>) => {
setNumberOfMinutes(parseInt(event.target.value || "7"));
};
const handleLangChange = (event: ChangeEvent<HTMLInputElement>) => {
setLang(event.target.value);
};
return (
<div className="Header">
<span>
<input onChange={handleNumberOfWordsInput} type="text" placeholder="Number of Words" />
</span>
<span>
<input onChange={handleNumberOfMinutesInput} type="text" placeholder="Number of Minutes" />
</span>
<span>
<input checked={lang == "de" ? true : false} type="radio" name="lang" value="de" id="de" onChange={handleLangChange} />
<span>DE</span>
<input checked={lang == "en" ? true : false} type="radio" name="lang" value="en" id="en" onChange={handleLangChange} />
<span>EN</span>
</span>
<span>
{!props.running ? <button onClick={() => { props.startWriting(numberOfMinutes, numberOfWords, lang)}}>Start</button> : <button onClick={() => { props.stopWriting() }}>Stop</button>}
</span>
</div>
);
}
如上所述,这是用户可以配置他们的写作训练器的地方。我们有Number of Words 和Number of Minutes 的文本字段,以及一个用于设置语言为EN或DE的单选元素。当用户点击Start 按钮时,我们会触发传入的startWriting 函数。字数、分钟和语言的值在组件中使用React的useState 和基本的HTML事件处理程序进行管理。 对于像这样的处理程序的传递和DOM事件的处理,TypeScript是非常棒的。一个方面是,即使在组件之间传递处理程序,如果你忘记传递参数或使用错误的参数(例如在改变东西时),TypeScript会注意到它并显示错误。另外,在使用TypeScript时,自动完成的支持是完全不同的水平,所以即使在这个非常小的应用程序中,我已经注意到在一个有很多人工作的巨大代码库中,切换到TypeScript会有巨大的好处。
总之,当计时器运行时,我们显示一个Start 按钮,否则显示一个Stop 按钮,它们在每种情况下都会触发各自的动作。让我们继续讨论Words 组件。
function Words(props: { words: WordState[] }) {
return (
<div className="Words">
{
props.words.map((word: WordState) => {
return <span key={`${word.word}`} className={ word.used ? "active" : "inactive"}>
{word.word}
</span>
})
}
</div>
);
}
这是一个简单的组件。我们从状态中传入words ,并根据它们是否被使用,将它们映射到具有不同类别(红色或绿色)的span元素上。
下一个组件也很简单,它本质上只是一个Text 的文本区。
function Text(props: {
updateText: (event: ChangeEvent<HTMLTextAreaElement>) => void,
running: boolean,
}) {
return (
<div className="Text">
<textarea disabled={!props.running} placeholder="Start Typing..." onChange={props.updateText} />
</div>
);
}
如上所述,Text 组件只由一个带有变化处理程序的文本区组成。如果我们不在running 状态下,我们也会禁用这个文本区,但这就是这里发生的一切。
最后,让我们看一下Timer 组件。
function Timer(props: { timer: number, running: boolean, stopTime: Date | null, dispatch: Dispatch<Action> }) {
useEffect(() => {
let interval: any;
if (props.running) {
interval = setInterval(() => {
props.dispatch({ type: 'tick' })
}, 1000);
} else {
clearInterval(interval);
}
return () => clearInterval(interval);
});
const now: number = new Date().valueOf();
let diff = props.stopTime ? (props.stopTime.valueOf() - now) / 1000 : 0;
if (diff <= 0) {
diff = 0;
}
return (
<div className="Timer" key={props.timer}>
{Math.round(diff)}
</div>
);
}
在这里我们可以看到useEffect 的使用,它是React生命周期方法的替代品,使我们能够在组件生命的不同阶段触发副作用。
在这个例子中,我们为这个组件设置了一个interval ,如果我们处于running 状态,每隔1000毫秒就会触发一个tick 动作,否则就清除这个间隔。这使我们能够在我们的状态下勾选秒数,并在每次勾选时通过传递剩余的时间重新渲染这个组件。我们还从该组件中返回clearInterval ,这样,每当该组件被销毁时,时间间隔就会被清除。
该组件的其余部分只包括计算剩余时间并将其显示给用户。就这样,我们创建了我们的用户界面,它看起来像这样。
总结
第一次使用TypeScript(之前我确实编辑和审查了一些别人的TS代码),我不得不说它绝对是我的JavaScript朋友和同事们谈论的游戏改变者。特别是在Rust的严格类型系统和编译器中,在像JavaScript这样的语言中拥有如此多的安全性,对于前端开发来说是一股新鲜空气。 总而言之,这是一次伟大的经历,我肯定能看到自己用TypeScript构建一些更有趣的东西。