[Opinion]今天的前端很容易被搞乱,我们需要进行整理
Source: Dev.to
免责声明: 这篇文章混合了对当代前端复杂性的抱怨以及我对如何解决它的想法。我已经离开前端/React 世界有一段时间了,所以文章中可能包含一些过时的想法。如果在阅读时发现此类想法,请告诉我。虽然我这里只讨论 React/Next.js,但我认为该论点可以扩展到整个前端生态系统,无论使用何种框架。因此,我将在整篇文章中交替使用 frontend 和 React 这两个词。
Source:
React 真是太复杂了
最近,我参与了一个任务,需要开发一个前端使用 Next.js 构建的应用。虽然我之前做过很多需要 React 和 Next.js 的任务,但那些时候我并不需要处理设计部分;我只是把后端的数据传递给 React 组件进行展示。这是我第一次必须认真考虑视觉设计——从布局到一个 “ 中小段文字的字体颜色——说实话,真的超级困难!
为什么?
这不仅仅是因为 MDN 上大量的 CSS 文档让人眼花缭乱(虽然这也有帮助)。更难的部分是 写出凌乱的 React 组件非常容易。所谓“凌乱的代码”,指的是那些目的或职责难以辨认,行为和状态更新难以追踪的组件。
在你指责我技术不足之前,想想你最近看到的 React/Next.js 代码。如果想不起来,可以浏览一下 官方 React 文档首页 的高级主题。例如,下面的摘录取自关于 管理输入和状态 的页面:
export default function Form() {
const [answer, setAnswer] = useState('');
const [error, setError] = useState(null);
const [status, setStatus] = useState('typing');
if (status === 'success') {
return
没错!
; }
async function handleSubmit(e) { e.preventDefault(); setStatus(‘submitting’); try { await submitForm(answer); setStatus(‘success’); } catch (err) { setStatus(‘typing’); setError(err); } }
function handleTextareaChange(e) { setAnswer(e.target.value); }
return ( <>
城市测验
在什么城市有一个广告牌可以把空气转化为可饮用的水?
function Quiz() {
const [answer, setAnswer] = useState('');
const [submitted, setSubmitted] = useState(false);
const [error, setError] = useState(null);
const handleChange = (e) => {
setAnswer(e.target.value);
};
const handleSubmit = async () => {
try {
await submitAnswer(answer);
setSubmitted(true);
} catch (err) {
setError(err);
}
};
return (
<div className="quiz-container">
<h2>City quiz</h2>
<p>In which city is there a billboard that turns air into drinkable water?</p>
<input
type="text"
value={answer}
onChange={handleChange}
placeholder="Your answer"
/>
<button onClick={handleSubmit}>Submit</button>
{error !== null && (
<div className="error-message">
{error.message}
</div>
)}
</div>
);
}
即使这个例子相对简单,它已经包含了:
- 三个状态变量
- 嵌套的 HTML 元素
- 两个事件处理函数
- 条件渲染
为什么看起来如此直接的东西会让人觉得复杂?
我认为在 React(以及前端整体)中存在一个难以克服的根本性问题:一个 React 组件必须在 JSX 中呈现来自受控状态的信息,而 JSX 本质上是 “更好用” 的 HTML 版本。它听起来很自然,但也引入了一个根本性的张力。
前端 = 状态 + 层级呈现
任何前端技术——无论是网页还是移动端——本质上都是在管理当前状态(无论在客户端还是服务器端)并在层级视图中渲染它。层级结构会带来若干挑战,例如:
- 布局 – 兄弟 HTML 节点之间如何关联?一个
<select>应该包含多少子元素?我们如何处理响应式设计? - 状态管理 – 是在一个地方获取所有数据并分发给多个组件,还是让每个小组件自行获取数据?我们如何处理更新和重新渲染?数据获取应该在父组件中进行还是在子组件中进行?
由于状态管理和布局紧密耦合,而 UI 必须保持层级结构,复杂度(或“混乱”)会迅速升级。
考虑之前的 Form 示例。假设我们想把 <input> 替换成 <select>,让用户可以从后端动态获取的列表中选择答案。一个自然的第一步可能是直接在同一个 Form 组件内部添加 useEffect:
export default function Form() {
// …
const [countries, setCountries] = useState([]);
useEffect(() => {
async function fetchCountries() {
try {
const response = await fetch('GET_COUNTRY_LIST_API');
const data = await response.json();
setCountries(data.countries || []);
} catch (error) {
setError(error);
}
}
fetchCountries();
}, []);
// …
}
这是一种好的解决方案吗?有些人可能会说是;也有人可能持不同意见。countries 列表应该在渲染并提交数据的同一个 Form 组件中获取,还是……?(讨论仍在进行中)
Source: https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0
重温 Container‑Presentational 模式
Dan Abramov 的著名 “Presentational‑Container” 模式 为组织这类混乱提供了有价值的洞见(如果想更容易入门,我推荐阅读 patterns.dev 上的这篇文章)。
根据我的理解,编写 React 组件可以分为以下两种模式:
| 类型 | 描述 |
|---|---|
| Stateful(或 非函数式) | 管理应用的内部状态。这可以是纯客户端状态(例如,输入框中的当前文本值),也可以是从后端或第三方 API 获取的数据。简而言之,任何使用 useState、useEffect 或 fetch 的组件都是 stateful。 |
| Stateless(或 纯函数式) | 纯函数式——读取的数据是不可变的,且没有 useEffect 引起的副作用。它仅负责将给定的数据可视化。 |
将模式应用到 Form 组件
Form 组件显然是 stateful 的,因为它使用 useState 管理了多个状态。如果我们在这里再加入 useEffect 来获取候选国家列表,那么该组件同样还负责处理从后端获取的数据。
这种关注点分离对维护尤为有用:
- 若要添加任何额外的数据提交,只需修改
Form组件。 - 如果国家文本提交出现问题,bug 必然出现在这个
Form中。
如果我们按照 Presentational‑Container 模式重构组件,就会把标记(presentational 部分)与状态处理逻辑(container 部分)分离。presentational 组件可能如下所示:
export const FormBox = ({
title,
description,
answer,
status,
error,
handleSubmit,
handleTextareaChange,
}: Props) => {
return (
<>
{title}
{description}
Submit
{error !== null && (
{error.message}
)}
); };
> **注意:** 容器组件会导入 `FormBox` 并传入相应的属性(状态值和回调函数)。
### 层次结构关注
即使有了这种逻辑分离,前端层次结构仍然可能模糊有状态组件和无状态组件之间的界限。没有限制将有状态元素嵌套在无状态元素内部。请参考以下示例:
```tsx
export function FormLayout() {
return (
{/* some other components */}
);
}
FormLayout 本身不包含任何提交逻辑,但在调试时你仍然可能会检查它,因为它在概念上将表单分组。这表明需要一个更全面的思维模型来组织前端代码。
重新审视原子设计
Brad Frost’s Atomic Design 为构建 React 项目提供了另一种有用的视角。Frost 将组件划分为五个层级(原子、分子、有机体、模板、页面),我的收获是:一个完整的前端页面可以从两个方面来思考:
| 方面 | 描述 |
|---|---|
| 布局 | 关注 视觉组件 在屏幕上的放置方式(尺寸、定位、flexbox 布局等)。这主要是 CSS 的职责,但每个组件应保持自包含,避免无意中影响兄弟组件(例如通过溢出或尺寸变化)。 |
| 功能页面 | 关注 组件 从产品角度想要向用户传达的内容。每个功能应遵循单一职责原则。一个功能可能由多个子功能组成(例如,包含文本输入、文件上传等的表单页面)。每个子功能负责自己的 UI 和数据状态。 |
命名考虑
FormLayout 这个名称可能会产生误导,因为它可能不仅包含表单本身,还会包含导航栏或广告横幅等无关元素。在这种情况下,使用更具描述性的名称——例如 QuizPageLayout——可能更合适。
综合起来
我们现在有了一个用于分离关注点的思维模型:
- 层次化特性结构 – 整个项目被组织为特性的树形结构。
- 页面级布局 – 每个顶层特性由布局组件分配其专属 页面。
- 特性级状态 – 每个特性自行获取和更新数据,保持状态隔离。
通过将 Container‑Presentational 模式与 Atomic Design 原则相结合,我们可以实现干净、可维护且可扩展的前端架构。
Source: …
Layout – 页面模型
让我们更详细地讨论 Layout‑Page 模型,并结合之前介绍的 Container‑Presentational 模式。
什么是 Layout?
- Layout 对应于 Atomic Design 中的 organisms、templates 和 pages。
- 它仅负责在整个屏幕上排列多个组件:决定每个组件的位置、显示方式和大小。
- 它可能包含视觉辅助元素,如分割线,但这类情况很少。
- Layout 永远不处理各个组件的渲染方式(即 Page),也不涉及它们的 margin 或 padding 属性。
什么是 Page?
- Page 代表产品中的单一功能,遵循单一职责原则。
- 它负责从后端获取和更新与该功能相关的数据,并在必要时管理客户端的 UI 状态。
- 仅对传入的数据进行可视化、无状态且纯函数式的页面也是合法的。
- 一个 Page 由两个要素组成:它自己的 layout 和 子页面。
- 如果一个页面仅包含基本的 HTML 元素且没有其他 React 组件,则可以视为 叶子页面。
由于每个页面都可以包含自己的 layout 和子页面,结构是 递归 的:形成一个页面树,每个页面在逻辑上被划分为 layout 和子页面。
示例树
QuizPage
├── @AdsBanner
│ ├── @Page
│ └── Layout
├── @QuizSubmitPage
│ ├── @Page # will be in this page
│ └── Layout
└── Layout
该树镜像了 Next.js App Router 的结构,尽管 Next.js 并未强制任何特定的设计原则。App Router 的基于文件的路由自然契合 Layout‑Page 模型,而该模型也部分受其启发。
映射到 Next.js 文件路由
quiz
├── @adsbanner
│ ├── page.tsx
│ └── layout.tsx
├── submit
│ ├── page.tsx # will be in this page
│ └── layout.tsx
├── layout.tsx
└── page.tsx
- 广告横幅 使用 并行路由(参见 Next.js 并行路由文档),因此横幅不会作为独立路由暴露。
- 因为它位于
quiz/下而不是quiz/submit/,所以横幅会出现在quiz/的所有子路由上,而不仅仅是提交页面。 - 并行路由对于 Layout‑Page 模型至关重要,因为一个产品功能可能包含多个子功能。
完整项目递归
Project
├── Layout
├── Page0
│ ├── Layout
│ ├── Page00
│ │ ├── Layout
│ │ ├── Page000
│ │ …
│ ├── Page01
│ ├── Page02
│ …
├── Page1
├── Page2
├── Page3
…
结束语
我可能不是这个思维模型的最初创作者;也许已经有人以不同的名称发布了类似的想法。经过长时间寻找组织混乱前端的有效方法后,这个模型对我而言浮现出来。我很期待听到您可能有的任何评论或参考资料。谢谢!