Understanding JavaScript behaviors in Drupal

by Juampy NR

I can barely remember the first time I added JavaScript to a Drupal page using a custom module. I'm sure looked at the documentation, took the example snippet, tweaked it, tested it, and moved on to something else. It was only later, using a debugger, that I saw how Drupal's "behavior" system worked and realized that my code was not being executed as I expected.

In this article, we'll cover the key facts about Drupal behaviors, then look at a real Drupal project to inspect its behaviors and optimize them.

Unraveling Drupal behaviors

Drupal’s official JavaScript documentation suggests that modules should implement JavaScript by attaching logic to Drupal.behaviors. Here is an example taken from that page:


Drupal.behaviors.exampleModule = {
  attach: function (context, settings) {
    $('.example', context).click(function () {
      $(this).next('ul').toggle('show');
    });
  }
};
  

Drupal core will call attached behaviors when the DOM (the object representation of the HTML) is fully loaded, passing in two arguments:

  • context: which contains the DOM.
  • settings: which contains all the settings injected server side.

We can confirm this at the following snippet extracted from Drupal core’s misc/drupal.js:


// Attach all behaviors.
$(function () {
  Drupal.attachBehaviors(document, Drupal.settings);
});
  

NOTE: $(function () is a shorthand for $(document).ready(). See http://api.jquery.com/ready for further details.

Now here is the point where I had that “aha!” moment the first time I watched the code execute in a debugger: Drupal.attachBehaviors() may be called more times under different circumstances after the DOM is loaded. For instance, Drupal core will also call Drupal.attachBehaviors() in these scenarios:

  • After an administration overlay has been loaded into the page.
  • After the AJAX Form API has submitted a form.
  • When an AJAX request returns a command that modifies the HTML, such as ajax_command_replace().

Furthermore, modules may call Drupal.attachBehaviors() as well. Here are some examples:

  • CTools calls it after a modal has been loaded.
  • Media calls it after the media browser has been loaded.
  • Panels calls it after in-place editing has been completed.
  • Views calls it after loading a new page that uses AJAX.
  • Views Load More calls it after loading the next chunk of items.
  • JavaScript from custom modules may call Drupal.attachBehaviors() when they add or change parts of the page.

The first time that Drupal.attachBehaviors() is called, the context variable contains the document object representing the DOM, but for the rest of the calls context will hold the affected piece of HTML. This is often overlooked by developers, leading to inefficient code that either causes tricky bugs or stresses the browser.

How can we make sure that existing Drupal behaviors take this into account? In the following section we will debug and analyze them in a real Drupal project.

Navigating through Drupal behaviors

While working on a Drupal project, I like to open a page and debug which behaviors are executed to make sure that they are efficient. I set a breakpoint at Drupal.attachBehaviors(), then open a page and follow the code of each behavior to see what it is doing.

Let’s take the Syfy project as an example, where Lullabot helped with its redesign and launch. Using Google Chrome Developer Tools, we will open Syfy’s homepage and set a breakpoint at the following line of core’s misc/drupal.js:

Google Chrome Debugging Console

After reloading the page, the debugger will kick in and stop code execution—waiting for us to make a decision on what to do:

Debugger started

If you do not have experience using a debugger, have a look at the Debugging JavaScript section for Google Chrome. A debugger lets you follow the code step by step inspecting the current variables and letting you write statements against the current context.

We said before that "Drupal will call all attached behaviors when the DOM is fully loaded, passing as arguments the DOM and all the settings injected server side.” Let’s verify this in the debugger. Here we can see the context and settings variables at the Scope Variables panel:

Scope Variables Panel

Most custom behaviors are to be executed just once, so on this first loop things are normally fine. The tricky bit comes later, once all behaviors have been processed and something calls Drupal.attachBehaviors() again. Let’s see an example.

Testing behaviors on subsequent calls

Syfy’s homepage has a View of tiles that uses Views Load More module to load extra items at the bottom. We will now scroll down and click on Load More to see what happens:

Load More Button

Drupal calls /views/ajax to load the next list of tiles and once it has appended them to the DOM, it executes Drupal.attachBehaviors() with a subtle difference that we can discover when we inspect its variables in the debugger:

Gotta love context

The context variable does not contain the full HTML document but the updated view. This difference is huge. Drupal behaviors that take the context variable into account when selecting portions of the DOM such as $('#some-id', context) will be quickly skipped since they won’t find what they are looking for. Unfortunately, it is a common mistake to overlook this and not use the context variable in jQuery selectors. Here is a behavior that I found while debugging behaviors in this second loop:


/**
  * Hide menu when Esc is pressed.
  */
Drupal.behaviors.syfyGlobalHideMenu = {
  attach: function (context, settings) {
    $(document).keyup(function (e) {
      if (e.keyCode == 27) {
        $('.nav-flyout', context).removeClass('js-flyout-active');
      }
    });
  }
};
  

It sets a listener at the document level to check if the pressed key was Esc in order to gracefully hide the main menu, which is expanded in the following screenshot:

Syfy's menu expanded

What is the problem here? It uses the context variable to find the menu at $('.nav-flyout', context), but it does not use context when it sets the keyup listener at $(document).keyup(function(e). This means that every time Drupal behaviors are processed, it will add a new keyup listener.

Making behaviors behave

There are a few ways to fix the above behavior so it sets a listener just once. Let’s see each of them and alter the above behavior accordingly. Here they are.

Using jQuery Once

This is the recommended approach at the Drupal.org JavaScript documentation. jQuery Once makes sure that something is processed just one time by adding a class on a DOM element after the code has run.


/**
  * Hide menu when Esc is pressed.
  */
Drupal.behaviors.syfyGlobalHideMenu = {
  attach: function (context, settings) {
    $('.nav-flyout', context).once('remove-modals', function () {
      $(document).keyup(function (e) {
        if (e.keyCode == 27) {
          $('.nav-flyout', context).removeClass('js-flyout-active');
        }
      });
    });
  }
};
  

The above code will add a class remove-modals-processed the first time it runs. The next time that Drupal.attachBehaviors() is called, .once() will find the class and skip.

Using the context variable in the jQuery selector

Not using the context variable in jQuery selectors is the source of most of headaches related with debugging buggy JavaScript behaviors. Here is an example where we make use of the context variable in our selector:


/**
  * Hide menu when Esc is pressed.
  */
Drupal.behaviors.syfyGlobalHideMenu = {
  attach: function (context, settings) {
    $(document, context).keyup(function (e) {
      if (e.keyCode == 27) {
        $('.nav-flyout', context).removeClass('js-flyout-active');
      }
    });
  }
};
  

The above approach is simple and effective. jQuery will find the document object in the context variable only the first time that Drupal.attachBehaviors() is called. Note, however, that if you have custom code that calls Drupal.attachBehaviors(document); then the condition will be met and another listener will be bound.

Going our own way

Our last alternative is ignoring Drupal's behavior system and simply waiting for the document object to be ready:


/**
  * Hide menu when Esc is pressed.
  */
$(function () {
  $(document).keyup(function (e) {
    if (e.keyCode == 27) {
      $('.nav-flyout').removeClass('js-flyout-active');
    }
  });
});
  

The above code will work as expected, and If you don't need access to the niceties of Drupal behaviors, it's a perfectly valid approach.

Conclusion

The main concept that we should take into account is that Behaviors will be called first when the DOM is loaded, and may be called more times with a context representing new additions or changes to the DOM. Our code has to be crafted so it kicks in only when it is needed.

Did this article spark your curiosity to open a project and navigate through its behaviors? I hope it did. Open a page, dive through each behavior and ask yourself questions like when should this piece of code run? should it run when the DOM is ready or after some AJAX request completes? This mindset will help you to write sharp and efficient JavaScript.

newsletter-bot