今天我们将学习如何使用 Next.js 和 Netlify 构建一个网球趣味问答应用。这个技术栈已成为我在许多项目中的首选。它允许快速开发和轻松部署。
事不宜迟,让我们开始吧!
我们正在使用什么
- Next.js
- Netlify
- TypeScript
- Tailwind CSS
为什么选择 Next.js 和 Netlify
您可能会认为这是一个简单的应用程序,可能不需要 React 框架。事实是,Next.js 为我提供了许多开箱即用的功能,让我可以直接开始编写应用程序的主要部分。例如,webpack 配置、getServerSideProps
和 Netlify 自动创建无服务器函数等。
Netlify 还使部署 Next.js git 仓库变得超级简单。稍后我们将详细介绍部署过程。
我们正在构建什么
基本上,我们将构建一个趣味问答游戏,它会随机显示网球运动员的名字,你需要猜出他们来自哪个国家。游戏包含五轮,并持续记录你答对的题目数量。
此应用程序所需的数据是运动员列表及其所属国家。最初,我考虑查询一些实时 API,但后来决定只使用本地 JSON 文件。我从 RapidAPI 获取了一个快照,并将其包含在 起始仓库 中。
最终产品看起来像这样

您可以在 Netlify 上找到最终的 部署版本。
起始仓库介绍
如果您想一起学习,可以克隆此 仓库,然后转到 start
分支
git clone [email protected]:brenelz/tennis-trivia.git
cd tennis-trivia
git checkout start
在此起始仓库中,我已经编写了一些样板代码来启动项目。我使用命令 npx create-next-app tennis-trivia
创建了一个 Next.js 应用程序。然后,我手动将几个 JavaScript 文件更改为 .ts
和 .tsx
。令人惊讶的是,Next.js 自动识别了我想要使用 TypeScript。这太容易了!我还使用 这篇文章作为指南 配置了 Tailwind CSS。
少说多做,让我们开始编码吧!
初始设置
第一步是设置环境变量。对于本地开发,我们使用 .env.local
文件来实现。您可以从 起始仓库 中复制 .env.sample
文件。
cp .env.sample .env.local
请注意,它当前只有一个值,即应用程序的路径。我们将在应用程序的前端使用它,因此必须在前面加上 NEXT_PUBLIC_
。
最后,让我们使用以下命令安装依赖项并启动开发服务器:
npm install
npm run dev
现在我们可以在 https://127.0.0.1:3000
访问我们的应用程序。我们应该看到一个相当空的页面,只有一个标题

创建 UI 标记
在 pages/index.tsx
中,让我们将以下标记添加到现有的 Home()
函数中
export default function Home() {
return (
<div className="bg-blue-500">
<div className="max-w-2xl mx-auto text-center py-16 px-4 sm:py-20 sm:px-6 lg:px-8">
<h2 className="text-3xl font-extrabold text-white sm:text-4xl">
<span className="block">Tennis Trivia - Next.js Netlify</span>
</h2>
<div>
<p className="mt-4 text-lg leading-6 text-blue-200">
What country is the following tennis player from?
</p>
<h2 className="text-lg font-extrabold text-white my-5">
Roger Federer
</h2>
<form>
<input
list="countries"
type="text"
className="p-2 outline-none"
placeholder="Choose Country"
/>
<datalist id="countries">
<option>Switzerland</option>
</datalist>
<p>
<button
className="mt-8 w-full inline-flex items-center justify-center px-5 py-3 border border-transparent text-base font-medium rounded-md text-blue-600 bg-white hover:bg-blue-50 sm:w-auto"
type="submit"
>
Guess
</button>
</p>
</form>
<p className="mt-4 text-lg leading-6 text-white">
<strong>Current score:</strong> 0
</p>
</div>
</div>
</div>
);
这构成了我们 UI 的框架。如您所见,我们使用了许多来自 Tailwind CSS 的实用程序类来使界面更美观。我们还有一个简单的自动完成输入框和一个提交按钮。在这里,您可以选择您认为运动员来自的国家,然后点击按钮。最后,底部有一个分数,根据正确或错误的答案进行更改。
设置我们的数据
如果您查看 data
文件夹,应该有一个 tennisPlayers.json
文件,其中包含我们在此应用程序中需要的所有数据。在根目录下创建一个 lib
文件夹,并在其中创建一个 players.ts
文件。请记住,.ts
扩展名是必需的,因为它是一个 TypeScript 文件。让我们定义一个与我们的 JSON 数据匹配的类型。
export type Player = {
id: number,
first_name: string,
last_name: string,
full_name: string,
country: string,
ranking: number,
movement: string,
ranking_points: number,
};
这就是我们在 TypeScript 中创建类型的方式。我们在左侧有属性的名称,在右侧有它的类型。它们可以是 基本类型,甚至可以是其他类型本身。
从这里,让我们创建表示我们数据的特定变量
export const playerData: Player[] = require("../data/tennisPlayers.json");
export const top100Players = playerData.slice(0, 100);
const allCountries = playerData.map((player) => player.country).sort();
export const uniqueCountries = [...Array.from(new Set(allCountries))];
需要注意的两点是,我们声明我们的 playerData
是一个 Player
类型的数组。这由冒号后跟类型表示。事实上,如果我们将鼠标悬停在 playerData
上,我们可以看到它的类型

在最后一行,我们获取了国家/地区的唯一列表,以便在我们的国家/地区下拉列表中使用。我们将我们的国家/地区传递给 JavaScript Set,它会去除重复的值。然后我们从中创建一个数组,并将其扩展到一个新数组中。它看起来可能没有必要,但这这样做是为了让 TypeScript 满意。
信不信由你,这确实是我们在应用程序中需要的所有数据!
让我们使我们的 UI 变得动态!
我们所有的值目前都是硬编码的,但让我们改变这一点。动态部分是网球运动员的姓名、国家/地区的列表和分数。
回到 pages/index.tsx
,让我们修改 getServerSideProps
函数,以创建一个包含五个随机运动员的列表,并提取我们的 uniqueCountries
变量。
import { Player, uniqueCountries, top100Players } from "../lib/players";
...
export async function getServerSideProps() {
const randomizedPlayers = top100Players.sort((a, b) => 0.5 - Math.random());
const players = randomizedPlayers.slice(0, 5);
return {
props: {
players,
countries: uniqueCountries,
},
};
}
我们返回的 props
对象中的任何内容都将传递给我们的 React 组件。让我们在页面上使用它们如您所见,我们为页面组件定义了另一种类型。然后我们将 HomeProps
类型添加到 Home()
函数中。我们再次指定 players
是 Player
类型的数组。
type HomeProps = {
players: Player[];
countries: string[];
};
export default function Home({ players, countries }: HomeProps) {
const player = players[0];
...
}
现在我们可以在 UI 的更下方使用这些 props。将“Roger Federer”替换为 {player.full_name}
(顺便说一下,他是我的最喜欢的网球运动员)。由于我们定义的类型,您应该在 player 变量上获得良好的自动完成,因为它列出了我们可以访问的所有属性名称。
在此下方,让我们现在将国家/地区的列表更新为以下内容

现在我们已经完成了三个动态部分中的两个,我们需要解决分数。具体来说,我们需要为当前分数创建一个状态。
<datalist id="countries">
{countries.map((country, i) => (
<option key={i}>{country}</option>
))}
</datalist>
完成后,在我们的 UI 中将 0 替换为 {score}
。
export default function Home({ players, countries }: HomeProps) {
const [score, setScore] = useState(0);
...
}
您现在可以通过转到 https://127.0.0.1:3000
检查我们的进度。您可以看到,每次页面刷新时,我们都会得到一个新的姓名;当在输入字段中键入时,它会列出所有可用的唯一国家/地区。
我们已经取得了不错的进展,但我们需要添加一些交互性。
添加一些交互性
我们已经取得了不错的进展,但我们需要添加一些交互性。
连接猜谜按钮
为此,我们需要一种方法来知道选择了哪个国家。我们通过添加更多状态并将其附加到我们的输入字段来实现。
export default function Home({ players, countries }: HomeProps) {
const [score, setScore] = useState(0);
const [pickedCountry, setPickedCountry] = useState("");
...
return (
...
<input
list="countries"
type="text"
value={pickedCountry}
onChange={(e) => setPickedCountry(e.target.value)}
className="p-2 outline-none"
placeholder="Choose Country"
/>
...
);
}
接下来,让我们添加一个 guessCountry
函数并将其附加到表单提交
const guessCountry = () => {
if (player.country.toLowerCase() === pickedCountry.toLowerCase()) {
setScore(score + 1);
} else {
alert(‘incorrect’);
}
};
...
<form
onSubmit={(e) => {
e.preventDefault();
guessCountry();
}}
>
我们所做的只是将当前玩家的国家与猜测的国家进行比较。现在,当我们返回应用程序并正确猜测国家时,分数会按预期增加。
添加状态指示器
为了使它更美观,我们可以根据猜测是否正确来渲染一些 UI。
因此,让我们为状态创建另一部分状态,并更新猜谜国家方法
const [status, setStatus] = useState(null);
...
const guessCountry = () => {
if (player.country.toLowerCase() === pickedCountry.toLowerCase()) {
setStatus({ status: "correct", country: player.country });
setScore(score + 1);
} else {
setStatus({ status: "incorrect", country: player.country });
}
};
然后在玩家姓名下方渲染此 UI
{status && (
<div className="mt-4 text-lg leading-6 text-white">
<p>
You are {status.status}. It is {status.country}
</p>
<p>
<button
autoFocus
className="outline-none mt-8 w-full inline-flex items-center justify-center px-5 py-3 border border-transparent text-base font-medium rounded-md text-blue-600 bg-white hover:bg-blue-50 sm:w-auto"
>
Next Player
</button>
</p>
</div>
)}
最后,我们希望确保在正确或错误状态下我们的输入字段不会显示。我们通过用以下内容包装表单来实现此目的
{!status && (
<form>
...
</form>
)}
现在,如果我们返回应用程序并猜测玩家的国家,我们会收到一条带有猜测结果的友好消息。
遍历玩家
现在可能要解决最具挑战性的部分:**我们如何从一个玩家转到下一个玩家?**
首先我们需要在状态中存储 currentStep
,以便我们可以用 0 到 4 之间的数字更新它。然后,当它达到 5 时,我们希望显示已完成状态,因为琐事游戏结束了。
再次,让我们添加以下状态变量
const [currentStep, setCurrentStep] = useState(0);
const [playersData, setPlayersData] = useState(players);
…然后用以下内容替换我们之前的 player 变量
const player = playersData[currentStep];
接下来,我们创建一个 nextStep
函数并将其连接到 UI
const nextStep = () => {
setPickedCountry("");
setCurrentStep(currentStep + 1);
setStatus(null);
};
...
<button
autoFocus
onClick={nextStep}
className="outline-none mt-8 w-full inline-flex items-center justify-center px-5 py-3 border border-transparent text-base font-medium rounded-md text-blue-600 bg-white hover:bg-blue-50 sm:w-auto"
>
Next Player
</button>
现在,当我们进行猜测并点击下一步按钮时,我们将转到新的网球运动员。再次猜测,我们会看到下一个,依此类推。
当我们在最后一个玩家上点击下一步时会发生什么?现在,我们得到一个错误。让我们通过添加一个表示游戏已完成的条件来修复它。当玩家变量未定义时,就会发生这种情况。
{player ? (
<div>
<p className="mt-4 text-lg leading-6 text-blue-200">
What country is the following tennis player from?
</p>
...
<p className="mt-4 text-lg leading-6 text-white">
<strong>Current score:</strong> {score}
</p>
</div>
) : (
<div>
<button
autoFocus
className="outline-none mt-8 w-full inline-flex items-center justify-center px-5 py-3 border border-transparent text-base font-medium rounded-md text-indigo-600 bg-white hover:bg-indigo-50 sm:w-auto"
>
Play Again
</button>
</div>
)}
现在,我们在游戏结束时看到一个友好的已完成状态。
重新开始按钮
我们快完成了!对于我们的“重新开始”按钮,我们希望重置游戏的所有状态。我们还希望从服务器获取新的玩家列表,而无需刷新。我们这样做
const playAgain = async () => {
setPickedCountry("");
setPlayersData([]);
const response = await fetch(
process.env.NEXT_PUBLIC_API_URL + "/api/newGame"
);
const data = await response.json();
setPlayersData(data.players);
setCurrentStep(0);
setScore(0);
};
<button
autoFocus
onClick={playAgain}
className="outline-none mt-8 w-full inline-flex items-center justify-center px-5 py-3 border border-transparent text-base font-medium rounded-md text-indigo-600 bg-white hover:bg-indigo-50 sm:w-auto"
>
Play Again
</button>
请注意,我们正在使用之前通过 process.env
对象设置的环境变量。我们还通过用我们刚刚检索到的客户端状态覆盖服务器状态来更新我们的 playersData
。
我们还没有填写我们的 newGame
路由,但这对于 Next.js 和 Netlify 无服务器函数来说很容易。我们只需要编辑 pages/api/newGame.ts
中的文件。
import { NextApiRequest, NextApiResponse } from "next"
import { top100Players } from "../../lib/players";
export default (req: NextApiRequest, res: NextApiResponse) => {
const randomizedPlayers = top100Players.sort((a, b) => 0.5 - Math.random());
const top5Players = randomizedPlayers.slice(0, 5);
res.status(200).json({players: top5Players});
}
这看起来与我们的 getServerSideProps
非常相似,因为我们可以重用我们友好的辅助变量。
如果我们返回应用程序,请注意“重新开始”按钮按预期工作。
改进焦点状态
我们可以做的最后一件事来改善用户体验是在每次步骤更改时将焦点设置在国家输入字段上。这只是一个不错的细节,方便用户使用。我们使用 ref
和 useEffect
来实现。
const inputRef = useRef(null);
...
useEffect(() => {
inputRef?.current?.focus();
}, [currentStep]);
<input
list="countries"
type="text"
value={pickedCountry}
onChange={(e) => setPickedCountry(e.target.value)}
ref={inputRef}
className="p-2 outline-none"
placeholder="Choose Country"
/>
现在,我们只需使用 Enter 键并键入国家即可更轻松地导航。
部署到 Netlify
您可能想知道我们如何部署这个东西。好吧,使用 Netlify 使其变得非常简单,因为它可以开箱即用地检测 Next.js 应用程序并自动配置它。
我所做的只是设置了一个 GitHub 仓库 并将我的 GitHub 帐户连接到我的 Netlify 帐户。从那里,我只需选择一个要部署的仓库并使用所有默认设置。

需要注意的一点是,您必须添加 NEXT_PUBLIC_API_URL
环境变量并重新部署才能使其生效。

您可以在此处找到我最终的 部署版本。
另请注意,您只需点击 GitHub 仓库 上的“部署到 Netlify”按钮即可。
结论
哇哦,你做到了!这是一段旅程,我希望你在旅途中学到了一些关于 React、Next.js 和 Netlify 的知识。
我计划在不久的将来扩展这个网球知识问答应用以使用 Supabase ,敬请期待!
如果您有任何问题/意见,请随时通过 Twitter 联系我。
不错的文章!
我只是出于好奇有一个问题,这是打字错误还是为了覆盖某些边缘情况而使用的技巧?
const uniqueCountries = [...Array.from(new Set(allCountries))];
而不是
const uniqueCountries = Array.from(new Set(allCountries));
可能是对
[...new Set(allCountries)]
的无意过度复杂化!