Writing web-based line of business applications entails creating a lot of views with tables.
There are many jQuery table plugins and Angular directives for rendering tables, but I created my own for, primarily, read only tables.
Why write my own? I mainly wrote my own because I found that I was writing the same HTML and JavaScript over and over in various applications that required tables. My basic template involved a header with sort/select functionality and an ngReat for the body rows. Every time I found myself copying this code, it became that much more tedious.
At any rate, what I came up with is pretty simple in premise. You put the directive in your mark-up, and define the data that you would like displayed, column mappings, and a few other attributes.
The basic set-up looks like this:
<custom-table table-columns="vm.tableColumns" ng-model="vm.tableData" client-sort="true" track-by="id" sort-by="vm.sortBy" sort-direction="vm.sortDirection" show-select-checkbox="true" show-select-all="true" show-sort="true" use-repeat="true" </custom-table>
I think most of the options are self-explanatory, but some may not be so clear. Track-by, for example defines the key field within the data, and uses it in the track by expression for the ngRepeat. show-select-checkbox defines whether or not a select checkbox is on each body row while select-all defines whether a “select all” checkbox is shown in the header. When this option is enabled, my triStateCheckbox directive is rendered in the header. client-sort defines whether or not the JavaScript sort method that is in the directive is used to sort the data when a sort header is clicked. On a side note, I found sorting in JavaScript to be interesting and actually pretty simple. Here’s the sorting method:
sort = function (array, fieldName, direction, isNumeric) { var sortFunc = function (field, rev, primer) { // Return the required a,b function return function (a, b) { // Reset a, b to the field a = primer(a[field]), b = primer(b[field]); // Do actual sorting, reverse as needed return ((a < b) ? -1 : ((a > b) ? 1 : 0)) * (rev ? -1 : 1); } }; var primer = isNumeric ? function (a) { return parseFloat(String(a).replace(/[^0-9.-]+/g, '')); } : function (a) { return String(a).toUpperCase(); }; isSorting = true; start = new Date().getTime(); array.sort(sortFunc(fieldName, direction === 'desc', primer)); end = new Date().getTime(); time = end - start; $log.log('Sort time: ' + time); };
Sorting an array then is a straight-forward method calling indicating sortBy, sortDirection, and whether the sort property is numeric:
if (tblCtrl.clientSort) { sort(tblCtrl.ngModel, tblCtrl.sortBy, tblCtrl.sortDirection, false); };
For defining the columns, an array of JavaScript objects is passed in. These objects define the bindings, name, sortBy name, whether the column is an anchor, it’s computed, it’s watched, and any filters to apply. A basic example goes like this:
var columns = [ { name: 'Column 1', value: 'column1', binding: "r.column3 + \" / \" + r.column4", style: {}, isWatched: true, isAnchor: false, isComputed: true, srefBinding: 'states.state1({id: r.id})' }, { name: 'Column 2', value: 'column2', binding: 'column2', isWatched: true, style: {} }, { name: 'Column 3', value: 'column3', binding: 'column3', isWatechhed: true, style: {} }, { name: 'Column 4', value: 'column4', binding: 'column4', isWatched: true, style: {} }, { name: 'Column 5', value: 'column5', binding: 'column5', style: {} }, { name: 'Column 6', value: 'column6', binding: 'column6', filter: "currency", isWatched: true, style: {} }, { name: 'Column 7', value: 'column7', binding: 'column7', style: {} }, { name: 'Column 8', value: 'column8', binding: 'column8', filter: "date:\"MM/dd/yyyy\"", style: {} }];
When a column is “computed,” I require that the the record name be referenced as “r.” simply for text replacement. The same is true for the srefBinding. There are probably more succinct ways to do this, but it seemed sufficient. The “isWatched” property is defaulted to true, but if it is set to false, then one-time binding is used for the column. I’m torn on one-time binding since further updates to any row of data is pretty much ignored. The “value” property defines what will be passed back as data via an event to indicate that a sort header has been clicked. This event is called “tableSortHeaderClicked” and sortBy/sortDirection are passed via the event.
In developing this directive, I got somewhat side tracked profiling ngRepeat performance (again). With a typical 8-10 column table, and watchers for every cell, it’s easy to get tens of thousands of watchers. Performance can really tank if one if not careful.
All of this made me really curious to examine ways to reduce watch counts and improve overall responsiveness. Many people are using libraries on top of libraries like React on top of Angular and other directives, like ui-grid, use similar virtualization. Another method of “optimization” is using lazy rendering as with limitTo.
I decided to try going back to basics and rendering straight-up HTML and attaching delegating events. It’s not terribly difficult to apply this method and still make it very Angular friendly. If you look closely at the attributes for the custom-table, it has one attribute called “use-repeat.” When this is set to false, the table header is still rendered through a $compile operation, since I didn’t want to abandon the already useful header, but the tbody rows are rendered as a string as HTML. These rows are inserted into the tbody via innerHTML. jQuery delegated events are attached to the tbody to watch for checkbox and hyperlink clicks. If a click is detected on a select checkbox, for example, the underlying data in the ngModel is updated, and $apply is called to invoke a $digest cycle. This is very similar, in premise, to the way in which you would make any jQuery plug-in work with Angular. Various attributes are put on each row in order to be able to track the row with a specific piece of data in the ngModel array.
Rendering the HTML is a fairly rudimentary process: iterate over the columns, iterate over the rows, evaluate the binding ($eval) to get the cell display text, output HTML. Here’s what the code for that looks like:
var getNonRepeatRows = function (indexes) { var recordName = 'r', binding = '', filter = '', itemValue = '', itemKey = '', keyName = trackBy ? trackBy : 'id', tableColumn, tableCells, objRegex = /{(.*?)}/, rowsArray = [], isArray = (indexes && indexes.length > 0); if (scope.ngModel) { var upperLimit = isArray ? indexes.length : scope.ngModel.length; for (var index = 0; index < upperLimit; index++) { var rowNum = isArray ? indexes[index] : index; recordName = "ngModel[" + rowNum.toString() + "]"; tableCells = ''; itemKey = scope.$eval(recordName + '.' + keyName); for (var i = 0; i < scope.tableColumns.length; i++) { tableColumn = scope.tableColumns[i]; binding = tableColumn.isComputed ? tableColumn.binding.replace(/r\./g, recordName + ".") : recordName + "." + tableColumn.binding; filter = tableColumn.filter ? ' | ' + tableColumn.filter : ''; itemValue = scope.$eval(binding + filter); if (tableColumn.isAnchor) { var json = tableColumn.srefBinding.match(objRegex)[0]; var params = scope.$eval(json.replace(/r\./g, recordName + ".")); var path = tableColumn.srefBinding.replace(objRegex, '').replace(/[{()}]/g, ''); var href = $state.href(path, params); tableCells += tableComputedCellNoRepeatTemplate.replace('<--BIND-->', itemValue) .replace('ui-sref', 'href') .replace('<--SREF-->', href) } else { tableCells += "<td>" + itemValue + "</td>"; } }; var tableRow = showSelectCheckbox ? tableRowNoRepeatTemplate.replace('<--CELLS-->', tableCells) : tableRowNoSelectNoRepeatTemplate.replace('<--CELLS-->', tableCells); tableRow = tableRow.replace(/<--RECORD-->/g, recordName).replace(/<--RECORDKEY-->/g, itemKey); rowsArray.push(tableRow); }; }; return isArray ? rowsArray : rowsArray.join("\n"); };
I have a $watchCollection on the ngModel. When it changes, the redraw method is called:
redrawTable = function () { start = new Date().getTime(); var tableRows = tblCtrl.getNonRepeatRows(); end = new Date().getTime(); time = end - start; $log.log('Render rows time: ' + time); start = new Date().getTime(); var tbody = $scope.element.find("tbody")[0]; tbody.innerHTML = tableRows; end = new Date().getTime(); time = end - start; $log.log('Insert rows time: ' + time); }
Below is a working example of it all put together. Two tables are rendered using the custom-table directive. One is rendered with use-repeat set to true and the other is rendered with use-repeat set to false. Providing this example gives a good glimpse of how ngRepeat performance does, or can, compare with direct DOM manipulation and basic event handling. The buttons at the top will let you add/remove data to see how performance is impacted as data grows/contracts. You can also toggle either or both tables off (ng-if). Keep an eye on the watch count as more rows are added to the ngRepeat table and how the browser can become extremely sluggish.
The full source can be seen on Github.
And, there’s a plunk if you prefer Plunker.