用 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 方式下载包。
- 先卸载老旧的 create-react-app,
项目用到的其他库或者插件
- 会使用到 antd 这个前端组件库,会使用到 react 相关的路由,会使用到 axios 处理 ajax,会使用到 qs 处理报文,会使用到 echarts 展示表格。
- 以上合起来的命令就是
npm install antd react-router-dom qs axios echarts echarts-for-react --save和npm 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后内存一直在涨就没停过,那就可能是内存泄漏了- 可以查看 create-react-app 项目 3.3.0 版本的一个issue,到目前 4.0.0 版本依然有这个问题。
- 可以下翻这个 issue,找到一些规避方法,比如yarn 上更新@babel/core,比如深度更新@babel/core,比如增加--max_old_space_size,比如禁用 sourcemap 或者降版本到 3.2.0,比如注释 webpack 配置中的 ts 检查。
- 前几种方法要么无效有么就是操作麻烦,本人使用的最后一种方法,也就是“注释 webpack 配置中的 ts 检查”,注释后效果理想(从 90%降到 50%)。
使用路由
react-router-dom在项目初始化时已经安装,在已经清空的 App.tsx 中引入 react-router-dom 的Route、HashRouter和Switch,会使用这三个来写路由组件,这个路由组件会暴露给入口文件 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;编写登陆表单
在 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。
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);
}
}.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 包裹它,再调一下样式。
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>
);
}
}.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 部分代码
componentDidMount() {
axios.get('/api/isLogin').then((res) => {
console.log('res', res);
});
}后端 LoginController.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
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
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
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 所需要的数据,也就是之前复制过来的实例。转化的过程中还需要参考配置项,比如标签的单选等。
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
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;
}