Fullstack React 学习笔记(二)

Components

A time-logging app

本章我们要实现一个计时器,先看下界面

计时器涉及更多的交互性

正式开始

$ cd time_tracking_app
$ npm install
$ npm start

现在我们监听 http://localhost:3000

正式编码前,先来熟悉下组织架构

$ ls
README.md
data.json
nightwatch.json
node_modules/
package.json
public/
semantic.json
server.js
tests/

本章我们使用了 server.js,并没有继续沿用上一章预编译的 live-server,关于 server 的具体细节下一章再表。此外,这里还用到了 data.json 来存储,它的行为更接近数据库,通过 JSON 文件可以实现数据持久化存储。

$ cd public
$ ls

favicon.ico
index.html
js/
semantic/
style.css
vendor/

我们再次使用了 SemanticUI 作为样式,关于具体的组件我们定义在 js/app.js 文件中。index.html 是整个项目的中心,其中引用了即将定义的组件。

<script
    type="text/babel" 
    data-plugins="transform-class-properties" 
    src="./js/app.js"
></script>

将 App 拆分成若干组件

本着单一职责原则,我们将 App 拆分成了若干独立组件,每个组件只负责自己的相关业务

相关的层级关系如下:

  • TimersDashboard 父层容器
    • EditableTimerList 显示一个时间列表
      • EditableTimer 展示一个时间界面或编辑界面
        • Timer 展示一个给定的时间
        • TimerForm 展示一个给定的时间编辑界面
    • ToggleableTimerForm 展示一个创建新 timer 的界面
      • TimerForm(不显示)展示一个新 timer 创建界面

从草图开始构建 React App

我们的 App 将从 Server 获取数据来渲染界面

这里要记住数据流是从父组件流向子组件(from parent to child),事件一般定义在父组件中,向下传递给子组件(from parent to child)

通常从草案到真实的 React 需要经历以下几个过程:

  1. 将 App 在设计阶段就拆分成若干组件
  2. 构建静态版本的 App(即硬编码数据源)
  3. 觉得哪些应该是 stateful 的
  4. 决定每个 state 应该存活于哪层组件中
  5. 硬编码初始 state
  6. 添加反转的数据流
  7. 添加服务器通信

接下来我们就按如上顺序来实现一个 React 版本的计时器

Step 2:构建一个静态版本的 App

TimersDashboard

先由 TimersDashboard 来开刀,它也是整个计时器的最外层(顶层)组件,它向 ToggleableTimerForm 传递了一个 prop:isOpen,用来决定渲染成 "+" 还是 TimerForm

class TimersDashboard extends React.Component {
  render() {
    return (
      <div className='ui three column centered grid'>
        <div className='column'>
          <EditableTimerList />
          <ToggleableTimerForm
            isOpen={true}
          />
        </div>
      </div>
    );
  }
}

接着定义了 EditableTimerList 组件,它渲染了两个 EditableTimer 组件

class EditableTimerList extends React.Component {
  render() {
    return (
      <div id='timers'>
        <EditableTimer
          title='Learn React'
          project='Web Domination'
          elapsed='8986300'
          runningSince={null}
          editFormOpen={false}
        />
        <EditableTimer
          title='Learn extreme ironing'
          project='World Domination'
          elapsed='3890985'
          runningSince={null}
          editFormOpen={true}
        />
      </div>
    );
  }
}

这两个 EditableTimer 组件唯一的不同就是 editFormOpen 的值不同,即展示的样式也不相同。

EditableTimer

EditableTimer 基于 editFormOpen 返回一个 TimerForm 或 Timer

class EditableTimer extends React.Component {
  render() {
    if (this.props.editFormOpen) {
      return (
        <TimerForm
          title={this.props.title}
          project={this.props.project}
        />
      );
    } else {
      return (
        <Timer
          title={this.props.title}
          project={this.props.project}
          elapsed={this.props.elapsed}
          runningSince={this.props.runningSince}
        />
      );
    }
  }
}

无论是 title 还是 project 都是从父组件(props)获取到

TimerForm

我们来构建一个 HTML 表格,它有两个输入框,第一个可以输入标题,第二个输入具体的工程,最后底部是一对按钮:

class TimerForm extends React.Component {
  render() {
    const submitText = this.props.title ? 'Update' : 'Create';
    return (
      <div className='ui centered card'>
        <div className='content'>
          <div className='ui form'>
            <div className='field'>
              <label>Title</label>
              <input type='text' defaultValue={this.props.title} />
            </div>
            <div className='field'>
              <label>Project</label>
              <input type='text' defaultValue={this.props.project} />
            </div>
            <div className='ui two bottom attached buttons'>
              <button className='ui basic blue button'>
                {submitText}
              </button>
              <button className='ui basic red button'>
                Cancel
              </button>
            </div>
          </div>
        </div>
      </div>
    );
  }
}

注意 input 标签,指定了它的类型是 text,并且使用了 React 属性 defaultValue,当表单被编辑时,会设定一个默认值。

稍后 ToggleableTimerForm 也使用 TimerForm 来创建 timers,那时不会传递给 TimerForm 任何 props(即 this.props.title 和 this.props.project)所以文本框将返回 undefined 并保持为空的状态。

这里我们根据 this.props.title 来判断按钮是 "Create" 还是 "Update"。

ToggleableTimerForm

接着来看下 ToggleableTimerForm,回忆下该组件是 TimerForm 的封装组件,它的形态要么是一个 "+",要么是一个 TimerForm,主要由父组件传递进来的 prop 决定(isOpen)

class ToggleableTimerForm extends React.Component {
  render() {
    if (this.props.isOpen) {
      return (
        <TimerForm />
      );
    } else {
      return (
        <div className='ui basic content center aligned segment'>
          <button className='ui basic button icon'>
            <i className='plus icon' />
          </button>
        </div>
      );
    }
  }
}

这里的 TimerForm 用作创建新 Timer,因此不会传递任何 props 给它

Timer

最后一个是 Timer 组件,主要用来显示计时界面,不用在意 div 和 span 元素,以及 className 属性,它们主要与样式有关

class Timer extends React.Component {
  render() {
    const elapsedString = helpers.renderElapsedString(this.props.elapsed);
    return (
      <div className='ui centered card'>
        <div className='content'>
          <div className='header'>
            {this.props.title}
          </div>
          <div className='meta'>
            {this.props.project}
          </div>
          <div className='center aligned description'>
            <h2>
              {elapsedString}
            </h2>
          </div>
          <div className='extra content'>
            <span className='right floated edit icon'>
              <i className='edit icon' />
            </span>
            <span className='right floated trash icon'>
              <i className='trash icon' />
            </span>
          </div>
        </div>
        <div className='ui bottom attached blue basic button'>
          Start
        </div>
      </div>
    );
  }
}

elapsed 代表了流逝的时间(毫秒表示),为了变成人类可读的时间格式,这里使用了 helpers.js 中的 renderElapsedString() 方法。

渲染(Render)整个 App

ReactDOM.render(
  <TimersDashboard />,
  document.getElementById('content')
);

保存 app.js 并启动服务(npm start)

修改一些 props 并刷新,观察界面渲染如何变化

  • 修改传递给 ToggleableTimerForm 的 props(由 true 变成 flase),观察 “+” 按钮如何渲染
  • 翻转两个 editFormOpen 的值(true<=>false),观察对应的两个 EditableTimer 是否也发生翻转变化

Step 3:决定哪些属性可以是 state

为了赋予我们的 App 具有交互性,必须将其静态属性转化为可变属性。第一步先确定哪些是可变的。

State 标准

可以根据如下标准来判断数据是否为 stateful

  1. 如果父组件通过 props 传递给它,那么它一定不是 state
  2. 它能随时间改变吗?如果不能,一定不是 state,关键在于它是否能被修改
  3. 它能由组件中的其他 state 或 props 计算出来吗?如果可以,那一定不是 state

应用以上标准

TimersDashboard

  • isOpen 对于 ToggleableTimerForm

    Stateful 这里定义的数据,它可以随时被修改,并且不能通过其他 state 或 props 计算出来

EditableTimerList

  • Timer properties

    Stateful 定义在该组件中的数据,它可以随时被修改,并且不能通过其他 state 或 props 计算出来

EditableTimer

  • editFormOpen 对于一个指定的定时器

    Stateful 定义在该组件中的数据,它可以随时被修改,并且不能通过其他 state 或 props 计算出来

Timer

  • Timer properties

    在此环境中,这些都是 not stateful,它由父组件传递而来

TimerForm

我们可能会试图认为 TimerForm 不管理任何 state 数据,比如 title 和 project 都是由父组件传递而来。但是稍后我们会看到,表单(forms)管理了自己的 state

因此在 TimerForm 外,我们定义了如下 state 数据:

+ 定时器列表和针对每个 Timer 的属性
+ 计时器的编辑表单是否打开
+ 创建的表单是否打开

Step 4:确定每个 state 应该存在于哪个组件中

虽然已经确定了哪些数据是 state,但它们的位置却并不合适,下面的任务是确定 state 最佳的位置

Thinking in React33 这篇文章提供了绝佳的指引

对于每个 state:

  • 标识出基于 state,每个组件渲染出的内容
  • 找出一个共有的父组件(state 需要在这个继承层级之中)
  • 位于较高层级的组件将拥有 state
  • 如果找不到合适的组件去放置一个 state,那么就创建一个新的组件来放置,然后再添加到组件层级结构中去

The list of timers and properties of each timer

在静态版本,我们认为 TimersDashboard 不使用 state,EditableTimerList 才是第一个使用的组件。同时 ToggleableTimerForm 也没有 state,这样 EditableTimerList 应该是共有的父组件。

虽然这种情况下,计时器的显示、修改、删除都没问题,但是创建操作呢?尽管 ToggleableTimerForm 不需要 state 来渲染,但它能影响 state,它能够插入一个新 timer。并且将数据向上传播到 TimersDashboard

因此,TimersDashboard 才是真正的共有父组件。它会向下传递 timer state 来渲染 EditableTimerList。它也能处理来自 EditableTimerList 和 ToggleableTimerForm 组件的修改与创建操作,以此来修改 state。新的 state 将通过 EditableTimerList 向下流动。

timer 的编辑开关是否被打开

一般情况下,这个 state 应该存储在每个 EditableTimer 中,但是如果想限制一次只能编辑一个表单,就需要提到父级组件中去,即放到 EditableTimerList 里。如果是编辑的时候也不许创建,那么就要继续向上走,放到 TimersDashboard 里,再去做逻辑判断。

Visibility of the create form

TimersDashboard 并不关心 ToggleableTimerForm 的样式,所以相关 state 放到 ToggleableTimerForm 内部即可。

总结下:我们在三个不同组件中存在三个 state:

  • TimersDashboard 拥有并管理着 Timer 数据
  • 每个 EditableTimer 管理着 timer edit form 相关的 state
  • ToggleableTimerForm 管理着自己的 state,将决定自己的显示样式

Step 5:硬编码初始 states

目前我们还没有处理与 server 连接的工作,就先用一些硬编码来填充 state,下一章再来完成与服务器连接的相关工作。

向 TimersDashboard 添加 state

class TimersDashboard extends React.Component {
  state = {
    timers: [
      {
        title: 'Practice squat',
        project: 'Gym Chores',
        id: uuid.v4(),
        elapsed: 5456099,
        runningSince: Date.now(),
      },
      {
        title: 'Bake squash',
        project: 'Kitchen Chores',
        id: uuid.v4(),
        elapsed: 1273998,
        runningSince: null,
      },
    ],
  };

  render() {
    return (
      <div className='ui three column centered grid'>
        <div className='column'>
          <EditableTimerList
            timers={this.state.timers}
          />
          <ToggleableTimerForm />
        </div>
      </div>
    );
  }
}

这里我们使用了 Babel 插件 transform-class-properties 语法完成了对 state 的初始化,uuid.v4() 生成了一串通用唯一标识符

在 EditableTimerList 中从 props 中接收数据

class EditableTimerList extends React.Component {
  render() {
    const timers = this.props.timers.map((timer) => (
      <EditableTimer
        key={timer.id}
        id={timer.id}
        title={timer.title}
        project={timer.project}
        elapsed={timer.elapsed}
        runningSince={timer.runningSince}
      />
    ));
    return (
      <div id='timers'>
        {timers}
      </div>
    );
  }
}

Props vs. state

可以认为 props 是 state 的一种不可变版本,它在 TimersDashboard 是可变 state,而作为不可变的 props 向下传递给了 EditableTimerList。

可以理解为:一些父组件管理着 State,数据流通过 props 传递给了子组件。如果 state 更新了,对应的组件需要重新调用 render() 执行再次渲染操作,并会层层引发子组件的渲染。

向 EditableTimer 中添加 state

class EditableTimer extends React.Component {
  state = {
    editFormOpen: false,
  };

  render() {
    if (this.state.editFormOpen) {
      return (
        <TimerForm
          id={this.props.id}
          title={this.props.title}
          project={this.props.project}
        />
      );
    } else {
      return (
        <Timer
          id={this.props.id}
          title={this.props.title}
          project={this.props.project}
          elapsed={this.props.elapsed}
          runningSince={this.props.runningSince}
        />
      );
    }
  }
}

Timer 组件中没有 state,都是由父组件传递下来的数据完成渲染

向 ToggleableTimerForm 中添加 state

class ToggleableTimerForm extends React.Component {
  state = {
    isOpen: false,
  };

接着定义一个函数去触发表单的创建

handleFormOpen = () => {
    this.setState({ isOpen: true });
  };

  render() {

最后我们把该函数与按钮关联起来

render() {
    if (this.state.isOpen) {
      return (
        <TimerForm />
      );
    } else {
      return (
        <div className='ui basic content center aligned segment'>
          <button
            className='ui basic button icon'
            onClick={this.handleFormOpen}
          >
            <i className='plus icon' />
          </button>
        </div>
      );
    }
  }
}

向 TimerForm 中添加 state

回想下 TimerForm 包含两个输入框

它是由用户进行输入修改,在 React 中,所有针对组件的修改操作都应该由 React 来处理,并保存在 state 中。这样就能保证用户通过 DOM 与之交互的虚拟组件与后台 React 组件的 state 匹配。

首先为 state 赋予初始值

class TimerForm extends React.Component {
  state = {
    title: this.props.title || '',
    project: this.props.project || '',
  };

如果是新创建的表单,这些 props 都为 undefined,我们传入了空字符,保持输入框为空。

之前在输入框我们使用了 defaultValue 来连接 props,它仅在输入框第一次渲染时赋值,因此我们这里改用 value 来持续追踪 state 的变化

<div className='field'>
  <label>Title</label>
    <input
      type='text'
      value={this.state.title}
    />
</div>

目前还没有任何方法可以让用户修改这个状态,输入框准备开始与组件的 state 同步,但是在用户做出修改那一刻,输入框将失去与组件 state 的同步。

我们可以通过 React 提供的,针对 input 元素的 onChange 属性来修正。可以定义一个函数赋予该属性,当输入框被修改就调用这个函数

<div className='field'>
  <label>Title</label>
  <input
    type='text'
    value={this.state.title}
    onChange={this.handleTitleChange}
  />
</div>
<div className='field'>
  <label>Project</label>
  <input
    type='text'
    value={this.state.project}
    onChange={this.handleProjectChange}
  />
</div>

这两个函数接收一个 event 对象,可以从中得到输入框的内容 target.value

handleTitleChange = (e) => {
    this.setState({ title: e.target.value });
  };

  handleProjectChange = (e) => {
    this.setState({ project: e.target.value });
  };

以上我们介绍了 React 中输入框的 value 属性与 onChange 属性。

目前已经通过 state props 组装好了向下的数据管道,保存 app.js 刷新下浏览器,点击 “+” 观察是否增加了一个新的创建界面

Step 6 添加反转的数据流

之前提到,子组件想要与父组件交流,只能通过父组件传递给子组件的函数实现,具体表现为:父组件定义好函数后,通过 props 传递给子组件,子组件执行此函数,并将要传递的信息放到函数参数中去。

在本例中有两个地方需要子组件向父组件传递消息

  • TimerForm 需要传递 create 和 update 事件(create 发生在 ToggleableTimerForm 组件下,而更新则发生在 EditableTimer 组件下)。这些事件都需要最终传递到 TimersDashboard 组件中去
  • Timer 组件由相当多的行为需要传递,比如 delete 和 edit 操作,以及 start 和 stop 逻辑

TimerForm

TimerForm 需要处理两个事件:

  • 当表单被提交时(创建或更新定时器)
  • 当 "Cancel" 按钮被点击时(关闭表单)

TimerForm 通过 props 从父组件接收两个函数来处理这些事件

  • props.onFormSubmit()
  • props.onFormClose()

这授权父组件来决定事件发生时的行为应该是什么

<div className='ui two bottom attached buttons'>
  <button
    className='ui basic blue button'
    onClick={this.handleSubmit}
  >
    {submitText}
  </button>
  <button
    className='ui basic red button'
    onClick={this.props.onFormClose}
  >
    Cancel
  </button>
</div>

这里执行的 onClick={this.handleSubmit} 其实也是父组件传递的 onFormSubmit() 函数,只不过封装了下

handleSubmit = () => {
  this.props.onFormSubmit({
    id: this.props.id,
    title: this.state.title,
    project: this.state.project,
  });
};

最后我们根据 this.props.id 是否存在,来判断第一个按钮的标题

render() {
  const submitText = this.props.id ? 'Update' : 'Create';

ToggleableTimerForm

来追踪下由 TimerForm 发出的 create 事件,它的第一站就是 ToggleableTimerForm,这里作为父组件之一要传递给 TimerForm 两个函数: onFormSubmitonFormClose,后者就定义与此组件

class ToggleableTimerForm extends React.Component {
  state = {
    isOpen: false,
  };

  // Inside ToggleableTimerForm
  handleFormOpen = () => {
    this.setState({ isOpen: true });
  };

  handleFormClose = () => {
    this.setState({ isOpen: false });
  };

  handleFormSubmit = (timer) => {
    this.props.onFormSubmit(timer);
    this.setState({ isOpen: false });
  };

  render() {
    if (this.state.isOpen) {
      return (
        <TimerForm
          onFormSubmit={this.handleFormSubmit}
          onFormClose={this.handleFormClose}
        />
      );
    } else {

onFormSubmit 函数依然是定义在父组件中传递下来的,ToggleableTimerForm 组件在这里仅起一个消息代理的角色。

调用 onFormSubmit() 的结果不会影响表单的关闭,执行 onFormSubmit() 与服务器之间的通讯是异步进行的。因此如果发生服务器连接错误,最好能显示在页面上通知用户

TimersDashboard

这是最顶层的父组件,在这里我们将定义那些叶子组件中的事件逻辑。首先来处理提交操作,事件发生时,它有可能是创建也可能是更新:

  • handleCreateFormSubmit() 处理 creates 事件,并传递给 ToggleableTimerForm 组件
  • handleEditFormSubmit() 处理 update 事件,并传递给 EditableTimerList 组件

这两个函数都沿着各自的组件层次结构向下传递移动,先来实现创建操作:

// Inside TimersDashboard
handleCreateFormSubmit = (timer) => {
  this.createTimer(timer);
};

createTimer = (timer) => {
  const t = helpers.newTimer(timer);
  this.setState({
    timers: this.state.timers.concat(t),
  });
};

render() {
  return (
    <div className='ui three column centered grid'>
      <div className='column'>
        <EditableTimerList
          timers={this.state.timers}
        />
        <ToggleableTimerForm
          onFormSubmit={this.handleCreateFormSubmit}
        />
      </div>
    </div>
  );
}

这里我们通过 timers: this.state.timers.concat(t) 向 timers 数组里添加新的 timer

完成创建 timer 之后保存 app.js,刷新浏览器看下效果,填写 Title 和 Project,点击 create 按钮,观察新的计时器是否添加成功

更新 Timers

说完了 create,接下来进行 update 操作,但现在还没有对 timer 添加编辑功能,所以还没办法显示一个可编辑的表单

为 Timer 添加编辑能力

{/* Inside Timer.render() */}
<div className='extra content'>
  <span
    className='right floated edit icon'
    onClick={this.props.onEditClick}
  >
    <i className='edit icon' />
  </span>
  <span className='right floated trash icon'>
    <i className='trash icon' />
  </span>
</div>

更新 EditableTimer

现在来更新 EditableTimer,它能够显示一个 TimerForm 或一个独立的 Timer;接着为这两个子组件传递不同的函数:

  • 对于 TimerForm 我们处理关闭和提交
  • 对于 Timer 我们处理 edit 按钮操作
handleEditClick = () => {
  this.openForm();
};

handleFormClose = () => {
  this.closeForm();
};

handleSubmit = (timer) => {
  this.props.onFormSubmit(timer);
  this.closeForm();
};

closeForm = () => {
  this.setState({ editFormOpen: false });
};

openForm = () => {
  this.setState({ editFormOpen: true });
};

接着作为 props 传递下去

render() {
  if (this.state.editFormOpen) {
    return (
      <TimerForm
        id={this.props.id}
        title={this.props.title}
        project={this.props.project}
        onFormSubmit={this.handleSubmit}
        onFormClose={this.handleFormClose}
      />
    );
  } else {
    return (
      <Timer
        id={this.props.id}
        title={this.props.title}
        project={this.props.project}
        elapsed={this.props.elapsed}
        runningSince={this.props.runningSince}
        onEditClick={this.handleEditClick}
      />
    );
  }
}

是不是很熟悉,EditableTimer 与 ToggleableTimerForm 所做的事情都差不多,它们是 TimerForm 与 TimersDashboard 沟通的桥梁。

更新 EditableTimerList

继续向上层移动到 EditableTimerList,该组件也仅仅是个消息传递的中介而已

// Inside EditableTimerList
const timers = this.props.timers.map((timer) => (
  <EditableTimer
    key={timer.id}
    id={timer.id}
    title={timer.title}
    project={timer.project}
    elapsed={timer.elapsed}
    runningSince={timer.runningSince}
    onFormSubmit={this.props.onFormSubmit}
  />
));
// ...

在 TimersDashboard 中定义 onEditFormSubmit() 方法

对于更新操作,我们需要遍历 timers 数组直到找出更新了的 timer,上一章介绍了 state 不能直接更新,必须使用 setState()

这里使用了 map() 来遍历 timer 数组,如果 timer 的 id 与要新提交的 timer 匹配,那么我们就返回一个新的数组,里面包含新修改了属性的 timer,这个新数组将传递给 setState() 方法

// Inside TimersDashboard
handleEditFormSubmit = (attrs) => {
  this.updateTimer(attrs);
};

createTimer = (timer) => {
  const t = helpers.newTimer(timer);
  this.setState({
    timers: this.state.timers.concat(t),
  });
};

updateTimer = (attrs) => {
  this.setState({
    timers: this.state.timers.map((timer) => {
      if (timer.id === attrs.id) {
        return Object.assign({}, timer, {
          title: attrs.title,
          project: attrs.project,
        });
      } else {
        return timer;
      }
    }),
  });
};

Object.assign() 接收三个参数,第一个是新对象;第二个是要复制的范本;第三个是要在第二个基础上做些修改,最终添加到第一个参数中返回。该方法返回的是一个崭新的对象,并不是原对象的引用。这里我们将 state 视为不可变的。不会修改任意 state 下的对象

接着讲提交操作作为 prop 传递下去

{ /* Inside TimersDashboard.render() */}
<EditableTimerList
  timers={this.state.timers}
  onFormSubmit={this.handleEditFormSubmit}
/>

现在保存下 app.js,刷新下浏览器,创建、更新、取消等操作都已经实现了

最后还剩实现删除按钮(删除一个 timer),以及实现 start/stop 按钮(计时逻辑)

删除 timers

为 Timer 添加删除事件处理

在 Timer 中,我们定义了一个函数来处理删除事件:

class Timer extends React.Component {
  handleTrashClick = () => {
    this.props.onTrashClick(this.props.id);
  };

  render() {

接着使用 onClick 将“删除”按钮绑定这个函数

{/* Inside Timer.render() */}
<div className='extra content'>
  <span
    className='right floated edit icon'
    onClick={this.props.onEditClick}
  >
    <i className='edit icon' />
  </span>
  <span
    className='right floated trash icon'
    onClick={this.handleTrashClick}
  >
    <i className='trash icon' />
  </span>
</div>

现在还没有定义 onTrashClick 函数,可以想象这个函数同样是定义在父组件中(TimersDashboard),我们同样需要 id 来找出哪个 timer 需要删除。

通过 EditableTimer 传递

// Inside EditableTimer
    } else {
      return (
        <Timer
          id={this.props.id}
          title={this.props.title}
          project={this.props.project}
          elapsed={this.props.elapsed}
          runningSince={this.props.runningSince}
          onEditClick={this.handleEditClick}
          onTrashClick={this.props.onTrashClick}
        />
      );
    }

通过 EditableTimerList 传递

// Inside EditableTimerList.render()
    const timers = this.props.timers.map((timer) => (
      <EditableTimer
        key={timer.id}
        id={timer.id}
        title={timer.title}
        project={timer.project}
        elapsed={timer.elapsed}
        runningSince={timer.runningSince}
        onFormSubmit={this.props.onFormSubmit}
        onTrashClick={this.props.onTrashClick}
      />
    ));

在 TimersDashboard 中实现删除操作

// Inside TimersDashboard
  handleEditFormSubmit = (attrs) => {
    this.updateTimer(attrs);
  };

  handleTrashClick = (timerId) => {
    this.deleteTimer(timerId);
  };

现在来使用 Array 的 filter() 方法实现 deleteTimer() 函数

// Inside TimersDashboard
  deleteTimer = (timerId) => {
    this.setState({
      timers: this.state.timers.filter(t => t.id !== timerId),
    });
  };

最后通过 prop 将 handleTrashClick() 传递下去

{/* Inside TimersDashboard.render() */}
  <EditableTimerList
    timers={this.state.timers}
    onFormSubmit={this.handleEditFormSubmit}
    onTrashClick={this.handleTrashClick}
  />

Array 的 filter() 通过筛选 timerId 将要保留的 timer 统统放进一个新的数组返回

保存 app.js 然后刷新浏览器,尝试删除操作

添加计时的功能

最简单的方法就是每秒去更新所有的 timer,但万一 App 关闭了呢?这些计时器还能继续运行吗?

我们这里给 timer 添加了一个 runningSince 和一个 elapsed 属性,在新建 timer 初始化时将 elapsed 设为 0。当用户点击 "Start" 按钮后,并不着急增加 elapsed,而是设置 runningSince 为当前开始的时间。

当点击 "Stop" 按钮结束时,我们把这段流逝的时间添加在 elapsed 中(结束时刻 - 开始时刻),并将 runningSince 设置为 null

如此一来,通过如下公式就能得到 timer 的具体时间:

Date.now() - runningSince + elapsed

为了在网页上真正像计时器那样运行,我们需要 React 持续不断地去渲染这些 timer;此时就要请出 React 的 forceUpdate() 方法,它会强制 React 的组件重新进行渲染,我们可以定时去调用它来实现平滑的页面动态时间展示。

为 Timer 添加一个定期执行的 forceUpdate() 函数

helpers.renderElapsedString() 函数接收两个参数(elapsed,runningSince)来计算某一刻的时间,它使用了上面提到的公式,只不过最终使用 millisecondsToHuman() 函数返回一个易读的时间格式字符串(HH:MM:SS)

在组件加载之后添加一个定期执行的 forceUpdate() 函数

class Timer extends React.Component {
  componentDidMount() {
    this.forceUpdateInterval = setInterval(() => this.forceUpdate(), 50);
  }

  componentWillUnmount() {
    clearInterval(this.forceUpdateInterval);
  }

  handleTrashClick = () => {
    this.props.onTrashClick(this.props.id);
  };

  render() {
    const elapsedString = helpers.renderElapsedString(
      this.props.elapsed, this.props.runningSince
    );
    return (

这里使用了 Javascript 的 setInterval() 函数,它会每隔 50 毫秒执行一次 forceUpdate(),组件也会每隔 50 毫秒根据当前时间重新渲染一次,最后将 setInterval() 的返回值保存在 this.forceUpdateInterval

componentWillUnmount()中,使用 clearInterval() 停止了 this.forceUpdateInterval,这通常发生在 timer 将要被删除之时,我们希望在 timer 被删除后,不再继续调用 forceUpdate() 了。

50 毫秒是一个比较合适的间隔

保存 app.js 试着刷新浏览器,第一个计时器开始计时了。

添加启动和停止功能

理论上应该把这部分功能赋予 Timer,但为了职责明晰,我们将这部分功能抽象出来,让 button 按钮变成独立的 React 组件。

向 Timer 添加计时器动作

先来修改下 Timer,放置我们的新组件 TimerActionButton。这个按钮需要知道计时器是否运行;还需要能够传播两个事件 onStartClick()onStopClick(),这两个事件最终是要向上传递到 TimerDashboard 中;最后还需要能修改 runningSince 属性。

首先添加事件处理函数(具体定义在父组件)

// Inside Timer
  componentWillUnmount() {
    clearInterval(this.forceUpdateInterval);
  }

  handleStartClick = () => {
    this.props.onStartClick(this.props.id);
  };

  handleStopClick = () => {
    this.props.onStopClick(this.props.id);
  };
  // ...

在 Timer 该显示按钮的位置,放置了刚定义的新按钮组件 TimerActionButton:

        {/* At the bottom of `Timer.render()`` */}
        <TimerActionButton
          timerIsRunning={!!this.props.runningSince}
          onStartClick={this.handleStartClick}
          onStopClick={this.handleStopClick}
        />
      </div>
    );

取反运算符会自动将各种类型的对象包括 null 其转为布尔值,两次取反让结果正常

创建 TimerActionButton 组件

class TimerActionButton extends React.Component {
  render() {
    if (this.props.timerIsRunning) {
      return (
        <div
          className='ui bottom attached red basic button'
          onClick={this.props.onStopClick}
        >
          Stop
        </div>
      );
    } else {
      return (
        <div
          className='ui bottom attached green basic button'
          onClick={this.props.onStartClick}
        >
          Start
        </div>
      );
    }
  }
}

通过 EditableTimer 和 EditableTimerList 传递事件

首先是 EditableTimer

// Inside EditableTimer
    } else {
      return (
        <Timer
          id={this.props.id}
          title={this.props.title}
          project={this.props.project}
          elapsed={this.props.elapsed}
          runningSince={this.props.runningSince}
          onEditClick={this.handleEditClick}
          onTrashClick={this.props.onTrashClick}
          onStartClick={this.props.onStartClick}
          onStopClick={this.props.onStopClick}
        />
      );
    }

接着是 EditableTimerList:

// Inside EditableTimerList
    const timers = this.props.timers.map((timer) => (
      <EditableTimer
        key={timer.id}
        id={timer.id}
        title={timer.title}
        project={timer.project}
        elapsed={timer.elapsed}
        runningSince={timer.runningSince}
        onFormSubmit={this.props.onFormSubmit}
        onTrashClick={this.props.onTrashClick}
        onStartClick={this.props.onStartClick}
        onStopClick={this.props.onStopClick}
      />
    ));

最后在 TimersDashboard 中定义这些函数,同样通过遍历 timers 数组找出对应的 timer 来设置 runningSince 属性。

首先定义了事件处理函数:

// Inside TimersDashboard
  handleTrashClick = (timerId) => {
    this.deleteTimer(timerId);
  };

  handleStartClick = (timerId) => {
    this.startTimer(timerId);
  };

  handleStopClick = (timerId) => {
    this.stopTimer(timerId);
  };

接着完成了 startTimer()stopTimer() 的具体实现:

deleteTimer = (timerId) => {
    this.setState({
      timers: this.state.timers.filter(t => t.id !== timerId),
    });
  };

  startTimer = (timerId) => {
    const now = Date.now();

    this.setState({
      timers: this.state.timers.map((timer) => {
        if (timer.id === timerId) {
          return Object.assign({}, timer, {
            runningSince: now,
          });
        } else {
          return timer;
        }
      }),
    });
  };

  stopTimer = (timerId) => {
    const now = Date.now();

    this.setState({
      timers: this.state.timers.map((timer) => {
        if (timer.id === timerId) {
          const lastElapsed = now - timer.runningSince;
          return Object.assign({}, timer, {
            elapsed: timer.elapsed + lastElapsed,
            runningSince: null,
          });
        } else {
          return timer;
        }
      }),
    });
  };

最后将事件处理函数作为 props 传递给子组件

        {/* Inside TimerDashboard.render() */}
          <EditableTimerList
            timers={this.state.timers}
            onFormSubmit={this.handleEditFormSubmit}
            onTrashClick={this.handleTrashClick}
            onStartClick={this.handleStartClick}
            onStopClick={this.handleStopClick}
          />

当 startTimer 发生时,相关 timer 的 runningSince 属性会被设置为当前时间,stopTimer 计算了 lastElapsed,它是距离开始时刻到现在所累计的时间,最终一并累加到 elapsed 中去,最后设置 runningSince 为 null,停止计时。

保存 app.js 刷新下浏览器,现在你能创建、更新、删除计时器了,开始计时后再停止计时,稍等片刻可以继续计时,这些功能都是正常的了。

不过,现在还没有引入持久化存储,刷新下网页,数据就又回归初始了,下一章我们将引入服务器来支持数据的持久化存储。

本章代码地址:https://github.com/walkingway/LearnReact


-EOF-

walkingway

Read more posts by this author.

Subscribe to Talk is cheap, Show me the world!

Get the latest posts delivered right to your inbox.

or subscribe via RSS with Feedly!