【译】LRS:分层式 React 结构(Layered React Structure)
8 min read
目录

原文 - 以下由 deepseek 翻译

前言

初涉网页开发时,我参与了一个 Angular 2 项目。Angular 提供了一套明确、规范且结构合理的风格指南,详细说明了项目应如何组织架构。配合一系列基于核心功能的官方工具,精心编写的 Angular 应用能呈现出极高的项目间一致性。

当我的下一个工作项目转向 React 时,发现官方提供的通用库寥寥无几,也没有成文的风格指南可供参考,这种落差感令我印象深刻。即便后来经手多个 React 项目,这种感受始终萦绕。因此当我开始独立开发一个(现已中止的)多年期应用时,我决心要解决这个问题。

本文阐述的是我经过多年实践探索出的解决方案,并在实验阶段后又历经多年生产环境使用的持续打磨。

本文提出的 React 项目组织方法称为 ” 分层式 React 结构 “,简称 “LRS”。

LRS 的核心思想是,应用程序的每一层都应能独立存在,并通过组合更清晰地构建你的应用。最终,这种代码组织方式将使你能够:

  • 无论项目规模如何,都能快速定位代码中任何部分的位置;
  • 以更贴近用户且一致的方式测试代码;
  • 通过逻辑集中化减少系统中引入的错误;
  • 降低追踪逻辑流程的复杂度;
  • 让开发者和利益相关者能更快地迭代用户界面;
  • 采用后避免关于代码存放位置的无效争论。

先决概念

在深入探讨 LRS 之前,我想先详细解释几个核心概念。让我们一同探索我构建 React 应用时的思维方式。

已理解相关概念?可直接跳转至文件系统示例快速浏览。

定义 ” 智能 ” 与 ” 傻瓜 ” 组件

早在 React 初期,你可能就听说过“智能”和“傻瓜”组件的概念。它们在 React 生态系统中如此盛行,部分归功于 Dan Abramov 在 2015 年发表的 这篇文章,使其在当时就广为人知。

其他名称
这一概念有多种表述方式。以下是一些你可能听到的替代术语:

  • ” 胖 ” 组件与 ” 瘦 ” 组件
  • ” 容器 ” 组件与 ” 展示 ” 组件
  • ” 有状态 ” 组件与 ” 纯 ” 组件
  • ” 屏幕 ” 与 ” 组件 ”

尽管 Dan 后来对这个概念改变了看法,但我已学会接纳“智能”组件与“非智能”组件之间的差异。

无需完整复述他的文章,以下是基本概念:

“智能”组件负责处理应用程序的业务逻辑:

// 这是 “智能” 组件的示例
function UserTable() {
	const {data, error, isLoading} = useQuery(/* ... */)
    useEffect(() => {
		if (!error) return;
        logError(error);
    }, [error])
    if (isLoading) {
        return <LoadingIndicator/>
    }
    if (error) {
		return <ErrorScreen error={error}/>;
    }
    return (
    	/* ... */
    )
}

” 而 ’ 傻瓜 ’ 组件则负责处理应用程序的展示与样式:"

// 这是一个 “傻瓜” 组件的示例
function LoadingIndicator() {
	return <>
        <p>Loading...</p>
    	<svg class="spinner">
            {/* ...*/}
        </svg>
    </>
}

" 智能 ” 与 ” 傻瓜 ” 组件的经验法则

虽然关于 ” 智能 ” 与 ” 傻瓜 ” 组件的讨论存在多种不同规则,以下是我遵循的通用组件类型准则;我通常建议遵循这些指导原则以确保正确运用分层 React 结构 (LRS)。

  • ” 傻瓜 ” 组件可以包含状态和逻辑,但仅在与 UI 相关时,绝不包含业务逻辑。
// 这是一个具有 state 的 “傻瓜” 组件的示例
function ErrorScreen({ error }) {
  // 可以包含状态,但仅包含与 UI 相关的状态
  const [isExpanded, setIsExpanded] = useState(false)
  const handleToggle = (event) => setIsExpanded(event.currentTarget.open)
  return (
    <>
      <p>There was an error</p>
      <details
        onToggle={handleToggle}
        open={open}
      >
        <summary>
          {isExpanded ? 'Hide error details' : 'Show error details'}
        </summary>
        <pre style="white-space: pre-wrap">
          <code>{error.stack}</code>
        </pre>
      </details>
    </>
  )
}
  • ” 傻瓜 ” 组件只能包含其他 ” 傻瓜 ” 组件
// DO NOT DO THIS
function UserListItem({ user }) {
  /* ... */
  const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
  return (
    <>
      {/* ... */}
      <button onClick={() => setIsEditDialogOpen(true)}>Edit</button>
      {/* This modal contains business logic for editing a user */}
      {isEditDialogOpen && <EditUserDialog user={user} />}
    </>
  )
}
// Instead, try moving state up to the parent:
function UserListItem({ user, openUserDialog }) {
  /* ... */
  return (
    <>
      {/* ... */}
      <button onClick={openUserDialog}>Edit</button>
    </>
  )
}
  • ” 傻瓜 ” 组件不得包含对任何上下文、服务或其他应用程序依赖项的依赖
// DO NOT DO THIS
function ProfileInformation() {
	const user = use(UserData);
    return <>
    	<p>User's name: {user.name}</p>
    	{/* ... */}
    <>
}
// Instead, move application developers up and pass them down
function ProfileInformation({user}) {
    return <>
    	<p>User's name: {user.name}</p>
    	{/* ... */}
    <>
}

我要说明的是,我之前也曾打破过这条规则,但仅限于那些包含以展示为重点信息的上下文,例如:

  • 国际化/翻译字符串上下文
  • 主题值上下文
  • 与 UI 呈现相关的功能标志
  • ” 智能 ” 组件不应关心数据的加载、变更或访问方式
// DON'T DO THIS
function ToggleDisplay({ displayInfo }) {
  const [open, setOpen] = displayInfo
  // ...
}
// Your implementation should not be so tied to the parent's data structure
function App() {
  const displayInfo = useState(false)
  return <ToggleDisplay displayInfo={displayInfo} />
}
// ---------------------------------------------------------------------------------
// Instead, pass individual data items to be more modular and less opinionated
function ToggleDisplay({ open, toggle }) {
  // ...
}
// Your implementation should not be so tied to the parent's data structure
function App() {
  const [open, setOpen] = useState(false)
  return (
    <ToggleDisplay
      open={open}
      toggle={() => setOpen(!open)}
    />
  )
}
  • ” 智能 ” 组件不应包含任何标记,且不得包含任何样式
// DO NOT DO THIS
function App() {
  return <div style={{ minHeight: '100vh' }}>{/* ... */}</div>
}
// Instead, break out styling to dedicated files
function App() {
  return <Layout>{/* ... */}</Layout>
}

定义工具类与服务类的区别

2015 年,Promise 被引入 JavaScript。虽然它们很好地解决了回调地狱问题,但直到 2017 年左右生态系统中实现了 async 和 await 后,使用起来才变得直观。

function main() {
  return sleep(1)
    .then(() => {
      console.log('One second has passed')
      return sleep(1)
    })
    .then(() => {
      console.log('Two seconds have passed')
    })
}
// vs
async function main() {
  await sleep(1)
  console.log('One second has passed')
  await sleep(1)
  console.log('Two seconds have passed')
}

想了解更多关于 async / await 和 JavaScript 中的 promises?看看我关于这个话题的文章

在 JavaScript 中引入 async 和 await API 的一个挑战是,你现在需要有效地在异步代码和同步代码之间对函数进行颜色编码。

虽然上述链接文章的作者认为这种开发者按颜色编码的做法是坏事,但我再次倾向于范式转变,理解每种代码颜色(同步和异步)的优缺点。

毕竟,同步代码可能会像这样引入副作用:


重要的是要记住,这可以绕过,大多数同步函数都可以被设计成纯函数。另一方面,异步函数本质上是具有副作用的;这通常是因为它们常用于与 I/O 交互。

在本章我的免费书籍中了解更多关于副作用及其与 React 的关系

因此,我发现区分同步实用程序和异步实用程序很有价值。
综上,我将同步工具称为 utils,而将类似的异步函数称为 services。

理解文件名的敏感性

很快,我们来了解一下计算机如何处理文件:

当你编写一个需要读取文件的程序时,它会调用操作系统的内核——这是一段旨在最低层级上连接机器硬件和软件的代码。

这个内核调用用于写入文件,将与您计算机的磁盘驱动程序以及磁盘上的文件结构进行通信。

文件的结构——通常称为文件系统——是由操作系统在格式化磁盘时(手动或在操作系统安装过程中)建立的。不同的操作系统有不同的默认文件系统:

OSDefault Filesystem  默认文件系统
WindowsNew Technology File System (NTFS)
新技术文件系统 (NTFS)
macOSApple File System (APFS)  
苹果文件系统 (APFS)
LinuxFourth extended filesystem (EXT4)
第四代扩展文件系统(EXT4)

这些文件系统各有优缺点,但对网页开发者而言,它们之间最显著的区别在于文件名的大小写敏感性。
以下两个文件名为例:

- test.txt
- tEsT.txt

虽然 Windows 和 macOS 默认将这两个文件视为相同,但 EXT4(因此大多数 Linux 安装)默认不具备这种识别能力。

这意味着在 Linux 实例中,你实际上可以让这两个文件存在于同一个文件夹中。

值得一提的是,APFS 可以配置为区分大小写,甚至现代 Windows 也可以通过手动命令在某些文件夹中启用区分大小写功能。
尽管如此,通常认为大多数机器默认不会为您配置这些设置。

因此,我强烈建议您将所有文件保持小写,并采用 kebob-case 命名约定,以确保文件在 Linux 环境和 macOS/Windows 开发者之间不会混淆;这在 CI/CD 和本地系统之间调试起来可能颇具挑战性。

引入分层式 React 结构(LRS)

既然我们已经处理完细枝末节,现在终于可以概述 LRS 的核心内容了。以下是 LRS 的实际应用示例:

src/
├── assets/                ## 非代码资产,如图片和字体
   └── logo.png
├── components/            ## 哑组件
   ├── button/
   ├── button.module.scss
   ├── button.stories.ts
   ├── button.spec.tsx
   ├── button.tsx
   └── index.ts
   └── input/
       ├── input.module.scss
       ├── input.tsx
       └── index.ts
├── constants/             ## 非逻辑硬编码值
   ├── theme.ts
   └── index.ts
├── hooks/                 ## 非 UI React 特定的可重用逻辑
   ├── use-android-permissions.ts
   └── index.ts
├── services/              ## I/O 代码逻辑
   ├── people.ts
   └── index.ts
├── types/                 ## 非 JS TypeScript 类型和接口
   ├── svg.d.ts
   ├── address.ts
   └── index.ts
├── utils/                 ## 非 React JS 可重用逻辑
   ├── helpers.ts
   └── index.ts
├── views/                 ## 应用程序中的视图(页面、屏幕或路由)
   ├── homescreen/
   ├── components/    ## 视图特定的组件,必须是展示组件
   └── homescreen-list/
       ├── homescreen-list.module.scss
       └── homescreen-list.tsx
   ├── homescreen.spec.tsx
   ├── homescreen.stories.tsx
   ├── homescreen.module.scss
   ├── homescreen.ui.tsx    ## 展示组件,包含视图布局
   ├── homescreen.view.tsx  ## 智能组件,包含网络和业务逻辑
   └── index.ts
   └── index.ts
└── app.tsx                ## 组件入口点,可能包含一些提供者

在 LRS 中,所有非源代码配置文件,如 .storybook 或 .eslintrc.json 文件,必须存放在 src 文件夹之外。什么是 storybook ?我应该用什么进行测试?我的 UI 组件呢?

别担心!

你们中的一些人可能已经习惯了 React,或者对我讨论和解决的问题以及 LSR 使用的工具有所了解。

对于那些还不了解的人,既然我们已经展示了 LSR 的完整结构,现在让我们更深入地探讨它为何以这种方式运作。

在接下来的段落中,我们将进一步了解配置中使用的工具。

LRS 共享代码

这使您能够按功能范围限定一组 utils 和 services ,同时保留根目录用于任何 utils 、 services 、 components 以及其他在多个屏幕中使用的工具。

LRS:基于文件的路由

views 的想法听起来不错,但我在 Next.js/TanStack Router 环境下工作,需要将路由存放在特定文件夹中——这种情况该如何处理?

幸运的是,有个简单的解决方案:将你的 pages / app 目录作为 views 文件夹的外壳:

// app/page.tsx
import { Homescreen } from '../views/homescreen/homescreen.view'
export default function HomescreenPage() {
  return <Homescreen />
}

你只需要添加这些内容即可!😄

与 LRS 搭配使用的工具

让我们探讨下我通常推荐与 LRS 搭配使用的一些工具。这些库要么能帮你节省时间,要么能让开发流程更高效。

注意
这一部分比文章的其他部分更具主观性。虽然我坚持这些建议,但完全有可能在不使用这些特定工具的情况下,通过 LRS 实现一个结构足够良好的 React 应用。

逻辑测试

如果你在 React 领域待得够久,很可能听说过 Kent C Dodds 的作品。他是一位多产的教育家,创建了 Epic ReactEpic WebTesting JavaScript 等课程。他的一篇热门文章 《编写测试。不要太多。主要是集成测试》 通过他的“测试奖杯”阐述了为何应优先编写集成测试:

在 Kent 的大部分关于测试的著作中,他通常会提到一个名为 “Testing Library” 的工具;这是一套工具集,能够在前端项目中实现更好的集成测试方法。

正是在这里,肯特展现了他的另一面;一位备受推崇的 Web 开发工具创作者。要知道,肯特是 “Testing Library” 的原作者。

虽然如今的 Testing Library 包含了许多框架的适配器,但我建议在你的 React 测试套件中使用以下工具:

警告
虽然你可以使用 React Hooks Testing Library 独立于代码测试 React Hooks,但我强烈建议避免这样做。不仅该项目的维护已经停滞,而且它还鼓励了大多数应用程序的不良测试实践。

这样,你就可以编写遵循用户行为的测试,如下所示:

import { describe, expect, it, afterEach, beforeAll } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from '@testing-library/user-event'
import { http } from "msw";
import { setupWorker } from 'msw/browser'
import { PeopleView } from "./people.view";
import { createPersonHobbiesUrl } from "../../services/people";
const user = userEvent.setup();
const worker = setupWorker()
beforeAll(() => worker.start());
afterEach(() => worker.resetHandlers());
describe("PeopleView", () => {
    it("Should allow the user to add a hobby to their person", async () => {
        worker.use(http.post(createPersonHobbiesUrl, () => HttpResponse.json({
            hobbies: [{
                id: "0",
                name: "Go to the gym"
            }]
        }))
        render(<PeopleView />)
        expect(screen.getByText("There are no hobbies")).toBeInTheDocument();
        await user.type(screen.getByLabelText("New hobby name"), "Do something fun");
        await user.click(screen.getByText("Add hobby"));
        await waitFor(() => expect(screen.getByText("Go to the gym")).toBeInTheDocument())
    })
})

这种设置使您能够验证实际用户行为,而非测试实现细节。

想了解更多关于最佳测试实践的内容?请参阅我们的文章,获取 5 条编写最佳测试的建议

Test Runner

在浏览那个列表时,你可能好奇为什么我推荐使用 Vitest 而非 Jest。其实,虽然选择 Vitest 而非 Jest 有诸多理由,但最关键的因素在于一个特性:

Vitest’s browser mode

看吧,虽然 Testing Library 和 MSW 让你能在测试中专注于用户体验,但使用像 JSDom(在 Jest UI 测试中很常见)这样的工具会让调试这些测试变得困难得多,因为你没有使用真正的浏览器。

使用 Vitest 浏览器模式,您可以在真实浏览器中快速运行测试;使调试变得更加容易。

UI 测试

虽然一开始自己维护 UI 库的想法听起来可能既无吸引力又低效,但我发现对于所有生产应用来说,这很快就会成为现实,无论最初意图如何。

这并不意味着你需要从头开始编写所有的 UI 组件;也许你会使用像 MUI 或 Ant Design 这样的 UI 库,但最终你将拥有一套自己内部可复用的组件。

尽管我在前两段中使用了较为消极的语气,但我认为这对你来说是个好消息!了解即将发生的事情能让你提前做好准备。此外,拥有一套统一的组件还能让你在应用中记录并强化更多一致性。

然而,问题在于当你作为团队开发者缺乏文档或良好的参考来说明有哪些可用的 UI 组件时。

这就是为什么我建议对所有共享组件使用 Storybook。它能让你集中查看 UI 元素,并根据项目发展和扩展的需要随时进行文档记录:

幸运的是,Storybook 使用起来很简单。以下是一个 React 的 Storybook 故事文件示例:

import type { Meta, StoryObj } from '@storybook/react'
import { Button } from './Button'
const meta: Meta<typeof Button> = {
  component: Button,
}
export default meta
type Story = StoryObj<typeof Button>
export const Primary: Story = {
  args: {
    primary: true,
    label: 'Button',
  },
}

样式

与其说是工具建议,不如说是我过去使用 Angular 时留下的习惯;我建议将样式与标记分开存放在不同的文件中。

这可以通过 CSS/SCSS 模块、Vanilla Extract 或类似工具理想地实现。不过,即使使用 Tailwind 也能奏效,只要将类提取到不同文件中的字符串模板字面量即可。

这样做的原因在于,通过拆分,你可以更清晰、更有条理地组织文件,保持其小巧并根据需要组合使用。

结论

我知道你们中的许多人会浏览这个布局,并认为想出这些知识是微不足道的。毕竟,在实施这个方案后,我发现了一些类似的替代方案,比如 Bulletproof React

但我最初并没有采用 Bulletproof React 或其他任何基础框架——大部分结构是在快速原型实现过程中,基于我在其他 React 代码库中遇到的挑战临时构思的。只有通过实验、试错和经验积累,我才逐步形成了本文概述的这些模式。

尽管创作短篇小说的道路漫长,但我很感激自己想出了这个模式。毕竟,史蒂夫·乔布斯本人在 1998 年说过:

这始终是我的信条之一——专注与简单。简单可能比复杂更难:你必须努力理清思路,才能做到简单。但最终这是值得的,因为一旦达到这种境界,你就能移山填海。

希望这篇文章对你和你的团队有所帮助。