React 应用架构实战 0x1:初始化项目和项目结构概览

227 阅读11分钟

在上一节中,我们看到了构建 React 应用程序时的所有挑战以及一些可以帮助我们处理这些挑战的很好的解决方案。在这一节中,我们将查看项目结构和初始化工具,这些工具构成了我们项目的良好基础。

Next.js

Next.js 是一个建立在 React 和 Node.js 之上的 Web 框架,支持构建 Web 应用程序。由于它可以在服务端运行,因此可以用作全栈框架。

为什么选择 Next.js?

使用 Next.js 有多个好处,使用它的原因如下:

  • 上手门槛低
    • 在 React 的早期,启动一个项目非常具有挑战性,要在屏幕上显示一个简单的页面,必须处理许多工具,例如 Webpack、Babel 等
    • 虽然现在仍在使用这些工具,但幸运的是,大多数工具配置都是隐藏的,并提供一个接口来扩展配置(在需要的时候)
    • 除了设置项目的挑战之外,随着时间的推移,维护所有这些依赖关系也非常具有挑战性,Next.js 将所有这些复杂性都隐藏起来,让开发人员能够快速开始一个新项目
  • 支持多种渲染策略
    • 能够使用多种渲染策略可能是我们想使用 Next.js 的主要原因,尽管它还具有其他优点
    • 支持在页面级别定义页面渲染的行为,即可以单独定义每个页面的渲染方式
    • 支持多种呈现策略
      • 客户端渲染 CSR
      • 服务器端渲染 SSR
      • 静态站点生成 SSG
      • 增量静态再生 ISR
    • 可以根据应用程序的需要使用不同的策略
  • 性能优化
    • Next.js 在构建时考虑了 Web 性能
    • 它实现了常见性能优化点
      • 代码分割
      • 懒加载
      • 预加载
      • 图像优化

Next.js 应用结构

使用 Next.js 最简单的方法是用 create-next-app CLI 工具创建新应用。

npx create-next-app jobs-app --typescript

这将创建一个 Next.js 应用程序,其中主要包含以下文件和目录:

- jobs-app
  - .next
  - public
  - src
    - pages
      - _app.tsx
      - _document.tsx
      - index.tsx
      - api
        - hello.ts
    - styles
      - globals.css
      - Home.module.css
  - .gitignore
  - next.config.js
  - package.json
  - tsconfig.json
  - README.md

文件和目录的作用如下:

  • .next:包含通过运行 Next.js 的 build 命令生成的可以应用于生产环境的应用程序文件
  • public:包含应用程序的静态资源,如图像、字体等
  • src/pages
    • 所有在此定义的页面都可以在相应的路由处使用
    • 通过基于文件的路由机制实现
    • 页面文件夹也可以位于项目的根目录中,但将所有内容保存在 src 文件夹中更好
  • src/pages/_app.tsx
    • 导出一个 React 组件,每个页面都包装在该组件中渲染
    • 通过使用这个特殊组件包装页面,可以为应用程序添加自定义行为,如为所有页面添加全局配置、提供程序、样式、布局等等
  • src/pages/index.tsx:定义根页面
  • next.config.js 支持扩展默认功能,例如 Webpack 配置和其他内容
  • package.json
    • dev 在 localhost:3000 上启动开发服务
    • build:构建生产应用程序
    • start:在 localhost:3000 上启动生产构建

TypeScript

JavaScript 是一种动态类型的编程语言,所以它在构建时无法捕获任何类型错误。这就是 TypeScript 的作用所在。

TypeScript 是 JavaScript 的超集,使我们可以使用某些静态类型语言的行为编写 JavaScript。这可以让开发者在出现潜在错误之前捕获许多问题。

为什么要使用 TypeScript ?

对于由大型团队构建的大型应用程序,TypeScript 尤其有用。TypeScript 编写的代码比使用纯 JavaScript 编写的代码更易阅读和理解。通过查看类型定义,我们可以弄清楚代码的某个部分应该如何工作。

另一个原因是,TypeScript 使得重构变得更加容易,因为大多数异常可以在运行应用程序之前被捕获。

TypeScript 可以提高编辑器的智能性,因为它可以提供更多关于代码的信息,以更好地支持自动完成和代码签名信息提示。好的提示可以让开发人员更快地编写代码。

TypeScript 配置

tsconfig.json 文件包含 TypeScript 编译器的配置选项。

{
  "compilerOptions": {
    "target": "es5",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "incremental": true,
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
  "exclude": ["node_modules"]
}

TypeScript 基础用法

原生类型

let numVal: number;
numVal = 1; // OK
numVal = "1"; // Error

let strVal: string;
strVal = "1"; // OK
strVal = false; // Error

let boolVal: boolean;
boolVal = true; // OK
boolVal = 1; // Error

正如我们所看到的,只能使用相应类型的值进行赋值。对除 any 类型之外的任何其他类型进行赋值都会导致 TypeScript 错误。

Any

any 类型是 TypeScript 中最宽松的类型,使用它将禁用任何类型检查。可以在想要绕过通常会出现的错误时使用它。但是,最好只在万不得已时使用它,并尽量先使用其他类型:

let anyVal: any;
anyVal = 1; // OK
anyVal = "1"; // OK
anyVal = false; // OK

let numVal: number;
numVal = anyVal; // OK

使用 any 类型的变量可以接受并被赋值为任何其他类型的值,这使其非常灵活。

Unknown

有时候,我们无法预先知道将要使用哪些类型。这可能发生在一些动态数据中,我们还不知道它的类型。这时,可以使用 unknown 类型:

let unknownVal: unknown;
unknownVal = 1; // OK
unknownVal = "1"; // OK

let unknownVal2: unknown;
let anyVal: any;
let numVal: number;
unknownVal2 = unknownVal; // OK
anyVal = unknownVal; // OK
numVal = unknownVal; // Error

可以将任何类型的值分配给 unknown 类型的变量。 但是,我们只能将 unknown 类型的值分配给 any 类型和 unknown 类型的变量。

Array

let numArr: number[];
numArr = [1, 2, 3]; // OK
numArr = [1, 2, "3"]; // Error

let strArr: Array<string>;
strArr = ["1", "2", "3"]; // OK
strArr = ["1", "2", 3]; // Error

Object

// 类型别名
type Person = {
  name: string;
  age: number;
};

// 接口
interface Person {
  name: string;
  age: number;
}

联合类型

type NumOrStr = number | string;
let numOrStrVal: NumOrStr;

numOrStrVal = 1; // OK
numOrStrVal = "1"; // OK
numOrStrVal = false; // Error

也可以在联合类型中加入字面量类型:

type Color = "red" | "green" | "blue";
let colorVal: Color;

colorVal = "red"; // OK
colorVal = "yellow"; // Error

交叉类型

type Person = {
  name: string;
  age: number;
};

type Employee = {
  id: number;
  salary: number;
};

type EmployeePerson = Person & Employee;

let employeePersonVal: EmployeePerson;

employeePersonVal = {
  name: "John",
  age: 30,
  id: 1,
  salary: 1000,
}; // OK
employeePersonVal = {
  name: "John",
  age: 30,
}; // Error

泛型

泛型是一种通过参数化来创建可重用类型的机制,它可以帮助我们减少代码重复。

type Echo<T> = {
  value: T;
};

let echoVal: Echo<number>;
echoVal = {
  value: 1,
}; // OK

let echoVal2: Echo<string>;
echoVal2 = {
  value: "1",
}; // OK

也可以在函数中使用泛型:

function echo<T>(value: T): T {
  return value;
}

let echoVal: number = echo<number>(1); // OK
let echoVal2: string = echo<string>("1"); // OK
let echoVal3: boolean = echo<boolean>(1); // Error

TypeScript 和 React

每个使用 JSX 的 TypeScript 文件必须使用 .tsx 扩展名。

type Props = {
  name: string;
};

const Hello = ({ name }: Props) => {
  return <div>Hello {name}</div>;
};

export default Hello;

ESLint

Linting 是一种分析源代码并检测代码库中任何潜在问题的过程。我们将使用 ESLint,它是 JavaScript 最流行的 linting 工具。它可以配置不同的插件和规则,以适应我们应用程序的需求。

ESLint 配置在项目根目录的 .eslintrc.js 文件中定义。可以添加不同的规则、使用不同的插件扩展它们,并覆盖要应用规则的文件,以满足应用程序的需求。

.eslintrc.js

{
  "extends": "next/core-web-vitals"
}

有时,我们不想 lint 每个文件夹和文件,可以在 .eslintignore 文件中定义要忽略的文件夹和文件。

ESLint 与编辑器和 IDE 的集成非常好,这让开发者可以在编写代码时看到文件中的任何潜在问题。

可以使用在 package.json 中定义的 lint 脚本来运行 ESLint:

{
  "scripts": {
    "lint": "next lint"
  }
}

Prettier

Prettier 是一款很好的代码格式化工具。它能够在整个代码库中强制执行一致的编码风格。通过在我们的 IDE 中使用“保存时格式化”的功能,我们可以根据 .prettierrc 文件中提供的配置自动格式化代码。它还会在代码有问题时给我们很好的反馈。如果它没有自动格式化代码,说明代码有问题,需要进行修复。

Prettier 提供了一个默认配置。我们可以通过创建 .prettierrc 文件并修改配置来覆盖默认配置。

与 ESLint 一样,有时候我们不想自动格式化某些文件。我们可以在 .prettierignore 文件中添加文件和文件夹来告诉 Prettier 忽略它们。

提交前检查

对于 TypeScript、ESLint 和 Prettier 这样的静态代码分析工具是很好的,我们已经配置好它们,并且可以在进行更改时运行单个脚本,以确保一切都处于最佳状态。

但是,这些工具也存在一些缺点。开发人员可能会忘记在提交到代码库之前运行所有检查,这仍然可能导致问题和不一致的代码进入生产环境。

幸运的是,有一种解决方案可以解决这个问题:可以在准备提交到代码库时,以自动化的方式运行所有检查。

可以使用 huskylint-staged 这两个库来实现:

  • husky 可以在我们的代码库中添加 Git 钩子
  • lint-staged 允许我们仅对 Git 暂存区域中的文件运行这些检查,这提高了代码检查的速度,因为在整个代码库上执行此操作可能会太慢

Commitizen

Commitizen 是一个用于规范化 Git 提交消息的工具。它可以帮助我们创建符合规范的提交消息,这样我们就可以使用工具来生成更好的更改日志。

pnpm install -D commitizen cz-conventional-changelog

commitizen init cz-conventional-changelog --pnpm --save-dev --save-exact

项目结构

就像前提到过的,React 在项目结构方面非常灵活。

拥有良好的项目结构的一些好处如下:

  • 职责分离
  • 更容易进行重构
  • 更好地理解代码库
  • 更容易让大型团队同时在代码库上协作开发

推荐使用基于领域/功能的结构:

src
├── components
├── config
├── features
├── layouts
├── lib
├── pages
├── providers
├── stores
├── testing
├── types
└── utils

具体目录的作用如下:

  • components:包含所有的共享组件
  • config:包含应用程序的配置文件
  • features:包含所有基于领域/功能的模块
  • layouts:包含页面的布局组件
  • lib:包含用于应用程序的不同库的配置
  • pages:包含所有页面,这是 Next.js 将在基于文件的路由中查找页面的位置
  • providers:包含应用程序的所有上下文 provider
    • 如果我们的应用程序使用许多不同的 provider 来进行样式、状态等操作,可以在此处将它们组合起来,然后导出一个单独的 provider
    • 可以将导出的 provider 用于我们的 _app.tsx,以使所有 provider 在所有页面上可用
  • stores:包含在应用程序中使用的所有全局状态存储
  • testing:包含与测试相关的模拟、帮助程序、实用程序和配置
  • types:包含在整个应用程序中使用的基本 TypeScript 类型定义
  • utils:包含应用程序中使用的所有共享工具函数

当项目开始时,根据类型将文件分组并将它们放在同一个文件夹中并没有什么问题。然而,随着应用程序规模的增长,由于存在大量同一类型的文件,导致难以理解和维护代码库。

按领域/功能拆分

为了以最简单和可维护的方式扩展应用程序,可以将大部分应用程序代码放在 features 文件夹中,该文件夹应包含不同的基于功能的内容。每个功能文件夹应包含给定功能的特定领域代码。这样我们可以将功能限定在一个特定的功能范围内,而不是将其声明与共享内容混合在一起。这比具有许多文件的扁平文件夹结构容易维护得多。

在某个 feature 文件夹中,我们可以将代码分为以下几个部分:

featureA
├── api
├── components
├── types
├── hooks
├── utils
└── index.ts
  • api:包含与特定功能相关的 API 请求声明和 API 钩子,这样能将 API 层和 UI 层分开,并可重用
  • components:包含与特定功能相关的组件
  • types:包含与特定功能相关的类型定义
  • hooks:包含与特定功能相关的自定义 React 钩子
  • utils:包含与特定功能相关的工具函数
  • index.ts:这是每个功能的入口点,它作为该功能的公共 API,并且只应导出其他模块可以访问的内容