Un Chrono - Comparatif Javascript / React+Redux - + bonus async/await
Actuellement en pleine refonte du projet « Chronofit » avec React+Redux, une app d’enchainement d’intervals chronométrés destinée aux sportifs (enfin surtout pour moi…) initialement en PHP + VanillaJS, j’ai du refaire la logique algorythmique du chrono.
L’objectif ici est de faire un comparatif rapide entre les deux solutions mises en place, du point de vue javascript, pas de html/css ici :
- La classe Chrono en VanillaJS,
- Le life cycle avec React-Redux,
La classe Chrono en VanillaJS
La classe chrono ci-dessous procure ses méthodes de contrôle et nécessite un élément du DOM pour son instanciation (le temps du chrono s’affichera dedans) :
/**
* Object Chrono,
* ATTRIBUTS D'INSTANCE :
* - startTime = integer, le temps de départ (0 par défaut)
* - display = HTMLElement dans lequel afficher,
* - interval = représente setInterval en cours.
*
* FONCTIONS :
* - countAhead : Compte de 0 jusqu'à l'info de stopper, +1 à chaque seconde
* - countDown : Compte à rebours depuis un temps donner jusqu'à 0,
* - return void
* - stop : Arrête countAhead et countDown,
* - return this.startTime
* - timeDislay : méthode interne
* - return string
* - playFirstBip : envoi les premiers Bips
* - playFinalBip : envoi le Bip final
*/
class Chrono {
constructor(startTime, display) {
this.startTime = startTime;
this.display = display;
this.interval = 0;
this.nextCountDown = false;
console.log("nouvel objet Chrono créé");
}
countAhead() {
console.log("départ chrono sur = " + this.display);
const aRepeter = () => {
// On affiche le temps
this.display.innerText = this.startTime;
this.startTime += 1;
};
this.interval = setInterval(aRepeter, 1000);
}
countDown() {
console.log("départ compte à rebours");
let timeString = this.timeDisplay();
// this.display.innerText = timeString;
const aRepeter = () => {
if (this.startTime <= 0) {
clearInterval(this.interval);
} else {
this.startTime--;
timeString = this.timeDisplay();
this.display.innerText = timeString;
if (this.startTime > 0 && this.startTime < 3) {
this.playFirstBip();
}
if (this.startTime == 0) this.playFinalBip();
}
};
aRepeter();
this.interval = setInterval(aRepeter, 1000);
}
stop() {
console.log("Arrêt du chrono");
clearInterval(this.interval);
this.interval = 0;
let timeString = this.timeDisplay();
this.display.innerText = timeString;
return this.startTime;
}
timeDisplay() {
let unitMin = Math.floor((this.startTime / 60) % 10);
let tenMin = Math.floor(this.startTime / 600);
let unitSec = Math.floor((this.startTime % 60) % 10);
let tenSec = Math.floor((this.startTime % 60) / 10);
let string = tenMin + "" + unitMin + ":" + tenSec + "" + unitSec;
return string;
}
playFirstBip() {
console.log("on fait Bip !");
let firstBip = new Audio("./public/sounds/first_bips.wav");
firstBip.play();
}
playFinalBip() {
console.log("on fait le bip final !");
let finalBip = new Audio("./public/sounds/final_bip.wav");
finalBip.play();
}
}
Alors ça fonctionne bien, ça pourrait probablement être adapté à un composant React sous forme de classe avec son propre state. Cependant, j’ai choisi d’utiliser React+Redux et le life-cycle me permet d’obtenir les mêmes effets mais avec une logique éclatée.
Le life cycle avec React-Redux
Le composant Chrono, difficile de faire plus simple :
const Chrono = ({time, text, isCounting, setTime}) => {
useEffect(() => {
if (isCounting){
setTimeout(() => {setTime(time + .1)}, 100 )
}
}, [time, isCounting, setTime])
return(
<div className="readtraining__timedisplay">
{text !== "" && <div className="readtraining__timedisplay__text">
{text}
</div>}
<div className="readtraining__timedisplay__time">
{trainingServices.formatChrono(time)}
</div>
</div>
)}
export default Chrono;
Donc le principe, après le render du composant ‘Chrono’ useEffect() déclenche la mise à jour de la prop ‘time’ dans le store après 0.1s.
La mise à jour de la prop ‘time’ provoque un nouveau render via le reducer puis le store, en ainsi de suite tant que la prop ‘isCounting’ vaut ‘true’.
Extrait du reducer :
...
case SET_EXOPLAYING_TIME:
return {
...state,
exoPlaying: {
...state.exoPlaying,
currentTime: action.time,
}
}
...
La mise en pause et la reprise du chrono sont effectuées grâce à des actions dispatchées dans le store via des boutons de contrôles dans l’UI.
Par exemple le bonton ‘Play’ du chrono :
<button
className="training__button --transparent --xxl"
onClick={() => startChrono()}
>
<i className="fas fa-play"></i>
</button>
Dispatch l’action dans le reducer :
case START_CHRONO:
return {
...state,
exoPlaying: {
...state.exoPlaying,
isCounting: true,
},
globalTime: {
...state.globalTime,
isCounting: true,
},
}
On voit ici que l’action ‘START_CHRONO’ déclenchera au final la mise en route de deux Chrono différents, celui de l’exercice en cours et celui général pour l’ensemble des exercices enchainés.
Ce qui amène à la problématique de l’enchainement complexe de comptes à rebours.
L’utilisation de ‘setTimeout’ c’est super pratique… oui mais voilà, pour certains entrainements il me faut enchainer des comptes à rebours et avoir un compte à rebours général.
La fin d’un compte à rebours d’exercice ou le changement d’exercice provoqué par l’utilisateur doit mettre à jour le compte à rebours général.
Or, chaque mise à jour de la prop affichée par le compte à rebours général, que ce soit par le composant compte à rebours lui-même ou par le changement d’exercice, déclenche un ‘setTimeout()’… c’est le bordel.
Une solution simple, c’est de rajouter une prop dans le state pour indiquer au composant ‘GlobalCountDown’ de ne pas envoyer d’action (donc pas de setTimeout()) pour modifier sa prop ‘time’ quand elle vient d’être modifiée par un changement d’exercice.
Le ‘GlobalCountDown’ :
const GlobalCountDown = ({time, text, isCounting, setTime, resetCurrent, setResetCurrent}) => {
useEffect(() => {
if (isCounting){
if (resetCurrent) {
// ExoPlaying changes
setTimeout(() => {
setResetCurrent(false);
}, 100 );
return
}
if (time > 0.1) {
setTimeout(() => {setTime(time - .1)}, 100 );
return
}
//
setTime(0)
}
}, [time, isCounting, setTime, resetCurrent, setResetCurrent])
La mise à jour de la prop dans le reducer au changement d’exercice :
case SET_CURRENT_EXO:
if (state.timeline[exoIndex].beginning) exoIndex++;
if (exoIndex === state.timeline.length - 1) return state;
return {
...state,
exoPlaying: {
...state.exoPlaying,
currentTime: state.timeline[exoIndex].duration,
},
globalTime: {
...state.globalTime,
resetCurrent: true,
currentTime: trainingServices.getRemainingDuration(state.timeline, exoIndex),
}
}
Finalement, je suis parti sur une solution un peu plus stylée à base de promise et async/await 🙂
Bonus async/await
Le petit problème avec le setTimeout, quand il a une callback qui gère de la donnée, c’est que la valeur de cette donnée est fixée au moment de l’exécution du setTimeout, pas au moment de l’exécution de sa callback. Donc si la valeur de la donnée a changer entre temps, c’est la galère.
L’utilisation d’une fonction asynchrone me permet d’attendre le temps prévu puis ensuite de comparer mes données à leur valeur actuelle, c’est quand même plus propre dans la logique.
Pour mettre en place le principe, il faut :
- Une fonction qui renvoit une Promise,
- Une fonction asynchrone qui attend que la Promise se résolve avant de passer à la suite,
La fonction avec Promise :
wait100ms = () => new Promise((resolve) => {
setTimeout(() => resolve(), 100);
}
La fonction asynchrone (auto-executable) dans le useEffect de mon Chrono :
const Chrono = ({ time, isCounting, setChronoTime }) => {
useEffect(() => {
(async() => {
await wait100ms();
if (isCounting ){
setChronoTime(time + .1)
}
})()
}, [time, isCounting, setChronoTime])
Fini pour aujourd’hui 🙂