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

Made with Slides.com