JavaScript: how to update progress bar inside nested for loops?

126 Views Asked by At

I have some nested for loops that take a while to run, so I want to display a progress bar. The problem is that this is not an inherently async process, it is a block of code with 3 nested loops. I tried a slew of ways to yield so as to render the page, with and without requestAnimationFrame(), async await, and an async generator w/for await...of. The snippet below represents the only way I could get it to work.

Is there a better way to do this? One that doesn't involve calling the generator function inside the animation callback, for example.

let i, start, val;
const progress = document.getElementsByTagName("progress")[0];
function run() {
  i = 0;
  val = 0;
  start = performance.now();
  requestAnimationFrame(animateProgress);
}
function animateProgress() {
  const next = loop().next();
  if (!next.done) {
    progress.value = next.value;
    frame = requestAnimationFrame(animateProgress);
  }
  else
    console.log(`Calculations took ${performance.now() - start}ms`);
}
function* loop() {
  let j;
  while (i < 100) {
    for (j = 0; j < 100; j++) {
      ++val;
    }
    ++i;
    yield val;
  }
}
* {
  font-family:monospace;
  font-size:1rem;
}
<button onclick="run()">Run</button>
<progress value="0" max="10000"></progress>

2

There are 2 best solutions below

2
Alexander Nenashev On BEST ANSWER

Run your calculations in a worker if they aren't DOM manipulations:

const progress = document.getElementsByTagName("progress")[0];

const executeFunctionInWorker = function(fn, progressCb){

  return new Promise(resolve => {
    const blob = new Blob([`
    let start = performance.now();
    (${fn.toString()})();
    postMessage({duration: performance.now() - start});
    `], {type: 'application/javascript'});
    const worker = new Worker(URL.createObjectURL(blob));
    worker.addEventListener('message', e => {
      if('duration' in e.data){
        resolve(e.data.duration);
      }else{
        progressCb(e.data.progress);
      }
    });
  });
  
};

const doComputation = () => {
  let count = 0;
  while(count++<1000){
    structuredClone(Array.from({length: 30000}, () => Math.random()));      
    postMessage({progress: Math.round(count/1000 * 100)});
  }
};

const run = async() => {
  $run.disabled = true;
  const duration = await executeFunctionInWorker(doComputation, value => progress.value = value);
  $run.disabled = false;
  console.log('Calculations took', duration, 'ms');
};
* {
  font-family:monospace;
  font-size:1rem;
}
<button id="$run" onclick="run()">Run</button>
<progress value="0" max="100"></progress>

On the main thread (note how much it's slower, since we need to calculate and report the progress):

const progress = document.getElementsByTagName("progress")[0];

const doComputation = async () => {
  let count = 0;
  while(count++<1000){
    structuredClone(Array.from({length: 30000}, () => Math.random()));      
    await new Promise(resolve => requestAnimationFrame(() => (progress.value = Math.round(count/1000 * 100), resolve())));
  }
};

const run = async() => {
  $run.disabled = true;
  const start = performance.now();
  await doComputation();
  $run.disabled = false;
  console.log('Calculations took', performance.now() - start, 'ms');
};
* {
  font-family:monospace;
  font-size:1rem;
}
<button id="$run" onclick="run()">Run</button>
<progress value="0" max="100"></progress>

0
Sideways S On

The accepted answer pointed me towards workers, which is the best solution IMO. But I went with a variation on that answer, so I'm posting it as an alternate implementation. This method produces a smoother progress animation, as you might expect by the use of requestAnimationFrame() (it's easier to see the difference in Chrome vs Firefox because Firefox is twice as fast). It is also organized in the way I had originally envisioned but had been unable to implement. The performance seems comparable, maybe a little bit slower than the accepted answer.

The idea is to make the counter independent from the animation loop and to update the progress element from the animation callback. Here it is as adapted from the accepted answer:

let count, frame;
const progress = document.getElementsByTagName("progress")[0];
function raf() {
  progress.value = count;
  requestAnimationFrame(raf);
}
function executeFunctionInWorker(fn) {
  return new Promise(resolve => {
    const blob = new Blob([`
      let start = performance.now();
      (${fn.toString()})();
      postMessage({duration: performance.now() - start});
      `], {type: 'application/javascript'}
    );
    const worker = new Worker(URL.createObjectURL(blob));
    worker.addEventListener('message', e => {
      if('duration' in e.data){
        resolve(e.data.duration);
      }else{
        count = e.data.progress;
      }
    });
  });
}
function doComputation() {
  let c = 0;
  while(c++ < 1000){
    structuredClone(Array.from({length: 30000}, () => Math.random()));
    postMessage({progress: c});
  }
}
async function run() {
  $run.disabled = true;
  count = 0;
  frame = requestAnimationFrame(raf);
  const duration = await executeFunctionInWorker(doComputation);
  cancelAnimationFrame(frame);
  $run.disabled = false;
  console.log('Calculations took', duration, 'ms', count);
}
* {
  font-family: monospace;
  font-size: 1rem;
}
<button id="$run" onclick="run()">Run</button>
<progress value="0" max="1000"></progress>