Old-School Front-End · Frameworks / 2026 ・ No.04

古法前端入门 框架篇

第4讲 React 的第一步 ── 模板、JSX、组件与 props

发布

浏览器画面。用 React 写的费用报销系统“新建申请”。事由为“大阪出差(10/14〜15)”,支付方式为个人垫付。明细 3 条:新干线 东京→大阪 14720 日元,酒店 1 晚 12800 日元,与客户午餐 100 美元。合计 42,643 日元,并提示需要收据
开始用 React 组件重写前端篇的申请表单(添加・删除行还不能用)(笔者于 2026 年 11 月 1 日在云端 Linux 上拍摄。窗口上方的标题栏是为了说明而补画的)

讲完 3 讲历史,终于开始用 React 写了。题材是前端篇的申请表单。
第 4 讲用 Vite 生成模板,把画面分成部件(组件),用 props 传递明细。添加或删除行还不能用(那是第 5 讲“状态”的工作)。作为替代,我们测量 JSX 的真面目、常见的错误、保存后画面就变化的机制,以及开发用与生产用的不同。

一

生成模板

新井终于是 React 了!先要装什么?

古井只要 Node.js。剩下的是 3 条命令。

全 15 讲的路线图(当前位置:第 4 讲)

1 为什么要框架 2 jQuery→MVC 3 React 以后 4 React 第一步 5 状态与重绘 6 表单 7 获取数据 8 重绘的代价 9 状态放哪里 10 页面切换 11 乐观更新 12 Server Comp. 13 缓存 14 测试 15 发布与选择 当前位置 缘由与历史 React 基础 速度与位置 页面与服务器 Next.js 收尾

React 本身是“决定画面样子”的工具,不负责打包文件或启动开发服务器。这些交给 Vite。和第 3 讲打包 Vue・Svelte・Solid 用的是同一个工具。

终端画面。npm create vite@latest keihi-react -- --template react --no-interactive 由 create-vite v9.3.3 生成模板,npm install 安装了 24 个包,npm run dev 用 324 ms 启动 VITE v8.3.5,显示 http://localhost:5180/
生成模板、安装依赖、启动开发服务器,只要 3 条命令(npm install 是第 2 次的 4 秒)(笔者于 2026 年 11 月 1 日在云端 Linux 上拍摄。窗口上方的标题栏是为了说明而补画的)
# 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 的流向

App 整个画面 AboutSection 事由・支付方式 ItemsSection 明细卡片 ItemTable 表格 TotalLine 合计 ItemRow ×3 1 行 rows, rate rows rows, rate row, n 样本明细(sample.js)从 App 进入 props 只从上往下流。子组件只接收使用,不改写 合计由 TotalLine 每次从 rows 计算,不保存在任何地方
# 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 的错误画面
浏览器画面。Vite 的错误画面。[plugin:vite:oxc] Transform failed with 1 error: [PARSE_ERROR] Adjacent JSX elements must be wrapped in an enclosing tag. 指向 src/lab/LabBroken.jsx 第 5 行的 <p>,并显示 Help: Did you want a JSX fragment <>...</>?
从 1 个组件并列返回 2 个元素,在送到浏览器之前的转换阶段就停住了,Vite 显示了错误画面(笔者于 2026 年 11 月 1 日在云端 Linux 上拍摄。窗口上方的标题栏是为了说明而补画的)

开发服务器里的 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 次的中位数

开发用与生产用

用开发用和生产用打开同一个申请表单(实测,时间是 5 次中位数) 文件数 开发用 22 生产用 4 送达大小 开发用 3,909 KB 生产用 451 KB 到合计显示 开发用 778 ms 生产用 344 ms 开发用逐个发送文件,并带有警告和对应源码的映射,所以大 生产用合并成 1 个 JavaScript 并压缩。给别人看的,永远是生产用

开发用逐个发送文件(为了 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 讲会测它的意义。

结 — 一页总结与下一讲

  1. 模板只要 3 条命令。React 决定画面样子,打包和提供交给 Vite。
  2. 画面分成部件(函数)。值用 props 只从上往下传,子组件不改写。
  3. JSX 是 jsx() 的调用。className・大写・合成 1 个,都由此而来。
  4. 合计不保存,每次只是计算。提示写“显示还是不显示”。
  5. 开发用带警告 3.8 MB,生产用 451 KB。自己的代码 10 KB。

新井画面做好了。……可按“添加一行”什么都没发生。

古井因为还没告诉 React,按下去会变什么。

古井下一讲是状态与重绘。用 useState 添加・删除行,一直测到 key 弄错时输入会错位。

参考

记录是笔者于 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

← 返回文章列表