A recent project required us to wade back into KnockoutJS's somewhat stagnant and troubled waters. Stagnant because of its age relative to newer front-end frameworks, slower release cycles and decreased adoption. Troubled because of its pre-Typescript origins, use of method syntax to access observables (which makes code clarity and intent very troublesome in larger projects), and because of performance issues we describe in this post with generating large sets of HTML elements using the foreach binding.
This post describes the issues we encountered using KnockoutJS and DataTables together, along with some patterns we found to avoid extremely slow rendering performance with KnockoutJS.
Cut to the Chase:
- To ensure KnockoutJS binds across JQuery DataTables page sets, apply KnockoutJS to the entire table before applying JQuery DataTables. Or KnockoutJS will not be able to bind to any page elements beyond the first page.
- Do not use native, built-in KnockoutJS bindings such as
foreachto generate large sets of DOM elements. Use alternative methods to generate the markup. We chose to render the large table on the server, with Razor. Other approaches exist, such as using native DOM methods to generate the DOM elements in a custom KnockoutJS binding.
The content below describes how we came to this conclusion.
Problem 1: KnockoutJS can't bind to HTML elements hidden by DataTables Pagination:
We were handed code that used DataTables to render HTML tables, after which KnockoutJS was applied. This approach worked fine in cases where there was only one page of data, or where KnockoutJS bindings were not not needed across DataTable pages. However, Knockout bindings would not work on any page beyond the first page rendered by DataTables. This is because of how DataTables manages its HTML elements across pages behind the scenes; the elements in pages are not rendered until needed. So KnockoutJS click bindings would work for the first Page, but not for any other Pages.
Solution 1: Apply KnockoutJS binding's on each Pagination event
Our first solution was to take advantage of the DataTables draw event, and apply KnockoutJS bindings to the set of rendered elements after each page had finished drawing. This worked, but not consistently, and required hacks such as using KnockoutJS's undocumented and internal
cleanNode method, and
setTimeout to force KnockoutJS binding. We felt this solution was a hack, and pursued a better solution.
Solution 2: Generate Table with Knockout, then apply DataTables
Because DataTables hides elements from the DOM which are not on the current page, and because KnockoutJS has common DOM-rendering bindings such as
foreach, we chose to reverse the rendering order: Have KnockoutJS generate the HTML table markup, and then apply DataTables to it. In this way, KnockoutJS could process all bindings for the entire table across all pages before DataTables applies it's pagination and hides nodes from the DOM. Note: DataTables deferRender does not affect this issue when supplying the table markup to DataTables.
This approach relied on a common KnockoutJS technique for generating tables: an outer
foreach enumerates the rows, which an inner
foreach enumerates the columns. This approach is what led us to our most significant problem, and the recommendations we make in this post for avoiding KnockoutJS performance issues.
Problem 2: KnockoutJS
foreach binding unacceptably slow
Solution 2 worked allowed the KnockoutJS bindings to work fine across all DataTable pages with one debilitating exception: suddenly the tables were taking 10+ seconds to render (only 3,000 records with 5 columns). An investigation using Occam's Razor narrowed down the issue to Knockout's
foreach binding. Further online research revealed that this performance problem is a known issue with KnockoutJS, and is a byproduct of how KnockoutJS handles bindings and traversing the DOM. While there are some published tips for improving these performance issues from the authors, none were applicable to our scenario (using pureComputed observables is the primary solution, but we were not using any computed observables).
(for fun, google "knockoutJS slow foreach")
Final Solution: Do Not Use Knockout to Render Large Table Markup
Our ultimate solution was to abandon the use of KnockoutJS in the project entirely, but due to project constraints that option was not available to us. Our final solution was to revert to rendering the HTML Table markup, complete with KnockoutJS binding syntax, on the server. We then applied KnockoutJS bindings, and then we applied JQuery DataTables on the client after page load. This approach reduced page loading and rendering times from 10+ seconds to less than 2 seconds.
Others seem to have come to similar conclusions: we discovered community created custom KnockoutJS bindings which use native DOM methods to create large strings of table markup and insert them into containing elements. Regardless, the key solution appears to be: avoid using KnockoutJS to generate even moderate amounts of DOM elements dynamically.
To summarize, our solution was:
- Generate HTML Table, with KnockoutJS Markup, using Razor syntax on the server (project is an ASP.NET core project). Keep the table hidden on page load with CSS. The same may be accomplished by generating the table using native DOM methods, using custom KnockoutJS's bindings to use native and performant DOM methods to generate large tables.
- On the client, apply KnockoutJS bindings
- After 2), apply JQuery DataTables. Show the table by removing CSS classes.
With every framework/architecture, there are benefits and costs. In KnockoutJS's case, it seems that the great and relatively transparent cascading dependency binding it offers through it's computed observables and bindings comes at a high performance cost when adding elements dynamically to the DOM. We believe we exhausted all native methods to improve KnockoutJS' performance for dynamically creating DOM elements and have concluded that the best approach is to avoid using KnockoutJS altogether to generate even moderate sets of dynamic elements.
While we would generally not recommend use of KnockoutJS for any large development projects, we recognize it's place and value in the ecosphere of front end frameworks and would recommend waiting until the latest attempt to modernize Knockout has been completed and is stable.