Chapitre 7.3 — JavaScript Moodle en profondeur
Partie 7 : Frontend — Chapitre 3/6 Public : dev React/Next.js. On a les templates (7.2) ; on écrit maintenant le JavaScript qui les anime. Version de référence : Moodle 5.2 (modules ES6 dans
amd/src/, transpilés en AMD par Grunt/Babel, exécutés via un loader RequireJS).
⏱️ TL;DR
- Le JS de Moodle s’écrit en ES6 dans
amd/src/, est transpilé (grunt amd) en AMD dansamd/build/, et chargé par un loader RequireJS. En prod on sertbuild/minifié ; en dev,$CFG->cachejs = false.- Un module exporte typiquement une fonction
init()appelée depuis le PHP ($PAGE->requires->js_call_amd('component/module', 'init', [args])) ou depuis un template ({{#js}}).- L’écosystème
core/*fournit tout :core/ajax(appeler des web services),core/str(chaînes),core/templates(rendre du Mustache),core/notification(alertes, confirmations),core/modal+core/modal_form(modales, formulaires en modale),core/fragment(fragments HTML+JS générés côté PHP),core/toast(toasts),core_form/changechecker(alerte modifications non enregistrées),core/pubsub(événements).- On ne fait jamais de
fetchbrut vers une URL Moodle : on passe parcore/ajax→ une external function déclarée et sécurisée (Partie 9).- Délégation d’événements obligatoire : le DOM appartient au PHP et est souvent re-rendu ; on écoute sur un ancêtre stable avec un sélecteur, pas sur des nœuds éphémères.
- Pièges :
definecaché (ne pas mélanger AMD et ES modules), jQuery résiduel (à éviter en 5.2), top-levelawait(interdit dans un module AMD), oublier de rebuild.
🎯 Objectifs
- Écrire, builder et charger un module AMD ES6.
- Appeler un web service proprement via
core/ajaxet re-render viacore/templates. - Utiliser les modules
core/*essentiels (modales, notifications, toasts, str). - Structurer le JS en délégation d’événements compatible avec le re-rendu serveur.
- Éviter les pièges classiques (build, jQuery, top-level await).
1. Le pipeline JS : de amd/src/ au navigateur
Vous écrivez de l’ES6 moderne (imports, const, arrow functions, async/await) dans amd/src/. Un build le transforme en AMD (Asynchronous Module Definition, le format que le loader RequireJS de Moodle comprend), dans amd/build/, minifié.
public/mod/simplecheck/amd/
├── src/
│ └── checklist.js ← VOUS écrivez ici (ES6)
└── build/
├── checklist.min.js ← généré par grunt (servi en prod)
└── checklist.min.js.map1.1 Le build
# Une fois, à la racine du dépôt Moodle
nvm use # la version de Node imposée par package.json (.nvmrc fourni)
npm install # installe grunt et l'outillage
# Compiler le JS d'un plugin
npx grunt amd --root=mod/simplecheck
# Recompiler automatiquement à chaque sauvegarde (dev)
npx grunt watchEn développement, réglez $CFG->cachejs = false; dans config.php : Moodle sert alors amd/src/ (via un transpileur à la volée), et vos modifications sont visibles sans rebuild ni purge. En production, cachejs est activé et Moodle sert amd/build/ minifié — d’où l’obligation de committer build/ dans une release (chapitre 6.7) ou de le générer en CI.
⚠️ Piège n°1 du débutant JS Moodle : « mon changement JS ne s’applique pas ». Causes, par ordre : (1)
cachejsesttrueet vous n’avez pas rebuildé (grunt amd) ; (2) vous avez oublié de purger les caches ; (3) vous avez éditéamd/build/à la main (il sera écrasé — on n’édite jamaisbuild/). En dev, mettezcachejs = falseet le problème disparaît.
1.2 Anatomie d’un module
// mod/simplecheck/amd/src/checklist.js
import Ajax from 'core/ajax';
import Notification from 'core/notification';
/**
* Initialise une instance de checklist.
*
* @param {string} rootId l'id de l'élément racine (fourni par {{uniqid}})
*/
export const init = (rootId) => {
const root = document.getElementById(rootId);
if (!root) {
return;
}
registerEvents(root);
};
const registerEvents = (root) => {
// délégation : on écoute sur la racine stable, pas sur chaque checkbox.
root.addEventListener('change', (e) => {
if (e.target.matches('input[type="checkbox"]')) {
saveCheck(root, e.target);
}
});
};
const saveCheck = async (root, checkbox) => {
const itemId = parseInt(checkbox.name.match(/\[(\d+)\]/)[1], 10);
try {
await Ajax.call([{
methodname: 'mod_simplecheck_set_check',
args: {itemid: itemId, checked: checkbox.checked ? 1 : 0},
}])[0];
} catch (error) {
Notification.exception(error);
checkbox.checked = !checkbox.checked; // rollback visuel en cas d'échec
}
};Conventions structurantes :
export const init = (…) => {…}: le point d’entrée. Nomméinitpar convention ; c’est ce que le PHP/{{#js}}appelle.- Imports en tête :
import X from 'core/x'. Le build résout ces imports en dépendances AMD. - Un module = un fichier = une responsabilité. Le nom du module est
component/fichier: icimod_simplecheck/checklist. - JSDoc sur les fonctions exportées : requis par le coding style et le lint (Partie 11).
2. Appeler le module depuis PHP ou un template
Deux façons de déclencher init() :
Depuis PHP (dans view.php, un renderer…) :
$PAGE->requires->js_call_amd('mod_simplecheck/checklist', 'init', ['simplecheck-' . $uniqueid]);Depuis un template (rappel 7.2) :
<div id="simplecheck-{{uniqid}}"> … </div>
{{#js}}
require(['mod_simplecheck/checklist'], (Checklist) => Checklist.init("simplecheck-{{uniqid}}"));
{{/js}}Les deux font la même chose : charger le module (le loader le fetch si besoin) puis appeler init avec des arguments. Le passage par id d’élément (issu de {{uniqid}}) est le pont entre le HTML serveur et le JS — l’hydratation manuelle du chapitre précédent.
💡 Pour un dev React :
js_call_amd(component/module, 'init', [args])≈ unReactDOM.hydrateRoot(element, <Component {...args} />)explicite et local. Le serveur a rendu le HTML (via Mustache) ; vous « montez » le comportement sur un élément précis. Pas de root global, pas de reconciliation : un îlot, uninit.
3. core/ajax : parler au serveur, jamais en fetch brut
Règle d’or : le JS n’appelle jamais une URL Moodle en fetch/XMLHttpRequest direct. Il appelle une external function (une fonction de web service, Partie 9) via core/ajax.
import Ajax from 'core/ajax';
// Ajax.call prend un TABLEAU de requêtes, renvoie un TABLEAU de promesses.
const [progressPromise] = Ajax.call([{
methodname: 'mod_simplecheck_get_progress',
args: {cmid: 42},
}]);
const progress = await progressPromise; // { percentage: 60, ... }Pourquoi ce détour :
- Sécurité : l’external function déclare ses paramètres (
PARAM_*), vérifierequire_login, le contexte et les capabilities côté serveur. Unfetchbrut contournerait tout ce cadre. - Sesskey et session :
core/ajaxgère automatiquement le jeton et l’endpoint (/lib/ajax/service.php). - Batching : plusieurs appels en un seul aller-retour réseau (le tableau).
- Contrat typé : l’external function définit la forme des paramètres et du retour (chapitre 9), comme un endpoint typé.
⚠️ Piège :
Ajax.call([...])renvoie un tableau de promesses, pas une promesse unique. On récupère[0](ou par déstructuration). Oublier l’indice est l’erreur classique (await Ajax.call([...])renvoie le tableau, pas la donnée).
💡 Pour un dev React :
core/ajaxest votre client d’API typé (à la tRPC/React Query), où les « routes » sont les external functions. Vous ne construisez pas d’URL ni de méthode HTTP : vous nommez une fonction serveur et passez des args. Le batching évoque le request batching d’Apollo/tRPC.
4. core/templates : re-render sans recharger
Couplé à core/ajax, core/templates rend le même Mustache que le serveur (7.2) :
import Templates from 'core/templates';
const refresh = async (root, context) => {
const {html, js} = await Templates.renderForPromise('mod_simplecheck/checklist', context);
Templates.replaceNodeContents(root, html, js); // remplace le contenu ET exécute le JS du template
};renderForPromise(nom, context)renvoie{html, js}: le HTML rendu et le JS éventuel du template (le{{#js}}).replaceNodeContents(node, html, js)injecte le HTML et exécute le JS associé — indispensable, sinon les{{#js}}du fragment re-rendu ne s’initialisent pas.- Autres helpers :
Templates.appendNodeContents,Templates.prependNodeContents.
⚠️ Piège : n’injectez jamais du HTML rendu via
element.innerHTML = htmlen ignorant lejs. Vous perdriez l’initialisation des sous-composants ({{#js}}) et casseriez l’hydratation. Utilisez toujoursTemplates.replaceNodeContents/appendNodeContents.
5. Modales, notifications, toasts
5.1 core/notification — alertes et confirmations
import Notification from 'core/notification';
// Confirmation (remplace le confirm() natif, stylé et accessible).
Notification.confirm(
'Supprimer',
'Confirmer la suppression de cette tâche ?',
'Supprimer', 'Annuler',
() => doDelete() // callback si confirmé
);
// Afficher une erreur d'exception (ex. échec d'un Ajax.call).
try { /* … */ } catch (e) { Notification.exception(e); }
// Alerte simple / toast d'info via addNotification.
Notification.addNotification({message: 'Enregistré', type: 'success'});5.2 core/toast — notifications éphémères
import {add as addToast} from 'core/toast';
addToast('Modifications enregistrées', {type: 'success'});5.3 core/modal et core/modal_form — fenêtres modales
import Modal from 'core/modal';
const modal = await Modal.create({
title: 'Détails',
body: Templates.render('mod_simplecheck/details', context), // une promesse de HTML
show: true,
});Pour un formulaire dans une modale (le pattern le plus fréquent — ajout/édition sans quitter la page), core/modal_form charge un moodleform via core/fragment et gère sa soumission AJAX :
import ModalForm from 'core/modal_form';
const modalForm = new ModalForm({
formClass: 'mod_simplecheck\\form\\item_form', // la classe moodleform (FQCN échappé)
args: {cmid: 42},
modalConfig: {title: 'Ajouter un item'},
});
modalForm.addEventListener(modalForm.events.FORM_SUBMITTED, (e) => {
// rafraîchir la liste après soumission réussie
refreshList();
});
modalForm.show();💡 Pour un dev React :
core/modal_formest un composant de formulaire en dialog clé en main : il charge le formulaire (rendu serveur), le soumet en AJAX, gère la validation serveur et émet des events (FORM_SUBMITTED) auxquels vous réagissez. C’est votre<Dialog>+<Form>+ mutation, mais fourni par le framework et rendu par le serveur (le formulaire est unmoodleformPHP, pas du JSX). Vous ne réimplémentez ni la validation ni le markup.
6. core/fragment : du HTML+JS généré par PHP à la demande
Parfois vous voulez injecter un morceau d’interface calculé côté PHP (avec accès complet aux APIs serveur) dans une page déjà chargée. C’est core/fragment : il appelle une fonction de fragment PHP (component_output_fragment_<name>) qui renvoie du HTML et du JS.
import Fragment from 'core/fragment';
import Templates from 'core/templates';
const html = await Fragment.loadFragment(
'mod_simplecheck', // component
'itemdetails', // nom du fragment → fonction PHP mod_simplecheck_output_fragment_itemdetails
contextId,
{itemid: 7}
);Côté PHP, une fonction dédiée construit ce fragment avec toute la puissance du serveur (DB, capabilities, renderers). C’est le mécanisme qui alimente core/modal_form (le formulaire est un fragment).
⚠️ Piège : un fragment n’est pas une external function. C’est un rendu HTML côté serveur exposé au JS. La sécurité (contexte, capabilities) reste votre responsabilité dans la fonction PHP du fragment — un
require_capabilityy est aussi obligatoire que dans une page.
7. Structurer le JS pour un DOM qui appartient au PHP
Le principe du chapitre 7.1 : le DOM appartient au serveur et peut être re-rendu (par core/templates, par un rechargement de fragment). Deux conséquences pratiques.
7.1 Délégation d’événements (obligatoire)
N’attachez pas d’écouteurs sur des nœuds éphémères (une checkbox qui sera remplacée au prochain re-render). Écoutez sur un ancêtre stable avec un sélecteur :
// ✅ délégation : survit au re-render du contenu interne
root.addEventListener('click', (e) => {
const btn = e.target.closest('[data-action="delete"]');
if (btn) {
onDelete(btn.dataset.itemid);
}
});
// ❌ fragile : cet écouteur meurt si le bouton est re-rendu
document.querySelector('[data-action="delete"]').addEventListener('click', onDelete);7.2 Ancrage par data-*, pas par structure
Ciblez des data-* stables posés dans le template (data-region, data-action, data-itemid), pas des chemins DOM fragiles (div > ul > li:nth-child(2)). Le template peut changer sa structure ; les data-* sont un contrat entre le HTML serveur et le JS.
💡 Pour un dev React : c’est l’inverse de votre habitude où React possède le DOM. Ici, un re-render serveur peut remplacer vos nœuds sans prévenir le JS. La délégation d’événements + les
data-*sont l’équivalent d’écouter au niveau d’un conteneur stable et d’identifier les cibles par attribut — un pattern « event delegation » que vous connaissiez avant React, et qui redevient nécessaire.
8. Les pièges classiques (récap)
⚠️
definecaché / mélange AMD-ESM. Écrivez de l’ES6 (import/export) dansamd/src/; le build produit l’AMD. N’écrivez pas dedefine(...)à la main et ne mélangez pas les deux syntaxes dans un même fichier.
⚠️ jQuery résiduel. Du vieux code Moodle utilise
import $ from 'jquery'. En 5.2, le core s’en éloigne (Bootstrap 5 est sans jQuery). N’introduisez pas de nouvelle dépendance jQuery : le DOM natif suffit (querySelector,closest,matches,dataset).
⚠️ Top-level
awaitinterdit. Un module AMD ne supporte pas l’awaitau niveau racine du module. Mettez vosawaitdans une fonctionasync(commeinitou un handler), jamais directement dans le corps du module.
⚠️ Oublier le rebuild. En prod (
cachejs=true), un changement deamd/src/sansgrunt amdne se voit pas. En dev,cachejs=falseévite le problème.
⚠️
Ajax.callrenvoie un tableau.const [p] = Ajax.call([...])puisawait p. Pasawait Ajax.call([...]).
9. Carte de l’écosystème core/*
| Module | Usage typique |
|---|---|
core/ajax | appeler une external function |
core/templates | rendre/re-render un Mustache côté client |
core/str | charger des chaînes de langue en JS |
core/notification | alertes, confirm, afficher une exception |
core/toast | message éphémère de succès |
core/modal, core/modal_form | modales, formulaire en modale |
core/fragment | injecter du HTML+JS calculé par PHP |
core/pubsub | publier/s’abonner à des événements applicatifs |
core_form/changechecker | prévenir « modifications non enregistrées » |
📚 Aller plus loin : la référence des modules est moodledev.io/docs/5.2/guides/javascript . Lisez le JS de
mod_forum/amd/src/et decore_course/amd/src/(l’éditeur de cours) : ce sont les exemples les plus riches et à jour du core.
✏️ Exercices
Exercice 1 — Mon JS ne s’applique pas.
En production, vous modifiez amd/src/checklist.js mais rien ne change dans le navigateur. Trois causes possibles et la solution de dev ?
✅ Solution
Causes : (1) cachejs = true et pas de rebuild → Moodle sert l’ancien amd/build/ ; (2) caches non purgés ; (3) vous avez édité amd/build/ directement (écrasé au prochain build — on n’édite que src/). Solution : lancer grunt amd pour recompiler puis purger les caches ; en développement, régler $CFG->cachejs = false; pour servir src/ directement et voir les changements sans rebuild.
Exercice 2 — fetch interdit.
Un dev appelle directement fetch('/mod/simplecheck/save.php?item=7') depuis son module. Pourquoi est-ce à proscrire, et quelle est la bonne approche ?
✅ Solution
Un fetch brut vers une URL Moodle contourne le cadre de sécurité : pas de validation typée des paramètres, pas de gestion du sesskey, pas de contrat de retour, et il faudrait écrire une page ad hoc côté serveur. Bonne approche : déclarer une external function (mod_simplecheck_set_check, Partie 9) qui valide require_login, le contexte, la capability et les paramètres (PARAM_*), puis l’appeler via core/ajax : Ajax.call([{methodname, args}])[0]. Le web service est le seul pont JS→serveur autorisé.
Exercice 3 — Écouteur qui meurt.
Après un rafraîchissement AJAX de la liste, les boutons « supprimer » ne réagissent plus. Le code fait document.querySelectorAll('[data-action=delete]').forEach(b => b.addEventListener('click', onDelete)) à l’init. Pourquoi, et comment corriger ?
✅ Solution
Le re-render (Templates.replaceNodeContents) remplace les boutons par de nouveaux nœuds ; les écouteurs attachés aux anciens nœuds disparaissent avec eux. Correctif : délégation d’événements sur un ancêtre stable (la racine, qui n’est pas remplacée) : root.addEventListener('click', e => { const b = e.target.closest('[data-action=delete]'); if (b) onDelete(b.dataset.itemid); }). L’écouteur unique survit à tous les re-renders du contenu interne.
Exercice 4 — Formulaire en modale. Vous voulez ajouter un item via une modale sans recharger la page, avec validation serveur. Quel module utilisez-vous et quel est le flux ?
✅ Solution
core/modal_form : on l’instancie avec formClass (le FQCN du moodleform PHP, ex. mod_simplecheck\\form\\item_form) et des args (ex. cmid). Le module charge le formulaire (rendu serveur via core/fragment), l’affiche en modale, le soumet en AJAX en appliquant la validation serveur du moodleform, et émet FORM_SUBMITTED en cas de succès. On écoute cet event pour rafraîchir la liste. Aucun markup ni validation à réécrire côté JS : le formulaire reste un moodleform PHP.
Exercice 5 — Top-level await.
Ce module lève une erreur au chargement : const config = await Ajax.call([...])[0]; écrit directement dans le corps du fichier. Pourquoi, et comment corriger ?
✅ Solution
Le top-level await n’est pas supporté dans un module AMD (le format cible du build) : l’await ne peut vivre que dans une fonction async. Correctif : déplacer l’appel dans init (ou une fonction async appelée par init) : export const init = async () => { const config = await Ajax.call([...])[0]; … };. Le corps du module ne doit contenir que des déclarations synchrones (imports, fonctions).
🧠 Quiz de révision
Q1. Décrivez le pipeline d’un module JS Moodle, de l’écriture au navigateur.
Réponse
amd/src/ ; grunt amd (Babel) le transpile en AMD minifié dans amd/build/ ; le loader RequireJS de la page le charge et l’exécute. En prod on sert build/ (nécessite un rebuild à chaque changement) ; en dev, cachejs=false sert src/ directement.Q2. Comment déclenche-t-on la fonction init d’un module, et d’où vient l’id qu’on lui passe ?
Réponse
$PAGE->requires->js_call_amd('component/module', 'init', [args]), ou depuis un template via {{#js}} require([...], m => m.init("...")) {{/js}}. L’id passé provient généralement de {{uniqid}} dans le template, servant de point d’ancrage entre le HTML serveur et le JS (hydratation d’îlot).Q3. Pourquoi passer par core/ajax plutôt qu’un fetch direct ?
Réponse
core/ajax appelle une external function qui valide côté serveur (login, contexte, capabilities, types PARAM_*), gère le sesskey et l’endpoint, permet le batching, et fournit un contrat typé. Un fetch brut contournerait tout ce cadre de sécurité. Ajax.call([...]) renvoie un tableau de promesses (prendre [0]).Q4. Pourquoi la délégation d’événements est-elle nécessaire dans Moodle ?
Réponse
core/templates/fragments), remplaçant les nœuds et leurs écouteurs. En écoutant sur un ancêtre stable avec un sélecteur (et en ciblant par data-*), le comportement survit aux re-renders.Q5. Citez deux pièges spécifiques au JS Moodle et leur solution.
Réponse
cachejs=true) → lancer grunt amd ; en dev, cachejs=false. (2) Top-level await interdit en AMD → mettre l’await dans une fonction async. Autres : ne pas introduire de jQuery neuf (DOM natif), ne pas oublier que Ajax.call renvoie un tableau, ne pas éditer amd/build/.Chapitre suivant : 04 — React dans Moodle, le tutoriel de bout en bout — les deux stratégies (React natif du core 5.2 via {{#react}} vs React embarqué dans un plugin via esbuild → AMD), la toolchain complète, le passage de données PHP → React, le tutoriel local_reactdemo (React 19 + TypeScript + hooks + web services), et le verdict honnête « quand React est-il justifié ? ».