# 用 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 来使用。

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;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

# 编写登陆表单

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

要使用 antd 的组件,得先在入口文件 index.tsx 中引入import 'antd/dist/antd.css';。我们要编写表单中的登录框,可以在antd 官网 (opens new window)顶部搜索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。

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);
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
.login-border {
  width: 300px;
  margin: 100px auto;
  padding: 20px 20px 0 20px;
  border: 1px solid #ccc;
}
1
2
3
4
5
6

# 编写主页

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

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>
    );
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
.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;
}
1
2
3
4
5
6
7
8
9
10

# 关联后端

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

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

前端 home.tsx 部分代码

componentDidMount() {
    axios.get('/api/isLogin').then((res) => {
        console.log('res', res);
    });
}
1
2
3
4
5

后端 LoginController.ts 部分代码

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

# 主页、登陆、登出

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

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

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

home.tsx

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 });
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53

login.tsx

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("登陆失败");
      }
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75

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

LoginController.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)));
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68

# 爬取和展示

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

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

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

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);
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175

# 统一前后端接口类型注解

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

resRlt.d.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;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23