7

I'm building an emoji picker and the most demanding task is creating ~1500 DOM elements for each emoji which blocks/makes the page unresponsive for about 500-700ms.

I've debugged this and it seems like the creation of DOM elements is what blocks the rest of JS execution:

function load_emojis(arr){
  var $emojis = [];
  # arr.length = 1500
  _.each(arr, function(){
    $emojis.push($('<span/>').append($('<img/>')));
  });

  $('.emojis').html($emojis);
}

is there a way to execute this whole thing asynchronously/in another thread so it doesn't block the JS following it?

I've tried to put it inside setTimeout but that still seems to be executed in the same thread thus still blocking JS execution.

CodeOverload
  • 47,274
  • 54
  • 131
  • 219
  • You want to set timeout on each append and you want to appended them to a parent that is hidden. Once all elements are added. Unhide the parent. – Darkrum Jun 21 '17 at 19:42
  • 1
    any chance you could do the creation server side? – Hopeless Jun 21 '17 at 19:43
  • Why don't you do anything with the values stored in `arr`? It seems like this code is not complete... – trincot Jun 21 '17 at 19:45
  • @trincot yeah i don't understand it either to me it looks like hes just creating empty with a empty – Darkrum Jun 21 '17 at 19:47
  • I'm not sure if jQuery is doing this under the hood, but I would expect using [document fragments](https://developer.mozilla.org/en-US/docs/Web/API/Document/createDocumentFragment) to give you a little bit of a boost as apposed to forcing a DOM reflow with every addition of a new emoji. – zero298 Jun 21 '17 at 19:51
  • Do you want the emoji to pop in as available or all at once? Have you tried pagenating them so you only show the visible cluster of emoji and then load more as the user scrolls down the list of emoji? – zero298 Jun 21 '17 at 19:54
  • @Darkrum this is a simplified code to show you an idea (setting attributes doesn't affect performance according to my tests) – CodeOverload Jun 21 '17 at 19:55
  • The performance of that function would improve somewhat by rewriting it without dependence on jquery/lodash. [jsperf](https://jsperf.com/create-a-bunch-of-elements) (about a 50% savings) – James Jun 21 '17 at 20:04

5 Answers5

5

JavaScript isn't threaded; it's like most UI libraries in that it does everything in a single thread with an event loop; async behaviors may do work in background threads (invisible to the JS programmer; they don't explicitly manage or even see threads), but the results are always delivered to the single foreground thread for processing.

Element rendering isn't actually done until your JS code finishes, and control returns to the event loop; if you're rendering too many things, the delay occurs when the browser needs to draw it, and there is not much you can do to help. The most you could conceivably do is reduce the parsing overhead by explicitly creating elements rather than passing a wad of text for parsing, but even so, your options are limited.

Things that might help, depending on browser, phase of moon, etc., include:

  1. Create and populate a single parent element that's not part of the DOM at all, then add that single parent to the DOM when it's finished populating (can avoid work involved in maintaining DOM structure)
  2. Create a template of the structure you'll add repeatedly, then use cloneNode(True) to copy that template, and fill in the small differences after; this avoids the work of parsing X node trees when it's really the same node tree repeated a number of times with a few attribute tweaks.
  3. If the images are larger than fit in the viewport, only insert/render the initially visible elements, adding the others in the background as the user scrolls (possibly also adding them proactively, but in small enough numbers and with a sufficient setTimeout/setInterval window between them that any given insertion doesn't take very long, and the UI stays responsive).
  4. A big one for "array of many images" is to stop using 1500+ images, and instead use a single monolithic image. You then either:

    a. Use the monolithic image at a given fixed offset and size repeatedly via CSS (image is decompressed and rendered once, and views of that image are mapped at different offsets repeatedly or...

    b. Use the map/area tags to insert the image only once, but make clicks behave differently on each part of the image (reduces the DOM layout work to a single image; all the other DOM elements must exist in the tree, but don't need to be rendered)

ShadowRanger
  • 143,180
  • 12
  • 188
  • 271
  • Really appreciate the ideas! Disregard my previous comment. I just got what you mean by one image with a map/area. that would probably work (though i still want some hover effects when you hover over an emoji). guess i'll figure a way around it. – CodeOverload Jun 21 '17 at 20:43
2

Here is a function that will divide the work into chunks:

function load_emojis(arr){
    var chunk = 20;
    $('.emojis').html(''); // clear before start, just to be sure
    (function loop(i) {
        if (i >= arr.length) return; // all done
        var $emojis = [];
        $.each(arr.slice(i, i+chunk), function(){
            $emojis.push($('<span/>').append($('<img/>')));
        });
        $('.emojis').append($emojis);
        setTimeout(loop.bind(null, i+chunk));
    })(0);
}

This will do a setTimeout for every 20 next items of your array.

Obviously the total time to complete will be longer, but other JS and user events can happen during the many little pauses.

I left out the second argument of setTimeout since the default (0) is enough to yield to other tasks in the event queue.

Also, I found your use of html() a bit odd, since the documentation allows the argument to be a string or a function, but you provided it an array of jQuery elements... In that case append() would be the function to use.

Play with the chunk size so to find the ideal size. It probably would be bigger than just 20.

trincot
  • 317,000
  • 35
  • 244
  • 286
  • This is a really interesting idea - I'm gonna try it and get back to you – CodeOverload Jun 21 '17 at 20:31
  • This is what I would have answered with. But i would have removed the array and just append directly to the DOM. – Darkrum Jun 21 '17 at 20:42
  • and the chunking in unnecessary. – Darkrum Jun 21 '17 at 20:49
  • @Darkum, the chunking allows to reduce the number of `setTimeout` steps, which might be interesting, because `setTimeout` introduces a [minimum delay of 4ms](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/setTimeout#Minimum_delay_and_timeout_nesting), which will pose a minimum time of completion for 1500 items to 6 seconds, which may or may not be acceptable. – trincot Jun 21 '17 at 20:58
  • @trincot Interesting I didn't know that there was a minimum delay added to setTimeout I always thought it was insert this event immediately after queued events if there was no argument passed.(I may be confusing it with a node.js specific function then) But it looks like from that link there is somthing about postMessage having the ability to have zero delay added. – Darkrum Jun 22 '17 at 02:12
1

My first solution isn't working, it only defer the problem. Your best shot is to optimize this functions a lot to perform it real quick.

function load_emojis(arr) {
  var emojis = new Array(arr.length);
  var emojisDiv = document.querySelector('.emojis');
  for (var i = 0; i < arr.length; i++) {
    emojis[i] = '<span><img src="" alt=""/></span>';
  }
  emojisDiv.innerHTML = emojis.join('');
}

NOT WORKING : There is one nice solution to this, but it's not a cross browser one, and only works in chrome firefox and opera : using window.requestIdleCallback.

function load_emojis(arr) {
  window.requestIdleCallback(function() {
     var $emojis = [];
     # arr.length = 1500
     _.each(arr, function(){
       $emojis.push($('<span/>').append($('<img/>')));
     });

     $('.emojis').html($emojis);
  });
}

see https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback

Here is a polyfill: https://github.com/PixelsCommander/requestIdleCallback-polyfill

Gatsbill
  • 1,760
  • 1
  • 12
  • 25
  • Interesting, i've tried this on the whole function but it doesn't make a difference. still i'll keep reading on it – CodeOverload Jun 21 '17 at 19:56
  • As long as the work is still done without yielding back to the event loop, it doesn't really matter if you wait for idle or not; as long as your JS is running without yielding, the browser will be non-responsive, so if you're idle at time X, your code is dispatched at time X+1, and the user tries to click, scroll, etc. at time X+2, their actions wait until your code returns control to the event loop, even if that doesn't happen until time X+1000. – ShadowRanger Jun 21 '17 at 20:04
  • @Gatsbill using strings of html instead of objects does reduce the time considerably in that section of the code, though when you append the final string it literally takes the same time (i guess JS parses the html string and creates the objects the same way we did already) in other words the whole function still takes the same time to execute. – CodeOverload Jun 21 '17 at 20:40
  • @Ryan, removing jquery methods speeds the function and the string concatenation is faster than the jquery creation method, but I guess it's not enaugh. You should look at trincot idea. – Gatsbill Jun 21 '17 at 20:43
0

Javascript is single threaded, so the short answer is no. So setTimeout will execute a function in the same thread, but will allow your code to keep running until the timeout has completed (at which point it will block). For more on the event loop, MDN has a great explanation.

That being said, browsers implement the Web Worker API, which allow you to spin up separate processes in which to run JS code concurrently with your main process. So you can achieve some degree of multithreading in the browser. Unfortunately, workers do not have the same API access as your main process, and worker processes cannot render directly to the DOM, as explained in this post.

djfdev
  • 5,747
  • 3
  • 19
  • 38
0

There's only one thread where you can perform DOM operations. You can perform non-DOM stuff in a separate thread with WebWorkers, but that obviously wouldn't work since you're adding to the DOM.

You're conducting 1501 DOM operations at once, each DOM operation is very costly. You can easily reduce it to 1 DOM operation:

function load_emojis(arr){
  var $emojis = [];
  # arr.length = 1500
  _.each(arr, function(){
    $emojis.push('<span><img src="..."/></span>');
  });

  $('.emojis').html($emojis.join(''));
}

This should be an order of magnitude slower than a single simple DOM operation, but that's much better than being 1500x slower. You should put in the src before it's added to the DOM, since you can't easily address the individual images anymore.

Leo Jiang
  • 24,497
  • 49
  • 154
  • 284