项目实战
本节将使用 React Router 继续完善 BBS 项目。为了避免由于项目过于复杂而不便讲解和理解,因此这里只抽象出 BBS 项目中最核心的三个页面:
-
登录页,负责应用的登录功能。
-
帖子列表页,以列表形式展示所有帖子的基本信息。
-
帖子详情页,展示某个帖子的详细内容。
具备这三个页面,BBS 的核心功能也就基本完整了。本节项目源代码的目录为 /chapter-07/bbs-router。
后台服务API介绍
真实 BBS 项目的数据肯定要保存到后台的数据库中,同时需要依赖后台服务提供的 API 实现对应用所需数据的增、删、改、查操作。本书不涉及后台开发的内容,为了更接近真实的项目开发场景,使用 APICloud 的数据云功能快速地生成了所需的 API。API 的生成方式不做介绍,这里主要介绍 API 的使用。
按照业务功能划分,API 可以分为三类:登录、帖子和评论。
登录:/user/login
执行用户登录验证。注销不调用后台的 API,只在客户端清除登录信息。注意,这是一个简化的登录和注销流程,并不适用于真实项目。
因为示例项目不涉及注册功能,我们预先在后台创建好三个账号,用户名分别是:tom、jack、steve,密码均是:123456。读者可使用这三个账号登录应用。 |
帖子:/post
与帖子相关的操作都通过这个 API 完成,包括获取帖子列表数据、获取某个帖子的详情、新增帖子、修改帖子等。APICloud 生成的 API 是 RESTful API,因此 /post 实际上相当于多个 API,例如以 HTTP Get 方法调用接口用于获取帖子数据,以 HTTP Post 方法调用接口用于新增帖子,以 Http Put 方法调用接口用于修改帖子。这属于 RESTful API 规范,这里不再展开。
评论:/comment
与评论相关的操作都通过这个 API 完成,包括获取某一个帖子的评论列表和新增一条评论。
为方便调用,API 相关信息已被封装到 utils/url.js 中,代码如下:
// 获取帖子列表的过滤条件
const postListFilter = {
fields: ["id", "title", "author", "vote", "updatedAt"],
limit: 10,
order: "updatedAt DESC",
include: "authorPointer",
includefilter: { user: { fields: ["id", "username"] } }
};
// 获取帖子详情的过滤条件
const postByIdFilter = id => ({
fields: ["id", "title", "author", "vote", "updatedAt", "content"],
where: { id: id },
include: "authorPointer",
includefilter: { user: { fields: ["id", "username"] } }
});
// 获取评论列表的过滤条件
const commentListFilter = postId => ({
fields: ["id", "author", "updatedAt", "content"],
where: { post: postId },
limit: 20,
order: "updatedAt DESC",
include: "authorPointer",
includefilter: { user: { fields: ["id", "username"] } }
});
function encodeFilter(filter) {
return encodeURIComponent(JSON.stringify(filter));
}
export default {
// 登录
login: () => "/user/login",
// 获取帖子列表
getPostList: () => `/post?filter=${encodeFilter(postListFilter)}`,
// 获取帖子详情
getPostById: id => `/post?filter=${encodeFilter(postByIdFilter(id))}`,
// 新建帖子
createPost: () => "/post",
// 修改帖子
updatePost: id => `/post/${id}`,
// 获取评论列表
getCommentList: postId =>
`/comment?filter=${encodeFilter(commentListFilter(postId))}`,
// 新建评论
createComment: () => "/comment"
};
读者重点关注最后 export 的对象,这个对象包含项目中需要使用的所有 API 信息。至于前面定义的变量和函数,是根据 APICloud 的规范提供 API 调用时所需的过滤条件,可不必深究。
我们使用 HTML 5 fetch 接口调用 API,在 utils/request.js 中对 fetch 进行了封装,定义了 get、post、put 三个方法,分别满足以不同 HTTP 方法(Get、Post、Put)调用 API 的场景。主要代码如下:
import {SHA1} from "./SHA1";
const AppId = "A6053788184630";
const AppKey = "8B3F5860-2646-2C47-DC50-39106919B260";
var now = Date.now();
const secureAppKey = SHA1(AppId+"UZ"+AppKey+"UZ"+now)+"."+now;
const headers = new Headers({
"X-APICloud-AppId": AppId,
"X-APICloud-AppKey": secureAppKey,
"Accept": "application/json",
"Content-Type": "application/json"
});
function get(url) {
return fetch(url, {
method: "GET",
headers: headers,
}).then(response => {
return handleResponse(url, response);
}).catch(err => {
console.error(`Request failed. Url = ${url} . Message = ${err}`);
return {error: {message: "Request failed."}};
})
}
function post(url, data) {
return fetch(url, {
method: "POST",
headers: headers,
body: JSON.stringify(data)
}).then(response => {
return handleResponse(url, response);
}).catch(err => {
console.error(`Request failed. Url = ${url} . Message = ${err}`);
return {error: {message: "Request failed."}};
})
}
function put(url, data) {
return fetch(url, {
method: "PUT",
headers: headers,
body: JSON.stringify(data)
}).then(response => {
return handleResponse(url, response);
}).catch(err => {
console.error(`Request failed. Url = ${url} . Message = ${err}`);
return {error: {message: "Request failed."}};
})
}
function handleResponse(url, response) {
if(response.status < 500){
return response.json();
}else{
console.error(`Request failed. Url = ${url} . Message = ${response.statusText}`);
return {error: {message: "Request failed due to server error "}};
}
}
export {get, post, put}
因为 APICloud 提供的 API 和本地程序运行在不同域下,所以本地程序直接调用 APICloud 的 API 会存在跨域调用的问题。我们利用代理服务器解决这个问题。在 create-react-app 中使用代理很简单,只需要在项目的 package.json 中配置 proxy 属性,proxy 的值是请求要转发到的最终地址。APICloud 提供的 API 运行在 https://d.apicloud.com/mcm/api 下,因此配置如下:
"proxy": "https://d.apicloud.com/mcm/api"
但需要注意,使用这种方式配置代理只在开发环境模式下有效,即 npm start 启动程序时,代理有效。
路由设计
路由设计的过程可以分为两步:
-
为每一个页面定义有语义的路由名称(path)。
-
组织 Route 结构层次。
定义路由名称
我们有三个页面,按照页面功能不难定义出如下的路由名称:
-
登录页:/login。
-
帖子列表页:/posts。
-
帖子详情页:/posts/:id(id代表帖子的ID)。
但是这些还不够,还需要考虑打开应用时的默认页面,也就是根路径 "/" 对应的页面。结合业务场景,帖子列表页作为应用的默认页面最为合适,因此,帖子列表页对应两个路由名称:"/posts" 和 "/"。
组织Route结构层次
React Router 4 并不需要在一个地方集中声明应用需要的所有 Route,Route 实际上也是一个普通的 React 组件,可以在任意地方使用它(前提是,Route 必须是 Router 的子节点)。当然,这样的灵活性也一定程度上增加了组织 Route 结构层次的难度。
我们先来考虑第一层级的路由。登录页和帖子列表页(首页)应该属于第一层级:
<Router>
<Switch>
<Route exact path="/" component={Home} />
<Route path="/login" component={Login} />
<Route path="/posts" component={Home} />
</Switch>
</Router>
第一个 Route 使用了 exact 属性,保证只有当访问根路径时,第一个 Route 才会匹配成功。Home 是首页对应的组件,可以通过 "/posts" 和 "/" 两个路径访问首页。注意,这里并没有直接渲染帖子列表组件,真正渲染帖子列表组件的地方在 Home 组件内,通过第二层级的路由处理帖子列表组件和帖子详情组件的渲染,components/Home.js 的主要代码如下:
class Home extends Component {
/** 省略其余代码 **/
render() {
const { match, location } = this.props;
const { userId, username } = this.state;
return (
<div>
<Header
username={username}
onLogout={this.handleLogout}
location={location}
/>
{/* 帖子列表路由配置 */}
<Route
path={match.url}
exact
render={props => <PostList userId={userId} {...props} />}
/>
{/* 帖子详情路由配置 */}
<Route
path={`${match.url}/:id`}
render={props => <Post userId={userId} {...props} />}
/>
</div>
);
}
}
Home 的 render 内定义了两个 Route,分别用于渲染帖子列表和帖子详情。PostList 是帖子列表组件,Post 是帖子详情组件,代码使用 Route 的 render 属性渲染这两个组件,因为它们需要接收额外的 username 属性。另外,无论访问的是帖子列表页面(首页)还是帖子详情页面,都会共用相同的 Header 组件。
登录页
登录页的界面很简单,只需要提供一个 form 表单供用户输入登录信息即可。因此,components/Login.js 中的 render 方法如下:
render() {
// from 保存跳转到登录页前的页面路径,用于在登录成功后重定向到原来页面
const { from } = this.props.location.state || { from: { pathname: "/" } };
const { redirectToReferrer } = this.state;
// 登录成功后,redirectToReferrer为true,使用Redirect组件重定向页面
if (redirectToReferrer) {
return <Redirect to={from} />;
}
return (
<form className="login" onSubmit={this.handleSubmit}>
<div>
<label>
用户名:
<input
name="username"
type="text"
value={this.state.username}
onChange={this.handleChange}
/>
</label>
</div>
<div>
<label>
密码:
<input
name="password"
type="password"
value={this.state.password}
onChange={this.handleChange}
/>
</label>
</div>
<input type="submit" value="登录" />
</form>
);
}
当用户点击 “登录” 按钮时,会调用后台的登录 API 进行验证,登录成功后,将用户信息存储到 sessionStorage 中,其他页面根据 sessionStorage 中是否有用户信息判断应用是否处于登录状态。登录逻辑代码如下:
// 提交登录表单
handleSubmit(e) {
e.preventDefault();
const username = this.state.username;
const password = this.state.password;
if (username.length === 0 || password.length === 0) {
alert("用户名或密码不能为空!");
return;
}
const params = {
username,
password
};
post(url.login(), params).then(data => {
if (data.error) {
alert(data.error.message || "login failed");
} else {
// 保存登录信息到sessionStorage
sessionStorage.setItem("userId", data.userId);
sessionStorage.setItem("username", username);
// 登录成功后,设置redirectToReferrer为true
this.setState({
redirectToReferrer: true
});
}
});
}
登录成功后,Login 组件会修改 state 中的 redirectToReferrer 为 true,当再次 render 时,会渲染 React Router 的 Redirect 组件,这个组件用于页面的重定向,将页面重定向到登录前的页面或首页(没有上一个页面的情况下)。完整的 components/Login.js 的代码如下:
import React, { Component } from "react";
import { Redirect } from "react-router-dom";
import { post } from "../utils/request";
import url from "../utils/url";
import "./Login.css";
class Login extends Component {
constructor(props) {
super(props);
this.state = {
username: "jack",
password: "123456",
redirectToReferrer: false // 是否重定向到之前的页面
};
this.handleChange = this.handleChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
}
// 处理用户名、密码的变化
handleChange(e) {
if (e.target.name === "username") {
this.setState({
username: e.target.value
});
} else if (e.target.name === "password") {
this.setState({
password: e.target.value
});
} else {
// do nothing
}
}
// 提交登录表单
handleSubmit(e) {
e.preventDefault();
const username = this.state.username;
const password = this.state.password;
if (username.length === 0 || password.length === 0) {
alert("用户名或密码不能为空!");
return;
}
const params = {
username,
password
};
post(url.login(), params).then(data => {
if (data.error) {
alert(data.error.message || "login failed");
} else {
// 保存登录信息到sessionStorage
sessionStorage.setItem("userId", data.userId);
sessionStorage.setItem("username", username);
// 登录成功后,设置redirectToReferrer为true
this.setState({
redirectToReferrer: true
});
}
});
}
render() {
// from 保存跳转到登录页前的页面路径,用于在登录成功后重定向到原来页面
const { from } = this.props.location.state || { from: { pathname: "/" } };
const { redirectToReferrer } = this.state;
// 登录成功后,redirectToReferrer为true,使用Redirect组件重定向页面
if (redirectToReferrer) {
return <Redirect to={from} />;
}
return (
<form className="login" onSubmit={this.handleSubmit}>
<div>
<label>
用户名:
<input
name="username"
type="text"
value={this.state.username}
onChange={this.handleChange}
/>
</label>
</div>
<div>
<label>
密码:
<input
name="password"
type="password"
value={this.state.password}
onChange={this.handleChange}
/>
</label>
</div>
<input type="submit" value="登录" />
</form>
);
}
}
export default Login;
帖子列表页
帖子列表页也是项目的首页。我们先划分出页面中的主要组件,如图7-1所示。

页面划分为 Header 和 PostList 两大组件,PostList 中又包含 PostEditor 和 PostsView 组件,PostsView 中包含 PostItem 组件。这只是其中一种组件划分方式,组件划分的方式并不唯一,还可以使用其他划分方式,例如把 PostEditor 移到 PostList 外,让它和 PostList 处于同一层级。如果帖子列表的每一项样式和逻辑都很简单,就不需要单独拆分出 PostItem 组件。总之,划分页面组件需要根据页面的结构、组件的复用性、组件的复杂度等因素综合考虑。
划分组件容易走两个极端,组件粒度过大和组件粒度过小。若组件粒度过大,则组件逻辑过于复杂,组件的可维护性和复用性都变差;若组件粒度过小,则项目中的组件数量激增,一个简单功能往往需要引入大量组件,增加开发成本,组件数量过多也不便于查找。一种观点是,一个组件只负责一个功能。对于这种观点,建议大家辩证地看待,如果几个功能都很简单,且每一个功能都没有复用的需求,那么将这几个功能放到一个组件中也未尝不可,这样做可以提高开发效率。事实上,本书中有些组件也是包含多个简单功能的。 |
下面我们就来逐一分析这几个组件。
Header
Header 组件定义了页面的顶部导航栏,其中使用了两个 Link 组件,分别导航到首页和登录页。代码如下:
import React, { Component } from "react";
import { Link } from "react-router-dom";
import "./Header.css";
class Header extends Component {
render() {
const { username, onLogout, location } = this.props;
return (
<div className="header">
<div className="nav">
<span className="left-link">
<Link to="/">首页</Link>
</span>
{/* 用户已登录,显示登录用户的信息;否则显示登录按钮 */}
{username && username.length > 0 ? (
<span className="user">
当前用户:{username} <button onClick={onLogout}>注销</button>
</span>
) : (
<span className="right-link">
{/* 通过 state 属性,保存当前页面的地址 */}
<Link to={{ pathname: "/login", state: { from: location } }}>
登录
</Link>
</span>
)}
</div>
</div>
);
}
}
export default Header;
导航到登录页 Link 的 to 属性的值不是一个字符串,而是一个对象: { pathname:"/login", state:{ from: location } }
。对象中的 location 是当前页面的位置,这样在 Login 组件执行完登录逻辑后,可以从 this.props.location.state 中获取上一个页面的 location,然后重定向到上一个页面。
PostList
PostList 是这个页面中最复杂的组件,它负责获取帖子列表数据、保存新建的帖子以及控制 PostEditor 的显示与隐藏。PostList 的 state 包含两个属性,即 posts 和 newPost,分别用于保存帖子列表数据和判断当前是否正在创建新的帖子。
当 PostList 组件挂载后,调用后台 API 获取列表数据,代码如下:
componentDidMount() {
this.refreshPostList();
}
// 获取帖子列表
refreshPostList() {
// 调用后台API获取列表数据,并将返回的数据设置到state中
get(url.getPostList()).then(data => {
if (!data.error) {
this.setState({
posts: data,
newPost: false
});
}
});
}
PostList 的 render 方法如下:
render() {
const {userId} = this.props;
return (
<div className="postList">
<div>
<h2>帖子列表</h2>
{/* 只有在登录状态,才显示发帖按钮 */}
{userId ? <button onClick={this.handleNewPost}>发帖</button> : null}
</div>
{/* 若当前正在创建新帖子,则渲染PostEditor组件 */}
{this.state.newPost ? (
<PostEditor onSave={this.handleSave} onCancel={this.handleCancel}/>
) : null}
{/* PostsView显示帖子的列表数据 */}
<PostsView posts={this.state.posts}/>
</div>
);
}
render 中渲染组件 PostEditor 和 PostsView,PostsView 用于展示帖子列表,PostEditor 用于创建新的帖子。保存新帖子的回调函数 handleSave 也定义在 PostList 中,主要执行的逻辑是调用后台 API 保存新帖子,保存完成后,再刷新帖子列表数据,代码如下:
// 保存帖子
handleSave(data) {
// 当前登录用户的信息和默认的点赞数,同帖子的标题和内容,共同构成最终待保存的帖子对象
const postData = {...data, author: this.props.userId, vote: 0};
post(url.createPost(), postData).then(data => {
if (!data.error) {
// 保存成功后,刷新帖子列表
this.refreshPostList();
}
});
}
另外,PostList 控制 PostEditor 的显示与隐藏的逻辑是:当用户处于登录状态且点击了发帖按钮后,PostEditor 会被渲染。
components/PostList.js 的完整代码如下:
import React, {Component} from "react";
import PostsView from "./PostsView";
import PostEditor from "./PostEditor";
import {get, post} from "../utils/request";
import url from "../utils/url";
import "./PostList.css";
class PostList extends Component {
constructor(props) {
super(props);
this.state = {
posts: [],
newPost: false
};
this.handleCancel = this.handleCancel.bind(this);
this.handleSave = this.handleSave.bind(this);
this.handleNewPost = this.handleNewPost.bind(this);
this.refreshPostList = this.refreshPostList.bind(this);
}
componentDidMount() {
this.refreshPostList();
}
// 获取帖子列表
refreshPostList() {
// 调用后台API获取列表数据,并将返回的数据设置到state中
get(url.getPostList()).then(data => {
if (!data.error) {
this.setState({
posts: data,
newPost: false
});
}
});
}
// 保存帖子
handleSave(data) {
// 当前登录用户的信息和默认的点赞数,同帖子的标题和内容,共同构成最终待保存的帖子对象
const postData = {...data, author: this.props.userId, vote: 0};
post(url.createPost(), postData).then(data => {
if (!data.error) {
// 保存成功后,刷新帖子列表
this.refreshPostList();
}
});
}
// 取消新建帖子
handleCancel() {
this.setState({
newPost: false
});
}
// 新建帖子
handleNewPost() {
this.setState({
newPost: true
});
}
render() {
const {userId} = this.props;
return (
<div className="postList">
<div>
<h2>帖子列表</h2>
{/* 只有在登录状态,才显示发帖按钮 */}
{userId ? <button onClick={this.handleNewPost}>发帖</button> : null}
</div>
{/* 若当前正在创建新帖子,则渲染PostEditor组件 */}
{this.state.newPost ? (
<PostEditor onSave={this.handleSave} onCancel={this.handleCancel}/>
) : null}
{/* PostsView显示帖子的列表数据 */}
<PostsView posts={this.state.posts}/>
</div>
);
}
}
export default PostList;
PostEditor
PostEditor 用于编辑帖子的信息,不仅会在帖子列表页中使用,在帖子详情页中也会使用。在帖子列表页,PostEditor 用于发布新帖子;在帖子详情页,PostEditor 用于修改当前帖子的信息。PostEditor 的 UI 很简单,主要由一个 input 和一个 textarea 组成,负责输入帖子的标题和正文。PostEditor 只负责界面逻辑,真正保存数据的逻辑是通过调用父组件的回调函数来完成的,components/PostEditor.js 的代码如下:
import React, {Component} from "react";
import "./PostEditor.css";
class PostEditor extends Component {
constructor(props) {
super(props);
const {post} = this.props;
this.state = {
title: (post && post.title) || "",
content: (post && post.content) || ""
};
this.handleCancelClick = this.handleCancelClick.bind(this);
this.handleSaveClick = this.handleSaveClick.bind(this);
this.handleChange = this.handleChange.bind(this);
}
// 处理帖子的编辑信息
handleChange(e) {
const name = e.target.name;
if (name === "title") {
this.setState({
title: e.target.value
});
} else if (name === "content") {
this.setState({
content: e.target.value
});
} else {
}
}
// 取消帖子的编辑
handleCancelClick() {
this.props.onCancel();
}
// 保存帖子
handleSaveClick() {
const data = {
title: this.state.title,
content: this.state.content
};
// 调用父组件的回调函数执行真正的保存逻辑
this.props.onSave(data);
}
render() {
return (
<div className="postEditor">
<input
type="text"
name="title"
placeholder="标题"
value={this.state.title}
onChange={this.handleChange}
/>
<textarea
name="content"
placeholder="内容"
value={this.state.content}
onChange={this.handleChange}
/>
<button onClick={this.handleCancelClick}>取消</button>
<button onClick={this.handleSaveClick}>保存</button>
</div>
);
}
}
export default PostEditor;
PostsView
PostsView 负责显示帖子列表。PostsView 渲染 PostItem 时,每个 PostItem 外面都包裹了一个 React Router 的 Link 组件,这样点击每一个帖子项,都会跳转到该帖子的详情页。components/PostsView.js 的代码如下:
import React, {Component} from 'react';
import {Link} from "react-router-dom";
import PostItem from "./PostItem";
class PostsView extends Component {
render() {
const {posts} = this.props
return (
<ul>
{posts.map(item => (
// 使用Link组件包裹每一个PostItem
<Link key={item.id} to={`/posts/${item.id}`}>
<PostItem post={item}/>
</Link>
))}
</ul>
);
}
}
export default PostsView;
PostItem
PostItem 组件用于渲染帖子列表的每一项,它不负责任何业务逻辑,只关注组件的渲染,我们使用一个无状态的函数组件实现 PostItem。components/PostItem.js 的代码如下:
import React from "react";
import {getFormatDate} from "../utils/date";
import "./PostItem.css";
import like from "../images/like.png";
function PostItem(props) {
const {post} = props;
return (
<li className="postItem">
<div className="title">{post.title}</div>
<div>
创建人:<span>{post.author.username}</span>
</div>
<div>
更新时间:<span>{getFormatDate(post.updatedAt)}</span>
</div>
<div className="like">
<span>
<img alt="vote" src={like}/>
</span>
<span>{post.vote}</span>
</div>
</li>
);
}
export default PostItem;
帖子详情页
帖子详情页有两种状态,即浏览状态和编辑状态,分别对应图7-2和图7-3。在编辑状态下,对页面进行组件划分,分为 Header、Post、PostEditor、CommentList和CommentsView。其中,Header 和 PostEditor 已经实现,这里只分析 Post、CommentList 和 CommentsView。(为简化逻辑,帖子的点赞功能并未实现。)
Post
Post 负责获取帖子详情数据、修改帖子以及展示和创建帖子的评论。在组件挂载后,Post 调用后台 API 获取帖子详情和帖子的评论数据,代码如下:


componentDidMount() {
this.refreshComments();
this.refreshPost();
}
// 获取帖子详情
refreshPost() {
const postId = this.props.match.params.id;
get(url.getPostById(postId)).then(data => {
if (!data.error && data.length === 1) {
this.setState({
post: data[0]
});
}
});
}
// 获取评论列表
refreshComments() {
const postId = this.props.match.params.id;
get(url.getCommentList(postId)).then(data => {
if (!data.error) {
this.setState({
comments: data
});
}
});
}
当 PostEditor 对帖子做了修改时,Post 会通过 handlePostSave 这个方法将更新的帖子同步到服务器。handlePostSave 代码如下:
// 保存帖子
handlePostSave(data) {
const id = this.props.match.params.id;
this.savePost(id, data);
}
// 同步帖子的修改到服务器
savePost(id, post) {
put(url.updatePost(id), post).then(data => {
if (!data.error) {
/* 因为返回的帖子对象只有author的id信息,
* 所有需要额外把完整的author信息合并到帖子对象中 */
const newPost = {...data, author: this.state.post.author};
this.setState({
post: newPost,
editing: false
});
}
});
}
当 CommentList 中有新的评论被创建时,Post 同样需要把新评论同步到服务器,这一过程通过 handleCommentSubmit 方法实现:
// 提交新建的评论
handleCommentSubmit(content) {
const postId = this.props.match.params.id;
const comment = {
author: this.props.userId,
post: postId,
content: content
};
this.saveComment(comment);
}
// 保存新的评论到服务器
saveComment(comment) {
post(url.createComment(), comment).then(data => {
if (!data.error) {
this.refreshComments();
}
});
}
components/Post.js 的完整代码如下:
import React, {Component} from "react";
import PostEditor from "./PostEditor";
import PostView from "./PostView";
import CommentList from "./CommentList";
import {get, put, post} from "../utils/request";
import url from "../utils/url";
import "./Post.css";
class Post extends Component {
constructor(props) {
super(props);
this.state = {
post: null,
comments: [],
editing: false
};
this.handleEditClick = this.handleEditClick.bind(this);
this.handleCommentSubmit = this.handleCommentSubmit.bind(this);
this.handlePostSave = this.handlePostSave.bind(this);
this.handlePostCancel = this.handlePostCancel.bind(this);
this.refreshComments = this.refreshComments.bind(this);
this.refreshPost = this.refreshPost.bind(this);
}
componentDidMount() {
this.refreshComments();
this.refreshPost();
}
// 获取帖子详情
refreshPost() {
const postId = this.props.match.params.id;
get(url.getPostById(postId)).then(data => {
if (!data.error && data.length === 1) {
this.setState({
post: data[0]
});
}
});
}
// 获取评论列表
refreshComments() {
const postId = this.props.match.params.id;
get(url.getCommentList(postId)).then(data => {
if (!data.error) {
this.setState({
comments: data
});
}
});
}
// 让帖子处于编辑态
handleEditClick() {
this.setState({
editing: true
});
}
// 保存帖子
handlePostSave(data) {
const id = this.props.match.params.id;
this.savePost(id, data);
}
// 取消编辑帖子
handlePostCancel() {
this.setState({
editing: false
});
}
// 提交新建的评论
handleCommentSubmit(content) {
const postId = this.props.match.params.id;
const comment = {
author: this.props.userId,
post: postId,
content: content
};
this.saveComment(comment);
}
// 保存新的评论到服务器
saveComment(comment) {
post(url.createComment(), comment).then(data => {
if (!data.error) {
this.refreshComments();
}
});
}
// 同步帖子的修改到服务器
savePost(id, post) {
put(url.updatePost(id), post).then(data => {
if (!data.error) {
/* 因为返回的帖子对象只有author的id信息,
* 所有需要额外把完整的author信息合并到帖子对象中 */
const newPost = {...data, author: this.state.post.author};
this.setState({
post: newPost,
editing: false
});
}
});
}
render() {
const {post, comments, editing} = this.state;
const {userId} = this.props;
if (!post) {
return null;
}
const editable = userId === post.author.id;
return (
<div className="post">
{editing ? (
<PostEditor
post={post}
onSave={this.handlePostSave}
onCancel={this.handlePostCancel}
/>
) : (
<PostView
post={post}
editable={editable}
onEditClick={this.handleEditClick}
/>
)}
<CommentList
comments={comments}
editable={Boolean(userId)}
onSubmit={this.handleCommentSubmit}
/>
</div>
);
}
}
export default Post;
CommentList
CommentList 用于显示评论列表(通过 CommentsView)和发表新评论,用户发表的新评论通过调用父组件 Post 的 handleCommentSubmit 方法保存到服务器。CommentList 也是只负责 UI 逻辑。components/CommentList.js 的完整代码如下:
import React, {Component} from "react";
import CommentsView from "./CommentsView";
import "./CommentList.css";
class CommentList extends Component {
constructor(props) {
super(props);
this.state = {
value: ""
};
this.handleChange = this.handleChange.bind(this);
this.handleClick = this.handleClick.bind(this);
}
// 处理新评论内容的变化
handleChange(e) {
this.setState({
value: e.target.value
});
}
// 保存新评论
handleClick(e) {
const content = this.state.value;
if (content.length > 0) {
this.props.onSubmit(this.state.value);
this.setState({
value: ""
});
} else {
alert("评论内容不能为空!");
}
}
render() {
const {comments, editable} = this.props;
return (
<div className="commentList">
<div className="title">评论</div>
{editable ? (
<div className="editor">
<textarea
placeholder="说说你的看法"
value={this.state.value}
onChange={this.handleChange}
/>
<button onClick={this.handleClick}>提交</button>
</div>
) : null}
<CommentsView comments={comments}/>
</div>
);
}
}
export default CommentList;
CommentsView
CommentsView 是负责显示评论列表的最终组件。components/CommentsView.js 的代码如下:
import React, {Component} from "react";
import {getFormatDate} from "../utils/date";
import "./CommentsView.css";
class CommentsView extends Component {
render() {
const {comments} = this.props;
return (
<ul className="commentsView">
{comments.map(item => {
return (
<li key={item.id}>
<div>{item.content}</div>
<div className="sub">
<span>{item.author.username}</span>
<span>·</span>
<span>{getFormatDate(item.updatedAt)}</span>
</div>
</li>
);
})}
</ul>
);
}
}
export default CommentsView;