第三篇:React 组件化开发 --- 函数即组件的哲学与实践
本文目标:不仅会用 React,更要理解 JSX 的编译原理、Virtual DOM 的 Diff 算法、组件设计的最佳实践。
一、JSX 的本质 --- 从"像 HTML"到"就是 JavaScript"
1.1 JSX 不是模板语言
很多初学者会以为 JSX 是 React 专属的模板语言,就像 Vue 的 <template> 或者 EJS 那样。不是的。 JSX 是 JavaScript 的语法糖,仅此而已。
让我们做一个实验。你写的这段代码:
javascript
const element = <h1 className="title">Hello World</h1>
经过 Babel / Oxc 编译后,变成:
php
import { jsx as _jsx } from 'react/jsx-runtime'
const element = _jsx('h1', {
className: 'title',
children: 'Hello World'
})
_jsx 函数的返回值是一个普通的 JavaScript 对象:
yaml
// React Element --- 这就是一个普通的 JS 对象!
{
$$typeof: Symbol(react.element),
type: 'h1',
key: null,
ref: null,
props: {
className: 'title',
children: 'Hello World'
},
_owner: null
}
这个对象被称为 React Element (React 元素)。记住:JSX 最终产出的就是一堆这样的普通 JS 对象,不是什么神秘的"模板"。
1.2 React Element、组件实例和 DOM 节点
这是很多面试者搞混的概念。我们用一个例子来区分:
javascript
// 1. 这是一个 React Element(JS 对象)
const header = <h1>Hello</h1>
// 2. 这是一个函数组件(返回 React Element)
function Welcome({ name }) {
return <h1>Hello, {name}</h1>
}
// 3. 当你在 JSX 里调用 Welcome 时
// 你先得到一个描述"要渲染 Welcome 组件"的 React Element:
const welcomeElement = <Welcome name="张三" />
// → { type: Welcome, props: { name: '张三' } }
| 概念 | 是什么 | 类比 |
|---|---|---|
| React Element | 一个普通 JS 对象,描述"屏幕上应该有什么" | 建筑设计图 |
| 组件 | 一个函数/类,接收 props 返回 React Element | 建筑师 |
| 组件实例 | React 内部创建,管理组件的状态和生命周期 | 施工现场的工头 |
| DOM 节点 | 浏览器里的真实 div/span | 建好的房子 |
你可以把 { type: 'h1', props: {...} } 这类 React Element 理解为虚拟 DOM 节点。React 负责比较新旧两棵虚拟 DOM 树,找出差异,然后最小化地更新真实 DOM。
1.3 深入 JSX 花括号 --- {} 里的世界
JSX 中用 {} 来嵌入 JavaScript。但很多人不知道里面可以写什么、不能写什么:
javascript
// ✅ 可以:表达式(有返回值的代码)
<div>
{count + 1} // 算术表达式
{items.length > 0 && <List />} // 逻辑表达式
{items.map(item => <Item />)} // 方法调用(返回数组)
{`Hello ${name}`} // 模板字符串
{(function() { return 42 })()} // IIFE(虽然不推荐)
</div>
// ❌ 不可以:语句(没有返回值)
<div>
{if (true) { ... }} // ❌ if 是语句,没有返回值
{for (let i = 0; i < 10; i++)} // ❌ for 是语句
{let x = 5} // ❌ 变量声明是语句
</div>
这就是为什么条件渲染要用三元运算符或 && 短路,循环要用 .map():
condition ? <A /> : <B />是表达式,有返回值items.map(fn)是表达式,返回新数组if (...) {...} else {...}是语句,没有返回值
核心理解 :JSX 花括号里只能写 JavaScript 表达式 ,不能写语句。这是语言规范决定的,不是 React 故意限制你。
二、Progress 组件 --- 把健壮性做到极致
2.1 组件的"防御性编程"
让我展示一个脆弱组件和一个健壮组件的对比:
ini
// ❌ 脆弱版:不做任何防御
const Progress = ({ text, percentage, total }) => {
return (
<div className="w-full bg-gray-100 rounded-lg">
<div
style={{ width: percentage + '%' }} // percentage 为 undefined → "undefined%"
className="bg-blue-400"
>
{text} // text 为 undefined → 什么都不显示(勉强能看)
{percentage.toFixed(2)}% // ❌ undefined.toFixed(2) → 直接崩溃!
{total ? ` of ${formatBytes(total)}` : ''} // total=0 → 条件为 false
</div>
</div>
)
}
arduino
// ✅ 健壮版:使用 ??= 防御
const Progress = ({ text, percentage, total }) => {
percentage ??= 0; // 空值合并赋值,ES2021
return (
<div className="w-full bg-gray-100 text-left rounded-lg overflow-hidden mb-0.5">
<div
style={{ width: `${percentage}%` }} // 一定是数字
className="bg-blue-400 whitespace-nowrap px-1 text-sm"
>
{text} {/* undefined 时不渲染任何内容 */}
{percentage.toFixed(2)}% {/* 安全,因为 percentage 已经赋默认值 */}
{isNaN(total) ? "" : ` of ${formatBytes(total)}`} {/* 用 isNaN 而不是 truthy */}
</div>
</div>
)
}
2.2 ?? vs || --- 面试最爱考的空值处理
这是区分"会用 JavaScript"和"理解 JavaScript"的分水岭:
javascript
falsy 值列表(共 8 个):false, 0, -0, 0n, '', null, undefined, NaN
// || 运算符:检查左边的值是否为 falsy
0 || 100 // → 100 (0 是 falsy)
'' || '默认' // → '默认' (空字符串是 falsy)
null || '默认' // → '默认'
undefined || '默认' // → '默认'
// ?? 运算符:只检查 null 和 undefined
0 ?? 100 // → 0 (0 不是 null/undefined)
'' ?? '默认' // → '' (空字符串不是 null/undefined)
false ?? true // → false (false 不是 null/undefined)
null ?? '默认' // → '默认'
undefined ?? '默认' // → '默认'
真实场景:进度条的百分比为 0 是完全合法的状态!
arduino
// ❌ 错误用法
percentage = percentage || 0; // 0% 变成了 0,看起来像"还没开始"
const width = percentage || 0; // 同上
// ✅ 正确用法
percentage ??= 0; // 只有 null/undefined 才赋默认值
记忆口诀 :
||和??的区别在于对 0 、空字符串 、false 的态度。用??更精确------「我没给你值的时候你再用默认值,我给你 0 你就用 0」。
2.3 formatBytes 函数的算法之美
arduino
function formatBytes(size) {
const i = size == 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024));
return +(size / Math.pow(1024, i)).toFixed(2) * 1 + ["B", "kB", "MB", "GB", "TB"][i];
}
步行追踪这个函数的执行:
ruby
formatBytes(0)
// size == 0 → i = 0
// +(0 / Math.pow(1024, 0)).toFixed(2) * 1 + ["B","kB","MB","GB","TB"][0]
// +(0).toFixed(2) * 1 + "B"
// +0 * 1 + "B"
// 0 + "B"
// "0B"
formatBytes(1536000)
// i = Math.floor(Math.log(1536000) / Math.log(1024))
// = Math.floor(14.245 / 6.931)
// = Math.floor(2.055) = 2 → MB
// +(1536000 / 1024²).toFixed(2) * 1
// +(1536000 / 1048576).toFixed(2) * 1
// +(1.4648...).toFixed(2) * 1
// +"1.46" * 1 // 一元 + 将字符串转为数字
// 1.46
// 1.46 + "MB"
// "1.46MB"
这里用到的对数换底公式:
scss
log_a(b) = log_c(b) / log_c(a)
log₁₀₂₄(size) = Math.log(size) / Math.log(1024)
Math.floor(Math.log(size) / Math.log(1024)) 算出的 i 值就是 size 对应的单位索引:
| size 范围 | i 值 | 单位 |
|---|---|---|
| 0 | 0 | B |
| 1 ~ 1023 | 0 | B |
| 1024 ~ 1048575 | 1 | kB |
| 1048576 ~ 1073741823 | 2 | MB |
| ≥ 1073741824 | 3 | GB |
面试加分:你能讲清楚对数的换底公式在 fileSize 转换中的应用,证你是不是真正理解代码而不是死记硬背。
2.4 为什么 React 中列表需要 key?
javascript
// App.tsx 中遍历下载列表
{
progressItems.map(({ text, percentage, total }, i) => (
<Progress
key={i} // ← 这个 key 是干什么的?
text={text}
percentage={percentage}
total={total}
/>
))
}
没有 key 会怎么样?
想象下载列表从 [model.onnx, tokenizer.json, config.json] 变成 [model.onnx, model2.onnx, tokenizer.json, config.json](在索引 1 位置插入了一个新文件):
css
没有 key(React 按索引比较):
旧:[0] model.onnx [1] tokenizer.json [2] config.json
↓ 相同 ↓ "变了" ↓ "变了"
新:[0] model.onnx [1] model2.onnx [2] tokenizer.json [3] config.json
[2] 变成了新组件 [3] 也是新组件
→ React 更新了 3 个 DOM 节点(实际只需要插入 1 个)
ini
有 key(React 按 key 比较):
旧:key=0 model.onnx key=1 tokenizer.json key=2 config.json
↓ 相同 ↓ 还存在(移到后面) ↓ 还存在(移到后面)
新:key=0 model.onnx key=3 model2.onnx key=1 tokenizer.json key=2 config.json
↑ 新增 ↑ 复用旧组件 ↑ 复用旧组件
→ React 只创建 1 个新 DOM 节点,另外 2 个复用
本项目用 key={i}(索作 key)有没有问题?
在本项目中用索引作 key 基本安全,因为:
- 下载列表是按顺序新增的,不会在中间插入
- 下载完成后列表不再变化
- 没有删除或重排操作
但在一般情况下不推荐用索引作 key,因为插入/删除/排序会导致 key 错位。最佳实践是用唯一 ID(如文件的 URL 或路径)。
面常见追问:"为什么用索引作 key 不好?" → 回答上面的场景即可。你能讲清楚 React 的 reconciliation(协调)算法,说明你理解的不只是 API 用法。
三、App 组件的深剖析
3.1 状态机设计模式
项目中用 status 实现了轻量级状态机:
java
// 状态定义:3 个合法状态
type Status = null | 'loading' | 'ready'
// 状态转换规则
const transitions = {
null: ['loading'], // 初始 → 只能转到 loading
loading: ['ready', 'error'], // 加载 → 可以成功(ready)或失败(error)
ready: [] // 就绪 → 终端状态,不再转换
}
scss
// 代码中的状态转换
<button
onClick={() => {
setStatus("loading"); // null → loading
}}
/>
// 模型加载完成时
setStatus("ready"); // loading → ready
// 出错时
setError("下载失败");
// status 还是 'loading',但 error 不为 null
// UI 会同时显示加载区域和错误提示
当前代码中,loading → ready 的转换还没有实现(需要通过 Transformers.js 下载完成后的回调来触发),但 UI 框架已经准备好了。
把业务状态建模为状态机,有以下好处:
- 状态可预测:不会出现 status = 'ready' 但还在下载进度条的情况
- 扩展容易:加新状态只需要加节点和转换边
- 调试友好:日志中打印 status 就能知道当前在哪一步
3.2 条件渲染的多层嵌套策略
javascript
// App.tsx 中的三层条件渲染
return (
// 第一层:浏览器能力检查
IS_WEBGPU_AVAILABLE ? (
<div>
<div>
{/* 第二层:错误状态检查 */}
{error && (
<div className="text-red-500">
<p>Unable to load model due to the following error:</p>
<p>{error}</p>
</div>
)}
{/* 第三层:按钮状态 */}
<button disabled={status !== null || error !== null}>
Load Model
</button>
</div>
{/* 第四层:加载进度 */}
{status === "loading" && (
<div>
{progressItems.map(...)}
</div>
)}
</div>
) : (
<div>您的浏览器还不支持WebGPU</div>
)
)
这种多层条件渲染的优点是声明性------你不需写"先隐藏 A 再显示 B"这样的命令式代码,而是描述"在不同条件下应该显示什么"。
3.3 disabled 逻辑的布尔代数
yaml
disabled={status !== null || error !== null}
这是一个典型的「禁止按钮在非初始状态点击」的逻辑。用布尔代数来验证:
vbscript
status error status !== null error !== null 结果
------ ----- --------------- -------------- ----
null null false false false ✅ 可点击
loading null true false true ❌ 禁用
ready null true false true ❌ 禁用
null "错误" false true true ❌ 禁用
loading "错误" true true true ❌ 禁用
所有非初始状态(即 status 有值或有 error)都会禁按钮。
四、TailwindCSS 原子类 --- 放弃写 CSS 的勇气
4.1 原子类的思维转变
传统 CSS:
css
/* 需要起名字、写选择器、写规则 */
.my-button {
border: 1px solid #e5e7eb;
padding: 8px 16px;
border-radius: 8px;
background-color: #60a5fa;
color: white;
}
.my-button:hover {
background-color: #3b82f6;
}
.my-button:disabled {
cursor: not-allowed;
}
TailwindCSS:
ini
<button class="border px-4 py-2 rounded-lg bg-blue-400
text-white hover:bg-blue-500
disabled:cursor-not-allowed select-none">
Load Model
</button>
核心转变:
- 不需要起名字 --- 程序员两大难题之一消失了
- 样式和结构同处一地 --- 删组件时不会留下"幽灵 CSS"
- 一致的约束 --- 不会出现 13px、14px、15px 混用的混乱
- AI 友好 --- 自然语言描述样式(蓝色背景、白色文字)直接对应类名
4.2 TailwindCSS v4 的按需生成原理
markdown
1. 开发时:
Vite 的 @tailwindcss/vite 插件扫描你的源代码
→ 发现你用到了 bg-blue-400, text-white, flex...
→ 从预设的 CSS 字典中提取对应的 CSS 规则
→ 只把这些规则编译进最终的 CSS 文件
2. 构建时:
同样的过程 → 最终 CSS 文件只包含你用到的类
→ 整个项目的 CSS 通常只有 3-10KB(gzip 后)
这就是为什么你的 index.css 只有一行 @import "tailwindcss" --- 具体的规则由插件动态生成。
4.3 项目中用到的原子类解读
scss
<div className="flex flex-col h-screen mx-auto items-center justify-end
text-gray-800 bg-white">
逐个翻译:
| 类名 | 对应 CSS | 含义 |
|---|---|---|
flex |
display: flex |
弹性布局 |
flex-col |
flex-direction: column |
垂直排列 |
h-screen |
height: 100vh |
全屏高度 |
mx-auto |
margin-left: auto; margin-right: auto |
水平居中 |
items-center |
align-items: center |
交叉轴居中 |
justify-end |
justify-content: flex-end |
主轴尾部 ➜ 靠底部 |
text-gray-800 |
color: rgb(31 41 55) |
深灰色文字 |
bg-white |
background-color: white |
白色背景 |
结合 justify-end 和底部 margin,整个页面的聊天输入框被推到了屏幕底部,很自然的聊天界面布局。
五、组件树 --- 从 DOM 思维到组件思维
5.1 为什么组件化是必然
想象一个没有组件化的世界,一个 3000 行的 HTML 文件 + 2000 行的 CSS + 1500 行的 JS,维护者离职后新人要花一周时间才能看懂。
组件化把大问题拆成小问题:
scss
页面 = App
├── 标题区
├── 说明文字区
├── 错误提示区 (条件渲染)
├── 按钮区
├── 进度列表区 (条件渲染)
│ ├── Progress #1
│ ├── Progress #2
│ └── Progress #3
└── 聊天输入区 (条件渲染)
每个矩形框 = 一个独立的功能单元 = 一个组件。
5.2 项目的组件关系
scss
App (父组件)
│
┌────────────┼────────────┐
│ │ │
▼ ▼ ▼
按钮 Progress ChatInput
(内置) (子组件) (内置)
× N 个
这个项目中只有一个提取出来的子组件(Progress),这是一个很好的架构判断:
- 不要过度拆分:按钮、文本框这些简单元素不需要单独成组件
- 在需要复用时拆分:Progress 会在列表中重复渲染 N 次,提成组件是自然的
- 在逻辑复杂时拆分:如果 ChatInput 未来加入 emoji、文件上传等功能,再拆也来得及
六、本篇小结
- JSX 不是魔法 ,是
_jsx()函数调用的语法糖,产物是普通 JS 对象(React Element) - React Element ≠ 组件 ≠ DOM 节点,三个概念要分清
??vs||是区分 JavaScript 段位的试金石,进度条场景下??=是唯一正确的选择- key 属性 是 React reconciliation 算法的核心,用索引做 key 只在数据稳定时安全
- 状态机模式 让 UI 状态流转可预测、可扩展、可调试
- 原子化 CSS 改变了写样式的思维方式:不再起类名、不再写选择器、样式删得干净
- 组件化的本质 是把"大问题"拆成"小问题",拆分时机取决于复用需求和复杂度
下一篇我们将真正深入 React Hooks 的内部机制 ------ useState 的闭包陷阱、useEffect 的执行时机、React 18 的自动批处理,这些才是面试中真正能拉开差距的知识点。**