# react-redux-course-lecture-2 **Repository Path**: reactor/react-redux-course-lecture-2 ## Basic Information - **Project Name**: react-redux-course-lecture-2 - **Description**: 从零开始学习 React & Redux 系列第二讲 - **Primary Language**: NodeJS - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2016-12-05 - **Last Updated**: 2020-12-19 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # 从 0 开始学 React 第二讲 ## 开始本课程 ```bash cd ~ mkdir -p worksapce/playground/react-redux-course cd ~/workspace/playground/react-redux-course git clone git@git.oschina.net:reactor/react-redux-course-lecture-2.git cd react-redux-course-lecture-2 npm i --registry=https://registry.npm.taobao.org npm start ``` ## 需求 1. 用户打开网页,系统能判断是否已登录,若未登录,则自动弹出登录框 2. 用户可以在登录的情况下查看到所有的资产列表 3. 用户可以通过一个搜索工具搜索过滤资产 4. 用户可以选择一个资产,然后输入申请说明,并向管理员提交资产申请 5. 用户可以查看自己的所有申请记录 此次我们就不再添加 `design.html` 文件了,直接开始项目的开发。 ## `isomorphic-fetch` 本次我们将会与后端服务有数据交互,在这里,为了我们以后的 **前后端同构** 做准备的,使用 `isomorphic-fetch` 可以同时照顾 *node* 和 *browser* 环境。 在项目中使用下面的命令安装`isomorphic-fetch`: ```bash npm i isomorphic-fetch es6-promise --save --registry=https://registry.npm.taobao.org ``` ### 使用 `isomorphic-fetch` 可以同时支持在服务器端以及浏览器端使用,在本项目的根目录下,有一个名为 [fetch-demo.js](./fetch-demo.js) 的文件,可以直接查看该文件内容,了解如何在服务器端使用 该模块,当然,这不在本课程的内容大纲中,所以,就不再细说,具体代码如下: ```javascript require('isomorphic-fetch'); const apiPrefix = '//jsonplaceholder.typicode.com'; const fetchPosts = () => { return fetch(`${apiPrefix}/posts`) .then( resp => { console.log('response status: ', resp.status); console.log('response message: ', resp.statusText); return resp.json(); }) .then( json => { console.log('response json data: ', JSON.stringify(json.slice(0,2), ' ', 2)); }); } fetchPosts(); ``` 在浏览器中使用的方式是一样的,这里不再重复贴代码了,我们需要关心的是,在使用时候使用。 ## React 组件的生命周期 ### 实例化过程 当组件在 **客户端** 被实例化,第一次被创建时,以下方法会依次被调用: 1. `getDefaultProps`: 对于每个组件实例来讲,这个方法只会调用一次,该组件类的所有后续应用, `getDefaultPops` 将不会再被调用,其返回的对象可以用于设置默认的 `props`(properties 的缩写) 值。 若是使用 `extends Component` 方法创建的组件,相当于 `constructor(props)` 方法中的 `super(props)` 执行; 2. `getInitialState`:对于组件的每个实例来说,这个方法的调用有且只有一次,用来初始化每个实 例的 `state` ,在这个方法里,可以访问组件的 `props`。 在 `class` 写法中,我们可以在 `constructor()` 方法中直接设置 `this.state = {}`; 3. `componentWillMount`:该方法在首次渲染之前调用,也是再 `render` 方法调用之前修改 `state` 的最后一次机会。 4. `render`:该方法会创建一个虚拟DOM,用来表示组件的输出。对于一个组件来讲,`render` 方法 是唯一一个必需的方法。 5. `componentDidMount`:该方法不会在服务端被渲染的过程中调用。该方法被调用时,已经渲染出真 实的 DOM。 ### 选择最佳的数据装载点 从上面的关于生命周期的分析中,我们可以看出,如果我们的想要在组件被装载时,数据就已经存在,那么 数据的获取就只能在 `getDefaultPops`、`getInitialState`以及 `componentWillMount` 这三 个 `hooks` 处进行,但是如果数据允许被延迟加载,那么我们还可以在 `componentDidMount` 里面进 行,此时,我们最好的办法就是加一个加载小动画了,也是最常见的处理方式。 但是,有一些操作我们却只能在 `render` 之前进行,比如,我们的应用想实现前后端同构,由于服务器端 并不会执行 `componentDidMount` 方法,所以我们就只能把数据请求放在 `render` 之前了,在本次 课程中的示例在将来的课程里会被用作服务器端渲染,所以,我们采取的办法是下面这样的: 1. 因为资产列表不需要登录即可访问,为了提升打开的速度(关于如何提长速度我们会在前后端同构的课程 里面详细说明),我们需要把第一屏幕数据放在 `componentWillMount` 中加载,这样,当服务器 渲染完成之后,数据就已经存在了 2. 把当前用户信息的获取当在 `componentDidMount` 中,因为我们的首屏幕信息并不关心当前用户是 否登录,只需要在用户打开页面之后,我们再获取当前登录用户信息,并根据用户信息进行相应处理即 可(若服务器返回未登录,则打开登录框,否则,直接将数据渲染至页面中)。 ## 在组件装载前加载初始资产数据 关于接口的说明,我们可以访问接口文档地址: - [http://tong.yixinonline.com/api/documentation#!/asset/getApiAssetAssets](http://tong.yixinonline.com/api/documentation#!/asset/getApiAssetAssets) ```javascript import React, { Component } from 'react'; import Fetch from 'isomorphic-fetch'; import './App.css'; const apiPrefix = '//tong.yixinonline.com/api' export default class App extends Component { constructor(props) { super(props) this.state = { assets: [] } } componentWillMount() { Fetch(`${apiPrefix}/asset/assets`) .then( resp => { return resp.json(); }) .then( json => { console.log(json); }); } render() { return (

Hello, React!

); } }; ``` ### 将 asset 数据请求,封装到一个方法中 根据接口文档我们可以看,对于 `assets` 接口的访问是可以有很多种请求方式的,我们不可能在任何一次 调用数据请求的时候都像上面那样,我们可以将对于 `assets` 数据的请求封装到一个组件的方法中(若 使用 Redux 的话,还有更优雅的方法,这会在后面讲到)。 #### 提取出数据请求的原始代码 完成之后的代码如下: ```javascript ... fetchAssets() { Fetch(`${apiPrefix}/asset/assets`) .then( resp => { return resp.json(); }) .then( json => { console.log(json); }); } componentWillMount() { this.fetchAssets(); } ... ``` 此时,我们可以在任何地方调用 `this.fetchAssets` 方法,即可获取数据了,但是,也只是获取而已, 我们还需要改变组件的状态机 `this.state` 才能触发界面的重新渲染,所以,还需要将获取到的数据更 新至 `state` 中,在这里,我们会使用一个 `this.setState()` 方法: ```javascript ... fetchAssets() { Fetch(`${apiPrefix}/asset/assets`) .then( resp => { return resp.json(); }) .then( json => { this.setState(json); }); } componentWillMount() { this.fetchAssets(); } ... ``` 还记得上一次我们讲过的吧,这就是数据在 `` 组件的外部获取,然后,我们可以像上次课程里面 所讲的一样,将得到的 `items` 值当作 `` 组件的属性传递过去,并由它渲染成最终的列表页。 ## 创建 AssetItems 与 AssetItem 组件 ### AssetItems 组件 ```javascript import React, { Component } from 'react'; import AssetItem from './AssetItem'; export default class AssetItems extends Component { constructor(props) { super(props); } render() { console.log(this.state); return (
{ this.props.entities.map( entity => ) }
) } } ``` ### AssetItem 组件 ```javascript import React, { Component } from 'react'; export default class AssetItem extends Component { constructor(props) { super(props); this.state = { entity: props.entity }; } render() { return (

{ this.state.entity.name }

介绍:{ this.state.entity.description }

) } } ``` ### App 组件 ```javascript ... import AssetItems from './AssetItems'; ... export default class App extends Component { ... render() { return (

资产列表 (总计: { this.state.range ? this.state.range.total : 0} )

); } }; ``` ## 添加加载更多按钮 至此,我们已经创建了一个可以查询到后端接口最新的资产列表数据的小应用了,但是,仅仅只能查看接口返 回的部分数据,并且,接下来,我们会在页面的最下方添加一个按钮,当用户点击该按钮时,再为其加载20条 数据。 ### 接口的分页设计 先返回接口文档,我们可以看看接口的分页方式是怎么样的: - [http://www.surl.ren/c/tad](http://www.surl.ren/c/tad#!/asset/getApiAssetAssets) 从接口文档中,我们可以看到, `/asset/assets` 接口提供了两种分页方式: 1. 通过 `query` 传递 `limit` 与 `skip` 参数, `limit` 表示的每次返回的条数,而 `skip` 表示的是跳过多少条记录,即从第几一条开始,默认是第 `0` 条,所以,当我们不传递任何参数的时 候,接口返回的是 20 条数据了; 2. 更加 `RESTFul` 的分页方式,即使用接口文档中说明的那个 `range` 头,我们知道在 `HTTP` 协 议中,有一对`Header`,用于表示数据长度的 `Content-Range` 与 `Range`,这对头信息中,一 般的使用场景告诉请求方文件长度是多少,或者由请求方告诉服务器端我需要多少长度的数据,在我们的 接口中,同样可以使用 `range` 告诉服务器,我需要多少数据,使用的单位是自定义的 `items`, 比如: `range: items=0-1`。 可以看到,此时我们获取到的数据不再是一个对象,而是一个数组了,使用这种方式的好处是,我们可 以更像 `RESTFul` 的方式去操作数据,同时,可以看到,此时的 `Response Headers` 中多出了 一个新的头 `Content-Range: items 0-1/57`,通过 HTTP 协议标准我们可以知道,当前查询的 资源集,总长度为 `57`,此次返回的是第 `0` 至 第 `1` 个资源。 第一种方式我们已经使用了很多年了,在我们这次的资产管理系统中,继续采用第一种方法(宜积分系统中 同时使用了这两种方法,在川哥和吴曼同学的努力下,宜积分的接口已经很 `RESTFul` 的,大家有时间 可以去看看它的请求,下面列出的是一些基于 React + Hapi 的前后端分离项目,有时间可以看看: - [宜积分](http://yijifen.yixinonline.com) - [FM1218](http://fm1218.yixinonline.com) - [宜人馆](http://tong.yixinonline.com/apps/yirenguan/) - [宜人书馆](http://tong.yixinonline.com/apps/library/) - [故障报修](http://tong.yixinonline.com/apps/repair/) - [宜人帮企业版](https://bc.yirendai.com) - [宜人帮](https://bang.yirendai.com) *这个还在改造中,大概12月底上线)* > *注* 关于RESTFul在宜人贷的使用,始祖级的应用应该就是信用生活了,而且还是 HATEOAS 的,当然, > 仅限于若飞同志开发的那些接口,后来就各种各样五花八门了…… ### 简单的封装 什么是封装?说简单一点,就是把最常使用的一些方法,通过一种更加通用的方式放到一起,然后在各种不同 的场合去使用它。 在本次课程中,我们可以作以下小封装: - 将 `fetch` 放在一个独立的工具包中; #### `helpers/fetcher` ```javascript import Fetch from 'isomorphic-fetch'; import Promise from 'bluebird'; const apiPrefix = '//tong.yixinonline.com/api'; const fetch = (url, { headers, query, params, body, method } = {}) => { return new Promise( (resolve, reject) => { url = `${apiPrefix}${url}`; if(typeof params === 'object') { Object.keys(params).map(k => url.replace(':' + k, params[k])); } if(typeof query === 'object') { url += '?' Object.keys(query).map( k => url += `${k}=${query[k]}&`) } const options = {}; if(typeof headers === 'object') { options.headers = headers; } method = method ? method.toUpperCase() : 'GET'; if(typeof body === 'object') { if(method === 'GET' || method === 'OPTIONS') { return reject(new TypeError('GET or OPTIONS request can not attach data.')) } try { options.body = JSON.stringify() } catch(e) { return reject(new TypeError(e)); } } Fetch(url, options) .then( resp => { if(resp.status >= 200 && resp.status < 300) { return resp.json(); } else { return reject(new Error(resp)) } }) .then( json => { resolve(json); }) .catch(e => { return reject(e); }); }) } export default fetch; ``` #### 然后对 App.jsx 进行一些小改造 将原有的 `fetchAssets` 方法修改为下面这样的: ```javascript fetchAssets(next) { if (this.state.noMore) { console.log('noMore: ', this.state); return; } // 若取当前的开始与结速位置 let {start, end, total} = this.state.range; let [skip, limit] = [ start, end - start ]; if (typeof total === 'number') { // 若请示的是下一页 if (next) { skip += limit; } } let options = { query: { skip: skip, limit: limit } } fetch(`/asset/assets`, options).then(json => { let {items, noMore} = this.state; let {range} = json; noMore = range.end === range.total; json.items.forEach(item => items.push(item)); this.setState({items, range, noMore}); }); } ``` 同时,初始 `state` 也做了一些修改,增加了 `noMore` 字段,用于保存是否还有更多数据这个状态, 添加了 `range`,数据结构就与服务器端返回的一致,这样我们就可以更加方便的进行分页了。 ```javascript const initState = { items: [], range: { start: 0, end: 10 }, noMore: false }; ``` ## 添加刷新功能 在 `ListView` 这种展示模式中,最常见的方式是,往下翻的时候,无限加载(我们现在使用的是一个加 载更多按钮模拟的),然后在顶部,往下拉一下松手,即是刷新整个页面,我们现在再使用另一个按钮来模拟 这个功能。 在 `render` 方法中,先添加一个按钮,并且绑定 `onClick` 事件的 handler funciton. ``` ``` 当该按扭被点击时,应该执行以下操作,才能完成刷新的工作: 1. 重置 `state`,设置 `range` 为初始状态, `items` 为空; 2. 重置 `noMore` 为 `false`,表示现在服务器上还有数据; 3. 重新加载数据。 现在就按上面的这个需求去实现这个 `refresh` 方法: ```javascript refresh() { this.setState({ items: [], range: { start: initState.range.start, end: initState.range.end }, noMore: false }); this.fetchAssets(); } ``` ## 添加登录功能 在前面的学习中,我们应该已经看到了一个到处都在使用的东西, `this.state`,几乎有任何数据的更新 与获取,都是用了这个玩意儿,那么它到底是个什么鬼东西? 回顾一下下我们曾经在哪里遇到过? 有没有想起来? Angular 的 ui-router,在定义不同的路由的时候,那个名字也叫 `state`,可以参考一下下 ui-router 的官方示例:[https://ui-router.github.io/sample-app-ng1/](https://ui-router.github.io/sample-app-ng1/) 。 那么问题来了,为什么我们在定义 ui-router 的路由的时候,那个方法名不叫 `path`,`route`, 而在现在的 `react` 项目里面,我们为什么又不叫 `this.data` ,而叫 `state`? ### 什么是状态机 状态机可归纳为4个要素,即现态、条件、动作、次态。 1. **现态**:是指当前所处的状态。 2. **条件**:当一个条件被满足,将会触发一次状态的迁移。 3. **动作**:条件满足后执行的动作。动作执行完毕后,可以迁移到新的状态,也可以仍旧保持原状态。 动作不是必需的,当条件满足后,也可以不执行任何动作,直接迁移到新状态。 4. **次态**:条件满足后要迁往的新状态。“次态”是相对于“现态”而言的,“次态”一旦被激活,就转变 成新的“现态”了 打住了,发现说多了…… 我们只要知道一点: 在 React 的世界里,第一原则就是使用状态机去解决一切问题,让状态进入系统设计,并且变得在系统开 发过程中全程可控。 ### 模型的设计 即使再小的业务组件,我们也能有更好的模型设计,比如在当前这个登录功能,通常有两种设计方式: 1. 按照数据维度设计 2. 按照业务维度设计 在前面的资产列表查询中,其实就是按照数据维度设计,所以我们可以看到,整个 `state` 的数据结构都 与数据有关: - `items`: 存储所有数据 - `range`: 表示数据的状态 - `noMore`: 表示是否还有新数据 然而,在登录功能中,我们就需要按一种维度设计,它是将组件中的数据,以及UI状态等统一抽象成模型。 比如下面这样: ``` this.state = { session: session, showLoginForm: false, loginForm: { login: '', password: '', useLdap: true } } ``` 它们分别干什么? - `session`: 用户存储会话数据 - `showLoginForm`: 用户表示是否需要展示登录表单 - `loginForm`: 登录表单 然后需要提供的方法有下面这些: - `toggleLogin`: 打开/关闭登录表单 - `login`: 执行登录 - `logout`:执行退出登录 - `handleLoginFormFieldChange`:处理登录表单中,每个字段的变更事件 那么,按照这样的设计,最后的程序走向是下面这样的: 同时,UI需要提供四个组件: - 一个用于展示当前已登录的用户信息 - 一个按钮,用于打开或者关闭登录框 - 一个按钮,用于退出当前登录状态 - 一个登录表单,用于收集用户输入的帐户信息 整个程序的执行方向是下面这样的: 1. 用户点击登录按钮 2. 更新状态机,设置 `showLoginForm` 为 `true` 3. 此时UI检测到状态的变更,展示 `loginForm` 4. 用户此时可以再次点击取消(就是登录按钮)关闭登录框,也可以在登录框中输入帐户信息 5. 当用户在表单中输入登录信息时,`handleLoginFormFieldChange` 执行,自动更新表单状态 6. 当用户点击登录表单中的登录按钮时,`login` 方法执行,向服务器发起登录操作 7. `login` 方法在执行成功时,将服务器返回的 `session` 数据更新至状态机 8. `state.session` 被更新,此时UI界面应该作出反馈: - 有会话信息了,所以,我可以展示当前登录的用户姓名 - 已经登录,所以不再需要展示登录按钮 - 已经登录,所以可以展示退出登退按钮 ### 完成编码 当一个程序的执行方向与流程都被设计好了之后,编码,就是最简单的一件事情了,直接上代码: #### 修改初始的 `state` ```javascript const initState = { items: [], range: { start: 0, end: 10 }, noMore: false, session: session, showLoginForm: false, loginForm: { login: '', password: '', useLdap: true } }; ``` #### 完成 `toggleLogin` 从上面的分析,我们可以知道,`toggleLogin` 的执行,并不是真的改变UI的样式(不像JQUERY操作 DOM),而是改变状态机中的状态: ```javascript toggleLogin() { this.setState({ showLoginForm: !this.state.showLoginForm }); } ``` 在上面的代码中,`toggleLogin` 的每一次执行,都会返转 `showLoginForm` 的值。 #### 完成 `logout` 退出登录也并不是改变界面,而是,改变状态机的状态: ```javascript logout() { this.setState({ session: null, showLoginForm: false }); } ``` #### 完成 `handleLoginFormFieldChange` `handleLoginFormFieldChange` 也一样啥也没干,除了改变状态机中的状态 ```javascript handleLoginFormFieldChange(e) { let update = this.state.loginForm; update[e.target.name] = e.target.value; this.setState({ loginForm: update }); console.log(this.state.loginForm); } ``` #### 最后,最复杂的一个方法 `login` 你们难道就没有发现,其实 `login` 再复杂,也不会是复杂了一大圈了之后,会来改变一下状态机的状态吗? ```javascript login(e) { e.preventDefault(); fetch('/sign/in', { body: this.state.loginForm, method: 'POST' }).then(session => { this.setState({ session: session }); }) } ``` ### UI的渲染 当完成上面的这些代码之后,UI的渲染就简单了,无法就是 `if else` 判断,根据不同的状态,作出不同 的输出嘛。 #### 登录按钮以及用户信息的展示 一个三元运算,若 `this.state.session` 不为 **假**,则展示 *XXX您好*,以及一个退出登录按 钮,若其为 **假** ,则展示 **登录按钮**。 而登录按钮可不只是登录这么简单,当 `showLoginForm` 为 *假* 时,表示现在还没有展示登录框,所 以我们展示的是 **登录** 二字,当 `showLoginForm` 为我*真* 时,表示现在登录框已经展示了,所 以,我们展示的是 **取消**。 ```javascript { this.state.session ? { this.state.session.credentials.displayName }您好 | : } ``` #### 登录表单 ```javascript { this.state.showLoginForm & !this.state.session ?


: null } ``` ## 到这个打止吧 都已经做成这样了,那个选择一个资产,然后填写申请说明,再提交申请的功能就不做了吧,跟登录一样, 实在是…… ## 但是其实 我们总不能每一个页面都自己做一个登录功能吧?这样也太坑了,有什么办法? 1. 把登录功能做成一个组件,放哪,哪里就能登录(还记得上次我们讲的组件的设计中,数据获取吗?这种 组件,数据获取应该放在登录组件内部哦) 2. 或者,做成一个页面,任何需要登录的功能的时候,都跳转至登录页面,然后登录完成之后再跳转回来 另外,是不是资产还得有个详情页面哪,总不能把所有的数据都展示在这一个页面里面吧? 关于这个,如何实现? 欲知后事如何,且听下回分解……