从一个名字编辑组件开始,我把 React + TS 的数据流和副作用彻底搞明白了

说实话,最开始学 React + TypeScript 的时候,我是拒绝的。

不就是写个组件吗,JavaScript 写得好好的,加个 .tsx 后缀,整个世界都变了。一个简单的 input 框,我得给 event 写类型;一个 props 传值,我得先定义个 interface;连个组件函数本身,都得套个 React.FC

最崩溃的是那天,我就想写一个"编辑名字"的小功能------上面显示名字,下面一个输入框加个按钮,点提交更新名字。就这么点事儿,我写了三版才写明白,中间 TS 报错报得我头皮发麻。

后来回头看,这三版代码刚好把 React 里最核心的几个概念串起来了:类型约束、单向数据流、状态提升、副作用......今天就顺着这个路子,把我当时怎么一步步踩坑、怎么想明白的,原原本本讲给你听。

第一版:把 event 直接甩给父组件,爽是爽,就是类型满天飞

最开始我是这么想的:父组件管状态,子组件就是个输入框,用户输入啥,我就把 event 传给父组件,父组件自己去拿 event.target.value

听起来没毛病对吧?我当时也觉得挺合理的。

代码大概长这样:

tsx 复制代码
// 父组件
import * as React from 'react';
import Hello from './components/Hello';
import NameEditComponent from './components/NameEditComponent';

const APP: React.FC = () => {
  const [username, setUserName] = React.useState('initialName');
  
  const setUserNameState = (event: React.ChangeEvent<HTMLInputElement>) => {
    setUserName(event.target.value);
  }

  return (
    <div>
      <Hello userName={username}/>
      <NameEditComponent 
        userName={username}
        onChange={setUserNameState}
      />
    </div>
  )
}

子组件更简单,就是个纯展示的 input:

tsx 复制代码
// 子组件
import * as React from 'react';

interface Props {
    userName: string;
    onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
}

const NameEditComponent: React.FC<Props> = (props) => {
    return (
        <div>
            <label>Update Name:</label>
            <input value={props.userName} onChange={props.onChange}></input>
        </div>
    )
}

跑起来没问题,功能也实现了。但我越看越别扭 ------

父组件凭啥要知道 React.ChangeEvent<HTMLInputElement> 这种东西?父组件的职责不就是 "持有状态、修改状态" 吗?现在倒好,父组件里出现了 DOM 事件的类型,子组件是什么 input 还是 textarea,父组件都得知道。

这就好比你去餐厅吃饭,服务员把后厨的菜刀直接递给你,说 "来,你自己切"。菜是能吃上,但总觉得哪里不对。

而且你发现没,React.ChangeEvent<HTMLInputElement> 这个类型,父子组件两边都得写一遍。以后子组件要是换成 textarea,两边都得改。这耦合度,想想就头疼。

我当时踩的坑最开始我连 React.ChangeEvent 都不知道写,直接写了个 Event,TS 直接给我报红,说 target 上没有 value 属性。我懵了半天,后来才反应过来 ------React 里的事件是 "合成事件",不是浏览器原生的那个 Event,得用 React 自己提供的类型。

等等,先插一句:React.FC 到底是个啥?

说到类型,我得先聊聊 React.FC 这个东西。最开始我就是照着教程抄,写组件就加个 React.FC,也不知道为啥要加。

后来我去翻了一眼 React 的类型定义,才发现它其实就是个泛型类型别名:

typescript 复制代码
type FC<P = {}> = FunctionComponent<P>;

再往里看,FunctionComponent 本质上就是个函数,接收 props,返回一个 ReactElement。说白了,它就是给 "函数组件" 这个东西上了个类型约束。

泛型这个东西,你可以理解成 "类型的参数"。就像函数的参数是传值的,泛型是传类型的。React.FC<Props> 意思就是:这是个函数组件,它的 props 类型是 Props

Props 又是啥?一般用 interface 来定义:

tsx 复制代码
interface Props {
    userName: string;
}

interface 你就把它想成一张 "清单"------ 这个组件的 props 里必须有啥、每个东西是什么类型,全列在上面。组件要是没按清单来,TS 直接给你报红。

说到 interface 和 type 的区别,我也纠结过一阵。后来我给自己定了个简单的规矩:描述对象的形状(比如 props、state)就用 interface,其他情况用 type。组件的 props 天然就是个对象,所以用 interface 就对了。

好了,类型的事儿先聊到这,咱们回到那个名字编辑组件。

第二版:子组件自己管自己的状态,父组件终于清净了

既然把 event 传给父组件那么别扭,那我换个思路 ------ 输入框那点事儿,子组件自己搞定不就行了?

子组件内部维护一个 "正在编辑的名字",用户输入的时候子组件自己改自己的状态,点提交的时候,只把最终的字符串值传给父组件。

这样父组件就只需要知道 "哦,新名字是个字符串",根本不用管你是 input 还是 textarea,更不用管什么 event 类型。

来看看代码:

tsx 复制代码
// 子组件 - 自己管理编辑状态
import * as React from 'react';

interface Props {
    initialName: string;
    onNameUpdate: (newName: string) => void;
}

const NameEditComponent: React.FC<Props> = (props) => {
    // 表单状态自己打理,不麻烦父组件
    const [editingName, setEditingName] = React.useState(props.initialName);
    
    const onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
        setEditingName(event.target.value);
    }
    
    const onNameSubmit = () => {
        // 提交的时候只传值,父组件根本不知道 event 是什么
        props.onNameUpdate(editingName);
    }
    
    return (
        <>
            <label>Update Name:</label>
            <input value={editingName} onChange={onChange}></input>
            <button onClick={onNameSubmit}>Submit</button>
        </>
    )
}

父组件这边就清爽多了:

tsx 复制代码
// 父组件
const APP: React.FC = () => {
  const [username, setUserName] = React.useState('initialName');

  return (
    <div>
      <Hello userName={username}/>
      <NameEditComponent 
        initialName={username}
        onNameUpdate={setUserName}
      />
    </div>
  )
}

你看,父组件的 onNameUpdate 就是个 (newName: string) => void,干干净净。DOM 事件的复杂性全被子组件封装在内部了。

当时我觉得这版就挺完美的了。父组件管 "真正的状态",子组件管 "编辑过程中的临时状态",各司其职。

但后来我又遇到了新问题 ------

如果有两个子组件都需要用到这个 "正在编辑的名字" 呢?比如左边一个实时预览,右边一个输入框,两边都要显示 editingName。那 editingName 放在子组件里就不行了,因为另一个子组件拿不到啊。

这时候就轮到 "状态提升" 出场了。

第三版:状态提升到父组件,子组件变成纯展示

状态提升这个词,听起来挺玄乎的,其实道理特别简单 ------哪个状态需要被多个组件共享,就把它放到它们共同的父组件里去

就像家里的零食,你一个人吃,放你自己抽屉里就行;但如果兄弟姐妹都要吃,就得放到客厅的公共柜子里。

好,那我们把 editingName 也提到父组件去:

tsx 复制代码
// 父组件 - 管理所有状态
import * as React from 'react';
import HelloComponent from './components/Hello';
import NameEditingComponent from './components/NameEditingComponent';

const App = () => {
    // 真正的名字
    const [name, setName] = React.useState<string>('defaultUserName');
    // 正在编辑的名字 - 提升到父组件了
    const [editingName, setEditingName] = React.useState('defaultUserName');

    const loadUserName = () => {
        setTimeout(() => {
            setName('name from async call');
            setEditingName('name from async call');
        }, 2000)
    }

    React.useEffect(() => {
        // 组件先挂出来,让用户看到东西
        // 然后再去加载数据,体验上更快
        loadUserName();
    }, [])

    const setUserNameState = () => {
        setName(editingName);
    }

    return (
        <>
            名字:{name}
            <HelloComponent userName={editingName}/>
            <NameEditingComponent 
                editingName={editingName}
                onNameUpdated={setUserNameState}
                onEditingNameUpdated={setEditingName}
                disabled={editingName === "" || editingName === name}
            />
        </>
    )
}

子组件这边就更简单了,完全没有自己的状态,纯靠 props 吃饭:

tsx 复制代码
// 子组件 - 纯展示,没有内部状态
import * as React from 'react';

interface Props {
    editingName: string;
    onNameUpdated: () => void;
    onEditingNameUpdated: (newEditingName: string) => void;
    disabled: boolean;
}

const NameEditComponent: React.FC<Props> = (props) => {
    const {
        editingName,
        onEditingNameUpdated,
        onNameUpdated,
        disabled,
    } = props;

    const onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
        onEditingNameUpdated(event.target.value);
    }

    const onNameSubmit = () => {
        onNameUpdated();
    }

    return (
        <>
            <label>Update Name:</label>
            <input 
                value={editingName}
                onChange={onChange}
            ></input>
            <button 
                disabled={disabled}
                onClick={onNameSubmit}
            >Submit</button>
        </>
    )
}

你看,子组件现在就干一件事:给我啥我显示啥,你点按钮我就通知你。它自己不存任何状态。

这种组件有个名字,叫 "受控组件"------ 它的一切都被父组件控制着,父组件传什么值,它就显示什么值。

对应的,第二版那种自己管状态的叫 "非受控组件"。

那到底用哪个?我后来总结了个简单的判断方法:

  • 这个状态只有它自己用?→ 放子组件里,非受控,简单
  • 这个状态别的组件也要用?→ 提升到父组件,受控,共享方便

没有绝对的好坏,看场景。

顺着这个组件,我搞懂了单向数据流

这三版代码写下来,我突然就理解了 React 一直说的 "单向数据流" 到底是啥意思。

以前看官方文档,"单向数据流" 这五个字,每个字都认识,放一起就懵。现在回头看,其实就是一句话:

数据从上往下流,事件从下往上传。

父组件把数据通过 props 传给子组件(往下流),子组件要改数据,就触发一个事件通知父组件(往上传),父组件自己改自己的 state,改完了再通过 props 传下来。

就这么一个循环,永远是单向的。子组件不能直接改父组件的 state,必须通过事件通知父组件来改。

你可以把它想象成一个公司的组织结构:

  • 老板(父组件)掌握核心数据(state)
  • 老板把任务和资料下发给员工(props 向下传)
  • 员工做完了,打个报告给老板(事件向上传)
  • 老板看了报告,决定要不要改数据(setState)
  • 改完了再把新资料发下去

员工永远不能直接改老板的数据库,必须走汇报流程。这就是单向数据流。

为什么要这么设计?因为简单、可预测。如果谁都能改数据,改来改去你都不知道是谁改的,出了 bug 排查起来想死。单向数据流就一条路,数据从哪来、怎么变的,清清楚楚。

聊完数据流,再说说 useEffect 这个让人又爱又恨的东西

刚才第三版代码里有这么一段:

tsx 复制代码
React.useEffect(() => {
    loadUserName();
}, [])

这个 useEffect,我最开始学的时候也是一头雾水。啥叫 "副作用"?好好的函数组件,怎么就有副作用了?

后来我想了个类比,觉得挺贴切的:

函数组件的本职工作是 "根据 state 和 props 渲染 UI",这是 "主作用"。除此之外的所有事情,都是 "副作用"。

比如:

  • 发请求拿数据 → 副作用
  • 操作 DOM → 副作用
  • 设个定时器 → 副作用
  • 往 localStorage 存东西 → 副作用

这些事儿都不是 "渲染 UI" 本身,但又跟 UI 有关联,所以叫 "副作用"------ 附带产生的作用。

useEffect 的四种打开方式

我当时写了个 Todo List 的 demo,把 useEffect 的几种写法都试了一遍,终于搞明白了。

tsx 复制代码
const App = () => {
  const [count, setCount] = useState(0);
  const [todos, setTodos] = useState([]);

  // 写法一:不传第二个参数
  useEffect(() => {
    console.log("每次渲染都执行");
  });

  // 写法二:传空数组
  useEffect(() => {
    console.log("只在挂载后执行一次");
  }, []);

  // 写法三:传具体依赖
  useEffect(() => {
    console.log("count 变了才执行");
  }, [count]);

  // 写法四:带清理函数
  useEffect(() => {
    const interval = setInterval(() => {
      console.log('Interval is here');
    }, 1000);
    return () => {
      console.log('组件卸载前执行,清理工作');
      clearInterval(interval);
    }
  }, [])

  // ...
}

这四种写法,其实对应了类组件里的三个生命周期:

  • 空数组 []componentDidMount(挂载后)
  • 不传第二个参数 → componentDidMount + componentDidUpdate(每次更新都跑)
  • 传具体依赖 → 依赖变了才跑,相当于 "监听" 某个状态
  • return 一个函数 → componentWillUnmount(卸载前)

我当时觉得最神奇的是,就这么一个 hook,把类组件三个生命周期全给替代了。而且还更灵活 ------ 你可以写多个 useEffect,每个管一件事,不用全堆在一个生命周期函数里。

我踩过的坑:依赖数组

useEffect 最容易踩坑的地方就是那个依赖数组。

我刚开始写的时候,经常漏写依赖。比如我想在 todos 变化的时候存到 localStorage:

tsx 复制代码
// 我最开始的写法 - 错的
useEffect(() => {
  localStorage.setItem('todos', JSON.stringify(todos));
}, []); // 我以为这样就只存一次

结果呢?todos 都更新了,localStorage 里还是旧数据。我调试了半天才反应过来 ------ 你依赖数组里不写 todos,它怎么知道 todos 变了?

正确写法:

tsx 复制代码
useEffect(() => {
  localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]); // todos 变了就重新存

一个简单的原则useEffect 里用到了哪些 state 或 props,依赖数组里就得写上。别偷懒,不然肯定出 bug。

当然也有例外,比如你就想在挂载时执行一次,里面用到的 state 就是初始值,那空数组也没问题。但这种场景其实不多,大部分时候你都得老老实实写依赖。

另一个坑:内存泄漏

还有一个坑,就是清理函数。

我写过一个 Demo 组件,里面设了个定时器,然后在父组件里根据 count 的奇偶来决定显不显示它:

tsx 复制代码
{count % 2 === 0 && <Demo />}

Demo 组件里有个 setInterval,每秒打印一次。最开始我没写清理函数:

scss 复制代码
// 错误示范 - 没有清理
useEffect(() => {
  setInterval(() => {
    console.log('Interval is here');
  }, 1000);
}, [])

结果你猜怎么着?组件卸载了,定时器还在跑。count 变成奇数,Demo 组件没了,但控制台还在一秒一秒地打印。

这就是内存泄漏 ------ 组件都没了,但它占的资源(定时器、订阅之类的)还没释放,内存永远收不回来。

正确写法是 return 一个清理函数:

tsx 复制代码
useEffect(() => {
  const interval = setInterval(() => {
    console.log('Interval is here');
  }, 1000);
  
  return () => {
    // 组件卸载前执行,打扫卫生
    clearInterval(interval);
  }
}, [])

React 会在组件卸载前调用这个 return 的函数,帮你把烂摊子收拾干净。

说真的,这个坑我掉进去过不止一次。每次写定时器、写订阅、写事件监听,我都得提醒自己:别忘了清理

顺手聊聊 localStorage

说到存数据,就聊聊 localStorage 吧。

前端本地存储这玩意儿,我最早接触的时候觉得挺神奇的 ------ 浏览器居然能存数据,关了页面再打开还在。

它的用法特别简单,就两个核心方法:

javascript 复制代码
// 存
localStorage.setItem('key', 'value');

// 取
localStorage.getItem('key');

但有个要注意的地方 ------它只能存字符串

你要是想存个对象、数组啥的,得先用 JSON.stringify 转成字符串:

tsx 复制代码
// 存的时候转字符串
localStorage.setItem('todos', JSON.stringify(todos));

// 取的时候转回来
const savedTodos = JSON.parse(localStorage.getItem('todos') || '[]');

我初始化 todos 的时候就是这么写的:

tsx 复制代码
const [todos, setTodos] = useState(() => {
  return JSON.parse(localStorage.getItem('todos')) || [];
});

useState 里传个函数,这叫 "惰性初始化"------ 只有第一次渲染的时候才会执行这个函数,后面重新渲染不会再跑。性能更好一点。

注意容量localStorage 大概只有 5M 左右的空间,别啥都往里塞。存点配置、用户偏好啥的还行,真要存大量数据,得用 IndexDB 那种浏览器数据库。

回头看,这一路我搞懂了啥

从一个名字编辑组件开始,写到第三版,再到 Todo List 里折腾 useEffect 和 localStorage,这一圈下来,我觉得有几个点是最关键的:

第一,类型约束不是负担,是保护。

最开始写 TS,我觉得全是废话 ------ 我自己传的 props 我还不知道是什么类型吗?但写多了就发现,TS 真的能帮你挡掉很多低级错误。拼写错了 prop 名、传错了类型、漏了必填字段,TS 直接在你写代码的时候就给你报红,比跑起来再 debug 快多了。

特别是大项目、多人协作的时候,类型就是最好的文档。你看一个组件的 interface,就知道它要接收什么、返回什么,不用去读实现代码。

第二,单向数据流是 React 的灵魂。

数据往下传,事件往上传,这个规矩看起来笨,但真的能让你的代码逻辑清晰很多。出了问题,顺着数据流往上找,一定能找到根源。比起 "谁都能改数据" 的混乱模式,单向的代价是多写几行代码,但换来的是可维护性的巨大提升。

第三,useEffect 的本质是 "同步"。

我后来想明白了,useEffect 不是 "生命周期",而是 "同步副作用到状态"。状态变了,副作用也要跟着变,保持同步。依赖数组就是在说 "哪些状态变了的时候,我需要重新跑这个副作用"。

这么理解的话,依赖数组就不是什么 "需要记住的规则",而是自然而然的事情 ------ 你用到了什么状态,就得在它变化的时候重新同步。

最后说两句

当然了,这些东西也不是万能的。

比如状态提升,也不是提得越高越好。什么状态都往最顶层扔,最后顶层组件变成了 "上帝组件",啥状态都管,啥逻辑都有,一样难维护。状态提升到 "刚好能被需要共享的组件们访问到" 的层级就行,别过度设计。

再比如 useEffect,也不是啥都得往里塞。有些逻辑放在事件处理函数里更合适,没必要为了用 hook 而用 hook。

学习这事儿吧,我觉得就是这样 ------ 先跑起来,再慢慢优化。别一开始就追求 "最佳实践",写着写着你自然就知道哪里别扭、哪里需要改了。我那个名字编辑组件,也是写了三版才找到舒服的写法。

如果你也在学 React + TS,看到这儿有啥想法,或者你有别的理解,欢迎评论区聊聊。搞懂了记得回来留个言,我也想看看你的思路是不是跟我一样。

相关推荐
程序员黑豆2 小时前
Java入门第一步:从零开始编写你的第一个Hello World程序
java·前端·ai编程
凌涘2 小时前
前端路由(二):React Router 组件化路由实战
前端
Darling噜啦啦2 小时前
useRef 的三个段位:从 DOM 引用到 Web Worker,彻底搞懂 React 的"非响应式"利器
react.js
鸽鸽2 小时前
别急着装第三方库,这些事浏览器原生就能做到——Web Native API 实战指南
前端
做前端的娜娜子2 小时前
从 0 到 1 打造一个配置可视化后台:一次前端配置中心化改造的实战复盘
前端·设计模式·数据可视化
南雨北斗2 小时前
vue3 RouterLink链接颜色修改的方法
前端
Richard.Wong2 小时前
Windows IIS 服务器部署 Vue3 前端项目详细流程
服务器·前端·windows
数据知道2 小时前
XSS 攻防全解:反射型、存储型、DOM 型实战演示
前端·安全·web安全·网络安全·xss
Nemo_XP2 小时前
C# gridlookupedit选中内容重复还原操作
服务器·前端·c#