Fullstack React 学习笔记(三)

Components & Servers

上一章,我们将 Timers 的 State 管理放置在了 TimerDashboard 组件中,所有的数据流都是从顶端流向叶子节点,而事件则是从叶子组件向上通过函数调用传递给了父组件。

目前 TimersDashboard 还是硬编码初始 state,任何更改只能在窗口打开时生效,这一章我们需要与服务器交流,将『修改』持久化保存在服务器上。这里所有的更改都保存在 data.json 文件中。

为了帮助你熟悉本章的 API,这里使用到了 curl 工具

server.js

根目录下存在一个 server.js 文件,这是一个 Node.js 写的服务端,专门用来与计时器进行通信。

server.js 使用 data.json 作为它的存储。该服务器将会从 data.json 中读取和写入数据。如果你仔细观察的话,server.js 涵盖针对 data.js 的更新、删除、计时器启动/停止等全部操作。

下面先来熟悉下服务端的 API

The Server API

虽然我们的目标是通过服务端来控制 state,但我们并不打算将所有 state 管理完全转移到服务器上。相反,服务器将保持其 state(在 data.json 中),React 也保留它的 state(稍后我们将演示为什么在这两个地方保持 state 是可取的)。

如果我们在 React(客户端)执行了修改 state 的操作需要持久化存储,就需要通知服务端修改相同的 state。这样就保持了二者 state 的同步。

可以脑补一下,要发送给服务端的『写』操作如下:

  • 一个计时器的创建
  • 一个计时器的更新
  • 一个计时器的删除
  • 一个计时器的启动
  • 一个计时器的停止

而涉及到的『读取』操作只有一个:从服务端获取所有的计时器。

如果你不熟悉 REST API,请看这里的介绍 Beginners guide to creating a REST API

text/html endpoint

GET/

server.js 负责『响应』应用程序的请求,当浏览器访问 localhost:3000,服务器将返回 index.html 文件,并载入所有的 JavaScript/React code

注意:React 并不通过此条路径去请求服务器,浏览器仅仅通过 localhost:3000/ 来载入整个 App。而 React 仅仅和 JSON endpoints 通信

JSON endpoints

data.json 是一种 JSON 格式的文本,我们可以将 JavaScript 对象序列化为 JSON,这样就可以通过网络进行传输了。

data.json 包含了一个对象数组,在 server.js 中我们可以通过如下函数将 JSON 字符串转换成 JavaScript 数组对象

fs.readFile(DATA_FILE, function(err, data) { 
    const timers = JSON.parse(data);
    // ...
});
GET /api/timers

返回所有的计时器

POST /api/timers

接收一个 JSON body 包含 title、project 和 id 属性。用来添加一个新的 timer 对象到存储中

POST /api/timers/start

接收一个 JSON body 包含属性 id 和 start(时间戳),通过 id 找到对应的 timer,将其 runningSince 设置为 start

POST /api/timers/stop

接收一个 JSON body 包含属性 id 和 stop(时间戳),通过 id 找到对应的 timer,更新 elapsed(stop - runningSince),并设置 runningSince 为 null。

PUT /api/timers

接收一个 JSON body 包含属性 id 和 title / project,通过 id 找到对应的 timer,更新 title、project

DELETE /api/timers

接收一个 JSON body 包含属性 id,通过 id 找到对应的 timer 删除

Post 和 Put 区别在于:Post 不是幂等的,而 Put 是幂等的

Playing with the API

可以在浏览器输入 http://localhost:3000/api/timers 来做 GET 请求,返回结果如下

目前我们只能通过浏览器实现 GET 请求,对于写入数据---比如 starting 和 stopping 计时器,即 POST、PUT、DELETE 请求一般通过 curl 实现。

在命令行中输入如下指令:

$ curl -X GET localhost:3000/api/timers

-X 标志指定了所使用的 HTTP 方法,它的返回结果为:

[{"title":"Mow the lawn","project":"House Chores","elapsed":5456099,"id":"0a4a79\
cb-b06d-4cb1-883d-549a1e3b66d7"},{"title":"Clear paper jam","project":"Office Ch\
ores","elapsed":1273998,"id":"a73c1d19-f32d-4aff-b470-cea4e792406a"},{"title":"P\
onder origins of universe","project":"Life Chores","id":"2c43306e-5b44-4ff8-8753\
-33c35adbd06f","elapsed":11750,"runningSince":"1456225941911"}]

我们可以通过 POST 一个请求给 api/timers/start 来启动计时器。需要发送的参数为计时器的 id 和 start 时间戳。

$ curl -X POST \
-H 'Content-Type: application/json' \
-d '{"start":1456468632194,"id":"a73c1d19-f32d-4aff-b470-cea4e792406a"}' \ localhost:3000/api/timers/start

-H 标志设置了 HTTP 请求的 header:Content-Type 为 application/json';-d 标志设置了请求的 body,里面是具体的 JSON 内容。

现在你们已经熟悉了与 REST API 的交互方式,这里我们写了一个简单的库 client 来与 API 进行交互。

我们可以使用工具『jq』在命令行解析与处理 JSON 数据

Loading state from the server

现在把在 TimersDashboard 中硬编码初始化的 state 值移除,改由从 server 端获取。这里我们的 React app 通过 client 库与 server 端进行交互。

client 库的路径为 public/js/client.js ,下一节再详细讨论

可以在 React app 上使用 client.getTimers() 来访问服务端,该方法在网络中是异步发起请求的,即调用后立即返回并不会等待网络的请求结果,此外该方法没有返回值(并不会返回 timers 数组)。

可以给 getTimers() 传递一个函数作为参数,当成功从服务端接收到数据后, getTimers() 就会运行这个作为参数传递进来的函数

我们在父组件中初始化了 timers 属性,将其设置为空数组,这将允许所有组件挂载并执行初始渲染。接着向服务端发起请求来获取 timers 并设置 state:

class TimersDashboard extends React.Component {
  state = {
timers: [],
};
  componentDidMount() {
    this.loadTimersFromServer();
    setInterval(this.loadTimersFromServer, 5000);
  }
  loadTimersFromServer = () => {
    client.getTimers((serverTimers) => (
        this.setState({ timers: serverTimers })
      )
    ); 
  };
// ...

让我们来捋一遍时间线

  1. 在渲染之前,React 初始化组件,state 被设置为一个包含 timers 数组(空数组)的对象
  2. 首次渲染,React 调用 TimersDashboard 的 render() 方法,以此触发 EditableTimerList 和 ToggleableTimerForm 组件的渲染
  3. 子组件被渲染,因为传递给 EditableTimerList 一个空数据数组,因此只能拼接出 <div id='timers'>;而 ToggleableTimerForm 渲染出一个 "+"
  4. 首次渲染结束,随着子组件的渲染,首次渲染完成并且生成的 HTML 被写入 DOM
  5. componentDidMount 被调用,当所有组件都被装载后,定义在 TimersDashboard 组件中的 componentDidMount 方法就会被调用

与此同时,client.getTimers() 方法也会被调用,我们定义了一个 success 函数作为参数传递给了 getTimers(),而这个 success 函数有一个 serverTimers 参数,serverTimers 是服务端返回的 Timers 数组。最后通过 setState() 方法触发渲染。

我们还通过 setInterval() 确保 loadTimersFromServer() 方法每 5 秒执行一次,尽最大努力保持 client 与 server之间的同步。

5 秒钟的延迟对于某些消息应用是难以接受的,之后会介绍长轮询的概念,长轮询允许将更改立即推送到客户端

client

client.js 的第一个方法就是 getTimers(),其内部使用了 Fetch API 来执行 GET 请求

function getTimers(success) { return fetch('/api/timers', {
      headers: {
        Accept: 'application/json',
      },
    }).then(checkStatus)
      .then(parseJSON)
      .then(success);
  }

Fetch

尽管大部分浏览器都已经支持 Fetch 了,我们还是在工程中手动添加了 fetch.js

<!-- inside `head` tags index.html -->
<script src="vendor/fetch.js"></script>

仔细观察 fetch() 接收两个参数

  • 资源的路径地址
  • 将请求的参数封装成一个对象

默认情况下,使用 Fecth 发起一个 GET 请求,只需告诉 Fetch 向 /api/timers 发起一个 GET 请求,并传入 headers 一个参数即可。在这个唯一的参数中声明只接受 JSON 响应。在 fetch() 的末尾,我们链接了 .then() 语句

}).then(checkStatus)
      .then(parseJSON)
      .then(success);

要搞明白如何工作,首先先来熟悉下每个函数的作用

  • checkStatus():这个函数定义在 client.js 内部,它用来检查服务端是否返回错误,如有错误则打印在终端
  • parseJSON():这个函数也定义在 client.js 内部,用来将服务端返回的 JSON 对象解析为 Javascript 对象
  • success():这个函数是我们传递给 getTimers() 的一个参数,它将在服务器成功返回结果后执行

Fetch 返回的是一个 promise,他允许链接 then() 语句,拆解来看下具体步骤:

  1. checkStatus() 调用时,我们给他传递了 fetch() 方法的返回值
  2. checkStatus() 验证完返回的 JSON 结果后,没问题的话原封不动地把返回结果向下传递
  3. parseJSON() 开始执行,并接收上一步的 JSON 结果作为参数
  4. parseJSON() 执行解析,返回一个 JavaScript 数组对象(timers)
  5. success() 开始调用,并接收上一步的 JavaScript 数组对象(timers)作为参数

这里是关于 Fetchpromises 的更多介绍

以上介绍了关于从服务端读取数据,如果需要向服务端写入数据,就需要用到 POST 请求了

startTimer()/api/timers/start 发起了一个 POST 请求,服务端需要计时器的 id 和 start 时间戳

function startTimer(data) {
    return fetch('/api/timers/start', {
      method: 'post',
      body: JSON.stringify(data),
      headers: {
        'Accept': 'application/json',
        'Content-Type': 'application/json',
      },
    }).then(checkStatus);
}

在这里除了 headers 外,我们还给 fetch() 多传递了两个属性:

method: 'post',
body: JSON.stringify(data),
  • method:默认是 GET 请求,这里指定为 POST
  • body:HTTP 请求的实体,需要发送给服务端

startTimer(data) 接收一个 data 参数,这个 data 就是我们要封装在 body 中发送给服务端的数据,上面提到服务端需要计时器的 id 和 start 时间戳,这里 data 为:

{
    "id": "bc5ea63b-9a21-4233-8a76-f4bca9d0a042",
    "start": 1455584369113
}

Sending starts and stops to the server

现在我们回到 React App 的 TimersDashboard 组件,在其定义的启动计时器和停止计时器方法中加入向服务器发送请求的代码:

// Inside TimersDashboard
// ...
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;
      } 
    }),
  });

  client.startTimer(
    { id: timerId, start: now }
  );
};

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;
      }
    }),
  });
  
  client.stopTimer(
    { id: timerId, stop: now }
  );
}; 

render() {

你可能会有疑虑,为什么还在 React APP 端手动更新 state,不是可以从服务端 GET 然后更新吗?其实这样做也没一点问题,比如可以:

startTimer: function (timerId) { 
    const now = Date.now();
    
    client.startTimer(
        { id: timerId, start: now }
    ).then(loadTimersFromServer);
},

不这么做的理由是考虑到用户体验,因为用户按下 start/stop 按钮期望页面 UI 立即反馈,即 React 立即重新渲染,如果改从 server 端获取数据,考虑到网络延迟,UI 的交互会变得迟缓。

因此我们在从服务端获取到数据前就将客户端先行更新,服务端的更新可能要等待该请求跋山涉水到达后才能生效。

我们将这种更新方式称作『乐观更新』,如果服务端不允许包含某些特定字符,客户端如果不做判断的话,在自己先行更新完毕后,服务端拒绝更新,这就会导致二者出现差异,因此客户端和服务端保持一致的代码规则很重要;
另外在生产环境中,发起网络请求时可能会遭遇各种错误;

Sending creates, updates, and deletes to the server

接着继续在 TimersDashboard 组件里,在其定义的创建、更新、删除计时器方法中加入向服务器发送请求的代码:

// Inside TimersDashboard
// ...
createTimer = (timer) => {
    const t = helpers.newTimer(timer);
    this.setState({
        timers: this.state.timers.concat(t),
    });
    
    client.createTimer(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;
            }
    }), 
});

    client.updateTimer(attrs);
};

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

startTimer = (timerId) => {

创建计时器需要发送完整的 timer 对象,包括了 id,title,project;更新操作需要发送 id 和发送变化的属性;删除只需要 id 即可。

现在保存下 app.js,刷新浏览器,尝试去创建,更新,删除计时器,完成这些操作后关闭再打开浏览器,看看结果是不是已经可以持久化保存了。


-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!