DrillLab
第 04 / 08 节LESSON 04 / 08约 13 分钟~13 min

按类型分组渲染六张卡Rendering the six cards grouped by type

两层 map:外层 Object.keys 出三个类型,内层出每组的车。key 有个坑。Two nested maps: Object.keys gives the three types on the outside, the cabs of each group on the inside. The key needs care.

2 个练习2 exercisesCab Booking · 第 2 部分Cab Booking · Part 2
这一页有什么On this page5
学完这节你会After this lesson you can
  • 用 Object.keys + 两层 map 把分组数据渲染出来Render grouped data with Object.keys and two nested maps
  • 说清为什么分组顺序不用自己排Explain why you do not have to sort the group order yourself
  • 写出 CabCard 的五个 data-testidWrite the five data-testid values of CabCard
  • 知道为什么 ride.id 单独做 key 在历史列表里不安全Know why ride.id on its own is not a safe key in the history list
这在考试里考什么What the exam does with this

测试 2 一次查九个断言:一个容器、三个分组标题(有序)、五种卡片字段各 6 个。这一节把这九个断言一次性满足。分组顺序是送分题 —— 老实用 Object.keys 就对了,自己排序反而会错。Test 2 checks nine things at once: one container, three group headings in order, and 6 of each of the five card fields. This lesson satisfies all nine together. The group order is a free point: use Object.keys as it comes and you are right, while sorting it yourself makes it wrong.

这节课要看的真实文件Real files this lesson looks at高亮行 = 需要你动手改的文件Highlighted rows are the files you edit
cab-booking-context/src/data/data.json三组六辆车,键顺序 Sedan → SUV → LuxurySix cars in three groups, keyed in the order Sedan → SUV → Luxury
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
cab-booking-context/src/components/CabOptions/CabOptions.jsx外层分组

提醒:源项目在磁盘上是做完的版本 —— 下面就是答案。想自己先写一遍的话,现在关上。Heads up: on disk this project is the finished version — what follows is the answer. Close this if you want to write it yourself first.

JSXCabOptions.jsx源项目From source
1import cabData from "../../data/data.json";
2import CabCard from "./CabCard";
3
4const CabOptions = ({ onSelectCab }) => {
5 return (
6 <main className="cabs-container">
7 <div className="page-heading">
8 <p className="eyebrow">Available now</p>
9 <h2>Select your desired Car</h2>
10 </div>
11
12 <div data-testid="all-cabs-section" className="all-cabs-section">
13 {Object.keys(cabData).map((type) => (
14 <section key={type} className="cab-type-section">
15 <h3 data-testid="car-type-heading">{type}</h3>
16 <div className="cab-list">
17 {cabData[type].map((cab) => (
18 <CabCard key={cab.id} cab={cab} onSelectCab={onSelectCab} />
19 ))}
20 </div>
21 </section>
22 ))}
23 </div>
24 </main>
25 );
26};
27
28export default CabOptions;
Source: cab-booking-context/src/components/CabOptions/CabOptions.jsx
cab-booking-context/src/components/CabOptions/CabCard.jsx五个字段都在这里

提醒:源项目在磁盘上是做完的版本 —— 下面就是答案。想自己先写一遍的话,现在关上。Heads up: on disk this project is the finished version — what follows is the answer. Close this if you want to write it yourself first.

JSXCabCard.jsx源项目From source
1const CabCard = ({ cab, onSelectCab }) => {
2 return (
3 <article className="cab-card">
4 <img src={cab.image} alt={cab.name} data-testid="cab-card-img" />
5 <div className="cab-card__content">
6 <p data-testid="cab-card-name" className="cab-card__name">
7 {cab.name}
8 </p>
9 <p data-testid="cab-card-type" className="cab-card__type">
10 Type: {cab.type}
11 </p>
12 <p data-testid="cab-card-price" className="cab-card__price">
13 Fare: ${cab.price}
14 </p>
15 <button
16 type="button"
17 data-testid="cab-card-select-button"
18 className="secondary-button"
19 onClick={() => onSelectCab(cab)}
20 >
21 Select
22 </button>
23 </div>
24 </article>
25 );
26};
27
28export default CabCard;
Source: cab-booking-context/src/components/CabOptions/CabCard.jsx
§01

Object.keys 加两层 mapObject.keys plus two nested maps

数据长什么样,代码就长什么样The shape of the code follows the shape of the data

一句话:data.json是「类型 → 车数组」的对象,所以外层遍历键、内层遍历值。

数据形状:

  • 顶层是一个对象,三个键:Sedan / SUV / Luxury
  • 每个键的值是一个数组,各 2 辆车
  • 每辆车有 id / name / type / price / image

所以 3 × 2 = 6 张卡 —— 测试 2 里那五个 toHaveLength(6) 就是这么来的。

分组顺序是送分题:Object.keys() 返回的字符串键顺序, 在现代 JS 里就是它们被写进对象的顺序(这是 ES2015 起明确规定的,不是巧合)。data.json 里写的是 Sedan → SUV → Luxury, 所以 Object.keys(cabData) 出来就是这个顺序, 正好对上测试 2 的 toEqual

反而是「我来排个序」会把它弄坏:.sort() 出来是字典序Luxury → SUV → Sedan,直接红。这一条的道理是:数据已经有意义的顺序时,别再加工。

会追问:Object.keys 的顺序真的可靠吗?」—— 对字符串键可靠,插入顺序。 但纯数字键会被排到最前面并按数值升序{ "2": …, "10": …, "a": … }出来是 2, 10, a)。 这道题的键都是单词,撞不到; 但如果分组键是年份或编号,就得自己维护一个顺序数组。

In one line: data.json is a “type to array of cars” object, so the outer loop walks the keys and the inner loop walks the values.

The shape of the data:

  • Top level is an object with three keys: Sedan / SUV / Luxury
  • Each key holds an array of 2 cars
  • Each car has id / name / type / price / image

So 3 × 2 = 6 cards — that is where the five toHaveLength(6) assertions in test 2 come from.

The group order is a gift: the string keys returned by Object.keys() come back in the order they were written into the object in modern JS — specified since ES2015, not an accident. data.json lists Sedan → SUV → Luxury, so Object.keys(cabData) hands you exactly the order test 2’s toEqual wants.

“Let me sort it” is what breaks it: .sort() gives lexicographic order, Luxury → SUV → Sedan, which fails. The lesson: when the data already carries a meaningful order, do not touch it.

Follow-up: “Is Object.keys order actually reliable?” — for string keys, yes: insertion order. But purely numeric keys get hoisted to the front in ascending numeric order ({ "2": …, "10": …, "a": … } comes out as 2, 10, a). The keys here are words, so it never bites; but if your group keys were years or numeric ids you would have to keep your own order array.

JSXsrc/components/CabOptions/CabOptions.jsx源项目From source
1import cabData from "../../data/data.json";
2import CabCard from "./CabCard";
3
4const CabOptions = ({ onSelectCab }) => {
5 return (
6 <main className="cabs-container">
7 <div className="page-heading">
8 <p className="eyebrow">Available now</p>
9 <h2>Select your desired Car</h2>
10 </div>
11
12 <div data-testid="all-cabs-section" className="all-cabs-section">
13 {Object.keys(cabData).map((type) => (
14 <section key={type} className="cab-type-section">
15 <h3 data-testid="car-type-heading">{type}</h3>
16 <div className="cab-list">
17 {cabData[type].map((cab) => (
18 <CabCard key={cab.id} cab={cab} onSelectCab={onSelectCab} />
19 ))}
20 </div>
21 </section>
22 ))}
23 </div>
24 </main>
25 );
26};
27
28export default CabOptions;
Source: cab-booking-context/src/components/CabOptions/CabOptions.jsx
JSONsrc/data/data.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
§02

五个字段,和 key 的那个坑The five fields, and the problem with the key

卡片里每个字段都有 testid;历史列表的 key 不能只用 idEvery field in the card has its own testid; the key in the history list cannot be the id alone

一句话:一张卡上五个data-testidcab-card-img / -name / -type / -price / -select-button

测试只数个数,不查内容 —— 五个断言都是 toHaveLength(6)。 但别因此偷懒:alt={cab.name} 该写还是要写,图片没有 alt 是真实的可访问性缺陷, 面试和 code review 都会提。

现在说 key。两个列表,两种情况:

  • 车列表(CabOptions 里):key={cab.id} 就够了 —— 六辆车的 id 各不相同,而且列表不会变。
  • 历史列表(RideHistory 里):key={ride.id}不安全 ——同一辆车可以被订两次, 历史里就有两条 id: 1。 React 会警告Encountered two children with the same key, 并且在更新时可能复用错的 DOM 节点。

源项目用的是 key={`${ride.id}-${index}`} ——id 加位置,两条相同的车也能区分开

会追问:「不是说 index 不能当 key 吗?」——说的是「不要只用 index」。纯 index 的问题是列表中间插入/删除时 key 会错位到别的数据上; 这里 ride.id 提供了身份、index 只用来消歧,而且历史是只在尾部追加的 —— 没有中间插入,index 不会错位。
真正干净的做法是给每条记录一个自己的 id(比如 bookedAt: Date.now()), 这样 key 就有了天然唯一值。 源项目没这么做,但这是面试里可以主动说的加分点。

In one line: five data-testid hooks per card: cab-card-img / -name / -type / -price / -select-button.

The tests only count them, they do not read them — all five assertions are toHaveLength(6). Do not let that make you lazy: still write alt={cab.name}, because an image with no alt is a real accessibility defect and both interviews and code review will call it out.

Now the keys. Two lists, two situations:

  • The cab list (in CabOptions): key={cab.id} is enough — the six ids are distinct and the list never changes.
  • The history list (in RideHistory): key={ride.id} is not safe the same cab can be booked twice, so the history holds two entries with id: 1. React warns Encountered two children with the same key and may reuse the wrong DOM node on update.

The source project uses key={`${ride.id}-${index}`} id plus position, so two identical cabs stay distinct.

Follow-up: “I thought index must never be a key?” — the rule is “never index alone”. Bare index breaks when items are inserted or removed in the middle, because a key then lands on different data. Here ride.id supplies identity and index only disambiguates — and the history is append-only at the tail, so nothing shifts.
The genuinely clean fix is to give each record its own id (say bookedAt: Date.now()), which makes the key naturally unique. The source project does not, but it is a good point to raise yourself in an interview.

JSXsrc/components/CabOptions/CabCard.jsx(五个 testid)src/components/CabOptions/CabCard.jsx (five testids)源项目From source
1const CabCard = ({ cab, onSelectCab }) => {
2 return (
3 <article className="cab-card">
4 <img src={cab.image} alt={cab.name} data-testid="cab-card-img" />
5 <div className="cab-card__content">
6 <p data-testid="cab-card-name" className="cab-card__name">
7 {cab.name}
8 </p>
9 <p data-testid="cab-card-type" className="cab-card__type">
10 Type: {cab.type}
11 </p>
12 <p data-testid="cab-card-price" className="cab-card__price">
13 Fare: ${cab.price}
14 </p>
15 <button
16 type="button"
17 data-testid="cab-card-select-button"
18 className="secondary-button"
19 onClick={() => onSelectCab(cab)}
20 >
21 Select
22 </button>
23 </div>
24 </article>
25 );
26};
27
28export default CabCard;
Source: cab-booking-context/src/components/CabOptions/CabCard.jsx
练习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.

L1认出来Spot it哪个 key 在历史列表里会出问题?Which key goes wrong in the history list?
用户连订了两次同一辆 Ford Fusion(id: 1)。 历史列表用下面哪个 key 会报「重复 key」警告?The user books the same Ford Fusion (id: 1) twice in a row. Which of these keys makes the history list warn about duplicate keys?
先选一个选项Pick an option first
L3写整块Write a block从零写出 CabCardWrite CabCard from an empty file
只给你 props 签名。五个 testid 自己写出来, 别忘了 alttype="button"You only get the props signature. Write the five testids yourself, and do not forget alt and type="button".
要求Requirements
  • 五个 data-testid 一个不少:cab-card-img / -name / -type / -price / -select-buttonAll five data-testid values: cab-card-img / -name / -type / -price / -select-button
  • img 要有 alt(测试不查,但没有 alt 是真实的可访问性缺陷)The img needs an alt (the tests do not check it, but a missing alt is a real accessibility defect)
  • 按钮写 type="button" —— 不写的话在 <form> 里会变成提交按钮The button needs type="button" — without it, inside a <form> it becomes a submit button
  • onClick 里包一层箭头函数把 cab 传进去,不是直接传 onSelectCabWrap the onClick in an arrow function that passes cab in; do not pass onSelectCab directly
  • 价格前面要有 $ —— 历史列表的断言查的是 "$20"The price needs a $ in front — the history assertion looks for "$20"
JSXsrc/components/CabOptions/CabCard.jsx
This check is textual: it looks for the right constructs, it does not run your code
提示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.

看答案之前,先确认你已经自己动手写过一遍。看懂别人的答案和自己写出来,是两种能力。Before you open this, make sure you have written it yourself once. Following someone else's answer and producing your own are two different skills.

错例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.

JSX多余的排序A sort that was not needed示意Illustrative
1// ✕ 自己给分组排序 —— 测试 2 直接红
2{Object.keys(cabData).sort().map((type) => (
3 <section key={type}>
4 <h3 data-testid="car-type-heading">{type}</h3>
5
6 </section>
7))}
8
9// .sort() 出来是字典序:["Luxury", "SUV", "Sedan"]
10// 断言要的是: ["Sedan", "SUV", "Luxury"]
1// ✕ sorting the groups yourself — test 2 fails
2{Object.keys(cabData).sort().map((type) => (
3 <section key={type}>
4 <h3 data-testid="car-type-heading">{type}</h3>
5
6 </section>
7))}
8
9// .sort() gives dictionary order: ["Luxury", "SUV", "Sedan"]
10// the assertion wants: ["Sedan", "SUV", "Luxury"]
sort() 默认按字符串比较,大写字母在前, 出来是 Luxury → SUV → Sedan
data.json 的键顺序本来就是对的。这个错误的模式很典型:看到列表就想「是不是该排一下」 —— 但数据源已经表达了顺序意图时,任何加工都是破坏。
顺带:sort() 还会原地修改它作用的数组。 这里作用在 Object.keys() 返回的新数组上所以无害, 但直接 someStateArray.sort() 就会改到 state。
sort() compares as strings by default, and capital letters come first, so the result is Luxury → SUV → Sedan.
The key order in data.json was already correct. The mistake follows a very common pattern: you see a list and wonder whether it should be sorted. When the data source already states the order, any extra step breaks it.
One more point: sort() also changes the array in place. Here it runs on the new array returned by Object.keys(), so it does no harm, but someStateArray.sort() would change the state itself.
JSX忘了包箭头函数The arrow function was left out示意Illustrative
1// ✕ 直接把 onSelectCab 当 onClick
2<button data-testid="cab-card-select-button" onClick={onSelectCab}>
3 Select
4</button>
5
6// onClick 会把「点击事件对象」当第一个参数传进去
7// → updateBookedCabDetails(clickEvent)
8// → 确认页显示 undefined is on the way
9// → 历史里那条记录的 name 和 price 都是 undefined
1// ✕ using onSelectCab as onClick directly
2<button data-testid="cab-card-select-button" onClick={onSelectCab}>
3 Select
4</button>
5
6// onClick passes the click event object in as the first argument
7// → updateBookedCabDetails(clickEvent)
8// → the confirmation page shows undefined is on the way
9// → the name and price of that history entry are both undefined
DOM 事件处理器永远收到事件对象作为第一个参数。 所以 onClick={onSelectCab} 等于onSelectCab(clickEvent), 而 cab 从来没被传出去。
症状很迷惑:不报错,流程也走得通 —— 页面照样切到 loading、再切到确认页, 只是车名变成了空白(因为 ?.name undefined,React 什么也不渲染)。 测试 3 会挂在toHaveTextContent("Ford Fusion is on the way…")
需要传自己的参数,就包一层:onClick={() => onSelectCab(cab)}
A DOM event handler always receives the event object as its first argument. So onClick={onSelectCab} means onSelectCab(clickEvent), and cab is never passed at all.
The symptom is confusing: no error, and the flow still works — the page moves to loading and then to the confirmation page, only the cab name is blank (because ?.name is undefined, and React renders nothing for it). Test 3 fails on toHaveTextContent("Ford Fusion is on the way…").
To pass an argument of your own, wrap it: onClick={() => onSelectCab(cab)}.
迁移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.

数据是「分组名 → 数组」的对象The data is an object of group name to array
Object.keys 外层、值数组内层,两层 mapObject.keys on the outside, the value array on the inside: two maps
断言用 toEqual 比分组顺序A toEqual check compares the group order
别自己 sort —— 键的插入顺序就是答案Do not sort it yourself; the order the keys were written in is the answer
列表里可能出现重复的业务 idThe same business id can appear twice in a list
key 用 `${id}-${index}`,或给每条记录一个自己的 idUse `${id}-${index}` as the key, or give every entry an id of its own
onClick 需要带自己的参数onClick has to carry an argument of your own
() => fn(arg);直接传 fn 会收到事件对象() => fn(arg); passing fn directly hands you the event object
这节的要点What to take away
  1. 3 个类型 × 2 辆车 = 6 张卡,五个 toHaveLength(6) 就是这么来的。3 types × 2 cabs = 6 cards, which is where the five toHaveLength(6) checks come from.
  2. 分组顺序来自 data.json 的键插入顺序,Object.keys 直接给你,别 sort。The group order comes from the order the keys were written in data.json. Object.keys hands it to you, so do not sort.
  3. CabCard 五个 testid:img / name / type / price / select-button。The five testids of CabCard: img, name, type, price, select-button.
  4. 历史列表的 key 不能只用 ride.id —— 同一辆车能订两次。The key in the history list cannot be ride.id alone, because the same cab can be booked twice.
  5. onClick={onSelectCab} 会把事件对象当 cab 传进去,必须包箭头函数。onClick={onSelectCab} passes the event object in place of cab, so you have to wrap it in an arrow function.

接下来What next

  1. 把这一节的练习做掉Do this lesson’s exercises2 个,就在这一页上面 —— 别攒着最后一起做2 of them, further up this page — do not save them for later
    回到练习 ↑Back up to them ↑
  2. 接着看下一节Continue to the next lessonLoading:一秒之后自己跳走Loading: it moves to the next page by itself after one second
    下一节Next lesson
读完并且做过上面的练习了吗?Read it and worked through the exercises above?
上一节:Previous: 用一个 state 管四个页面Controlling four pages with one piece of state