构建 React 的思维模型:同步、状态与钩子
了解 React 核心概念背后的原理——如同步、组件、属性、状态和钩子——从而培养直觉,而非死记硬背 API。
如果你已经会写代码,但尚未深入研究 React——或者只是稍作接触——这篇文章正是为你而写的。
刚开始学习 React 时,人们很容易直接着手学习 useState、useEffect、props、hooks 以及一大堆其他 API。你可以掌握语法,写出能运行的代码,却依然无法真正理解React 为何会如此运作。
本文旨在填补这一空白。
我们不会把 React 当成一堆需要记忆的 API,而是会探讨React 设计背后的逻辑以及各部分之间的关联。语法固然重要,但一旦理解其背后的原理,学习起来就会容易得多。因此,我们不会从“如何编写 React 代码”开始,而是先建立“如何思考 React”的思维方式。
我们将讲解组件、属性、状态、渲染、协调机制、钩子、属性传递、上下文以及路由,并将每个概念与其旨在解决的问题联系起来。我们的目标并非列出 React 所有的 API,而是为你提供一种思维模型,让你在遇到这些 API 时能够迅速理解其作用。
你可能听说过,某种巧妙的算法是 React 运行如此快速的原因之一。这听起来几乎像魔法——一个 JavaScript 库究竟是如何如此高效地更新用户界面的?
这种算法被称为协调机制,它是我们理解的起点。
协调机制——让 React 成为可能的算法
从零开始对比两棵 DOM 树并计算出所需的最小变更集,这个问题解决起来大约需要 O(n³) 的时间。
但假设对齐过程能做出一些合理的假设,同时我们再给它一些关于变更可能出现在何处的提示呢?
就这样! 复杂度就会降至大约 O(n) 级别。
这意味着,一种原本需要约 31年 才能完成的任务,只需借助这些假设以及开发者的少许指导,时间就能缩短到接近 16分钟。
等等……提示?这些提示到底是什么,我们又该如何提供它们呢?
不必担心。其实比听起来简单,我们很快就会详细解释。
目前,就让对齐过程在后台自动运行,转而探讨对我们开发者来说真正重要的问题。
那为什么还要费心使用 React 呢?
为何选择 React?
无论别人怎么说,当你从普通的 HTML、CSS 和 JavaScript 文件转向组件、钩子、属性和状态这些概念时,确实需要经历一次真正的思维调整。
与 FastAPI 或 Go 相比,React 真正让人感觉需要付出更大的努力才能掌握。
但关键在于:这种难度主要集中在初期。
一旦你开始深入了解 React,就会发现这些听起来令人生畏的概念其实建立在相当简单的基础之上。
你不必一次性记住整个应用程序的所有内容。
相反,你可以专注于单个组件——它接收哪些数据、需要记录什么信息,以及其渲染输出应如何更新。
正是这种分块思考的方式,使得构建和维护复杂的界面变得可行。归根结底,这正是开发人员真正关心的结果。
编写更易于构建、理解、修改和维护的代码。其目的就是帮助你实现这一目标。
React的四大支柱:—
组件
从本质上讲,组件就是一个JavaScript函数,它接收一个对象作为输入,然后返回用JSX编写的UI内容。
JSX是JavaScript的一种语法扩展,允许你在JavaScript代码中直接嵌入类似HTML的标记。你可以使用纯CSS或Tailwind CSS这样的工具库来为元素添加样式。
任何足够复杂的 React 应用,归根结底都只是组件之间互相传递数据的网络。
即便你从未接触过 React,也能理解组件的工作原理。
const data = {
question: "What is React?",
answer: "A JavaScript library for building user interfaces"
};
function FlashCard(data) {
return (
<div className="border rounded-lg bg-gray-50 p-4">
<h2>{data.question}</h2>
<p>{data.answer}</p>
</div>
);
}
忽略掉一些 React 特有的语法细节后,这其实就是 React 的核心理念。
还不错吧?
从根本上说,我们就是将数据输入函数,然后得到相应的 UI 结果。
因此作为开发者,你的真正任务是在设计简洁组件的同时牢记以下三个问题:
- 它需要接收哪些信息?
- 它会保留哪些信息?
- 它的外观应如何更新?
几个参数和一些变量有什么好复杂的?我们不是可以直接传入所需数据,并将想要存储的内容存在局部变量中吗?
有点像,但并不完全如此。
那所谓界面更新指的是什么?想象有两个销售员试图推销同一辆车,但经理只告诉了其中一人价格变动的消息。
另一个销售员会怎样?他们仍然会报出旧价格,却毫不知情。
现在想象经理在所有人都在使用的群聊中发布更新。只要更改一次价格,整个团队就能立即看到。
这本质上就是 React 被设计出来要解决的问题。
每当影响屏幕显示的某些信息发生变化时,所有依赖这些信息的元素——在我们的类比中即人员,在React中则为组件——都需要一种可靠的方式来察觉并响应这一变化。React提供的正是这样的机制。
Props
function User(name, age, city)和function User(name, city)可以互换吗?那function User(city, name)呢?
依赖位置参数的典型函数只能按特定顺序接受固定数量的参数——请记住,组件本质上也是一种函数。
现在假设你创建了一个用于显示用户姓名和年龄的组件,而它目前被用在代码库中的67个不同位置。如果你的经理要求你同时显示用户的城市信息,那么逐一更新所有使用该组件的地方将耗费大量时间。
但如果函数被设计为让旧的调用保持不变,而新的调用可以选择性地传递额外数据呢?
这时需要的工具就是一个用来替代参数列表的单一对象。React将此称为props。
Props其实就是React将传递给组件的所有数据打包成一个对象的方式。
/** So a component like this one **/
function User({ name = "Guest", age = 18, city = "Unknown" }) {
return (
<div>
<h2>{name}</h2>
<p>{age} years old</p>
<p>{city}</p>
</div>
);
}
/** Can be used in ways like **/
<User /> /** Guest, 18, Unknown **/
<User name="Pritam" /> /** Pritam, 18, Unknown **/
<User name="Rahul" age={21} /> /** Rahul, 21, Unknown **/
<User name="Priya" age={22} city="Delhi" /> /** Priya, 22, Delhi **/
状态
经销商经理应该只自己保留新价格?只告诉一名销售员?告诉两人?还是向经销商里的所有人宣布,包括保安和清洁人员?
想象一个储物箱,你把个人物品放进去。仅通过从外部看这个箱子,你能判断里面的东西是否有变化吗?如果箱子被移到了不同的架子上呢?至少你会注意到它的位置变了,从而推断出发生了什么。
现在把那个箱子想象成 React 用来存储你所定义值的机制。
这意味着需要有一种方式将更新后的值传递给依赖这些值的组件,同时也要让这些组件能够知道它们的值何时发生了变化。
const [count, setCount] = useState(initialCount);
你之前见过这种 useState 的用法吗?
它为组件提供了两样东西:
- count — 当前的状态值。
- setCount — 一个函数,用于请求更新该状态值。
那直接写 value = 5 有什么问题呢?
React 没有办法知道框内的内容发生了变化!
换句话说,你需要一种机制来告诉 React:“嘿,我更新了这个值,你可能需要刷新界面。”
这就是之前提到的提示吗?算是,也不算。
useState 能让你存储值、请求更新,并同时通知 React 变化已经发生。但为什么必须明确告知 React 呢?
因为否则你就会变得像那个粗心的汽车经销商经理一样。
还记得那个实际犯过的错误吗?经理修改了价格,更新了自己的看板副本,却忘了告知其他人。你比他聪明——直接调用 setCount() 就行了。
那么一旦调用 setCount(),实际上会发生什么?
React 会获取新的状态,重新渲染组件以确定当前 UI 应该呈现的样子,然后通过协调机制来确定屏幕上真正需要更改的内容。
你只是告诉 React 有某些东西发生了变化。协调机制则负责确定具体是什么变了,以及因此需要更新哪些内容。
钩子
useState() —— 这是一个内置函数,允许组件保存状态,并提供请求更新该状态的方式。
当该状态被更新时,可能会出现两种情况:
- 新值对渲染结果没有影响 —— React 可能仍会重新渲染,但视觉上不会有任何变化。
具备特殊React功能的函数被称为钩子。还有几种值得了解。
useRef()——当你需要为一个与用户界面完全无关的值创建容器时非常有用。
const count = useRef(0);
count.current++;
因此,一个简单的原则是:如果值的变化也会导致用户界面变化,请使用useState();如果仅需要改变值本身而不产生任何渲染效果,则使用useRef()。
它还有另一个常见用途——引用实际的DOM元素——但目前掌握这个概念就足够了。
useEffect() — 用于在 React 完成 UI 渲染后需要执行某些操作的场景。
useEffect(() => {
console.log("Runs after every render");
});
useEffect(() => {
console.log("Runs once after the initial render");
}, []);
useEffect(() => {
console.log("After the initial render and whenever count changes");
}, [count]); /** Dependencies go here **/
useContext() — 想象这样一个组件树:某个数据如 name 必须经过多层组件才能传递到深层嵌套的 UserName 组件。
再进一步扩展这个树结构,让多份数据在那些根本不会直接使用它们的组件之间传递。
这种模式被称为prop drilling。
React 的 Context API 为解决这一问题提供了方法:它允许你在树结构的更深处获取数据,而无需手动将数据传递给中间的每一个组件。
const ParentContext = createContext(null)
<ParentContext value={money}>
<ChildComponent />
</ ParentContext>
function ChildComponent() {
const money = useContext(ParentContext);
return <p>Money: {money}</p>;
}
除此之外还有许多其他钩子,值得你自己去尝试使用。
到目前为止,讨论的内容都是关于React如何组织组件以及数据如何在它们之间传递。但一个真正的应用还需要决定该界面的哪些部分应该显示在哪些URL下。
React Router
React允许你使用能够自动更新的组件来构建界面,而无需强制浏览器重新加载整个页面。但一个真正的应用通常需要多个页面,这就引出了一个新的问题。
当你的应用需要多个不同的视图时会发生什么?
你可能希望实现类似以下的结构:
/home → 主页
/dashboard → 仪表板
/profile → 个人资料页
如果使用普通的 HTML `` 标签来连接这些元素,点击它们就会触发完整的浏览器导航。整个页面会被清除,应用会从新地址重新启动。
实际上你希望的是在 React 自动判断出哪些组件需要替换的同时更新 URL,而无需丢弃其他所有内容。
这正是 React Router 所解决的问题。
可以将其视为一层负责将 URL 映射到组件的机制。
例如:
<BrowserRouter>
<Routes>
<Route path="/home" element={<Home />} />
<Route path="/dashboard" element={<Dashboard />} />
</Routes>
</BrowserRouter>
React Router 会检查当前的 URL,并渲染与之关联的组件。BrowserRouter 利用浏览器的 History API 处理客户端端的导航,而 Routes 则会选出与当前路径最匹配的 Route。
即便如此,仍然存在另一个需要处理的复杂问题。
如果不想让整个屏幕重新渲染怎么办?
想象这样的布局:
┌─────────────────────────────┐
│ Header │
├──────────┬──────────────────┤
│ │ │
│ Menu │ Page content │
│ │ │
├──────────┴──────────────────┤
│ Footer │
└─────────────────────────────┘
从/home跳转到/dashboard时,头部、侧边栏或底部不应消失后又重新出现。只有主要内容区域需要变化。
这正是嵌套路由和<Outlet />存在的意义。
function Layout() {
return (
<>
<Header />
<Menu />
<Outlet />
<Footer />
</>
);
}
这样,这些路由就可以相互嵌套:
<Routes>
<Route element={<Layout />}>
<Route index element={<Home />} />
<Route path="dashboard" element={<Dashboard />} />
</Route>
</Routes>
通过这种设置,Layout会保持挂载状态,React Router会替换<Outlet />中匹配的子路由。根据React Router的官方文档,<Outlet />标识了要渲染匹配子路由的位置。
index 路由将 <Home /> 映射到 / 路径,因此当没有更具体的路径被激活时,它就会成为 outlet 中显示的默认视图。
所以从 /home 转到 /dashboard 并不是要完全替换整个页面,更准确地说应该是:
“保持界面中的这一部分不变,仅将这一区域替换为属于新路由的组件。”
接下来该做什么?
一旦你充分理解了 React 旨在解决的难题以及它用来解决这些问题的机制,就有必要花时间阅读真实的代码库,了解经验丰富的团队是如何构建他们的应用程序的。
寻找一些能够展示大型生产级 React 应用程序如何进行组织与架构设计的代码仓库。
不要试图一次性掌握整个代码库,而是选择某个功能,逐层深入研究应用程序的架构。注意组件的分类方式、数据的来源、状态的管理方式,以及应用程序各部分之间的交互机制。
另外,找一些展示React 如何与 Redux 结合并形成可用应用程序的示例也是很有价值的。
如果找到的项目比较老旧,不要将其模式视为当前 React 的标准。应利用它来了解大型应用程序如何被拆分,以及 Redux 如何融入这种架构中。
一旦你能够熟练掌握典型的 React 文件结构,并能独立创建简单的组件,下一步就是学习如何构建能够在大规模场景下稳定运行的应用程序。
扩展 React 应用会带来一系列新的问题,包括:
- SSR 与服务器组件 —— 当应用的部分功能在服务器上运行而非完全在浏览器中运行时,其行为会发生怎样的变化。
- 状态管理 —— 当应用的状态变得过于庞大或被过度共享,以至于本地状态和 Context 无法有效处理时该如何应对。Redux 是众多解决方案之一。
- 数据获取与缓存 —— 实际应用如何处理加载指示、错误处理、缓存机制,以及保持客户端数据与服务器的同步。
- 性能优化 —— 如何判断何时渲染真正成为瓶颈,并有针对性地进行优化,而非一开始就对所有方面都进行优化。
在开始开发之前,你无需掌握所有这些内容。
更实际的方法是先开始编码,遇到具体问题后再去学习解决该问题的相关概念或工具。
归根结底,学习 React 的目的从来不是记住它的 API 接口,而是理解这些 API 为何存在,以及如何运用它们来解决相应的问题。
相关阅读
- 了解 React 自定义钩子:无需共享状态即可复用逻辑 — 了解什么是 React 自定义钩子,它们如何在不同组件之间提取和共享带状态的逻辑,以及在构建时需要避免哪些常见错误。
- React 设计模式:从经典面向对象到现代钩子 — 阐述了 Singleton、Factory、Observer 等经典软件设计模式在 React 中的应用,以及 HOC、Hooks、复合组件等 React 特有的设计模式。