从零开始做应用(7)—— 2025互联网上最好的React 入门教程(下)

60 阅读17分钟

8. React哲学:单向数据流,不变性以及纯函数

在开始用React构建复杂的应用之前,我们需要做一些脑力操练,然后才能把React这把刀用的虎虎生风。

React的核心思想

UI就是组件,而组件就是函数,函数是纯粹的

和DOM中嵌套的HTML元素类似,React中UI就是可以嵌套的组件,而每一个组件都是一个函数,这个函数返回一个JSX对象。我们之前已经知道,JSX仅仅是语法糖,等价于创建一个React元素树。

状态变更产生新的元素树

同样的 props(以及依赖的状态)返回完全相同的元素树。这个结果保持不变。

如果React元素从出生就不再变化,为什么我们有动态的页面呢?那是因为,任何状态的变更,React只需要重新调用组件(记住这是函数)就会得到新的元素树,而这个新的元素树包含了最新的状态。React会比较新的和旧的元素树(虚拟DOM),只更新真正变化的部分。这看起来浪费,但实际上极快。

不是修改已有的元素树,而是利用组件函数改变即新建,这就是React的不变性。元素树负责UI渲染,而状态管理会触发元素树的新建。通过显式的状态管理,我们把状态变更和UI渲染完全隔离开来。

数据通过props单向流动

props就是创建组件时传入的参数,由组件的父组件负责传递进去。这个流动的单向的。如果父组件需要了解子组件的情况,可以使用回调或者更复杂的全局状态管理。但是大部分情况下,我们通过这种单向的限制来控制整体复杂性。这种限制是控制复杂性的规约,而不是阻碍灵活性的绊脚石。

React的核心构造

React把支持内部变更的机制叫做hook。这些构造有一个明显的约定特征是使用use开头。

组件,组件,还是组件

组件是React的核心,而组件代表了UI的结构。你的应用完全由嵌套的组件树构造而成。React提供的简单直接的方式,定义,导出和使用组件。组件中,使用显式状态管理机制,确保了管理状态和最终元素树的分离。

状态管理

无论是用户交互,还是UI的自主变更(如动画),变化则意味着状态的改变。React提供了几个配方,你需要的只是照方抓药:

  • 简单的组件内部状态变更。这是局部感染,吃useState控制
  • 复杂的组件内部状态变更,这是大面积局部感染,吃useReducer消解
  • 父组件的变更需要通知到子组件。这是感染了,使用props,当药传给子组件
  • 子组件变更了需要通知父组件。这是反向感染了,父组件准备一个药盒子,作为props的一部分交给子组件,等拿药通知(回调)
  • 多个组件依赖同一个状态变更。这是局部流行了。需要一起做做操出出汗,把状态提升到共同的父组件,然后等待父组件通过 props 向下发药
  • 多个组件依赖一个局部的状态变更。这是零星散发。需要加速派药。使用useContext直送到家,而不是层层props转发
  • 多个组件依赖于多个状态。这是大面积流行。需要集体防治。使用useContext + useReducer联合才能起效
  • 全局多个状态复杂依赖。这是新冠叠加1908大流感。你需要三方药厂来协助简化处理,如Zustand, Jotai等

药虽好,但不能瞎吃。吃不对,不仅问题不能解决,还带来没必要的复杂性甚至导入更严重的问题。这就考验开方大夫的功力了。我们通过后面的实际开发,让大家都成为合格的React医生。

外部交互管理 —— 副作用

如果可变状态是组件“内部感染”,与外部交互,如从数据源加载数据,推送订阅信息,那就是外源病毒。出于同样的理由(将数据变更与 UI 渲染隔离),React也对这种外部交互提供了显式隔离的猛药:useEffect。React中把外部交互称为副作用(Side Effect)。

中文中“副作用”似乎不是一个好词,但是这里的副作用仅仅指的是与 React 外部系统(如浏览器 API、网络请求、定时器等)的交互。

副作用由三部分组成:

  • 数据依赖清单。清单中的数据发生变化时才触发副作用重新执行。
  • 行为。具体的交互。组件第一次渲染完毕后(也叫挂载),必然执行
  • 清理操作。一个清除外部污染的机会,必须提供,但是可以是空操作。组件最后卸载的时候必执行

组件渲染完成后,副作用按照其声明的顺序依次执行:先执行上一次的清理动作,然后执行本次行为。除了首次执行,后续的执行都是依靠依赖清单中值的变更触发。

这里有一个容易踩到的坑。useEffect这个药必须按固定顺序吃,比如不能放到if语句中(当if条件不满足就吃不到了)。事实上,React要求useEffect必须放在组件的顶层代码中。

因为副作用总是在组件渲染之后这个时间点被调用,就好像哪里设置一个钩子,每次到了就调一下(依赖数据没有变化则跳过),这个机制也被称之为hook。

以下是一个简单的副作用定义。假设一个时钟,需要定期更新UI显示的时间。

useEffect(() => {
    // 副作用行为:创建一个定时器,每 1000 毫秒(1秒)更新一次状态
    const timerId = setInterval(() => { setCurrentTime(new Date()); }, 1000);

    // 副作用清理函数:返回一个清理函数,用于停止定时器
    return () => { clearInterval(timerId); };
    
    // 数据依赖清单:这里是空数组。意味着这个副作用只在组件“挂载”时运行一次,并在“卸载”时清理一次。
}, []);

useEffect的依赖清单写什么?

  1. 数据依赖必须写,这个很容易理解
  2. 使用了组件内定义的函数,不要写,但是把这个函数使用useCallback包装起来,确保其不变
  3. 组件外依赖函数,不要写。这些函数不随着组件的重新构造而变化
  4. useState等管理的状态依赖,通常需要写;但如果要访问最新的值且不需要触发重新渲染时,可以使用 useRef 消除依赖,避免一种称为“闭包陷阱”的问题。

React还有其他的辅助性构造,如性能优化,运行时并发调度等。但是对我们的学习,这些已经足够了。当你真正吃透这三板斧——纯函数组件、显式状态、显式副作用——你写出来的代码就会自带一种味道:干净、好推导、几乎不出bug、性能还好。这就是‘React’风格的代码。

哲学意味的讨论可以了,让我们回到React的高级特性上来。

9. 状态管理的本质和高级状态处理

我们已经使用过了useState,它用来维护单个内部状态。假设有如下组件:

const IncButton = ({}) => {
  const [x, setX] = useState(0);  // 声明一个初始值为0的状态x,使用数组解构
  return <button onClick={() => setX(prevX => prevX + 1)}>{x}</button>;  // 使用函数式更新,确保不可变
};

它被另一个组件使用,这个组件是它的父组件。

组件的生命周期

IncButton的生命周期是:

  1. 父组件的创建触发IncButton的创建
    1. 在React中创建变量x
    2. 创建按钮,这时候x为零,所以显示零
    3. (用户单击按钮)
    4. 触发setX函数
    5. setX往x的更新队列里放入更新请求
    6. setX通知React调度器,变更发生,请求重新渲染
  2. 销毁IncButton

React调度器收到通知后,它会:

  1. 触发“渲染阶段”(Render Phase),开始计算新的虚拟DOM
    1. 遍历组件树,重新构建组件(组件是函数,执行即可)
    2. 在执行中,useState 从队列应用更新,x的值更新为1
    3. 得到一个新的父组件以及子组件IncButton,显示为1
  2. 比较老的虚拟DOM和新的虚拟DOM的区别,发现仅仅有IncButton发生了变化
  3. 进入“提交阶段”(Commit Phase),应用变化,从DOM中摘除原有的值为0的IncButton,加上新的值为1的IncButton,UI可以看到变化了。

需要仔细理解这个过程。彻底弄明白这个,绝大多数所谓的React困难就不复存在。

  • set*函数不直接更新值的内容,学习React的新手在setX之后读取x的值没有变化,会惊奇不已
  • 一个组件是纯的,它不会变化,所以组件内所有的事件处理,外部效应处理都是为下一次的渲染做准备,它准备随时牺牲
  • 更新处理可以是批量的。浏览器事件(如点击)是“原子”的。调度器很聪明,在一个事件处理中,如果更新了多次,最终只会触发一次重新计算和渲染
  • 每次渲染都是全部重新计算,但是仅仅变化的会被替换
  • 变化的组件不是原来的组件,而是全新的组件,同样,它也是纯的,不能改变。

以下代码模拟了React内部的实现,updateQueue是React维护的更新队列。

for (const update of updateQueue) {
  if (typeof update === 'function')
    finalState = update(finalState);
  else
    finalState = update;
}

复杂对象更新

状态的保持可以是一个简单的值,也可以是一个对象。

因为JavaScript的语义,确保在调用set*的时候新建对象而不是修改原有对象,这里有一点挑战。但是如果你深入了解了JavaScript,这都不是问题!

对于对象,假设我们需要把对象的x属性+1,我们需要setX(prev => ({...prev, x: prev.x + 1}))

以下表格综合了常见的情况。注意,这个不是需要背诵的,而是让你对基于已有对象新建的一般模式有一个基本的体会。

操作数组更新(不可变方式)Map/Set 更新(不可变方式)
增加setState([...prev, newItem])setState(new Map(prev).set(key, val))
setState(new Set(prev).add(newItem))
删除setState(prev.filter(i => i.id !== id))setState(new Map(prev).delete(key))
setState(new Set([...prev].filter(x => x !== item)))
修改对象setState(prev.map(i => i.id === id ? {...i, updated} : i))setState(new Map(prev).set(key, {...prev.get(key), updated}))
setState(new Set([...prev].map(x => x === item ? updated : x)))
变更顺序setState([...prev].sort((a,b) => a - b))setState(new Map([...prev.entries()].sort(...)))
setState(new Set([...prev].sort((a,b) => a - b)))

重申以下几点,务必牢记:

  • 组件是纯的,永远不变。如果有变化,那是新组件
  • 状态不属于组件
  • 状态的真正更新是在useState调用的时候
  • 状态的更新可以是批量处理的

useReducer

当一个组件有多个状态,需要响应多个事件(> 3)时,使用useReducer可以简化状态的管理。

和useState返回一个更新state的函数不同,useReducer模拟的是状态机:你传入一个状态集,经过变换后返回新的状态集。useReducer把状态的计算集中到一处,隔离和和其他代码的交互,大大降低了代码的整体复杂度。

状态机是计算机科学中一个非常常见的概念,用于描述一个系统状态的变更。

假设一个计数器,支持增、减、重置操作:

const initialState = { count: 0 };

function reducer(state, action) {
  switch (action.type) {
    case 'increment': return { count: state.count + 1 };
    case 'decrement': return { count: state.count - 1 };
    case 'reset': return initialState;
    default: throw new Error();
  }
}

const Counter = () => {
  const [state, dispatch] = useReducer(reducer, initialState);
  return (
    <>
      Count: {state.count}
      <button onClick={() => dispatch({ type: 'increment' })}>+</button>
      <button onClick={() => dispatch({ type: 'decrement' })}>-</button>
      <button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
    </>
  );
};

这比多个useState更易测试和扩展,尤其在Redux-like逻辑中。

useContext

我们知道,父组件给子组件传递信息的一般方法是通过props。这在绝大多数情况下就足够了,但是有时候,需要嵌套地把一个信息从顶层传递到底层。假设一个场景,我们需要根据用户是否登录来调整组件的显示。这个信息不属于任何组件,我们需要访问这个信息的话,需要从顶层组件一直传递到最底层。这是可耻的冗余,我们需要消除它。更复杂的情况下,每一层可能都要更新这个信息,我们需要取到父组件处理之后的信息。

如果你来设计,怎么做呢?

对于没有变更的情况,这不就是全局可访问的变量么?一个简单的想法是注册一个变量,然后在需要的时候访问它。

useContext提供了一个“上下文”来共享状态。

import { createContext, useContext, useState } from 'react';

// 创建Context
const UserContext = createContext(null);

// 提供者组件(顶层)
function App() {
  const [user, setUser] = useState(null);
  return (
    <UserContext.Provider value={{ user, setUser }}>
      <LoginButton />  {/* 深层子组件,无需层层props */}
    </UserContext.Provider>
  );
}

// 消费者
function LoginButton() {
  const { user, setUser } = useContext(UserContext);
  return user ? <p>Welcome, {user.name}!</p> : <button onClick={() => setUser({ name: 'Alice' })}>Login</button>;
}

但是useContext需要复杂的JSX构造(UserContext.Provider部分),任何变化也会导致所有依赖context的组件的重新渲染。更好的办法是无侵入性的方案,如Zustand。

Zustand是一个轻量级、简洁、高性能的 React 状态管理库,这个词在德语里的意义是“状态”。它使用非常简单,而且保证了组件依赖的部分变化了才重新渲染。

定义状态:

// store.js
import { create } from 'zustand';

export const useCounterStore = create((set) => ({
  count: 0,
  increment: () => set(state => ({ count: state.count + 1 })),
  decrement: () => set(state => ({ count: state.count - 1 })),
  reset: () => set({ count: 0 }),
}));

使用:

import React from 'react';
import { useCounterStore } from './store';

function Counter() {
  const { count, increment, decrement, reset } = useCounterStore();
  return (
    <div>
      <h2>Count: {count}</h2>
      <button onClick={increment}>+</button>
      <button onClick={decrement}>-</button>
      <button onClick={reset}>reset</button>
    </div>
  );
}

异步UI与Suspense:React的新高度

随着 React 18+ 的发展,调度器获得了中断和恢复渲染的能力,极大地提升了用户体验。当一个状态更新导致了耗时的计算(渲染阶段)时,React 可以暂停它,让浏览器先处理用户交互,再继续渲染。

应对并发症状,我们需要两种“高级调度药方”:

  1. useTransition:标识非紧急更新。当用户的操作导致界面更新,如自动补齐,不应该因为获取自动补齐的信息缓慢而导致输入卡顿。可以把自动补齐的逻辑使用startTransition包裹起来,确保交互的流畅

    function SearchInput() {
    const [isPending, startTransition] = useTransition(); // 获取状态和启动器
    const [inputValue, setInputValue] = useState('');
    const [searchQuery, setSearchQuery] = useState('');
    
    const handleChange = (e) => {
        // 紧急更新:输入框必须立即更新,保持流畅
        setInputValue(e.target.value); 
        
        // 非紧急更新:在 Transition 中处理耗时筛选/搜索
        startTransition(() => { 
        setSearchQuery(e.target.value); // 允许被中断和延迟
        });
    };
    
    return (
        <>
        <input value={inputValue} onChange={handleChange} />
        {isPending && <span>正在更新...</span>} {/* 使用 isPending 显示平滑过渡 */}
        </>
    );
    }
    
  2. <Suspense>:数据加载的边界。当数据还为加载完毕的时候,不必等待,渲染一个类似“加载中”的提示,直到数据加载完毕。

    <Suspense fallback={<Spinner />}>
      <AsyncComponent />
    </Suspense>
    

10. 与外部世界的同步 —— useEffect

我们知道,尽管有使用上的差异,useState/useReducer/useContext都是在计算虚拟DOM(渲染阶段)被调用的,确保当前渲染是最新的状态。这些状态的更新都非常快,保证了渲染的速度。

但是如果你想从数据库加载内容,放到这里就不行了。JavaScript把这些操作归类到异步操作,所有的异步操作,其耗时是不可控的。一个加载可能把整个页面卡死不动。

React提供了一个机制,可以在DOM变更完成后,触发这些时间不可控的动作,叫做Effect。useEffect就用于管理非状态的变更:获取数据啦,推动订阅了等等。当Effect完成后,必要的数据准备已经完成,我们只需要更新状态就行,这回触发另一次渲染。

这里有个情况需要注意。我们已经知道组件是每次变更都要新建。如果Effect依赖于组件“挂载”,岂不是每次都会触发?Effect更新触发新的渲染,无穷循环了……

Effect依赖的挂载是真实的DOM挂载事件,而不是组件。我们已经知道,React会比较新旧VDOM的差异,仅仅替换变化的那部分。但是这个替换不一定导致实际DOM的替换:如果一个DOM对象的标签(如div,button等)和key没有变化,React就认为这个对象仅仅是内部微调,直接更新这个对象,而不是完全替换。这时候,useEffect就不会被触发。

这个机制涉及到React的Fiber实现。Fiber是优化的VDOM对比算法,可以保证DOM对象的稳定。

useEffect的使用样例如下:

useEffect(() => {  // 1️⃣
  fetch(`https://example.com/userId=${userId}`)
    .then(user => setUser(user));  // 更新状态,触发下次渲染
  return () => {};  // 2️⃣ 清理函数(可选,用于取消请求等)
}, [userId]);  // 3️⃣ 依赖清单

1️⃣:用于外部操作的回调函数 2️⃣:返回一个用于清理操作的函数,这里为空 3️⃣:依赖清单。当依赖变化时触发调用。没有任何依赖时,仅仅在挂载后调用一次

useEffect 的依赖清单需要特别关注,务必把所有的依赖都列入。

useEffect 的一个现实问题是,实际使用的时候,往往需要同时处理多个不同的状态,如加载过程提示,错误处理等等,这使得实际的代码很复杂。我们使用更先进的库来简化操作,比如swr。

import useSWR from 'swr';

function UserProfile({ userId }) {
  // useSWR 封装了 useEffect, useState, 缓存, 加载和错误处理
  const { data: user, error, isLoading } = useSWR(`/api/users/${userId}`, fetcher); 
  
  if (error) return <div>加载失败...</div>;
  if (isLoading) return <p>Loading...</p>;
  // 数据就绪,组件只负责渲染
  return <h1>{user.name}</h1>; 
}

上面的代码中,一个简单的useSWR就完整封装了加载状态控制,错误处理等多个信息。

11. 跨越渲染的联系 —— useRef

内外状态的管理使用之前的工具强化后,我们就有了一个完整的环境。但是凡事都有例外,为了应对还无法覆盖的特别情况或者在特别的时候临时冲破现有的机制,React提供了一个越狱工具, useRef。

  • useRef可以让你更新一个值,但是不触发渲染,这是对状态管理的补丁
  • useRef可以让你直接访问DOM,这是对React尚未封装的DOM API的捷径

仅仅在非常必要的时候才使用useRef。

以下是useRef的使用样例。

  • DOM访问:焦点管理。

    const inputRef = useRef(null);
    useEffect(() => { inputRef.current?.focus(); }, []);
    return <input ref={inputRef} />;
    

12. 性能优化和自定义的 Hook

性能优化的Hooks ——useMemo与useCallback

在实际项目中,纯函数组件有时会因昂贵的计算或频繁重渲染导致性能瓶颈。React提供useMemo和useCallback来“缓存”结果,避免不必要的重复工作。

  • useMemo:记忆昂贵计算。只有依赖变化时才重新计算,返回缓存值。示例:计算大数组的求和。

    const sum = useMemo(() => expensiveArray.reduce((a, b) => a + b, 0), [expensiveArray]);
    
  • useCallback:记忆函数引用。函数是对象,每次渲染都新生,如果被子组件引用,子组件props变化就不得不重新渲染。useCallback确保只有依赖变化时才创建新函数,避免子组件无谓重渲染。

    const handleClick = useCallback((item) => { /* 处理逻辑 */ }, [item]);
    

这些不是“必须药”,但在列表渲染或深层组件树中,能显著提升性能。记住:过度使用会增加复杂性,只在Profiler工具检测到瓶颈时引入。

自定义hook

有时候,需要联合使用多个hook的话,可以自定义使用。自定义的hook也需要遵循React的约定,使用use作为开始。

function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    setLoading(true);
    fetch(url)
      .then(res => res.json())
      .then(setData)
      .catch(setError)
      .finally(() => setLoading(false));
  }, [url]);

  return { data, loading, error };
}

// 使用
function MyComponent() {
  const { data, loading } = useFetch('/api/users');
  if (loading) return <p>Loading...</p>;
  return <ul>{data?.map(user => <li key={user.id}>{user.name}</li>)}</ul>;
}

自定义Hook让代码DRY(Don't Repeat Yourself),易测试。提示:保持纯净,避免副作用,除非明确。

13. 尾声

读到这里很辛苦,但是非常值得。你以后可能会使用其他框架做开发,但是React给你打好的基础永远不会失效。React还在不断进化,简化表达,提升性能,随时阅读React官方文档是一个好主意。

阅读仅仅是一个方面,实践才是检验学习效果的最后手段。跟着“从零开始做应用”系列开发你自己的React应用吧

2025年实践 checklist

  1. 工具链:用Vite起步(npm create vite@latest),集成TypeScript(类型安全props/state)。
  2. 调试:React DevTools + Performance Tracks(19.2新工具,追踪渲染路径)。
  3. 测试:@testing-library/react模拟用户交互。
  4. 下一步:探索Next.js 15(App Router + React 19),建全栈App。React Foundation(2025新组织)正推动生态统一,继续关注!