React 项目搭建

466 阅读10分钟

Summary

搭建 React 项目的几种方案介绍。以及 React 生态圈内会常用到的一些技术方案选择,如:样式方案、CSS-in-JS、路由、网络请求、全局状态管理、项目开发体验和工程化(eslint, prettier, husky + lint-staged)、多语言国际化、动画、暗黑模式

  1. create-react-app
  2. vite
  3. ant-design-pro, umi
  4. Next.js / Remix / Gatsby

Prerequisites

常见的线上编辑代码工具

  1. CodePen
  2. CodeSandbox(丰富的初始化模版快速创建项目,可在线编辑并允许,可分享链接)
  3. StackBlitz (丰富的初始化模版快速创建项目,可在线编辑并允许,可分享链接) vite.new/
  4. github.dev (在任意 Github 项目点击 . 可以直接在网页打开,方便查看项目源码)
React 语法

React 语法

ReactVue是目前最流行的前端 UI 框架,由于 React 是一个蛮大的话题,可能需要自行花费一定时间学习才能正确明白和适用。所以语法部分这里不多介绍,就简单介绍一些基本的语法。

  1. JSX

JSXJavaScript语言的扩展,类似于 html 标签,可以用来结构化的展示页面组件构成,具于 JavaScript 灵活的特点和运算能力。

const element = <h1>Hello, world!</h1>;

Hello World

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<h1>Hello, world!</h1>);

参数

const element = <a href="https://www.reactjs.org"> link </a>;

表达式

function formatName(user) {
  return user.firstName + ' ' + user.lastName;
}

const user = {
  firstName: 'Harper',
  lastName: 'Perez'
};

const element = (
  <h1>
    Hello, {formatName(user)}!
  </h1>
);

最终 JSX 也会被编译成 JavaScript

React.createElement('div', {style: 'color: red;'})
  1. 组件

React 中,最重要的概念就是组件,每个页面可以由数个组件组成,每个组件可以拥有自己独特的功能特性和界面展示。

函数组件

function Button() {
  return <button className="button">Hello</button>
}

类组件

class Button extends React.Component {
  // ...省略生命周期等方法
  render() {
    return <button className="button">Hello</button>
  }
}
  1. propsstate

给组件传参数和组件间通信通过 props

function Welcome(props) {
  return <h1>Hello, {props.name}</h1>;
}

组件内部可以维护自由状态 state

class Clock extends React.Component {
  constructor(props) {
    super(props);
    this.state = {date: new Date()};
  }

  render() {
    return (
      <div>
        <h1>Hello, world!</h1>
        <h2>It is {this.state.date.toLocaleTimeString()}.</h2>
      </div>
    );
  }
}

propsstate 的改变都会触发组件的 rerender

  1. 事件处理
<button onClick={activateLasers}>
  Activate Lasers
</button>
  1. 生命周期和 hooks

类组件才有生命周期,在 React 16.8 未引入 hook 概念之前,函数组件无法由于 state,所以功能单一并且只能用于展示组件。但是 hooks 使得函数组件能够维护自身 state 并且将各个逻辑抽取封装成 hooks 函数使得组件逻辑更加易读、代码复用高、非常有利于项目维护,目前都是会采用函数组件加 hooks 写法编写 React 项目。

import React, { useState } from 'react';

function Example() {
  // Declare a new state variable, which we'll call "count"
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

方案一:Create React app

create-react-appReact 团队官方提供的一套快速搭建和初始化 React 项目的脚手架工具。基于 Webpack (前端最成熟、适用范围最广的项目打包工具) 无需开发者手动从零开始配置,也提供一定的项目配置能力。

初始化项目

npx create-react-app my-app
cd my-app
npm start

# typescript
npx create-react-app my-app --template typescript

# 其他模版
npx create-react-app my-app --template [template-name]

项目目录结构

my-app
├── README.md
├── node_modules # 依赖
├── package.json # 项目信息、运行命令、依赖管理
├── .gitignore
├── public # 公共资源路径
│   ├── favicon.ico
│   ├── index.html # html 模版文件
│   ├── logo192.png
│   ├── logo512.png
│   ├── manifest.json
│   └── robots.txt
└── src # 源代码目录
    ├── App.css
    ├── App.js # 主组件入口
    ├── App.test.js
    ├── index.css
    ├── index.js # 项目入口文件
    ├── logo.svg
    ├── serviceWorker.js
    └── setupTests.js

命令

# 运行项目
npm start
# 打包
npm run build

样式方案

  1. css/less/scss
    css 和常见的 css 预处理器都是默认支持的,这也是最常见的写法。只需要新建样式文件,然后组件内引入该样式文件通过 className 来对应样式,基本相同于 html 原生的写法
.button {
  color: red;
}
import './button.less';

function Button() {
  return <button className="button">Hello</button>
}
  1. CSS-in-JS 方案之 emotion

CSS-in-JS 方案的优势在于样式和组件在同一个文件内,编写组件时不需要不停切换文件。并且由于使用 JavaScript 编写样式可以利用其灵活性,变量,主题切换,样式复用都会变得更简单。各种 CSS-in-JS 库的出现都简化了样式和组件编写的过程。常见的有 styled-components,Emotion, vanilla-extract

Create React App 优势

  1. 官方团队提供和维护,较稳定
  2. 无需配置

Create React App 缺点

  1. 缺少新功能
  2. 项目较大之后打包速度慢,需要一定优化手段
  3. 虽然无需配置,但是其实是将各种工具的配置入口隐藏,需要一些自定义配置时可能无法做到,比如配置 postcss 插件
  4. 周边生态缺少维护,如用来自定义配置的 cracocreate-react-app 更新到 5 之后很长时间都没有跟进支持,主开发者无精力继续维护项目等

方案二:Vite

Vite 优势

  1. 快。开发模式快,编译快(ESM + esbuild(go 语言写的 JavaScript 编译器) + 依赖预编译等)
  2. 与框架无关,可以用于任意前端框架项目
  3. 配置灵活,功能丰富
  4. 打包基于 rollup,有丰富的插件

初始化项目

npm create vite@latest

npm create vite@latest my-app -- --template react-ts

创建目录结构

cd src
mkdir -p assets assets/icons assets/images components constants pages pages/home styles utils

工程化

  1. eslint
  2. prettier
  3. husky + lint-staged
  4. rollup-plugin-visualizer 用于分析打包依赖大小
npm i -D eslint eslint-config-react-app eslint-config-prettier prettier lint-staged rollup-plugin-visualizer @types/node@16 cross-env
npx husky-init && npm install
touch .eslintrc .eslintignore .prettierrc .prettierignore

.eslintrc

{
  "extends": [
    "react-app",
    "react-app/jest",
    "prettier"
  ]
}

.prettierrc

{
  "singleQuote": true,
}

package.json

{
  "scripts": {
    "analyze": "tsc && cross-env ANALYZE=true vite build",
  },
  "lint-staged": {
    "**/*.{js,jsx,ts,tsx}": "eslint --fix --ext .js,.jsx,.ts,.tsx",
    "**/*.{js,jsx,tsx,ts,less,css,json}": [
      "prettier --write"
    ]
  }
}

.husky/pre-commit

#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

npx lint-staged

vite.config.ts

import { visualizer } from 'rollup-plugin-visualizer';
import { defineConfig, splitVendorChunkPlugin } from 'vite';

// 打包生产环境才引入的插件
if (process.env.NODE_ENV === 'production') {
  process.env.ANALYZE &&
    plugins.push(
      visualizer({
        open: true,
        gzipSize: true,
        brotliSize: true,
      })
    );
}

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [splitVendorChunkPlugin(), react(), ...plugins],
  resolve: {
    alias: [
      {
        find: '@',
        replacement: '/src',
      },
    ],
  }
});

组件库

选项

npm i react-vant@next

使用

import { Button } from 'react-vant';

样式方案/样式覆盖/暗黑模式

样式方案还是采用 css + less + className。需要考虑暗黑模式,所有样式都用 CSS 变量,然后根据 html 标签属性切换

由于是 H5 项目,需要考虑不同设备屏幕大小适配,引入插件 postcss-px-to-viewport 可以自动将 px 单位转换为 vw 单位

npm i -D less postcss-px-to-viewport
cd styles
touch css-variable.less global.less index.less react-vant.less variables.less common.less

postcss.config.js

module.exports = {
  plugins: {
    'postcss-px-to-viewport': {
      viewportWidth: 375,
    },
  },
}

index.less

@import './css-variables.less';
@import './react-vant.less';
@import './global.less';
@import './common.less';

css-variables.less

:root {
  --primary-color: #333;
  --page-bg: #f5f5f5;
}

:root[data-theme='dark'] {
  --primary-color: #fff;
  --page-bg: #0a1929;
}

图标引入

图标都采用 SVG 可以方便使用、修改大小、颜色,下载到项目本地,通过 svgrvite-plugin-svgr 可以当成 React 组件直接引入

npm i -D vite-plugin-svgr

vite.config.ts

import svgr from 'vite-plugin-svgr';

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [svgr({
    // 这个选项将组件导出到 default 而不是 ReactComponent
    // exportAsDefault: true,
    svgrOptions: {
      icon: true,
      // 删除 svg fill 颜色
      replaceAttrValues: {
        none: 'currentColor',
        black: 'currentColor',
        '#62626B': 'currentColor',
        '#686872': 'currentColor',
        '#0055FF': 'currentColor',
        '#3F7FFF': 'currentColor',
        '#A8A8A8': 'currentColor',
        '#1A1A1A': 'currentColor',
        '#EA4D44': 'currentColor',
      },
    },
  }),],
});

vite.env.d.ts 增加这个类型可以帮助 TS 识别 svg 导入

/// <reference types="vite-plugin-svgr/client" />

使用

import { ReactComponent as Logo } from './logo.svg'

网络请求

使用熟悉的 axios 通过拦截器封装网络请求,网络请求代码部分使用工具 openapi2typescript 通过后端提供的 Swagger 文档自动生成

npm i -D @umijs/openapi
touch openapi.config.js
# 在 package.json 中增加了命令之后可以每次都通过此命令生成网络请求代码
npm run openapi

package.json

{
  "scripts": {
    "openapi": "node openapi.config.js",
  }
}

openapi.config.js

const { generateService } = require('@umijs/openapi')

generateService({
  schemaPath: 'http://petstore.swagger.io/v2/swagger.json',
  serversPath: './servers',
})

/**
 * 生成app端接口
 */
generateService({
  requestLibPath: "import request from '@/utils/request'",
  schemaPath: 'http://192.168.2.147:9088/v3/api-docs?group=app端汇总',
  serversPath: './src/services',
  projectName: 'app',
});

src/utils/request.ts

import { store } from '@/store';
import { toggleIsLogin } from '@/store/reducers/user';
import axios, { AxiosRequestConfig } from 'axios';
import { Toast } from 'react-vant';
import { getUserToken, removeUserToken, TOKEN_KEY } from './auth';

export const API_CODES = {
  SUCCESS: 200,
  UNAUTHORIZED: 401,
  FORBIDDEN: 403,
};

export const isRequestSuccess = (res: API.ApiResponse) => {
  return res && res.code === API_CODES.SUCCESS;
};

export const isNeedLogin = (res: API.ApiResponse) => {
  return res && res.code === API_CODES.SUCCESS;
};

const request = axios.create({});

request.interceptors.request.use(
  (config) => {
    const token = getUserToken();

    if (token && config.headers) {
      config.headers[TOKEN_KEY] = token;
    }

    return config;
  },
  (error) => Promise.reject(error)
);

request.interceptors.response.use(
  (response) => {
    const res = response.data;

    if (isRequestSuccess(res)) {
      return response; // axios 类型不支持返回 response 中的 data,将 response 整个返回,后续取数据需要 res.data 中获取
    }

    // 如果用户未登录跳转至登录页面
    if (isNeedLogin(res)) {
      store.dispatch(toggleIsLogin(false));
      removeUserToken();
      window.location.reload(); // TODO: toLogin()
    }
    const errMsg = res.msg || '请求错误';
    Toast(errMsg);
    return Promise.reject(response);
  },
  async (error) => {
    let errMsg = '请求错误';
    if (error.response) {
      const { data } = error.response;
      errMsg = data.msg || errMsg;
    }
    Toast(errMsg);
    return Promise.reject(error);
  }
);

/**
 * 网络请求,封装后的 axios
 * 1. AxiosInstance 不支持泛型,openapi2typescript 生成的请求会带上请求接口返回数据类型,
 * 2. openapi2typescript 默认采用 umi_request 模板,会添加额外参数 requestType, axios 不支持该参数
 * @param {string} url
 * @param {AxiosRequestConfig<R>} config
 * @returns
 */
function requestMethod<T>(
  url: string,
  config: AxiosRequestConfig<any> & { requestType?: string }
) {
  const _config = {
    ...config,
  };

  if (config.requestType === 'json') {
    _config.headers = {
      Accept: 'application/json',
      'Content-Type': 'application/json;charset=UTF-8',
      ..._config.headers,
    };
  } else if (config.requestType === 'form') {
    _config.headers = {
      Accept: 'application/json',
      'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
      ..._config.headers,
    };
  }

  delete _config.requestType;

  return request.request<T>({
    url,
    ..._config,
  });
}

export default requestMethod;

全局状态管理

用于保存一些需要全局存储的信息,比用用户登录状态、用户信息等

选项

npm i redux react-redux @reduxjs/toolkit redux-logger
mkdir -p src/store src/store/reducers
touch src/store/index.ts src/store/hooks.ts

src/store/index.ts

import { Action, configureStore, ThunkAction } from '@reduxjs/toolkit';
import logger from 'redux-logger';
import counterReducer from './reducers/counterSlice';
import user from './reducers/user';

const middlewares: any[] = [];

if (import.meta.env.DEV) {
  middlewares.push(logger);
}

export const store = configureStore({
  reducer: { counter: counterReducer, user },
  middleware: (getDefaultMiddleware) => {
    return getDefaultMiddleware().concat(middlewares);
  },
});

export type AppDispatch = typeof store.dispatch;
export type RootState = ReturnType<typeof store.getState>;
export type AppThunk<ReturnType = void> = ThunkAction<
  ReturnType,
  RootState,
  unknown,
  Action<string>
>;

src/store/hooks.ts

import type { AppDispatch, RootState } from '@/store';
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';

// Use throughout your app instead of plain `useDispatch` and `useSelector`
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

main.tsx

import React from 'react';
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';
import App from './App';
import { store } from './store';
import './styles/index.less';

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <Provider store={store}>
      <App />
    </Provider>
  </React.StrictMode>
);

使用

import { useAppDispatch, useAppSelector } from '@/store/hooks';
import { fetchUserInfo } from '@/store/reducers/user';
import { useEffect } from 'react';
import { Navigate, useLocation } from 'react-router-dom';

function RequireAuth({ children }: { children: JSX.Element }) {
  const location = useLocation();
  const isLogin = useAppSelector((state) => state.user.isLogin);
  const userInfo = useAppSelector((state) => state.user.userInfo);
  const dispatch = useAppDispatch();

  useEffect(() => {
    if (isLogin && !userInfo) {
      dispatch(fetchUserInfo());
    }
  }, [dispatch, isLogin, userInfo]);

  if (!isLogin) {
    // Redirect them to the /login page, but save the current location they were
    // trying to go to when they were redirected. This allows us to send them
    // along to that page after they login, which is a nicer user experience
    // than dropping them off on the home page.
    return <Navigate to="/login" state={{ from: location }} replace />;
  }

  return children;
}

export default RequireAuth;

路由

npm install react-router-dom@6

App.tsx

import { lazy, Suspense, useEffect, useState } from 'react';
import { Route, Routes } from 'react-router-dom';
import { ConfigProvider } from 'react-vant';
import { Theme, useTheme } from './components/ThemeProvider';
import PageChannel from './pages/channel';
import PageHome from './pages/home';
import PageSubscribe from './pages/subscribe';
import PageUser from './pages/user';

// const PageSubscribe = lazy(() => import('./pages/subscribe'))
// const PageChannel = lazy(() => import('./pages/channel'))
// const PageUser = lazy(() => import('./pages/user'))
const PageMember = lazy(() => import('./pages/user/member'));

function App() {
  return (
    <div className="App">
        <Suspense fallback={<>...</>}>
          <Routes>
            <Route path="/" element={<PageHome />} />
            <Route path="subscribe" element={<PageSubscribe />} />
            <Route path="channel" element={<PageChannel />} />
            <Route path="user" element={<PageUser />} />
            <Route path="user/member" element={<PageMember />} />
          </Routes>
        </Suspense>
    </div>
  );
}

export default App;

main.tsx

import { BrowserRouter } from 'react-router-dom';

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
      <BrowserRouter>
        <App />
      </BrowserRouter>
  </React.StrictMode>
);

多语言国际化

选项

npm install react-i18next i18next i18next-browser-languagedetector --save

i18n.ts

import i18n from 'i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import { initReactI18next } from 'react-i18next';
import en from './i18n/en-US.json';
import zhTw from './i18n/zh-TW.json';

i18n
  // detect user language
  // learn more: https://github.com/i18next/i18next-browser-languageDetector
  .use(LanguageDetector)
  // pass the i18n instance to react-i18next.
  .use(initReactI18next)
  // init i18next
  // for all options read: https://www.i18next.com/overview/configuration-options
  .init({
    debug: import.meta.env.DEV,
    fallbackLng: 'zhTw',
    interpolation: {
      escapeValue: false, // not needed for react as it escapes by default
    },
    resources: {
      en: {
       translation: en,
      },
      zhTw: {
        translation: zhTw,
      },
    },
  });

export default i18n;

i18n/en-US.json

{
  "title": "Title",
}

i18n/zh-TW.json

{
  "title": "标题",
}

使用

import PageContainer from '@/components/PageContainer';
import PageContainerContent from '@/components/PageContainer/PageContainerContent';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { NavBar } from 'react-vant';

function PageMessageGroups() {
  const { t } = useTranslation();
  const navigate = useNavigate();

  const handleClickLeft = () => {
    navigate(-1);
  };

  return (
    <PageContainer>
      <NavBar
        title={t('app.navbar.group')}
        fixed
        placeholder
        border={false}
        onClickLeft={handleClickLeft}
      />
      <PageContainerContent>PageMessageGroups</PageContainerContent>
    </PageContainer>
  );
}
export default PageMessageGroups;

template

配置太复杂了,每个项目都要这样重头做一遍吗?不用!直接从 GitLab Template 生成项目!

方案三:Next.js / Remix / Gatsby

Next.js 是成熟的 React 框架,优势在于开箱即用的 SSR,约定式目录结构、文件路径路由、丰富的性能优化等等,比较适用于官网、强 SEO、页面性能要求较高场景,也可用于任意通用 React 项目。

方案四:Umi + Ant Design Pro

Ant Design 是阿里开源的一套非常优秀的 React 组件库,Umi 是类似于 Next.js 的前端开发框架,也是开箱即用,内置多种最佳开发实践,可以专注于业务开发。

Ant Design Pro 在基于 Ant Design 组件库和 Umi 框架之上,封装了一套开箱即用的中台前端/设计解决方案

初始化项目

# 使用 npm
npx create-umi myapp

按照 umi 脚手架的引导,第一步先选择 ant-design-pro:

? Select the boilerplate type (Use arrow keys)
❯ ant-design-pro  - Create project with a layout-only ant-design-pro boilerplate, use together with umi block.
  app             - Create project with a easy boilerplate, support typescript.
  block           - Create a umi block.
  library         - Create a library with umi.
  plugin          - Create a umi plugin.

安装依赖:

$ cd myapp && npm install

启动项目

npm start