The Perfect Drupal JavaScript Behavior

Christopher Bloom

slides.com/illepic/drupal-js

Photo by Jonas Denil on Unsplash

Stop. Read this.

Drupal runs JS on a page in a very specific way.

Photo by Fancycrave on Unsplash

// core/misc/drupal.init.es6.js
(function(domready, Drupal, drupalSettings) {
  // Attach all behaviors.
  domready(() => {
    Drupal.attachBehaviors(document, drupalSettings);
  });
})(domready, Drupal, window.drupalSettings);
// core/misc/drupal.es6.js
Drupal.attachBehaviors = function(context, settings) {
  context = context || document;
  settings = settings || drupalSettings;
  const behaviors = Drupal.behaviors;
  // Execute all of them.
  Object.keys(behaviors || {}).forEach(i => {
    if (typeof behaviors[i].attach === 'function') {
      // Don't stop the execution of behaviors in case of an error.
      try {
        behaviors[i].attach(context, settings);
      } catch (e) {
        Drupal.throwError(e);
      }
    }
  });
};

IIFE

(function moduleNameClosure(local, alias, for, those, window, D) {
  // We're safe in here ...




}(things, that, live, on, window, Drupal));

Immediately invoked function expression

  • Defines function, captures scope, then executes it
  • Prevent code leakage into global scope
  • Immediate break if outer vars undefined
  • Name it, no anonymous functions

Namespace

(function moduleNameClosure(D) {
  D.behaviors.p2UniqueNamespace = {
    attach: function drupalAttach() {
      // Do stuff ...
    },
  };
}(Drupal));

STOP. Is your behavior name unique?

  • According to Drupal.attachBehaviors() in core, every unique key at Drupal.behaviors with a function at .attach is executed at document.ready()
  • Note that Drupal.behaviors is just a big object with many keys
Object.keys(Drupal.behaviors).find(b => b === 'myNamespace')
  ? 'DANGER! NAMESPACE EXISTS!'
  : 'Your namespace is safe';

Behaviors

(function moduleNameClosure(D) {
  D.behaviors.p2UniqueNamespace = {
    attach: function drupalAttach(context) {
      $('.thing', context)
        .once('p2UniqueNamespace')
        .addClass('flerp');
    },
  };
}(Drupal));

Assume every behavior fires multiple times

  • jQuery.once() can help
  • If .once() is needed, generally a sign something else should be fixed

ES6 Linting

(function moduleNameClosure(D, $) {
  D.behaviors.p2UniqueNamespace = {
    attach() { // Enhanced object literals shorthand functions
      $('.thing')
        .on('click', () => doStuff()); // Arrow. Warning: scope
    }, // Trailing comma = good
  }; // semicolon
}(Drupal, jQuery));

Study your favorite linter to tune your code

  • AirBnB + Babel is nice
  • Note the shorthands available
  • Prettier is your friend
  • IE11 sucks. IE11 is the "lowest" browser we support

Context

(function moduleNameClosure(D, $) {
  D.behaviors.p2UniqueNamespace = {
    attach(context) { // "$" convention for jQuery element
      $('.flerp', context).doAwesome();
      // OR
      $(context).find('.flerp').doAwesome();
    },
  };
}(Drupal, jQuery));

context = document + AJAX HTML

  • Scoped selector, limits where jQuery looks
  • Provided entire page on load or returned HTML via Drupal AJAX asynchronously
  • EVERY selector must be scoped

Settings

(function moduleNameClosure(D, $) {
  D.behaviors.p2UniqueNamespace = {
    attach(context, settings) {
      const { bloomModule } = settings; // Destructuring
      $('.flerp', context)
        .doAwesome(bloomModule.awesomeLevel);
    },
  };
}(Drupal, jQuery));

Provide data from PHP -> JS

  • Alias for global drupalSettings object
  • Allow your PHP to communicate to JS
function bloom_preprocess_html(&$vars) {
  $vars['#attached']['drupalSettings']['bloomModule']['awesomeLevel'] = 'RAD';
}

Guard yo' execution

(function moduleNameClosure(D, $) {
  D.behaviors.p2UniqueNamespace = {
    attach(context, settings) {
      const { bloomModule } = settings;
      const $flerp = $('.flerp', context); // Var your jQuery
      // No settings or no DOM, GET OUT
      if (!bloomModule || !$flerp.length) {
        return;
      }
      $flerp.doAwesome(bloomModule.awesomeLevel);
    },
  };
}(Drupal, jQuery));

Assume nothing exists

  • If your data doesn't exist, bail
  • If your DOM doesn't exist, bail

jQuery Delegates

(function moduleNameClosure(D, $) {
  D.behaviors.p2UniqueNamespace = {
    attach(context, settings) {
      const $header = $('.header', context);
      const $menu = $header.find('.menu'); // scoped to $header
      const $trigger = $menu.find('.trigger'); // scoped to $header $menu

      // Single event bubbles up for many href elements
      $header.on('click', 'a', function headerClick(e) {
        $(e.delegateTarget).css('border', 'red'); // $header
        $(this).css('color', 'blue'); // this = <a>
      });
      
      $trigger.on('click', () => $menu.toggle());
    },
  };
}(Drupal, jQuery));
  • Bind many elements to a single event
  • Selectors are scoped to components

function(){} vs () => {}

(function moduleNameClosure(D, $) {
  D.behaviors.p2UniqueNamespace = {
    attach(context, settings) {
      const $header = $('.header', context);

      // "this" is VERY important to click()
      $header.on('click', 'a', function headerClick() {
        $(this).css('color', 'blue'); // this = <a>
      });

      // Just need to execute something, scope doesn't matter
      $trigger.on('click', () => $menu.toggle());
    },
  };
}(Drupal, jQuery));
  • Do you need "this"/protected scope? Use function(){}
  • Otherwise, use arrows

Pure functions

(function moduleNameClosure(D, $) {
  // Helper function
  function findAllTheThings(collection) {
    return collection
      .filter(({ name }) => name === 'Bill Murray')
      .map(person => {
        person.awesome = person.awesome * 10;
        return person;
      })
  }

  D.behaviors.p2UniqueNamespace = {
    attach(context, settings) {
      const { people } = settings;
      if (!people) {
        return;
      }

      $peopleDivs = findAllTheThings(settings.people)
        .map(person => $(<div/>).text(person));

      $('.people-holder', context).append($peopleDivs);
    },
  };
}(Drupal, jQuery));
  • Outside namespace
  • Inside IIFE

Global utils

// utils.js
(function utilsClosure(global) {
  const onlyAwesome = collection =>
    collection.filter(({ name }) => name === 'Bill Murray');

  global.bloomUtils = {
    onlyAwesome, // Enhanced object literals shorthand props!
  };
}(window));

// bloom-module.js
(function moduleNameClosure(D, $, u) {
  D.behaviors.p2UniqueNamespace = {
    attach(context, settings) {
      const { people } = settings;
      if (!people) { return; }

      $peopleDivs = u.onlyAwesome(people)
        .map(person => $(<div/>).text(person));

      $('.people-holder', context).append($peopleDivs);
    },
  };
}(Drupal, jQuery, bloomUtils));
  • Put a helper namespace on window
  • Share business logic across your project

Don't store state in DOM

(function moduleNameClosure(D, $) {
  const state = {
    menuOpen: true,
  };

  D.behaviors.p2UniqueNamespace = {
    attach(context, settings) {

      const state2Dom = (context, state) => {
        $('.menu', context).toggleClass('open', state.menuOpen);
      };
 
      $('.toggle', context)
        .on('click', () => state.menu = !state.menu);

      state2Dom();
    },
  };
}(Drupal, jQuery));
  • Manipulate data, "Reconcile" that data to HTML
  • Data is fast, DOM thrashing is slow

Use lodash/underscore

(function moduleNameClosure(D, _) {
  D.behaviors.p2UniqueNamespace = {
    attach(context, settings) {
      // get() is safe object querying
      const thing = 
        _.get(settings, 'bloomModule.some.thing', 'default');

      // assign() (available in ES6) cascades overrides of object
      const defaults = { color: blue };
      const carouselSettings = 
        _.assign({}, defaults, settings.carousel);

      const days = _.range(1, 5).map(day => getResults(day));
      
      $(window)
        .on('resize', _.debounce(() => console.log('blerp')), 400));
    },
  };
}(Drupal, _));
  • Shut up, just use it
  • Underscore in Drupal 8 core, replaced by lodash in Particle

STOP LOOPING

(function moduleNameClosure(D) {
  D.behaviors.p2UniqueNamespace = {
    attach(context, settings) {
      // Gross
      let loop = 0;
      const peopleArray = [];
      for (loop; loop < settings.people.length; loop++) {
        const person = settings.people[loop];
        person.fullName = `${person.firstName} ${person.lastName}`;
        peopleArray.push(person);
      }

      // Awww yisss
      const betterArray = settings.people.map(person => ({
        ...person,
        { fullname: `${person.firstName} ${person.lastName}` },
      }));
    },
  };
}(Drupal));
  • Functional transforms will serve you better

Use fetch and promises

(function moduleNameClosure(D) {
  D.behaviors.p2UniqueNamespace = {
    attach(context, settings) {
      const { murrayQuotesUri } = settings.p2;
      const $widget = $('.widget', context);
      if (!murrayQuotesUri || !$widget.length) { return; }

      fetch(murrayQuotesUri)
        .then(data => data.json())
        .then(quotes => quotes.map(quote => $('<div/>').text(quote)))
        .then($quotes => $widget.append($quotes));
    },
  };
}(Drupal))
  • Promises > callbacks
  • Polyfill if need be, it's worth it

Share between behaviors

// rando-module/js/rando-module.js
(function randoModuleClosure(D, $, u) {
  D.behaviors.randoModule = {
    attach(context, settings) {
      // Other modules subscribe to the promise
      u.quotes
        .then(qs => qs.map(q => $("<div/>".text(q))))
        .then($qsEl => $(".w", context).append($qsEl));
    },
  };
})(Drupal, jQuery, p2Utils);

If multiple behaviors share logic, move the logic to a dependent library

# p2-utils/p2-utils.libraries.yml
core:
  js: js/utils.js
  dependencies:
    - core/drupal
    - core/drupalSettings
// p2-utils/js/utils.js
(function utilsGlobalClosure(global) {
  global.p2Utils = {
    init(uri) {
      this.quotes = fetch(uri)
        .then(data => data.json());
    }
  };
})(window);

(function utilsModuleClosure(D, $, u) {
  D.behaviors.p2Utils = {
    attach(context, settings) {
      const { murrayUri } = settings.p2;
      if (murrayUri) {
        u.init(murrayUri); // Util calls API
      }
    }
  };
})(Drupal, jQuery, p2Utils);
# rando-module/rando-module.libraries.yml
core:
  js: js/rando-module.js
  dependencies:
    - p2-utils/core
    - core/drupal
    - core/drupalSettings

Don't do this

// Mistaken that all functions had to be behaviors
Drupal.behaviors.randomFunction = function() { }

// Didn't understand settings arg
Drupal.behaviors.nameSpace = {
  attach: function(context, settings) {
    const mySetting = Drupal.settings.mySetting;
  }
};

// Non-strict equality
var thing == 'something';

// .attach() already runs on $(document).ready()
$(document).ready(function() {});

// Stop using 'use strict'
'use strict';
  • Taken from actual production code

Common gotchas

  • Non-unique behavior name.
    • I have lost a hundred hours to this one
  • Overriding a global variable name.
    • "data" is not a good var to put on window
  • Not selecting against "context"
    • The Mysterious Case of the Click That Fired 37 Times
  • Putting too much in a Drupal behavior
    • A behavior should do the bare minimum to provide data to and execute your JavaScript app
    • Can my code run WITHOUT Drupal? (See Particle)
  • Over-nesting
    • Guard early
    • Most JavaScript doesn't need to live in deep if statements

Thanks!

I'm available to talk JS ANYTIME.

Photo by Jeremy Thomas on Unsplash

The Perfect Drupal JavaScript Behavior

By Christopher Bloom

The Perfect Drupal JavaScript Behavior

Tips and best practices to write quality JavaScript in Drupal.

  • 7,123