Monday, May 19, 2014

innerHTML vs appendNode vs DocumentFragment - Optimizing bulk DOM operations for mobile

There are cases when you want to actually create and show a large chunk of new HTML from javascript. The simplest one is when you are loading a wholly new view in a single-page app. Should you worry about how to implement that?

TL;DR 1) on early-2014 mobile browsers the raw method used to create and show a couple of thousand nodes doesn't matter. 2) with more than a couple of thousand takes too long anyway. 3) the game has changed significantly in the past couple of years. 4) you must measure the right thing (create and show).

innerHTML, appendNode or DocumentFragment?

It's not easy to get good answers by Googling:

The 1st Google result (by Andrew Hedges) for 'innerhtml speed' heavily recommends using innerHTML (instead of DOM operations) on iPhone. To be fair, it's talking about iOS 2.2. It also does specifically note, that you should measure not only the javascript execution time but the time it takes to show the results.

The 1st Google result (by John Resig) for 'documentfragment' tells us that DocumentFragments give us a 2-3x performance improvement over direct appendNode(). This too is old, from 2008, and talks about desktop browsers.

For 'innerhtml vs dom speed' the 1st and 2nd results are Andrew Hedges's pages. The 3rd result is a stackoverflow discussion, where the highest-ranking answer does say that it shouldn't matter, but does also say that for large numbers of elements you should use DocumentFragments and links to John Resig's page.

If you measure just javascript, DOM methods are faster

(Link to test code, I'll explain the test setup in detail shortly.)

But when you factor in rendering time, the difference mostly disappears

Getting your HTML on front of the user requires not only the new DOM, but calculating styles, calculating layout and painting the results on-screen. (If this is unfamiliar ground, Tali Garsiel and Paul Irish wrote an excellent explanation called 'How Browsers Work'). The recalculations are not done synchronously as you manipulate the DOM, but in bulk, later, after your javascript is done.

With total rendering time, all variants come in within 10-15%. The results look similar, but aren't quite the same. On Android the absolute difference between DOM manipulation and innerHTML actually grows from 50ms to 80ms (I don't know why, maybe some calculations can be shared when reusing DOM nodes via cloneNode?), but on iPhone the absolute difference goes down from 70ms to 30ms. The Webkit on the iPhone is actually doing the style recalculation (and render tree creation) synchronously when using innerHTML (Blink recentishly changed innerHTML to use the same mechanism as appendNode). Timings below, the top result is with innerHTML and shows no Style calculation time, whereas the bottom result is with appendNode and shows 50ms spent in style calculation:

Commercial break: The Mobile HTML5 Rendering Profiler

The measurements in this blog post have been created with (and exported into a spreadsheet from) the Mobile HTML5 Rendering Profiler. The Profiler lets you test the speed of your whole DOM manipulation chain: javascript, style calculation, layout and paint.

You can measure some of the rendering time by delaying your 'finished' time to a setTimeout() as PPK pointed out in 2009.. That's definitely better than just measuring javascript execution time. The Profiler can also measure painting times, split recalculation to styles and layout; and it runs your tests several times to measure whether changes are significant or not (how much variance there is from run to run).

The Profiler will set you back 55 EUR (+ VAT), but do first download the 7-day trial and see what makes your app go fast (or slow...).

With enough nodes, there are differences

Firstly: you shouldn't be doing this. The times are in the seconds: your users aren't going to hang around for seconds. However, these results tell us some interesting things about the browser internals :-)

So with 10000 sibling nodes, innerHTML is suddenly faster on the iPhone. What gives? It's the interaction of two different properties of the WebKit used in the browser at the moment (iOS 7.1): 1) innerHTML calculates styles synchronously, appendNode lazily and 2) the lazy calculation is O(n^2) in the number of siblings. This is motivation behind the 'appendNode, nested' variant: that one cuts down on the number of sibling nodes by inserting some intermediate nodes in the tree (NB: this does not result in the same DOM tree, but may well work for you, depending on your styling and code).

On Android we finally see DocumentFragment making headway. I don't really know why.

Some conclusions

Browsers are trying to become better and better in dynamic manipulation of the content. The bugs and merges linked to above show how the internal mechanisms for the different APIs (innerHTML, appendNode, DocumentFragment) are converging, so it won't matter what you use. They also show that at any single point in time there might be issues with any of them...

I'm somewhat surprised that Chrome on the Samsung S5 is consistently about 100% slower than Mobile Safari on the iPhone 5 (not iPhone5S).

There are a lot of things these tests don't tell you anything about: the effect of more or less complex styles, how does modification work compared to creation of new nodes etc.

Test setup details

The devices used are an iPhone 5 running iOS 7.1 and a Samsung Galaxy S5 running Androd 4.4.2 and Chrome 34.

You can just go and read the code if you want to.

The test code creates a given number of items like:

<div ng-repeat="item in items" class="item row">
  <div class="name col-xs-6" ng-bind="item.name">item {{i}}</div>
  <div class="description col-xs-6" ng-bind="item.description"
    >A description of item {{i}} - Lorem ipsum dolor sit amet,
     consectetur adipisicing elit, sed do eiusmod tempor
     incididunt ut labore et dolore magna aliqua</div>
</div>

It's not using AngularJS, the attributes are there to make sure some other test comparisons are apples-to-apples. The {{i}} are replaced with actual item indices. Here are the code variants (edited for line length and brevity):

/* cloneNode */
function setHtmlCloneNodeDom() {
  var i = 1;
  var div = document.createElement("div");
  div.setAttribute("ng-repeat", "item in items");
  div.setAttribute("class", "item row");
  div.innerHTML = '' 
    + '  <div class="name col-xs-6" ng-bind="item.name">
    +     '</div>'
    + '  <div class="description col-xs-6" ' + 
    +     'ng-bind="item.description">A description of item '
    +     i + ' - ' + longtext + '</div>'
  ;
  var item = div.getElementsByTagName("div")[0];
  var description = div.getElementsByTagName("div")[1];
  for (i = 0; i < COUNT; i++) { 
    item.textContent = "item " + i;
    description.textContent = "A description of item " + i +
        " - " + longtext;
    container.appendChild(div.cloneNode(true));
  } 
}

/* innerHTML */
function setHtmlInnerHtml() {
  var html = [];
  for (var i = 0; i < COUNT; i++) {
    html.push('<div ng-repeat="item in items" class="item row">');
    html.push('  <div class="name col-xs-6" ng-bind="item.name">');
    html.push('item ');
    html.push(i.toString()); html.push('</div>');
    html.push('  <div class="description col-xs-6" ');
    html.push('ng-bind="item.description">A description of item ');
    html.push(i.toString()); html.push(' - ');
    html.push(longtext); html.push('</div>');
    html.push('</div>');
  }
  container.innerHTML = html.join("");
}

/* DocumentFragment */
function setHtmlCloneNodeFragment() {
  var fragment = document.createDocumentFragment();
  /* like setHtmlCloneNodeDom */
  for (i = 0; i < COUNT; i++) {
    /* like setHtmlCloneNodeDom */
    fragment.appendChild(div.cloneNode(true));
  }
  container.appendChild(fragment);
}

/* appendNode, nested */
function setHtmlCloneNodeTree() {
  /* like setHtmlCloneNodeDom */
  var each = COUNT / 50;
  var tree_templ = document.createElement("div");
  for (i = 0; i < COUNT; i++) {
    if (i % each === 0) {
      var tree = tree_templ.cloneNode();
      container.appendChild(tree);
    }
    /* like setHtmlCloneNodeDom */
    tree.appendChild(div.cloneNode(true));
  }
}

Tests were run with the Mobile HTML5 Profiler. Full data and graphs available as a Google Spreadsheet (exported via 'Copy TSV' from the Profiler).

Sunday, May 18, 2014

Rerunning Protractor tests when files change and reusing the same browser instance

Running ng-scenario tests with karma had the nice feature of keeping browsers open and rerunning the tests when files change. I at least wrote my end-to-end tests very incrementally, having one test enabled, adding a pause() at the end and writing test code as I checked out what the browser looked like. So I was a bit annoyed with Protractor's explicit lack of a permanent runner. This hit me especially when running against node-webkit, which takes about 8s to start on my machine (vs. 1s for regular Chrome).

So I wrote a hacky script that patches enough of protractor to enable running it multiple times (you can see it on github too).


// keep_running.js
//
// _Example_ for using a single browser for rerunning protractor
// tests when files change (inspired by ng-scenario+karma's normal behaviour)
//
// By Mika Raento, Karhea Oy
// Put in the public domain, do whatever you wish with it
//
// Restrictions of the example:
// - only chromedriver
// - only jasmine
// - tests are assumed to live under acceptance/
// - watches current directory
// - simplistic assumption about process.kill
// - monkey-patches protractor, jasmine and node internals, will
//   easily break with new versions of any
// - there's a reason protractor doesn't support this: it's much easier to
//   get deterministic tests if you restart with a fresh browser
//
// node-requirements: protractor q minijasminenode chokidar
//
// run with:
// $ node keep_running.js   <protractor options go here>
//
var q = require('q');

// Monkey-patch the chrome provider to reuse the same browser instance
chrome = Object.getPrototypeOf(
  require('protractor/lib/driverProviders/chrome.dp.js')());
var orig_getDriver = chrome.getDriver;
var driver;
chrome.getDriver = function() {
  if (driver) {
    this.driver_ = driver;
  } else {
    driver = orig_getDriver.apply(this);
  }
  return driver;
};
chrome.teardownEnv = function() {
  // could we do something here to clean at least some things up?
  return q.fcall(function() {});
};
chrome.setupEnv = function() {
  return q.fcall(function() {});
};

var maybe_run;
var should_run = true;
process.exit = function() {
  is_running = false;
  maybe_run();
};
var is_running = false;
var pending;

require('minijasminenode');
maybe_run = function() {
  if (is_running) return;
  if (!should_run) {
    if (pending) return;
    pending = setTimeout(function() {
      pending = null;
      maybe_run();
    }, 300);
    return;
  }
  is_running = true;
  should_run = false;
  // protractor and (mini)jasmine use node's require() for side effects,
  // reset the modules here
  delete require.cache[require.resolve('protractor/lib/cli.js')];
  delete require.cache[require.resolve('protractor/lib/runner.js')];
  for (var k in require.cache) {
    // especially our test specs
    if (k.indexOf("/acceptance/") > 0) {
      // TODO: parse the command line and configuration file to
      // see what are out specs
      delete require.cache[k];
    }
  }
  // Don't reuse the same jasmine environment for new tests
  jasmine.currentEnv_ = undefined;

  // Kick the run off
  // Here we could call the underlying protractor/lib/launcher.js instead
  // and give it a list of changed files
  require('protractor/lib/cli.js');
};

require('chokidar').watch('.').on('all', function() {
  should_run = true;
});

process.on('exit', function() { if (driver) driver.quit(); });

maybe_run();