本人的React学习笔记分类(也是对应本人技术成长过程):[想快速入门看这部分]、[想对React系统全面进行学习的同学看这里]、[对基础学习完成且有了一定开发经验,想尝试解析源码的看这里]

实战分析:评论功能(一)

课程到这里大家已经掌握了 React.js 的基础知识和组件的基本写法了。现在可以把我们所学到的内容应用于实战当中。这里给大家提供一个实战的案例:一个评论功能。效果如下:

贰、React小书_阶段练习_day12-2 - 图1

在线演示地址

接下来会带大家一起来学习如何分析、编写这个功能。在这个过程中会补充一些之前没有提及的知识点,虽然这些知识点之前没有单独拿出来讲解,但是这些知识点也很关键。

组件划分

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

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

对于上面这个评论功能,可以粗略地划分成以下几部分:

贰、React小书_阶段练习_day12-2 - 图2

CommentApp:评论功能的整体用一个叫 CommentApp 的组件包含起来。CommentApp 包含上部和下部两部分。

CommentInput:上面部分是负责用户输入可操作的输入区域,包括输入评论的用户名、评论内容和发布按钮,这一部分功能划分到一个单独的组件 CommentInput 中。

CommentList:下面部分是评论列表,用一个叫 CommentList 的组件负责列表的展示。

Comment:每个评论列表项由独立的组件 Comment 负责显示,这个组件被 CommentList 所使用。

所以这个评论功能划分成四种组件,CommentAppCommentInputCommentListComment。用组件树表示:

贰、React小书_阶段练习_day12-2 - 图3

现在就可以尝试编写代码了。

组件实现

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

  1. create-react-app comment-app

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

  1. src/
  2. CommentApp.js
  3. CommentInput.js
  4. CommentList.js
  5. Comment.js
  6. ...

你可以注意到,这里的文件名的开头是大写字母。我们遵循一个原则:如果一个文件导出的是一个类,那么这个文件名就用大写开头。四个组件类文件导出都是类,所以都是大写字母开头。

我们先铺垫一些基础代码,让组件之间的关系清晰起来。遵循“自顶而下,逐步求精”的原则,我们从组件的顶层开始,再一步步往下构建组件树。先修改 CommentApp.js 如下:

  1. import React, { Component } from 'react'
  2. import CommentInput from './CommentInput'
  3. import CommentList from './CommentList'
  4. class CommentApp extends Component {
  5. render() {
  6. return (
  7. <div>
  8. <CommentInput />
  9. <CommentList />
  10. </div>
  11. )
  12. }
  13. }
  14. export default CommentApp

CommentApp 现在暂时还很简单,文件顶部引入了 CommentInputCommentList 。然后按照上面的需求,应用在了 CommentApp 返回的 JSX 结构中,上面是用户输入区域,下面是评论列表。

现在来修改 CommentInput.js 中的内容:

  1. import React, { Component } from 'react'
  2. class CommentInput extends Component {
  3. render() {
  4. return (
  5. <div>CommentInput</div>
  6. )
  7. }
  8. }
  9. export default CommentInput

这里暂时让它只简单返回 <div> 结构,同样地修改 CommentList.js

  1. import React, { Component } from 'react'
  2. class CommentList extends Component {
  3. render() {
  4. return (
  5. <div>CommentList</div>
  6. )
  7. }
  8. }
  9. export default CommentList

现在可以把这个简单的结构渲染到页面上看看什么效果,修改 src/index.js

  1. import React from 'react'
  2. import ReactDOM from 'react-dom'
  3. import CommentApp from './CommentApp'
  4. import './index.css'
  5. ReactDOM.render(
  6. <CommentApp />,
  7. document.getElementById('root')
  8. )

然后进入工程目录启动工程:

  1. npm run start

在浏览器中可以看到,基本的结构已经渲染到了页面上了:

贰、React小书_阶段练习_day12-2 - 图4

添加样式

现在想让这个结构在浏览器中居中显示,我们就要给 CommentApp 里面的 <div> 添加样式。修改 CommentApp 中的render 方法,给它添加一个 wrapper 类名:

  1. ...
  2. class CommentApp extends Component {
  3. render() {
  4. return (
  5. <div className='wrapper'>
  6. <CommentInput />
  7. <CommentList />
  8. </div>
  9. )
  10. }
  11. }
  12. ...

然后在 index.css 文件中添加样式:

  1. .wrapper {
  2. width: 500px;
  3. margin: 10px auto;
  4. font-size: 14px;
  5. background-color: #fff;
  6. border: 1px solid #f1f1f1;
  7. padding: 20px;
  8. }

在浏览器中可以看到样式生效了:

贰、React小书_阶段练习_day12-2 - 图5

评论功能案例的所有样式都是通过这种方式进行添加。由于我们专注点在于 React.js,本案例后续不会在样式上过于纠缠。这里写好了一个样式文件(index.css )提供给大家,可以复制到 index.css 当中。后续只需要在元素上加上类名就可以了。

index.css

  1. body {
  2. margin: 0;
  3. padding: 0;
  4. font-family: sans-serif;
  5. background-color: #fbfbfb;
  6. }
  7. .wrapper {
  8. width: 500px;
  9. margin: 10px auto;
  10. font-size: 14px;
  11. background-color: #fff;
  12. border: 1px solid #f1f1f1;
  13. padding: 20px;
  14. }
  15. /* 评论框样式 */
  16. .comment-input {
  17. background-color: #fff;
  18. border: 1px solid #f1f1f1;
  19. padding: 20px;
  20. margin-bottom: 10px;
  21. }
  22. .comment-field {
  23. margin-bottom: 15px;
  24. display: flex;
  25. }
  26. .comment-field .comment-field-name {
  27. display: flex;
  28. flex-basis: 100px;
  29. font-size: 14px;
  30. }
  31. .comment-field .comment-field-input {
  32. display: flex;
  33. flex: 1;
  34. }
  35. .comment-field-input input,
  36. .comment-field-input textarea {
  37. border: 1px solid #e6e6e6;
  38. border-radius: 3px;
  39. padding: 5px;
  40. outline: none;
  41. font-size: 14px;
  42. resize: none;
  43. flex: 1;
  44. }
  45. .comment-field-input textarea {
  46. height: 100px;
  47. }
  48. .comment-field-button {
  49. display: flex;
  50. justify-content: flex-end;
  51. }
  52. .comment-field-button button {
  53. padding: 5px 10px;
  54. width: 80px;
  55. border: none;
  56. border-radius: 3px;
  57. background-color: #00a3cf;
  58. color: #fff;
  59. outline: none;
  60. cursor: pointer;
  61. }
  62. .comment-field-button button:active {
  63. background: #13c1f1;
  64. }
  65. /* 评论列表样式 */
  66. .comment-list {
  67. background-color: #fff;
  68. border: 1px solid #f1f1f1;
  69. padding: 20px;
  70. }
  71. /* 评论组件样式 */
  72. .comment {
  73. position: relative;
  74. display: flex;
  75. border-bottom: 1px solid #f1f1f1;
  76. margin-bottom: 10px;
  77. padding-bottom: 10px;
  78. min-height: 50px;
  79. }
  80. .comment .comment-user {
  81. flex-shrink: 0;
  82. }
  83. .comment-username {
  84. color: #00a3cf;
  85. font-style: italic;
  86. }
  87. .comment-createdtime {
  88. padding-right: 5px;
  89. position: absolute;
  90. bottom: 0;
  91. right: 0;
  92. padding: 5px;
  93. font-size: 12px;
  94. }
  95. .comment:hover .comment-delete {
  96. color: #00a3cf;
  97. }
  98. .comment-delete {
  99. position: absolute;
  100. right: 0;
  101. top: 0;
  102. color: transparent;
  103. font-size: 12px;
  104. cursor: pointer;
  105. }
  106. .comment p {
  107. margin: 0;
  108. /*text-indent: 2em;*/
  109. }
  110. code {
  111. border: 1px solid #ccc;
  112. background: #f9f9f9;
  113. padding: 0px 2px;
  114. }

实战分析:评论功能(二)

处理用户输入

我们从 ComponentInput 组件开始,学习 React.js 是如何处理用户输入的。首先修改 ComponentInput.js,完善 ComponentInputrender 函数中的 HTML 结构:

  1. import React, { Component } from 'react'
  2. class CommentInput extends Component {
  3. render () {
  4. return (
  5. <div className='comment-input'>
  6. <div className='comment-field'>
  7. <span className='comment-field-name'>用户名:</span>
  8. <div className='comment-field-input'>
  9. <input />
  10. </div>
  11. </div>
  12. <div className='comment-field'>
  13. <span className='comment-field-name'>评论内容:</span>
  14. <div className='comment-field-input'>
  15. <textarea />
  16. </div>
  17. </div>
  18. <div className='comment-field-button'>
  19. <button>
  20. 发布
  21. </button>
  22. </div>
  23. </div>
  24. )
  25. }
  26. }
  27. export default CommentInput

在浏览器中可以看到 ComponentInput 的结构和样式都已经生效:

贰、React小书_阶段练习_day12-2 - 图6

因为还没有加入处理逻辑,所以你输入内容,然后点击发布是不会有什么效果的。用户可输入内容一个是用户名(username),一个是评论内容(content),我们在组件的构造函数中初始化一个 state 来保存这两个状态:

  1. ...
  2. class CommentInput extends Component {
  3. constructor () {
  4. super()
  5. this.state = {
  6. username: '',
  7. content: ''
  8. }
  9. }
  10. ...
  11. }
  12. ...

然后给输入框设置 value 属性,让它们的 value 值等于 this.state 里面相应的值:

  1. ...
  2. <div className='comment-field'>
  3. <span className='comment-field-name'>用户名:</span>
  4. <div className='comment-field-input'>
  5. <input value={this.state.username} />
  6. </div>
  7. </div>
  8. <div className='comment-field'>
  9. <span className='comment-field-name'>评论内容:</span>
  10. <div className='comment-field-input'>
  11. <textarea value={this.state.content} />
  12. </div>
  13. </div>
  14. ...

可以看到接受用户名输入的 <input /> 和接受用户评论内容的 <textarea />value 值分别由 state.usernamestate.content 控制。这时候你到浏览器里面去输入内容看看,你会发现你什么都输入不了。

这是为什么呢?React.js 认为所有的状态都应该由 React.js 的 state 控制,只要类似于 <input /><textarea /><select /> 这样的输入控件被设置了 value 值,那么它们的值永远以被设置的值为准。值不变,value 就不会变化。

例如,上面设置了 <input />valuethis.state.usernameusernameconstructor 中被初始化为空字符串。即使用户在输入框里面尝试输入内容了,还是没有改变 this.state.username 是空字符串的事实。

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

  1. ...
  2. <div className='comment-field-input'>
  3. <input
  4. value={this.state.username}
  5. onChange={this.handleUsernameChange.bind(this)} />
  6. </div>
  7. ...

上面的代码给 input 加上了 onChange 事件监听,绑定到 this.handleUsernameChange 方法中,该方法实现如下:

  1. ...
  2. handleUsernameChange (event) {
  3. this.setState({
  4. username: event.target.value
  5. })
  6. }
  7. ...

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

贰、React小书_阶段练习_day12-2 - 图7

类似于 <input /><select /><textarea> 这些元素的 value 值被 React.js 所控制、渲染的组件,在 React.js 当中被称为受控组件(Controlled Component)。对于用户可输入的控件,一般都可以让它们成为受控组件,这是 React.js 所推崇的做法。另外还有非受控组件,这里暂时不提及。

同样地,让 <textarea /> 成为受控组件:

  1. ...
  2. handleContentChange (event) {
  3. this.setState({
  4. content: event.target.value
  5. })
  6. }
  7. ...
  8. <div className='comment-field'>
  9. <span className='comment-field-name'>评论内容:</span>
  10. <div className='comment-field-input'>
  11. <textarea
  12. value={this.state.content}
  13. onChange={this.handleContentChange.bind(this)} />
  14. </div>
  15. </div>
  16. ...

向父组件传递数据

当用户在 CommentInput 里面输入完内容以后,点击发布,内容其实是需要显示到 CommentList 组件当中的。但这两个组件明显是单独的、分离的组件。我们再回顾一下之前是怎么划分组件的:

贰、React小书_阶段练习_day12-2 - 图8

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

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

先给发布按钮添加事件:

  1. ...
  2. <div className='comment-field-button'>
  3. <button
  4. onClick={this.handleSubmit.bind(this)}>
  5. 发布
  6. </button>
  7. </div>
  8. ...

用户点击按钮的时候会调用 this.handleSubmit 方法:

  1. ...
  2. handleSubmit () {
  3. if (this.props.onSubmit) {
  4. const { username, content } = this.state
  5. this.props.onSubmit({username, content})
  6. }
  7. this.setState({ content: '' })
  8. }
  9. ...

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

修改 CommentApp.js ,让它可以通过传入回调来获取到新增评论数据:

  1. class CommentApp extends Component {
  2. handleSubmitComment (comment) {
  3. console.log(comment)
  4. }
  5. render() {
  6. return (
  7. <div className='wrapper'>
  8. <CommentInput
  9. onSubmit={this.handleSubmitComment.bind(this)} />
  10. <CommentList />
  11. </div>
  12. )
  13. }
  14. }

CommentApp 中给 CommentInput 传入一个 onSubmit 属性,这个属性值是 CommentApp 自己的一个方法 handleSubmitComment。这样 CommentInput 就可以调用 this.props.onSubmit(…) 把数据传给 CommenApp

现在在 CommentInput 中输入完评论内容以后点击发布,就可以看到 CommentApp 在控制台打印的数据:

贰、React小书_阶段练习_day12-2 - 图9

这样就顺利地把数据传递给了父组件,接下来我们开始处理评论列表相关的逻辑。

实战分析:评论功能(三)

接下来的代码比较顺理成章了。修改 CommentList 可以让它可以显示评论列表:

  1. // CommentList.js
  2. import React, { Component } from 'react'
  3. class CommentList extends Component {
  4. render() {
  5. const comments = [
  6. {username: 'Jerry', content: 'Hello'},
  7. {username: 'Tomy', content: 'World'},
  8. {username: 'Lucy', content: 'Good'}
  9. ]
  10. return (
  11. <div>{comments.map((comment, i) => {
  12. return (
  13. <div key={i}>
  14. {comment.username}:{comment.content}
  15. </div>
  16. )
  17. })}</div>
  18. )
  19. }
  20. }
  21. export default CommentList

这里的代码没有什么新鲜的内容,只不过是建立了一个 comments 的数组来存放一些测试数据的内容,方便我们后续测试。然后把 comments 的数据渲染到页面上,这跟我们之前讲解的章节的内容一样——使用 map 构建一个存放 JSX 的数组。就可以在浏览器看到效果:

贰、React小书_阶段练习_day12-2 - 图10

修改 Comment.js 让它来负责具体每条评论内容的渲染:

  1. import React, { Component } from 'react'
  2. class Comment extends Component {
  3. render () {
  4. return (
  5. <div className='comment'>
  6. <div className='comment-user'>
  7. <span>{this.props.comment.username} </span>:
  8. </div>
  9. <p>{this.props.comment.content}</p>
  10. </div>
  11. )
  12. }
  13. }
  14. export default Comment

这个组件可能是我们案例里面最简单的组件了,它只负责每条评论的具体显示。你只需要给它的 props 中传入一个 comment 对象,它就会把该对象中的 usernamecontent 渲染到页面上。

马上把 Comment 应用到 CommentList 当中,修改 CommentList.js 代码:

  1. import React, { Component } from 'react'
  2. import Comment from './Comment'
  3. class CommentList extends Component {
  4. render() {
  5. const comments = [
  6. {username: 'Jerry', content: 'Hello'},
  7. {username: 'Tomy', content: 'World'},
  8. {username: 'Lucy', content: 'Good'}
  9. ]
  10. return (
  11. <div>
  12. {comments.map((comment, i) => <Comment comment={comment} key={i} />)}
  13. </div>
  14. )
  15. }
  16. }
  17. export default CommentList

可以看到测试数据显示到了页面上:

贰、React小书_阶段练习_day12-2 - 图11

之前我们说过 CommentList 的数据应该是由父组件 CommentApp 传进来的,现在我们删除测试数据,改成从 props 获取评论数据:

  1. import React, { Component } from 'react'
  2. import Comment from './Comment'
  3. class CommentList extends Component {
  4. render() {
  5. return (
  6. <div>
  7. {this.props.comments.map((comment, i) =>
  8. <Comment comment={comment} key={i} />
  9. )}
  10. </div>
  11. )
  12. }
  13. }
  14. export default CommentList

这时候可以看到浏览器报错了:

贰、React小书_阶段练习_day12-2 - 图12

这是因为CommentApp 使用 CommentList 的时候并没有传入 comments。我们给 CommentList 加上 defaultProps 防止 comments 不传入的情况:

  1. class CommentList extends Component {
  2. static defaultProps = {
  3. comments: []
  4. }
  5. ...

这时候代码就不报错了。但是 CommentInputCommentApp 传递的评论数据并没有传递给 CommentList,所以现在发表评论时没有反应的。

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

  1. import React, { Component } from 'react'
  2. import CommentInput from './CommentInput'
  3. import CommentList from './CommentList'
  4. class CommentApp extends Component {
  5. constructor () {
  6. super()
  7. this.state = {
  8. comments: []
  9. }
  10. }
  11. handleSubmitComment (comment) {
  12. console.log(comment)
  13. }
  14. render() {
  15. return (
  16. <div className='wrapper'>
  17. <CommentInput onSubmit={this.handleSubmitComment.bind(this)} />
  18. <CommentList comments={this.state.comments}/>
  19. </div>
  20. )
  21. }
  22. }
  23. export default CommentApp

接下来,修改 handleSubmitComment :每当用户发布评论的时候,就把评论数据插入 this.state.comments 中,然后通过 setState 把数据更新到页面上:

  1. ...
  2. handleSubmitComment (comment) {
  3. this.state.comments.push(comment)
  4. this.setState({
  5. comments: this.state.comments
  6. })
  7. }
  8. ...

小提示:这里的代码直接往 state.comments 数组里面插入数据其实违反了 React.js 的 state 不可直接修改的原则 。但其实这个原则是为了 shouldComponentUpdate 的优化和变化的跟踪,而这种目的在使用 React-redux 的时候其实会自然而然达到,我们很少直接手动地优化,这时候这个原则就会显得有点鸡肋。所以这里为了降低大家的理解成本就不强制使用这个原则,有兴趣的朋友可以参考: Tutorial: Intro To React - React

现在代码应该是可以按照需求正常运作了,输入用户名和评论内容,然后点击发布:

贰、React小书_阶段练习_day12-2 - 图13

为了让代码的健壮性更强,给 handleSubmitComment 加入简单的数据检查:

  1. ...
  2. handleSubmitComment (comment) {
  3. if (!comment) return
  4. if (!comment.username) return alert('请输入用户名')
  5. if (!comment.content) return alert('请输入评论内容')
  6. this.state.comments.push(comment)
  7. this.setState({
  8. comments: this.state.comments
  9. })
  10. }
  11. ...

到这里,我们的第一个实战案例——评论功能已经完成了!完整的案例代码可以在这里 comment-app 找到, 在线演示 体验。

总结

在这个案例里面,我们除了复习了之前所学过的内容以外还学习了新的知识点。包括:

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

当然,在真实的项目当中,这个案例很多地方是可以优化的。包括组件可复用性方面(有没有发现其实 CommentInput 中有重复的代码?)、应用的状态管理方面。但在这里为了给大家总结和演示,实现到这个程度也就足够了。

到此为止,React.js 小书的第一阶段已经结束,你可以利用这些知识点来构建简单的功能模块了。但是在实际项目如果要构建比较系统和完善的功能,还需要更多的 React.js 的知识还有关于前端开发的一些认知来协助我们。接下来我们会开启新的一个阶段来学习更多关于 React.js 的知识,以及如何更加灵活和熟练地使用它们。让我们进入第二阶段吧!

实战分析:评论功能(四)

目前为止,第二阶段知识已经基本介绍完,我们已经具备了项目上手实战必备的 React.js 知识,现在可以把这些知识应用起来。接下来是实战环节,我们会继续上一阶段的例子,把评论功能做得更加复杂一点。

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

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

贰、React小书_阶段练习_day12-2 - 图14

在线演示地址

大家可以在原来的第一阶段代码的基础上进行修改,第一、二阶段评论功能代码可以在这里找到: react-naive-book-examples。可以直接使用最新的样式文件 index.css 覆盖原来的 index.css。

接下来可以分析如何利用第二阶段的知识来构建这些功能,在这个过程里面可能会穿插一些小技巧,希望对大家有用。我们回顾一下这个页面的组成:

贰、React小书_阶段练习_day12-2 - 图15

我们之前把页面分成了四种不同的组件:分别是 CommentAppCommentInputCommentListComment。我们开始修改这个组件,把上面的需求逐个完成。

自动聚焦到评论框

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

  1. ...
  2. <textarea
  3. ref={(textarea) => this.textarea = textarea}
  4. value={this.state.content}
  5. onChange={this.handleContentChange.bind(this)} />
  6. ...

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

  1. class CommentInput extends Component {
  2. static propTypes = {
  3. onSubmit: PropTypes.func
  4. }
  5. constructor () {
  6. super()
  7. this.state = {
  8. username: '',
  9. content: ''
  10. }
  11. }
  12. componentDidMount () {
  13. this.textarea.focus()
  14. }
  15. ...

这个功能就完成了。现在体验还不是很好,接下来我们把用户名持久化一下,体验就会好很多。

大家可以注意到我们给原来的 props.onSubmit 参数加了组件参数验证,在这次实战案例中,我们都会给评论功能的组件加上 propTypes 进行参数验证,接下来就不累述。

持久化用户名

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

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

  1. ...
  2. <input
  3. value={this.state.username}
  4. onBlur={this.handleUsernameBlur.bind(this)}
  5. onChange={this.handleUsernameChange.bind(this)} />
  6. ...

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

  1. class CommentInput extends Component {
  2. constructor () {
  3. super()
  4. this.state = {
  5. username: '',
  6. content: ''
  7. }
  8. }
  9. componentDidMount () {
  10. this.textarea.focus()
  11. }
  12. _saveUsername (username) {
  13. localStorage.setItem('username', username)
  14. }
  15. handleUsernameBlur (event) {
  16. this._saveUsername(event.target.value)
  17. }
  18. ...

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

输入用户名,然后到浏览器里里面看看是否保存了:

贰、React小书_阶段练习_day12-2 - 图16

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

  1. ...
  2. componentWillMount () {
  3. this._loadUsername()
  4. }
  5. _loadUsername () {
  6. const username = localStorage.getItem('username')
  7. if (username) {
  8. this.setState({ username })
  9. }
  10. }
  11. _saveUsername (username) {
  12. localStorage.setItem('username', username)
  13. }
  14. ...

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

这样体验就好多了,刷新页面,不需要输入用户名,并且自动聚焦到了输入框。我们 1、 2 需求都已经完成。

小贴士

这里插入一些小贴示,大家可以注意到我们组件的命名和方法的摆放顺序其实有一定的讲究,这里可以简单分享一下个人的习惯,仅供参考。

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

  1. <CommentInput
  2. onSubmit={this.handleSubmitComment.bind(this)} />

这样统一规范处理事件命名会给我们带来语义化组件的好处,监听(onCommentInputSubmit 事件,并且交给 this 去处理(handle)。这种规范在多人协作的时候也会非常方便。

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

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

如果所有的组件都按这种顺序来编写,那么维护起来就会方便很多,多人协作的时候别人理解代码也会一目了然。

实战分析:评论功能(五)

(本文未审核)

持久化评论

同样地,可以通过类似于用户名持久化的方式对评论列表内容进行持久化,让用户发布的评论在刷新页面以后依然可以存在。修改 src/CommentApp.js

  1. class CommentApp extends Component {
  2. constructor () {
  3. super()
  4. this.state = {
  5. comments: []
  6. }
  7. }
  8. componentWillMount () {
  9. this._loadComments()
  10. }
  11. _loadComments () {
  12. let comments = localStorage.getItem('comments')
  13. if (comments) {
  14. comments = JSON.parse(comments)
  15. this.setState({ comments })
  16. }
  17. }
  18. _saveComments (comments) {
  19. localStorage.setItem('comments', JSON.stringify(comments))
  20. }
  21. handleSubmitComment (comment) {
  22. if (!comment) return
  23. if (!comment.username) return alert('请输入用户名')
  24. if (!comment.content) return alert('请输入评论内容')
  25. const comments = this.state.comments
  26. comments.push(comment)
  27. this.setState({ comments })
  28. this._saveComments(comments)
  29. }
  30. ...

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

现在发布评论,然后刷新可以看到我们的评论并不会像以前一样消失。非常的不错,持久化评论的功能也完成了。

显示评论发布时间

现在我们给每条评论都加上发布的日期,并且在评论列表项上显示已经发表了多久,例如“1 秒前”、“30分钟前”,并且会每隔 5 秒进行更新。修改 src/CommentInput.js 当用户点击发布按钮的时候,传出去的评论数据带上评论发布的时间戳:

  1. ...
  2. handleSubmit () {
  3. if (this.props.onSubmit) {
  4. this.props.onSubmit({
  5. username: this.state.username,
  6. content: this.state.content,
  7. createdTime: +new Date()
  8. })
  9. }
  10. this.setState({ content: '' })
  11. }
  12. ...

在评论列表项上显示评论,修改 src/comment.js

  1. class Comment extends Component {
  2. static propTypes = {
  3. comment: PropTypes.object.isRequired
  4. }
  5. constructor () {
  6. super()
  7. this.state = { timeString: '' }
  8. }
  9. componentWillMount () {
  10. this._updateTimeString()
  11. }
  12. _updateTimeString () {
  13. const comment = this.props.comment
  14. const duration = (+Date.now() - comment.createdTime) / 1000
  15. this.setState({
  16. timeString: duration > 60
  17. ? `${Math.round(duration / 60)} 分钟前`
  18. : `${Math.round(Math.max(duration, 1))} 秒前`
  19. })
  20. }
  21. render () {
  22. return (
  23. <div className='comment'>
  24. <div className='comment-user'>
  25. <span>{this.props.comment.username} </span>:
  26. </div>
  27. <p>{this.props.comment.content}</p>
  28. <span className='comment-createdtime'>
  29. {this.state.timeString}
  30. </span>
  31. </div>
  32. )
  33. }
  34. }

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

再看看页面显示:

贰、React小书_阶段练习_day12-2 - 图17

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

  1. ...
  2. componentWillMount () {
  3. this._updateTimeString()
  4. this._timer = setInterval(
  5. this._updateTimeString.bind(this),
  6. 5000
  7. )
  8. }
  9. ...

这样就可以做到评论的发布时间自动刷新了,到这里前 4 个需求都已经完成了。

实战分析:评论功能(六)

(本文未审核)

删除评论

现在发布评论,评论不会消失,评论越来越多并不是什么好事。所以我们给评论组件加上删除评论的功能,这样就可以删除不想要的评论了。修改 src/Comment.jsrender 方法,新增一个删除按钮:

  1. ...
  2. render () {
  3. const { comment } = this.props
  4. return (
  5. <div className='comment'>
  6. <div className='comment-user'>
  7. <span className='comment-username'>
  8. {comment.username}
  9. </span>:
  10. </div>
  11. <p>{comment.content}</p>
  12. <span className='comment-createdtime'>
  13. {this.state.timeString}
  14. </span>
  15. <span className='comment-delete'>
  16. 删除
  17. </span>
  18. </div>
  19. )
  20. }
  21. ...

我们在后面加了一个删除按钮,因为 index.css 定义了样式,所以鼠标放到特定的评论上才会显示删除按钮,让用户体验好一些。

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

贰、React小书_阶段练习_day12-2 - 图18

CommentCommentApp 之间隔了一个 CommentListComment 无法直接跟 CommentApp 打交道,只能通过 CommentList 来转发这种删除评论的消息。修改 Comment 组件,让它可以把删除的消息传递到上一层:

  1. class Comment extends Component {
  2. static propTypes = {
  3. comment: PropTypes.object.isRequired,
  4. onDeleteComment: PropTypes.func,
  5. index: PropTypes.number
  6. }
  7. ...
  8. handleDeleteComment () {
  9. if (this.props.onDeleteComment) {
  10. this.props.onDeleteComment(this.props.index)
  11. }
  12. }
  13. render () {
  14. ...
  15. <span
  16. onClick={this.handleDeleteComment.bind(this)}
  17. className='comment-delete'>
  18. 删除
  19. </span>
  20. </div>
  21. )
  22. }

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

  1. class CommentList extends Component {
  2. static propTypes = {
  3. comments: PropTypes.array,
  4. onDeleteComment: PropTypes.func
  5. }
  6. static defaultProps = {
  7. comments: []
  8. }
  9. handleDeleteComment (index) {
  10. if (this.props.onDeleteComment) {
  11. this.props.onDeleteComment(index)
  12. }
  13. }
  14. render() {
  15. return (
  16. <div>
  17. {this.props.comments.map((comment, i) =>
  18. <Comment
  19. comment={comment}
  20. key={i}
  21. index={i}
  22. onDeleteComment={this.handleDeleteComment.bind(this)} />
  23. )}
  24. </div>
  25. )
  26. }
  27. }

当用户点击按钮的时候,Comment 组件会调用 props.onDeleteComment,也就是 CommentListhandleDeleteComment 方法。而 handleDeleteComment 会调用 CommentList 所接受的配置参数中的 props.onDeleteComment,并且把下标传出去。

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

  1. ...
  2. handleDeleteComment (index) {
  3. console.log(index)
  4. }
  5. render() {
  6. return (
  7. <div className='wrapper'>
  8. <CommentInput onSubmit={this.handleSubmitComment.bind(this)} />
  9. <CommentList
  10. comments={this.state.comments}
  11. onDeleteComment={this.handleDeleteComment.bind(this)} />
  12. </div>
  13. )
  14. }
  15. }
  16. ...

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

  1. ...
  2. handleDeleteComment (index) {
  3. const comments = this.state.comments
  4. comments.splice(index, 1)
  5. this.setState({ comments })
  6. this._saveComments(comments)
  7. }
  8. ...

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

现在就可以愉快地删除评论了。但是,你删除评论以后 5 秒钟后就会在控制台中看到报错了:

贰、React小书_阶段练习_day12-2 - 图19

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

  1. ...
  2. componentWillUnmount () {
  3. clearInterval(this._timer)
  4. }
  5. ...

这才算完成了第 5 个需求。

显示代码块

用户在的输入内容中任何以 ` 包含的内容都会用包含起来显示到页面上。这是一个 HTML 结构,需要往页面动态插入 HTML 结构我们只能用dangerouslySetInnerHTML了,修改src/Comment.js,把原来render()` 函数中的:

  1. <p>{comment.content}</p>

修改成:

  1. <p dangerouslySetInnerHTML={{
  2. __html: this._getProcessedContent(comment.content)
  3. }} />

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

  1. ...
  2. _getProcessedContent (content) {
  3. return content
  4. .replace(/`([\S\s]+?)`/g, '<code>$1</code>')
  5. }
  6. ...

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

  1. ...
  2. _getProcessedContent (content) {
  3. return content
  4. .replace(/&/g, "&amp;")
  5. .replace(/</g, "&lt;")
  6. .replace(/>/g, "&gt;")
  7. .replace(/"/g, "&quot;")
  8. .replace(/'/g, "&#039;")
  9. .replace(/`([\S\s]+?)`/g, '<code>$1</code>')
  10. }
  11. ...

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

这时候在评论框中输入:

这是代码块 console.log,这是

正常内容

。>

然后点击发布,看看效果:

贰、React小书_阶段练习_day12-2 - 图20

我们安全地完成了第 6 个需求。到目前为止,第二阶段的实战已经全部完成,你可以在这里找到完整的代码。

总结

到这里第二阶段已经全部结束,我们已经掌握了全部 React.js 实战需要的入门知识。接下来我们会学习两个相对比较高级的 React.js 的概念,然后进入 React-redux 的世界,让它们配合 React.js 来构建更成熟的前端页面。