基于React构建大中型Web应用

最近基于React + Redux + Webpack + ECMAScript6做了丁香调查新版问卷项目。

整个问卷前后端分离,前端渲染完全由数据驱动。

现将经验总结如下:

大体的分享思路为:技术选型说明 -> 简单介绍React -> 使用ES6编写React组件 -> Redux -> 整个项目的工程化构建 -> 一些小技巧 -> 反思总结

项目要求

  • 前后端分离
  • 移动端、pc端使用同一套代码,要求等比例缩放页面
  • 除IE8及IE8以下版本浏览器不需要支持,其余浏览器要表现良好
  • 尽可能减少后期维护成本
  • 支持问卷的二次定制

项目特点

两个字即可以概括:复杂

  • 交互
  • 业务逻辑

技术方案

React + Redux + Webpack + ECMAScript6


选择该技术方案原因

选择的原则:选择最合适的

确定方案之前,可选择方案主要有:

  1. 使用操作DOM的js库。比如:jQuery/Zepto
  2. 使用MVC(MVVM)框架。比如:Backbone、Angular
  3. 使用专注于MVC中View层(界面)的解决方案。比如:Vue、React

排除其他方案的主要理由:

jQuery(write less do more)

  • 仅仅是一个js库,不适合做交互和业务逻辑很复杂的大中型项目
  • 每个人编写的代码质量不同,风格不同增加了维护成本。

题外话: jQuery/Zepto会慢慢(or 已经)过时

过时不代表你就一定不可以再用,或者要从现有项目中清除抛弃掉。
项目维护和管理本身是另一回事情,并不是完全由技术因素决定的。

Backbone

  • 要定义多个类才能实现一个功能
  • 每个人编写的代码质量不同,风格不同增加了维护成本。

Angular

  • 过重
  • 移动端性能
  • 短期内团队成员接手维护困难

Vue

Vue.js 不支持 IE8 及其以下版本,因为 Vue.js 使用了 IE8 不能实现的 ECMAScript 5 特性

  • 项目浏览器兼容要IE8+
  • 短期内团队成员接手维护困难

React

React 本身其实还算简单的。

从面向对象编程的角度,一切皆为对象(Object)。对象由一个类(Class)有自己的 属性 和 方法。

延展到React中:

类 —> 组件(Component)
属性 —> state 和 props
方法 —> 方法

最简单的理解,一个组件的渲染函数就是一个基于 state 和 props 的纯函数,state 是自己的,props 是外面来的,
任何 state 或者 props 变了就重新渲染一遍。

一个例子:

我这个人在大千世界中就可以看做为一个 组件(Component)。

我有自己的属性(props),比如:性别男;有两只手;出生在吉林…

我还有自己的状态(state),比如:第一次在团队中做技术分享时,是略紧张并快乐的;16年夏天的我是一个胖子;近视…

如何区分state 和 props?

简单地对每一项数据提出三个问题:

是否是从父级通过 props 传入的?如果是,可能不是 state 。
是否会随着时间改变?如果不是,可能不是 state 。
能根据组件中其它 state 数据或者 props 计算出来吗?如果是,就不是 state

特点

  • 作为MVC架构的V层。可以在新项目中完全使用React,也可以作为一个小特征轻易地在已有项目中使用。
  • 虚拟DOM
  • 单向响应的数据流
  • Declarative(声明式) 代码运行效果可预测性更高,更容易debug。(声明式的把视图组件写好,当数据变化时,React去负责正确的渲染页面且仅会更新变化的部分。)
  • Component-Based
  • Learn Once, Write Anywhere 服务端Node.js渲染/Ract Native

JSX(JavaScript XML)

当对React组件有了基本的概念之后,我们可以设想一下:我们现在已经有了一个用来创建Views的组件(类),如果这个组件
可以做到渲染出页面,那么必然会有一个用于渲染的方法。

如果是我们自己来实现这样一个渲染方法,大概做法是:渲染方法中接收state 或者 props,返回一坨html字符串。

回想在使用jQuery中,或许我们写过很多次类似的函数。

但是这类函数有一些弊端:不易阅读、容易出错、难以复用…

So,创作React的那群哥们儿弄出来一个叫JSX的东西。

JSX 是 JavaScript语法糖。仅此而已,如果非得加一个词来描述这个语法糖,那就是 好用的 语法糖。

对语言功能没有影响,旨在提高代码可读性,使开发者更容易使用,减少代码出错的概率。

还要多少年, 前端开发才能像客户端开发那样轻松?

生命周期

人(组件)是会生老病死的。无论是在哪一个状态,都是有一些方法来改变这个人(组件)的。 — 李小帅

目的:挂载、更新、移除阶段 改变组件

如何创建一个React组件

第一步:拆分用户界面为一个组件树

  • 单一功能原则:理想状态下一个组件应该只做一件事,假如它功能逐渐变大就需要被拆分成更小的子组件。
  • 检查数据模型结构是否正确。这是因为用户界面和数据模型在 信息构造 方面都要一致,这意味着将你可以省下很多将 UI 分割成组件的麻烦事。你需要做的仅仅只是将数据模型分隔成一小块一小块的组件,以便它们都能够表示成组件。

第二步: 利用 React ,创建应用的一个静态版本

  • 将数据模型渲染到 UI 上,但是没有交互功能
  • 仅通过 props 传递数据(state 仅用于实现交互功能)

第三步:识别出最小的(但是完整的)代表 UI 的 state

  • 关键点在于精简:不要存储重复的数据。

第四步:确认 state 的生命周期

明确哪个组件会改变或者说拥有这个 state 数据模型。

第五步:添加反向数据流

  • 传递一个回调函数
  • ReactLink插件

示例

Footer组件


ECMAScript6

let/const

let

  • 用来声明变量
  • 用法类似于var
  • 所声明的变量,只在let命令所在的代码块内有效
  • for循环的计数器,很合适使用let
  • 不存在变量提升。变量一定要在声明后使用,否则报错
  • 不允许重复声明

const

  • 声明一个只读的常量
  • 一旦声明,常量的值就不能改变

解构赋值

ES6允许按照一定模式,从数组和对象中提取值,对变量进行赋值,这被称为解构(Destructuring)。

注意,ES6内部使用严格相等运算符(===),判断一个位置是否有值。
所以,如果一个数组成员不严格等于undefined,默认值是不会生效的。

解构可用于数组、对象、字符串、数字、布尔值、函数的参数。

示例:

1
2
3
4
var user_info = this.props.user_info;
var survey_info = this.props.survey_info;
var items_info = this.props.items_info;
var buttons_info = this.props.buttons_info;
1
const { user_info, survey_info, items_info, buttons_info } = this.props;

or

1
import { connect } from 'react-redux';

字符串的扩展

模板字符串

传统的JavaScript语言,输出模板通常是这样写的。

1
2
3
4
5
6
$('#result').append(
'There are <b>' + basket.count + '</b> ' +
'items in your basket, ' +
'<em>' + basket.onSale +
'</em> are on sale!'
);

上面这种写法相当繁琐不方便,ES6引入了模板字符串解决这个问题。

1
2
3
4
5
$('#result').append(`
There are <b>${basket.count}</b> items
in your basket, <em>${basket.onSale}</em>
are on sale!
`);

函数的扩展

箭头函数

ES6允许使用“箭头”(=>)定义函数。

示例:

1
2
3
let mapStateToProps = (state, ownProps) => {

}

or

1
2
3
4
5
6
7
8
9
10
11
12
13
const { optionvalue } = this.props.item;
let info = {
rates: [],
unknow: []
};

optionvalue.forEach(optionObj => {
if (isNaN(parseInt(optionObj.label))) {
info.unknow.push(optionObj);
} else {
info.rates.push(optionObj);
}
});

对象的扩展

属性的简洁表示法

ES6允许直接写入变量和函数,作为对象的属性和方法。这样的书写更加简洁。
ES6允许在对象之中,只写属性名,不写属性值。这时,属性值等于属性名所代表的变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let mapStateToProps = (state, ownProps) => {
let fields = [];

// some code

const validate = (values, props) => {
// some code
};

return {
fields,
validate
};
}

属性名表达式

如果使用字面量方式定义对象(使用大括号), 用表达式作为对象的属性名,即把表达式放在方括号内。

1
2
3
4
// 实时存储
realtimeStorage && realtimeStorage({
[field.name]: field.value
});

Object.is()

用来比较两个值是否严格相等,与严格比较运算符(===)的行为基本一致。
不同之处只有两个:一是+0不等于-0,二是NaN等于自身。

1
2
3
4
5
+0 === -0 //true
NaN === NaN // false

Object.is(+0, -0) // false
Object.is(NaN, NaN) // true

Object.assign()

Object.assign方法用于对象的合并,将源对象(source)的所有可枚举属性,复制到目标对象(target)。

注意,如果目标对象与源对象有同名属性,或多个源对象有同名属性,则后面的属性会覆盖前面的属性。

Object.assign拷贝的属性是有限制的,
只拷贝源对象的自身属性(不拷贝继承属性),也不拷贝不可枚举的属性(enumerable: false)。

Object.assign方法实行的是浅拷贝,而不是深拷贝。
也就是说,如果源对象某个属性的值是对象,那么目标对象拷贝得到的是这个对象的引用。

1
Object.assign(target, source1, ..., sourcen);

示例:

1
2
3
case QuestionTypes.RATE_MIX:
Object.assign(errors, mixValidator(item, values));
break;

常见用途

对象的扩展运算符

ES7有一个提案,将Rest解构赋值/扩展运算符(…)引入对象。Babel转码器已经支持这项功能。

示例:

1
2
3
4
5
6
7
<input type={ inputType }
autoComplete="off"
className="input-single-text"
disabled={ disabled }
{...field}
onBlur={this.handleBlur.bind(this) }
/>

or

1
let copyState = {...state};

Set和Map数据结构

Set示例:

1
2
3
4
5
6
7
8
9
10
11
// 用户输入重复值验证
let values = [];
for (let itemid in fieldValues) {
if (fieldValues[itemid]) {
values.push(fieldValues[itemid]);
}
}
const set = new Set(values);
if (set.size < values.length) {
errorInfo = '答案内容请勿重复';
}

Map示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
case QuestionTypes.RATE_MIX:
const rateFields = new Map();
item.post_info.forEach(itemid => {
rateFields[itemid] = fields[itemid];
});

question = <RateMix key={index} id={num}
item={item}
fields={rateFields}
touch={touch}
realtimeStorage={realtimeStorageFunc}
/>;
break;

Class

基本上,ES6的class可以看作只是一个语法糖,它的绝大部分功能,ES5都可以做到,
新的class写法只是让对象原型的写法更加清晰、更像面向对象编程的语法而已。

Class之间可以通过extends关键字实现继承,这比ES5的通过修改原型链实现继承,要清晰和方便很多。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { Component } from 'react';

class App extends Component {
constructor(props) {
super(props);

// code
}

changePreviewInfo(itemid) {
// code
}

render() {
// code
}
}

修饰器

修饰器(Decorator)是一个函数,用来修改类的行为。这是ES7的一个提案,目前Babel转码器已经支持。

修饰器对类的行为的改变,是代码编译时发生的,而不是在运行时。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
import { sortable } from 'react-anything-sortable';

@sortable
class SortableItem extends React.Component {
render() {
return (
<div {...this.props}>
{this.props.children}
</div>
);
}
}

Module

JavaScript开发大型的、复杂的项目的巨大障碍:没有模块(module)体系。

ES6之前,社区制定了一些模块加载方案:

  • 浏览器 AMD
  • 服务器 CommonJS

ES6在语言规格的层面上,实现了模块功能,而且实现得相当简单,完全可以取代现有的CommonJS和AMD规范,成为浏览器和服务器通用的模块解决方案。

ES6模块的设计思想,是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。
CommonJS和AMD模块,都只能在运行时确定这些东西。

Node的默认模块格式是CommonJS,目前还没决定怎么支持ES6模块。
所以,只能通过Babel这样的转码器,在Node里面使用ES6模块。

export default命令

其他模块加载通过export default命令导出的模块时,import命令可以为该匿名函数指定任意名字。

模块的整体加载

示例:

1
import * as QuestionTypes from '../../constants/QuestionTypes';

Redux

跨组件通信?多组件共享状态?多人协作的可维护性?

通熟易懂的redux教程一份

React 设计思想


Webpack

Webpack入门

区分开发和生产环境

图片按需加载

1
2
3
4
5
6
7
8
module: {
loaders: [
{
test: /\.(png|jpg)$/,
loader: 'file?hash=sha512&digest=hex&name=[path][name].[ext]?v=[hash]'
}
]
}

publicPath

异步请求的状态管理

每道题可能发n个请求,题目内请求取最新即可。

题目间请求结果(最好)保证有序执行。


前端log系统

及时发现并定位bug、记录异常


一些技巧和处理方案

Babel

webpack-dev-server

cross-env

统一Mac、Linux、Windows命令行的差异

reqwest

redux-form

高阶函数的使用

示例:表单预览

移动端、PC端判断

判断:

1
2
3
if (ua.indexOf("Android") != -1 || ua.indexOf("iPhone") != -1 || ua.indexOf("iPad") != -1) {
window.isMobile = true;
}

使用:

配合classnames

1
2
3
4
5
6
7
8
9
import classnames from 'classnames';

<div className={
classnames({
"pc": !window.isMobile
})
}>
{ this.props.children }
</div>

配合级联组件使用:

1
2
3
4
5
const DesktopOrMobile = window.isMobile ? 'Mobile' : 'Desktop';

cascadeArea = new Cascading[DesktopOrMobile]({
// some code
});

浏览器类型和版本号判断

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
export default function Broswer() {
var _broswer = {};
var sUserAgent = navigator.userAgent;
// console.info("useragent: ", sUserAgent);

var isOpera = sUserAgent.indexOf("Opera") > -1;
if (isOpera) {
//首先检测Opera是否进行了伪装
if (navigator.appName == 'Opera') {
//如果没有进行伪装,则直接后去版本号
_broswer.version = parseFloat(navigator.appVersion);
} else {
var reOperaVersion = new RegExp("Opera (\\d+.\\d+)");
//使用正则表达式的test方法测试并将版本号保存在RegExp.$1中
reOperaVersion.test(sUserAgent);
_broswer.version = parseFloat(RegExp['$1']);
}
_broswer.opera = true;
}

var isChrome = sUserAgent.indexOf("Chrome") > -1;
if (isChrome) {
var reChorme = new RegExp("Chrome/(\\d+\\.\\d+(?:\\.\\d+\\.\\d+))?");
reChorme.test(sUserAgent);
_broswer.version = parseFloat(RegExp['$1']);
_broswer.chrome = true;
}

//排除Chrome信息,因为在Chrome的user-agent字符串中会出现Konqueror/Safari的关键字
var isKHTML = (sUserAgent.indexOf("KHTML") > -1 || sUserAgent.indexOf("Konqueror") > -1 || sUserAgent.indexOf("AppleWebKit") > -1) && !isChrome;
if (isKHTML) { //判断是否基于KHTML,如果是的话再继续判断属于何种KHTML浏览器
var isSafari = sUserAgent.indexOf("AppleWebKit") > -1;
var isKonq = sUserAgent.indexOf("Konqueror") > -1;
if (isSafari) {
var reAppleWebKit = new RegExp("Version/(\\d+(?:\\.\\d*)?)");
reAppleWebKit.test(sUserAgent);
var fAppleWebKitVersion = parseFloat(RegExp["$1"]);
_broswer.version = parseFloat(RegExp['$1']);
_broswer.safari = true;
} else if (isKonq) {
var reKong = new RegExp("Konqueror/(\\d+(?:\\.\\d+(?\\.\\d)?)?)");
reKong.test(sUserAgent);
_broswer.version = parseFloat(RegExp['$1']);
_broswer.konqueror = true;
}
}

// !isOpera 避免是由Opera伪装成的IE
var isIE = sUserAgent.indexOf("compatible") > -1 && sUserAgent.indexOf("MSIE") > -1 && !isOpera;
if (isIE) {
var reIE = new RegExp("MSIE (\\d+\\.\\d+);");
reIE.test(sUserAgent);
_broswer.version = parseFloat(RegExp['$1']);
_broswer.msie = true;
}

// 排除Chrome 及 Konqueror/Safari 的伪装
var isMoz = sUserAgent.indexOf("Gecko") > -1 && !isChrome && !isKHTML;
if (isMoz) {
var reMoz = new RegExp("rv:(\\d+\\.\\d+(?:\\.\\d+)?)");
reMoz.test(sUserAgent);
_broswer.version = parseFloat(RegExp['$1']);
_broswer.mozilla = true;
}

return _broswer;
}

// 调用
// var broswer = Broswer();
// console.info("broswer.version: ", broswer.version);
// console.info("broswer.msie is ", broswer.msie);
// console.info("broswer.safari is ", broswer.safari);
// console.info("broswer.opera is ", broswer.opera);
// console.info("broswer.mozilla is ", broswer.mozilla);
// console.info("broswer.chrome is ", broswer.chrome);
// console.info("broswer.konqueror is ", broswer.konqueror);

Google Analytics

没有采用由后台添加到页面,而是前端直接在js代码中添加如下代码:

1
2
3
4
5
6
7
// Google Analytics
(function (i, s, o, g, r, a, m) {
i['GoogleAnalyticsObject'] = r; i[r] = i[r] || function () {
(i[r].q = i[r].q || []).push(arguments)
}, i[r].l = 1 * new Date(); a = s.createElement(o),
m = s.getElementsByTagName(o)[0]; a.async = 1; a.src = g; m.parentNode.insertBefore(a, m)
})(window, document, 'script', '//www.google-analytics.com/analytics.js', 'ga');

初始化:

1
ga('create', userId, 'auto');

页面打点:

1
ga('send', 'pageview', url);

事件打点:

1
ga('send', 'event', eventCategory, action);

微信分享带缩略图

“作弊方法”

分享网页到朋友圈带缩略图

常规方法

移动端、PC端两套样式

方案:通过 classnames 和 window.isMobile 决定应用根组件的class名,然后使用less来定制某一端的样式即可。

应用根组件:

import React from 'react';
import classnames from 'classnames';

export default class Div extends React.Component {
    render() {
        const { app_root } = this.props;

        return (
            <div className={
                classnames({
                    "app-root": app_root,
                    "pc": !window.isMobile,
                    "preview": window.preview
                })
            }>
                { this.props.children }
            </div>
        );
    }
}

隐藏IE浏览器输入框自带的清除按钮

input::-ms-clear {
    display: none;
}

自动定位有错误的题目

在渲染题目时,给其 id 属性,根据 id 获取错误题目的坐标 (x, y)

window.scroll(x, y);

总结

收获

  • 整个应用由数据驱动,更快的定位bug
  • 较低的后期维护成本
  • 一套可复用的React组件

不足

  • 较少的使用 PropTypes
  • 缺少静态类型检测(Typescript)
  • 缺少测试代码
  • 编码风格与小组规范不统一
  • 应该使用react-router

经验

应用数据结构的设计很重要

  • 数据结构尽可能扁平化

类型检查很重要

  • 外来的数据一定要验证数据类型,格式正确才可以继续往下走。

  • 不要完全相信后台给过来的数据。

尽可能记录下来沟通结果

方便出问题时进行沟通

解决问题方法

  • 不要带着情绪去解决问题
  • 或许需要的仅仅是休息五分钟,或者暂时放下这个问题明天再做
  • 再认真冷静的读一读官方文档
  • Google
  • 联系作者(Github、Email等)
  • 请教身边伙伴

沟通

目标:为了高效合理的解决问题

与后台:

  • 某功能的实现由谁来做?

性能问题?

  • 改接口(数据格式、增删字段)

与产品:

  • 某功能更合适(代码角度、设计角度等)的实现方案

  • 时间节点

按时/提前高质量完成PM最重要


反思

React带给我们了什么?

组件化开发方式的一些思考

高度易维护

……

Redux带给我们了什么?

大大减低了状态的保存与管理的成本,此外我们还可以通过一定的手段,复现过去的动作。

……


技术参考

React - 用于构建用户界面的JAVASCRIPT库

Thinking in React

Redux 中文文档

Redux Form - The best way to manage your form state in Redux.

Webpack 中文指南

ECMAScript 6入门

志遥 wechat
微信扫一扫,我在丁香园记公众号等你