第三篇:React 组件化开发 — 函数即组件的哲学与实践

第三篇: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>

核心转变:

  1. 不需要起名字 --- 程序员两大难题之一消失了
  2. 样式和结构同处一地 --- 删组件时不会留下"幽灵 CSS"
  3. 一致的约束 --- 不会出现 13px、14px、15px 混用的混乱
  4. 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、文件上传等功能,再拆也来得及

六、本篇小结

  1. JSX 不是魔法 ,是 _jsx() 函数调用的语法糖,产物是普通 JS 对象(React Element)
  2. React Element ≠ 组件 ≠ DOM 节点,三个概念要分清
  3. ?? vs || 是区分 JavaScript 段位的试金石,进度条场景下 ??= 是唯一正确的选择
  4. key 属性 是 React reconciliation 算法的核心,用索引做 key 只在数据稳定时安全
  5. 状态机模式 让 UI 状态流转可预测、可扩展、可调试
  6. 原子化 CSS 改变了写样式的思维方式:不再起类名、不再写选择器、样式删得干净
  7. 组件化的本质 是把"大问题"拆成"小问题",拆分时机取决于复用需求和复杂度

下一篇我们将真正深入 React Hooks 的内部机制 ------ useState 的闭包陷阱、useEffect 的执行时机、React 18 的自动批处理,这些才是面试中真正能拉开差距的知识点。**

相关推荐
胡萝卜术1 小时前
编译期与运行期的双重防线:从 TypeScript 类型之争到 LLM 输出的自动化择优
前端·设计模式·面试
用户852495071841 小时前
Harness 工程:用工程化手段驾驭 LLM,而不是迷信一次回答
面试
Dr.kangder1 小时前
嵌入式面试总结(一)——嵌入式系统实时性
面试·职场和发展·架构·嵌入式·虚拟化
sunly_1 小时前
React Suspense 用法详解
前端·javascript·react.js
何时梦醒2 小时前
TypeScript 类型系统核心:type 与 interface 全方位深度对比(附实战案例)
前端·面试·typescript
书源2 小时前
AI 能写代码之后,前端工程师的价值在哪里?
前端·面试·程序员
退休倒计时2 小时前
【每日一题】LeetCode 88. 合并两个有序数组 TypeScript
算法·leetcode·职场和发展·typescript
吃饱了得干活2 小时前
Java并发安全:看这一篇就懂了!
java·后端·面试
早点睡9752 小时前
向量检索原理入门:从 ANN 到 HNSW
后端·面试