DrillLab
第 08 / 08 节LESSON 08 / 08约 55 分钟~55 min

从零重写:空文件夹里做出来Rewrite it: build the whole app in an empty folder

这一节没有新知识。只有一个要求:不看答案,把整个应用写出来。There is nothing new to learn here. There is one requirement: write the whole app without looking at the answer.

1 个练习1 exercisesCab Booking · 第 3 部分Cab Booking · Part 3
这一页有什么On this page4
学完这节你会After this lesson you can
  • 在空文件夹里搭出 Vite + React + Vitest 的测试环境Set up a Vite, React and Vitest test environment in an empty folder
  • 凭四个测试的要求写出 Context 和六个组件Write the Context and the six components from what the four tests ask for
  • 自己发现并修掉 .js / .jsx 那个坑Find and fix the .js and .jsx problem yourself
  • 跑到 4 passed / 4 totalReach 4 passed / 4 total
这在考试里考什么What the exam does with this

真实考试就是这样:一个仓库、一份 README、一套测试,没有答案。前面三个部分你都是「跟着看」,这一节是「自己做」。做不出来不代表白学了 —— 卡在哪一步,那一步就是你真正的薄弱点。A real exam looks exactly like this: one repository, one README, one set of tests, and no answer. In the first three parts you were reading along. In this lesson you do it yourself. Not finishing does not mean the earlier work was wasted. Wherever you get stuck is your real weak point.

这节课要看的真实文件Real files this lesson looks at2 项 · 2 个可以展开看原文2 items · 2 can be opened
cab-booking-context/src/test/App.test.jsx唯一允许看的东西:四个测试The only thing you may look at: the four tests
JSXApp.test.jsx源项目From source
1import { act, fireEvent, render, screen } from "@testing-library/react";
2import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3import App from "../App";
4import { CabProvider } from "../context/CabContext";
5
6const renderApp = () =>
7 render(
8 <CabProvider>
9 <App />
10 </CabProvider>,
11 );
12
13describe("React: Cab Booking", () => {
14 beforeEach(() => {
15 vi.useFakeTimers();
16 });
17
18 afterEach(() => {
19 vi.runOnlyPendingTimers();
20 vi.useRealTimers();
21 });
22
23 it("renders the home page and empty ride history", () => {
24 renderApp();
25
26 expect(
27 screen.getByText("Book a Safe Ride with HackerRide"),
28 ).toBeInTheDocument();
29 expect(screen.getByTestId("book-button")).toBeInTheDocument();
30 expect(screen.getByTestId("no-ride-title")).toHaveTextContent(
31 "No ride history yet.",
32 );
33 });
34
35 it("shows grouped cab options with all required card fields", () => {
36 renderApp();
37
38 fireEvent.click(screen.getByTestId("book-button"));
39
40 expect(screen.getByTestId("all-cabs-section")).toBeInTheDocument();
41 expect(screen.getAllByTestId("car-type-heading").map((node) => node.textContent))
42 .toEqual(["Sedan", "SUV", "Luxury"]);
43 expect(screen.getAllByTestId("cab-card-img")).toHaveLength(6);
44 expect(screen.getAllByTestId("cab-card-name")).toHaveLength(6);
45 expect(screen.getAllByTestId("cab-card-type")).toHaveLength(6);
46 expect(screen.getAllByTestId("cab-card-price")).toHaveLength(6);
47 expect(screen.getAllByTestId("cab-card-select-button")).toHaveLength(6);
48 });
49
50 it("completes a booking and adds it to ride history", () => {
51 renderApp();
52
53 fireEvent.click(screen.getByTestId("book-button"));
54 fireEvent.click(screen.getAllByTestId("cab-card-select-button")[0]);
55
56 expect(screen.getByTestId("loading")).toBeInTheDocument();
57
58 act(() => {
59 vi.advanceTimersByTime(1000);
60 });
61
62 expect(screen.getByTestId("confirm-message")).toHaveTextContent(
63 "Ford Fusion is on the way and will arrive shortly.",
64 );
65
66 fireEvent.click(screen.getByTestId("confirm-button"));
67
68 expect(screen.getByTestId("history-cabs")).toHaveTextContent(
69 "Ford Fusion",
70 );
71 expect(screen.getByTestId("history-cabs")).toHaveTextContent("$20");
72 });
73
74 it("keeps only the newest three rides", () => {
75 renderApp();
76
77 const selectCabByIndex = (index) => {
78 fireEvent.click(screen.getByTestId("book-button"));
79 fireEvent.click(screen.getAllByTestId("cab-card-select-button")[index]);
80 act(() => {
81 vi.advanceTimersByTime(1000);
82 });
83 fireEvent.click(screen.getByTestId("confirm-button"));
84 };
85
86 selectCabByIndex(0);
87 selectCabByIndex(1);
88 selectCabByIndex(2);
89 selectCabByIndex(3);
90
91 const rides = screen.getAllByTestId("history-cabs");
92 expect(rides).toHaveLength(3);
93 expect(rides[0]).toHaveTextContent("Ford Explorer");
94 expect(rides[1]).toHaveTextContent("Toyota Highlander");
95 expect(rides[2]).toHaveTextContent("Honda Accord");
96 expect(screen.queryByText(/Ford Fusion/)).not.toBeInTheDocument();
97 });
98});
Source: cab-booking-context/src/test/App.test.jsx
cab-booking-context/src/data/data.json数据可以照抄,那不是考点The data can be copied as it is; it is not what is being tested
JSONdata.json源项目From source
1{
2 "Sedan": [
3 {
4 "id": "sedan-1",
5 "name": "Ford Fusion",
6 "type": "Sedan",
7 "price": 20,
8 "image": "/cabs/ford-fusion.svg"
9 },
10 {
11 "id": "sedan-2",
12 "name": "Honda Accord",
13 "type": "Sedan",
14 "price": 24,
15 "image": "/cabs/honda-accord.svg"
16 }
17 ],
18 "SUV": [
19 {
20 "id": "suv-1",
21 "name": "Toyota Highlander",
22 "type": "SUV",
23 "price": 32,
24 "image": "/cabs/toyota-highlander.svg"
25 },
26 {
27 "id": "suv-2",
28 "name": "Ford Explorer",
29 "type": "SUV",
30 "price": 36,
31 "image": "/cabs/ford-explorer.svg"
32 }
33 ],
34 "Luxury": [
35 {
36 "id": "luxury-1",
37 "name": "Mercedes E-Class",
38 "type": "Luxury",
39 "price": 55,
40 "image": "/cabs/mercedes-e-class.svg"
41 },
42 {
43 "id": "luxury-2",
44 "name": "BMW 5 Series",
45 "type": "Luxury",
46 "price": 60,
47 "image": "/cabs/bmw-5-series.svg"
48 }
49 ]
50}
Source: cab-booking-context/src/data/data.json
§01

按什么顺序写What order to write it in

让测试一条一条变绿,而不是全写完再跑Make the tests pass one at a time, instead of writing everything and running them at the end

一句话:按测试的顺序写 —— 测试 1 绿了再写测试 2 需要的东西。

为什么不要「全写完再跑」:六个组件加一个 Context 一次写完, 第一次跑出来四条全红, 你不知道是哪一层的问题。 而一次只让一条变绿,红的原因永远只有一个

推荐顺序(每一步都跑一次npx vitest run):

  1. 搭环境 + 跑空测试。npm create vite@latest、装vitest / jsdom / @testing-library/react / @testing-library/jest-dom, 配好 vite.config.mjstest 段和 setup 文件。把测试文件放进去,先确认它「能跑起来并且全红」 —— 这一步就已经比源项目的基线好了。
  2. Context + Provider。三件套写完,index.jsx 里包住<App />文件名直接叫 .jsx —— 你已经知道那个坑了。
  3. Home + RideHistory → 测试 1 变绿。空历史的分支先写,列表分支可以先留空。
  4. CabOptions + CabCard → 测试 2 变绿。data.json 照抄, 六张卡的五个 testid 对照表检查一遍。
  5. App 的状态机 + Loading + CabConfirmation → 测试 3 变绿。这一步最长,因为要串起四个页面。
  6. RideHistory 的slice(-3).reverse() → 测试 4 变绿。前面留空的列表分支现在补上。

卡住的时候:提示分四级,先自己想 15 分钟再看第一级。 看提示不丢人,但一上来就看提示,这一节就白做了 —— 你练的不是「照着提示写代码」, 是「没有提示时自己找路」。

In one line: write in the order of the tests — get test 1 green, then build what test 2 needs.

Why not “write it all, then run”: write six components and a Context in one go and the first run gives you four red tests with no idea which layer is at fault. Turning them green one at a time means there is only ever one reason for the red.

A good order (run npx vitest run after every step):

  1. Set up the project and run the empty test. npm create vite@latest, install vitest / jsdom / @testing-library/react / @testing-library/jest-dom, then configure the test section of vite.config.mjs and the setup file. Drop in the test file and confirm it runs and is all red — that alone already beats the source project’s baseline.
  2. Context and Provider. Write the three parts and wrap <App /> in index.jsx. Name the file .jsx from the start — you know about that trap now.
  3. Home + RideHistory, until test 1 is green. Write the empty-history branch first; the list branch can stay empty for now.
  4. CabOptions + CabCard, until test 2 is green. Copy data.json verbatim and check the five testids on each of the six cards against the table.
  5. App’s state machine + Loading + CabConfirmation, until test 3 is green. This is the longest step, because it strings four pages together.
  6. RideHistory’s slice(-3).reverse(), until test 4 is green. Fill in the list branch you left empty earlier.

When you get stuck: the hints come in four levels — think for 15 minutes before opening the first one. There is no shame in reading a hint, but opening one immediately wastes the whole exercise: what you are training is not “write code from a hint”, it is “find the way with no hint at all”.

练习Practice

动手做Get your hands on it

填空只是过渡。真正掌握的标准,是在没有答案的时候从头写出来 —— 所以做完 L2 之后一定要往 L3、L4 走。Filling blanks is a stepping stone. The real bar is writing it from nothing, so once L2 is comfortable, push on to L3 and L4.

L4从零重写Rebuild from scratch空文件夹里做出整个 Cab BookingBuild the whole of Cab Booking from an empty folder
只给需求和文件清单。不给任何代码。卡住了按四级提示走,答案在最后一道门后面。You get the requirements and the file list only. No code at all. If you get stuck, work through the four levels of hints. The answer sits behind the last door.
需求(不给代码,自己实现)Requirements — no code given, implement it yourself
  • 首页:一个大标题「Book a Safe Ride with HackerRide」、一个 data-testid="book-button" 的按钮,下面是行程历史区Home page: a big heading "Book a Safe Ride with HackerRide", a button with data-testid="book-button", and the ride history area below it
  • 行程历史:没有记录时显示 <p data-testid="no-ride-title">No ride history yet.</p>;有记录时每条一个 <li data-testid="history-cabs">,显示车名和 $价格Ride history: with no records show <p data-testid="no-ride-title">No ride history yet.</p>; with records show one <li data-testid="history-cabs"> per entry, holding the cab name and the $price
  • 行程历史只显示最新三条,最新的排最上面The ride history shows the three newest entries only, newest at the top
  • 点 book-button 进入选车页:容器 data-testid="all-cabs-section",按类型分三组,每组一个 <h3 data-testid="car-type-heading">,顺序必须是 Sedan / SUV / LuxuryPressing book-button opens the cab page: a container with data-testid="all-cabs-section", three groups by type, each with one <h3 data-testid="car-type-heading">, and the order has to be Sedan / SUV / Luxury
  • 每辆车一张卡,五个 testid:cab-card-img / cab-card-name / cab-card-type / cab-card-price / cab-card-select-button。类型显示 "Type: X",价格显示 "Fare: $N"One card per cab, with five testids: cab-card-img / cab-card-name / cab-card-type / cab-card-price / cab-card-select-button. The type reads "Type: X" and the price reads "Fare: $N"
  • 点某张卡的 Select:把这辆车记为当前预订、追加进历史,然后进入加载页 data-testid="loading"Pressing Select on a card: record that cab as the current booking, append it to the history, then go to the loading page with data-testid="loading"
  • 加载页 1000ms 后自动进入确认页;确认页 data-testid="confirm-message" 显示「<车名> is on the way and will arrive shortly.」The loading page moves to the confirmation page after 1000ms; the confirmation page shows data-testid="confirm-message" reading "<cab name> is on the way and will arrive shortly."
  • 确认页有 data-testid="confirm-button",点了回首页,此时历史里能看到刚才那辆车The confirmation page has data-testid="confirm-button"; pressing it returns to the home page, where the history now shows that cab
  • 状态必须放在 Context 里:createContext + Provider + 自定义 hook(hook 里带「不在 Provider 内就抛错」的守卫),Provider 包在 App 外面The state has to live in a Context: createContext + Provider + a custom hook (the hook carries a guard that throws when it is used outside the Provider), and the Provider wraps App
  • 数据用 data.json:三个类型各两辆车,Sedan 第一辆是 Ford Fusion / $20,SUV 两辆是 Toyota Highlander / Ford Explorer,Sedan 第二辆是 Honda AccordThe data comes from data.json: two cabs per type, the first Sedan is Ford Fusion / $20, the two SUVs are Toyota Highlander / Ford Explorer, and the second Sedan is Honda Accord
你需要自己建的文件Files you create yourself
文件清单File list
package.jsonvite + react + vitest + jsdom + @testing-library/react + @testing-library/jest-dom
vite.config.mjsplugins: [react()],test 段配 environment: "jsdom" / globals / setupFilesplugins: [react()], and a test section with environment: "jsdom" / globals / setupFiles
index.html一个 <div id="root">one <div id="root">
src/index.jsxcreateRoot,用 <CabProvider> 包住 <App />createRoot, with <CabProvider> wrapping <App />
src/App.jsxcurrentPage 状态机 + handleSelectCabthe currentPage state machine + handleSelectCab
src/context/CabContext.jsx三件套。注意扩展名 —— 里面有 JSXthe three parts. Watch the extension — this file holds JSX
src/components/AppHeader.jsx只显示标题,没有 testidshows the title only, no testid
src/components/Home/Home.jsxhero + book-button + <RideHistory />
src/components/Home/RideHistory.jsx空状态 / 最新三条倒序the empty state / the three newest in reverse
src/components/CabOptions/CabOptions.jsxObject.keys 分组grouping with Object.keys
src/components/CabOptions/CabCard.jsx五个 testidthe five testids
src/components/Loading/Loading.jsxsetTimeout 1000 + clearTimeout
src/components/CabConfirmation/CabConfirmation.jsx?.name + confirm-button
src/data/data.json三组六辆车,键顺序 Sedan → SUV → Luxurysix cabs in three groups, with the key order Sedan → SUV → Luxury
src/test/setup.jsimport "@testing-library/jest-dom"
src/test/App.test.jsx把源项目那四个测试原样放进来 —— 这是你的判分依据copy the source project's four tests in unchanged — this is what grades you
写完后在本机这样验证Verify it locally like this
npm install
装完无报错。React 18/19 都可以,测试用的 API 没差别It finishes with no errors. React 18 or 19 both work; the APIs the tests use are the same
npx vitest run
刚放进测试文件时应该是 4 failed / 4 total,报错都是 Unable to find an element by: [data-testid=...]。全部做完是 Test Files 1 passed / Tests 4 passed (4)Right after you drop the test file in it should be 4 failed / 4 total, all reporting Unable to find an element by: [data-testid=...]. When everything is done it is Test Files 1 passed / Tests 4 passed (4)
npx vitest run 2>&1 | grep -c 'no tests'
0。如果不是 0,说明你也踩了 .js 里写 JSX 那个坑 —— 把带 JSX 的文件改名成 .jsx0. Anything else means you hit the JSX-in-a-.js-file problem too — rename the files that hold JSX to .jsx
npm run dev
浏览器里手动走一遍:首页 → 选车 → 加载 1 秒 → 确认 → 回首页看到历史。连订四辆,历史应该只有三条且最新在最上Walk through it by hand in the browser: home page → pick a cab → 1 second of loading → confirm → back to the home page with the history there. Book four in a row and the history should hold three, newest at the top
提示Hints共 4 级,已看 0 级4 levels, 0 opened
先自己想两分钟。想不出来再点右上角 —— 提示是一级一级放的,不会一次给完。Think for two minutes first. Then use the button above — hints come one level at a time, never all at once.

这一关的意义就在于「没有答案也能写出来」。请确认你已经在本机建好文件、跑过验证命令,再打开参考答案对照。The whole point of this level is writing it with no answer in front of you. Create the files on your machine and run the verification commands first, then come back and compare.

Tick this once you have written it locally and the verification commands give the expected output.
错例Wrong

初学者常见的几种写法错误Mistakes beginners actually make

下面每一段都是「能编译、但结果不对」或者「一跑就炸」的真实写法。先自己看出问题在哪,再看解释。Every snippet below either compiles and gives the wrong answer, or blows up on the first run. Spot the problem yourself before reading the explanation.

Text一次写完的下场(示意)Where writing it all at once leads (illustration)示意Illustrative
1# ✕ 先把六个组件全写完,最后才跑测试
2
3$ npx vitest run
4 ✗ renders the home page and empty ride history
5 ✗ shows grouped cab options with all required card fields
6 ✗ completes a booking and adds it to ride history
7 ✗ keeps only the newest three rides
8
9 Test Files 1 failed (1)
10 Tests 4 failed (4)
11
12# 现在怎么办?四条全红,不知道从哪查。
13# Provider 层级错了?testid 拼错了?状态机没接上?
14# 三种原因都会导致这个输出。
1# ✕ writing all six components first, and only then running the tests
2
3$ npx vitest run
4 ✗ renders the home page and empty ride history
5 ✗ shows grouped cab options with all required card fields
6 ✗ completes a booking and adds it to ride history
7 ✗ keeps only the newest three rides
8
9 Test Files 1 failed (1)
10 Tests 4 failed (4)
11
12# now what? All four are red and there is nowhere to start.
13# Wrong Provider level? Misspelled testid? State machine not connected?
14# All three causes produce this same output.
四条全红提供的信息量几乎为零。Provider 放错层级、testid 拼错、状态机没接上 —— 三种完全不同的原因会给出同一个输出
而按测试顺序推进的话,每一步只有一个变量: 刚写完 RideHistory、测试 1 还是红, 那问题只可能在你刚写的那几行或 Provider 层级里。
这不是「教学建议」,是真实考试里的时间管理。考场有计时,调试时间比写代码时间更容易失控
Four failing tests carry almost no information. A Provider on the wrong level, a misspelled testid, and a state machine that is not connected are three completely different causes that produce the same output.
If you work through the tests in order, each step has only one variable: you have just written RideHistory and test 1 is still failing, so the problem can only be in the lines you just wrote or in the level of the Provider.
This is not a teaching suggestion, it is time management in a real exam. The exam is timed, and debugging time gets out of control more easily than writing time.
迁移Transfer

换一道题也能用Works on other problems too

考试不会原题重考。真正能带走的是「看到这种信号 → 伸手去拿这个解法」。The exam will not reuse the same question. What you take away is the reflex: see this signal, reach for that solution.

拿到一个只给测试的项目You get a project that gives you only tests
先跑基线记下来,再按测试顺序一条一条变绿Run the baseline and write it down, then make the tests pass one by one in order
一次改动之后好几条测试同时红Several tests fail together after a single change
回退到只改一处,把变量降到一个Go back to changing one thing at a time, so there is only one variable
「我知道怎么做但写不出来」"I know how to do it but I cannot write it"
那就是这一档要练的东西 —— 卡住的地方才是薄弱点That is exactly what this level trains; the place you get stuck is the weak point
本机装不了 NodeYou cannot install Node on your own machine
StackBlitz:WebContainers 能真跑 npm install 和 npm testStackBlitz: WebContainers really do run npm install and npm test
这节的要点What to take away
  1. 按测试顺序写:测试 1 绿了再写测试 2 需要的东西,每步只有一个变量。Write in test order: make test 1 pass, then write what test 2 needs, so every step has only one variable.
  2. 第一步是「让测试能跑起来并且全红」—— 这就已经好过源项目的基线了。The first step is to get the tests running and all failing. That alone is better than the baseline of the source project.
  3. 带 JSX 的文件从一开始就叫 .jsx,别重复那个坑。Give every file that contains JSX the .jsx extension from the start, so the same problem does not come back.
  4. 两个最容易错的点:slice(-3).reverse() 的顺序、确认页的 ?.name。The two easiest things to get wrong: the order of slice(-3).reverse(), and ?.name on the confirmation page.
  5. 提示分四级,先自己想 15 分钟 —— 你练的是没提示时自己找路。The hints come in four levels. Think for 15 minutes on your own first, because what you are practising is finding the way without hints.

接下来What next

  1. 把这一节的练习做掉Do this lesson’s exercises1 个,就在这一页上面 —— 别攒着最后一起做1 of them, further up this page — do not save them for later
    回到练习 ↑Back up to them ↑
  2. 这一门读完了 —— 去验收Course finished — go get checked考场:空文件夹、计时、没有提示按钮The arena: an empty folder, a clock, no hint button
    去考场To the arena
读完并且做过上面的练习了吗?Read it and worked through the exercises above?
上一节:Previous: 完整答案跑不起来 —— 一个扩展名的事The complete answer does not run — the cause is one file extension