react基础3

react基础3:表单和后端

前两篇回顾

react基础组件+如何组合组件

react基础2分页排序搜索+路由

下一篇

react基础4授权与部署

Forms 表单

Introduction

登陆表单

添加电影信息的表单

查询表单

Building a Bootstrap Form

我们先把表单添加到NavBar当中去

首先在NavBar中添加一个Login标签

1
2
3
4
5
<li className="nav-item">
<NavLink className="nav-link" to="/login">
Login
</NavLink>
</li>

其次我们在新建一个loginForm.jsx来存放我们的表单组件(表单格式在bootstrap上)

同时添加一个Login按钮

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
import React, { Component } from "react";
class LoginForm extends Component {
state = {};
render() {
return (
<div>
<h1>Login</h1>
<form>
<div className="form-group">
<label htmlFor="username">Username</label>
<input id="username" type="text" className="form-control" />
</div>
<div className="form-group">
<label htmlFor="password">Password</label>
<input
type="password"
class="form-control"
id="exampleInputPassword1"
placeholder="Password"
/>
</div>
<button className="btn btn-primary">Login</button>
</form>
</div>
);
}
}

export default LoginForm;

然后在App中注册路由

1
<Route path="/login" component={LoginForm}></Route>

Handling Form Submission 提交表单

我们不希望提交表单的时候重新下载页面。那么怎么办?

每个form元素又有一个onSubmit事件,然后我们要把它设置成一个句柄(handler),

在handler中我们需要用到一个preventDefult方法。它可以阻止事件的默认行为

1
2
3
4
5
6
7
8
9
10
11
12
13
import React, { Component } from "react";
class LoginForm extends Component {
handleSubmit = e=>{
e.preventDefault();
// Call the server
console.log('Submitted');
}
render() {
return (
<div>
<h1>Login</h1>
<form onSubmit={this.handleSubmit} >
//....

现在再次点击submit,页面没有被重载

Refs

在平常的时候我们会从document中获得信息,但是在react中我们从不使用document对象。

所以为了获取Users和Password,我们需要给其一个引用(reference)。但这个也不是一个好方法,anyway,这个逻辑就是在组件中新建一个引用,然后在表单的input标签中通过 ref={this.引用}

这样我们就可以实时获取表单中的信息了

1
const username = this.username.current.value;

尽量不要过度使用 refs

现在我们希望在页面渲染完成之后,username输入获得光标

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class LoginForm extends Component {
username = React.createRef();
//利用hooks,在页面渲染完后focus on username
componentDidMount(){
this.username.current.focus();
}

handleSubmit = (e) => {
e.preventDefault();
const username = this.username.current.value;
console.log("Submitted");
};
//...
}

当然我们也可以用 autofocus 属性,物品们把它放在input标签里去

1
2
3
4
5
6
7
<input
autoFocus
ref={this.username}
id="username"
type="text"
className="form-control"
/>

和利用hooks是等价的

Controlled Elements

接下来介绍更标准的输入域的方式。

我们在state中新建一个account对象,里面包含username和password两个属性

1
2
3
state = {
account: { username: "", password: "" },
};

但这是不够的,因为表单组件中的input也具有自己的state,我们需要改变这一点,把表单变成一个受控元素,只接受来自props中的信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
         
//...
handleChange = (e) => {
const account = { ...this.state.account };
account.username = e.currentTarget.value;//获得最新的输入讯息
this.setState({ account });
};
//...
<div className="form-group">
<label htmlFor="username">Username</label>
<input
autoFocus
value={this.state.account.username}//从state中获取
onChange={this.handleChange}//若文本内容发生改变那么就调用handleChange
id="username"
type="text"
className="form-control"
/>
</div>
//...

回到浏览器我们看到输入sss之后,username中也呈现了sss

Handling Multiple Inputs

我现在想动态设置这个属性(需要用到中括号操作符)

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
import React, { Component } from "react";
class LoginForm extends Component {
state = {
account: { username: "", password: "" },
};
handleSubmit = (e) => {
e.preventDefault();
console.log("Submitted");
};

//首先从事件e中析构出currentTarget,并把它称为input(更直观)
handleChange = ({currentTarget:input}) => {
const account = { ...this.state.account };
///在这里我们利用中括号访问!
account[input.name] = input.value;
this.setState({ account });
};

render() {
//先解构state,提取account
const {account } = this.state
return (
<div>
<h1>Login</h1>
<form onSubmit={this.handleSubmit}>
<div className="form-group">
<label htmlFor="username">Username</label>
<input
autoFocus
value={account.username}//从account中获取username
onChange={this.handleChange}//每当改变就发起
name="username"//设置name,以供handleChange访问
id="username"
type="text"
className="form-control"
/>
</div>
<div className="form-group">
<label htmlFor="password">Password</label>
<input
type="password"
onChange={this.handleChange}//每当改变就发起
value={account.password}
name="password"
className="form-control"
id="password"
placeholder="Password"
/>
</div>
<button className="btn btn-primary">Login</button>
</form>
</div>
);
}
}

export default LoginForm;

这样,username和password都成为了受控元素

Common Errors

如果我删除了this.state.account.username ,那么我再试图访问account中的username的时候,会得到undefined,然而value={undefined},虽然定了value,但这不是一个受控元素。 那么这个元素就会有自己的state和value。当我们输入a的时候,(会自动创建state.username) 并且想把这个username传递给表单,但是表单这时候已经不是一个受控元素了,所以说会报错。

这就是为什么我们必须创建一个password,username,并且把它命名为空串的原因(注意,这里千万不能写undefined或者null)。作为一个原则,当我们创建一个表单的时候,当我想创建state对象当中的属性的时候,我们一定要写空串或者从服务器获得数据

Extracting a Reusable Input

我们可以把表单独立成一个可以公用的组件, 然后通过析构,从props中获得所有我想要的信息

input.jsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import React from "react";
//从props中解构出name,label,value,onChange
const Input = ({name,label,value,onChange}) => {
return (
//下面就是替换了,username 用 name替换,渲染部分用label替换,
//this.account.username 用value替换,把原来的this.handleChange变为向上抛出一个event
<div className="form-group">
<label htmlFor={name}>{label}</label>
<input
autoFocus
value={value}
onChange={onChange}
name={name}
id={name}
type="text"
className="form-control"
/>
</div>
);
};

export default Input;

然后回到login部分我们这样修改

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//...
return (
<div>
<h1>Login</h1>
<form onSubmit={this.handleSubmit}>
<Input
name="username"
value={account.username}
label="Username"
onChange={this.handleChange}
/>
<Input
name="password"
value={account.password}
label="Password"
onChange={this.handleChange}
/>
<button className="btn btn-primary">Login</button>
</form>
</div>
);
//...

Validation 验证

几乎所有的表单域都需要值验证。下面我们为我们造的表单添加验证

我们在state中添加errors,然后在里面写上我们出现的错误类型.注意,这里用对象而不是用数组,因为对象的话更容易定位,直接用点即可

1
2
3
errors:{
//...
}

然后我们需要在handleChange方法中调用一个验证的函数

1
2
3
4
5
6
7
8
9
10
validate = ()=>{
return {username:'Username is required'}
}
handleSubmit = (e) => {
e.preventDefault();
const errors = this.validate();
this.setState({ errors });
if (errors) return;
console.log("Submitted");
};

A Basic Validation Implementation

接下来实现上面声明的验证函数(基本功能)

实现的逻辑是这样的,当我们点击提交表单的时候,会调用handleSubmit方法,handleSubmit方法有会调用validate()方法。在 validate中,我们来判断是否存在errors,如果不存在返回null,那么console.log(errors)就不会打印,在最后打印Submitted如果存在errors,那么把errors对象返回,在handleSubmit中打印。然后是先更新。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
validate = () => {
const errors = {};
const { account } = this.state;
if (account.username.trim() === "")
errors.username = "Username is required.";
if (account.password.trim() === "")
errors.password = "Password is required.";
return Object.keys(errors).length === 0 ? null : errors;
};
handleSubmit = (e) => {
e.preventDefault();
const errors = this.validate();
console.log(errors);

this.setState({ errors });
if (errors) return;
console.log("Submitted");
};

Displaying Validation Errors 显示验证错误信息

我们想要在输入错误的时候显示一个错误信息 div.alert.alert-danger

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const Input = ({ name, label, value, error, onChange }) => {
return (
<div className="form-group">
<label htmlFor={name}>{label}</label>
<input
value={value}
onChange={onChange}
name={name}
id={name}
type="text"
className="form-control"
/>
{/*在原来的表单下面添加一个错误显示
只有当error非空才显示,显示内容是从props中解构的error*/}
{error && <div className="alert alert-danger">{error}</div>}
</div>
);
};

然后再loginForm.jsx中,给两个表单添加error属性。注意,一个给的是username,一个给的是password

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<Input
name="username"
value={account.username}
label="Username"
onChange={this.handleChange}
error={errors.username}
/>
<Input
name="password"
value={account.password}
label="Password"
onChange={this.handleChange}
error={errors.password}
/>

实现逻辑是当我们提交空表单的时候我们更新了state中的错误,state把错误传递给Input,然后Input实现渲染更新

Validation on Change 在值变更的时候验证

刚才是在提交的时候显示错误,现在我们在输入值的时候就显示错误。那么我们就必须在handleChange的时候进行修改。首先拷贝errors对象,然后调用判断当前输入是否有错并赋给errorMessage。如果errorMessage非空的话,就把errorMessage装给errors,否则就删除该处信息。最后更新state当中的errors和account

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
validateProperty = ({ name, value }) => {
if (name === "username") {
if (value.trim() === "") return "Username is required.";
}
if (name === "password") {
if (value.trim() === "") return "Password is required.";
}
};
handleChange = ({ currentTarget: input }) => {
const errors = { ...this.state.errors };
const errorMessage = this.validateProperty(input);
if (errorMessage) errors[input.name] = errorMessage;
else delete errors[input.name];

const account = { ...this.state.account };
account[input.name] = input.value;
this.setState({ account, errors });
};

Joi

为了实现验证,我们需要用到一个非常强大的库Joi

https://www.npmjs.com/package/joi

example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const Joi = require('joi');

const schema = Joi.object().keys({
username: Joi.string().alphanum().min(3).max(30).required(),
password: Joi.string().regex(/^[a-zA-Z0-9]{3,30}$/),
access_token: [Joi.string(), Joi.number()],
birthyear: Joi.number().integer().min(1900).max(2013),
email: Joi.string().email({ minDomainAtoms: 2 })
}).with('username', 'birthyear').without('password', 'access_token');

// Return result.
const result = Joi.validate({ username: 'abc', birthyear: 1994 }, schema);
// result.error === null -> valid

// You can also pass a callback which will be called synchronously with the validation result.
Joi.validate({ username: 'abc', birthyear: 1994 }, schema, function (err, value) { }); // err === null -> valid

我们看到这样就方便很多, Joi.string()[一定要字符串形式].alpanum()[字母数字的组合].min(3).max(30)[3到30个字符串之间].required()[必须的]

还可以让string满足这个 正则表达式之类的

如何实现?

首先建一个schema对象,里面有我们需要检测的内容

用result 来接收Joi判断好的对象,其中第一个参数就是受测对象第二个参数就是上面定义的schema

{abortEarly: false,} 就是说让Joi全部检测完才返回,否则Joi检测到错误就不进行下去了

1
2
3
4
5
6
7
8
9
10
11
12
schema = {
username: Joi.string().required(),
password: Joi.string().required(),
};

validate = () => {
const result = Joi.validate(this.state.account, this.schema, {
abortEarly: false,
});
console.log(result);
//...
}

那么我们就看到在error对象中的details中就由我们的报错信息

Validating a Form Using Joi

errors对象中有error属性,他只在问题出现的时候才会被创建。errors中有一个details数组,里面记录了我们的错误信息。我们的挑战就是把这个映射到我们的errors对象当中

我们对handleSubmit和validate进行修改。对于validate我们通过result接受了errors对象以后,判断是否为空,如果非空的话,我们就让遍历details数组,每次迭代一次就给errors对象添加一个新的属性,path中存放的就是 input.name,然后返回

在handleSubmit当中如果errors存在,那么就同步,否则就把errors置空

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
 schema = {
//label在显示的时候提示Username而不是username
username: Joi.string().required().label("Username"),
password: Joi.string().required().label("Password"),
};

validate = () => {
const options = { abortEarly: false };
//这里通过析构简化了代码
const { error } = Joi.validate(this.state.account, this.schema, options);
if (!error) return null;
const errors = {};
for (let item of error.details) errors[item.path[0]] = item.message;
return errors;
};
handleSubmit = (e) => {
e.preventDefault();
const errors = this.validate();
this.setState({ errors: errors || {} });
if (errors) return;
console.log("Submitted");
};

Validating a Field Using Joi

这里调整一下 validateProperty

因为我希望在一个输入框中输入不符合格式的话,就报错,所以我们每次报错一个

我们首先从input中解构name,value

然后利用ES6的运算方法在中括号中写name,那么运行的时候name是什么,[name] 就是什么、

我们就用这个obj来替代this.state.account,因为这时候obj中只有当前表单,所以Joi.validate只会检测当前的表单中的错误。注意,这里不要用abortEarly:false 了,因为我们不希望他一下子把所有的错误提示出来,那样看着会很烦

因为Joi.validate()返回一个errors对象,所以我们要从中析构出error来

1
2
3
4
5
6
validateProperty = ({ name, value }) => {
const obj = { [name]: value };
const schema = { [name]: this.schema[name] };
const { error } = Joi.validate(obj, schema);
return error ? error.details[0].message : null;
};

Disabling the Submit Button

我想禁用提交按钮,知道表单数据合法

这里我如果this.validate()存在,那么我们就把Login按钮变为红色,而且设置为不能提交

disabled={this.validate()}

那么如果检测到没有错误,那么我们就把Login变为蓝色,这时候才能够提交

1
2
3
4
5
6
{ this.validate()&&<button disabled={this.validate()} className="btn btn-danger">
Login
</button>}
{ !this.validate()&&<button className="btn btn-primary">
Login
</button>}

每个表单都可能有自己的errors对象

Extracting a Reusable Form

接下来我们把表单中可以重复利用的都抽象成方法或者子组件。

我们可以新建一个子组件,然后让我这个LoginForm 继承我们的Form组件,这样Form组件中就存放了一些验证、提交的方法,而这些方法对于所有表单都是可以用的。

在LoginForm中,需要保留state,schema 这些表特有的,还要保留render和doSubmit方法(当提交成功后显示的内容,如Submitted之类的)

form.jsx

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
import React, { Component } from "react";
import Joi from "joi-browser";
class Form extends Component {
//这里把form的信息统一命名为data,然后在loginform中还需要重定义
state = {
data: {},
errors: {},
};
validate = () => {
const options = { abortEarly: false };
const { error } = Joi.validate(this.state.account, this.schema, options);
if (!error) return null;
const errors = {};
for (let item of error.details) errors[item.path[0]] = item.message;
return errors;
};
validateProperty = ({ name, value }) => {
const obj = { [name]: value };
const schema = { [name]: this.schema[name] };
const { error } = Joi.validate(obj, schema);
return error ? error.details[0].message : null;
};
handleSubmit = (e) => {
e.preventDefault();
const errors = this.validate();
this.setState({ errors: errors || {} });
if (errors) return;
this.doSubmit();
};
handleChange = ({ currentTarget: input }) => {
const errors = { ...this.state.errors };
const errorMessage = this.validateProperty(input);
if (errorMessage) errors[input.name] = errorMessage;
else delete errors[input.name];

const account = { ...this.state.account };
account[input.name] = input.value;
this.setState({ account, errors });
};
}
export default Form;

login.jsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import React, { Component } from "react";
import Input from "./common/input";
import Joi from "joi-browser";
import Form from "./common/form";
class LoginForm extends Form {
state = {
data: { username: "", password: "" },
errors: {},
};

schema = {
username: Joi.string().required().label("Username"),
password: Joi.string().required().label("Password"),
};

doSubmit = () => {
console.log("Submitted");
};

render(){/*...*/}

Extracting Helper Rendering Methods

接下来我们来接化render
首先把按钮变成一个方法,通过调用渲染

在form.jsx中加上renderButton方法,同时在loginForm.jsx中调用这个方法,因为是直接继承自Form的,所以可以直接调用{this.renderButton(“Login”)}

1
2
3
4
5
6
7
8

renderButton(label) {
return (
<button disabled={this.validate()} className="btn btn-primary">
{label}
</button>
);
}

我们也可以把输入域放到Form组件当中去,在form中加入renderInput方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import Input from "./input";
renderInput(name, label, type = "text") {
const { data, errors } = this.state;

return (
<Input
type={type}
name={name}
value={data[name]}
label={label}
onChange={this.handleChange}
error={errors[name]}
/>
);
}

然后在LoginForm中的render中调用者两个方法

1
2
{this.renderInput("username", "Username")}//这里不传入第三个参数,那么默认类型就是text
{this.renderInput("password", "Password", "password")}//传入password那么密码会被隐藏

当然。input组件也需要修改

因为真正在每个表单中中改变的值只有name,label,error,剩下的我们不想一个一个析构。所以我们直接用…rest来代替,这样我们也不需要再每次添加新的属性进去了,所有的属性都会自动添加

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import React from "react";
const Input = ({ name, label, error, ...rest }) => {
return (
<div className="form-group">
<label htmlFor={name}>{label}</label>
<input
{...rest}
name={name}
id={name}
className="form-control"
/>
{error && <div className="alert alert-danger">{error}</div>}
</div>
);
};

export default Input;

Exercise 1-Register Form

现在我们创建一个注册表单,就会灰常简单

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
import React, { Component } from "react";
import Joi from "joi-browser";
import Form from "./common/form";
class RegisterForm extends Form {
state = {
data: { username: "", password: "", name: "" },
errors: {},
};

schema = {
//username必须是email格式的
username: Joi.string().required().email().label("Username"),
//密码不小于5个字符,不大于10个字符
password: Joi.string().required().min(5).max(10).label("Password"),
name: Joi.string().required().label("Name"),
};
doSubmit = () => {
console.log("Submitted");
};
render() {
return (
<div>
<h1>Login</h1>
<form onSubmit={this.handleSubmit}>
{this.renderInput("username", "Username")}
{this.renderInput("password", "Password", "password")}
{this.renderInput("name", "Name")}
{this.renderButton("Register")}
</form>
</div>
);
}
}

export default RegisterForm;

然后再App.js中注册路由,在navbar中添加,即可

Exercise 2- Movie Form

我们接下来做一个添加电影的表单,

那么我们首先做一个按钮。在Movies.jsx中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//...    
return (
<div className="row">
<div className="col-3">
<ListGroup
items={this.state.genres}
selectedItem={this.state.selectedGenre}
onItemSelect={this.handleGenreSelect}
/>
</div>
<div className="col">
<Link// 这里其实是一个链接,只是伪装成了按钮
to="/movies/new"
className="btn btn-primary"
style={{ marginBottom: 20 }}
>
New Movie
</Link>

然后我们重构MovieForm

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
import React from "react";
import Joi from "joi-browser";
import Form from "./common/form";
import { getMovie, saveMovie } from "../services/fakeMovieService";
import { getGenres } from "../services/fakeGenreService";

class MovieForm extends Form {
/*在movieForm表单下拉类型的时候,我们在内部实现的其实是选择genreId而不是genre名称
data中的四个元素分别对应Title,Genre,Number in Stock,Rate
*/
state = {
data: {
title: "",
genreId: "",
numberInStock: "",
dailyRentalRate: ""
},
genres: [],
errors: {}
};
/*
schema中设定了四个Input组件必须满足的条件。numberInStock 是数字类型的,从0-100
Rate从0-10
*/
schema = {
_id: Joi.string(),
title: Joi.string()
.required()
.label("Title"),
genreId: Joi.string()
.required()
.label("Genre"),
numberInStock: Joi.number()
.required()
.min(0)
.max(100)
.label("Number in Stock"),
dailyRentalRate: Joi.number()
.required()
.min(0)
.max(10)
.label("Daily Rental Rate")
};
/*
现在来看看componentDidMount这个钩子中处理的内容
首先是从假想的服务器中获得genres字段,然后更新state

然后读取路由中的id参数,并赋值给一个movieID 如果检测到这是新电影
也就是我从按钮中点击进入的话。我就直接return掉,因为我们不需要生成一个已经有电影的表单。
反之,我们把数据给渲染上去。那么我们获取电影信息的方法就是 getMovie
export function getMovie(id) {
return movies.find((m) => m._id === id);
}
然后如果找不到的话,那么就返回not-found
找到了,那么就通过setState,和mapToViewModel方法把电影信息填到state当中,完成渲染
*/
componentDidMount() {
const genres = getGenres();
this.setState({ genres });

const movieId = this.props.match.params.id;
if (movieId === "new") return;

const movie = getMovie(movieId);
if (!movie) return this.props.history.replace("/not-found");

this.setState({ data: this.mapToViewModel(movie) });
}
//这就是上面所说的mapToViewModel了,也就是返回一个state.data类型的对象,然后实现覆盖
mapToViewModel(movie) {
return {
_id: movie._id,
title: movie.title,
genreId: movie.genre._id,
numberInStock: movie.numberInStock,
dailyRentalRate: movie.dailyRentalRate
};
}
// 然后处理doSubmit方法,如果提交成功的话,那就saveMovie,也就是在fakemovieSeries中的
/*
如果movieInDb存在,那么就是在原来的基础上保存修改过后的内容,如果不存在,就是一个空对象
然后就把新建或者要更新的movie的信息都填到这个对象当中去
export function saveMovie(movie) {
let movieInDb = movies.find((m) => m._id === movie._id) || {};
movieInDb.name = movie.name;
movieInDb.genre = genresAPI.genres.find((g) => g._id === movie.genreId);
movieInDb.numberInStock = movie.numberInStock;
movieInDb.dailyRentalRate = movie.dailyRentalRate;

这里要注意要把Date.now()转换成string类型的,否则类型不同,无法保存
通过push来吧这个电影信息加到Db当中去
if (!movieInDb._id) {
movieInDb._id = Date.now().toString();
movies.push(movieInDb);
}

return movieInDb;
}
*/
doSubmit = () => {
saveMovie(this.state.data);

this.props.history.push("/movies");
};
//随后渲染这个表单。
render() {
return (
<div>
<h1>Movie Form</h1>
<form onSubmit={this.handleSubmit}>
{this.renderInput("title", "Title")}
{this.renderSelect("genreId", "Genre", this.state.genres)}
{this.renderInput("numberInStock", "Number in Stock", "number")}
{this.renderInput("dailyRentalRate", "Rate")}
{this.renderButton("Save")}
</form>
</div>
);
}
}

export default MovieForm;

Exercise 3- Search Movies 加入搜索功能

首先建一个SearchBox

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import React from "react";

const SearchBox = ({ value, onChange }) => {
return (
<input
type="text"
name="query"
className="form-control my-3"
placeholder="Search..."
value={value}
//当我在表单输入的时候,我们调用onChange,然后从输入框中获取value值赋给handleSearch
onChange={e => onChange(e.currentTarget.value)}
/>
);
};

export default SearchBox;

然后在Movies中添加这个组件和handleSearch方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//... 
state = {
//我们虽然可以不在这个state中声明赋值,因为直接state.属性也是可以访问的(只是没value)
//但是呢我们一般还是要声明的,这样对自己和别人看代码更有帮助
movies: [],
genres: [],
currentPage: 1,
pageSize: 4,
searchQuery: "",
selectedGenre: null,
sortColumn: { path: "title", order: "asc" },
};
//...
/*这里拿到的是一个字符串,因为我们把Input放到了组件当中,当搜索框发生改变的时候,onChange
调用了handleSearch,然后handleSearch 实现了对state的更新,注意,这里要把currentPage
重置为1,因为我们当前可能不在第一页,那就看不到第一页的内容了!
*/
handleSearch = (query) => {
this.setState({ searchQuery: query, selectedGenre: null, currentPage: 1 });
//...

<SearchBox value={searchQuery} onChange={this.handleSearch} />

此外我们还需要修改movies.jsx种getPageData方法 对原来的filter函数进行了修改

1
2
3
4
5
6
7
8
9
10
let filtered = allMovies;
//如果searchQuery非空,那么我们就要选择电影名称以searchQuery开头的电影(这里没实现模糊查询)
if (searchQuery)
filtered = allMovies.filter((m) =>
m.title.toLowerCase().startsWith(searchQuery.toLowerCase())
);
//否则就像之前一样,选择电影类型和selectedGenre一样的电影
else if (selectedGenre && selectedGenre._id)
filtered = allMovies.filter((m) => m.genre._id === selectedGenre._id);
//如果都不满足,就说明现在是All Genres,且没有查询内容,那么不变 filtered = allMovies;

Calling Backend Services

这一章开始我们来讲讲前后端链接

JSON Placeholder

http://jsonplaceholder.typicode.com/posts

Extension:JSONView

jsonplaceholder 给我们提供了一个API,这个API提供了诸如帖子等功能的一些终端

我们可以通过它来实现CRUD(增删改查)

Http Clients

几种发送Http请求的方式

这里先下载Axios

npm install axios@0.18

利用源码中的 https://www.filepicker.io/api/file/ZfFMGUER9OAWZWe4kkWk

http-app ,npm install ,npm start 打开

Getting Data

利用axios获取数据,在app.js中导入后,添加cdm,然后在里面请求刚才的页面

这里的promise是一个装载异步操作的对象,promise对象承诺保存异步操作的结果

当我声明promise的时候,它历即进入pending状态 pending>resolved(success) OR rejected(failure)

1
2
3
4
componentDidMount() {
const promise= axios.get('http://jsonplaceholder.typicode.com/posts');
console.log(promise);
}

这些就是我们请求来的数据,但是我们真正想要的是Object中的部分,也就是config,data…..

我们只想获取具体的数据,所以我们做这样一步:利用ES6中的新型特性await,await和async总时成对出现,所以在这个Hook 前我们还要用async修饰

1
2
3
4
5
async componentDidMount() {
const promise = axios.get("http://jsonplaceholder.typicode.com/posts");
const response = await promise;
console.log(response);
}

我们还有更简洁的写法

1
2
3
4
async componentDidMount() {
const response = await axios.get("http://jsonplaceholder.typicode.com/posts");
console.log(response);
}

进一步我们只想获取data,那么我们就用析构就好了

1
2
3
4
async componentDidMount() {
const {data:posts} = await axios.get("http://jsonplaceholder.typicode.com/posts");
this.setState({posts});
}

更新完成后,因为框架老师已经搭好了,所以我们就直接可以看到这些帖子了

Creating Data

如上图我们点击了Add以后,会得到一个表单,点击提交以后在现实情况下会形成一个对象,发送给后端,后端得到以后存储至数据库。现在我们来模拟一下

上面我用了get来请求数据,这里我用post来创建一条数据

创建有两方面,前端创建(表格上显示)和后端创建(数据库中存放)

1
2
3
4
5
6
7
8
9
10
11
12
//值得注意的是,这里是一个方法属性,所以async要卸载handleAdd后面  
handleAdd = async () => {
//假设我们想把 title为a,body为b的放到这个表格当中,那么我们就新建一个对象
const obj = { title: "a", body: "b" };
//然后利用post把这个对象给post到这个假的服务器上去,返回值我们解构出data并重命名post
const { data: post } = await axios.post(apiEndpoint, obj);
//打印
console.log(post);
//实现本地state的更新 把post放在其他的数据之前
const posts = [post, ...this.state.posts];
this.setState({ posts });
};

但是我们发现有id相同的信息,这是因为我们这不是一个真的服务器。

Lifecycle of a Request

现在看看一个request的生命周期

先来看看第一张图,Request Method 是OPTIONS, 每个HTTP请求都有个叫做“Method”的属性来描述这个请求的目的(增删改查),通常的方式是get得到,post创建,put更新,delete删除

这里是个options请求,产生的原因是这个应用在本地运行,但是他的后端却在别的地方jsonplaceholder

这是两个独立的域,从安全角度考虑,当一个应用向另一个域发起HTTP请求的时候,浏览器总会发出一个options请求,这就是为何这有OPTIONS了

再来看看第二张,这里请求的地址一样,但实际Method确实Post。Post用于创建数据。状态码为201代表了这个已经成功创建了。

现在来看反馈标签,这里你可以看到网站服务器给我们的反馈。适合我们发给他的一样的。Axios将这个对象保存到了一个response对象当中。这个response是对象有一个data属性(我们解构出来的)。 这个response对象有其他属性,我们可以用来读取请求的状态以及请求头等等,这就是一个请求的生命周期

Updating Data

我们看到如果要更新一条信息的话,必须在请求的时候在posts后面加上当前信息的id才可以。

所以我们用put方式实现更新,先来看看我们能不能正确的请求信息。

(更新有两种方法,一种是put,一种是patch,patch可以针对对象的特定属性实现更新,而put是对整个对象实现更新)这里用了put,所以在传入第二个参数的时候我把整个post全传进去了

1
2
3
4
5
handleUpdate = async (post) => {
post.title = "UPDATED";
const { data } = axios.put(apiEndpoint + "/" + post.id, post);
console.log(data);
};

我们看到点击以后title就改成了UPDATED

为了更新一个对象(资源),我们向后端发送了HTTP请求,这个后端有给定的id的资源,所以服务器得到了id,然后在response中返回了这个对象(资源)

好,现在我们实现更新。更新有两方面,前端更新(表格上更新)和后端更新(数据库更新)

1
2
3
4
5
6
7
8
9
10
11
12
handleUpdate = async (post) => {
post.title = "UPDATED";
await axios.put(apiEndpoint + "/" + post.id, post);//到这里实现了后端更新,但前端未变
//现在把整个state.posts拷贝下来
const posts = [...this.state.posts];
//现在得到要更新的那个对象的index
const index = posts.indexOf(post);
//现在用更新完毕的post去覆盖原来的post
posts[index] = { ...post };
//实现更新
this.setState({ posts });
};

Deleting Data

同样的,删除数据也要从后端和前端同时删除

1
2
3
4
5
handleDelete = async (post) => {
axios.delete(apiEndpoint + "/" + post.id);
const posts = this.state.posts.filter((p) => p.id !== post.id);
this.setState({ posts });
};

就不用我多说了

Optimistic vs Pessimistic Updates

但是我们在操作的时候,常常会有半秒到一秒钟的延迟。这是因为我们总是先请求服务器。然后再更新视图。使用这种方法实现的话,当请求服务器后端出现错误的时候,余下的代码将不会被执行。这就是我们说的Pessimistic Updates,因为我们不确定请求服务器后是否成功

Optimistic Updates:我们假设服务器大部分情况都是请求成功的,所以不妨先更新界面,再请求服务器。如果请求失败,我们再回滚至用户之前的界面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
handleDelete = async (post) => {
//首先我们先把原来的state保存下来
const originalPosts = this.state.posts;
//实现界面的更新
const posts = this.state.posts.filter((p) => p.id !== post.id);
this.setState({ posts });
//然后请求服务器,如果发生错误(这里我为了测试手动抛出一个错误)
try {
axios.delete(apiEndpoint + "/" + post.id);
throw new Error('')
} catch (ex) {//接收到错误后,再浏览器发出alert
//然后回滚,就是把之前保存着的”备份“更新回去
alert("Something failed while deleting a post!");
this.setState({ posts: originalPosts });
}
};

Expected vs Unexpected Errors

我们怎么判断这里获得的异常是一个可预见的错误呢

这里有两个异常对象的属性:

ex.request:这个属性在成功发送请求给服务器时被设置

ex.resposne:这个属性在服务器正常反馈的时候被设置为null

所以这个逻辑可以这么处理,当ex.response不为空(错误存在)且状态码为404的时候,我们就收到了一个Expected Errors,否则我们收到的就是Unexpected Errors

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
handleDelete = async (post) => {
const originalPosts = this.state.posts;
const posts = this.state.posts.filter((p) => p.id !== post.id);
this.setState({ posts });
try {
axios.delete(apiEndpoint + "/" + post.id);
} catch (ex) {
if (ex.response && ex.response.status === 404)
alert("This post has already been deleted!");
else {
console.log('Logging the error',ex);
alert("An unexcepted error occurred!");
}
this.setState({ posts: originalPosts });
}
};

Handling Unexpected Errors Globally

我们现在只是在handleDelete中做到了检查异常。但是我们需要在发帖,更新贴,读取贴的时候都实现。所以如果有个不可预期的错误,那么就需要记录日志,打印错误信息。但是我们不想再每个地方都这样重复相同的代码

这时候应该使用 Axios Interceptors。 我们在类外写这个方法。

axios.interceptors后可以有response也可以有request,传入的参数是两个函数。第一个函数是成功的时候调用的,第二个函数是失败的时候调用的。这里我们只写当发生错误的时候该怎么办,所以把第一个参数设置为null

为了让catch捕获异常,我们需要返回一个Promise.reject 这是一个对象,我们就把axios发现的错误放到这个对象当中。这样我们无论何时得到了一个错误,我们就会调用axios.interceptors,然后控制权交给catch处理

现在,我们让axios.interceptors处理unexpectedError,那么我先过滤掉4开头的状态码,因为那些都是expectedError,如果还有错误,那就是unexpectedError,我们就记录日志,并且给一个alert。不管发现的是expected还是unexpected error,都需要返回一个Promise.reject

1
2
3
4
5
6
7
8
9
10
11
axios.interceptors.response.use(null, (error) => {
const expectedError =
error.response &&
error.response.status >= 400 &&
error.response.status <= 500;
if (!expectedError) {
console.log("Logging the error", error);
alert("An unexcepted error occurred!");
}
return Promise.reject(error);
});

如果把id改成999,那么这就输入404的范畴,会显示 This post has already been deleted

如果我们请求一个非法的网址,那么我们就会得到一个 unexpected error,那么就像这样

这样,只有在我需要针对特定错误做什么的时候,我们才在下面的catch中写上错误的类型以及弹出的内容。其他的错误就交给axios.interceptors去解决

Extracting a Reusable Http Service

使用axios.interceptors我们成功处理了不可预期的错误。但这不理想,他污染了App module,所以这些代码把App.js 复杂化了。另外一点是当某天我想要创建另外一个React应用的时候。我需要回来复制这段axios.interceptors到新的应用。我们可以把Http Service变成一个可以重复利用的module

我们新建一个service 文件夹,新建一个 httpService.js,然后把这些处理错误的代码放到httpService当中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import axios from "axios";
axios.interceptors.response.use(null, (error) => {
const expectedError =
error.response &&
error.response.status >= 400 &&
error.response.status <= 500;
if (!expectedError) {
console.log("Logging the error", error);
alert("An unexcepted error occurred!");
}
return Promise.reject(error);
});

export default{
get:axios.get,
post:axios.post,
put:axios.put,
delete:axios.delete,
}

在导入httpService后把App.js中所有的axios都换成http

1
import http from "./services/httpService";

这样,这个模块就不再使用axios模块了,我们把Axios服务隐藏在http模块了,之后我如果想换一个库替代axios,我们需只要改一个地方。也就是我的httpService.js 。只要这个模块能导出get,post,put,delete方法,应用中的其余部分都不会受到影响。App.js中我们并不关心使用了什么库来发送HTTP请求,这是封装一个http模块最主要的好处之一了。新建项目的话也会方便很多

Extracting a Config Module

然后在App.js中引入的时候,把所有的apiEndpoint 都改成config

这样可以避免 hard coding

Displaying Toast Notifications

我们把原来的alert弹窗改成这样的,会比原来更加美观

npm i react-toastify@4.1

然后导入

1
2
import { ToastContainer } from "react-toastify";
import "react-toastify/dist/ReactToastify.css";

在把httpService中的alert改成 toast.error(“An unexcepted error occurred!”);

toast还有其他的如success,warn等弹窗

当然也可以直接改成 toast (“An unexcepted error occurred!”); 颜色更好看

Logging Errors

我们要把日志作为系统功能的一部分。这里使用Sentry.io

//..

Extracting a Logger Service

//…

Vidly Backend

Setting Up the Node Backend

https://www.bilibili.com/video/BV1Sb411P79t?p=153

有个坑。。需要先手动安装这个bcrypt否则一大堆错误

npm install bcrypt —unsafe-perm

这就算成功了。

这个后端支持CRUD

如果要修改数据,需要在config.json中修改设置

Disabling Authentication

1
2
3
4
5
6
{
"jwtPrivateKey": "unsecureKey",
"db": "mongodb://localhost/vidly",
"port": "3900",
"requiresAuth": false //修改成false,这样不需要授权认证即可修改
}

Exercise- Connect Movies Page to the Backend

把连接内存改为链接后端

Adding Http and Log Services

在service文件夹中导入 httpservice

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import axios from "axios";
import logger from "./logService";
import { toast } from "react-toastify";

axios.interceptors.response.use(null, error => {
const expectedError =
error.response &&
error.response.status >= 400 &&
error.response.status < 500;

if (!expectedError) {
logger.log(error);
toast.error("An unexpected error occurrred.");
}

return Promise.reject(error);
});

export default {
get: axios.get,
post: axios.post,
put: axios.put,
delete: axios.delete
};

logservice

1
2
3
4
5
6
7
8
9
10
function init() {}

function log(error) {
console.error(error);
}

export default {
init,
log
};

app中导入

1
2
import { ToastContainer } from "react-toastify";
import "react-toastify/dist/ReactToastify.css";

添加

Replacing FakeGenreService

在service文件夹中新建genreService.js

1
2
3
4
 apiUrl="http://localhost:3900/api";
export function getGenres() {
return http.get(apiUrl + "/genres");
}

在movies中修改

1
2
3
4
5
6
7
8
9
//...
import { getGenres } from "../services/genreService";
//...
async componentDidMount() {
const { data } = await getGenres();
const genres = [{ _id: "", name: "All Genres" }, ...data];
const { data: movies } = await getMovies();
this.setState({ movies, genres });
}

Replacing FakeMovieService

movieService.js

1
2
3
4
5
6
7
8
9
10
import http from "./httpService";
apiUrl="http://localhost:3900/api";
const apiEndpoint = apiUrl + "/movies";
export function getMovies() {
return http.get(apiEndpoint);
}

export function deleteMovie(movieId) {
return http.delete(apiEndpoint+"/"+movieId);
}

在movies中给对应的函数加上async 和await

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
  async componentDidMount() {
const { data } = await getGenres();
const genres = [{ _id: "", name: "All Genres" }, ...data];

const { data: movies } = await getMovies();
this.setState({ movies, genres });
}
//...
//我们这里实现optmistic 利用try-catch
handleDelete = async movie => {
const originalMovies = this.state.movies;
const movies = this.state.movies.filter(m=>m._id!=movie._id);
this.setState ({movies});
try{
await deleteMovie(movie._id);
}catch(ex){
if(ex.response&&ex.response.status===404)
toast.error('This movie has already been deleted');
this.setState({ movies: originalMovies });
}

};

现在的表格就是以数据库数据构成的。现在删除的话,就是真的在数据库中删除了

Extracting a Config File 封装配置文件

1
2
3
{
"apiUrl": "http://localhost:3900/api"
}

然后在logService和httpService中进行修改

1
2
3
4
5
6
import http from "./httpService";
import { apiUrl } from "../config.json";

export function getGenres() {
return http.get(apiUrl + "/genres");
}
1
2
3
4
5
import http from "./httpService";
import { apiUrl } from "../config.json";

const apiEndpoint = apiUrl + "/movies";
//...

Exercise- Connect Movie Form to the Backend

现在我们如果点击电影,会发现

这是因为movieForm任然使用了fakeMovieService

我们现在来替换他

movieService.js

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
import http from "./httpService";
import { apiUrl } from "../config.json";

const apiEndpoint = apiUrl + "/movies";
/*
为了不到处使用字符串拼接,我们把代码重构一下
这里使用Es6中的模板语法,${}中的参数会实时计算
= return apiEndpoint+'/'+id;
然后把接下来的都替换掉
*/
function movieUrl(id) {
return `${apiEndpoint}/${id}`;
}

export function getMovies() {
return http.get(apiEndpoint);
}
/*
import { getMovie, saveMovie } from "../services/movieService";
因为movieServise没有这两个函数,所以我们写一下这两个函数
*/

export function getMovie(movieId) {
return http.get(movieUrl(movieId));
}
/*
这个save函数可以保存新建的电影信息,也可以更新已经有的电影信息。
如果movie._id存在的,那么我们就要用put(第一个参数是url,第二个参数是请求对象)
但是我们做的后端不喜欢请求体中有id。因为如果body中有id的话,那么前面也是id,后面也有id,
会产生混淆。
为了解决这个问题,我们就克隆这个movie的信息,然后删除这个id属性,在把这两个参数传入put

如果movie._id不存在,说明我们是新建电影信息,这样我们就用point
*/
export function saveMovie(movie) {
if (movie._id) {
const body = { ...movie };
delete body._id;
return http.put(movieUrl(movie._id), body);
}

return http.post(apiEndpoint, movie);
}

export function deleteMovie(movieId) {
return http.delete(movieUrl(movieId));
}

movieForm.jsx

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
import React from "react";
import Joi from "joi-browser";
import Form from "./common/form";
import { getMovie, saveMovie } from "../services/movieService";
import { getGenres } from "../services/genreService";

class MovieForm extends Form {
state = {
data: {
title: "",
genreId: "",
numberInStock: "",
dailyRentalRate: ""
},
genres: [],
errors: {}
};

schema = {
_id: Joi.string(),
title: Joi.string()
.required()
.label("Title"),
genreId: Joi.string()
.required()
.label("Genre"),
numberInStock: Joi.number()
.required()
.min(0)
.max(100)
.label("Number in Stock"),
dailyRentalRate: Joi.number()
.required()
.min(0)
.max(10)
.label("Daily Rental Rate")
};

async populateGenres() {
const { data: genres } = await getGenres();
this.setState({ genres });
}
/*
try catch 的作用就是当输入未存储的电影id的时候,我们就需要导向not-found,之前是通过
if(!movie) return this.props.history.replace("/not-found")实现的,但现在需要catch到异常才执行这一步;
try放在第一行,更加美观,反正不影响
*/
async populateMovie() {
try {
const movieId = this.props.match.params.id;
if (movieId === "new") return;

const { data: movie } = await getMovie(movieId);
this.setState({ data: this.mapToViewModel(movie) });
} catch (ex) {
if (ex.response && ex.response.status === 404)
this.props.history.replace("/not-found");
}
}
//这里代码重构一下,更具有可读性,否则堆起来很不好看。在render 这个hook结束之后,
//调用commponentDidMount 这个hook,然后他有会调用两个函数,把genre和movies都渲染上去
//componentDidMount 就不会被渲染
async componentDidMount() {
await this.populateGenres();
await this.populateMovie();
}

mapToViewModel(movie) {
return {
_id: movie._id,
title: movie.title,
genreId: movie.genre._id,
numberInStock: movie.numberInStock,
dailyRentalRate: movie.dailyRentalRate
};
}
/*
这里用到了saveMovie所以要用 async和await
*/
doSubmit = async () => {
await saveMovie(this.state.data);

this.props.history.push("/movies");
};

render() {
return (
<div>
<h1>Movie Form</h1>
<form onSubmit={this.handleSubmit}>
{this.renderInput("title", "Title")}
{this.renderSelect("genreId", "Genre", this.state.genres)}
{this.renderInput("numberInStock", "Number in Stock", "number")}
{this.renderInput("dailyRentalRate", "Rate")}
{this.renderButton("Save")}
</form>
</div>
);
}
}

export default MovieForm;

现在,没有地方用到fakeservice了!

-------------本文结束,感谢您的阅读-------------