Old-School Front-End · Frameworks / 2026 ・ No.04
古法前端入门 框架篇
第4讲 React 的第一步 ── 模板、JSX、组件与 props
发布
讲完 3 讲历史,终于开始用 React 写了。题材是前端篇的申请表单。
第 4 讲用 Vite 生成模板,把画面分成部件(组件),用 props 传递明细。添加或删除行还不能用(那是第 5 讲“状态”的工作)。作为替代,我们测量 JSX 的真面目、常见的错误、保存后画面就变化的机制,以及开发用与生产用的不同。
生成模板
新井终于是 React 了!先要装什么?
古井只要 Node.js。剩下的是 3 条命令。
全 15 讲的路线图(当前位置:第 4 讲)
React 本身是“决定画面样子”的工具,不负责打包文件或启动开发服务器。这些交给 Vite。和第 3 讲打包 Vue・Svelte・Solid 用的是同一个工具。
# npm create vite@latest keihi-react -- --template react 生成的内容(create-vite 9.3.3) .gitignore .oxlintrc.json 代码检查(oxlint)的设置 README.md index.html 入口 HTML(只有 <div id="root">) package.json 用到的包和 dev・build 命令 vite.config.js Vite 设置(只加 React 插件) public/ 原样发布的文件 src/main.jsx 开始在 #root 上绘制画面 src/App.jsx 最外层的组件 src/App.css, index.css, assets/ 样例的样式和图片(这次删掉了) # 生成用了 1.3 秒。npm install 安装 25 个包,共 62 MB,17.5 秒(第 2 次 4 秒)。npm run dev 约 0.3 秒启动
入口的 index.html 里只有 1 个 <div id="root">,画面内容全由 src/ 的 JavaScript 绘制。前端篇是先写 HTML,再用 JavaScript 改写。这里顺序相反。
把画面分成部件
把申请表单分成了 6 个部件。部件是函数,名字以大写字母开头。
组件树与 props 的流向
# src/sample.js
// 第 4 讲:还不用“状态”,所以明细显示固定的样本(第 5 讲改成可以添加・删除)
import { T } from './text.js';
export const RATE = 151.23; // 1 美元是多少日元(第 7 讲从服务器获取)
export const SAMPLE_ROWS = [
{ id: 1, date: '2026-10-14', category: 'transport', item: T.sample[0], amount: '14720', currency: 'JPY' },
{ id: 2, date: '2026-10-14', category: 'hotel', item: T.sample[1], amount: '12800', currency: 'JPY' },
{ id: 3, date: '2026-10-15', category: 'meeting', item: T.sample[2], amount: '100', currency: 'USD' },
];
# src/App.jsx(main 内部)
<form>
<AboutSection />
<ItemsSection rows={SAMPLE_ROWS} rate={RATE} />
…
</form>
<ItemsSection rows={SAMPLE_ROWS} rate={RATE} /> 看起来像 HTML 属性,其实是在给函数传参数。这些参数叫作 props。
# src/components/ItemsSection.jsx
import { T } from '../text.js';
import ItemTable from './ItemTable.jsx';
import TotalLine from './TotalLine.jsx';
/** 明细卡片:只是把表和合计排在一起。把收到的 rows 原样传给子组件 */
export default function ItemsSection({ rows, rate }) {
return (
<div className="card mb-4">
<fieldset className="card-body">
<legend className="h6 section-title">{T.items} <span id="count" className="badge text-bg-secondary">{T.count(rows.length)}</span></legend>
<ItemTable rows={rows} />
<TotalLine rows={rows} rate={rate} />
</fieldset>
</div>
);
}
# src/components/ItemTable.jsx(tbody 内部)
<tbody id="items">
{rows.map((row, i) => <ItemRow key={row.id} row={row} n={i + 1} />)}
</tbody>
ItemsSection 只是把收到的 rows 原样传给表格(ItemTable)和合计(TotalLine)。ItemTable 用 rows.map(…) 逐行生成 ItemRow。值只从上往下流,下面的部件不会改写上面的值。第 3 讲说的“单向”,就是这个形状。
明细的 1 行
import { T } from '../text.js';
/** 明细的 1 行。把从父组件收到的 row(props)原样变成表的 1 行 */
export default function ItemRow({ row, n }) {
return (
<tr>
<td><input type="date" className="form-control form-control-sm" name="date" defaultValue={row.date} aria-label={T.rowLabel(n, T.date)} /></td>
<td>
<select className="form-select form-select-sm" name="category" defaultValue={row.category} aria-label={T.rowLabel(n, T.category)}>
<option value="">{T.choose}</option>
{Object.entries(T.categories).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
</select>
</td>
<td><input type="text" className="form-control form-control-sm" name="item" defaultValue={row.item} aria-label={T.rowLabel(n, T.item)} /></td>
<td><input type="text" className="form-control form-control-sm text-end" name="amount" defaultValue={row.amount} inputMode="numeric" autoComplete="off" aria-label={T.rowLabel(n, T.amount)} /></td>
<td>
<select className="form-select form-select-sm" name="currency" defaultValue={row.currency} aria-label={T.rowLabel(n, T.currency)}>
<option value="JPY">{T.jpy}</option>
<option value="USD">{T.usd}</option>
</select>
</td>
<td><button type="button" className="btn btn-outline-danger btn-sm" aria-label={T.rowRemove(n)}>{T.remove}</button></td>
</tr>
);
}
看起来是 HTML,但有几处不同。不是 class 而是 className,不是 for 而是 htmlFor,inputmode 写成 inputMode。{ } 里可以写 JavaScript 表达式,{row.item} 就放入 row 的内容。
输入栏只用 defaultValue 放入初始值。把“这一栏的值”交给 React 要从第 5 讲开始,现在输入的文字由浏览器自己保存。
JSX 的真面目
浏览器读不懂 JSX。Vite 在交给浏览器之前,把它变成普通的 JavaScript。我们用不压缩的设置输出生产用构建来看看。
# npm run build(不压缩的设置)生成的 ItemRow。JSX 变成了 jsx()・jsxs() 的调用
function ItemRow({ row, n }) {
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("tr", { children: [
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("td", { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("input", {
type: "date",
className: "form-control form-control-sm",
name: "date",
defaultValue: row.date,
"aria-label": T.rowLabel(n, T.date)
}) }),
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("td", { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("select", {
className: "form-select form-select-sm",
name: "category",
…
] });
}
<tr>…</tr> 变成了 jsxs("tr", { children: [...] }) 这样的函数调用,属性变成了对象的键。由此可以看出 JSX 规则的理由。
- className ── 对象的键用 JavaScript 的名字(DOM 里也是 element.className)
- 以大写开头 ── 小写会变成 "tr" 这样的 HTML 名称(字符串),大写会变成 ItemRow 这个函数
- 合成 1 个返回 ── 函数只能返回 1 个值。想并列时用 <>…</>(Fragment)包起来
合计只是计算出来
import { T } from '../text.js';
import { calcTotal, needsReceipt } from '../money.js';
/** 合计和收据提示。合计每次都从 props 计算(不保存在任何地方) */
export default function TotalLine({ rows, rate }) {
const total = calcTotal(rows, rate);
return (
<>
<div className="d-flex flex-wrap justify-content-between align-items-center gap-2 mt-3">
<button type="button" className="btn btn-outline-primary btn-sm" id="add-row">{T.add}</button>
<p className="mb-0 fs-5">{T.total}<output id="total" className="fw-bold">{T.yen(total)}</output></p>
</div>
{needsReceipt(total) && <p className="alert alert-warning py-2 mt-3 mb-0" id="receipt-note">{T.receipt}</p>}
<p className="small text-body-secondary mt-2 mb-0" id="rate-note">{T.rate(rate)}</p>
</>
);
}
合计每次都从 rows 计算后显示,不保存在任何地方。前端篇把合计写进画面的 <output>,每次行变化都重写(第 1 讲正是在这里对不上)。这里“rows 定了合计就定了”,没有会对不上的地方。
收据提示是 {needsReceipt(total) && <p>…</p>}。条件为 false 时什么也不显示。不是加上或去掉 hidden,而是写显示还是不显示。计算函数(money.js)只是给前端篇的 app.js 加上了 import。
故意出错
新井和 HTML 几乎一样嘛。这样就不会写错了。
古井那就先把大家都会犯的错全犯一遍。
# 在开发服务器(npm run dev)上逐个制造错误。控制台显示的内容和画面
直接写 class・for
Invalid DOM property `class`. Did you mean `className`?(for 也一样)
→ 画面照样显示(只有警告)
部件名用小写(itemRow)
<itemRow /> is using incorrect casing. … start its name with an uppercase letter.
→ 变成未知元素 <itemrow></itemrow>,行不显示
只有 value 没有 onChange
You provided a `value` prop to a form field without an `onChange` handler. This will render a read-only field.
→ 输入“0”也还是 14720
map 时没有 key
Each child in a list should have a unique "key" prop.
→ 现在能显示(第 5 讲测会错位的例子)
改写了 props
Cannot assign to read only property 'n' of object
→ 画面全白(因错误而停止)
内容里有 <img onerror=…>
(没有消息)
→ 用 { } 时保持文字。用 dangerouslySetInnerHTML 时生成了 img,onerror 运行了
并列返回了 2 个元素
[PARSE_ERROR] Adjacent JSX elements must be wrapped in an enclosing tag.
→ 转换时停止,出现 Vite 的错误画面
开发服务器里的 React 发现错误时,会在控制台写出原因(连 className 的建议都有)。但画面多半不会停,不看就注意不到。小写的部件名,明明一行都不显示,却只是警告而不是错误。
XSS 方面,用 { } 放入的文字一定按文字显示。和前端篇第 8 讲选择 textContent 一样的防护,从一开始就有。使用名字就写着危险的 dangerouslySetInnerHTML 时,onerror 运行了。
一保存就变
开发服务器在保存文件时,只替换那个部件(HMR)。在事由里输入“大阪出差”后,改写了各种文件。
改写的文件结果输入的事由 components/TotalLine.jsx 只替换该部件(50 ms)保留 components/AboutSection.jsx 只替换该部件(51 ms)保留 sample.js 替换 App.jsx保留 text.js 重新加载页面消失 main.jsx 重新加载页面消失 # 在事由中输入“大阪出差”后改写文件。时间是 5 次的中位数(从保存文件到画面变化) # text.js 也被 main.jsx 读取,所以替换范围扩大到 main.jsx,变成了重新加载
部件文件约 50 ms 就变了,输入的事由和明细内容都保留了。可是改写汇总画面文字的 text.js 时,整个页面重新加载,事由消失了。text.js 也被 main.jsx 读取,而 main.jsx 不是部件,无法替换。把文件写成只包含部件,HMR 就很好用。
开发用与生产用
用 npm run dev,以及 npm run build 后用 npm run preview 提供的版本,分别打开同一个画面。
开发用(npm run dev)生产用(npm run build) 加载的文件22 4 其中 JavaScript20 1 送达大小3.8 MB 451 KB 到合计显示778 ms 344 ms # 开发用中最大的是 react-dom_client.js(3,132,085 B),包含发出警告的机制和对应原始源码的映射 # 开发用时 CSS 也作为 JavaScript 文件送达(包括 bootstrap.min.css)。时间是 5 次的中位数
开发用与生产用
开发用逐个发送文件(为了 HMR 只替换 1 个),还带有第六节发出警告的机制和对应原始源码的映射,所以有 3.8 MB。生产用合并成 1 个并压缩,451 KB。测速度、给人看,都用生产用。
自己的代码有多少 KB
大小gzip 只有 React(内容只有 1 个 <p>)218,795 B 67,378 B 这一讲的申请表单229,019 B 71,093 B → 自己代码的部分10,224 B 3,715 B jQuery 版(前端篇第 14 讲)95,275 B 35,125 B # jQuery 版是 jquery・app・auth・validate・form-jq 这 5 个(连提交和输入检查都能用)。这一讲的 src 去掉空行共 228 行
即使内容只有 1 个 <p>,React 也有 219 KB,和这一讲申请表单的差,也就是自己写的部分,只有 10 KB。React “一开始要付的”很大,之后加部件只会一点点增加。jQuery 版连提交和输入检查都能用,95 KB。老实说,现在 jQuery 版更轻,能做的事也更多。
在自己的电脑上
# 生成模板、安装、启动开发服务器 npm create vite@latest keihi-react -- --template react cd keihi-react npm install npm run dev # 打开 http://localhost:5173/ # 做出生产用版本,在本机提供试试 npm run build npm run preview # http://localhost:4173/
在浏览器里装上 React Developer Tools,就能在开发者工具的 Components 标签里看到组件树和各自收到的 props。请和第二节的图对照。另外,模板 main.jsx 里的 <StrictMode>,是只在开发用时把组件运行 2 次来找错的机制。第 7 讲会测它的意义。
结 — 一页总结与下一讲
- 模板只要 3 条命令。React 决定画面样子,打包和提供交给 Vite。
- 画面分成部件(函数)。值用 props 只从上往下传,子组件不改写。
- JSX 是 jsx() 的调用。className・大写・合成 1 个,都由此而来。
- 合计不保存,每次只是计算。提示写“显示还是不显示”。
- 开发用带警告 3.8 MB,生产用 451 KB。自己的代码 10 KB。
新井画面做好了。……可按“添加一行”什么都没发生。
古井因为还没告诉 React,按下去会变什么。
古井下一讲是状态与重绘。用 useState 添加・删除行,一直测到 key 弄错时输入会错位。
参考
- Vite — Getting Started
- React — Your First Component
- React — Writing Markup with JSX
- React — Passing Props to a Component
- React — Conditional Rendering
- React — Rendering Lists
- React — dangerouslySetInnerHTML
- Vite — Hot Module Replacement
记录是笔者于 2026 年 11 月 1 日在云端 Linux 上取得的。在 create-vite 9.3.3 的 react 模板(Vite 8.3.5、@vitejs/plugin-react 6.1.2、React 19.3.1、oxlint)上加了 Bootstrap 5.3.8。画面用 Playwright 驱动的 Chromium 153.0.8010.12 打开,取得控制台内容、文件数量和大小以及时间。HMR 时间是从保存文件到画面变化,加载时间是从导航开始到合计显示,都是 5 次的中位数。npm install 的时间会随网络状况变化。
古法前端入门 框架篇 第4讲 — React 的第一步 / 2026.11