likes
comments
collection
share

7年前端只会Vue? 用一周快速入门 react 并实现配置中心项目

作者站长头像
站长
· 阅读数 9

这里通过将 vue3+ts+pinia 配置中心项目用 react18+react-router-redux 函数组件方式重写,快速掌握 react 基础的 CRUD。

7年前端只会Vue? 用一周快速入门 react 并实现配置中心项目

前言

虽然做前端有些年了,但都是用的 Vue,基本不会 React,工作中没有写过一行 React 代码。最近花时间学了下 React,下面是我从 Vue 转 React 的一些经验总结。

先定一个小目标,从 react 零基础到可以用 react 完成一个简单配置中心项目。

如下图,配置中心项目包含一个登录页(包含token鉴权)、一个短链接配置页面(表格、增删改查),接口都是真实的,比较适合入门新的前端框架,参考:fe-framework-study

7年前端只会Vue? 用一周快速入门 react 并实现配置中心项目

其中 Vue3 版本是之前已经实现了的,这次主要用 react 重写一遍。对应 Koa.js 接口服务开源地址:zuo-config-server 包含 jwt 登录鉴权 + 允许跨域中间件 + mongodb 增删改查等。

前端框架脚手架UI 库路由状态管理接口请求
Vue (3.2)npm init vueElement PlusVue RouterPiniaAxios
React (18)creat-react-appAntd (v5.8)React-routerReduxAxios

React 基础语法入门

看书、官网文档

最先我计划是通过看 《React Hooks 实战》 这本书来入门,但看了一小部分后,感觉文字太多了,对新手不是很友好,它更适用于在有一定 react 基础后,进行性能优化、hooks、可复用方面的进阶提升。

7年前端只会Vue? 用一周快速入门 react 并实现配置中心项目

放弃看书后,开始看最新的交互式官网文档 react.dev ,先通过 Quick Start 快速熟悉 react 基础语法:如何显示数据、JSX 语法、函数式组件、条件渲染、列表渲染、更新页面数据

// 函数式组件
function MyButton() {
  return (
    <button>
      I'm a button
    </button>
  );
}

// jsx 语法 hello world
export default function MyApp() {
  return (
    <div>
      <h1>Welcome to my app</h1>
      <MyButton />
    </div>
  );
}

用 React 实现井字游戏

在官网中,有一个 Tic-Tac-Toe 井字游戏的教程,可以用来很好的熟悉 react 基础语法,比如页面渲染/更新、事件处理等。

但感觉官网讲的太细了,比较啰嗦,由于我之前有 Vue 基础,所以我在看完 Quick Start 基础语法后,就开始不看教程直接按功能自己尝试用 react 来实现这个小游戏了。

7年前端只会Vue? 用一周快速入门 react 并实现配置中心项目

翻了下官网,创建一个 react 项目工程,新官网直接推荐使用 Next.js 或 Remix 等第三方框架,于是直接用 npx create-next-app 创建一个 Next.js 工程,用于实现井字游戏。

生成的项目目录后如下图,默认使用 App Router,主页是 app/page.tsx。这里我需要新开一个页面,在 app 目录下创建 game/page.tsx 即可通过 localhost:3000/game 访问该函数组件页面

7年前端只会Vue? 用一周快速入门 react 并实现配置中心项目

然后再想一下,怎么逐渐实现该游戏

  1. 怎么在页面画 9 宫格
  2. 点击方块后怎么让对应方格显示 X/O,事件怎么处理?
  3. 怎么判断谁获胜了
  4. 点击 ReStart 重新开始,怎么清除数据,重新开始游戏

7年前端只会Vue? 用一周快速入门 react 并实现配置中心项目

下面是我写的具体实现代码,完整代码参考:0-next-tic-tac-toe - github

"use client";

import { useState } from "react";
import "./game.scss";

export default function Game() {
  const [current, setCurrent] = useState("O");
  const [initArr, setInitArr] = useState(new Array(9).fill(""));
  let [hasWin, setHasWin] = useState(false)
  let [resultInfo, setResultInfo] = useState('')

  const handleClick: (index: number) => undefined = (index: number) => {
    if (hasWin) {
        return
    }
    if (initArr[index] === "") {
      initArr[index] = current;
      setInitArr(initArr);
      setCurrent(current === "O" ? "X" : "O");
      judgeResult()
    }
  };

  const list = initArr.map((item, index) => {
    return (
      <div className="square" key={index} onClick={(e) => handleClick(index)}>
        {item}
      </div>
    );
  });

  //   1 2 3
  //   4 5 6
  //   7 8 9
  const judgeResult = () => {
    let winList = [
      [1, 2, 3],
      [4, 5, 6],
      [7, 8, 9],
      [1, 4, 7],
      [2, 5, 8],
      [3, 6, 9],
      [1, 5, 9],
      [3, 5, 7],
    ];
    let win = winList.some(item => {
        let [a, b, c] = item
        if (initArr[a - 1] !== '' && initArr[a - 1] === initArr[b - 1] && initArr[b - 1] === initArr[c - 1]) {
            setResultInfo(initArr[a - 1] + ' 获胜')
            return true
        }
    })
    setHasWin(win)
    if (!win && initArr.every((item, index) => item !== '')) {
        setResultInfo('游戏结束,平局')
    }
  };

  const handleRestart = () => {
    setInitArr(new Array(9).fill(''))
    setCurrent('O')
    setHasWin(false)
    setResultInfo('')
  }

  return (
    <div className="main">
      <p>Current {current} <span className="text-purple-600">{resultInfo}</span> </p>
      <button onClick={handleRestart}>Restart</button>
      <div className="square-wrap">{list}</div>
    </div>
  );
}

在纯 html 中使用 React

上面是在 Next.js 中使用 react,但怎么在纯 html 中使用 React 呢?官网提供了一个 demo,下面是 hello world 代码,可以看到主要是 3 个 js 文件在起作用,React 核心功能:react 以及 react-dom;解析 jsx 语法的 babel

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>react html</title>
    <script src="https://unpkg.com/react@18/umd/react.development.js"></script>
    <script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
    <!-- Don't use this in production, because performance slowly -->
    <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
  </head>
  <body>
    <div id="root"></div>
    <script type="text/babel">
      function MyApp() {
      	return <h1>Hello, world!</h1>;
      }

      const container = document.getElementById("root");
      const root = ReactDOM.createRoot(container);
      root.render(<MyApp />);
    </script>
  </body>
</html>

React Hooks 与表单

上面通过实现井字游戏对 react 语法有了基本的熟悉,也知道怎么在 html 中直接使用 react,但好像还没有基础表单,下面来看看 react 表单逻辑

7年前端只会Vue? 用一周快速入门 react 并实现配置中心项目

如上图,有两个 input 输入框,输入内容后实时同步数据到下面的文案中,点击 submit 提交数据到接口,点击 reset 重置表单。感觉 react 表单比 vue v-model 方式要复杂一点,完整代码参考:1-react-html - github


<script type="text/babel">
  const { useState } = React;
  
  // form 表单函数组件
  function LoginForm() {
    const [name, setName] = useState("");
    const [age, setAge] = useState("");

    function nameChange(e) {
      setName(e.target.value);
    }
    function ageChange(e) {
      setAge(e.target.value);
    }
    function reset() {
      setName("");
      setAge("");
    }
    function submit() {
      if (!name || !age) {
        alert("Please enter you name or age!");
        return;
      }
      fetch("/user", {
        method: "POST",
        headers: new Headers({
          "Content-Type": "application/json",
        }),
        body: JSON.stringify({
          name,
          age,
        }),
      })
        .then((res) => console.log(res))
        .catch(console.log);
    }
    return (
      // 阻止默认事件防止页面刷新
      <form onSubmit={(e) => e.preventDefault()}>
        <input
          value={name}
          onChange={nameChange}
          placeholder="Enter your name"
        />
        <input
          value={age}
          onChange={ageChange}
          placeholder="Enter your age"
        />
        <p class="bold">
          Your Input, name: {name}, age: {age}
        </p>
        <button onClick={submit}>Submit</button>
        <button onClick={reset}>Reset</button>
      </form>
    );
  }
  const container = document.getElementById("root");
  const root = ReactDOM.createRoot(container);
  root.render(
    <div>
      <Greeting name="dev-zuo" />
      <LoginForm />
    </div>
  );
</script>

对 React 的一些理解

这部分内容涉及对 React 的一些理解,如果只是想用 React 快速搬砖可以直接跳过这部分,直接看下一章 React 实现配置中心

setXxx 后值没变的问题

在井字游戏 history 回溯逻辑时,将九宫格渲染数据替换为历史数据(setInitArr(arr))后,再调用 judgeResult 判断是否有获胜者时,发现 setInitArr 调用后,judgeResult 拿到的 initArr 数据还是旧的,不是最新的值,导致判断逻辑异常,加了 setTimeout 5秒 都没用

这里 useState 返回的 setXxx 是异步执行的, 直接取值会取不到,如果设置后,一定要用新值去做一些逻辑,需要在 useEffect 回调函数中处理

  const [initArr, setInitArr] = useState(new Array(9).fill(""));
  
  function toSomeStep(stepInfo: any) {
    let { arr, current } = stepInfo;
    setInitArr(arr);
    // judgeResult() // 读取最新 initArr 值判断胜负
    setCurrent(current === "O" ? "X" : "O");
  }

  // 跳到某一个节点时,重新判断是否结束,setXxx 为异步,加 setTimeout 都没用
  useEffect(() => {
    // Code here will run after *every* render
    judgeResult()
  })

状态的理解 what is state?

最开始接触 State 状态这个词的时候,是在 Vue 状态管理(Vuex) 文档中,我一般把它简单理解为 State 状态就是 data 数据。状态管理,就是对数据的管理。

React 文档中对于 State 状态的描述比较好。State: A Component's Memory(状态: 一个组件的记忆)

Components often need to change what’s on the screen as a result of an interaction. Typing into the form should update the input field, clicking “next” on an image carousel should change which image is displayed, clicking “buy” should put a product in the shopping cart. Components need to “remember” things: the current input value, the current image, the shopping cart. In React, this kind of component-specific memory is called state.

组件通常需要更新屏幕上的内容作为交互的结果,在表单中输入应该更新输入字段,单击图像轮播上的“下一步”应更改显示的图像,单击“购买”应该将产品放入购物车。组件需要“记住”事物:当前输入值、当前图像、购物车。在 React 中,这种特定于组件的记忆称为状态。

纯函数与副作用 effect

在 Vue3 中我不是很理解新出的 watchEffect,为什么要 watch 副作用?在 react 中又有一个 useEffect,那副作用是什么?怎么理解副作用呢?React 文档中 Keeping Components Pure 描述的很好,强烈建议看一遍这个文档,对应中文版 保持组件纯粹,下面是简单的理解。

要理解副作用(Side effects),需要先理解纯函数(Pure function)的概念

Some JavaScript functions are pure. Pure functions only perform a calculation and nothing more. By strictly only writing your components as pure functions, you can avoid an entire class of baffling bugs and unpredictable behavior as your codebase grows.

有些 JavaScript 函数是纯函数。纯函数仅执行计算,仅此而已。通过严格仅将组件编写为纯函数,可以避免随着代码库的增长而出现一整类令人困惑的错误和不可预测的行为。

可以简单的理解为尽量让组件保持纯净、仅用于显示逻辑,业务逻辑写在外部,这样更有利于维护。

  • 纯函数不会改变函数作用域之外的变量,它只管自己的事,不会更改渲染之前存在的任何变量。
  • 给定相同的输入,组件应该始终返回相同的 JSX。
function Recipe({ drinkers }) {
  return (
    <ol>    
      <li>Boil {drinkers} cups of water.</li>
      <li>Add {0.5 * drinkers} cups of milk to boil and sugar to taste.</li>
    </ol>
  );
}
export default function App() {
  return (
    <section>
      <h2>For two</h2>
      <Recipe drinkers={2} />
      <h2>For a gathering</h2>
      <Recipe drinkers={4} />
    </section>
  );
}

哪些地方可能引发副作用?

函数式编程在很大程度上依赖于纯函数,但 某些事物 在特定情况下不得不发生改变。这是编程的要义!这些变动包括更新屏幕、启动动画、更改数据等,它们被称为 副作用。它们是 “额外” 发生的事情,与渲染过程无关。

在 React 中,副作用通常属于 事件处理程序。事件处理程序是 React 在你执行某些操作(如单击按钮)时运行的函数。即使事件处理程序是在你的组件 内部 定义的,它们也不会在渲染期间运行! 因此事件处理程序无需是纯函数

如果你用尽一切办法,仍无法为副作用找到合适的事件处理程序,你还可以调用组件中的 useEffect 方法将其附加到返回的 JSX 中。这会告诉 React 在渲染结束后执行它。然而,这种方法应该是你最后的手段

React 实现配置中心

vue3 版本 github.com/dev-zuo/zuo…

这里写一个 react 版本,对应仓库 github.com/react-chall…

脚手架

首先需要创建一个新的工程来开始这个项目,由于新官网只有 Next.js、Remix 等文档,但印象中,有专门的脚手架,看了下旧的官网,找到了 create-react-app 文档。

之前很多旧的项目都是用它来创建的,这里就用这个脚手架来创建新项目

npx create-react-app my-app

创建完成后,比较原始,感觉体验完全比不上 vue-cli,很多必要的组件都需要自己引入。比如 ts、路由、状态管理等。在 Vue 中习惯配置的 src @ 别名,在文档中没有找到可以配置的地方。创建后目录结构如下

7年前端只会Vue? 用一周快速入门 react 并实现配置中心项目

整体布局 Antd 与 sass

用脚手架创建好项目后,开始基础布局:顶部导航、左侧菜单,右侧主内容区域

7年前端只会Vue? 用一周快速入门 react 并实现配置中心项目

直接修改 src/App.js,把之前 Vue 版本 App.vue 的代码拷贝过来改一改,先有个基本的样式,逻辑待后面修改

import './App.scss';
import MenuLeft from './components/MenuLeft';

function App() {

  const accountInfo = {}
  return (
    <div>
      <header className="home-header">
        <h1>zuo-config 配置中心</h1>
        <div>
          {accountInfo.name}
          <a href="https://github.com/react-challenge/2-react-config-fe" target="_blank" rel="noreferrer">
            Github
          </a>
        </div>
      </header>
      <main className="home-main">
        <nav className="home-main-left" v-if="!route.meta.hideLeftMenu">
          <MenuLeft />
        </nav>
        <section
          className="home-main-right { route.meta.hideLeftMenu ? 'hide-left-menu' : '' }"
        >
          {/* <RouterView /> */}
        </section>
      </main>
    </div>
  );
}

export default App;

其中左侧菜单,需要使用组件库,用 Ant Design React 代替 Vue 中的 element-plus

在引入 sass 方便写样式,另外 react 没有 vue 那种 scoped css,样式需要手动引入 css 文件,且默认是全局样式。可以通过加一个大类(class) 类包裹,防止样式污染

菜单 components/MenuLeft.jsx 代码如下

import { Menu } from 'antd';

export default function MenuLeft() {
  const menuItems = [
    { key: 'short-link', label: '短连接配置' },
    { key: 'api-mock', label: 'https接口Mock' },
  ]

  return (
    // onClick={onClick}
    <Menu
      style={{ width: 256 }}
      defaultSelectedKeys={['short-link']}
      mode="inline"
      items={menuItems}
    />
  );
}

路由 React-Router

点击左侧菜单需要刷新右侧内容组件,需要用到类似 vue-router 这样的路由组件

7年前端只会Vue? 用一周快速入门 react 并实现配置中心项目

create-react-app 默认没有集成路由,在网上找了下可以使用 React-Router,看了下文档,这个有点整复杂了,而且路由按需懒加载还贼麻烦,感觉没有 vue-router 设计的好。

相比上面写井字游戏使用的 Next.js 来讲,React-Router 显的有点原始,Next.js 自带路由功能,按照目录就可以自动生成路由,下面是 Vue Router 与 React Router 对比

路由类型链接组件路由占位组件编程式跳转路由拦截当前路由
Vue-Routerrouter-linkrouter-viewrouter.push() 等router.beforeEachroute
React-RouterLinkOutletuseNavigate()loader 函数useHref

修改 src/App.js 中的 <RouterView /><Outlet />,修改 src/index.js 增加路由配置

import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import {  createBrowserRouter, RouterProvider } from "react-router-dom";
// 三个页面组件
import ApiMock from './views/ApiMock';
import Login from './views/Login';
import ShortLink from './views/ShortLink';

const router = createBrowserRouter([
  {
    path: "/",
    element: <App />,
    children: [
        {
          path: "/short-link",
          // 这里面可以加 loader、action 等参数处理拦截、请求数据逻辑
          element: <ShortLink />
        },
        {
          path: "/api-mock",
          element: <ApiMock />
        },
        {
          path: "/login",
          element: <Login />,
        },
    ]
  },
]);

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <React.StrictMode>
    <!-- 路由配置 -->
    <RouterProvider router={router} />
  </React.StrictMode>
);

设置 src/components/MenuLeft.jsx 点击菜单进行路由跳转,当前路由高亮等逻辑,其中

  • 编程式导航,参考:programmatically-navigate-using-react-router
  • 一级路由 redirect 到二级路由,React-Router 中并没有找到 Vue-Router 中 redirect 参数配置,这里我在菜单组件加载时手动处理跳转,另外由于函数组件中没有 mounted 这种钩子,只能用 useEffect 处理,可能会执行多次。。。。。
  • 路由 meta 元数据在 React-Rouer 中也没有,比较难整,因为我需要在某些页面因此左侧菜单,在 Vue 中我的判断条件就是 v-if="!route.meta.hideLeftMenu",但 React 中就比较麻烦了,需要在页面中增加处理逻辑,参考:chore: hide menu logic
import { Menu } from 'antd';
import { useEffect } from 'react';
import { useNavigate, useHref } from "react-router-dom";

const menuItems = [
  { key: '/short-link', label: '短连接配置' },
  { key: '/api-mock', label: 'https接口Mock' },
]

export default function MenuLeft() {
  const navigate = useNavigate();
  const curHref = useHref()
  let activeKey = curHref === '/' ? '/short-link' : curHref

  const onClick = (e) => {
    console.log('click ', e);
    navigate(e.key) // 点击后跳转
  };

  useEffect(() => {
    if (curHref === '/') {
      navigate('/short-link')
    }
  })

  return (
    <Menu
      style={{ width: 256 }}
      defaultSelectedKeys={[activeKey]}
      mode="inline"
      items={menuItems}
      onClick={onClick}
    />
  );
}

axios 接口请求、配置鉴权

完成基础页面结构、路由跳转后,就需要处理业务逻辑了。进入项目,首先要请求接口获取当前登录用户信息,如果没有就跳转到登录页。如下图

7年前端只会Vue? 用一周快速入门 react 并实现配置中心项目

接口请求可以直接复用 Vue3+ts+vite 的 axios 配置代码 ,需要做 3 个修改,完整代码参考:react axios - react-config-fe

  1. 环境变量取值,开发环境、生产环境使用不同的接口地址,在 create-react-app 中有内置环境变量 adding-custom-environment-variables
// src/config/env.js
const _CONFIG_ENV = {
    // development
    development: {
        BASE_URL: 'http://127.0.0.1:5000'
    },
    // production
    production: {
        BASE_URL: 'https://config.zuo11.com'
    }
}

export const CONFIG_ENV = _CONFIG_ENV[process.env.NODE_ENV || 'production']
  1. message 消息提示
// vue + element-plus
import { ElMessage } from "element-plus";
ElMessage.error(plainMsg ? `${msg}: ${plainMsg}` : msg);

// react + antd
import { message } from 'antd';
message.error(plainMsg ? `${msg}: ${plainMsg}` : msg);
  1. 跳转到登录页,路由处理。在 react 中非函数组件中(比如纯 js)无法使用 useNavigate,跳转会比较麻烦,这里直接使用 window.location.href 进行简单设置,详情参考 react-router 在非组件内进行路由跳转
// vue-router
// const router = createRouter({
//   routes: [
//     {
//       path: "/",
//       redirect: () => ({ path: "/short-link" }),
//     },
//   ],
// });
// export default router;
import router from "@/router";
router.push("/login");

// react-router
window.location.href = '/login'

React 函数组件与生命周期钩子

前面引入了 axios 完成了接口请求的基础建设,现在开始请求用户信息接口,当接口返回未登录,axios 中的拦截配置会直接跳转到 Login 界面。

但找了下文档,函数组件好像没有类似 Vue 中的 mounted 这种钩子函数,只找到了类组件中可以使用的生命周期 钩子函数,参考:Adding lifecycle methods to a class component

7年前端只会Vue? 用一周快速入门 react 并实现配置中心项目

  // 类组件钩子函数
  componentDidMount() {  // 但函数式组件中没有。。。。
      this.setupConnection();
  }

  componentDidUpdate(prevProps, prevState) {
    if (
      this.props.roomId !== prevProps.roomId ||
      this.state.serverUrl !== prevState.serverUrl
    ) {
      this.destroyConnection();
      this.setupConnection();
    }
  }

  componentWillUnmount() {
    this.destroyConnection();
  }

// function components methods

函数式组件中需要使用 useEffect,在 src/App.js 主页面组件中,当页面渲染时,调用获取用户登录信息接口,需要加一个判断条件,不然很容易发生多次请求的情况。

import axiosApi from "./utils/axios";

function App() {
  // 获取用户信息
  async function getUserInfo() {
    try {
      // await new Promise((resolve) => setTimeout(resolve, 2000)); // sleep, test loading
      const res = await axiosApi.get("/user/info");
      console.log("获取用户信息", res);
      // 存储用户信息 ...
    } catch (e) {
      console.error(e);
      message.error(e.message);
    }
  }

  let [isMounted, setIsMounted] = useState(false);
  useEffect(() => {
    if (curHref !== "/login" && !isMounted) {
      console.log("get user info");
      getUserInfo();
      setIsMounted(true);
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [curHref, isMounted]);
  
   return (
    // 精简代码
    <div>zuo-config 配置中心</div>

}

登录表单校验逻辑

完成接口请求后,会跳转到登录页,现在来看登录页面组件实现,这里就需要高频率使用 ant-design UI 框架提供的表单、按钮、输入框等逻辑了,其中

  • React 表单并没有 Vue 那种 v-model,这里的 form 只是表单的实例,表单数据获取是在 Form 组件的 onFinish 回调函数中可以拿到,输入框字段通过 Form.Item 的 name 属性指定。
  • Form.Item 的 rules 属性可以直接设置校验逻辑
  • React 中并没有 v-model.trim 这种自动去首尾空格的,处理空格比较麻烦,在rules 中有个 whitespace: true 属性用于处理空格校验
import { Button, Form, Input, message } from 'antd';
import '../styles/login.scss'
import axiosApi from '../utils/axios'
import { useState } from 'react';
import { useNavigate } from 'react-router-dom'

export default function Login() {
    const [form] = Form.useForm();
    const [loading, setLoading] = useState(false)
    const accountInfo = {}
    const formItemLayout = { labelCol: { span: 8 }, wrapperCol: { span: 16 } }
    const navigate = useNavigate()

    // rules 初步校验通过后 submit 逻辑
    const onFinish = async (values) => {
        console.log('Success:', values); // {name: 'qqe', password: 'sf'}
        let form = values
        //  登录
        try {
            setLoading(true)
            // await new Promise((resolve) => setTimeout(resolve, 2000)); // sleep, test loading
            const res = await axiosApi.post("/user/login", {
                name: form.name,
                password: form.password,
            });
            console.log(res);
            if (res.code === 0) {
                message.success("登录成功");
                Object.assign(accountInfo, res.data); // 将用户信息/token 写入 pinia 状态管理
                localStorage.setItem("config-fe-token", res?.data?.token); // 防止刷新页面后 token 丢失
                navigate('/')
            }
        } catch (e) {
            console.error(e);
            message.error(e.message);
        } finally {
            setLoading(false)
        }
    };
    // rules 校验失败调用
    const onFinishFailed = (errorInfo) => {
        console.log('Failed:', errorInfo);
    };

    return (
        <div>
            <div className="tips">游客体验账号: 用户名 admin, 密码 admin</div>
            {loading}
            {/* {accountInfo} */}
            <div className="login-view">
                <Form
                    {...formItemLayout}
                    layout="horizontal"
                    form={form}
                    // initialValues={{ layout: formLayout }}
                    initialValues={{
                        name: 'admin',
                        password: 'admin'
                    }}
                    onFinish={onFinish}
                    onFinishFailed={onFinishFailed}
                >
                    <Form.Item name="name" label="用户名" rules={[
                        {
                            required: true,
                            message: 'Please input your name!',
                        },
                    ]}>
                        <Input placeholder="input username" />
                    </Form.Item>
                    <Form.Item name="password" label="密码" rules={[
                        {
                            required: true,
                            message: 'Please input your passowrd!',
                        },
                    ]}>
                        <Input placeholder="input password" type="password" />
                    </Form.Item>
                    <Form.Item wrapperCol={{ span: 16, offset: 8 }}>
                        <Button type="primary" htmlType='submit'>登录</Button>
                    </Form.Item>
                </Form>
            </div>
        </div>
    )
}

Redux 用户信息存状态管理

上面在登录成功后,并没有将登录成功后获取的用户信息存入状态管理,现在来处理状态管理逻辑

  • 组件传值 props
  • context 类似 provide
  • 状态管理 redux 、 mbox

状态管理选哪个? 推荐大家看下这篇文章 react状态管理选哪个? - 知乎 - 王玉略,以下是这篇文档内容的摘要

React 状态管理分类

  • 没有状态管理工具:直接用 props 或者 context
  • 单项数据流:redux(使用人最多)、zustand
  • 双向绑定:mobx、valtio
  • 状态原子化:jotai、recoil
  • 有限状态机:xstate

分场景挑选合适的状态管理工具:

  • 如果是小型的项目且没有多少状态需要共享,那么不需要状态管理,react 本身的 props 或者 context 就能实现需求
  • 如果需要手动控制状态的更新,单向数据流是合适的选择,例如:redux,zustand
  • 如果需要简单的自动更新,双向绑定的状态管理是不二之选,例如:mobx,valtio
  • 如果是两个或多个组件之间简单的数据共享,那么原子化或许是合适的选择:,例如:jotai,recoil
  • 如果状态有复杂数据流的处理,请用 rxjs
  • 如果管理的是复杂的业务状态,那么可以使用有限状态机做状态的跳转管理,例如:xstate
  • 如果有在非react上下文订阅、操作状态的需求,那么 jotai、recoil 等工具不是好的选择。

但这些看起来好复杂,我直接根据 github star 来判断哪个用的最多,最终选择了 redux

下面直接来看 redux 的使用,官方推荐 react-redux toolkit 使用方式

安装依赖

npm install @reduxjs/toolkit react-redux --save

1、主入口引入 store src/index.js

import { store } from './store/index'
import { Provider } from 'react-redux'

// ...
root.render(
  <React.StrictMode>
    <Provider store={store}>
      <RouterProvider router={router} />
    </Provider>
  </React.StrictMode>
);
// ...

2、store 分为两个文件,一个入口文件,一个 global 模块

src/store/index.js

import { configureStore } from '@reduxjs/toolkit'

import globalStateReducer from './globalState'

export const store = configureStore({
  reducer: {
    globalState: globalStateReducer,
  },
})

src/store/globalState.js,这里保留了 count 变量,方便理解,其实可以去掉,只保留 accountInfo

import { createSlice } from '@reduxjs/toolkit'

export const globalStateSlice = createSlice({
    name: 'globalState',
    initialState: {
        accountInfo: {},
        count: 0,
    },
    reducers: {
        setAccoutInfo: (state, action) => {
            state.accountInfo = action.payload
        },
        increment: (state) => {
            state.count += 1
        },
        decrement: (state) => {
            state.count -= 1
        },
    },
})

// 修改数据
export const { increment, decrement, setAccoutInfo } = globalStateSlice.actions

// 异步操作
export const incrementAsync = (amount) => (dispatch) => {
    setTimeout(() => {
        dispatch(incrementByAmount(amount))
    }, 1000)
}

// 用于数据使用 const accountInfo = useSelector(selectXxx)
// in the slice file. For example: `useSelector((state) => state.counter.value)`
export const selectCount = (state) => state.globalState.count
export const selectAccoutInfo = (state) => state.globalState.accountInfo

export default globalStateSlice.reducer

3、设置数据/获取数据

// src/App.js
import { useSelector, useDispatch } from 'react-redux'
import { setAccoutInfo, selectAccoutInfo } from './store/globalState'

function App() {
  const accountInfo = useSelector(selectAccoutInfo) // 可用于显示,值变更后页面会刷新
  const dispatch = useDispatch()
  
  // 获取用户信息
  async function getUserInfo() {
    try {
      // await new Promise((resolve) => setTimeout(resolve, 2000)); // sleep, test loading
      const res = await axiosApi.get("/user/info");
      console.log("获取用户信息", res);
      dispatch(setAccoutInfo(res.data || {})) // 设置数据
    } catch (e) {
    }
  };
  
  return (<div>{accountInfo.name}</div>}
}

在其他任意组件内获取状态管理设置的值

7年前端只会Vue? 用一周快速入门 react 并实现配置中心项目

// src/components/MenuLeft.jsx
import { useSelector } from 'react-redux'
import { selectAccoutInfo } from '../store/globalState' // 注意路径问题

export default function MenuLeft() {
  const accountInfo = useSelector(selectAccoutInfo)

  return (
    <div>
      <span className="account-name">{accountInfo.name}</span>
    </div>
  );
}

以下是配置中心引入 redux 提交记录 feat: add react-redux toolkit methods

列表页 table 增删改查(curd)

上面完成了基本的登录功能,现在来开始短链接配置页面的增删改查(搬砖)

7年前端只会Vue? 用一周快速入门 react 并实现配置中心项目

列表与分页

Table 组件 columns 配置表格列,data 设置数据, 表格组件自带 pagination ,需要设置为 false,单独使用 Pagination 组件

import { CONFIG_ENV } from '../config/env.js'
import { useSelector } from 'react-redux'
import { selectAccoutInfo } from '../store/globalState'
import { Input, Button, Table, Space, message, Pagination } from 'antd'
import { debounce } from "lodash-es";
import '../styles/shortLink.scss'
import { useEffect, useState } from 'react';
import axiosApi from '../utils/axios.js'

export default function ShortLink() {
    const accountInfo = useSelector(selectAccoutInfo)
    // 原先是计算属性,react 中不需要特殊声明,异步获取的也会更新
    const apiUrl = `${CONFIG_ENV.BASE_URL}/share/shortLink/list?_id=${accountInfo._id
        }&pageSize=100`;
    ;

    let queryText = ""
    const queryChange = (e) => {
        console.log("queryChange", e.target.value);
        queryText = e.target.value
        console.log(queryText)
        resetPage();
        getList(1, 20);
    };
    const queryChangeDebounce = debounce(queryChange, 300);

    function addShortLink() {
    }

    const columns = [
        { title: '短连接路径', dataIndex: 'shortLink', key: 'shortLink' },
        { title: '重定向 URL', dataIndex: 'redirect', key: 'redirect' },
        { title: '创建时间', dataIndex: 'createDate', key: 'createDate' },
        {
            title: '操作', key: 'opt', render: (_, record) => (
                <Space size="middle">
                    <Button type="link" onClick={() => editShortLink(record)}>修改</Button>
                    <Button type="link" onClick={() => deleteShortLink(record)}>删除</Button>
                </Space>
            ),
        }
    ]
    const [tableData, setTableData] = useState({
        list: [],
        total: 0
    })
    const [loading, setLoading] = useState(false);
    let [currentPage, setCurrentPage] = useState(1)
    let [pageSize, setPageSize] = useState(20)
    const getList = async (number, size) => {
        try {
            setLoading(true);
            // await new Promise((resolve) => setTimeout(resolve, 2000)); // sleep, test loading
            const res = await axiosApi.get("/shortLink/list", {
                params: {
                    queryText,
                    currentPage: number || currentPage,
                    pageSize: size || pageSize,
                },
            });
            console.log(res);
            const tableDataList = (res.data?.list || []).map((item) => {
                item.key = item.shortLink
                return item; // 处理数据
            });
            setTableData({
                total: res.data?.total || 0,
                // total: 100, // 分页测试
                list: tableDataList
            })
            window.scrollTo(0, 0); // 分页后,滚动到顶部
        } catch (e) {
            console.error(e);
            message.error(e.message);
        } finally {
            setLoading(false);
        }
    };
    useEffect(() => {
        getList()
    }, [])

    function editShortLink(record) {
        console.log('editShortLink', record)
    }

    function deleteShortLink(record) {
        console.log('deleteShortLink', record)
    }

    function onPaginationChange(number, size) {
        console.log(number, size) // 加个判断,如果 size 变更,当前页切到 1
        setCurrentPage(pageSize !== size ? 1 : number)
        setPageSize(size)
        getList(pageSize !== size ? 1 : number, size)
    }
    function resetPage() {
        setCurrentPage(1)
        setPageSize(20)
    }

    return (
        <div className="short-link-page">
            <div className="sl-top">
                <div className="sl-top-left">
                    <span className="stl-label">路径查询:</span>
                    <Input placeholder="请输入查询关键字" onChange={queryChangeDebounce} allowClear />
                </div>
                <div>
                    <Button type="primary" onClick={addShortLink}>新增短链接</Button>
                </div>
            </div>

            <Table
                columns={columns}
                dataSource={tableData.list}
                bordered
                size="middle"
                loading={loading}
                pagination={false}
            />
            <div className="pagination-wrap">
                <Pagination
                    total={tableData.total}
                    current={currentPage}
                    pageSize={pageSize}
                    showSizeChanger
                    showQuickJumper
                    showTotal={() => `共 ${tableData.total} 条`}
                    onChange={onPaginationChange}
                />
            </div>
        </div>
    )
}

其中语言默认为英文,如果需要设置成中文,需要进行设置

// src/index.js
import zhCN from "antd/locale/zh_CN";
import { ConfigProvider } from "antd";

root.render(
  <React.StrictMode>
    <Provider store={store}>
      <ConfigProvider locale={zhCN}>
        <RouterProvider router={router} />
      </ConfigProvider>
    </Provider>
  </React.StrictMode>
);

新增/修改 Modal 弹窗

在 ant-design 中并没有像 element-plus 中的 el-dialog,对应的弹窗组件为 Modal,由于没有 v-model 双向绑定,react 的弹窗要比 vue 的简单一点。

7年前端只会Vue? 用一周快速入门 react 并实现配置中心项目

直接将弹窗封装成一个组件 ShortLinkAdd.jsx 在列表中引入,增加是否显示弹窗、修改数据信息两个变量,代码如下,为了让组件在关闭后销毁,加了判断,仅当 isOpenEditDialog 为 true 时,显示弹窗组件。

// src/views/ShortLink.jsx
import ShortLinkAdd from './ShortLinkAdd.jsx';

export default function ShortLink() {

    const [isOpenEditDialog, setIsOpenEditDialog] = useState(false)
    const [editData, setEditData] = useState({}) // 如果是编辑有数据,新建为空对象
    function addShortLink() {
        setEditData({})
        setIsOpenEditDialog(true) // 点击新增显示弹窗
    }
    function editShortLink(record) {
        console.log('editShortLink', record)
        setEditData(record)
        setIsOpenEditDialog(true) // 点击编辑拿到数据后显示弹窗
    }
    const columns = [
        { title: '短连接路径', dataIndex: 'shortLink', key: 'shortLink' },
        {
            title: '操作', key: 'opt', render: (_, record) => (
                <Space size="middle">
                    <Button type="link" onClick={() => editShortLink(record)}>修改</Button>
                    <Button type="link" onClick={() => deleteShortLink(record)}>删除</Button>
                </Space>
            ),
        }
    ]
    return (
        <div className="short-link-page">
            // ... 表格分页等代码
            
            // 弹窗组件
            {isOpenEditDialog &&
                <ShortLinkAdd
                    isOpenEditDialog={isOpenEditDialog}
                    setIsOpenEditDialog={setIsOpenEditDialog}
                    editData={editData}
                    handleOk={(isEdit) => isEdit ? getList() : getList(1, 20)}
                />}
        </div>
    )
}

ShortLinkAdd 组件代码如下,其实就是对 Modal 组件的封装,代码如下

  • 和登录页不同,由于 modal 自带两个按钮,点击事件由 onOk、onCancel 两个属性指定。无法直接和表单 submit 关联。这里拿到 form 实例后直接使用 form.validateFields() 触发 rules 检验,它返回一个 promise,成功为表单值,失败为错误信息
  • 校验成功后,根据组件传参 editData 判断是否为新增,新增、修改调用不同的接口
  • 新增、修改接口调用成功后,执行成功传参 handleOk 进行列表刷新,再关闭弹窗
import { Modal, Form, Input, message } from 'antd'
import { useState } from 'react';
import axiosApi from '../utils/axios'

export default function ShortLinkAdd({ isOpenEditDialog, setIsOpenEditDialog, editData, handleOk }) {
    const { TextArea } = Input;
    const [form] = Form.useForm();
    const isEdit = JSON.stringify(editData) !== '{}'

    function handleCancel() {
        setIsOpenEditDialog(false)
    }

    async function confirm() {
        try {
            let formData = await form.validateFields()
            saveShortLink(formData)
        } catch (e) {
            console.log(e)
        }
    }

    const [saveLoading, setSaveLoading] = useState(false)
    async function saveShortLink({ shortLink, redirect }) {
        console.log("saveShortLink", form);
        try {
            setSaveLoading(true)
            // await new Promise((resolve) => setTimeout(resolve, 2000)); // sleep, test loading
            let apiLink = isEdit ? "/shortLink/edit" : "/shortLink/add";
            let payload = {
                shortLink,
                redirect
            }
            isEdit && (payload._id = editData._id)
            const res = await axiosApi.post(apiLink, payload);
            console.log(res)
            message.success(isEdit ? "修改成功!" : "添加成功!");
            handleOk && handleOk(isEdit)
            setIsOpenEditDialog(false)
        } catch (e) {
            console.error(e);
            message.error(e.message);
        } finally {
            setSaveLoading(false)
        }
    };

    return (
        <Modal title="短链接配置"
            open={isOpenEditDialog}
            onOk={confirm}
            onCancel={handleCancel}
            wrapClassName="sl-add-dialog"
            destroyOnClose={true}
            confirmLoading={saveLoading}
        >
            <Form
                form={form}
                labelCol={{ flex: '110px' }}
                labelWrap
                layout="horizontal"
                initialValues={{
                    shortLink: editData?.shortLink || '',
                    redirect: editData?.redirect || ''
                }}
            >
                <Form.Item name="shortLink" label="短链接" rules={[
                    {
                        required: true,
                        message: '请输入短连接',
                        whitespace: true
                    },
                ]}>
                    <Input />
                </Form.Item>
                <Form.Item name="redirect" label="重定向地址" rules={[
                    {
                        required: true,
                        message: '请输入重定向地址',
                        whitespace: true
                    },
                ]}>
                    <TextArea rows={2} />
                </Form.Item>
            </Form>
        </Modal>
    )
}

删除 Popconfirm

列表查询、新增、修改完成后,再来看删除,Vue 中使用 ElMessageBox.confirm 来进行二次确认,在 react 中使用 Popconfirm 来专门进行这种操作

7年前端只会Vue? 用一周快速入门 react 并实现配置中心项目

  • 在 columns 数据的对应列 render 函数中,可以直接使用 jsx 语法来自定义 table 列,这比 vue 要灵活很多。
  • 点击删除会触发 Popconfirm 组件进行二次确认,点击确认会执行 onConfirm 指定的函数,神奇的是,如果这个函数是异步的,确定按钮会自动加 loading
export default function ShortLink() {
    const columns = [
        { title: '短连接路径', dataIndex: 'shortLink', key: 'shortLink' },
        {
            title: '操作', key: 'opt', render: (_, record) => (
                <Space size="middle">
                    <Button type="link" onClick={() => editShortLink(record)}>修改</Button>
                    <Popconfirm
                        title="删除确认"
                        description="您确认要删除该短链接配置吗"
                        onConfirm={() => deleteShortLink(record)}
                        onCancel={() => { }}
                        okText="确定"
                        cancelText="取消"
                    >
                        <Button type="link">删除</Button>
                    </Popconfirm>

                </Space>
            ),
        }
    ]

    // button 自带 loading 效果, 666
    async function deleteShortLink(record) {
        try {
            // await new Promise((resolve) => setTimeout(resolve, 2000)); // sleep, test loading
            const res = await axiosApi.post("/shortLink/del", {
                _id: record?._id,
            });
            console.log(res);
            message.success("删除成功!");
            getList(1, 20); // 刷新列表
        } catch (e) {
            message.error(e.message);
        }
    }

    return (
        <div className="short-link-page">
         // ...
        </div>
    )
}

现在,基本完成了这个配置中心的 CURD 功能,对 react 表单、列表、分页、接口处理等都有了一定的熟悉,项目完整代码:2-react-config-fe

简单总结

上面我们先是通过 React 官方文档的 Quick Start 快速了解了基础的 React 语法。

然后不看官网的 tic-tac-toe 教程,直接根据功能,自己创建一个 Next.js 项目来用 React 尝试实现这个功能。 对 React 语法进行熟悉。

为了更好的理解 React,我们又了解了怎么在纯 html 中使用 react jsx、怎么使用 hooks、怎么实现表单提交。

自此,我们基本有了可以开始做项目的基础能力,为了更好的理解 React,有继续讨论了 setXxx 异步的问题、State 状态、纯函数与副作用的理解。

后面就是,怎么从 0 到 1 一步步实现配置中心项目了。包括:怎么用脚手架创建项目、怎么引入组件库实现基本结构布局、怎么使用路由、怎么配置 axios、怎么在 mounted 时请求接口、实现登录表单功能、登录后的用户信息存到 redux 状态管理、列表/分页、增改、删等。

这些全部了解后,就可以进行 React 简单的 CRUD 搬砖了。

后续进阶

这篇文章只是进行了 react 的简单入门,并没有涉及性能优化,hooks 封装等,后续进阶,需要去实践更多的业务场景、更加深入的去看 react 官方文档。

如果您发现我理解的有问题,或者有什么好的建议,欢迎在评论区或者私信留言指正,谢谢!