使用TypeScript建立一个简单的Express后端API

119 阅读7分钟

我们将使用TypeScript建立一个简单的Express后端API,它将为我们提供一个从前端请求的随机单词列表。

此外,我们将为这个应用程序建立一个用户界面,在那里我们可以配置我们想使用的单词量,我们想在几分钟内写完的时间长度,以及德语和英语之间的语言切换器。此外,还将有一个可供书写的文本字段、一个计时器和一个指示器,显示我们在文本中已经使用了哪些单词。

太棒了,我们开始吧!

服务器的实现

让我们从服务器开始,因为它非常简单。我们将以Express为基础,只创建两个端点。

  • GET /words/de/$number - returns $number German words
  • GET /words/en/$number - 返回 $number English words

我们将使用的唯一依赖性是typescript,expresscors

我们将把上面提到的包含德语和英语单词的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.readFileSyncJSON.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 - 在用户打字时,更新一个词的 。WordState
  • tick - 每秒钟发生一次,当计时器运行时,倒数剩余时间并重新渲染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 。然后我们定义startWritingstopWritingupdateText 的动作。在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 WordsNumber 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 ,这样,每当该组件被销毁时,时间间隔就会被清除。

该组件的其余部分只包括计算剩余时间并将其显示给用户。就这样,我们创建了我们的用户界面,它看起来像这样。

image.png

总结

第一次使用TypeScript(之前我确实编辑和审查了一些别人的TS代码),我不得不说它绝对是我的JavaScript朋友和同事们谈论的游戏改变者。特别是在Rust的严格类型系统和编译器中,在像JavaScript这样的语言中拥有如此多的安全性,对于前端开发来说是一股新鲜空气。 总而言之,这是一次伟大的经历,我肯定能看到自己用TypeScript构建一些更有趣的东西。