High Performance JavaScript using Drupal 7's JavaScript API

Overview

In today's web, JavaScript is an essential component of any nice, easy to use website. However, while the basics of JavaScript can be easy to learn, it can be hard to know whether the scripts you are writing are heavy, causing page lag, and making for longer load times.

Drupal comes with an excellent JavaScript API, allowing for the creation of high-performance applications, but it can be confusing to know exactly how to effectively use the API.

This tutorial will go over various elements of Drupal 7's JavaScript API, and how to use them to get the most out of your JavaScript, with the least overhead, creating the smoothest user experience possible.

Presentation

This tutorial accompanies a presentation done at Drupal Meetup Tokyo #3 on September 23, 2016. A video of the presentation can be seen at https://youtu.be/8xt7NtndHfk (Japanese only). The accompanying PowerPoint presentation can be downloaded at http://jaypan.com/presentations/Drupal.JavaScript.API.pptx

What defines high performance JavaScript?

High performance JavaScript is getting the most functionality out of your JavaScript, with the lightest memory footprint possible, on each page load. This is achieved through the following means:

  • Adding JavaScript files in a cacheable manner
  • Adding JavaScript files in a manner to ensure that only necessary scripts are included on page load
  • Ensuring event handlers are only attached to elements a single time
  • Traversing the minimum number of elements possible when attaching event handlers
  • Releasing memory when elements using that memory are removed from the DOM (page)

Adding JavaScript to pages - History

Drupal has various methods of adding JavaScript to pages. First, we'll start with a quick review of the history of these methods, to see how Drupal's API has progressed.

Drupal 6

Drupal 6 had two primary means of adding JavaScript to pages:

  • Using scripts[] in the .info file for modules and themes. This method was easy to use, however any scripts added in this manner were included in every page load, whether they were needed or not.
  • Using Drupal's PHP function drupal_add_js(). This function could be called almost anywhere from within Drupal 6, and anything included in the function would be included on that page load.

Drupal 7

Drupal 7 had the same two methods as Drupal 6, and added the following two methods:

  • The #attached property of render arrays. By attaching JavaScript in this manner, the scripts were cacheable along with the render array, making for more efficient page loads. It also allows for overriding of the render array further down the page build process (to be explained later in this tutorial), so that scripts could be more finely tuned.
  • Creating 'libraries' of scripts, defined in hook_library(). Defining a library within this hook allows for the grouping of a set of JS and CSS files, which can then be added as part of a render array, or by calling drupal_add_library().

Drupal 7 also added hook_js_alter(), which allows for adding/removing/altering scripts before they are delivered to the browser, allowing for fine-tuning of exactly what scripts are part of the page load.

Drupal 8

Drupal 8 uses asset libraries, similar to hook_library() in Drupal 7. Drupal 8 asset libraries are defined in *.libraries.yml files. These asset libraries are then added as the #attached property of render arrays, same as in Drupal 7. In Drupal 8, drupal_add_js() no longer exists.

This tutorial discusses JavaScript in Drupal 7

Adding JavaScript to pages

This tutorial will discuss two methods of adding scripts to pages in Drupal 7.

In module and theme .info files

Adding JavaScript to a module or theme is as simple as adding a single line to the .info file for the module/theme, in the following manner.

scripts[] = some_script.js

Pros:

  • Easy to add
  • Cached
  • Passes through the JavaScript API

Cons:

  • Added to every page, which adds unnecessary overhead if not needed on every page.
  • Configuration options cannot be fine-tuned
  • Can only be removed from page loads by implementing hook_js_alter()

Using the #attached property of render arrays

JavaScript can be attached to any render array in the Drupal page load, using the 'js' key of the '#attached' property of the render array, as follows:

  1. $element['#attached']['js'][] = [
  2. 'type' => 'file',
  3. 'data' => 'path/to/some_script.js',
  4. ];

Pros:

  • Configuration options can be finely tuned
  • Scripts are included when caching is enabled
  • Scripts can easily be removed further down the page build process if not needed

Cons:

  • Harder to learn

Configuration options when adding scripts

When attaching JavaScript to pages using drupal_add_js(), or the #attached property of render arrays, Drupal's JavaScript API provides various configuration options for fine tuning the method as to where the script is attached to the DOM:

Scope

The first configuration option we'll look at is the scope. The scope is the location within the DOM where the scripts will be added. The default scope for scripts is 'header', with the other option being 'footer'. Any scripts set with a scope of header will be put in the section of the DOM, and any scripts with a scope of footer will be added before the closing tag.

Group

There are three groups to which scripts can be added:

  • JS_LIBRARY - this is used for core JS scripts like jQuery, drupal.js and ajax.js
  • JS_DEFAULT - this is the default level at which scripts are added unless otherwise specified
  • JS_THEME - this is where scripts added through the .info file in themes will be added

Groups of scripts are added in the above order. This ensures that core libraries like jQuery are available to all scripts added later in the page building process.

Weight

Scripts are given a default weight of 0 (zero). However a different weight can be used, and scripts will be added from lightest (negative numbers) to heaviest (positive numbers), within the group in which they are defined.

How scripts are added to the page

Scripts are added to the page as follows:

  1. Scripts are separated by scope (header and footer)
  2. Scripts within a scope are ordered by group (JS_LIBRARY etc)
  3. Scripts within a group are ordered by weight

Additional configuration options

There are a couple of other configuration options to look at that have relevance.

every_page:

  • Defaults to FALSE
  • All files with this set to TRUE are aggregated together, and included on eveyr page load
  • JavaScript added in .info files have this set to TRUE

preprocess

  • Defaults to TRUE
  • When aggregation is turned on, all files with preprocess set to TRUE will be aggregated into files according to their scope and group, and included on every page
  • Ideally should never be FALSE, as it adds more overhead when aggregation is turned on

Configuration example with drupal_add_js()

When using drupal_add_js(), the configuration options discussed above can be set as follows:

  1. drupal_add_js(
  2.  
  3. // The path to the script
  4. drupal_get_path('module', 'my_module') . '/js/some_script.js',
  5.  
  6. // Configuration options
  7. [
  8. 'scope' => 'footer',
  9. 'group' => JS_LIBRARY,
  10. 'weight' => 1,
  11. 'every_page' => TRUE,
  12. ]
  13. );

Configuration example with #attached

When using the #attached property of render arrays, the configuration options discussed above can be set as follows:

  1. $element['some_element'] = [
  2.  
  3. // An element needs either #markup or #theme for the JS to be rendered.
  4. '#markup' => '<p>' . t('Delete') . '</p>',
  5. '#attached' => [
  6. 'js' =&gt; [
  7. [
  8. 'type' => 'file',
  9. // The path to the script.
  10. 'data' => drupal_get_path('module', 'my_module') . '/js/delete_link.js',
  11. 'scope' => 'footer',
  12. 'group' =&gt; JS_LIBRARY,
  13. 'weight' => 1,
  14. 'every_page' => TRUE,
  15. ],
  16. ],
  17. ],
  18. ];

Further reading

More information on the configuration options for adding JavaScript to Drupal 7 can be found on the API documentation for drupal_add_js().

Adding JavaScript in an effective manner

In Drupal 7, it's preferable to add JavaScript using the #attached property of render arrays, over using drupal_add_js(). Scripts attached using #attached are cached whenever possible, while scripts attached using drupal_add_js() are not. For example, in hook_block_view(), if drupal_add_js() is used, and block caching is turned on, the scripts will not be included. However, if #attached is used, and block caching is turned on, then the script will be added.

The other disadvantage of using drupal_add_js() is that the only way a script can be removed further down the page build process, is to use hook_js_alter().

When using #attached on the other hand, the JavaScript is only attached to the page if the render element it is attached to is rendered. To explain further, lets look at the following code:

  1. $element['wrapper'] = [
  2. 'content' => [
  3. // content of the element goes here
  4. 'read_more_link' => [
  5. '#markup' => '<a class="read_more" href="/node/123">' . t('Read More') . '</a>',
  6. '#attached' => [
  7. 'js' => [
  8. [
  9. 'type' => 'file',
  10. 'data' => drupal_get_path('module', 'my_module') . '/js/read_more_link.js',
  11. ],
  12. ],
  13. ],
  14. ],
  15. ]
  16. ];

In the above code, the read_more_link.js file is attached to $element['wrapper']['read_more_link']. Somewhere else in the code, we may have an alter function as follows (purely hypothetical code):

  1. function mymodule_element_view_alter(&$build) {
  2. $build['wrapper']['read_more_link']['#access'] = FALSE;
  3. }

Now, $element['wrapper']['read_more_link'] has had its access set to FALSE, which means that the element will not be rendered on page load. As this element is the one that has the JavaScript attached to it, and the element is not rendered, the JavaScript will not be included in the output for the page.

As the two examples above show, due to caching, and the ability to more easily fine-tune the scripts added to the page, it's much preferable to add JavaScript using #attached than using drupal_add_js().

Writing Effective Scripts

Wrapping scripts in anonymous functions makes code more bulletproof. Any failures of code inside an anonymous function will usually not kill the rest of the JavaScript on the page. An anonymous function wrapper looks like this:

  1. (function() { // Code goes here }());

Any global JavaScript variables, such as the Drupal object, or the jQuery object, should be passed into the function as follows:

  1. (function(Drupal){ // Use the Drupal object here }(Drupal));

Global variables can be aliased inside the anonymous function. For example, most people familiar with jQuery are used to coding with the $ object, referring to jQuery. However, Drupal by default sets jQuery to jQuery.noConflict(), meaning that trying to use the $ object will result in an error. However, we can alias jQuery as $ inside our anonymous function as follows:

  1. (function($) {
  2. // Using $ below is ok, as $ exists inside the anonymous function:
  3. $(".some_element").hide();
  4. }(jQuery));
  5.  
  6. // This is ok, as jQuery exists globally;Z
  7. jQuery(".some_element").hide();
  8.  
  9. // This will cause an error, since $ only exists inside the anonymous function,
  10. // and this code is outside the anonymous function:
  11. $(".some_element").hide();
  12.  

As you can see, the global variable name, jQuery, is passed in at the end of the anonymous function, and the aliased name, $, is given at the start of the function.

Another benefit to using anonymous function wrappers is that it allows us to not worry about reusing function names. If a function name is re-used in the global scope, the second version will overwrite the first version. For example:

  1. function doSomething() {
  2. console.log("this");
  3. }
  4.  
  5. function doSomething() {
  6. console.log("that");
  7. }
  8.  
  9. doSomething();

As the second function declaration overwrites the first one, this code will write 'that' to the console, and the first function is never called.

Functions written into separate anonymous function wrappers will not overwrite each other, as the function only exists inside the wrapper in which it's defined:

  1. (function() {
  2. function doSomething() {
  3. console.log("this");
  4. }
  5. doSomething();
  6. }());
  7.  
  8. (function() {
  9. function doSomething() {
  10. console.log("that");
  11. }
  12. doSomething();
  13. }());

In the above code, the function name doSomething() is used inside each anonymous wrapper, but these will not conflict since they don't exist within the same scope. Therefore, both 'this' and 'that' will be printed to the console.

Using anonymous functions means that when writing scripts, we can use simple, descriptive function names inside each script, without having to worry that they are overwriting, or will be overwritten by, other scripts somewhere else on the page.

In the same manner that function names only exist within the scope/function in which they are defined, so do variables only exist within the function in which they are defined. For example:

  1. (function() {
  2. (function(Drupal) {
  3. var message = Drupal.t("hello");
  4. alert(message);
  5. // alerts "hello"
  6. }(Drupal));
  7. // 'message' does not exist outside the anonymous function:
  8. alert(message); // Error

Because the variable message is defined within the anonymous function, trying to use this variable outside the function will result in an error.

Storing jQuery elements in script-wide variables

One way that variables 'global' to a script can be used, is to store references to a jQuery object that is regularly used in a script. When jQuery objects are created, the entire page is parsed for the selector given in the object. This causes overhead each time that object is searched for. Storing the object in a variable however saves having to parse the page for that object when it is needed in the future. For example, if a specific container will have new nodes loaded into it regularly throughout a script, a reference to the container can be stored in a script-wide variable when the script is initialized, as follows:

  1. (function() {
  2. // Declare our variable. This will be available to any function in this script
  3. var container;
  4.  
  5. // The function to initialize this script function
  6. init() {
  7. // Save our jQuery object into the container variable
  8. container = $("#node_container");
  9. }
  10. // Call our initialization script
  11. init();
  12.  
  13. // container now contains $("#node_container"), for use anywhere in the script
  14. }());

Drupal.behaviors

Drupal.behaviors is an extremely important and powerful part of the Drupal JavaScript API. Behaviors has two methods:

  • attach()
  • detach()

Drupal.behaviors - attach()

This is the Drupal equivalent of window.onload() or jQuery's $(document).ready(). The attach() method of Drupal.behaviors is executed when the page (DOM) is loaded, and should be used instead of window.onload and/or $(document).ready().

Each module should use a unique key for Drupal.behaviors. We at Jaypan will usually use the filename in camelCase, as our unique key. This example just uses uniqueKey:

  1. (function(Drupal) {
  2. Drupal.behaviors.uniqueKey = {
  3. attach:function() {
  4. alert("attached");
  5. }
  6. };
  7. }(Drupal));

With the above code, when the page/DOM is loaded, the JavaScript will alert "attached". This is a good way to test if your script has been loaded into the page, and that your wrapper is structured correctly.

Unlike window.onload and $(document).ready(), the attach() method of Drupal.behaviors is also called by various Drupal scripts whenever new elements are inserted into the DOM. Any script can do this using the function Drupal.attachBehaviors() (more on this later).

Context

When attaching (and detaching) behaviors, the 'context' is passed into the function. The context is the portion of the document being loaded. On initial page load, this will be the entire document, and with further calls, it will be the new DOM elements being added. The context is passed to the relevant jQuery selector, to ensure that only elements found within the context are selected. Example:

  1. (function($, Drupal) {
  2. Drupal.behaviors.uniqueKey = {
  3. attach:function(context) {
  4. // Any .some_class elements found inside context will be retrieved:
  5. $(".some_class", context).appendTo("body");
  6. }
  7. }
  8. }(jQuery, Drupal));

With the above code, on initial page load, the context is the entire document. As the context is passed as the second argument to our jQuery element ($(".some_class", context)), the entire document will be searched for any elements with a class of .some_class. After page load, subsequent calls to attach() will contain the newly inserted elements in the DOM, and as such, only those newly inserted elements will be searched for elements with a class of .some_class. It is important to use context, so as to keep your script as light as possible. If context is not used, the entire DOM will be searched each time attach is called, making for more overhead in your JavaScript.

Attaching Behaviors

Behaviors are attached by calling Drupal.attachBehaviors(). This should be called anytime new content is inserted into the DOM, such as after an AJAX load. For example, if we are using the following jQuery $.ajax() function to load new content, we would call Drupal.attachBehaviors() as follows:

  1. $.ajax({
  2. url:"/path/to/some/ajax/callback",
  3. success:function(data) {
  4. // data.newElements contains HTML to be inserted into the DOM
  5. $("#some_element").html(data.newElements);
  6. // Next, we call Drupal.attachBehaviors() on the new elements.
  7. // Remember that the argument passed to this function, in this case
  8. // $("#some_element"), becomes the 'context' variable in
  9. // Drupal.behaviors.[KEY].attach.
  10. Drupal.attachBehaviors($("#some_element"));
  11. }
  12. });

By passing $("#some_element") to Drupal.attachBehaviors(), this becomes the context in the attach method of Drupal.behaviors. If Drupal.attachBehaviors() is called without passing any argument, the entire document is used as the context, adding unnecessary overhead, since the rest of the DOM other than the new elements would have been searched already when the document was originally loaded.

Context - caveat

When creating a jQuery element using context as follows:

$(".node", context) 

jQuery will search for the selector within the children of context. That means that if the class is in the outermost (parent) object of context, it will not be found. For example if this HTML is the context:

  1. <div class="node">
  2. <p>Node contents</p>
  3. </div>

The jQuery object will be empty, because .node is the outermost element of the HTML.

If you find that this is happening in a script you've written, then this can be fixed by changing this:

  1. Drupal.attachBehaviors(newElements);

To this:

Drupal.attachBehaviors(newElements.parent()); 

By passing the parent of whatever elements have been inserted into the DOM, the selector will now be a child of the context, and will be found. However, note that this will add additional elements into context that other than the newly inserted elements, and therefore may add significant overhead to your script. As such, it should only be done when absolutely necessary.

Drupal.behaviors - detach()

The detach() method of Drupal.behaviors is called right before elements are to be removed from the DOM. It should be called to remove any event handlers attached to elements that are about to be removed.

Just like attach(), context is used as an argument. Context will contain the elements of the DOM that are about to be removed, and context should be used with any jQuery selectors.

  1. (function($, Drupal) {
  2. Drupal.behaviors.uniqueKey = {
  3. detach:function(context) {
  4. $(".some_class", context).unbind("click");
  5. }
  6. };
  7. }(jQuery, Drupal));

With the above code, anytime an element with a class of .some_class is about to be removed from the DOM, it will first have its click handlers removed. This keeps elements that don't exist anymore from having handlers left in memory, creating unnecessary overhead.

Detaching Behaviors

Behaviors are detached using Drupal.detachBehaviors(). This should be called on any elements right before they are about to be removed from the DOM. For example, in the following code, a delete link is clicked, and the element it belongs to is removed from the DOM:

  1. $(".node_hide_link").click(function() {
  2. // Traverse the DOM upwards until the node wrapper is found
  3. var node = $(this).parents(".node:first");
  4. node.slideUp(300, function() {
  5. // $(this) is $(".node")
  6. Drupal.detachBehaviors($(this));
  7.  
  8. // Remove .node from the DOM
  9. $(this).remove();
  10. });
  11. });

With this code, when .node_hide_link is clicked, the DOM is traversed upwards to find it's parent .node. This element then has $.slideUp() called on it, which will cause it to collapse upwards. After it has collapsed upwards, it can be removed from the DOM. So first, we pass it to Drupal.detachBehaviors(), which removes any handlers, and then the node is removed from the DOM altogether using $.remove().

jQuery Once

The last item to look at in this tutorial is jQuery.once(). This function is a wrapper function, almost like the anonymous function we are using to wrap our entire scripts. The code inside $.once() is only executed a single time. This is primarily used to attach event handlers, such as click, hover, mousedown etc, to elements in the page. Event handlers should only be attached a single time to an element, as attaching handlers multiple times to an element adds overhead, and page lag, on top of causing unexpected difficult to debug behavior.

The following shows how we would use $.once with the $.click() handler from the last example:

  1. $(".node_hide_link").once("some-unique-key", function() {
  2. // $(this) is $(".node_hide_link")
  3. $(this).click(function() {
  4. // Hide the node
  5. });
  6. });

The above code creates a jQuery object containing all .node_hide_link elements on the page, and loops through them just like jQuery.each(). Each element gets the code inside $.once applied to it, but only a single time. If the element is looped over again in the future, the code inside is ignored. This ensures our $.click() handler is only applied a single time.

Note that the usage of $.once() has changed in D8, and needs to be used differently. More on this can be read here.

Tying it all together

So now to bring this all together. We will put our code in an anonymous function, passing it the jQuery, Drupal and window objects. We will then use Drupal.behaviors' attach(), to call functions that attach click handlers node hide links in the page. As new nodes are added to the page, the node hide links in the newly added nodes will have click handlers attached to them, ensuring that they also work with javascript. And finally, we will add some code that removes those click handlers when the node is hidden and removed from the page.

  1. // We start with our anonymous function. The function is passed
  2. / three global variables, the jQuery object, the Drupal object, and the window
  3. // object. These are the three globals that will be used in this script.
  4. (function($, Drupal, window) {
  5. // Within this function (and therefore this script), there will be
  6. // two script-wide variables. These variables only exist within the anonymous
  7. // function, not in the global scope. These are declared
  8. // at the top of the script for clarity, but are not initialized until later.
  9. var initialized, container;
  10. // This is the function that will load new nodes using Ajax. function
  11. loadNewNodes() {
  12. // The ajax call is made
  13. $.ajax({
  14. // The URL is set to some AJAX callback that will
  15. // return some new nodes
  16. url:"/path/to/server/script/that/loads/new/nodes",
  17. // This is our success function that will be called when
  18. // data is returned from the AJAX callback. The data
  19. // variable is JSON data
  20. success:function(data) {
  21. // We add the new nodes to a div that is created on the fly,
  22. // using $.html() to turn the JSON into HTML elements, so that
  23. // the appropriate event handlers can be attached.
  24. var newNodes = $("<div/>").html(data.nodes);
  25. // Drupal.attachBehaviors is then called, passing the new nodes. This
  26. // will pass the newNodes through any Drupal.behaviors declarations
  27. // (see further down the script)
  28. Drupal.attachBehaviors(newNodes);
  29. // The newNodes are then prepended to the top of the container
  30. // div (which is initialized later in the script)
  31. container.prepend(newNodes.children());
  32. // This function is then set to call itself again, so that new
  33. // nodes can be loaded into the DOM
  34. window.setTimeout(loadNewNodes, 5000);
  35. }
  36. });
  37. }
  38.  
  39. // This function attaches click handlers to node hide links.
  40. // These are any elements on the page that have a class
  41. // of .node_hide_link.
  42. function nodeHideLinkAttach(context) {
  43. // The jQuery object is created using context, which will
  44. // be the full document on page load, meaning that any
  45. // .node_hide_link elements that exist on page load get the
  46. // event handlers applied. When new nodes are inserted into the
  47. // DOM in loadNewNodes(), the context will be the new
  48. // nodes, and only they will be parsed for .node_hide_link.
  49. // Between these two calls to Drupal.behaviors, all elements of
  50. // .node_hide_link will have the click handler applied a single time.
  51. $(".node_hide_link", context).once("node-hide-link-attach", function() {
  52. // When .node_hide_link is clicked, this code is executed.
  53. $(this).click(function(e) {
  54. // First the default link behavior of loading a new page is disabled
  55. e.preventDefault();
  56. // Next, the DOM is traversed upwards, until an element
  57. // with a class of .node is found.
  58. // This element is then collapsed upwards using $.slideUp().
  59. $(this).parents(".node:first").slideUp(300, function() {
  60. // This code is called after $.slideUp() has completed.
  61. // In this code, $(this) is $(".node").
  62. // .node will now be removed from the DOM, so it is
  63. // first passed through Drupal.detachBehaviors().
  64. Drupal.detachBehaviors($(this));
  65. // $(".node") is now removed from the DOM.
  66. $(this).remove();
  67. });
  68. });
  69. });
  70. }
  71.  
  72. // The code inside this function is only to be executed a single time
  73. // for the script. The code inside the function is not to be executed
  74. // when new elements are to be inserted into the DOM. This code
  75. // will be used to initialize the variables declared at the start/top of
  76. // the script, and will also trigger the code that will load new nodes
  77. // into the DOM.
  78. function init() {
  79. // Check if the initialized variable has been initialized. If it hasn't,
  80. // this is the first time this function has been called (ie - on page load),
  81. // and it needs to be executed.
  82. if(!initialized) {
  83. // Initialized is now set to true, so that this code is not run again.
  84. initialized = true;
  85. // We set container to be our container, so that we don't have to traverse
  86. // the DOM every time we need it.
  87. container = $("#some_container_div");
  88. // Now a timeout is set to call the function loadNewNodes()
  89. // after five seconds.
  90. window.setTimeout(loadNewNodes, 5000);
  91. }
  92. }
  93.  
  94. // Here are Drupal behaviors for the script.
  95. Drupal.behaviors.uniqueKey = {
  96. attach:function(context) {
  97. // Initialize the script
  98. init();
  99. // Apply click handlers to .node_hide_link elements.
  100. nodeHideLinkAttach(context);
  101. },
  102. detach:function(context) {
  103. // Remove any event handlers added to elements in this script.
  104. // In this script, $(".node_hide_link") is the only element to have
  105. // a handler attached, so it has its click handlers removed.
  106. $(".node_hide_link", context).unbind("click");
  107. }
  108. };
  109. }(jQuery, Drupal, window));

Summary

Working with Drupal 7's JavaScript API can be a daunting and confusing task at the start, but using the tools in this tutorial, you should be able to write light-weight JavaScript functionality, that runs as smoothly as possible, giving your users a great experience when using your sites! Happy JavaScripting.