如何使用 MERN Stack 创建具有精美动画的全栈多步骤注册应用程序

2025-06-10

如何使用 MERN Stack 创建具有精美动画的全栈多步骤注册应用程序

在本文中,我们将使用 MERN(MongoDB、Express、React、Node.js)堆栈构建一个具有流畅动画过渡的令人惊叹的多步骤注册表单。

通过构建此应用程序,您将学习到许多 React 概念,包括:

  • 如何管理多个表单的数据并验证每个字段
  • 如何跨路由保留表单数据的值
  • 如何更新每个注册步骤的进度指示
  • 如何从 API 加载特定国家/地区的州和城市
  • 如何使用非常流行的 framer-motion 库创建平滑的滑动动画
  • 如何使用 Express.js 创建 Rest API
  • 如何用MongoDB实现登录和注册功能
  • 如何在 MongoDB 中以加密形式存储和验证密码

还有更多。

我们将使用 React Hooks 语法在 React 中构建此应用程序。如果您是 React Hooks 新手,请查看我的《React Hooks 简介》文章,了解 Hooks 的基础知识。

我们还将使用 MongoDB 数据库来存储用户输入的数据,因此请确保按照本文中的说明在本地安装 MongoDB 数据库。

那么让我们开始吧。

初始项目设置

使用以下方式创建新项目create-react-app

npx create-react-app multi-step-form-using-mern
Enter fullscreen mode Exit fullscreen mode

项目创建完成后,删除src文件夹中的所有文件,并在文件夹中创建index.js文件和styles.scss文件src。同时,在文件夹中创建componentsrouter和文件夹utilssrc

安装必要的依赖项:

yarn add axios@0.21.1 bootstrap@4.6.0 react-bootstrap@1.5.0 country-state-city@2.0.0 framer-motion@3.7.0 node-sass@4.14.1 react-hook-form@6.15.4 react-router-dom@5.2.0 sweetalert2@10.15.5
Enter fullscreen mode Exit fullscreen mode

打开文件并将此处styles.scss的内容添加到其中。

我们将使用 SCSS 语法编写 CSS。如果您是 SCSS 新手,请查看我的这篇文章,了解它的简介。

如何创建初始页面

Header.js在文件夹内创建一个新文件components,内容如下:

import React from 'react';

const Header = () => (
  <div>
    <h1>Multi Step Registration</h1>
  </div>
);

export default Header;
Enter fullscreen mode Exit fullscreen mode

FirstStep.js在文件夹内创建一个新文件components,内容如下:

import React from 'react';

const FirstStep = () => {
  return (
    <div>
      First Step Form
    </div>
  )
};

export default FirstStep;
Enter fullscreen mode Exit fullscreen mode

AppRouter.js在文件夹内创建一个新文件router,内容如下:

import React from 'react';
import { BrowserRouter, Route, Switch } from 'react-router-dom';
import FirstStep from '../components/FirstStep';
import Header from '../components/Header';

const AppRouter = () => (
  <BrowserRouter>
    <div className="container">
      <Header />
      <Switch>
        <Route component={FirstStep} path="/" exact={true} />
      </Switch>
    </div>
  </BrowserRouter>
);

export default AppRouter;
Enter fullscreen mode Exit fullscreen mode

在这个文件中,最初,我们为第一步添加了一条路线。

如果您是 React Router 的新手,请查看我的免费React Router 简介课程。

现在,打开该src/index.js文件并在其中添加以下内容:

import React from 'react';
import ReactDOM from 'react-dom';
import AppRouter from './router/AppRouter';
import 'bootstrap/dist/css/bootstrap.min.css';
import './styles.scss';

ReactDOM.render(<AppRouter />, document.getElementById('root'));
Enter fullscreen mode Exit fullscreen mode

现在,通过运行命令启动应用程序yarn start,您将看到以下屏幕:

初始屏幕

如何在标题中添加进度步骤

Progress.js在文件夹内创建一个新文件components,内容如下:

import React from 'react';

const Progress = () => {
  return (
    <React.Fragment>
      <div className="steps">
        <div className="step">
          <div>1</div>
          <div>Step 1</div>
        </div>
        <div className="step">
          <div>2</div>
          <div>Step 2</div>
        </div>
        <div className="step">
          <div>3</div>
          <div>Step 3</div>
        </div>
      </div>
    </React.Fragment>
  );
};

export default Progress;
Enter fullscreen mode Exit fullscreen mode

并在文件中使用它,Header.js如下所示:

import React from 'react';
import Progress from './Progress';

const Header = () => (
  <div>
    <h1>Multi Step Registration</h1>
    <Progress />
  </div>
);

export default Header;
Enter fullscreen mode Exit fullscreen mode

现在,如果您检查该应用程序,您将看到以下屏幕:

随着进展

如何创建第一步表单

现在,让我们添加第一步的表单。

打开components/FirstStep.js文件并将其替换为以下内容:

import React from 'react';
import { useForm } from 'react-hook-form';
import { Form, Button } from 'react-bootstrap';

const FirstStep = (props) => {
  const { register, handleSubmit, errors } = useForm();

  const onSubmit = (data) => {
    console.log(data);
  };

  return (
    <Form className="input-form" onSubmit={handleSubmit(onSubmit)}>
      <div className="col-md-6 offset-md-3">
        <Form.Group controlId="first_name">
          <Form.Label>First Name</Form.Label>
          <Form.Control
            type="text"
            name="first_name"
            placeholder="Enter your first name"
            autoComplete="off"
            ref={register({
              required: 'First name is required.',
              pattern: {
                value: /^[a-zA-Z]+$/,
                message: 'First name should contain only characters.'
              }
            })}
            className={`${errors.first_name ? 'input-error' : ''}`}
          />
          {errors.first_name && (
            <p className="errorMsg">{errors.first_name.message}</p>
          )}
        </Form.Group>

        <Form.Group controlId="last_name">
          <Form.Label>Last Name</Form.Label>
          <Form.Control
            type="text"
            name="last_name"
            placeholder="Enter your last name"
            autoComplete="off"
            ref={register({
              required: 'Last name is required.',
              pattern: {
                value: /^[a-zA-Z]+$/,
                message: 'Last name should contain only characters.'
              }
            })}
            className={`${errors.last_name ? 'input-error' : ''}`}
          />
          {errors.last_name && (
            <p className="errorMsg">{errors.last_name.message}</p>
          )}
        </Form.Group>

        <Button variant="primary" type="submit">
          Next
        </Button>
      </div>
    </Form>
  );
};

export default FirstStep;
Enter fullscreen mode Exit fullscreen mode

在这里,我们使用非常流行的react-hook-form库来轻松管理带有验证的表单。

React-hook-form 使得处理简单和复杂的表单变得非常容易,因为我们不需要onChange自己管理每个输入字段及其处理程序的状态,这使得代码干净且易于理解。

查看我的这篇文章react-hook-form来详细了解。

从上面的代码可以看出,要使用该react-hook-form库,我们需要首先导入并使用useForm钩子。

  const { register, handleSubmit, errors } = useForm();
Enter fullscreen mode Exit fullscreen mode

这里,

  • registerref是钩子提供的函数useForm。我们可以将它分配给每个输入字段,以便react-hook-form跟踪输入字段值的变化。
  • handleSubmit是我们在提交表单时可以调用的函数
  • errors将包含验证错误(如果有)

在上面的代码中,我们ref为从钩子中获得的每个输入字段赋予了一个useForm,如下所示:

ref={register({
  required: 'First name is required.',
  pattern: {
    value: /^[a-zA-Z]+$/,
    message: 'First name should contain only characters.'
  }
})}
Enter fullscreen mode Exit fullscreen mode

另外,我们添加了onSubmit传递给handleSubmit函数的函数。

<Form className="input-form" onSubmit={handleSubmit(onSubmit)}>
Enter fullscreen mode Exit fullscreen mode

请注意,对于每个输入字段,我们都给出了唯一name的必填字段,以便react-hook-form可以跟踪变化的数据。

当我们提交表单时,该handleSubmit函数将处理表单提交。它会将用户输入的数据发送到onSubmit我们正在记录到控制台的函数。

const onSubmit = (data) => {  
 console.log(data);
};
Enter fullscreen mode Exit fullscreen mode

如果有任何错误,我们将这样显示:

{errors.first_name && (
  <p className="errorMsg">{errors.first_name.message}</p>
)}
Enter fullscreen mode Exit fullscreen mode

如果出现任何错误,对象将自动填充每个输入字段所指定errors属性名称。在上述情况下,是赋予第一个输入字段的名称。namefirst_name

现在,让我们检查应用程序的功能。

第一步表格

正如您所看到的,我们用很少的代码为表单添加了响应式验证功能。

如何创建第二步表单

现在,在文件夹SecondStep.js内创建一个components包含以下内容的新文件:

import React from 'react';
import { useForm } from 'react-hook-form';
import { Form, Button } from 'react-bootstrap';

const SecondStep = (props) => {
  const { register, handleSubmit, errors } = useForm();

  const onSubmit = (data) => {
    console.log(data);
  };

  return (
    <Form className="input-form" onSubmit={handleSubmit(onSubmit)}>
      <div className="col-md-6 offset-md-3">
        <Form.Group controlId="first_name">
          <Form.Label>Email</Form.Label>
          <Form.Control
            type="email"
            name="user_email"
            placeholder="Enter your email address"
            autoComplete="off"
            ref={register({
              required: 'Email is required.',
              pattern: {
                value: /^[^@ ]+@[^@ ]+\.[^@ .]{2,}$/,
                message: 'Email is not valid.'
              }
            })}
            className={`${errors.user_email ? 'input-error' : ''}`}
          />
          {errors.user_email && (
            <p className="errorMsg">{errors.user_email.message}</p>
          )}
        </Form.Group>

        <Form.Group controlId="password">
          <Form.Label>Password</Form.Label>
          <Form.Control
            type="password"
            name="user_password"
            placeholder="Choose a password"
            autoComplete="off"
            ref={register({
              required: 'Password is required.',
              minLength: {
                value: 6,
                message: 'Password should have at-least 6 characters.'
              }
            })}
            className={`${errors.user_password ? 'input-error' : ''}`}
          />
          {errors.user_password && (
            <p className="errorMsg">{errors.user_password.message}</p>
          )}
        </Form.Group>

        <Button variant="primary" type="submit">
          Next
        </Button>
      </div>
    </Form>
  );
};

export default SecondStep;
Enter fullscreen mode Exit fullscreen mode

现在,让我们在AppRouter.js文件中为SecondStep组件添加另一条路由。

import React from 'react';
import { BrowserRouter, Route, Switch } from 'react-router-dom';
import FirstStep from '../components/FirstStep';
import Header from '../components/Header';
import SecondStep from '../components/SecondStep';

const AppRouter = () => (
  <BrowserRouter>
    <div className="container">
      <Header />
      <Switch>
        <Route component={FirstStep} path="/" exact={true} />
        <Route component={SecondStep} path="/second" />
      </Switch>
    </div>
  </BrowserRouter>
);

export default AppRouter;
Enter fullscreen mode Exit fullscreen mode

SecondStep另外,如上所示,在文件顶部导入组件。

现在,我们为第二步添加了一条路由,让我们通过访问 URL http://localhost:3000/second来检查应用程序。

第二步表格

如您所见,功能运行正常,但我们直接访问了/second路由。相反,让我们添加代码,以编程方式从步骤 1 重定向到步骤 2。

Route当我们为内部提供任何组件时BrowserRouter,React 路由器会自动向该组件传递 3 个 props,它们是:

  • 历史
  • 位置和
  • 匹配

其中,该history对象包含一个push方法,我们可以使用它从一个组件重定向到另一个组件。

因此打开FirstStep.js文件并onSubmit用以下代码替换该函数:

const onSubmit = (data) => {
    console.log(data);
    props.history.push('/second');
  };
Enter fullscreen mode Exit fullscreen mode

在这里,对于该push方法,我们提供了需要重定向到的路由。

重定向

如您所见,当我们单击Next第一步中的按钮时,我们将被重定向到第二步。

现在,在文件夹constants.js内创建一个utils包含以下内容的新文件:

export const BASE_API_URL = 'http://localhost:3030';
Enter fullscreen mode Exit fullscreen mode

这里我们指定了后端 API 的 URL。因此,我们不需要在每个 API 调用中都指定它。我们只需要在需要调用 API 时使用此常量即可。

现在,让我们在AppRouter.js文件中为ThirdStep组件添加另一条路由。

...
<Switch>
  <Route component={FirstStep} path="/" exact={true} />
  <Route component={SecondStep} path="/second" />
  <Route component={ThirdStep} path="/third" />
</Switch>
...
Enter fullscreen mode Exit fullscreen mode

如何从 API 获取所有国家/地区的列表

在文件夹内创建一个新文件ThirdStep.jsfile,components内容如下:

import React, { useState, useEffect } from 'react';
import { Form, Button } from 'react-bootstrap';
import csc from 'country-state-city';
import axios from 'axios';
import { BASE_API_URL } from '../utils/constants';

const ThirdStep = (props) => {
  const [countries, setCountries] = useState([]);
  const [states, setStates] = useState([]);
  const [cities, setCities] = useState([]);
  const [isLoading, setIsLoading] = useState(false);

  const [selectedCountry, setSelectedCountry] = useState('');
  const [selectedState, setSelectedState] = useState('');
  const [selectedCity, setSelectedCity] = useState('');

  useEffect(() => {
   const getCountries = async () => {
     try {
       const result = await csc.getAllCountries();
       console.log(result);
     } catch (error) {}
    };

    getCountries();
  }, []);

  const handleSubmit = async (event) => {
    event.preventDefault();
  };

  return (
    <Form className="input-form" onSubmit={handleSubmit}>
      <div className="col-md-6 offset-md-3"></div>
    </Form>
  );
};

export default ThirdStep;
Enter fullscreen mode Exit fullscreen mode

在此文件中,我们使用country-state-city npm 库来获取可用国家、城市和州的列表,如下所示:

import csc from 'country-state-city';
Enter fullscreen mode Exit fullscreen mode

然后在组件中,我们定义了一些状态:

const [countries, setCountries] = useState([]);
const [states, setStates] = useState([]);
const [cities, setCities] = useState([]);
const [isLoading, setIsLoading] = useState(false);

const [selectedCountry, setSelectedCountry] = useState('');
const [selectedState, setSelectedState] = useState('');
const [selectedCity, setSelectedCity] = useState('');
Enter fullscreen mode Exit fullscreen mode

这里countries,、states和在将存储分别来自 API的cities列表的状态中声明。countriesstatescities

添加了另一个isLoading状态来跟踪数据何时加载。selectedCountry并且当用户选择特定的下拉值时将包含选定的值selectedStateselectedCity

然后我们添加了一个useEffect钩子来发起 API 调用以获取国家列表,如下所示:

useEffect(() => {
  ...
  const result = await csc.getAllCountries();
  ...
}, []);
Enter fullscreen mode Exit fullscreen mode

这里,我们调用库getAllCountries的方法country-state-city来获取可用国家/地区的列表。需要注意的是,我们传入了一个空数组[]作为钩子的第二个参数useEffect,因此该钩子在组件挂载时只会被调用一次。

现在,打开SecondStep.js文件并onSubmit用以下代码替换该函数:

const onSubmit = (data) => {
    console.log(data);
    props.history.push('/third');
};
Enter fullscreen mode Exit fullscreen mode

使用此代码,我们可以轻松导航到该ThirdStep组件。

现在,让我们检查一下该应用程序。

国家日志

如您所见,在组件加载时,我们会在对象数组中获取可用国家/地区的列表。

每个对象都包含一个isoCodename属性,我们可以在代码中使用它将其显示在屏幕上。

因此将useEffect钩子更改为以下代码:

useEffect(() => {
  const getCountries = async () => {
    try {
      setIsLoading(true);
      const result = await csc.getAllCountries();
      let allCountries = [];
      allCountries = result?.map(({ isoCode, name }) => ({
        isoCode,
        name
      }));
      const [{ isoCode: firstCountry } = {}] = allCountries;
      setCountries(allCountries);
      setSelectedCountry(firstCountry);
      setIsLoading(false);
    } catch (error) {
      setCountries([]);
      setIsLoading(false);
    }
  };

  getCountries();
}, []);
Enter fullscreen mode Exit fullscreen mode

在这里,我们首先设置isLoading标志来true指示数据正在加载,我们很快就会使用它。

数组的每个对象都包含许多其他属性,如phonecode,等flagcurrency但我们只想要isoCodename因此我们使用数组映射方法来过滤掉那些属性,如下所示:

allCountries = result?.map(({ isoCode, name }) => ({
  isoCode,
  name
}));
Enter fullscreen mode Exit fullscreen mode

这里,我们使用 ES11 可选链式运算符,用 表示,因此仅当前一个引用不是或 时,?后面的代码才会执行​​。由于我们正在解构,因此可选链式运算符是必需的。?undefinednullisoCodename

可选链式运算符在很多场景下都非常有用。在《精通现代 JavaScript》一书中,您可以详细了解它

然后我们有以下代码:

const [{ isoCode: firstCountry } = {}] = allCountries;
setCountries(allCountries);
setSelectedCountry(firstCountry);
setIsLoading(false);
Enter fullscreen mode Exit fullscreen mode

让我们了解一下我们在这里做什么。

这里,我们使用了对象解构重命名和赋值语法。我们解构了对象数组isoCode第一个对象的属性,并将该属性重命名为,以便标识它是列表中的第一个国家/地区。我们还分配了一个默认的空对象,这样即使数组为空也不会报错。allCountriesisoCodefirstCountryallCountries

简而言之,我们的意思是,isoCode从对象数组的第一个对象中取出属性allCountries,并将其重命名为firstCountry。如果该firstCountry属性在数组的第一个对象中不存在,allCountries则将空对象的默认值赋给{}变量firstCountry

然后我们使用以下代码将selectedCountry状态值更新为firstCountry值并将isLoading状态值更新为:false

setSelectedCountry(firstCountry);
setIsLoading(false);
Enter fullscreen mode Exit fullscreen mode

现在,在ThirdStep.js文件中,更改以下代码:

return (
  <Form className="input-form" onSubmit={handleSubmit}>
    <div className="col-md-6 offset-md-3"></div>
  </Form>
);
Enter fullscreen mode Exit fullscreen mode

到此代码:

return (
    <Form className="input-form" onSubmit={handleSubmit}>
      <div className="col-md-6 offset-md-3">
        <Form.Group controlId="country">
          {isLoading && (
            <p className="loading">Loading countries. Please wait...</p>
          )}
          <Form.Label>Country</Form.Label>
          <Form.Control
            as="select"
            name="country"
            value={selectedCountry}
            onChange={(event) => setSelectedCountry(event.target.value)}
          >
            {countries.map(({ isoCode, name }) => (
              <option value={isoCode} key={isoCode}>
                {name}
              </option>
            ))}
          </Form.Control>
        </Form.Group>
      </div>
    </Form>
  );
Enter fullscreen mode Exit fullscreen mode

因此我们可以在下拉菜单中看到国家列表。

现在,如果您导航到步骤 3,您将看到以下屏幕:

国家人口

如您所见,国家/地区下拉菜单正确填充了所有国家/地区,并且在下拉菜单值发生变化时,selectedCountry州/省也会更改为国家/地区代码(isoCode),如在 React Dev 工具中所示。

如何从 API 获取州列表

现在,让我们添加根据所选国家获取州列表的代码。

useEffect在文件中第一个钩子后添加以下代码ThirdStep.js

useEffect(() => {
    const getStates = async () => {
      try {
        const result = await csc.getStatesOfCountry(selectedCountry);
        let allStates = [];
        allStates = result?.map(({ isoCode, name }) => ({
          isoCode,
          name
        }));
        console.log({ allStates });
        const [{ isoCode: firstState = '' } = {}] = allStates;
        setCities([]);
        setSelectedCity('');
        setStates(allStates);
        setSelectedState(firstState);
      } catch (error) {
        setStates([]);
        setCities([]);
        setSelectedCity('');
      }
    };

    getStates();
  }, [selectedCountry]);
Enter fullscreen mode Exit fullscreen mode

在这里,我们通过传递作为参数getStatesOfCountry从库中调用方法,并根据 API 的结果更新相应的状态,如下所示:country-state-cityselectedCountry

setCities([]);
setSelectedCity('');
setStates(allStates);
setSelectedState(firstState);
Enter fullscreen mode Exit fullscreen mode

所有国家、州和城市下拉菜单都是相互关联的,因此如果我们要更改国家,我们也应该更新州,就像我们在上面的代码中所做的那样。

另外,请注意,我们已将selectedCountry作为第二个参数传递给useEffect依赖项数组中的钩子:

useEffect(() => {
 ...
}, [selectedCountry]); 
Enter fullscreen mode Exit fullscreen mode

因此,这种效果仅在州发生变化时才会运行,selectedCountry这意味着一旦我们更改国家下拉菜单,我们就会进行 API 调用以获取仅与该国家相关的州,然后填充状态下拉值。

Form.Group现在,在国家下拉菜单之后的第一个结束标签后添加以下代码:

<Form.Group controlId="state">
  <Form.Label>State</Form.Label>
  <Form.Control
    as="select"
    name="state"
    value={selectedState}
    onChange={(event) => setSelectedState(event.target.value)}
  >
    {states.length > 0 ? (
      states.map(({ isoCode, name }) => (
        <option value={isoCode} key={isoCode}>
          {name}
        </option>
      ))
    ) : (
      <option value="" key="">
        No state found
      </option>
    )}
  </Form.Control>
</Form.Group>
Enter fullscreen mode Exit fullscreen mode

在这里,我们在屏幕上显示州下拉菜单,如果所选国家/地区没有州,我们会显示No state found消息,因为有些国家/地区没有任何州。

现在,如果您检查该应用程序,您将看到以下屏幕:

州人口

如上所示,当我们更改国家下拉值时,州下拉列表也会根据所选国家进行更新。

如何从 API 获取城市列表

现在,让我们根据国家和州的值填充城市。

在第二个钩子后添加另一个useEffect钩子,如下所示:

useEffect(() => {
  const getCities = async () => {
    try {
      const result = await csc.getCitiesOfState(
        selectedCountry,
        selectedState
      );
      let allCities = [];
      allCities = result?.map(({ name }) => ({
        name
      }));
      const [{ name: firstCity = '' } = {}] = allCities;
      setCities(allCities);
      setSelectedCity(firstCity);
    } catch (error) {
      setCities([]);
    }
  };

  getCities();
}, [selectedState]);
Enter fullscreen mode Exit fullscreen mode

在这里,我们通过传递和作为参数getCitiesOfState从库中调用方法,并根据 API 的结果更新城市下拉菜单。country-state-cityselectedCountryselectedState

Form.Group现在,在状态下拉列表后的第二个结束标签后添加以下代码:

<Form.Group controlId="city">
  <Form.Label>City</Form.Label>
  <Form.Control
    as="select"
    name="city"
    value={selectedCity}
    onChange={(event) => setSelectedCity(event.target.value)}
  >
    {cities.length > 0 ? (
      cities.map(({ name }) => (
        <option value={name} key={name}>
          {name}
        </option>
      ))
    ) : (
      <option value="">No cities found</option>
    )}
  </Form.Control>
</Form.Group>
Enter fullscreen mode Exit fullscreen mode

在这里,我们在屏幕上显示城市下拉菜单,如果所选州没有城市,我们会显示No cities found消息,因为有些州没有任何城市。

现在,如果您检查该应用程序,您将看到以下屏幕:

城市人口

如上所示,更改国家和州后,相应的城市列表就会填充到城市下拉菜单中。

另外,在城市下拉菜单之后的Register最后一个结束标签后添加按钮:Form.Group

<Button variant="primary" type="submit">
  Register
</Button>
Enter fullscreen mode Exit fullscreen mode

现在,您的屏幕将如下所示:

最后一步

现在,我们已经完成了所有步骤的屏幕,让我们让标题中的步骤进展顺利,这样我们就能清楚地知道我们目前处于哪个步骤。

如何在标题中添加进度指示器

Progress我们在组件内部展示了组件Header,但文件中的Progress任何 和 中都没有提到组件。因此,默认情况下,我们无法访问和组件中的和属性来识别我们处于哪条路线。RouteAppRouter.jsHeaderRoutehistorylocationmatchHeaderProgress

但有一个简单的方法可以解决这个问题。React Router 提供了一个withRouter组件,我们可以在Progress组件中使用它,这样我们就可以访问historylocationmatch属性。

打开文件并在文件顶部Progress.js添加组件的导入:withRouter

import { withRouter } from 'react-router-dom';
Enter fullscreen mode Exit fullscreen mode

并从此代码更改导出语句:

export default Progress;
Enter fullscreen mode Exit fullscreen mode

到此代码:

export default withRouter(Progress);
Enter fullscreen mode Exit fullscreen mode

因此,当我们将Progress组件传递给withRouter组件时,我们将可以访问组件内的historylocationmatchprops Progress

现在,Progress用以下代码替换该组件:

const Progress = ({ location: { pathname } }) => {
  const isFirstStep = pathname === '/';
  const isSecondStep = pathname === '/second';
  const isThirdStep = pathname === '/third';

  return (
    <React.Fragment>
      <div className="steps">
        <div className={`${isFirstStep ? 'step active' : 'step'}`}>
          <div>1</div>
          <div>
            {isSecondStep || isThirdStep ? (
              <Link to="/">Step 1</Link>
            ) : (
              'Step 1'
            )}
          </div>
        </div>
        <div className={`${isSecondStep ? 'step active' : 'step'}`}>
          <div>2</div>
          <div>{isThirdStep ? <Link to="/second">Step 2</Link> : 'Step 2'}</div>
        </div>
        <div className={`${pathname === '/third' ? 'step active' : 'step'}`}>
          <div>3</div>
          <div>Step 3</div>
        </div>
      </div>
    </React.Fragment>
  );
};
Enter fullscreen mode Exit fullscreen mode

在这里,在第一行中,我们用一行代码locationprops对象中解构属性,然后pathname从属性中解构属性,如下所示:location

const Progress = ({ location: { pathname } }) => {
Enter fullscreen mode Exit fullscreen mode

根据我们所处的路线,我们将active类添加到每个stepdiv。

另外,Link在文件顶部导入组件:

import { Link, withRouter } from 'react-router-dom';
Enter fullscreen mode Exit fullscreen mode

现在,如果您检查该应用程序,您将看到以下屏幕:

工作进度

如您所见,当我们处于特定步骤时,该步骤号会在进度条中显示为活动状态,并带有突出显示的文本,并且当我们浏览各个步骤时,前面步骤的文本会显示为链接,因此我们可以导航回任何步骤来更改任何数据。

如何跨路线保留输入的数据

但是您会注意到,当我们通过单击步骤 3 中的链接转到步骤 1 时,步骤 1 输入的数据会丢失。

这是因为当我们从一条路由转到另一条路由时,React Router 会完全卸载前一个路由组件,并安装连接到该路由的下一个路由组件,因此所有状态值都会丢失。

因此,让我们添加一种方法,以便在导航到上一步时保留输入的数据。

如您所知,只有与文件中提到的路线相连的组件AppRouter.js才会在路线改变时被安装和卸载,但AppRouter在我们的例子中,即使路线发生变化,组件也不会被卸载,因此存储用户输入的数据的最佳位置是AppRouter组件。

让我们在文件中添加user状态updateUserresetUser功能AppRouter.js

const [user, setUser] = useState({});

const updateUser = (data) => {
  setUser((prevUser) => ({ ...prevUser, ...data }));
};

const resetUser = () => {
  setUser({});
};
Enter fullscreen mode Exit fullscreen mode

所以我们会将每个步骤中用户输入的数据存储在状态中user,即一个对象中。

在函数中updateUser,我们传递数据来更新user状态。在updateUser函数中,我们首先使用变量展开用户对象的值prevUser,然后展开data对象,这样最终的对象将是两个对象的合并。

为了更新状态,我们使用状态的更新器语法和对象的隐式返回语法。

所以这个代码:

setUser((prevUser) => ({ ...prevUser, ...data }));
Enter fullscreen mode Exit fullscreen mode

与下面的代码相同:

setUser((prevUser) => {
  return {
    ...prevUser,
    ...data
  };
});
Enter fullscreen mode Exit fullscreen mode

如上所示,如果我们想从箭头函数隐式返回一个对象,我们可以跳过 return 关键字并将对象括在圆括号中。

这将使代码更短,并且还可以避免在代码中输入错误,因此您会发现大多数 React 代码都是使用隐式返回语法编写的。

因此,如果我们处于步骤 1,那么我们将传递{first_name: 'Mike', last_name: 'Jordan' }asdata并将其添加到user状态中。

然后在步骤 2 中,如果我们将传递{user_email: 'test@example.com', user_password: 'test@123'}dataupdateUser函数将更新user如下所示:

const prevUser = { first_name: 'Mike', last_name: 'Jordan' };
const data = { user_email: 'test@example.com', user_password: 'test@123' };

const result = { ...prevUser, ...data };
console.log(result); // { first_name: 'Mike', last_name: 'Jordan', user_email: 'test@example.com', user_password: 'test@123' }
Enter fullscreen mode Exit fullscreen mode

现在,我们已经创建了user状态、updateUser函数,我们需要将它传递给连接到该步骤的每个路线,以便我们可以通过调用该函数来保存用户输入的数据updateUser

我们文件中的当前路线AppRouter.js如下所示:

<Switch>
  <Route component={FirstStep} path="/" exact={true} />
  <Route component={SecondStep} path="/second" />
  <Route component={ThirdStep} path="/third" />
</Switch>
Enter fullscreen mode Exit fullscreen mode

因此,要将userupdateUser作为道具传递给与路线相连的组件,我们不能像这样传递它:

<Route component={FirstStep} path="/" exact={true} user={user} updateUser={updateUser} />
Enter fullscreen mode Exit fullscreen mode

因为这样 props 会被传递给Route而不是FirstStep组件。所以我们需要使用以下语法:

<Route
  render={(props) => (
    <FirstStep {...props} user={user} updateUser={updateUser} />
  )}
  path="/"
  exact={true}
/>
Enter fullscreen mode Exit fullscreen mode

这里,我们使用了渲染 props 模式来传递 props。这样可以正确传递 props,并且不会FirstStep在每次重新渲染时重新创建组件。

您可以查看我的“React Router 简介”课程,以了解有关为什么我们需要使用render而不是componentprop 的更多信息。

现在,对所有与步骤相关的路线进行此更改后,您的路线将如下所示:

<BrowserRouter>
  <div className="container">
    <Header />
    <Switch>
      <Route
        render={(props) => (
          <FirstStep {...props} user={user} updateUser={updateUser} />
        )}
        path="/"
        exact={true}
      />
      <Route
        render={(props) => (
          <SecondStep {...props} user={user} updateUser={updateUser} />
        )}
        path="/second"
      />
      <Route
        render={(props) => (
          <ThirdStep {...props} user={user}  />
        )}
        path="/third"
      />
    </Switch>
  </div>
</BrowserRouter>
Enter fullscreen mode Exit fullscreen mode

请注意,我们不会将updateUserprop 传递给ThirdStep组件路由,因为当我们从步骤 3 提交表单时,我们会将所有数据直接保存到数据库中。

如果您愿意,您可以将updateUser函数传递给ThirdStep组件并通过调用该函数将其保存到状态,updateUser但这不是必需的。

现在,让我们使用updateUser这些组件内部的功能来保存数据。

因此打开FirstStep.js文件SecondStep.js并在onSubmit处理程序函数内部添加props.updateUser(data)作为第一个语句。

// FirstStep.js
const onSubmit = (data) => {
  props.updateUser(data);
  props.history.push('/second');
};

// SecondStep.js
const onSubmit = (data) => {
  props.updateUser(data);
  props.history.push('/third');
};
Enter fullscreen mode Exit fullscreen mode

现在,如果您检查该应用程序,您将看到以下屏幕:

保存至状态

如您所见,最初AppRouter组件状态是一个空对象,但是当我们在每个步骤中提交表单时,状态对象会使用用户输入的数据进行更新。

现在,让我们使用状态中保存的数据,并在从下一步返回上一步时填充相应的输入字段。

如您所知,我们正在使用钩子来react-hook-form管理表单FirstStepSecondStep组件中不断变化的数据useForm

但是该useForm钩子还采用一个可选参数,我们可以使用它来在路线改变时保留值。

因此,从文件中更改以下代码FirstStep.js

const { register, handleSubmit, errors } = useForm();
Enter fullscreen mode Exit fullscreen mode

到此代码:

const { user } = props;
const { register, handleSubmit, errors } = useForm({
  defaultValues: {
    first_name: user.first_name,
    last_name: user.last_name
  }
});
Enter fullscreen mode Exit fullscreen mode

在这里,我们user从传递文件路由的 props 对象中解构 prop AppRouter.js,然后使用该defaultValues属性为每个输入字段设置值。

需要提醒的是,first_namelast_name是组件中输入字段的名称,FirstStepreact-hook-form 使用它们来跟踪变化的数据。

现在,如果您检查该应用程序,您将看到以下屏幕:

保留的数据

如您所见,当我们从步骤 2 返回到步骤 1 时,步骤 1 中输入的数据不会丢失,因为我们会user在路由更改时再次安装组件时使用状态中的数据重新设置它。

现在,让我们在文件中添加类似的代码SecondStep.js

const { user } = props;
const { register, handleSubmit, errors } = useForm({
  defaultValues: {
    user_email: user.user_email,
    user_password: user.user_password
  }
});
Enter fullscreen mode Exit fullscreen mode

现在,如果您检查该应用程序,您将看到以下屏幕:

数据保留步骤 2

可以看到,当我们从步骤 3 返回到步骤 2 或步骤 1 时,在步骤 1 和步骤 2 中输入的数据并没有丢失。因此,我们成功地跨步骤保存了数据。

如何向应用程序添加动画过渡

现在,让我们为应用程序添加平滑滑动动画功能。

为了添加动画,我们使用了一个非常流行的帧运动库。

Framer motion 可以非常轻松地使用声明式方法添加动画,就像 React 的做法一样。

因此让我们在FirstStep组件中添加动画。

打开FirstStep.js文件并在文件顶部添加帧运动库的导入语句:

import { motion } from 'framer-motion';
Enter fullscreen mode Exit fullscreen mode

要为页面上的任何元素添加动画,我们需要在其前面加上motion如下前缀:

<div>Click here to animate it</div>

// the above code will need to be converted to

<motion.div>Click here to animate it</motion.div>
Enter fullscreen mode Exit fullscreen mode

使用 motion 作为前缀将返回一个添加了特定动画功能的 React 组件,以便我们可以将 props 传递给该元素。

因此,在FirstStep.js文件内部,将 motion 前缀添加到以下 div 后:

<div className="col-md-6 offset-md-3">
...
</div>
Enter fullscreen mode Exit fullscreen mode

它看起来是这样的:

<motion.div className="col-md-6 offset-md-3">
...
</motion.div>
Enter fullscreen mode Exit fullscreen mode

一旦我们为其添加了运动前缀,我们就可以像这样为该元素提供额外的道具:

<motion.div
  className="col-md-6 offset-md-3"
  initial={{ x: '-100vw' }}
  animate={{ x: 0 }}
>
...
</motion.div>
Enter fullscreen mode Exit fullscreen mode

这里,我们提供了一个initialprop 来指定动画的开始位置。我们希望整个表单从左侧滑入,因此我们将其设置x为 ,-100vw表示从左侧开始 100% 的视口宽度。因此,表单的初始位置将位于最左侧,但在屏幕上不可见。

然后我们给animatepropx赋值为 ,0这样表单就会从左侧滑入,然后回到它在页面上的原始位置。如果我们给 prop 赋值为 ,10那么x它会从原始位置移动到10px右侧。

现在,文件中的整个 JSX 代码FirstStep.js将如下所示:

return (
  <Form className="input-form" onSubmit={handleSubmit(onSubmit)}>
    <motion.div
      className="col-md-6 offset-md-3"
      initial={{ x: '-100vw' }}
      animate={{ x: 0 }}
    >
      <Form.Group controlId="first_name">
        <Form.Label>First Name</Form.Label>
        <Form.Control
          type="text"
          name="first_name"
          placeholder="Enter your first name"
          autoComplete="off"
          ref={register({
            required: 'First name is required.',
            pattern: {
              value: /^[a-zA-Z]+$/,
              message: 'First name should contain only characters.'
            }
          })}
          className={`${errors.first_name ? 'input-error' : ''}`}
        />
        {errors.first_name && (
          <p className="errorMsg">{errors.first_name.message}</p>
        )}
      </Form.Group>

      <Form.Group controlId="last_name">
        <Form.Label>Last Name</Form.Label>
        <Form.Control
          type="text"
          name="last_name"
          placeholder="Enter your last name"
          autoComplete="off"
          ref={register({
            required: 'Last name is required.',
            pattern: {
              value: /^[a-zA-Z]+$/,
              message: 'Last name should contain only characters.'
            }
          })}
          className={`${errors.last_name ? 'input-error' : ''}`}
        />
        {errors.last_name && (
          <p className="errorMsg">{errors.last_name.message}</p>
        )}
      </Form.Group>

      <Button variant="primary" type="submit">
        Next
      </Button>
    </motion.div>
  </Form>
);
Enter fullscreen mode Exit fullscreen mode

现在,如果您检查应用程序,您将看到页面加载时的滑动动画:

滑动动画

如您所见,表单从页面左侧滑入,但看起来还不太流畅。

为了使动画流畅,transition除了initialanimate道具之外,我们还可以提供额外的道具。

<motion.div
  className="col-md-6 offset-md-3"
  initial={{ x: '-100vw' }}
  animate={{ x: 0 }}
  transition={{ stiffness: 150 }}
>
...
</motion.div>
Enter fullscreen mode Exit fullscreen mode

这里,我们添加了一个transition值为 的 prop 150stiffness你可以尝试将值从 改为150其他值,看看哪个看起来更合适。我150这里就用这个。

现在,如果您检查应用程序,您将看到页面加载时出现流畅的滑动动画:

流畅的动画

SecondStep.js让我们在和文件中进行相同的动画更改ThirdStep.js

import { motion } from 'framer-motion';
...
<motion.div
  className="col-md-6 offset-md-3"
  initial={{ x: '-100vw' }}
  animate={{ x: 0 }}
  transition={{ stiffness: 150 }}
>
...
</motion.div>
Enter fullscreen mode Exit fullscreen mode

现在,如果您检查应用程序,您将看到页面加载时所有 3 个步骤的平滑滑动动画:

所有步骤动画

如何使用 Node.js 设置后端

现在,我们已经完成了前端的所有基本功能。让我们设置后端服务器代码,以便将表单中输入的数据保存到 MongoDB 数据库中。

server在文件夹外创建一个名称为 的新文件夹,并在文件夹src创建models、文件夹routersserver

server现在,从命令行文件夹执行以下命令:

yarn init -y
Enter fullscreen mode Exit fullscreen mode

这将在文件夹内创建一个package.json文件server,以便我们可以管理依赖项。

现在,通过从终端文件夹执行以下命令来安装所需的依赖项server

yarn add bcryptjs@2.4.3 cors@2.8.5 express@4.17.1 mongoose@5.11.18 nodemon@2.0.7
Enter fullscreen mode Exit fullscreen mode

.gitignore现在,在文件夹内创建一个新文件server,并在其中添加以下行,这样node_modules如果您决定将代码推送到 GitHub,该文件夹就不会被推送到 GitHub。

node_modules
Enter fullscreen mode Exit fullscreen mode

db.js在文件夹内创建一个新文件server,内容如下:

const mongoose = require('mongoose');

mongoose.connect('mongodb://127.0.0.1:27017/form-user', {
  useNewUrlParser: true,
  useCreateIndex: true,
  useUnifiedTopology: true
});
Enter fullscreen mode Exit fullscreen mode

这里,我们使用mongoose库来操作 MongoDB。对于mongoose.connect方法,我们提供了一个以form-user数据库名称作为连接字符串的连接字符串。

您可以给出任何您想要的名称来代替form-user

index.js现在,在文件夹中创建一个新文件,server并在其中添加以下内容:

const express = require('express');
require('./db');

const app = express();
const PORT = process.env.PORT || 3030;

app.get('/', (req, res) => {
  res.send('<h2>This is from index.js file</h2>');
});

app.listen(PORT, () => {
  console.log(`server started on port ${PORT}`);
});
Enter fullscreen mode Exit fullscreen mode

现在,打开文件并在其中server/package.json添加部分:scripts

"scripts": {
    "start": "nodemon index.js"
},
Enter fullscreen mode Exit fullscreen mode

这里我们使用npm 包,如果文件中的任何更改或文件中包含的nodemon任何更改,它将重新启动 express 服务器,因此我们不必在每次更改时手动重新启动服务器。index.jsindex.js

因此你的整个package.json文件将如下所示:

{
  "name": "server",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "scripts": {
    "start": "nodemon index.js"
  },
  "dependencies": {
    "bcryptjs": "2.4.3",
    "cors": "2.8.5",
    "express": "4.17.1",
    "mongoose": "5.11.18",
    "nodemon": "2.0.7"
  }
}
Enter fullscreen mode Exit fullscreen mode

现在,打开另一个终端并yarn start从文件夹内部执行命令server

如果您访问http://localhost:3030/,您将看到以下屏幕:

服务器初始页面

这表明我们的 Express 服务器已正确设置。接下来我们编写 Rest API 来存储用户注册数据。

如何创建 REST API

user.js在文件夹中创建一个新文件server/models,内容如下:

const mongoose = require('mongoose');

const userSchema = mongoose.Schema(
  {
    first_name: {
      type: String,
      required: true,
      trim: true
    },
    last_name: {
      type: String,
      required: true,
      trim: true
    },
    user_email: {
      type: String,
      required: true,
      trim: true,
      validate(value) {
        if (!value.match(/^[^@ ]+@[^@ ]+\.[^@ .]{2,}$/)) {
          throw new Error('Email is not valid.');
        }
      }
    },
    user_password: {
      type: String,
      required: true,
      trim: true,
      minlength: 6
    },
    country: {
      type: String,
      required: true,
      trim: true
    },
    state: {
      type: String,
      trim: true
    },
    city: {
      type: String,
      trim: true
    }
  },
  {
    timestamps: true
  }
);

const User = mongoose.model('User', userSchema);

module.exports = User;
Enter fullscreen mode Exit fullscreen mode

在这里,我们创建了一个User模式来定义集合中存储的数据的结构User

如果您从未使用过mongoose图书馆,请查看我的这篇文章进行介绍。

user.js在文件夹内创建一个新文件routers,内容如下:

const express = require('express');
const User = require('../models/user');
const bcrypt = require('bcryptjs');
const router = express.Router();

router.post('/register', async (req, res) => {
  const { user_email, user_password } = req.body;

  console.log('req.body', req.body);

  let user = await User.findOne({ user_email });
  if (user) {
    return res.status(400).send('User with the provided email already exist.');
  }

  try {
    user = new User(req.body);
    user.user_password = await bcrypt.hash(user_password, 8);

    await user.save();
    res.status(201).send();
  } catch (e) {
    res.status(500).send('Something went wrong. Try again later.');
  }
});

module.exports = router;
Enter fullscreen mode Exit fullscreen mode

在这里,我们为/register路由创建了一个 post API。我们将以 JSON 格式将数据传递给此 API,Express 服务器会将其存储在req.body对象中,因此我们从中解构了电子邮件和密码值:

const { user_email, user_password } = req.body;
Enter fullscreen mode Exit fullscreen mode

然后使用模型findOne的方法User,我们首先检查是否有任何用户具有提供的电子邮件地址。

  let user = await User.findOne({ user_email });
Enter fullscreen mode Exit fullscreen mode

如果存在这样的用户,那么我们会将错误返回给客户端(即我们的 React App)。

return res.status(400).send('User with the provided email already exist.');
Enter fullscreen mode Exit fullscreen mode

在发回响应时指定错误的 HTTP 响应代码总是好的。

您可以在此网站上找到所有 HTTP 状态代码及其含义

然后,我们将所有用户数据(如 first_name、last_name、user_email、users_password、country、state 和 city)传递给req.body构造函数User

但是我们不想将用户输入的数据原样存储到数据库中,因此我们使用非常流行的bcryptjs npm 库在将密码保存到数据库之前对其进行哈希处理。

user.user_password = await bcrypt.hash(user_password, 8);
Enter fullscreen mode Exit fullscreen mode

查看我的这篇文章来了解bcryptjs详细信息。

一旦密码被散列,我们就会调用模型save的方法User将所有详细信息连同散列密码一起保存到 MongoDB 数据库中。

await user.save();
Enter fullscreen mode Exit fullscreen mode

一旦我们完成,我们将发回带有状态代码的响应,201该状态代码描述了某些内容已创建。

res.status(201).send();
Enter fullscreen mode Exit fullscreen mode

请注意,这里我们没有发回任何数据,而只是发送了一个响应,表示请求成功并且创建了一条新记录。

最后,我们将导出快递,router以便在文件中使用它index.js

现在,打开server/index.js文件并在文件顶部导入用户路由器:

const userRouter = require('./routers/user');
Enter fullscreen mode Exit fullscreen mode

由于我们以 JSON 格式将数据从 React 应用程序发送到 Node.js 服务器进行注册,因此我们需要为中间件添加以下代码:

app.use(express.json());
Enter fullscreen mode Exit fullscreen mode

另外,在PORT常量之后添加以下代码行:

app.use(userRouter);
Enter fullscreen mode Exit fullscreen mode

因此你的整个server/index.js文件将如下所示:

const express = require('express');
const userRouter = require('./routers/user');
require('./db');

const app = express();
const PORT = process.env.PORT || 3030;

app.use(express.json());
app.use(userRouter);

app.get('/', (req, res) => {
  res.send('<h2>This is from index.js file</h2>');
});

app.listen(PORT, () => {
  console.log(`server started on port ${PORT}`);
});
Enter fullscreen mode Exit fullscreen mode

这里,我们userRouter为 Express 应用提供了一个中间件。因此我们可以向它发出 API 请求。

将每个路由器分离到自己的文件中并使用该app.use方法将其包含在内总是好的,以避免通过将其写入单个文件而使代码变得更大。

./mongod --dbpath=<path_to_mongodb-data_folder>现在,按照本文所述运行命令来启动本地 MongoDB 数据库服务器并保持其运行。

然后通过yarn start从文件夹运行命令重新启动快速服务器server并保持其运行。

yarn start如果尚未完成,请打开另一个终端并通过运行命令启动反应应用程序。

因此现在您将打开两个单独的终端 - 一个用于运行 express 服务器应用程序,另一个用于运行 react 应用程序,如下所示。

VSCode 终端

现在,我们在 VSCode 中打开终端。您可以先在Terminal -> New TerminalVS Code 的菜单中打开第一个终端,然后点击+图标即可打开其他终端。

如何从 React 应用调用 REST API

现在,让我们在 React 应用程序中更改代码以对我们的/registerAPI 进行 API 调用。

打开ThirdStep.js文件并handleSubmit用以下代码替换该方法:

const handleSubmit = async (event) => {
    event.preventDefault();

    try {
      const { user } = props;
      const updatedData = {
        country: countries.find(
          (country) => country.isoCode === selectedCountry
        )?.name,
        state:
          states.find((state) => state.isoCode === selectedState)?.name || '',
        city: selectedCity
      };

      await axios.post(`${BASE_API_URL}/register`, {
        ...user,
        ...updatedData
      });
    } catch (error) {
      if (error.response) {
        console.log('error', error.response.data);
      }
    }
  };
Enter fullscreen mode Exit fullscreen mode

在这里,一旦我们在步骤 2 中提交表单,我们就会调用handleSubmit对我们的 API 进行 API 调用的方法/register

await axios.post(`${BASE_API_URL}/register`, {
  ...user,
  ...updatedData
});
Enter fullscreen mode Exit fullscreen mode

/register在这里,我们以 JSON 格式将数据传递给API。

由于我们将国家代码存储在中selectedCountry,将州代码存储在selectedState状态变量中(用 表示)isoCode,因此我们首先使用数组find方法找出与该国家和州代码相关的实际名称,如下所示:

const updatedData = {
  country: countries.find(
    (country) => country.isoCode === selectedCountry
  )?.name,
  state:
    states.find((state) => state.isoCode === selectedState)?.name || '',
  city: selectedCity
};
Enter fullscreen mode Exit fullscreen mode

如果您想快速复习最广泛使用的数组方法(包括数组查找方法),请查看我的这篇文章

在状态变量内部selectedCity我们存储了名称,因此我们不需要在那里使用过滤方法。

在使用find状态方法时,我们添加了||条件,因为如果任何选定的国家/地区都没有可用的状态,那么在访问时?.name,可能会出现这种undefined情况,为了避免存储undefined在数据库中,我们使用||运算符来存储空字符串''而不是或undefind

如何测试 REST API

现在,让我们检查应用程序的功能。

CORS 错误

如您所见,当我们尝试在步骤 3 中提交表单时,浏览器控制台中出现了 CORS(跨域资源共享)错误。

这是因为浏览器不允许访问在另一个端口上运行的应用程序的数据,因为我们在端口 3000 上运行 React 应用程序,在端口 3030 上运行 Node.js 应用程序。

这是出于安全原因以及跨域策略。

因此,为了解决这个问题,我们需要安装cors npm 包并在我们的server/index.js文件中使用它,这样 Node.js 服务器将允许任何应用程序访问其 API。

别担心,我们将在本文后面看到如何在不使用的情况下使用 Node.js API,cors并且还可以避免运行两个单独的终端来启动 React 和 Node.js 服务器。

因此,现在打开server/index.js文件并添加 cors 的导入,如下所示:

const cors = require('cors');
Enter fullscreen mode Exit fullscreen mode

请注意,我们cors之前在创建 express 服务器时已经安装了 npm 包。

并将其作为 express 中间件添加到app.use(userRouter)语句之前,如下所示:

app.use(express.json());
app.use(cors());
app.use(userRouter);
Enter fullscreen mode Exit fullscreen mode

现在你的index.js文件将如下所示:

const express = require('express');
const cors = require('cors');
const userRouter = require('./routers/user');
require('./db');

const app = express();
const PORT = process.env.PORT || 3030;

app.use(express.json());
app.use(cors());
app.use(userRouter);

app.get('/', (req, res) => {
  res.send('<h2>This is from index.js file</h2>');
});

app.listen(PORT, () => {
  console.log(`server started on port ${PORT}`);
});
Enter fullscreen mode Exit fullscreen mode

现在,如果您提交表单,您将正确看到记录到控制台的数据:

保存日志

数据也会保存到数据库中,如下所示:

保存到数据库

现在,我们已经成功将前端 React 应用程序连接到后端 Node.js 应用程序并将数据保存到数据库中。

如何显示注册反馈弹出窗口

你可能注意到了,注册用户后,没有任何迹象表明数据已成功保存到数据库。现在就来做这件事吧。

为了显示成功消息,我们将使用sweetalert2,这是一个非常流行的可定制弹出模式库。

将其导入ThirdStep.js文件中,如下所示:

import Swal from 'sweetalert2';
Enter fullscreen mode Exit fullscreen mode

并在handleSubmit函数内部,axios.post调用之后,在 try 块中添加以下代码:

Swal.fire('Awesome!', "You're successfully registered!", 'success').then(
(result) => {
  if (result.isConfirmed || result.isDismissed) {
    props.history.push('/');
  }
}
);
Enter fullscreen mode Exit fullscreen mode

并在 catch 块中添加以下代码:

if (error.response) {
  Swal.fire({
    icon: 'error',
    title: 'Oops...',
    text: error.response.data
  });
}
Enter fullscreen mode Exit fullscreen mode

所以你的handleSubmit函数现在看起来像这样:

const handleSubmit = async (event) => {
    event.preventDefault();

    try {
      const { user } = props;
      const updatedData = {
        country: countries.find(
          (country) => country.isoCode === selectedCountry
        )?.name,
        state:
          states.find((state) => state.isoCode === selectedState)?.name || '', // or condition added because selectedState might come as undefined
        city: selectedCity
      };

      await axios.post(`${BASE_API_URL}/register`, {
        ...user,
        ...updatedData
      });
      Swal.fire('Awesome!', "You're successfully registered!", 'success').then(
        (result) => {
          if (result.isConfirmed || result.isDismissed) {
            props.history.push('/');
          }
        }
      );
    } catch (error) {
      if (error.response) {
        Swal.fire({
          icon: 'error',
          title: 'Oops...',
          text: error.response.data
        });
        console.log('error', error.response.data);
      }
    }
  };
Enter fullscreen mode Exit fullscreen mode

现在,如果您检查该应用程序,您将看到以下屏幕:

电子邮件错误

如您所见,如果具有该电子邮件地址的用户已存在于数据库中,那么我们将显示来自 catch 块的错误消息。

如果数据库中不存在用户电子邮件,则我们会看到成功弹出窗口,如下所示:

成功注册

如果您检查弹出代码是否成功,它看起来像这样:

Swal.fire('Awesome!', "You're successfully registered!", 'success').then(
  (result) => {
    if (result.isConfirmed || result.isDismissed) {
      props.history.push('/');
    }
  }
);
Enter fullscreen mode Exit fullscreen mode

因此,如果用户点击OK按钮或在弹出模式之外点击,我们会使用 将用户重定向到步骤 1。props.history.push('/');但是,注册成功后,我们还应该清除输入字段中用户输入的数据。那就这样做吧。

如果您还记得,我们在组件内部添加了一个resetUser函数AppRouter来清除user状态数据。

我们将此函数作为 prop 传递给ThirdStep组件。这样你的ThirdStep路由看起来会像这样:

<Route
  render={(props) => (
    <ThirdStep
      {...props}
      user={user}
      updateUser={updateUser}
      resetUser={resetUser}
    />
  )}
  path="/third"
/>
Enter fullscreen mode Exit fullscreen mode

handleSubmit在文件函数内部ThirdStep.js,调用之前像这样props.history.push('/');调用该函数:resetUser

Swal.fire('Awesome!', "You're successfully registered!", 'success').then(
  (result) => {
    if (result.isConfirmed || result.isDismissed) {
      props.resetUser();
      props.history.push('/');
    }
  }
);
Enter fullscreen mode Exit fullscreen mode

现在,如果您注册一个新用户,您将看到注册后,您将被重定向到步骤 1,并且所有输入字段也将被清除。

已清除的字段

如何向应用程序添加登录功能

我们已经为前端和后端添加了完整的注册功能。接下来我们添加登录功能,以便检查提供邮箱和密码的用户是否存在,并检索该用户的详细信息。

打开该routers/user.js文件并在其中的语句前添加以下代码module.exports

router.post('/login', async (req, res) => {
  try {
    const user = await User.findOne({ user_email: req.body.user_email });
    if (!user) {
      return res.status(400).send('User with provided email does not exist.');
    }

    const isMatch = await bcrypt.compare(
      req.body.user_password,
      user.user_password
    );

    if (!isMatch) {
      return res.status(400).send('Invalid credentials.');
    }
    const { user_password, ...rest } = user.toObject();

    return res.send(rest);
  } catch (error) {
    return res.status(500).send('Something went wrong. Try again later.');
  }
});
Enter fullscreen mode Exit fullscreen mode

这里,我们首先使用 方法来检查所提供邮箱地址的用户是否已经存在findOne。如果不存在这样的用户,则返回状态码为 的错误400

如果有用户拥有提供的电子邮件地址,我们将使用bcrypt.compare方法将原始非散列密码与散列密码进行比较。如果散列转换后的密码与user对象中的密码不匹配,则返回错误信息Invalid credentials

但是如果密码匹配,那么我们将使用 ES9 对象的 rest 运算符创建一个具有除散列密码之外的rest所有属性的新对象:user

const { user_password, ...rest } = user.toObject();
Enter fullscreen mode Exit fullscreen mode

这是因为出于安全原因我们不想返回散列密码。

然后我们将rest删除密码的对象发送回客户端(我们的 React 应用程序)。

现在,我们已经创建了后端 API,让我们集成前端部分以实现登录功能。

使用以下代码在文件夹Login.js创建一个新文件:components

import React, { useState } from 'react';
import { useForm } from 'react-hook-form';
import { Form, Button } from 'react-bootstrap';
import axios from 'axios';
import { BASE_API_URL } from '../utils/constants';

const Login = () => {
  const { register, handleSubmit, errors } = useForm();
  const [successMessage, setSuccessMessage] = useState('');
  const [errorMessage, setErrorMessage] = useState('');
  const [userDetails, setUserDetails] = useState('');

  const onSubmit = async (data) => {
    console.log(data);

    try {
      const response = await axios.post(`${BASE_API_URL}/login`, data);
      setSuccessMessage('User with the provided credentials found.');
      setErrorMessage('');
      setUserDetails(response.data);
    } catch (error) {
      console.log(error);
      if (error.response) {
        console.log('error', error.response.data);
        setErrorMessage(error.response.data);
      }
    }
  };

  return (
    <Form className="input-form" onSubmit={handleSubmit(onSubmit)}>
      <div className="col-md-6 offset-md-3">
        {errorMessage ? (
          <p className="errorMsg login-error">{errorMessage}</p>
        ) : (
          <div>
            <p className="successMsg">{successMessage}</p>

            {userDetails && (
              <div className="user-details">
                <p>Following are the user details:</p>
                <div>First name: {userDetails.first_name}</div>
                <div>Last name: {userDetails.last_name}</div>
                <div>Email: {userDetails.user_email}</div>
                <div>Country: {userDetails.country}</div>
                <div>State: {userDetails.state}</div>
                <div>City: {userDetails.city}</div>
              </div>
            )}
          </div>
        )}
        <Form.Group controlId="first_name">
          <Form.Label>Email</Form.Label>
          <Form.Control
            type="email"
            name="user_email"
            placeholder="Enter your email address"
            ref={register({
              required: 'Email is required.',
              pattern: {
                value: /^[^@ ]+@[^@ ]+\.[^@ .]{2,}$/,
                message: 'Email is not valid.'
              }
            })}
            className={`${errors.user_email ? 'input-error' : ''}`}
          />
          {errors.user_email && (
            <p className="errorMsg">{errors.user_email.message}</p>
          )}
        </Form.Group>

        <Form.Group controlId="password">
          <Form.Label>Password</Form.Label>
          <Form.Control
            type="password"
            name="user_password"
            placeholder="Choose a password"
            ref={register({
              required: 'Password is required.',
              minLength: {
                value: 6,
                message: 'Password should have at-least 6 characters.'
              }
            })}
            className={`${errors.user_password ? 'input-error' : ''}`}
          />
          {errors.user_password && (
            <p className="errorMsg">{errors.user_password.message}</p>
          )}
        </Form.Group>

        <Button variant="primary" type="submit">
          Check Login
        </Button>
      </div>
    </Form>
  );
};

export default Login;
Enter fullscreen mode Exit fullscreen mode

现在,打开AppRouter.js文件并在所有路由的末尾、结束Switch标签之前添加一个用于登录的路由,如下所示:

<BrowserRouter>
     ...
    <Route component={Login} path="/login" />
    </Switch>
  </div>
</BrowserRouter>
Enter fullscreen mode Exit fullscreen mode

另外,Login在顶部包含组件:

import Login from '../components/Login';
Enter fullscreen mode Exit fullscreen mode

现在,如果您访问http://localhost:3000/login,您将看到以下屏幕:

登录屏幕

这里我们其实不需要在标题中显示这些步骤,所以让我们添加一个条件在登录页面上隐藏它。

打开Progress.js文件并添加另一个 const 变量,如下所示:

const isLoginPage = pathname === '/login';
Enter fullscreen mode Exit fullscreen mode

并在 div 类的开始之前添加三元运算符条件steps

<React.Fragment>
  {!isLoginPage ? (
    <div className="steps">
     ...
    </div>
  ) : (
    <div></div>
  )}
</React.Fragment>
Enter fullscreen mode Exit fullscreen mode

因此,如果该页面不是登录页面,那么我们将显示步骤,否则我们将显示一个空的 div。

请注意,如果我们没有任何内容要渲染,我们需要渲染一个空的 div,因为如果我们没有从组件返回任何 JSX,React 将抛出错误。

您的整个Progress.js文件现在看起来如下:

import React from 'react';
import { Link, withRouter } from 'react-router-dom';

const Progress = ({ location: { pathname } }) => {
  const isFirstStep = pathname === '/';
  const isSecondStep = pathname === '/second';
  const isThirdStep = pathname === '/third';
  const isLoginPage = pathname === '/login';

  return (
    <React.Fragment>
      {!isLoginPage ? (
        <div className="steps">
          <div className={`${isFirstStep ? 'step active' : 'step'}`}>
            <div>1</div>
            <div>
              {isSecondStep || isThirdStep ? (
                <Link to="/">Step 1</Link>
              ) : (
                'Step 1'
              )}
            </div>
          </div>
          <div className={`${isSecondStep ? 'step active' : 'step'}`}>
            <div>2</div>
            <div>
              {isThirdStep ? <Link to="/second">Step 2</Link> : 'Step 2'}
            </div>
          </div>
          <div className={`${pathname === '/third' ? 'step active' : 'step'}`}>
            <div>3</div>
            <div>Step 3</div>
          </div>
        </div>
      ) : (
        <div></div>
      )}
    </React.Fragment>
  );
};

export default withRouter(Progress);
Enter fullscreen mode Exit fullscreen mode

如何测试登录功能

现在,如果您检查登录页面,您将看到页面标题中没有步骤,但其他页面将显示步骤。

无需步骤登录

如果您输入正确的登录凭据,那么您将获得与该用户相关的详细信息,如下所示:

登录成功消息

如果登录凭据无效,您将看到如下所示的错误消息:

登录无效

如果电子邮件存在但密码不匹配,那么您将看到如下所示的错误消息:

无效凭证

现在,让我们了解文件中的代码Login.js

const onSubmit = async (data) => {
  console.log(data);

  try {
    const response = await axios.post(`${BASE_API_URL}/login`, data);
    setSuccessMessage('User with the provided credentials found.');
    setErrorMessage('');
    setUserDetails(response.data);
  } catch (error) {
    console.log(error);
    if (error.response) {
      console.log('error', error.response.data);
      setErrorMessage(error.response.data);
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

onSubmit函数中,我们/login通过传递登录表单中输入的数据来向端点发出 API 调用。

如果 API 响应中没有错误,我们将设置状态,并使用来自 API 的响应successMessage设置状态,否则我们将设置状态。userDetailserrorMessage

在 JSX 中,如果errorMessage状态不为空,我们将显示错误消息,否则显示successMessage带有数据的状态值userDetails

{errorMessage ? (
  <p className="errorMsg login-error">{errorMessage}</p>
) : (
  <div>
    <p className="successMsg">{successMessage}</p>

    {userDetails && (
      <div className="user-details">
        <p>Following are the user details:</p>
        <div>First name: {userDetails.first_name}</div>
        <div>Last name: {userDetails.last_name}</div>
        <div>Email: {userDetails.user_email}</div>
        <div>Country: {userDetails.country}</div>
        <div>State: {userDetails.state}</div>
        <div>City: {userDetails.city}</div>
      </div>
    )}
  </div>
)}
Enter fullscreen mode Exit fullscreen mode

请注意,由于该应用程序用于显示多步骤表单功能,因此我们没有在屏幕上提供登录页面的链接。我添加了登录页面,以便您了解如何验证用户登录。

如果需要,您可以在标题中包含登录页面链接,或者直接使用http://localhost:3000/login访问它。

如何设置无效路由页面

现在,我们已经完成了应用程序的全部功能。让我们添加一些代码,这样如果我们在浏览器URL中输入任何无效路由,我们就应该将用户重定向回主页。

目前,如果您访问任何无效路由,如http://localhost:3000/contact,您将看到一个空白页,并且控制台中也没有错误,因为文件内的路由列表中没有匹配的路由AppRouter.js

空白页

因此,打开AppRouter.js文件并在登录路由后输入另一个路由,如下所示:

  ...
  <Route component={Login} path="/login" />
  <Route render={() => <Redirect to="/" />} />
</Switch>
Enter fullscreen mode Exit fullscreen mode

在这里,我们没有为Route最后一个路由提供任何到组件的路径,因此如果上述任何路由不匹配,则将执行最后一个路由,将用户重定向到组件/路由FirstPage

Redirect另外,从react-router-dom文件顶部导入组件:

import { BrowserRouter, Redirect, Route, Switch } from 'react-router-dom';
Enter fullscreen mode Exit fullscreen mode

请注意,您只需将其作为最后一条路线输入,这样如果上述任何路线不匹配,则将执行最后一条路线并将其重定向到主页。

现在我们来验证一下。

页面未找到

如您所见,对于所有无效路线,我们都会被重定向到主页,即第一步页面。

如何摆脱 CORS 库

如您所知,要运行此应用程序,我们需要yarn start在一个终端中使用命令启动我们的 React 应用程序,我们还需要从后端服务器的文件夹执行yarn start命令server,我们还需要保持我们的 MongoDB 服务器在第三个终端中运行。

这样就无需运行两个单独的yarn start命令了。这样一来,你只需在单个主机提供商上部署应用即可。

如果您还记得的话,server/index.js我们在文件中添加了以下代码:

app.use(cors());
Enter fullscreen mode Exit fullscreen mode

添加此代码会允许任何应用程序访问我们的 API,这在本地环境中工作时没有问题,但允许所有人访问我们的 API 则不安全。所以,让我们修复它。

打开server/index.js文件并在该行上方添加以下代码app.use(express.json());

app.use(express.static(path.join(__dirname, '..', 'build')));
Enter fullscreen mode Exit fullscreen mode

在这里,我们正在配置我们的快速应用程序以使用文件夹的内容build作为我们应用程序的起点。

当我们为 React 应用程序build运行命令时,将创建该文件夹。yarn build

由于该build文件夹将在文件夹外部创建server,因此我们需要..退出server文件夹才能访问它。

另外,path在文件顶部导入节点包:

const path = require('path'); 
Enter fullscreen mode Exit fullscreen mode

我们不需要安装pathnpm 包,当我们在系统上安装 Node.js 时它会被默认添加。

现在,您可以cors从文件中删除导入及其使用server/index.js

您的最终server/index.js文件现在看起来如下:

const path = require('path');
const express = require('express');
const userRouter = require('./routers/user');
require('./db');

const app = express();
const PORT = process.env.PORT || 3030;

app.use(express.static(path.join(__dirname, '..', 'build')));
app.use(express.json());
app.use(userRouter);

app.get('/', (req, res) => {
  res.send('<h2>This is from index.js file</h2>');
});

app.listen(PORT, () => {
  console.log(`server started on port ${PORT}`);
});
Enter fullscreen mode Exit fullscreen mode

现在停止两个yarn start终端的命令,只在一个终端中执行文件夹(即我们的项目文件夹)yarn build内的命令。multi-step-form-using-mern

yarn build命令将需要一些时间才能完成,因为它会执行一些优化,并且只有当我们完成所有应用程序功能并准备将应用程序部署到生产环境时才应执行该命令。

构建完成

命令成功完成后,您将看到build创建的文件夹,如下所示:

文件结构

build文件夹包含我们的整个 React App,因此您可以使用该build文件夹将我们的应用程序部署到生产环境。

现在,打开src/utils/constants.js文件并替换此代码:

export const BASE_API_URL = 'http://localhost:3030';
Enter fullscreen mode Exit fullscreen mode

使用以下代码:

export const BASE_API_URL = '';
Enter fullscreen mode Exit fullscreen mode

现在,我们已经创建了文件夹,从终端build导航到文件夹并执行命令:serveryarn start

服务器已启动

可以看到,服务器在3030端口启动。

因此,让我们通过http://localhost:3030/访问我们的应用程序。

完整流程

如你所见,我们只需运行一个yarn start命令即可启动 Node.js Express 服务器。Node.js 服务器会从build文件夹的 3030 端口渲染我们的 React 应用。

因此,我们所有的 API 现在都可以在和 等http://localhost:3030平台上使用http://localhost:3030/registerhttp://localhost:3030/login

因此我们将BASE_API_URL值更改为空字符串:

export const BASE_API_URL = '';
Enter fullscreen mode Exit fullscreen mode

因此,当我们已经在线时,我们可以使用和来http://localhost:3030发出所有 POST 请求 API /login/register

因此,我们只需要一个终端来运行yarn start命令,另一个终端来启动 MongoDB 服务,这样我们就可以在像heroku这样的单个托管服务提供商上部署我们的应用程序,而不是在一个托管服务提供商上部署 React 应用程序,在另一个托管服务提供商上部署 Node.js 应用程序。

请注意,如果您对 React 应用程序代码进行任何更改,则需要yarn build从项目文件夹重新运行命令,然后yarn startserver文件夹重新运行命令。

但是这种设置有一个问题。如果你直接访问除/路线之外的任何路线,你将收到如下所示的错误:/first/second/login

错误

这是因为我们从 Express.js 启动服务器,所以请求总是会转到 Express.js 服务器(我们使用 Express.js 创建的 Node 服务器),而 Node.js 端没有/second处理该请求的路由。所以会报错。

因此,要解决此问题,请打开server/index.js文件,并在app.listen语句之前和所有其他路由之后添加以下代码:

app.use((req, res, next) => {
  res.sendFile(path.join(__dirname, '..', 'build', 'index.html'));
});
Enter fullscreen mode Exit fullscreen mode

因此,此代码将充当默认路由,如果任何先前的路由不匹配,此代码将从我们的 React 应用程序文件夹index.html发回文件。build

由于该/second路线存在于我们的 React 应用程序中,因此您将看到正确的第 2 步页面。

如果输入的路由在 Node.js 应用程序和我们的 React 应用程序中都不存在,那么您将被重定向到第 1 步页面,这是我们应用程序的主页,因为AppRouter.js文件中有我们的最后一条路由。

<Route render={() => <Redirect to="/" />} />
Enter fullscreen mode Exit fullscreen mode

因此,您的完整server/index.js文件将如下所示:

const path = require('path');
const express = require('express');
const userRouter = require('./routers/user');
require('./db');

const app = express();
const PORT = process.env.PORT || 3030;

app.use(express.static(path.join(__dirname, '..', 'build')));
app.use(express.json());
app.use(userRouter);

app.get('/', (req, res) => {
  res.send('<h2>This is from index.js file</h2>');
});

app.use((req, res, next) => {
  res.sendFile(path.join(__dirname, '..', 'build', 'index.html'));
});

app.listen(PORT, () => {
  console.log(`server started on port ${PORT}`);
});
Enter fullscreen mode Exit fullscreen mode

现在你不会再收到错误了:

错误已修复

如果您想深入了解使用 Node.js 渲染 React 应用程序,请查看我的这篇文章

现在,我们已经完成了前端和后端功能,如下所示:

完整的工作应用程序

结束点

我们已完成应用程序的功能构建。

您可以在此存储库中找到此应用程序的完整 GitHub 源代码

为了进一步提升你的技能,你可以在第三步添加额外的验证来改进应用程序,检查用户是否已在表单中输入所有详细信息。因为你可以直接通过http://localhost:3030/second访问表单的第二步页面,并从那里继续操作。

感谢阅读!

想要详细了解所有 ES6+ 功能,包括 let 和 const、承诺、各种承诺方法、数组和对象解构、箭头函数、async/await、导入和导出以及更多从头开始的内容吗?

看看我的《精通现代 JavaScript》这本书。这本书涵盖了学习 React 的所有先决条件,并能帮助你更好地掌握 JavaScript 和 React。

由于很多人要求降价,我今天正在进行折扣促销,只需 13 美元即可购买。所以,不要错过这个机会。

另外,您可以查看我的免费“React Router 简介”课程,从头开始学习“React Router”。

想要定期了解有关 JavaScript、React 和 Node.js 的最新内容吗?请在 LinkedIn 上关注我

鏂囩珷鏉ユ簮锛�https://dev.to/myogeshchavan97/how-to-create-a-full-stack-multi-step-registration-app-with-nice-animations-using-the-mern-stack-12gp
PREV
如何轻松创建和托管您自己的 REST API,而无需编写任何代码
NEXT
为您的下一个项目精心挑选的免费 API 列表