从 ??= 到 onKeyDown:一个 React 组件的“自我修养”

0 阅读8分钟

好的组件像乐高,随便拼;烂的组件像胶水,粘上就撕不下来

上一篇我们聊了 React 合成事件的来龙去脉,顺便拆解了一个 WebGPU 推理应用的完整逻辑。今天这篇,我把目光聚焦到组件设计这件事上——借着项目里 Progress 进度条组件的“进化史”,聊聊如何写出健壮、可复用、让使用者爽的组件。

一切得从一个细节说起:percentage ??= 0 这行代码。


一、那个不起眼的 ??=,暴露了组件设计的“良知”

先来看 Progress 组件的核心代码:

const Progress = ({ text, percentage, total }) => {
    percentage ??= 0;  // 👈 就这一行,看出了封装者的用心
    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}
                {percentage.toFixed(2)}%
                {isNaN(total) ? "" : `of ${formatBytes(total)}`}
            </div>
        </div>
    )
}

??= 是 ES2022 引入的逻辑空值赋值运算符。它的行为很简单:

percentage ??= 0;
// 等价于:
if (percentage === null || percentage === undefined) {
    percentage = 0;
}

那为什么需要这个?

想象一个场景:父组件刚开始加载数据,progressItems 还是空数组,或者某个文件的 percentage 字段压根没传。这时候子组件如果直接拿 undefined 去算宽度,页面可能直接崩,或者进度条变成 NaN%。

// 父组件初始化时,数据可能是这样的
const [progressItems, setProgressItems] = useState([]);
// 或者这样(遗漏了 percentage)
{ text: 'model.onnx', total: 34353543453 }  // 没有 percentage!

有了 ??= 0,不管外面传来什么妖魔鬼怪,percentage 至少是个数字 0。进度条不会炸,页面不会白屏。

一个好的组件,不是使用者用得爽,而是使用者随便用都不会出错。这叫“防御性编程”。


二、State vs Props:数据的两条“河流”

在我们这个应用里,数据有两条清晰的流向,理解它们,是 React 入门到进阶的关键分水岭。

1. State:组件自己的“私人管家”

// App.tsx 中的 state
const [input, setInput] = useState('');
const [status, setStatus] = useState("ready");
const [error, setError] = useState(null);
const [loadingMessage, setLoadingMessage] = useState("开始加载");
const [progressItems, setProgressItems] = useState([]);

State 是组件自己打理的数据,别人动不了,只有组件自己通过 setXXX 函数来修改。

status 从 "ready" → "loading" → "ready",这个过程是 App 组件自己控制的。外部组件(比如 Progress)根本不知道 status 的存在,也不需要知道——这叫“信息隐藏”

2. Props:父组件的“传递手”

// 父组件传递
<Progress
    key={i}
    text={text}
    percentage={percentage}
    total={total}
/>

// 子组件接收
const Progress = ({ text, percentage, total }) => { ... }

Props 是从父组件流向子组件的“只读数据” 。子组件只能读,不能改。

如果子组件想“修改” props 怎么办?答案是:你不能改,但你可以通知父组件来改。通过回调函数(比如 onProgressUpdate),子组件把“我想改”的信号发出去,父组件收到后自己修改 state,然后新的 props 再流下来。

这就是  “单向数据流”  的核心思想:数据永远向下流,事件永远向上冒。

子组件负责展示,父组件负责逻辑。各司其职,互不干扰——这叫“单一职责原则”。


三、formatBytes:一个工具函数的“小确幸”

代码里有个不起眼但很贴心的工具函数:

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]
    );
}

这个函数把字节数格式化成可读的字符串:34353543453 → "32.00 GB"

为什么说它贴心?

因为用户在下载模型时,看到的不再是一串天文数字,而是人类可读的文件大小。这个小细节直接提升了产品的“高级感”——好的用户体验,就藏在这些不起眼的细节里。

在 Progress 组件里,它被这样使用:

{isNaN(total) ? "" : `of ${formatBytes(total)}`}

如果 total 没传(undefined)或不是数字,isNaN(total) 为 true,直接不显示大小。又一层防御。


四、聊天输入框:状态如何“联动”?

来看新增的聊天输入框,它完美展示了 状态如何驱动多个 UI 元素协同工作

<textarea 
    placeholder="Type your message here..."
    disabled={status !== 'ready'}  // 👈 只有模型就绪才能输入
    value={input}
    onInput={(e) => {
        const target = e.target as HTMLTextAreaElement;
        setInput(target.value);
    }}
    onKeyDown={(e) => {
        if (input.length > 0 && e.key === 'Enter' && !e.shiftKey) {
            e.preventDefault();
            onEnter();  // 发送消息
        }
    }}
    title={status === 'ready' ? 'Model is ready' : 'Model is not loaded yet'}
/>

注意这些联动细节:

  1. disabled 联动:只有 status === 'ready' 时输入框才可用。模型没加载完?想打字?门都没有。
  2. title 联动:鼠标悬停时显示当前状态提示,用户体验拉满。
  3. Enter 发送Enter 键发送消息,Shift+Enter 换行——和微信、Discord 一样的交互习惯。
  4. value 受控:输入框的值由 input state 控制,输入事件更新 state,state 再回流到输入框。这就是 React 的“受控组件”模式。

受控组件 vs 非受控组件

模式数据源更新方式适用场景
受控组件React stateonInput 更新 state需要实时校验、格式化、联动
非受控组件DOM 自身ref 获取值简单表单、性能敏感场景

我们这里用的是受控组件,因为需要根据 status 控制 disabled,还要拦截 Enter 键做自定义逻辑。


五、onEnter:事件处理函数的“分水岭”

const onEnter = () => {
    console.log(input);
    // 真实场景:
    // 1. 调用推理 API
    // 2. 显示用户消息
    // 3. 显示 LLM 回复
}

这个函数目前只打印输入内容,但它是一个重要的架构分界线

  • 之上(UI 层):只管展示和交互,不管业务逻辑
  • 之下(逻辑层):处理推理、API 调用、状态管理

把 onEnter 单独抽出来,而不是直接写在 JSX 里,保证了:

  1. 可测试性:可以单独测试 onEnter 逻辑
  2. 可扩展性:后续添加推理逻辑,不影响 UI 代码
  3. 可读性:JSX 里不塞复杂逻辑,一眼看懂

onKeyDown:一个交互细节的“灵魂拷问”

最精彩的部分来了:onKeyDown 事件处理逻辑

onKeyDown={(e) => {
    if (input.length > 0 && e.key === 'Enter' && !e.shiftKey) {
        e.preventDefault();
        onEnter();
    }
}}

我们拆解一下这四个条件:

条件含义为什么这样设计
input.length > 0输入框有内容防止空消息发送
e.key === 'Enter'按下的是回车键触发发送
!e.shiftKey没有同时按下 Shift区分“换行”和“发送”
e.preventDefault()阻止默认行为防止输入框内换行

核心交互逻辑:

  • 单独按 Enter → 发送消息(调用 onEnter()
  • Shift + Enter → 换行(不触发发送,让 textarea 默认换行行为生效)

这就是为什么需要 !e.shiftKey 和 e.preventDefault() ——我们要在 Enter 时阻止换行,而 Shift+Enter 时保留换行。这个交互习惯和微信、Discord 完全一致,用户零学习成本。

好的交互设计,就是让用户感觉“它本来就该这样”。

为什么 preventDefault() 放在条件里面?

因为只有在“发送”场景下我们才想阻止换行;如果是 Shift+Enter,我们希望它换行,所以不能阻止默认行为。条件判断的精确性,决定了用户体验的细腻度。


六、类型断言 as:TypeScript 的“我比你懂”

代码里有一处 TypeScript 特有的写法:

onInput={(e) => {
    const target = e.target as HTMLTextAreaElement;
    setInput(target.value);
}}

这里用了 as 进行类型断言

为什么需要?因为 React 的 onInput 事件对象 e 是 SyntheticEvent 类型,它的 target 属性被定义为 EventTarget 类型,而 EventTarget 并不一定有 value 属性。

// 没有断言的话,TypeScript 会报错:
// Property 'value' does not exist on type 'EventTarget'.
const target = e.target;  // 类型: EventTarget
target.value;  // ❌ 类型错误

用 as HTMLTextAreaElement 告诉 TypeScript:“我确定这个 target 是一个 textarea 元素,你放心吧。

类型断言不是“欺骗编译器”,而是“告诉编译器你掌握了他不知道的信息”。用在刀刃上,能大幅提升开发体验。


七、一个完整的组件设计清单

让我们总结一下,一个健壮的 React 组件应该考虑哪些方面:

检查项本项目实践为什么要这么做
Props 默认值percentage ??= 0防止 undefined 导致 UI 崩溃
Props 类型校验可用 PropTypes 或 TS 接口开发阶段提前发现类型错误
空状态处理progressItems 初始为空数组组件不会因为空数据而报错
条件渲染{error && <ErrorBox />}只在需要时渲染,节省性能
列表 keykey={i}帮助 React 识别列表项变化
禁用状态disabled={status !== 'ready'}防止用户在不可用状态操作
用户提示title 属性让用户知道为什么被禁用
受控组件value + onInput数据和 UI 保持同步

八、总结:组件设计的“三字经”

  1. ??= 防御 props 为空,isNaN 防御计算错误,disabled 防御误操作
  2. :State 管内部,Props 管外部,工具函数单独抽,各司其职
  3. :状态驱动 UI,事件触发状态更新,形成闭环单向数据流

好的组件像一个自动售货机:投币(props)→ 出饮料(UI),内部怎么运作,使用者不需要操心。

烂的组件像一个漏水的水管:外面看着正常,里面全是坑,碰一下就崩。

React 组件设计的本质,就是把复杂度封装在内部,把简洁暴露给外部Progress 组件虽然只有几十行代码,但它完美诠释了这个理念——使用者只需要传 textpercentagetotal,剩下的(格式化、进度条样式、空值处理)全由组件自己搞定。

这种“让使用者爽”的封装思维,才是 React 组件开发的精髓所在。