10

Go+GraphQL+React+Typescript搭建简书项目(五)——注册与登录

 4 years ago
source link: https://studygolang.com/articles/28084
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

项目地址: github

前端的路由

在React中,路由的使用主要由react-router-dom提供。使用yarn安装react-router-dom。

$ yarn add react-router-dom

我们删除脚手架提供的示例代码,只保留index和App。

我们这里将路由内容写在App.tsx文件中。

import React from "react";
import {BrowserRouter, Route, Switch} from "react-router-dom"

export default function App() {
    return (
        <BrowserRouter>
                <Switch>
                    <Route path='/'/>
                    <Route path='/signIn'/>
                    <Route path='/signUp'/>
                </Switch>
        </BrowserRouter>
    )
}

这里我们定义了三个路由,path分别对应登录、注册和首页。路由被Switch包裹,以提供路由匹配切换的功能。一般情况下,匹配的规则为从上到下匹配,匹配成功即停止。

最上层的为BrowserRouter,正是它实现了前端的路由功能。当然也可以使用HashRouter。这两者最直观的区别,在于HashRouter的url中会出现'#'号,形似 http://127.0.0.1 :3000/#。

但最主要的区别在于,HashRouter使用url中的hash部分创建路由,并不会向服务器请求资源。而BrowserRouter基于真实url实现路由,会向服务端请求资源,如何服务端没有做相应的配置,会发生资源不存在的问题。

这里我们使用BrowserRouter,在本地开发时不会出现问题,后续上服务端时,我们再将如何进行配置。

现在我们为路由添加相应的组件。

在src目录下新建pages目录,用于存放我们的页面组件。在pages下新建sign目录,存放注册登录组件,新建jianshu目录,存放首页组件。

进入jianshu目录,新建jianshu.tsx文件。

import React from "react";

/**
 * Jianshu router component
 * @constructor
 */
export default function Jianshu() {
    return (
        <div>Index</div>
    )
}

进入sign目录,新建sign.tsx文件。

import React from "react";

/**
 * Sign router component
 * @constructor
 */
export default function Sign() {
    return (
        <div>Sign</div>
    )
}

现在我们的路由组件就建立好了。但是还不着急马上使用。我们先来介绍一下React 中的 suspense 和 lazy。

React 中的 suspense 和 lazy

lazy

在React项目中,我们所有import导入的组件和库,默认都是直接导入。使用webpack打包后,会将这些文件全部打包称为一个bundle文件。在正常情况下,加载一个js文件要比多个js文件快很多。

但是随着我们import的组件和库越多,bundle文件的体积越大,就会造成首次加载的速度变慢,甚至时间过长而无法忍受。这个时候,我们就需要对bundle中对内容进行代码分割。以期实现按需加载对模式。

而在React中,React.lazy()正是用来对项目代码进行分割,实现懒加载使用的。懒加载意味着当只有组件被使用时,其内部的资源才会被导入。

Suspense

Suspense的作用就是在遇到异步请求或者异步导入组件的时候等待请求和导入完成再进行渲染。我们通常使用Suspense来实现loading画面。

在路由中使用懒加载

了解了suspense 和 lazy的用处后,我们现在来正式使用他们。使用的方式很简单,修改App.tsx文件。

import React, {Suspense, lazy} from "react";
import {BrowserRouter, Route, Switch} from "react-router-dom"

export default function App() {
    return (
        <BrowserRouter>
            <Suspense fallback={<div>loading</div>}>
                <Switch>
                    <Route path='/' component={lazy(() => import('./pages/jianshu/jianshu'))}/>
                    <Route path='/signIn' component={lazy(() => import('./pages/sign/sign'))}/>
                    <Route path='/signUp' component={lazy(() => import('./pages/sign/sign'))}/>
                </Switch>
            </Suspense>
        </BrowserRouter>
    )
}

我们将组件的import使用lazy包裹起来,即可以实现懒加载。而将有异步请求或者动态加载的部分,放到Suspense下,就可以在请求过程中,调用fallback回调函数,渲染loading画面。

制作顶部进度条

在浏览网页的时候,我们常常会看到这样一个效果,当网页加载或者我们发出的请求,还未完成时,浏览器的顶部,会有一条细长的进度条。

我们现在也将借助Nprogress库实现这一效果,并将它应用在Suspense的fallback中,实现路由动效。

安装nprogress。

$ yarn add nprogress

在src下新建component目录,用于存放公共组件。在component下新建loading.tsx文件。

import React, { useEffect } from 'react';
import NProgress from 'nprogress';
import 'nprogress/nprogress.css';
import {Col, Row} from "antd";

const Loading = () => {
    useEffect(() => {
        NProgress.start();
        return () => {
            NProgress.done();
        };
    }, []);
    return (
        <Row>
            <Col span={12} offset={6}>
                Loading
            </Col>
        </Row>
    );
};

export default Loading;

将loading组件应用到路由中。修改App.tsx。

import React, {Suspense, lazy} from "react";
import {BrowserRouter, Route, Switch} from "react-router-dom"
import Loading from "./component/loading";

export default function App() {
    return (
        <BrowserRouter>
            <Suspense fallback={<Loading/>}>
                <Switch>
                    <Route path='/' component={lazy(() => import('./pages/jianshu/jianshu'))}/>
                    <Route path='/signIn' component={lazy(() => import('./pages/sign/sign'))}/>
                    <Route path='/signUp' component={lazy(() => import('./pages/sign/sign'))}/>
                </Switch>
            </Suspense>
        </BrowserRouter>
    )
}

在React中使用GraphQL客户端

现在我们来看如何在React中,请求GraphQL服务端资源。

Apollo Client

在React中,我们通常使用Apollo Client来管理对GraphQL的请求。关于Apollo Client详细对介绍,可以查看官方文档: https://www.apollographql.com...

使用Apollo Client最大的好处就是,Apollo Client实现了对资源的缓存,这意味着我们可以放弃复杂难以管理的redux,而只专注于如何请求资源。

首先安装Apollo Client。

yarn add apollo-boost @apollo/react-hooks graphql

要使用Apollo Client,就需要创建了一个全局的客户端以供使用。我们修改index.tsx文件。

import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import ApolloClient from 'apollo-boost';
import {ApolloProvider} from '@apollo/react-hooks';

const client = new ApolloClient({
    uri: 'http://localhost:8008/graphql',
    fetchOptions:{
        credentials:'include',
    },
    onError: ({networkError}) => {
        // @ts-ignore
        if (networkError && networkError.statuscode === 401) {
            // 跳转到登录界面
        }
    },
});

ReactDOM.render(
    <ApolloProvider client={client}>
        <App/>
    </ApolloProvider>,
    document.getElementById('root')
);

这里我们使用new ApolloClient创建了client,指定其要请求的uri路径。由于我们需要使用到cookie,所以这里设置了fetchOptions的credentials选项为include。

然后对于请求的一些共性的错误处理,这里列了一处,对于后端返回的401网络错误,统一识别未未登录,直接跳转到登录界面(逻辑未写)。

最后使用ApolloProvider将整个项目包起来,指定其client,就可以提供客户端服务了。

使用Apollo Client与后端交互

我们知道,要请求GraphQL服务端的资源,必须发送对应的GraphQL请求内容。Apollo Client使用gql提供了对GraphQL请求的解析。

gql使用的方法也很简单,直接声明即可。

const GET_DOGS = gql`
  {
    dogs {
      id
      breed
    }
  }
`;

知道了如何定义请求的内容,我们再来看看怎么发送请求。

在Apollo Client中,发送请求的方式大致有两种。Hook和组件。

Hook

官方现在推荐的方式,也是我们将使用的方式,是React的一个不算新的新特性,Hook。

无论是对于query查询,mutation变更,subscription订阅,官方都提供了对应的React Hook的Api给我们调用。

分别是useQuery,useMutation,useSubscription。这里以useQuery作为讲解示例。

React Hook只能在React的高阶组件中使用,class是无法使用的。

我们来看使用示例。

function Dogs({ onDogSelected }) {
  const { loading, error, data } = useQuery(GET_DOGS);

  if (loading) return 'Loading...';
  if (error) return `Error! ${error.message}`;

  return (
    <select name="dog" onChange={onDogSelected}>
      {data.dogs.map(dog => (
        <option key={dog.id} value={dog.breed}>
          {dog.breed}
        </option>
      ))}
    </select>
  );
}

可以看到,我们将之前示例中定义的GET_DOGS作为请求内容,传入useQuery中,然后获得了三个值。分别代表加载中,错误和请求应该返回的数据。

正常情况下,我们需要对这三种状态进行分别处理。

当Apollo Client从服务端正确获取数据的时候,默认会对数据进行缓存,这意味着,如果页面没有进行刷新,缓存没有丢失的情况下,我们后续相同的请求会很快得到响应。

但有时候,我们可能想再次获取,而不是读取缓存,这个时候官方也提供了相应的办法:轮询和重新获取。

function DogPhoto({ breed }) {
  const { loading, error, data } = useQuery(GET_DOG_PHOTO, {
    variables: { breed },
    skip: !breed,
    pollInterval: 500,
  });

  if (loading) return null;
  if (error) return `Error! ${error}`;

  return (
    <img src={data.dog.displayImage} style={{ height: 100, width: 100 }} />
  );
}

上面的示例是轮询的使用方法,我们给定了一个时间间隔,客户端会在每隔一段时间,对数据进行重新抓取。

当然我们还有直接手动重新抓取的办法。

function DogPhoto({ breed }) {
  const { loading, error, data, refetch } = useQuery(GET_DOG_PHOTO, {
    variables: { breed },
    skip: !breed,
  });

  if (loading) return null;
  if (error) return `Error! ${error}`;

  return (
    <div>
      <img src={data.dog.displayImage} style={{ height: 100, width: 100 }} />
      <button onClick={() => refetch()}>Refetch!</button>
    </div>
  );
}

我们在useQuery返回的值中,得到refetch这个函数,直接调用它就可以重新获取了。

useQuery在使用时,会直接进行请求。可是某些情况下,我们会想全部由自己手动控制。这个时候可以使用useLazyQuery这个钩子函数。

import React, { useState } from 'react';
import { useLazyQuery } from '@apollo/react-hooks';

function DelayedQuery() {
  const [dog, setDog] = useState(null);
  const [getDog, { loading, data }] = useLazyQuery(GET_DOG_PHOTO);

  if (loading) return <p>Loading ...</p>;

  if (data && data.dog) {
    setDog(data.dog);
  }

  return (
    <div>
      {dog && <img src={dog.displayImage} />}
      <button onClick={() => getDog({ variables: { breed: 'bulldog' } })}>
        Click me!
      </button>
    </div>
  );
}

在示例中,我们手动调用getDog来与后端交互。

组件

因为这个方式我们基本不会使用,所以这里只做介绍,看个示例就可以了。

const Dogs = ({ onDogSelected }) => (
  <Query query={GET_DOGS}>
    {({ loading, error, data }) => {
      if (loading) return 'Loading...';
      if (error) return `Error! ${error.message}`;

      return (
        <select name="dog" onChange={onDogSelected}>
          {data.dogs.map(dog => (
            <option key={dog.id} value={dog.breed}>
              {dog.breed}
            </option>
          ))}
        </select>
      );
    }}
  </Query>
);

注册与登录

前面的示例程序,都是官方的,现在我们来正式编写自己的请求。

我们在component目录下新建query目录,进入query目录,新建query.tsx文件,用来统一管理所有的gql。

import {gql} from "apollo-boost";

export const CheckUsernameGQL = gql`
query ValidUsername($username:String!){
  ValidUsername(username:$username)
}
`;

export const CheckEmailGQL = gql`
query ValidEmail($email:String!){
  ValidEmail(email:$email)
}
`;

export const SignUpGQL  = gql`
mutation SignUp($email:String!,$password:String!,$username:String!){
  SignUp(email:$email,password:$password,username:$username){
    id
    username
    introduce
    avatar
    state
    root
  }
}
`;

export  const SignInGQL = gql`
mutation SignIn($username:String!,$password:String!,$rememberme:Boolean!){
  SignIn(username:$username,password:$password,rememberme:$rememberme){
    id
    username
    introduce
    avatar
    state
    root
  }
}
`

在sign目录下新建sign.tsx和sign.less文件。

.sign {
  height: 100%;
  min-height: 550px;
  text-align: center;
  font-size: 14px;
  background-color: #f1f1f1;
  padding-bottom: 10%;

  .logo {
    position: absolute;
    top: 56px;
    margin-left: 50px;
    background-color: inherit;
  }

  .main {
    width: 400px;
    margin: 60px auto 0;
    padding: 50px 50px 30px;
    background-color: #fff;
    border-radius: 4px;
    box-shadow: 0 0 8px rgba(0, 0, 0, .1);
    vertical-align: middle;
    display: inline-block;

    .title {
      margin: 0 auto 50px;
      font-weight: 400;
      color: #969696;
    }


    .restyle > span {
      width: 200px;
      margin: 0 8px 8px 0;
    }

    .remember-btn {
      float: left;
      margin: 15px 0;
    }

    .forgot-btn {
      float: right;
      position: relative;
      margin: 15px 0;
      font-size: 14px;
    }

    .login-btn, .register-btn {
      width: 100%;
      height: 43px;
      font-size: 18px;
      border: none;
      border-radius: 25px;
      color: #fff;
      background: #42c02e;
      cursor: pointer;
      outline: none;
      display: block;
      clear: both;
    }

    .login-btn {
      background: #3194d0;
    }

    .tooltip-inner{
      margin-top: 12px;
    }
  }
}
import React, {useEffect, useState} from "react";
import "./sign.less"
import {Button, Checkbox, Form, Input, Layout, Tabs, Tooltip, message} from "antd";
import {RouteComponentProps} from "react-router-dom";
import {UserOutlined, LockOutlined, MailOutlined} from '@ant-design/icons';
import {useLazyQuery, useMutation} from "@apollo/react-hooks";
import {CheckEmailGQL, CheckUsernameGQL, SignInGQL, SignUpGQL} from '../../component/query/query'
import {IconFont} from "../../component/IconFont";
import NProgress from 'nprogress';
import 'nprogress/nprogress.css';

const {Header, Content} = Layout;
const {TabPane} = Tabs;

/**
 * sign router component
 * @constructor
 */
export default function Sign(props: RouteComponentProps) {
    const [path, setPath] = useState(props.location.pathname);
    useEffect(() => {
        setPath(props.location.pathname)
    }, [props.location.pathname]);
    return (
        <Layout className="sign">
            <Header className="logo">
                <a href="/">
                    <img style={{width: 100, height: 100}} src="logo192.png" alt="logo"/>
                </a>
            </Header>
            <Content className="main">
                <Tabs
                    tabBarGutter={60} defaultActiveKey={path} onTabClick={() => {
                    if (path === "/signIn") {
                        props.history.push("/signUp")
                    } else {
                        props.history.push("/signIn")
                    }
                }}>
                    <TabPane tab="登录" key="/signIn" className="title">
                        <LoginForm history={props.history} location={props.location} match={props.match}/>
                    </TabPane>
                    <TabPane tab="注册" key="/signUp" className="title">
                        <RegisterForm history={props.history} location={props.location} match={props.match}/>
                    </TabPane>
                </Tabs>
            </Content>
        </Layout>
    )
};

const LoginForm = (props: RouteComponentProps) => {

    const [form] = Form.useForm();

    const [signIn] = useMutation(SignInGQL);

    const onFinish = (values: any) => {
        if (values.rememberme===undefined){
            values.rememberme = false
        }
        NProgress.start()
        signIn({variables: values}).then(r => {
            if (r.errors) {
                let err = r.errors.join("\n");
                message.error(err);
                return
            }
            if (r.data) {
                props.history.push('/')
            }
        }).catch(reason => {
            message.error(reason.toString());
        })
        NProgress.done()
    };

    return (
        <Form form={form} style={{paddingTop: 10}} name="login" initialValues={{remember: true}} scrollToFirstError
              onFinish={onFinish}>
            <Form.Item name="username" className="input-prepend restyle" rules={
                [{required: true, message: '用户名不能为空'}]}>
                <Input size={"large"} prefix={<UserOutlined className="icon"/>}
                       placeholder="用户名 / 邮箱"/>
            </Form.Item>
            <Form.Item name="password" className="input-prepend" rules={
                [{required: true, message: '密码不能为空'}]
            }>
                <Input.Password prefix={<LockOutlined/>} size={"large"} placeholder="Password"/>
            </Form.Item>
            <Form.Item>
                <Form.Item name="rememberme" valuePropName="checked" className="remember-btn">
                    <Checkbox>记住我</Checkbox>
                </Form.Item>
                <div className="forgot-btn">
                    <a href="/?">
                        忘记密码
                    </a>
                </div>
            </Form.Item>
            <Form.Item>
                <Button htmlType="submit" className="login-btn">登录</Button>
            </Form.Item>
        </Form>
    )
};

const RegisterForm = (props: RouteComponentProps) => {

    const [form] = Form.useForm();

    const [uv, setuv] = useState(false);
    const [ev, setev] = useState(false);

    const [signUp] = useMutation(SignUpGQL);

    const onFinish = (values: any) => {
        NProgress.start()
        signUp({variables: values}).then(r => {

            if (r.errors) {
                let err = r.errors.join("\n");
                message.error(err);
                return
            }
            if (r.data) {
                props.history.push('/signIn')
            }
        }).catch(reason => {
            message.error(reason.toString());
        })
        NProgress.done()
    };

    const [checkUsername, {error: error1}] = useLazyQuery(CheckUsernameGQL);

    const [checkEmail, {error: error2}] = useLazyQuery(CheckEmailGQL);

    const validateUsername = () => {
        let username = form.getFieldValue("username");
        if (username) {
            checkUsername({variables: {username: username}});
            if (error1 && error1.message !== "") {
                setuv(true);
                return
            }
        }
        setuv(false);

    };

    const validateEmail = () => {
        let email = form.getFieldValue("email");
        if (email) {
            checkEmail({variables: {email: email}});
            if (error2 && error2.message !== "") {
                setev(true);
                return
            }
        }
        setev(false);
    };

    return (
        <Form form={form} style={{paddingTop: 10}} name="register" scrollToFirstError onFinish={onFinish}>

            <Form.Item name="username" className="input-prepend restyle" rules={[
                {max: 20, message: '用户名长度不能多于20个字符!'},
                {min: 6, message: '用户名长度最低为8个字符!'},
                {required: true, message: '请输入用户名'},
            ]}>
                <Input onBlur={validateUsername}
                       onFocus={() => setuv(false)}
                       size={"large"} prefix={<UserOutlined/>}
                       placeholder="设置你的用户名"
                       suffix={
                           <Tooltip visible={uv} getPopupContainer={() => document.body}
                                    placement={"right"} className="tooltip-inner"
                                    overlay={error1 && error1.message !== "" &&
                                    <div>
                                        <IconFont type="icon-gantanhao"/><span>  {error1.message}</span>
                                    </div>}
                           />}
                />
            </Form.Item>
            <Form.Item name="email" className="input-prepend restyle" rules={[
                {type: 'email', message: "请输入正确的邮箱地址"},
                {required: true, message: '请输入你的邮箱地址'},
            ]}>

                <Input onBlur={validateEmail}
                       onFocus={() => setev(false)}
                       size={"large"} prefix={<MailOutlined/>}
                       placeholder="设置邮箱地址"
                       suffix={
                           <Tooltip visible={ev} getPopupContainer={() => document.body} placement={"right"}
                                    overlay={error2 && error2.message !== "" &&
                                    <div className="tooltip-inner">
                                        <IconFont type="icon-gantanhao"/><span>  {error2.message}</span>
                                    </div>}
                           />}
                />
            </Form.Item>
            <Form.Item name="password" className="input-prepend" rules={[
                {max: 20, message: '密码长度不能多于20个字符'},
                {min: 8, message: '密码长度不能少于8个字符'},
                {required: true, message: '请设置你的密码'}
            ]}>
                <Input.Password prefix={<LockOutlined/>} size={"large"}
                                placeholder="设置密码"/>
            </Form.Item>
            <Form.Item>
                <Button htmlType="submit" className="register-btn">注册</Button>
            </Form.Item>
        </Form>
    )
};

效果如下。

vmYzEf2.png!web

BbAjqyU.png!web

作者个人博客地址: https://unrotten.org

作者微信公众号:

fAVjiqU.png!web

欢迎关注我们的微信公众号,每天学习Go知识

FveQFjN.jpg!web

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK