实战-评论功能

组件划分

React.js 中一切都是组件,用 React.js 构建的功能其实也就是由各种组件组合而成。所以拿到一个需求以后,我们要做的第一件事情就是理解需求、分析需求、划分这个需求由哪些组件构成。

组件的划分没有特别明确的标准。划分组件的目的性是为了代码可复用性、可维护性。只要某个部分有可能复用到别的地方,你都可以把它抽离出来当成一个组件;或者把某一部分抽离出来对代码的组织和管理会带来帮助,你也可以毫不犹豫地把它抽离出来。

组件实现

在写代码之前,我们先用 create-react-app 构建一个新的工程目录。所有的评论功能在这个工程内完成:

create-react-app comment-app

然后在工程目录下的 src/ 目录下新建四个文件,每个文件对应的是上述的四个组件。

src/
  CommentApp.js
  CommentInput.js
  CommentList.js
  Comment.js

添加样式

处理用户输入

React.js 当中必须要用 setState 才能更新组件的内容,所以我们需要做的就是:监听输入框的 onChange 事件,然后获取到用户输入的内容,再通过 setState 的方式更新 state 中的 username,这样 input 的内容才会更新。

在这个方法中,我们通过 event.target.value 获取 <input /> 中用户输入的内容,然后通过 setState 把它设置到 state.username 当中,这时候组件的内容就会更新,inputvalue 值就会得到更新并显示到输入框内。这时候输入已经没有问题了。

类似于 <input /><select /><textarea> 这些元素的 value 值被 React.js 所控制、渲染的组件,在 React.js 当中被称为受控组件。对于用户可输入的控件,一般都可以让它们成为受控组件,这是 React.js 所推崇的做法。

向父组件传递数据

当用户在 CommentInput 里面输入完内容以后,点击发布,内容其实是需要显示到 CommentList 组件当中的。但这两个组件明显是单独的、分离的组件。

CommentApp 组件将 CommentInputCommentList 组合起来,它是它们俩的父组件,可以充当桥接两个子组件的桥梁。所以当用户点击发布按钮的时候,我们就将 CommentInputstate 当中最新的评论数据传递给父组件 CommentApp ,然后让父组件把这个数据传递给 CommentList 进行渲染。

CommentInput 如何向 CommentApp 传递的数据?父组件 CommentApp 只需要通过 props 给子组件 CommentInput 传入一个回调函数。当用户点击发布按钮的时候,CommentInput 调用 props 中的回调函数并且将 state 传入该函数即可。

handleSubmit 方法会判断 props 中是否传入了 onSubmit 属性。有的话就调用该函数,并且把用户输入的用户名和评论数据传入该函数。然后再通过 setState 清空用户输入的评论内容。

我们在 CommentAppstate 中初始化一个数组,来保存所有的评论数据,并且通过 props 把它传递给 CommentList

总结

  1. 实现功能之前先理解、分析需求,划分组件。并且掌握划分组件的基本原则——可复用性、可维护性。
  2. 受控组件的概念,React.js 中的 <input /><textarea /><select /> 等元素的 value 值如果是受到 React.js 的控制,那么就是受控组件。
  3. 组件之间使用 props 通过父元素传递数据的技巧。

我们在上一阶段的评论功能基础上加上以下功能需求:

  1. 页面加载完成自动聚焦到评论输入框。
  2. 把用户名持久化,存放到浏览器的 LocalStorage 中。页面加载时会把用户名加载出来显示到输入框,用户就不需要重新输入用户名了。
  3. 把已经发布的评论持久化,存放到浏览器的 LocalStorage 中。页面加载时会把已经保存的评论加载出来,显示到页面的评论列表上。
  4. 评论显示发布日期,如“1 秒前”,”30 分钟前”,并且会每隔 5 秒更新发布日期。
  5. 评论可以被删除。
  6. 类似 Markdown 的行内代码块显示功能,用户输入的用 `` 包含起来的内容都会被处理成用 <code> 元素包含。例如输入 console.log 就会处理成 <code>console.log</code> 再显示到页面上。

自动聚焦到评论框

这个功能是很简单的,我们需要获取 textareaDOM 元素然后调用 focus() API 就可以了。我们给输入框元素加上 ref 以便获取到 DOM 元素,修改 src/CommentInput.js 文件。

组件挂载完以后完成以后就可以调用 this.textarea.focus(),给 CommentInput 组件加上 ComponentDidMount 生命周期。

持久化用户名

用户输入用户名,然后我们把用户名保存到浏览器的 LocalStorage 当中,当页面加载的时候再从 LocalStorage 把之前保存的用户名显示到用户名输入框当中。这样用户就不用每次都输入用户名了,并且评论框是自动聚焦的,用户的输入体验就好很多。

我们监听用户名输入框失去焦点的事件 onBlur

handleUsernameBlur 中我们把用户的输入内容保存到 LocalStorage 当中。

handleUsernameBlur 中我们把用户输入的内容传给了 _saveUsername 私有方法(所有私有方法都以 _ 开头)。_saveUsername 会设置 LocalStorage 中的 username 字段,用户名就持久化了。这样就相当于每当用户输入完用户名以后(输入框失去焦点的时候),都会把用户名自动保存一次。

然后我们组件挂载的时候把用户名加载出来。这是一种数据加载操作,不依赖 DOM 操作的组件启动的操作都可以放在 componentWillMount 中进行,所以给 CommentInput 添加 componentWillMount 的组件生命周期。

componentWillMount 会调用 _loadUsername 私有方法,_loadUsername 会从 LocalStorage 加载用户名并且 setState 到组件的 state.username 中。那么组件在渲染的时候(render 方法)挂载的时候就可以用上用户名了。

组件的私有方法都用 _ 开头,所有事件监听的方法都用 handle 开头。把事件监听方法传给组件的时候,属性名用 on 开头。

这样统一规范处理事件命名会给我们带来语义化组件的好处,监听(onCommentInputSubmit 事件,并且交给 this 去处理(handle)。

组件的内容编写顺序如下:

  1. static 开头的类属性,如 defaultPropspropTypes
  2. 构造函数,constructor
  3. getter/setter
  4. 组件生命周期。
  5. _ 开头的私有方法。
  6. 事件监听方法,handle*
  7. render*开头的方法,有时候 render() 方法里面的内容会分开到不同函数里面进行,这些函数都以 render* 开头。
  8. render() 方法。

持久化评论

我们增加了 _loadComments_saveComments 分别用于加载和保存评论列表数据。用户每次提交评论都会把评论列表数据保存一次,所以我们在 handleSubmitComment 调用 _saveComments 方法;而在 componentWillMount 中调用 _loadComments 方法,在组件开始挂载的时候把评论列表数据加载出来 setStatethis.state 当中,组件就可以渲染从 LocalStorage 从加载出来的评论列表数据了。

显示评论发布时间

修改 src/CommentInput.js 当用户点击发布按钮的时候,传出去的评论数据带上评论发布的时间戳。

每个 Comment 组件实例会保存一个 timeString 状态,用于该评论显示发布了多久。_updateTimeString 这个私有方法会根据 props.comment 里面的 createdTime 来更新这个 timeString:计算当前时间和评论发布时间的时间差,如果已经发布 60 秒以上就显示分钟,否则就显示秒。然后 componentWillMount 会在组件挂载阶段调用 _updateTimeString 更新一下这个字符串,render() 方法就把这个显示时间差的字符串渲染到一个 <span> 上。

这时候的时间是不会自动更新的。除非你手动刷新页面,否则永远显示“1 秒前”。我们可以在 componentWillMount 中启动一个定时器,每隔 5 秒调用一下 _updateTimeString,让它去通过 setState 更新 timeString

删除评论

我们知道评论列表数据是放在 CommentApp 当中的,而这个删除按钮是在 Comment 当中的,现在我们要做的事情是用户点击某条评论的删除按钮,然后在 CommentApp 中把相应的数据删除。

CommentCommentApp 之间隔了一个 CommentListComment 无法直接跟 CommentApp 打交道,只能通过 CommentList 来转发这种删除评论的消息。

现在在使用 Comment 的时候,可以传入 onDeleteCommentindex 两个参数。index 用来标志这个评论在列表的下标,这样点击删除按钮的时候我们才能知道你点击的是哪个评论,才能知道怎么从列表数据中删除。用户点击删除会调用 handleDeleteComment ,它会调用从上层传入的 props.onDeleteComment 函数告知上一层组件删除的消息,并且把评论下标传出去。

也就是说,我们可以在 CommentAppCommentList 传入一个 onDeleteComment 的配置参数来接受这个删除评论的消息,修改 CommentApp.js

现在点击删除按钮,可以在控制台看到评论对应的下标打印了出来。其实这就是这么一个过程:CommentList 把下标 index 传给 Comment。点击删除按钮的时候,Commentindex 传给了 CommentListCommentList 再把它传给 CommentApp

我们通过 comments.splice 删除特定下标的评论,并且通过 setState 重新渲染整个评论列表;当然了,还需要把最新的评论列表数据更新到 LocalStorage 中,所以我们在删除、更新以后调用了 _saveComments 方法把数据同步到 LocalStorage 中。

这是因为我们忘了清除评论的定时器,修改 src/Comment.js,新增生命周期 commentWillUnmount 在评论组件销毁的时候清除定时器。

显示代码块

用户在的输入内容中任何以 `` 包含的内容都会用 <code> 包含起来显示到页面上。<code> 这是一个 HTML 结构,需要往页面动态插入 HTML 结构我们只能用 dangerouslySetInnerHTML 了。

我们把经过 this._getProcessedContent 处理的评论内容以 HTML 的方式插入到 <p> 元素中。this._getProcessedContent 要把 `` 包含的内容用 <code> 包裹起来,一个正则表达式就可以做到了。

但是这样做会有严重的 XSS 漏洞,用户可以输入任意的 HTML 标签,用 <script> 执行任意的 JavaScript 代码。所以在替换代码之前,我们要手动地把这些 HTML 标签进行转义。

5replace 实际上是把类似于 <> 这种内容替换转义一下,防止用户输入 HTML 标签。最后一行代码才是实现功能的代码。