Comment j'ai construit l'animation du logo Quo.js avec un moteur externe à React, des souscriptions atomiques dans React, et presque aucun code répétitif.
TL;DR : J'ai transformé un PNG statique du logo Quo.js en centaines de cercles SVG animés qui s'assemblent, se dispersent autour de la souris et reviennent en douceur — le tout en React 19 + TypeScript utilisant @quojs/core et @quojs/react. L'astuce consiste à exécuter un petit moteur complètement en dehors de React, en diffusant des mises à jour d'état groupées dans un store Quo.js, puis en effectuant le rendu avec des souscriptions atomiques dans React afin que l'interface utilisateur ne soit re-rendue que lorsque les coordonnées d'un cercle spécifique changent réellement.
Technologies : React 19, TypeScript, Vite, SVG
État : @quojs/core et @quojs/react
Ce qui n'est pas utilisé : three.js, WebGL, WebGPU. Ceci est du pur DOM/SVG + requestAnimationFrame.
Ce que vous verrez (GIFs)
Si vous visitez le site web de Quo.js, vous verrez ceci :
-
Intro : les particules convergent pour former le logo "Quo.js".
Et si vous jouez avec le curseur :
-
Interactivité : les cercles s'éloignent du curseur en orbite, puis reviennent à leur position "d'origine".
Dev.to pourrait compresser les fichiers GIF, si vous voyez des animations saccadées, envisagez de télécharger les fichiers GIF (intro et interaction) ou visitez le site web de Quo.js pour du 60fps fluide.
L'idée en un diagramme
Nous gardons React léger en déplaçant tout le travail d'animation dans un simple nano-moteur TypeScript. Le moteur possède la boucle de frames, le quadtree et les calculs de mouvement. Il envoie des mises à jour groupées à un store Quo.js. React ne consomme que la petite portion dont il a besoin par cercle via des sélecteurs atomiques.
- Moteur (hors de React) : boucle requestAnimationFrame, limitation dt, lissage FPS, quadtree, physique des cercles.
- Store Quo.js : réducteurs purement fonctionnels, effets pour les petits timings et événements de cycle de vie.
-
React : rend simplement les nœuds
circleviauseSliceProp('logo', 'd.circle_0')etc.
Pourquoi cette conception ? (Performance et simplicité)
-
React reste inactif sauf si nécessaire. Chaque composant
<Circle>souscrit à son propre cheminx,y. Pas de re-rendus globaux. Pas de prop drilling. -
Le moteur est agnostique du framework. Il ne connaît pas React. Il dispatche simplement des actions (
batchUpdate) vers le store. - Moins de code répétitif que Redux/RTK. Quo.js utilise des channels + événements, des effets sans thunks/sagas, et des sélecteurs atomiques pour souscrire à des chemins pointés exacts.
- Mises à jour groupées par frame + un réducteur serré maintiennent les écritures d'état minimales.
- rAF + limitation dt + buffer circulaire FPS offrent un mouvement fluide sans accrocs.
Étape 1 – Modéliser l'état et les événements de canal
Nous gardons l'état logo pour l'animation.
export type Circle = { id: string; x: number; y: number; r?: number };
export type GroupedCircle = { group: "d" | "u" | "x" } & Circle;
export type LogoState = {
enabled: boolean;
fps: number;
itemCount: { d: number; u: number; x: number };
size: { height: number; width: number };
d: Record<string, Circle>;
u: Record<string, Circle>;
x: Record<string, Circle>;
intro: { remaining: number; total?: number; done: boolean };
};
export type LogoAM = {
start: {};
stop: {};
fps: { fps: number };
size: { height: number; width: number };
count: { d: number; u: number; x: number };
update: GroupedCircle;
batchUpdate: { changes: GroupedCircle[] };
introProgress: { remaining: number; total: number };
introComplete: {};
};
Modèle de dispatch :
store.dispatch("logo", "batchUpdate", { changes: [...] });
Étape 2 – Le réducteur : immuable, rapide
import type { ActionPair, ReducerSpec } from "@quojs/core";
import type { AppAM, LogoAM, LogoState, Circle } from "../types";
export const LOGO_INITIAL_STATE: LogoState = {
enabled: true,
d: Object.create(null),
u: Object.create(null),
x: Object.create(null),
fps: 0,
itemCount: { d: 0, u: 0, x: 0 },
size: { height: 0, width: 0 },
intro: {
remaining: 0,
total: 0,
done: false,
},
};
const LOGO_ACTIONS = [
["logo", "update"],
["logo", "stop"],
["logo", "fps"],
["logo", "size"],
["logo", "count"],
["logo", "batchUpdate"],
["logo", "introProgress"],
["logo", "introComplete"],
["logo", "start"],
] as const satisfies readonly ActionPair<AppAM>[];
// sucre syntaxique pour l'inférence de type
type GroupKey = keyof Pick<LogoState, "d" | "u" | "x">;
function upsertItem(state: LogoState, group: GroupKey, next: Circle): LogoState {
const groupMap = state[group];
const prev = groupMap[next.id];
// insertion
if (!prev) {
const nextGroup = { ...groupMap, [next.id]: next };
return { ...state, [group]: nextGroup };
}
// mise à jour uniquement si quelque chose a réellement changé
if (
prev.x === next.x &&
prev.y === next.y
) {
return state; // no-op
}
const nextGroup = { ...groupMap, [next.id]: { ...prev, ...next } };
return { ...state, [group]: nextGroup };
}
export const logoReducer: ReducerSpec<LogoState, AppAM> = {
actions: [
...LOGO_ACTIONS
],
state: LOGO_INITIAL_STATE,
reducer: (state, action) => {
if (action.channel !== "logo") return state;
if (!state.enabled && action.event !== "start") return state;
switch (action.event) {
case "update": {
const { group, id, x, y } = action.payload;
const next: Circle = { id, x, y };
return upsertItem(state, group as GroupKey, next);
}
case "start": {
if (state.enabled) return state;
return {
...state,
enabled: true,
};
}
case "stop": {
if (!state.enabled) return state;
return {
...state,
enabled: false
};
}
case "fps": {
const { fps } = action.payload as LogoAM["fps"];
if (state.fps === fps) return state;
return {
...state,
fps,
};
}
case "count": {
const next = action.payload as LogoAM["count"];
const prev = state.itemCount;
if (prev.d === next.d && prev.u === next.u && prev.x === next.x) return state;
return { ...state, itemCount: next };
}
case "size": {
const { height, width } = action.payload as LogoAM["size"];
const prev = state.size;
if (prev.height === height && prev.width === width) return state;
return {
...state, size: {
height,
width,
}
};
}
case "batchUpdate": {
if (!action.payload.changes.length) return state;
const { changes } = action.payload;
let wroteAny = false;
for (const c of changes) {
let prev = state[c.group][c.id];
if (!prev) {
prev = {
...state[c.group][c.id],
...c,
};
state = {
...state,
[c.group]: {
...state[c.group],
[c.id]: prev,
}
};
wroteAny = true;
continue;
}
const nx = c.x ?? prev.x;
const ny = c.y ?? prev.y;
if (nx !== prev.x || ny !== prev.y) {
state = {
...state,
[c.group]: {
...state[c.group],
[c.id]: { ...prev, x: nx, y: ny },
}
};
wroteAny = true;
}
}
return wroteAny ? { ...state } : state;
}
case "introProgress": {
const { remaining, total } = action.payload;
return {
...state,
intro: {
...state.intro,
remaining,
total,
}
};
}
case "introComplete": {
return {
...state,
intro: {
...(state.intro ?? {}),
remaining: 0,
done: true
}
};
}
default:
return state;
}
},
};
- Éviter les écritures si
x,yinchangés. - Grouper plusieurs mises à jour par frame.
- Toucher uniquement les clés modifiées.
Étape 3 – Créer le store
export const store = createStore({
name: "Quo.js",
reducer: { logo: logoReducer },
effects: [],
});
Étape 4 – Le moteur (hors de React)
- Exécute la boucle rAF.
- Calcule dt et FPS.
- Dispatche
logo/batchUpdate. - Souscrit aux effets du store pour start/stop.
- N'importe jamais React.
if (dt > 0) this.simulation.loop(this._dt, now);
if (this._running && gen === this._rafGen) {
this._handle = requestAnimationFrame((t) => this._tick(t, gen));
}
Chaque cercle émet des mises à jour { group, id, x, y } lorsqu'il se déplace.
Étape 5 – Relier le moteur et le store dans React
useEffect(() => {
const engine = new Engine({ targetFPS: 60, autoStart: false }, store);
const setup = async () => {
const image = await loadImagePixels(quoLogo);
const { specs, width, height, groupCounts } = extractCircleSpecsFromImage(
image,
{ spacing: 3, initialR: 0.5, maxCircles: 1500 }
);
store.dispatch("logo", "size", { height, width });
store.dispatch("logo", "count", groupCounts);
const sim = new Simulation(engine, { items: specs, name: "Quo Packing" });
engine.attach(sim);
engine.init();
engine.start();
};
setup();
return () => engine.teardown();
}, [store]);
Attendez, manu—comment transformons-nous un PNG en un essaim de cercles ?
Sous le capot, extractCircleSpecsFromImage charge le PNG du logo dans un canvas hors écran, échantillonne l'alpha des pixels sur une grille configurable (par défaut : espacement de 8px), et émet une spécification de cercle partout où alpha > 50.
const threshold = 0.2; // 0–1, ajustez pour la densité
const spacing = 8;
for (let y = 0; y < h; y += spacing) {
for (let x = 0; x < w; x += spacing) {
const alpha = ctx.getImageData(x, y, 1, 1).data[3] / 255;
if (alpha > threshold) {
circles.push({ x, y, r: spacing * 0.45 });
}
}
}
Vous voulez un essaim plus dense ? Réduisez l'espacement. Vous voulez un éclairage d'ambiance ? Mappez alpha → rayon. Ce ne sont que des données—hackez à volonté.
Étape 6 – Colle React avec @quojs/react
export const { useStore, useDispatch, useSliceProp } =
createQuoHooks(AppStoreContext);
Maintenant chaque <Circle> souscrit atomiquement :
export const Circle = ({ id, group }) => {
const path = `${group}.${id}`;
const { x, y } = useSliceProp({
reducer: "logo",
property: path,
}) ?? { x: 0, y: 0 };
return <circle className={`group-${group}`} cx={`${x}px`} cy={`${y}px`} />;
};
Seul ce cercle est re-rendu lorsque ses propres coordonnées changent.
Récapitulatif du flux de données
FAQ / Notes
- Pourquoi pas RTK ? Besoin d'un modèle channel/event, effets async, souscriptions atomiques.
- Pourquoi pas three.js/WebGL ? Excessif pour des particules 2D ; SVG + Quo.js est léger.
-
Complétion de l'intro ? La simulation suit les cercles restants → dispatche
introProgress→introComplete. - FPS ? Moyenné via buffer circulaire, dispatché avec parcimonie.
Pourquoi des dispatchs parcimonieux, pas du spam par frame ?
Inonder React de 60 mises à jour d'état/sec est un big-bang de rendu. Au lieu de cela, le moteur utilise un buffer circulaire pour suivre les temps de frame, calculant le FPS uniquement lorsque le buffer boucle (~toutes les 30 frames).
if (this.frameCount % 30 === 0) {
const fps = 30 / (deltaSum / 1000);
this.store.dispatch(setFps(fps));
}
Résultat ? Une mise à jour atomique toutes les ~500ms, zéro saccade, et Redux Devtools reste sain d'esprit. —oui, vous avez bien lu : Quo.js supporte Redux Devtools—. La télémétrie de performance doit servir l'animation—pas la priver.
Reproduire localement
pnpm add @quojs/core@0.2.0 @quojs/react@0.2.0
- Créer le store (voir Étape 3).
- Envelopper
<AppStoreContext.Provider value={store}>. - Monter le moteur (Étape 5).
- Rendre les cercles SVG (Étape 6).
Documentation pour Quo.js et compagnie :
Petits mais importants patterns
- Écriture groupée par frame.
- Protection no-op sur valeurs identiques.
- Souscriptions à chemin exact.
- Protection des tokens de génération rAF.
- Dispatch parcimonieux de télémétrie.
Essayez-le !
- ⭐ Mettez une étoile à Quo.js sur GitHub
- 🧪 Essayez quojs
- 🐞 Signalez des problèmes / idées
- 🧩 Contribuez des exemples
Réflexions finales
Cette animation montre comment React peut être un moteur de rendu, pas une boucle de jeu. Avec la gestion de changements d'état précis et granulaires, vous obtenez un FPS élevé, peu d'agitation React, et une logique maintenable.
Une note rapide sur les noms des hooks pour
useSlicePropetuseSliceProps: Ceux-ci vont probablement changer pour quelque chose de plus significatif commeuseAtomicPropetuseAtomicPropsavant la prochaine version (dans une semaine), alors ne les utilisez pas encore en production. Quo.js est toujours en phase de tests bêta.
Bon codage, manu.
Licence : MPL-2.0 — partagez avec le monde.


Top comments (0)