【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,要不要臉了。

相關文章
相關標籤/搜索