【React教学】通用型DataTable组件——400行内

其实严格意义来讲,应该将Pagination(分页处理)和数据加载(AjaxLoad)做为一个独立的组件来处理,不过为了方便展现,就一股脑都作在这个Table里面了。javascript

目前只实现到整个Table的数据加载,不包含单独更新某行某个单元格数据的状态处理。php

这一次用到的类库也比较多,这里先汇总一下:css

npm install webpack webpack-dev-server react react-dom jquery lodash babel-loader babel-preset-es2015 babel-preset-react babel-plugin-transform-class-properties babel-plugin-transform-es2015-block-scoping babel
-plugin-transform-es2015-computed-properties --save-dev

对,没错,好多,好啰嗦。html

先上张预览图:前端

由于涉及到客户的资料,因此数据打了模糊滤镜了。java

这个Table组件包含的特性:react

  1. Ajax翻页
  2. 指定checkbox的字段
  3. checkbox翻页会保存(就是多页的checkbox的记录都维持着),这点对于大规模数据校对的时候很必要。
  4. 点击行选中checkbox
  5. 选中行(checkbox)更改行样式
  6. 支持数据排序,实际上数据排序用的是jquery-tablesort,没有嵌入到组件中,一个只有126行的table排序,很是实用。不过其实若是容许单元格数据更新的话,排序就要嵌入在renderTableBodyRows的方法中了。
  7. 有几个基本的事件,onMount,onInit,onRow,onFoot。
  8. 整个Table的操做(翻页)都是无刷新的。

其实要作一个知足各方面使用的Table组件,事情并不简单。除了Table组件外,我还定义了两个公共的方法,并放在了整个项目的入口文件中(webpack的entry),详情以下:jquery

var _ = require('lodash');
var jQuery = require('jquery');
var React = require('react');
var ReactDOM = require('react-dom');

window.jQuery = jQuery;
window._ = _;
window.php = require('phpjs');

window.filterContent = function filterContent(value, column) {
	if (_.isObject(value)) {
		if (_.isArray(value)) {
			return value;
		}
		else if (React.isValidElement(value)) {
			return value;
		}
		else {
			if (value instanceof Date) {
				return php.date(column.format || this.props.defaultDateFormat || 'Y-m-d H:i:s', value);
			}
			else {
				return value.toString();
			}
		}
	}
	if (column) {
		if (column.options && column.options[value])
			return column.options[value];
		else if (column.datetime && value) {
			var timestamp = php.strtotime(value);
			if (timestamp > 0)
				return php.date(column.format || 'Y-m-d H:i', timestamp);
			return '';
		}
	}
	return value;
};

window.tag = function tag(tag, content, props) {
	return React.createElement(tag, props || {}, filterContent(content));
};


var Table = require('./components/Table.jsx');

React开发要点精讲

filterContent函数

filterContent方法,用于过滤指定的内容,这里的过滤指将内容过滤为符合React.isValidElement的内容,并能够嵌入在react的html标签中的内容。webpack

这里实际上是React很重要须要掌握的一个技巧。JS中有几种变量的类型:字符、数值、NULL、Boolean、Array、Object。除了Object之外,其余的类型均可以直接插入到react的html中做为内容使用。这里所谓直接插入,包括如下两种形式:git

1. html方式(JSX)

<div>{filterContent(content)}</div>

2. 使用React的JS API方法

React.createElement('div', {}, filterContent(content));

尤为注意,全部Object类型,除了React.isValidElement()判断为有效的对象之外,直接用上述两种方法做为标签内容使用,都会抛出React的异常。包括正则表达式和Date对象。

filterContent方法容许传入第二个参数,就是对过滤内容的一些配置参数。这个函数实际上是我正式版本的一个缩略版本,但其实已能够用于实用了。

补充说一下,判断为数组的时候,最好打扁遍历这个数组,而后依次将数组元素放入filterContent中执行,最后返回过滤完毕的数组便可,React会进行后续的处理。

tag函数

这个函数其实就是对外讲React.createElement的方法精简化输出实现的一个方法,由于直接生成有状态的DOMElement实在太实用了,让人忍不住想在任意地方去使用。实际上这个方法,就是上面说的建立React HTML标签的方法二。

第一个参数,实际上是能够直接传入你本身自定义的React.Component的。

第二个参数就是要插入的内容

第三个参数是这个标签的属性,也就是React组件的props。

获取组件的DOM对象

这里须要粗略的说一下ReactComponent(ReactClass)从实例化->DOM实例化的状态切换。

咱们在使用React.createClass或者extends React.Component时,定义大多数的方法和属性,面向的是DOM实例化状态下的对象的方法和属性。

在咱们执行React.createElement(或者你直接new ReactComponent)的时候,其实是建立了这个Component(ReactClass)的普通实例化对象,这个对象并不具有完整的属性(props)、状态(state),以及你所定义的方法。

在React中,并不推荐直接去操做这个普通的实例化对象,也没有提供太多的接口给你去操做。React认为只有渲染到DOM节点树上的对象才是有效的控制对象。以下:

var el = ReactDOM.render(<HelloWorld />, document.getElementById('test'));

ReactDOM.render返回的,才是一个DOM实例化的对象。

而<HelloWorld />则是普通实例化对象。

那么在已经生成了实例化对象的时候,咱们该如何得到这个实例化对象所关联的DOM节点呢?

// 接着上一段代码
var domEl = ReactDOM.findDOMNode(el);

可是要注意,React有一套很严密的状态机处理的方法,有效的获取到这个DOM实例化对象的DOM节点,必须确保在componentDidMountcomponentDidUpdate以后,不然也会报异常。

好,今天要讲的内容基本上就到这,下面是Table组件的代码(Table.jsx):

var React = require('react');
var ReactDOM = require('react-dom');
var _ = require('lodash');
var php = require('phpjs');
var $ = require('jquery');

class Table extends React.Component {

	static defaultProps = {
		columns: {},
		mergeColumns: {},
		data: [],
		pageData: {},
		pageLinksCount: 10,
		pageOffset: 1,
		url: '',
		ajaxLoad: false,
		ajaxGetColumns: true,
		onInit: null,
		onRow: null,
		onFoot: null,
		onMount: null,
		checkbox: null,
		checked: [],
		thEmpty: '未指定表字段',
		tdEmpty: '未指定表数据',
		defaultDateFormat: 'Y-m-d H:i:s'
	};

	id = 0;

	updateMount = false;

	constructor(props) {
		super(props);
		this.id = _.uniqueId('table_');
		this.state = {
			error: null,
			ajaxLoading: false,
			ajaxGetColumns: true,
			columns: this.props.columns,
			mergeColumns: this.props.mergeColumns,
			data: this.props.data,
			pageData: this.props.data,
			goPage: 0,
			checked: this.props.checked
		};
	}

	makeKey() {
		return this.id + '_' + _.flattenDeep(arguments).join('_');
	}

	getCheckboxField() {
		if (this.props.checkbox && this.state.columns[this.props.checkbox])
			return 'checkbox_' + this.props.checkbox;
		return false;
	}

	getFields() {
		var fields = Object.keys(this.state.columns), checkboxField = this.getCheckboxField();
		if (checkboxField !== false)
			fields = [checkboxField].concat(fields);
		return fields;
	}

	getColumn(field) {
		let column = this.state.columns[field] || {}, checkboxField = this.getCheckboxField();
		if (field === checkboxField) {
			column.label = <input type="checkbox" value="check_all"
			                      onChange={(e) => this.checkAll(e.target.checked)} checked={this.isCheckedAll()}/>
		}
		else {
			if (_.isString(column))
				column = {label: column};
			else if (!_.isObject(column))
				column = {label: field};
			if (!column.label)
				column.label = field;
			if (this.state.mergeColumns[field])
				column = _.merge(column, this.state.mergeColumns[field]);
			if (_.isString(column))
				column = {label: column};
			else if (!_.isObject(column))
				column = {label: field};
			if (!column.label)
				column.label = field;
			if (this.state.mergeColumns[field])
				column = _.merge(column, this.state.mergeColumns[field]);
		}
		return column;
	}

	loadData(page) {
		this.setState({ajaxLoading: true});
		$.ajax({
			url: this.props.url,
			data: {
				columns: this.state.ajaxGetColumns ? 1 : 0,
				page: page || 1
			},
			dataType: 'json'
		}).success((data) => {
			data.ajaxGetColumns = false;
			data.ajaxLoading = false;
			data.goPage = data.pageData.pageNumber || 1;
			this.setState(data);
		}).fail(() => {
			this.setState({error: '网络错误,请从新尝试!'});
		})
	}

	getData() {
		return this.state.data || [];
	}

	isChecked(item) {
		return this.getCheckboxField() !== false && this.state.checked.length > 0 && _.indexOf(this.state.checked, item + '') > -1;
	}

	isCheckedAll() {
		var isChecked = false, checkboxField = this.getCheckboxField(), field = this.props.checkbox,
			data = this.getData(), length = data.length, counter = 0;
		if (checkboxField === false || this.state.checked.length <= 0)
			return false;
		_.each(data, (row) => {
			if (row[field] && this.isChecked(row[field]))
				counter += 1;
		});
		return counter >= length;
	}

	checkAll(isCheck) {
		var items = [], checkboxField = this.getCheckboxField(), field = this.props.checkbox;
		if (checkboxField !== false)
			_.each(this.getData(), function (row) {
				if (row[field])
					items.push(row[field]);
			});
		return this.checkItem(items, isCheck);
	}

	checkItem(item, isCheck) {
		isCheck = !!isCheck;
		let checked = this.state.checked;
		if (!_.isArray(item))
			item = [item];
		_.each(item, function (it) {
			it = it + '';
			if (isCheck) {
				if (_.indexOf(checked, it) < 0)
					checked.push(it);
			}
			else {
				var index = _.indexOf(checked, it);
				if (index > -1)
					checked.splice(index, 1);
			}
		});
		this.setState({checked: checked});
		return this;
	}

	checkRow(event, value) {
		var target = event.target, tag = target.tagName.toLowerCase();
		if (tag !== 'input' && tag !== 'a' && tag !== 'button') {
			this.checkItem(value, !this.isChecked(value));
		}
	}

	dom() {
		return ReactDOM.findDOMNode(this);
	}

	changeGoPage(value) {
		var pageData = this.state.pageData;
		if (isNaN(value))
			value = pageData.pageNumber || 1;
		this.setState({goPage: value});
	}

	componentDidMount() {
		var data = this.getData();
		if (this.props.ajaxLoad && this.props.url && data.length <= 0)
			this.loadData(this.props.pageOffset);

	}

	componentDidUpdate() {
		if (this.getData().length > 0 && _.isFunction(this.props.onMount))
			this.props.onMount.call(this, ReactDOM.findDOMNode(this));
	}

	renderTableHead() {
		return <thead>
		<tr>
			{this.renderTableHeadCells()}
		</tr>
		</thead>;
	}

	renderTableHeadCells() {
		let fields = this.getFields(), length = fields.length;
		if (length <= 0) {
			if (this.state.ajaxLoading) {
				return <th>正在获取数据,请稍候……</th>;
			}
			return <th>
				<div className="at-table-empty">{filterContent(this.props.tdEmpty)}</div>
			</th>;
		}
		return this.getFields().map((field) => {
			let column = this.getColumn(field), isSort = typeof column.sort === 'undefined' || !!column.sort,
				className = !isSort || field === this.getCheckboxField() ? 'no-sort' : 'sort-head';
			return <th key={this.makeKey('head', field)} data-field={field} className={className}>
				{this.getColumn(field).label}
			</th>;
		});
	}

	renderTableBody() {
		return <tbody>{this.renderTableBodyRows()}</tbody>
	}

	renderTableBodyRows() {
		let fields = this.getFields(), data = this.getData(), length = data.length,
			checkboxField = this.getCheckboxField(), checkbox = this.props.checkbox;
		if (length <= 0) {
			if (this.state.ajaxLoading) {
				return <tr>
					<td>
						<div className="at-ajax-loading">正在加载表数据,请稍候……</div>
					</td>
				</tr>;
			}
			return <tr>
				<td>
					<div className="at-table-empty">{filterContent(this.props.tdEmpty)}</div>
				</td>
			</tr>;
		}
		if (_.isFunction(this.props.onInit))
			this.props.onInit.call(this, data);
		return this.getData().map((row, i) => {
			var clone = _.clone(row);
			if (_.isFunction(this.props.onRow)) {
				this.props.onRow.call(this, row, clone);
			}
			return <tr key={this.makeKey('tr', i)}
			           className={clone[checkbox] && this.isChecked(clone[checkbox]) ? 'at-row-checked' : ''}
			           onClick={(e) => this.checkRow(e, clone[checkbox])}>
				{
					fields.map((field) => {
						let value = clone[field] || null;
						let data = {
							value: value,
							text: value,
							field: field,
							index: i
						};
						if (this.props.onRow[field] && _.isFunction(this.props.onRow[field])) {
							this.props.onRow[field].call(this, data, row);
						}
						if (field === checkboxField) {
							return <td key={this.makeKey('td', i, field)}
							           data-field={field}>
								<input type="checkbox" value={clone[checkbox]} key={this.makeKey('checkbox_', i, field)}
								       checked={this.isChecked(clone[checkbox])}
								       onChange={(e) => this.checkItem(clone[checkbox], e.target.checked)}/>
							</td>;
						}
						else {
							return <td key={this.makeKey('td', i, field)} data-field={field} data-sort-value={data.value}>
								{filterContent(data.text, this.getColumn(field))}
							</td>;
						}
					})
				}
			</tr>;
		});
	}

	renderTableFoot() {
		var foot = {
			data: {},
			show: false
		}, fields = this.getFields();
		if (_.isFunction(this.props.onFoot))
			this.props.onFoot.call(this, foot);
		if (foot.show) {
			return <tfoot>
			<tr className="at-sum-row">
				{
					fields.map((field) => {
						return <td key={this.makeKey('tfoot', field)}>{foot.data[field]}</td>
					})
				}
			</tr>
			</tfoot>;
		}
	}

	renderPagination() {
		let pageData = this.state.pageData, links = [], tail = [];
		if (pageData.pageNumber > 0 && pageData.pageSize > 0) {
			var current = parseInt(pageData.pageNumber), linksCount = parseInt(this.props.pageLinksCount),
				middle = parseInt(linksCount / 2),
				total = pageData.pageCount, start = 1, end = linksCount;
			if (total > linksCount) {
				if (current >= middle) {
					start = current - (middle - 1);
					end = linksCount + start - 1;
					if (start > 1) {
						links.push(<li className="pagination-item" key={this.makeKey('page_item_', 1)}>
							<a href={'#page/' + (1)} onClick={() => this.loadData(1)}>{1}</a></li>);
						end -= 1;
					}
					if (start > 2)
						links.push(<li className="pagination-item pagination-item pagination-omission"
						               key={this.makeKey('page_omission_', 'start')}><span>...</span></li>);
				}
				if (end >= total) {
					start -= end - total;
					end = total;
				}
				else {
					if (end < total - 1)
						tail.push(<li className="pagination-item pagination-item pagination-omission"
						              key={this.makeKey('page_omission_', 'end')}><span>...</span></li>);
					if (end !== total)
						tail.push(<li className="pagination-item" key={this.makeKey('page_item_', total)}>
							<a href={'#page/' + (total)} onClick={() => this.loadData(total)}>{total}</a></li>);
				}
			}
			for (let i = start; i <= end; i++) {
				let className = 'pagination-item';
				if (i == pageData.pageNumber)
					className += ' pagination-active';
				let link = <li className={className}
				               key={this.makeKey('page_item_', i)}
				               key={this.makeKey('page_item_', i)}><a href={'#page/' + (i)}
				                                                      onClick={() => this.loadData(i)}>{i}</a></li>;
				links.push(link);
			}
			return <div className="pagination-box">
				<ul className="pagination-list">
					<li className="pagination-item">
						{this.getCheckboxField() ? '选中' + this.state.checked.length + '行,' : ''}
						{pageData.recordCount && pageData.recordCount > 0 ? '共' + pageData.recordCount + '条记录' : ''}
					</li>
					{links.concat(tail)}
					<li className="pagination-item">
						<input type="text"
						       value={this.state.goPage}
						       onChange={(e) => this.changeGoPage(e.target.value)}/>
						<a href="#" onClick={() => this.loadData(this.state.goPage)}>跳转</a>
					</li>
				</ul>
			</div>;
		}
	}

	render() {
		return <div className={this.state.ajaxLoading ? 'at-table-loading' : ''}>
			<table className="at-table">
				{this.renderTableHead()}
				{this.renderTableBody()}
				{this.renderTableFoot()}
			</table>
			{this.renderPagination()}
		</div>;
	}
}

$.fn.table = function (props) {
	if (!this.get(0))
		throw new ReferenceError('Invalid DOM Element!');
	else if (!this.prop('data-table')) {
		props = props || {};
		props = _.merge(props, this.data());
		let input = ReactDOM.render(<Table {...props}/>, this.get(0));
		this.prop('data-table', input);
	}
	return this.prop('data-table');
};

module.exports = Table;

这个Table组件,实际上是从个人正式项目中抽离出来,而且针对第一阶段使用ReactJS碰到的一些问题从新作了调整和优化。要说的话,可能距离正经的开源还有距离,但本身平常用用仍是没啥问题的。

如何调用呢?

<!DOCTYPE html>
<html lang="en">
<head>
	<meta charset="UTF-8">
	<title>Title</title>
	<link rel="stylesheet" type="text/css" href="normalize.css"/>
	<link rel="stylesheet" type="text/css" href="font-awesome.css"/>
	<link rel="stylesheet" type="text/css" href="main.css"/>
</head>
<body>
<div id="table_header"></div>
<div id="table_container"></div>
<script type="text/javascript" src="app.js"></script>
<script type="text/javascript" src="jquery.tablesort.js"></script>

<script type="text/javascript">
	(function() {
		var $ = jQuery;
		$(document).ready(function () {
			var total = 0;
			function confirmData() {
				alert('123');
			}
			$('#table_container').table({
				// ajax的数据url
				url: 'http://localhost/ajax/purchase.json',
				// 是否使用ajax加载
				ajaxLoad: true,
				// 数据内容,你能够不填写data,而让ajax来加载
				// data: [],
				// 默认页面的当前页,这个也会影响ajax第一次优先加载第几页
				pageOffset: 1,
				// 分页的链接显示多少个,实际上不管如何都会按照双数-1,即19 => 19,20 => 19
				pageLinksCount: 20,
				// checkbox对应的字段
				checkbox: 'OrderID',
				// 已经选中的行
				checked: ['120014', '120009'],
				// 表字段的设置,若是ajaxLoad,建议这里留空,附加的字段能够用mergeColumns来设定
				// columns: {},
				// 额外附加的字段说明,他会和columns相关的字段的设定内容合并
				mergeColumns: {
					DeliveryDate: { datetime: true, format: 'Y-m-d' },
					OrderDate: { datetime: true, format: 'Y-m-d' },
					Checked: { options: { 0: tag('strong', '否', { className: 'red' }), 1: tag('strong', '是') } },
					Valid: { sort: false }
				},
				// 初始化接口,这里其实是渲染到table head的时候,因此这里请不要作任何关于DOM节点的操做
				onInit: function(data) {
					total = 0;
				},
				// 这里其实是应该叫作onDataMount,也即,当加载了有效的表数据的时候,才会执行这个结果
				// 但由于他执行的时机其实是比React渲染完成要略早的,因此这里执行的内容仍是给一个延迟吧
				onMount: function(dom) {
					setTimeout(function() {
						// 这里咱们对这个表绑定了一个tablesort的操做,翻页的时候这个tablesort会更新
						// 但这里就不处理翻页时默认的排序状态了。
						$($(dom).find('table')).tablesort({

						}).sort('th.sort-th');
					}, 500);
				},
				// 每一行数据的处理过滤方式,下面这里这个演示的是针对每一行的每个字段的过滤方式
				onRow: {
					// data是一个object,结构为:{ value: value, text: value, field: field, index: rowIndex }
					// value为原值,text也是原值,但输出的时候会使用text来输出,而不使用value,field是字段名,index是行号
					// row则是当前行的数据,由于过滤某个单元格的数据时,仍是须要使用到行数据的。
					OrderID: function(data, row) {
						data.text = tag('strong', data.value);
						total += parseInt(data.value) || 0;
					},
					Valid: function(data, row) {
						data.text = tag('button', '未核实', { onClick: confirmData });
					}
				},
				// 下面是行数据过滤的另外一个版本,这个方式只能针对一行作过滤,两种模式只能任选一种
				// row是默认的行数据,clone是复制出来的行数据,通过这个接口后,输出的每一行的数据实际上使用的是clone的内容
				// 因此要经过这里修改输出的内容,请直接修改clone
//				onRow: function(row, clone) {
//
//				},
				// foot这里只有两个属性:show 是否显示,data 相关显示在tfoot行的数据,通常tfoot主要用来输出汇总的数据内容
				onFoot: function(foot) {
					foot.show = true;
					foot.data = {
						OrderID: total
					}
				}
			});
		});

	}) (jQuery);

</script>
</body>
</html>

使用说明已经在注释中了,具体就不作多解释了。

额外补充一些说明,ajax的数据格式:

{
    "columns": {
        "id": ["label" => "主键"]
    },
    "data": [
        {
            "id": 1,
            "name": "hello"
        }
    ],
    "pageData": {
        "pageCount": 426,
        "pageNumber": "1",
        "pageParam": "page",
        "pageSize": 20,
        "recordCount": 8513
    }
}

后记

其实在过去的2年里,我一直在考虑如何简化后端程序员如何简化操做HTML复杂性的问题。因此在AgiMVC后续的升级版本已经kephp中,都实现了HTML部分的操做函数在内。设计的思想就是用函数名取代繁琐的HTML标签嵌套,而且容许用户实现自定的函数,以实现自定义的标签组合。

而实际上当看到React的时候,我发现本身的想法,和他出发点是很类似的。而React的虚拟化DOM操做,很像我06-07年在某个网站写的一套基于内存操做DOM节点的方法。固然整体而言,React走得更远,还包括了ReactNative。

因此我在对ReactJS有了一个总体性的了解之后,决定入他的坑。

如今前端MVC实在太多,已经进入了前端写模板的时代了。后端程序只要关心数据接口的准确性,前端能够包揽一切。

比起诸多的jade、handlebars、mustcache等js前端模板语言而言,ReactJS最大的优点是保持了HTML与JS混合编写,并实时调用JS变量的内容,没有再通过一层模板系统过滤。这种方式使得你写出来的HTML标签,最终其实是以JS API的方式保存的,对于团队而言,无非就是有一个写JS的地方而已。而无需额外再去学习一套模板的引擎本身一时脑洞设计出来的模板语言。

保持了DOM节点的另一个好处就是,可以与HTML规范与时俱进,好比 SVG,这里的好处实在太多。同时还可以因应浏览器的JS引擎升级而升级,彻底不须要去改变什么。

固然转用了ReactJS之后,并不可以立刻改善后端程序员写HTML的局面,这须要有一个量变到质变的累积。

而经历过这么多年的前端改革洗礼,我已经决定,整个团队的前端的ReactJS组件,由本身的团队成员来写,杜绝使用任何外部插件,由于其实全部的插件,都只是因应一时一刻某一特定环境写成,好比jQuery系列的插件,进入到ReactJS时代,其实80%均可以做废扔掉了。而目前大多数的ReactJS插件,实际上也只是针对某个CSS框架,或者某套UI规范写的,若是哪天你以为这个CSS看着烦了,要换,基本上所有代码做废。做为UI框架,应该考虑得更远,也应该考虑得更全面。这也包括整个团队的前端打包构建规范,一次性代码、屡次性使用的问题。

好吧,随意东拉西扯的,扯远了!

突然想用ReactJS来写一套替代phpmyadmin的东西,phpmyadmin现行版本实在太扯,各类bug,也敢release,要不要脸了。

相关文章
相关标签/搜索