3.精通React-如何组织React组件代码结构?-《前端知识进阶》

admin 2025-11-01 15:29:33 编程 来源:ZONE.CI 全球网 0 阅读模式
  • 1. 导入依赖项
  • 2. 静态定义
    • (1)常量定义
    • (2)类型定义
  • 3. 组件定义
  • 4. 变量声明
  • 5. Effects
  • 6. 渲染内容
  • 7. 部分渲染
  • 8. 局部函数
  • 9. 纯函数
  • 完整示例

    在日常开发中,团队每个人组织代码的方式不尽相同,参差不齐。下面我们就从代码结构的角度来看看如何组织一个更加优雅的 React 组件。

    1. 导入依赖项

    我们需要在组件文件顶部导入组件所需的依赖项,通常是使用 import 来进行导入。对于不同类别的依赖项,建议对它们进行分组,这有助于帮助我们更好的理解组件。可以将导入的依赖分为四类:

    1. // 外部依赖
    2. import React from "react";
    3. import { useRouter } from "next/router";
    4. // 内部依赖
    5. import { Button } from "../src/components/button";
    6. // 本地依赖
    7. import { Tag } from "./tag";
    8. import { Subscribe } from "./subscribe";
    9. // 样式
    10. import styles from "./article.module.scss";
    • 外部依赖:外部依赖主要是第三方依赖,这些依赖定义在package.json文件中并从node_modules 中导入;
    • 内部依赖:内部依赖主要是位于组件文件夹之外的可重用的组件或模块,这些导入都应该使用相对导入语法,以 ../ 开头。通常,大部分导入的依赖项都属于这一类。因此,如果需要的话,我们可以将这一类组件进一步进行分离,例如:UI组件、数据相关的导入、services等;
    • 本地依赖:本地依赖主要是与组件位于同一文件夹中的本地依赖项或子组件。这些依赖项的所有导入路径应以./开头。主要是比较大的组件会包含本地依赖项;
    • 样式:最后这一部分大部分时候只包含一个导入,代表样式组件。如果导入了多个样式表,则需要考虑样式的拆分是否有问题。

    对导入依赖项进行手动分组对我们来说可能比较麻烦,而 Prettier 敲好可以帮助我们自动格式化代码,我们可以安装 prettier-plugin-sort-imports 插件,并使用它来自动格式化依赖项导入。需要在项目根目录创建prettier.config.js文件,并在里面添加规则:

    1. module.exports = {
    2. // 其他 Prettier 配置
    3. importOrder: [
    4. // 默认情况下,首先会放置外部依赖项
    5. // 内部依赖
    6. "^../(.*)",
    7. // 本地依赖项,样式除外
    8. "^./((?!scss).)*$",
    9. // 其他
    10. "^./(.*)",
    11. ],
    12. importOrderSeparation: true,
    13. };

    下面是该插件给出的例子,输入:

    1. import React, {
    2. FC,
    3. useEffect,
    4. useRef,
    5. ChangeEvent,
    6. KeyboardEvent,
    7. } from 'react';
    8. import { logger } from '@core/logger';
    9. import { reduce, debounce } from 'lodash';
    10. import { Message } from '../Message';
    11. import { createServer } from '@server/node';
    12. import { Alert } from '@ui/Alert';
    13. import { repeat, filter, add } from '../utils';
    14. import { initializeApp } from '@core/app';
    15. import { Popup } from '@ui/Popup';
    16. import { createConnection } from '@server/database';

    输出:

    1. import { debounce, reduce } from 'lodash';
    2. import React, {
    3. ChangeEvent,
    4. FC,
    5. KeyboardEvent,
    6. useEffect,
    7. useRef,
    8. } from 'react';
    9. import { createConnection } from '@server/database';
    10. import { createServer } from '@server/node';
    11. import { initializeApp } from '@core/app';
    12. import { logger } from '@core/logger';
    13. import { Alert } from '@ui/Alert';
    14. import { Popup } from '@ui/Popup';
    15. import { Message } from '../Message';
    16. import { add, filter, repeat } from '../utils';

    prettier-plugin-sort-imports:https://github.com/trivago/prettier-plugin-sort-imports

    2. 静态定义

    在导入完依赖项的下方,通常会定义有使用 TypeScript 或 Flow 等静态类型检查器时的文件级常量和类型定义。下面就来分别看一下每一种。

    (1)常量定义

    任何magic值,例如字符串或者数字,都应该放在文件的顶部,import 语句的下方。由于这些都是静态常量,这意味着它们的值不会改变。因此将它们放在组件中是没有意义的,因为放在组件中的话,它们会在每次重新渲染组件时重新创建。

    1. const MAX_READING_TIME = 10;
    2. const META_TITLE = "Hello World";

    对于更复杂的静态数据结构,可以将它提取到一个单独的文件中,以保持组件的干净。

    (2)类型定义

    我使用的是TypeScript,接下来声明组件 props 的类型 interface

    1. interface Props {
    2. id: number;
    3. name: string;
    4. title: string;
    5. meta: Metadata;
    6. }

    如果这个 props 的类型不需要导出,可以使用 Props 作为接口名称,这样可以帮助我们立即识别组件 props 的类型定义,并将其与其他类型区分开来。

    只有当这个 props 需要在多个组件使用时,才会添加组件名称,例如ButtonProps,因为他在导入另一个组件时,不应该与本地的Props接口冲突。

    3. 组件定义

    定义函数组件的方式有两种:函数声明箭头函数,我个人更喜欢使用函数声明的形式,以为这就是语法声明的内容:函数。官方文档的示例中也使用了这种方法:

    1. function Article(props: Props) {
    2. /**/
    3. }

    只会在必须使用 forwardRef 时使用箭头函数:

    1. const Article = React.forwardRef<HTMLArticleElement, Props>(
    2. (props, ref) => {
    3. /**/
    4. }
    5. );

    通常会在组件最后默认导出组件:

    1. export default Article;

    4. 变量声明

    接下来,我们就需要在组件里面进行变量的声明。注意,即使使用const声明的这里也称为变量,因为它们的值通常在不同的渲染之间发生变化,只有在执行单个渲染过程时是恒定的。

    1. const { id, name, title } = props;
    2. const router = useRouter();
    3. const initials = getInitials(name);

    此部分通常包含在组件级别使用的所有变量,使用 const 或 let 定义,具体取决于它们在渲染期间是否更改其值:

    • 解构数据,通常来自 props、数据 stores或应用程序的状态;
    • Hooks,自定义hooks、框架内置Hooks,例如 useState、useReducer、useRef、useCallback 或 useMemo;
    • 在整个组件中使用的已处理数据,由函数计算得出;

    一些较大的组件可能需要在组件中声明很多变量。这种情况下,建议根据它们的初始化方法或者用途对它们进行分组:

    1. // 框架 hooks
    2. const router = useRouter();
    3. // 自定义 hooks
    4. const user = useLoggedUser();
    5. const theme = useTheme();
    6. // 从 props 中解构的数据
    7. const { id, title, meta, content, onSubscribe, tags } = props;
    8. const { image, author, date } = meta;
    9. // 组件状态
    10. const [email, setEmail] = React.useState("");
    11. const [showMenu, toggleMenu] = React.useState(false);
    12. const [activeTag, dispatch] = React.useReducer(reducer, tags);
    13. // 记忆数据
    14. const subscribe = React.useCallback(onSubscribe, [id]);
    15. const summary = React.useMemo(() => getSummary(content), [content]);
    16. // refs
    17. const sideMenuRef = useRef<HTMLDivElement>(null);
    18. const subscribeRef = useRef<HTMLButtonElement>(null);
    19. // 计算数据
    20. const initials = getInitials(author);
    21. const formattedDate = getDate(date);

    变量分组的方法在不同组件之间可能会存在很大的差异,它取决于变量的数量和类型。关键点是要将相关变量放在一起,在不同的组之间添加一个空行来提高代码的可读性。

    注:上面代码中的注释仅用于标注分组类型,在实际项目中不会写这些注释。

    5. Effects

    Effects 部分通常会写在变量声明之后,他们可能是React中最复杂的构造,但从语法的角度来看它们非常简单:

    1. useEffect(() => {
    2. setLogo(theme === "dark" ? "white" : "black");
    3. }, [theme]);

    任何包含在effect之内但是在其外部定义的变量,都应该包含在依赖项的数组中。

    除此之外,还应该使用return来清理副作用:

    1. useEffect(() => {
    2. function onScroll() {
    3. /*...*/
    4. }
    5. window.addEventListener("scroll", onScroll);
    6. return () => window.removeEventListener("scroll", onScroll);
    7. }, []);

    6. 渲染内容

    UI组件的核心就是它的内容,此内容使用 JSX 语法定义并在浏览器中呈现为 HTML。这就是为什么我更喜欢让函数组件的的 return 语句尽可能靠近文件顶部的原因。其他一切都只是细节,所以它们应该放在文件较下的位置。

    1. function Article(props: Props) {
    2. // 变量声明
    3. // effects
    4. // ❌ 自定义的函数不建议放在 return 部分的前面
    5. function getInitials() {
    6. /*...*/
    7. }
    8. return /* content */;
    9. }
    10. export default Article;
    1. function Article(props: Props) {
    2. // 变量声明
    3. // effects
    4. return /* content */;
    5. // ✅ 自定义的函数建议放在 return 部分的后面
    6. function getInitials() {
    7. /*...*/
    8. }
    9. }
    10. export default Article;

    难道return不应该放在函数的最后吗?其实不然,对于简单的常规函数,肯定是要将return放在最后的。然而,React组件并不是简单的函数,它们通常包含具有各种用途的嵌套函数,例如事件处理程序。

    最后的return语句,以及前面的一堆其他函数,实际上阻碍了代码的阅读,使得很难找到组件渲染的内容:

    • 很难搜索该return语句,因为可能有来自其他嵌套函数的多个 return 语句;
    • 在文件末尾滚动查找 return 语句并不能保证很容易找到它,因为返回的 JSX 块可能非常大。

    当然,函数定义的位置是因人而异的,如果将函数放在return的下方,那么如果想要使用箭头函数来自定义函数,那就只能使用var来定义,因为letconst不存在变量提升,不能在定义箭头函数之前使用它。

    7. 部分渲染

    在处理大型 JSX 代码时,将某些内容块提取为单独的函数来渲染组件的一部分是很有帮助的,类似于将大型函数分解为多个较小的函数。

    1. function Article(props: Props) {
    2. // ...
    3. return (
    4. <article>
    5. <h1>{props.title}</h1>
    6. {renderBody()}
    7. {renderFooter()}
    8. </article>
    9. );
    10. function renderBody() {
    11. return /* article body JSX */;
    12. }
    13. function renderFooter() {
    14. return /* article footer JSX */;
    15. }
    16. }
    17. export default Article;
    • 可以给这些函数前面加上render前缀,以将它们与其他不返回 JSX 的函数区分开;
    • 可以将函数放在return语句之后,以便将与内容相关的所有内容组合在一起;
    • 无需向这些函数传递任何参数,因为它们可以访问props和组件定义的所有变量;

    那为什么不将它们提取为组件呢?关于部分渲染函数其实是存在争议的,一种说法是避免从组件内定义的任何函数中返回 JSX。另一种方法是将这些函数提取为单独的组件。

    1. function Article(props: Props) {
    2. // ...
    3. return (
    4. <article>
    5. <h1>{props.title}</h1>
    6. <ArticleBody {...props} />
    7. <ArticleFooter {...props} />
    8. </article>
    9. );
    10. }
    11. export default Article;
    12. function ArticleBody(props: Props) {}
    13. function ArticleFooter(props: Props) {}

    这种情况下,就必须手动将子组件所需的局部变量通过props传递,因为在使用TypeScript时,我们通常还需要为组件的props定义额外的类型。最终就会得到臃肿的代码,这往往会导致代码变得难以阅读和理解:

    1. function Article(props: Props) {
    2. const [status, setStatus] = useState("");
    3. return (
    4. <article>
    5. <h1>{props.title}</h1>
    6. <ArticleBody {...props} status={status} />
    7. <ArticleFooter {...props} setStatus={setStatus} />
    8. </article>
    9. );
    10. }
    11. export default Article;
    12. interface BodyProps extends Props {
    13. status: string;
    14. }
    15. interface FooterProps extends Props {
    16. setStatus: Dispatch<SetStateAction<string>>;
    17. }
    18. function ArticleBody(props: BodyProps) {}
    19. function ArticleFooter(props: FooterProps) {}

    这些单独的组件不可以重复使用,它们仅由它们所属的组件使用;并且单独使用它们是没有意义的。因此,这种情况下,还是建议将部分JSX提取成渲染函数。

    8. 局部函数

    UI组件通常会包含事件处理函数,它们是嵌套函数,通常会更改组件的内部状态或调度操作以更新应用的状态。

    另一类嵌套函数就是闭包,它们是读取组件状态或props的不纯函数,有助于构建组件逻辑。

    1. function Article(props: Props) {
    2. const [email, setEmail] = useState("");
    3. return (
    4. <article>
    5. {/* ... */}
    6. <form onSubmit={subscribe}>
    7. <input type="email" value={email} onChange={setEmail} />
    8. <button type="submit">Subscribe</button>
    9. </form>
    10. </article>
    11. );
    12. // 事件处理
    13. function subscribe(): void {
    14. if (canSubscribe()) {
    15. // 发送订阅请求
    16. }
    17. }
    18. function canSubscribe(): boolean {
    19. // 基于 props 和 state 的逻辑
    20. }
    21. }
    22. export default Article;
    • 通常会使用函数声明而不是函数表达式来声明函数,因为函数是存在提升的,这允许我们在使用它们之后定义它们。这样就可以将它们放在组件函数的末尾。return语句之后;
    • 如果一个函数中嵌套了另外一个函数,那么会将调用者放在被调用者之前;
    • 通常将这些功能按使用顺序排列。

      9. 纯函数

      最后,就是纯函数,我们可以将它们放在文件的底部,在React组件之外: ```typescript function Article(props: Props) { // …

      // ❌ 纯函数不应该放在组件之中 function getInitials(str: string) {} }

    export default Article;

    1. ```typescript
    2. function Article(props: Props) {
    3. // ...
    4. }
    5. // ✅ 纯函数应该放在组件之外
    6. function getInitials(str: string) {}
    7. export default Article;

    首先,纯函数没有依赖项,如 props、状态或局部变量,它们接收所有依赖项作为参数。 这意味着可以将它们放在任何地方。 但是,将它们放在组件之外还有其他原因:

    • 它向任何阅读代码的开发人员发出信号,表示它们是纯粹的;
    • 它们很容易测试,只需要将要测试的函数导出并导入到测试文件中即可;
    • 如果需要提取和重用它们,可以很容易将它们很移动到其他文件。

      完整示例

      下面是一个完整的典型 React 组件文件。由于重点是文件的结构,因此省略了实现细节。 ```typescript // 1️⃣ 导入依赖项 import React from “react”; import { Tag } from “./tag”; import styles from “./article.module.scss”;

    // 2️⃣ 静态定义 const MAX_READING_TIME = 10;

    interface Props { id: number; name: string; title: string; meta: Metadata; }

    // 3️⃣ 组件定义 function Article(props: Props) {

    // 4️⃣ 变量定义 const router = useRouter(); const theme = useTheme();

    const { id, title, content, onSubscribe } = props; const { image, author, date } = meta;

    const [email, setEmail] = React.useState(“”); const [showMenu, toggleMenu] = React.useState(false);

    const summary = React.useMemo(() => getSummary(content), [content]);

    const initials = getInitials(author); const formattedDate = getDate(date);

    // 5️⃣ effects React.useEffect(() => { // … }, []);

    // 6️⃣ 渲染内容 return (

    {title}

    1. {renderBody()}
    2. <form onSubmit={subscribe}>
    3. {renderSubscribe()}
    4. </form>
    5. </article>

    );

    // 7️⃣ 部分渲染 function renderBody() { // }

    function renderSubscribe() { // }

    // 8️⃣ 局部函数 function subscribe() { // } }

    // 9️⃣ 纯函数 function getInitials(str: string) { // }

    export default Article; ``` 参考:

    https://andreipfeiffer.dev/blog/2021/react-components-anatomy#variable-declarations