项目实战

本节将使用 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 启动程序时,代理有效。

路由设计

路由设计的过程可以分为两步:

  1. 为每一个页面定义有语义的路由名称(path)。

  2. 组织 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所示。

image 2024 04 24 12 46 33 217
Figure 1. 图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}&nbsp;<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 获取帖子详情和帖子的评论数据,代码如下:

image 2024 04 24 13 03 12 409
Figure 2. 图7-2
image 2024 04 24 13 03 34 100
Figure 3. 图7-3
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;