Skip to content

用 react 展示爬虫数据

项目初始化

  • 使用 create-react-app 这个项目脚手架:

    • 先卸载老旧的 create-react-app,npm uninstall create-react-app -g
    • 然后全局安装它npm install create-react-app -g
    • 在一个空目录或者你的前端 workspace 下运行create-react-app crawler_react --template typescript --use-npm
    • 上一条的命令是以ts 版本的脚手架作为模板新建一个名为crawler_react的项目,use-npm 是以 npm 方式下载包。
  • 项目用到的其他库或者插件

    • 会使用到 antd 这个前端组件库,会使用到 react 相关的路由,会使用到 axios 处理 ajax,会使用到 qs 处理报文,会使用到 echarts 展示表格。
    • 以上合起来的命令就是npm install antd react-router-dom qs axios echarts echarts-for-react --savenpm install @types/react-router-dom @types/qs @types/echarts -D
  • 删除 src 中不需要的文件:

    • 删除入口文件 index.tsx 中的 reportWebVitals 和 index.css 的使用,对应删除 reportWebVitals.ts 文件和 index.css 文件;
    • 清空 App.tsx 中的所有内容,删除 logo.svg 文件和 App.css 文件;
    • 删除测试相关的文件 setupTests.ts 和 App.test.tsx。
  • 如果有发现项目npm start后内存一直在涨就没停过,那就可能是内存泄漏了

使用路由

react-router-dom项目初始化时已经安装,在已经清空的 App.tsx 中引入 react-router-dom 的RouteHashRouterSwitch,会使用这三个来写路由组件,这个路由组件会暴露给入口文件 index.tsx 来使用。

ts
import React from "react";
import { HashRouter, Switch, Route } from "react-router-dom";
import LoginView from "./views/login/login";

const ViewRouter = () => {
  return (
    <div>
      <HashRouter>
        <Switch>
          <Route path="/login" exact component={LoginView} />
        </Switch>
      </HashRouter>
    </div>
  );
};
export default ViewRouter;

编写登陆表单

在 src 下新建一个 views 文件夹,再在 views 下新建一个 login 文件夹,再在 login 下新建 login.tsc 和 login.css。

要使用 antd 的组件,得先在入口文件 index.tsx 中引入import 'antd/dist/antd.css';。我们要编写表单中的登录框,可以在antd 官网顶部搜索form,在右侧找到“登陆框”,然后将代码复制过来放到login.tsc里。

去掉login.tsc里的ReactDOM.render(<NormalLoginForm />, mountNode);,还有 username 和 remember 两个 item 组件去掉,最后的注册Or <a href="">register now!</a>也去掉。然后导入import React from 'react';,这样可以解决Form的报错。然后给整个登陆套一个 div,写上我们自己的样式 login.css。

ts
import React, { Component } from "react";
import { Form, Input, Button } from "antd";
import { LockOutlined } from "@ant-design/icons";
import "./login.css";

export default class LoginView extends Component {
  public render() {
    return (
      <div className="login-border">
        <Form name="normal_login" className="login-form" initialValues={{ remember: true }} onFinish={this.onFinish}>
          <Form.Item name="password" rules={[{ required: true, message: "请输入密码!" }]}>
            <Input prefix={<LockOutlined className="site-form-item-icon" />} type="password" placeholder="Password" />
          </Form.Item>
          <Form.Item>
            <Button type="primary" htmlType="submit" className="login-button">
              登陆
            </Button>
          </Form.Item>
        </Form>
      </div>
    );
  }
  private onFinish(values: any) {
    console.log("Received values of form: ", values);
  }
}
css
.login-border {
  width: 300px;
  margin: 100px auto;
  padding: 20px 20px 0 20px;
  border: 1px solid #ccc;
}

编写主页

在 views 下新建一个 home 文件夹,再在 home 下新建 home.tsc 和 home.css。我们要编写按钮可以使用 antd 的<Button>,当然需要引入import { Button } from 'antd';,然后我们自己写个 div 包裹它,再调一下样式。

ts
import React, { Component } from "react";
import "./home.css";
import { Button } from "antd";

export default class HomeView extends Component {
  public render() {
    return (
      <div className="home-border">
        <Button type="primary">查看数据</Button>
        <Button type="primary">爬取数据</Button>
        <Button type="primary">退出登陆</Button>
      </div>
    );
  }
}
css
.home-border {
  width: 350px;
  margin: 100px auto;
  padding: 20px 0 20px 20px;
  border: 1px solid #ccc;
  border-radius: 5px;
}
.home-border .ant-btn {
  margin-right: 21px;
}

关联后端

axios项目初始化时已经安装了,我们要使用 axios 发送 ajax 请求来关联后端,使用时先导入import axios from 'axios';。然后我们需要打开本项目的代理,目的是让前端发送的请求让代理转发到后端服务上(都没有部署并且都在本地),具体就是打开 package.json 文件,在里面加上"proxy": "http://localhost:7001/"的配置,这个地址就是后端启动后访问的地址。

我们将 axios 请求放到 react 的componentDidMount声明周期函数里写。使用axios.get(),参数是接口地址常以/api开头,返回的是个 promise。写完后,后端要对应有api/xxx的接口。

前端 home.tsx 部分代码

ts
componentDidMount() {
    axios.get('/api/isLogin').then((res) => {
        console.log('res', res);
    });
}

后端 LoginController.ts 部分代码

ts
@get('/api/isLogin')
private isLoginApi(req: ExpRequest, res: Response): void {
    res.json(getResponse<boolean>(isLogin(req, res)));
}

主页、登陆、登出

主页:在进入主页时需要判断是否已登陆,已登陆就加载主页,否则重定向到登陆页。这个登陆状态需要使用state来存储,在接口返回时使用this.setState()来更新状态,这样就会触发 render。重定向使用 react-router-dom 的Redirect标签。

登陆:登陆页的逻辑首先也是判断是否已经登陆了,登陆就直接重定向到主页,否则显示登陆框,登陆框的登陆要调用后端的/api/login接口,其他的处理同主页一样(使用 state、重定向标签)。

登出:登出按钮在主页,需要添加一个onClick事件,然后请求/api/logout接口,接口返回成功后要更新 state,并重定向到登陆页。

home.tsx

ts
import React, { Component } from "react";
import { Redirect } from "react-router-dom";
import "./home.css";
import { Button } from "antd";
import axios from "axios";

export default class HomeView extends Component {
  public state = { isLogin: false, loaded: false };
  // 组件第一次渲染完成,此时dom节点已经生成,可以在这里调用ajax请求,返回数据,setState后组件会重新渲染
  componentDidMount() {
    // ajax请求
    axios.get("/api/isLogin").then((res) => {
      if (res.data?.data) {
        this.setState({ isLogin: true, loaded: true });
      } else {
        this.setState({ isLogin: false, loaded: true });
      }
    });
  }
  // 渲染组件
  public render() {
    const { isLogin, loaded } = this.state;
    if (loaded) {
      // isLogin接口走完了才显示页面
      if (isLogin) {
        // 登陆了就显示主页
        return (
          <div className="home-border">
            <Button type="primary">查看数据</Button>
            <Button type="primary">爬取数据</Button>
            <Button
              type="primary"
              onClick={() => {
                this.logout();
              }}
            >
              退出登陆
            </Button>
          </div>
        );
      }
      return <Redirect to="/login" />; // 没登录就重定向到登陆页
    }
    return null;
  }
  // 登出
  private async logout() {
    const res = await axios.get("/api/logout");
    if (res.data?.data) {
      this.setState({ isLogin: false });
    }
  }
}

login.tsx

ts
import React from "react";
import { Form, Input, Button, message } from "antd";
import { LockOutlined } from "@ant-design/icons";
import axios from "axios";
import qs from "qs";
import { Redirect } from "react-router-dom";
import "./login.css";

interface Istate {
  isLogin: boolean;
  loaded: boolean;
}

export default class LoginView extends React.Component<any, Istate> {
  constructor(props: any) {
    super(props);
    this.state = { isLogin: false, loaded: false };
    this.submit = this.submit.bind(this);
  }
  // 组件第一次渲染完成,此时dom节点已经生成,可以在这里调用ajax请求,返回数据,setState后组件会重新渲染
  componentDidMount() {
    // ajax请求
    axios.get("/api/isLogin").then((res) => {
      if (res.data?.data) {
        this.setState({ isLogin: true, loaded: true });
      } else {
        this.setState({ isLogin: false, loaded: true });
      }
    });
  }
  public render() {
    const { isLogin, loaded } = this.state;
    if (loaded) {
      // isLogin接口走完了才显示页面
      if (!isLogin) {
        // 没登陆就显示登陆框
        return (
          <div className="login-border">
            <Form name="normal_login" className="login-form" initialValues={{ remember: true }} onFinish={this.submit}>
              <Form.Item name="password" rules={[{ required: true, message: "请输入密码!" }]}>
                <Input
                  prefix={<LockOutlined className="site-form-item-icon" />}
                  type="password"
                  placeholder="Password"
                />
              </Form.Item>
              <Form.Item>
                <Button type="primary" htmlType="submit" className="login-button">
                  登陆
                </Button>
              </Form.Item>
            </Form>
          </div>
        );
      }
      return <Redirect to="/"></Redirect>; // 登陆了就重定向到主页
    }
    return null;
  }
  // 点击“登陆”按钮
  private async submit(values: any) {
    if (values && values.password) {
      const res = await axios.post(
        "/api/login", // 接口名,登陆校验的接口
        qs.stringify({ password: values.password }), // qs会处理传参,headers得完善
        { headers: { "Content-Type": "application/x-www-form-urlencoded" } }
      );
      if (res.data?.data) {
        this.setState({ isLogin: true }); // 登陆了更新状态
      } else {
        message.error("登陆失败");
      }
    }
  }
}

其实后端也做了修改,主要是去掉后端的/处理,然后给根路径设置为api

LoginController.ts

ts
import { Request, Response, NextFunction, RequestHandler } from "express";
import "reflect-metadata";
import { controller } from "../decorator/controllerDecorator";
import { get, post } from "../decorator/requestDecorator";
import { use } from "../decorator/useDecorator";
import { getResponse } from "../utils/getResponse";

// 请求体的session
interface ISession extends CookieSessionInterfaces.CookieSessionObject {
  login: boolean;
}
// 请求体
export interface ExpRequest extends Request {
  body: {
    [key: string]: string | undefined;
  };
  session: ISession;
}
// 登陆状态
const isLogin = (req: ExpRequest, res: Response): boolean => {
  return !!(req.session && req.session.login);
};
// 登陆状态的中间件
export const checkLogin: RequestHandler = (req: ExpRequest, res: Response, next: NextFunction): void => {
  if (isLogin(req, res)) {
    next();
  } else {
    res.json(getResponse<boolean>(false, "您还没有登陆!请先登陆!"));
  }
};
// 根路径
const root: string = "/api";

// 这个类不用实例化也不用静态调用,但是一定要在index.ts里导入,因为要触发装饰器
@controller(root)
class LoginController {
  // 登陆校验
  @post("/login")
  private loginPost(req: ExpRequest, res: Response): void {
    if (isLogin(req, res)) {
      res.json(getResponse<boolean>(true));
    } else {
      const { password } = req.body;
      if (password === "123") {
        // 登陆状态更新
        req.session.login = true;
        res.json(getResponse<boolean>(true));
      } else {
        res.json(getResponse<boolean>(false, "密码错误!"));
      }
    }
  }
  // 登出
  @get("/logout")
  private logout(req: ExpRequest, res: Response): void {
    if (isLogin(req, res)) {
      req.session.login = undefined;
      res.json(getResponse<boolean>(true));
    } else {
      res.json(getResponse<boolean>(false, "您本来就是未登录状态!"));
    }
  }
  // 是否已登陆
  @get("/isLogin")
  private isLoginApi(req: ExpRequest, res: Response): void {
    res.json(getResponse<boolean>(isLogin(req, res)));
  }
}

爬取和展示

爬取:爬取按钮在主页,需要添加一个onClick事件,然后请求/api/data接口,接口返回后提示用户爬取成功或者失败。

展示:展示就使用echarts,根据我们的需要选择一个柱状图,将链接里的实例复制过来。

首先导入import ReactEcharts from 'echarts-for-react',然后使用这个标签<ReactEcharts option={this.getOption()} onEvents={this._onEvents} />,需要在 getOption 方法里给这个图表赋予数据。那么得请求/api/show接口,然后存储在 state 中。具体在 getOption 中将 state 中存储的数据转化为 echarts 所需要的数据,也就是之前复制过来的实例。转化的过程中还需要参考配置项,比如标签的单选等。

ts
import React from "react";
import { Redirect } from "react-router-dom";
import "./home.css";
import { Button, message } from "antd";
import ReactEcharts from "echarts-for-react";
import axios from "axios";

// 汇总信息(所有榜单的汇总)
interface IrankInfo {
  name: string; // 名字or标题
  list: Irank[]; // 具体榜单
}
// 具体榜单信息
interface Irank {
  name: string; // 名字or标题
  list: Ibook[]; // 榜单里的具体小说
}
// 小说信息
interface Ibook {
  num: string; // 序号(排名)
  bookName: string; // 书名
  bookCount: string; // 票数or点击率or人气
}
// echarts纵坐标
interface IyAxisData {
  [key: string]: string[];
}
// 切换榜单事件
type Func = (...args: any[]) => any;
interface EventMap {
  [key: string]: Func;
}
// react的state
interface Istate {
  isLogin: boolean; // 是否登录
  loaded: boolean; // 登录接口是否请求完
  data: IrankInfo; // 排行榜数据
}
export default class HomeView extends React.Component<any, Istate> {
  // echarts绑定事件
  private _onEvents: EventMap = {
    legendselectchanged: this.onLegendChang.bind(this), // 切换榜单事件
  };
  private _yAxisData: IyAxisData = {}; // 纵坐标
  constructor(props: any) {
    super(props);
    this.state = { isLogin: false, loaded: false, data: { name: "", list: [] } }; // 初始化state
    this.crawlerData = this.crawlerData.bind(this); // 给事件绑定this
    this.logout = this.logout.bind(this); // 给事件绑定this
  }
  // 组件第一次渲染完成,此时dom节点已经生成,可以在这里调用ajax请求,返回数据,setState后组件会重新渲染
  public async componentDidMount() {
    // 是否登录
    const isLoginRes = await axios.get("/api/isLogin");
    if (isLoginRes.data?.data) {
      this.setState({ isLogin: true, loaded: true });
    } else {
      this.setState({ isLogin: false, loaded: true });
    }
    const showRes = await axios.get("/api/show");
    if (showRes.data?.data) {
      this.setState({ data: showRes.data.data });
    } else {
      message.error("展示数据获取失败!");
    }
  }
  // 渲染组件
  public render() {
    const { isLogin, loaded } = this.state;
    if (loaded) {
      // isLogin接口走完了才显示页面
      if (isLogin) {
        // 登陆了就显示主页
        return (
          <div className="home-border">
            <div className="buttons">
              <Button type="primary" onClick={this.crawlerData}>
                爬取数据
              </Button>
              <Button type="primary" onClick={this.logout}>
                退出登陆
              </Button>
            </div>
            <ReactEcharts option={this.getOption()} onEvents={this._onEvents} />
          </div>
        );
      }
      return <Redirect to="/login" />; // 没登录就重定向到登陆页
    }
    return null;
  }
  // 登出
  private async logout() {
    const res = await axios.get("/api/logout");
    if (res.data?.data) {
      this.setState({ isLogin: false });
    } else {
      message.error("退出失败!");
    }
  }
  // 爬取一次数据
  private async crawlerData() {
    const res = await axios.get("/api/data");
    if (res.data?.data) {
      message.success("爬取成功!");
    } else {
      message.error("爬取失败!");
    }
  }
  // echarts数据,类型注解先从'echarts-for-react'找不到合适的再去'echarts'的类型定义文件里找
  private getOption(): echarts.EChartOption {
    const data: IrankInfo = this.state.data;
    const title = data.list.length ? "数据来自纵横中文网" : "";
    const legendArr: string[] = [];
    const series: echarts.EChartOption.Series[] = [];
    data.list.forEach((value: Irank, index: number) => {
      legendArr.push(value.name);
      // 保证book是按照num降序排列
      const bookList = value.list.sort((a, b) => parseInt(b["num"]) - parseInt(a["num"]));
      const bookData: number[] = [];
      const nameArr: string[] = [];
      bookList.forEach((value: Ibook, index: number) => {
        bookData.push(parseInt(value.bookCount));
        nameArr.push(value.bookName);
      });
      this._yAxisData[value.name] = nameArr;
      series.push({ name: value.name, type: "bar", data: bookData });
    });
    return {
      title: {
        text: data.name,
        subtext: title,
      },
      legend: {
        selectedMode: "single", // 单选
        data: legendArr,
      },
      tooltip: {
        // 数据悬浮显示
        trigger: "axis",
        axisPointer: {
          type: "shadow",
        },
      },
      grid: {
        left: "3%",
        right: "4%",
        bottom: "3%",
        containLabel: true,
      },
      xAxis: {
        type: "value",
        boundaryGap: [0, 0.01],
      },
      yAxis: {
        type: "category",
        data: this._yAxisData[legendArr[0]],
      },
      series,
    };
  }
  // 切换榜单
  private onLegendChang(param: any, echarts: echarts.ECharts) {
    // console.log('param', param);
    // console.log('echarts', echarts);
    const option = echarts.getOption();
    if (option != null) {
      option.yAxis = {
        type: "category",
        data: this._yAxisData[param.name],
      };
    }
    echarts.setOption(option);
  }
}

统一前后端接口类型注解

因为前后端接口传递的数据是一样的,那么可以让它们用同一份类型定义文件。分别放到前后端的 src 目录下即可,对应前后端接口相关的类型就使用下面这份类型定义。

resRlt.d.ts

ts
declare namespace ResRlt {
  // 汇总信息(所有榜单的汇总)
  interface IrankInfo {
    name: string; // 名字or标题
    list: Irank[]; // 具体榜单
  }
  // 具体榜单信息
  interface Irank {
    name: string; // 名字or标题
    list: Ibook[]; // 榜单里的具体小说
  }
  // 小说信息
  interface Ibook {
    num: string; // 序号(排名)
    bookName: string; // 书名
    bookCount: string; // 票数or点击率or人气
  }
  type LoginRes = boolean;
  type LogoutRes = boolean;
  type IsLoginRes = boolean;
  type DataRes = boolean;
  type ShowRes = IrankInfo | boolean;
}

MIT Licensed | Copyright © 2023-present LiawnLiu