90%前端写React+TS都踩坑!从组件类型、单向数据流到本地存储完整实战

0 阅读6分钟

做企业级React项目不用TS,后期重构直接崩溃。 很多人只会简单写React.FC,但props泛型、合成事件、useEffect副作用、前端持久化存储全是模糊点。

读完这篇你能掌握:

  1. React.FC、interface/type、泛型完整类型约束写法
  2. 父子组件单向数据流标准通信方案
  3. React合成事件TS类型规范(ChangeEvent)
  4. useEffect生命周期与副作用实战场景
  5. localStorage与IndexedDB选型、代码差异、适用场景
  6. 企业级无状态组件最佳实践

image.png 全文代码可直接复制运行,踩坑点全部标注,新手也能看懂。

一、为什么企业项目强制 React + TypeScript

React本身源码就是TS编写,搭配TS是大型项目标配,核心收益3点:

  • 静态类型校验:开发阶段直接报类型错误,不用等到页面运行才崩溃
  • 类型即文档:组件props、入参、返回值自带提示,团队协作零沟通成本
  • 完善高级语法:泛型、接口、类型别名,解决JS弱类型带来的各种隐性bug

无TS项目痛点: 传参拼写错误、事件对象类型混乱、复杂对象无约束、重构时牵一发而动全身。

二、React函数组件核心类型:React.FC 完整解析

2.1 React.FC 底层定义

// React源码简化定义
type FC<P = {}> = FunctionComponent<P>;
interface FunctionComponent<P = {}> {
  (props: P & { children?: ReactNode }): ReactNode;
}

关键点:

  1. FC类型别名,接收泛型P约束props
  2. 组件返回值强制为ReactNode,不能返回普通字符串/数字
  3. 自带可选children属性,不用手动声明

2.2 基础Hello组件实战(Props类型约束)

// 用interface定义组件入参规范
interface HelloProps {
  name: string;
  age?: number; // 可选参数
}

// FC泛型传递Props类型约束
const Hello: React.FC<HelloProps> = ({ name, age }) => {
  return (
    <div>
      <p>你好,{name},今年{age ?? "未知"}岁</p>
    </div>
  );
};

// 使用
<Hello name="张三" age={22} />

2.3 interface vs type 怎么选(高频踩坑)

写法适用场景
interface定义组件props、对象结构、需要扩展属性
type联合类型、交叉类型、简单类型别名

示例区分:

// interface:支持继承扩展
interface User {
  id: number;
  name: string;
}
interface UserInfo extends User {
  address: string;
}

// type:仅做别名,不支持继承
type UserType = {
  id: number;
  name: string;
}

三、React合成事件TS类型(表单开发必用)

原生事件写法类型模糊,React提供内置事件泛型React.ChangeEvent,精准绑定DOM元素类型。

3.1 输入框受控组件标准写法

import { useState } from 'react';

const InputDemo: React.FC = () => {
  const [editingName, setEditingName] = useState("");

  // 泛型指定HTMLInputElement,完整拿到输入框DOM类型
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setEditingName(e.target.value);
  };

  return (
    <input value={editingName} onChange={handleChange} placeholder="请输入名称" />
  );
};

踩坑提醒: 不要直接写(e) => void,丢失DOM元素类型,无法获取target完整属性。

3.2 自定义事件父子通信类型规范

子组件抛出自定义事件,父组件接收,统一约束回调函数类型:

// 子组件Props
interface ChildProps {
  // 自定义回调,参数为输入字符串
  onChangeName: (val: string) => void;
}

const ChildInput: React.FC<ChildProps> = ({ onChangeName }) => {
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    // 向上抛出值
    onChangeName(e.target.value);
  };
  return <input onChange={handleChange} />;
};

// 父组件使用
const Parent = () => {
  const handleUpdate = (name: string) => {
    console.log("子组件传过来的值:", name);
  };
  return <ChildInput onChangeName={handleUpdate} />;
};

四、单向数据流:组件状态分层3个迭代版本(企业标准)

核心原则:父组件持有状态,子组件仅展示,状态通过props下发,修改方法通过自定义事件上抛,也就是UI = fn(props)。

版本1:原始写法(状态分散,可读性差)

子组件私有状态,父子各自维护一份数据,数据不同步,多人协作极易出bug。

// 子组件自己存editingName,父组件无法同步
const Child = () => {
  const [editingName, setEditingName] = useState("");
  const change = (e: React.ChangeEvent<HTMLInputElement>) => {
    setEditingName(e.target.value);
  };
  return <input value={editingName} onChange={change} />;
};

版本2:折中方案(子私有状态,提交同步父)

子组件编辑时操作本地状态,仅提交时同步父组件,适合临时草稿场景。

版本3:最优方案(状态提升,纯展示子组件)

所有状态统一放到父组件,子组件无任何state,纯受控组件,性能更好、数据统一。

// 父组件:唯一状态持有者
const Parent = () => {
  const [editingName, setEditingName] = useState("");

  const handleChange = (val: string) => {
    setEditingName(val);
  };

  return <ChildInput value={editingName} onChange={handleChange} />;
};

// 子组件:无状态,只接收props渲染
interface ChildInputProps {
  value: string;
  onChange: (v: string) => void;
}
const ChildInput: React.FC<ChildInputProps> = ({ value, onChange }) => {
  const inputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    onChange(e.target.value);
  };
  return <input value={value} onChange={inputChange} />;
};

优势:

  1. 全局数据唯一源头,不会出现数据不一致
  2. 子组件无渲染冗余,性能更高
  3. 全局状态统一管理,方便持久化存储

五、useEffect副作用钩子:替代全套生命周期

5.1 useEffect对应三大生命周期

  • 挂载后mounted:依赖数组传[],仅页面初始化执行一次
  • 更新后updated:依赖数组填入监听变量,变量变化执行
  • 卸载前unmounted:函数内部返回清理函数,组件销毁执行

5.2 接口请求标准实战(最常用场景)

页面挂载后请求后端接口,拿到数据渲染列表:

import { useState, useEffect } from 'react';

interface UserItem {
  id: number;
  name: string;
}

const UserList: React.FC = () => {
  const [userList, setUserList] = useState<UserItem[]>([]);

  useEffect(() => {
    // 模拟接口请求
    const fetchUser = async () => {
      const res = await fetch("/api/user");
      const data = await res.json();
      setUserList(data);
    };
    fetchUser();

    // 卸载清理:取消请求,防止组件销毁后setState报错
    return () => {
      console.log("组件卸载,清理副作用");
    };
  }, []); // 空依赖,仅挂载执行一次

  return (
    <div>
      {userList.map(item => (
        <div key={item.id}>{item.name}</div>
      ))}
    </div>
  );
};

踩坑提醒: 依赖数组必须补齐所有内部使用的变量,否则会出现闭包陷阱。

六、前端本地持久化:localStorage vs IndexedDB 完整对比

6.1 localStorage 基础使用代码

限制:仅支持字符串存储,容量约5MB,同步API,阻塞主线程。

// 写入对象必须序列化
const saveUser = (user: UserItem) => {
  localStorage.setItem("user", JSON.stringify(user));
};

// 读取需要反序列化
const getUser = (): UserItem | null => {
  const str = localStorage.getItem("user");
  if (!str) return null;
  return JSON.parse(str);
};

// 删除
localStorage.removeItem("user");
// 清空全部
localStorage.clear();

适用场景:少量配置、token、主题、简单偏好设置。

6.2 IndexedDB 核心特点

  1. 异步API,不会阻塞页面渲染
  2. 大容量存储,支持Blob、二进制、对象直接存储,无需JSON序列化
  3. 支持数据表、索引、条件筛选、分页查询
  4. 适合离线缓存、大量列表、本地文件存储

原生API写法繁琐,项目中统一使用Dexie.js封装简化开发。

6.3 两者核心区别总结

维度localStorageIndexedDB
读写模式同步,阻塞UI异步Promise,不卡顿页面
存储容量约5MB几十MB起,受磁盘配额限制
数据类型仅字符串,对象需序列化原生支持对象、Blob、ArrayBuffer
查询能力仅按键取值,无筛选支持索引、条件、分页查询
上手难度极简,一行调用原生复杂,需封装库

选型建议

  1. 几十KB简单配置 → localStorage
  2. 大量数据、离线页面、存储图片文件、需要筛选数据 → IndexedDB

七、全文核心踩坑汇总(开发高频问题)

  1. 使用React.FC忘记泛型,props无类型约束,编辑器无提示
  2. 合成事件不写React.ChangeEvent<HTMLInputElement>,丢失DOM类型
  3. 父子组件状态不提升,多处维护同一份数据,造成数据不同步
  4. useEffect依赖数组漏写变量,出现闭包旧值bug
  5. localStorage直接存对象,忘记JSON.stringify,读取后变成[object Object]
  6. 大批量数据使用localStorage存储,同步读写导致页面严重卡顿

八、完整实战总结

  1. React企业级项目优先搭配TS,用React.FC+interface约束组件props,类型自文档化
  2. 表单事件统一使用React内置合成事件泛型,规范类型、减少报错
  3. 严格遵循单向数据流,状态统一提升至父组件,子组件做纯UI渲染
  4. useEffect替代class生命周期,处理接口请求、定时器等副作用,记得清理卸载资源
  5. 小体量配置用localStorage,大批量离线数据、文件存储选用IndexedDB