Introducing Live Extensions For Better-DOM: What They Are And How They Work

Advertisement

After recently writing an article on “Writing A Better JavaScript Library For The DOM”, I realized that the topic is indeed a very complex one and that it’s important to understand what exactly live extensions are and how they work. In today’s article, I will answer most questions that were asked regarding “live extensions” and help you get going with this new concept.

The Responsibilities Of Live Extensions

Event handling is one of the key principles of working with the DOM. Events are the primary means of receiving feedback from user interaction.

Simple Event Binding

In this first example, documentation and tutorials that cover DOM events is what I call “simple event binding”. You attach a listener for the desired event on the DOM element in which you expect it to happen on.

link.addEventListener("click", function(e) {
  // do something when the link is clicked
}, false);

The first argument indicates the type of an event, the second argument is a listener, and the third argument defines an event phase (so-called “bubbling” or “capturing”). The reason why the last argument exists is because most DOM events traverse the DOM tree from document node to target node (capture phase) and back to the document node (bubble phase). This process is called “event flow” and brings several powerful features.

Live and Delegated Events

Instead of attaching a handler for each element in a group, we can attach one listener onto an ancestor shared by all of the elements in that specific group. Then, we can determine where an event took place using the target property of the event object, passed into the listener. This is known as “event delegation”:

list.addEventListener("click", function(e) {
  if (e.target.tagName === "LI") {
    // do something when a child <li> element is clicked
  }
}, false);

By having all event handlers on a particular parent, we can update the innerHTML property of this element without losing the ability to listen to events for new elements. The feature was called “Live Events” in jQuery, and it quickly became popular because of its ability to filter events by a CSS selector. Later, delegated events replaced them due to their flexibility by allowing to bind a listener to any element within the document tree.

But even event delegation does not overcome the following problems:

  • When DOM mutation is required after a new element (that matches a specific selector) comes into the document tree,
  • When an element should be initialized on a excessive event such as scroll or mousemove,
  • Or on non-bubbling events, e.g. load, error, etc.

This is what live Extensions aim to solve.

Live Extensions Use Cases

Take a look at the following diagram that explains the responsibilities:

live-extension-respo

1. DOM Mutations For Existing And Future Elements

Imagine you want to develop a reusable datepicker widget. In HTML5, there is a standards-based <input type="date"> element that could be used to create a polyfill. But the problem is that this element looks and behaves very different from browser to browser:

dateinputs
Date input element in different browsers.

The only way to make the element behave consistently is to set the type attribute value to "text". This will cancel a legacy implementation and enable JavaScript to make your own. Try defining a live extension with the example below:

DOM.extend("input[type=date]", {
  constructor: function() {
    // cancel browser-specific implementation
    this.set("type", "text");
    // make your own styleable datepicker,
    // attach additional event handlers etc.
  }
});

2. Media Query Callbacks

I highly recommend reading Paul Hayes’ article on how to “Use CSS transitions to link Media Queries and JavaScript”.

“A common problem in responsive design is the linking of CSS3’s media queries and JavaScript. For instance on a larger screen we can restyle, but it might be useful to use JavaScript and pull in different content at the same time, e.g. higher quality images.”

Paul was probably the first who started to use “hidden force” of CSS3 animation events to solve mutation-related problems. Live extensions are powered by the same trick, therefore you can use them to make DOM modifications depending on the current viewport:

DOM.extend(".rwd-menu", {
  constructor: function() {
    var viewportWidth = DOM.find("html").get("clientWidth");

    if (viewportWidth < 768) {
      // hide <ul> and construct Emmet abbreviation for a
      // <select> element that should be used on small screens
      this.hide().after("select[onchange='location=this.value']>" +
        this.children("li").reduce(function(memo, item) {
          var text = item.get("textContent"),
            href = item.find("a").get("href");

          memo.push("option[value=" + href + "]>live-extension-respo{" + text + "}");
          return memo;
        }, []).join("^"));
    }
  }
});

3. Element Media Queries

Back in 2011, Andy Hume implemented a script for applying styles depending on the dimensions of a particular element (not viewport, like for media queries). Later, this technique was named “element media queries”:

“Media queries work really well when you want to adjust the core layouts of the site, but they’re less suited to changing styles at a smaller more granular level.”

With the help of live extensions, it’s easy to implement element media queries support using the offset method:

DOM.extend(".signup-form", {
  constructor: function() {
    var currentWidth = this.offset().width;
    // add extra class depending on current width
    if (currentWidth < 150) {
      this.addClass("small-signup-form");
    } else if (currentWidth > 300) {
      this.addClass("wide-signup-form");
    }
  }
});

4. Efficiently Attach A Global Listener To Frequent Events

DOM.extend(".detectable", {
  constructor: function() {
    // mousemove bubbles but it’s usually a very bad
    // idea to listen to such event on a document level
    // but live extensions help to solve the issue
    this.on("mousemove", this.onMouseMove, ["pageX", "pageY"]);
  },
  onMouseMove: function(x, y) {
    // just output current coordinates into console
    console.log("mouse position: x=" + x + ", y=" + y);
  }
});

5. Listing Non-Bubbling Events On A Document Level

DOM.extend("img.safe-img", {
  constructor: function() {
    // error event doesn’t bubble so it’s not
    // possible to do the same using live events
    this.on("error", this.onError);
  },
  onError: function() {
    // show a predefined png if an image download fails
    this.src = "/img/download-failed.png"
  }
});

Brief Look Into History

The problems which live extensions aim to solve is not entirely new, of course. There are different approaches that address the above-mentioned issues. Let’s have a quick look at some of them.

HTML Components

Internet Explorer started supporting DHTML behaviors with IE 5.5:

“DHTML behaviors are components that encapsulate specific functionality or behavior on a page. When applied to a standard HTML element on a page, a behavior enhances that element’s default behavior.”

To attach behavior to future elements, Internet Explorer used an *.htc file with a special syntax. Here’s an example illustrating how we used to make :hover work on elements instead of <a>:

<PUBLIC:COMPONENT URN="urn:msdn-microsoft-com:workshop" >
  <PUBLIC:ATTACH EVENT="onmouseover" ONEVENT="Hilite()" />
  <PUBLIC:ATTACH EVENT="onmouseout"  ONEVENT="Restore()"  />
  <SCRIPT LANGUAGE="JScript">
  var normalColor, normalSpacing;
 
  function Hilite() {
    normalColor  = currentStyle.color;  
    normalSpacing= currentStyle.letterSpacing;
 
    runtimeStyle.color  = "red";
    runtimeStyle.letterSpacing = 2;
  }

  function Restore() {
    runtimeStyle.color  = normalColor;
    runtimeStyle.letterSpacing = normalSpacing;
  }
</SCRIPT>
</PUBLIC:COMPONENT>

If you provided the above-mentioned code into the hilite.htc file, you could access it within CSS through the behavior property:

li {
  behavior: url(hilite.htc);
}

I was really surprised to discover that HTML components supported creating custom tags (starting from version 5.5), have single domain limitations and tons of other stuff that you probably have never used before. Despite Microsoft submitting a proposal to W3C, other browser vendors decided not to support this feature. As a result, HTML components were removed from Internet Explorer 10.

Decorators

In my previous article, I mentioned the Decorators which are a part of Web components. Here’s how you can implement the open/closed state indicator of the <details> element using decorators:

<decorator id="details-closed">
  <script>
    function clicked(event) {
      event.target.setAttribute('open', 'open');
    }
    [{selector: '#summary', type: 'click', handler: clicked}];
  </script>
  <template>
    <a id="summary">
      &blacktriangleright; <content select="summary"></content>
    </a>
  </template>
</decorator>

<decorator id="details-open">
  <script>
  function clicked(event) {
    event.target.removeAttribute('open');
  }
  [{selector: '#summary', type: 'click', handler: clicked}];
  </script>
  <template>
    <a id="summary">
      &blacktriangledown; <content select="summary"></content>
    </a>
    <content></content>
  </template>
</decorator>

Decorators are also applied using the special decorator property in CSS:

details {
  decorator: url(#details-closed);
}

details[open] {
  decorator: url(#details-open);
}

You’ll quickly notice that this is very close to what Microsoft proposed in HTML Components. The difference is that instead of separate HTC files, decorators are HTML elements that can be defined within the same document. The example above is only provided to show that the Web platform is working on these topics, since decorators aren’t properly specified just yet.

Live Extensions API

While designing APIs for live extensions, I decided to follow the following rules:

  1. Live extensions should be declared in JavaScript.
    I strongly believe that everything that somehow changes the behavior of an element should be presented in a JavaScript file. (Note that better-dom inserts a new CSS rule behind the scenes, but this includes only implementation details).
  2. APIs should be simple to use.
    No tricky file formats or new HTML elements: only a small amount of knowledge related to the constructor and event handlers is required to start developing a live extension (thus, the barrier to entry should be low).

As a result, there are only two methods to deal with: DOM.extend and DOM.mock.

DOM.extend

DOM.extend declares a live extension. It accepts a CSS selector as the first argument which defines what elements you want to capture. General advice: try to make the selector simple.

Ideally, you should only use a tag name, class or attribute with or without a value or their combinations with each other. These selectors can be tested quicker without calling an expensive matchesSelector method.

The second argument is a live extension definition. All properties of the object will be mixed with an element wrapper interface except constructor and event handlers.

Let’s look at a simple example. Let’s assume we have such an element on a Web page:

<div class="signin-form modal-dlg">...</div>

The task is to show it as a modal dialog. This is how the live extension could look like:

DOM.extend(".modal-dlg", {
  constructor: function() {
    var backdrop = DOM.create("div.modal-dlg-backdrop");
    // using bind to store reference to backdrop internally
    this.showModal = this.showModal.bind(this, backdrop);
    // we will define event handlers later
  },
  showModal: function(backdrop) {
    this.show();
    backdrop.show();
  }
});

Now you can access the public method showModal in any (present or future) element that has the modal-dlg class (in our case this is the signin-form div):

var signinForm = DOM.find(".signin-form");

DOM.find(".signin-btn").on("click", function() {
  // the signin button doesn’t have the modal-dlg class
  // so it’s interface doesn’t contain the showModal method
  console.log(this.showModal); // => undefined
  signinForm.showModal(); // => shows the signin dialog
});

Note: The better-dom-legacy.js file which is included conditionally for Internet Explorer versions 8 and 9, contains the es5-shim library so you can safely use standards-based EcmaScript 5 functions (such as Function.prototype.bind) in your code. I’ve been using the bind method heavily in my code to build testable methods easily.

The Constructor Property

The constructor function is called when an element becomes visible. This is because of the animationstart event that is used to implement DOM.extend. Browsers are clever so they don’t fire animation events for hidden elements. This lazy initialization saves resources sometimes, but be careful with accessing initially hidden elements.

In older Internet Explorers versions such as 8 and 9, contentready event from better-dom-legacy.htc is used to implement live extensions. Therefore, the constructor function executes immediately in these browsers — even for hidden elements.

Note: Keep in mind not to rely on time whenever an extension has been initialized. The actual initialization of a live extension varies across browsers!

Constructor is usually the place where you attach event handlers and perform DOM mutations where necessary. Once the function has been completed, all methods that begin with “on” (in better-dom 1.7 also “do”) followed by an uppercase letter, event handlers, will be removed from the element wrapper’s interface.

Let’s update our .signin-form live extension with the help of a close button and the ESC key:

DOM.extend(".modal-dlg", {
  constructor: function() {
    var backdrop = DOM.create("div.modal-dlg-backdrop"),
      closeBtn = this.find(".close-btn");

    this.showModal = this.showModal.bind(this, backdrop);
    // handle click on the close button and ESC key
    closeBtn.on("click", this.onClose.bind(this, backdrop));
    DOM.on("keydown", this.onKeyDown.bind(this, closeBtn), ["which"])
  },
  showModal: function(backdrop) {
    this.show();
    backdrop.show();
  },
  onClose: function(backdrop) {
    this.hide();
    frame.hide();
  },
  onKeyDown: function(closeBtn, which) {
    if (which === 27) {
      // close dialog by triggering click event 
      closeBtn.fire("click");
    }
  }
});

Despite the fact that the live extension contains both onClose and onKeyDown methods, they won’t be mixed into the element wrapper interface:

var signinForm = DOM.find(".signin-form");

console.log(signinForm.onClose); // => undefined
console.log(signinForm.onKeyDown); // => undefined

This kind of behavior exists simply because you can have multiple live extensions for a single element that may overload public methods of each other and produce unexpected results. For event handlers, this is not possible; they exist only inside of the constructor function.

Extending * Elements

Sometimes it is useful to extend all of the element wrappers with a particular method (or methods). But then again, you can also use the universal selector to solve the problem:

DOM.extend("*", {
  gesture: function(type, handler) {
    // implement gestures support
  }
});
…
DOM.find("body").gesture("swipe", function() {
  // handle a swipe gesture on body
});

The * selector has a special behavior: all extension declaration properties will be injected directly into the element wrapper prototype except for the constructor which is totally ignored. Therefore, there is no performance penalty that is usually associated with the universal selector.

Note: Never pass more specific selectors such as .some-class * into DOM.extend because they are slow and do not have the same behavior as mentioned above.

Multiple Live Extensions on the Same Element

More often that not, it makes sense to split a large live extension into several pieces to reduce complexity. For instance, you may have such an element on your page:

<div class="infinite-scroll chat"></div>

There are two different extensions attached to it. The .infinite-scroll extension implements a well-known infinite scroll pattern, e.g. it’s responsible for loading new content. At the same time, the .chat extension shows tooltips whenever a user hovers over a userpic, adds smileys into messages, and so on. However, be accurate with multiple extensions: even though all event handlers may have been removed from the interface, you still may have public methods that intersect with each other.

Inheritance

Live extensions respect declaration order; you can use this to your advantage and develop your own component hierarchy. Late binding helps to declare overridable event handlers and method overloading allows to redefine a method implementation in a child extension:

DOM.extend(".my-widget", {
  constructor: function() {
    this.on("click", "_handleClick");
  },
  showMessage: function() { }
});

DOM.extend(".my-button", {
  _handleClick: function() {
    console.log("I am a button!");
  },
  showMessage: function() {
    alert("I am a button message!");
  }
});

If you take a closer look at the code above, you’ll notice that the .my-button extension does not attach a click listener. The registration is done with the help of late binding instead of a simple event handler in .my-widget. Late binding is a perfect choice here: even if a child does not implement _handleClick there won’t be any errors since the handler will be silently ignored.

While spreading functionality across multiple modules is possible, this is not recommended in everyday use. Double check if you really need to go in this direction, because it’s the most complex one.

Writing Tests with DOM.mock

One requirement for a high-quality widget is test coverage. New elements are captured by a live extension asynchronously, so it’s not that easy to simply make them in memory. To solve this problem, better-dom has the DOM.mock function:

var myButton = DOM.mock("button.my-button");

DOM.mock creates elements, just like DOM.create. Additionally, it synchronously applies the registered live extensions to the newly created elements. For even more convenience, all wrapper objects created by DOM.mock preserve event handlers (e.g. onClick), so you can test them.

From time to time, you may need to create a “fake” instance of an element. Use DOM.mock without arguments to make such an object:

console.log(DOM.mock().length); // => 0

A test for the modal dialog live extension introduced earlier could look like this (I use Jasmine):

describe(".modal-dlg", function() {
  var dlg, backdrop;

  beforeEach(function() {
    dlg = DOM.mock("div.modal-dlg");
    backdrop = DOM.mock();
  });

  it("should hide itself and backdrop on close", function() {
    var dlgSpy = spyOn(dlg, "hide"),
      backdropSpy = spyOn(backdrop, "hide");

    dlg.onClose(backdrop);
    expect(dlgSpy).toHaveBeenCalled();
    expect(backdropSpy).toHaveBeenCalled();
  });

  it("should show itself and backdrop on show", function() {
    var dlgSpy = spyOn(dlg, "show"),
      backdropSpy = spyOn(backdrop, "show");

    dlg.showModal(backdrop);
    expect(dlgSpy).toHaveBeenCalled();
    expect(backdropSpy).toHaveBeenCalled();
  });
});

Feature Detection (in better-dom 1.7)

There are some cases when filtering with a CSS selector is not flexible enough. For instance, let’s say you want to declare a live extension but only for browsers that support (or do not support) a particular feature. You may need to run tests in a headless browser like PhantomJS that support the feature natively. Starting with better-dom 1.7, DOM.extend supports the optional argument condition.

Assume we need to create a polyfill for the placeholder attribute. It doesn’t make sense to implement it for browsers that have built-in support. Below is an example of how the feature detection could look like:

var supportsPlaceholder = typeof DOM.create("input")
      .get("placeholder") === "string";

By using just a simple “If” statement as shown in the example below, we won’t have an ability to test the widget because PhantomJS supports the placeholder attribute and the live extension will never be declared.

if (!supportsPlaceholder) {
  DOM.extend("[placeholder]", {
    // implement placeholder support
  };
}

In order to solve this problem, you can use an extra condition argument in DOM.extend that might be Boolean or a function:

DOM.extend("[placeholder]", !supportsPlaceholder, {
  constructor: function() { … },
  onFocus: function() { … },
  onBlur: function() { … }
});

DOM.mock ignores the condition argument, so you can access all methods of the [placeholder] extension even if current browser passes the check:

var input = DOM.mock("input[placeholder=test]");

typeof input.onFocus; // => "function"

Conclusion

Live extensions — and better-dom as an implementation of the concept — are a good base to build upon whenever your target is uncertain, e.g. when creating a polyfill that may or may not be used on a particular site. Or regular widgets that may or may not be needed, depending upon some AJAX call.

Live extensions aim to separate declaration and the use of widgets. They bring loose coupling (or decoupling, rather) of any DOM-based component, and allow your code to become smaller, cleaner and easier to maintain. You can even combine such independent pieces with any existing framework within the market (or with the vanilla DOM, of course).

You may now be thinking, “But wait, there are projects like Polymer or x-tags, right?” Well, live extensions cover a different area; they are not about custom tags but rather about extending existing ones instead. I prefer a standards-based way (if possible) of creating UI widgets, so making polyfills is my choice.

Better-dom also has another advantage: a carefully crafted live extension does not force you to rewrite a website’s markup using different tags. All you need is to simply include a script file on your page. Standards-based elements can potentially work without JavaScript, so they degrade well when it’s disabled. And the library’s browser support allows you to start using live extensions straight away.

Feel free to share your thoughts in the comments section below or on the better-dom project home page.

(il)

↑ Back to top

Maksim is a freelance front-end and back-end Web developer who lives in Minsk, Belarus. In his spare time, he likes to learn Web standards and to contribute to open source projects that could accelerate publishing new ideas via the Internet.

  1. 1

    Wow, such a awesome article. Nice work.

    0
  2. 2
  3. 3

    Really nice work!
    I love the concept, it’s one of these frameworks that gets out of your way. But maybe you should consider to pull out i18n into an extension, the requirements on i18n are very individual and not everyone needs internationalization. If you offer some extension points, people will build extensions and possibly some of these extensions will find their way into the core of the framework.

    0
  4. 4

    Cool to know this. I always wanted to do this without changing the markup tags.
    I understand better-dom acts as a polyfill till the final spec works in all browsers. Is Web Components a near future spec for browsers or it does work right now out of the box?

    0
  5. 5

    Thanks, very good article.
    While reading I stumbled upon this: “What’s wrong with extending the DOM”
    http://bit.ly/1ajI8ZD

    0
  6. 6

    Nice work and a useful clarification.

    My primary use case for the Live Extensions is to load and initialize scripts after content was added through an AJAX load. That way I can use conditional lazy-loading for all scripts and don’t need to call an initialization method after inserting content.

    In our case, we are using a page-transition script to fade in the next content page. better-dom and require.js are loaded on the initial request (e.g. the homepage) and work hand in hand to load further resources.

    Example: Inserting a picture gallery (UL) and display it as slider.

    DOM.extend( ‘[data-slider]‘, {
    constructor: function() {
    var target = this._node;
    require( [ 'app/view/slider' ], function( Slider ) {
    Slider.init( target );
    } );
    }
    } );

    0
  7. 7

    Sylvain Pollet-Villard

    February 20, 2014 11:01 am

    Good stuff used for bad reasons…

    input date : “the problem is that this element looks and behaves very different from browser to browser:”

    Problem ? If there is one lesson that adaptive web design has taught me is that an element should NOT look and behave the same from browser to browser. Your custom date-picker may be super sexy on major browsers, but what about semantics, accessibility, atypical browsers and user contexts ? input type=”date” exists for a reason, so that browser vendors provide an optimal and consistent input UI for each device. If you think there is a legitimate reason to use a JS widget, use at least an input:date as its hidden input tag.

    Media Query Callbacks : “A common problem in responsive design is the linking of CSS3’s media queries and JavaScript (…) to use JavaScript and pull in different content”

    Pull in different content, I call it adaptive loading. It is usually done once at page load. Adaptive loading and media queries do not go well together, as one adapts dynamically to viewport changes and the other not ; so you multiply the test cases by combining both approaches. From my own experience, this is a bad idea.

    I think live extensions may be very valuable, but IMO the first two examples are really badly chosen.

    0
  8. 8

    Maksim Chemerisuk

    February 8, 2014 9:10 am

    Thanks!

    In 1.7 version I added ability to make custom builds, so almost any function can be removed for a custom bundle.

    I18n implementation is very unobtrusive: the only thing that it relies on is the standards-based `lang` attribute. So I believe that it can be safely combined with any existing i18n solution without a big price (it takes just ~50 lines of code without comments).

    Also it’s possible to extend better-dom via `DOM.extend(“*”, …)` as described in the article, and you can always define a new static method on the global DOM variable. Anyway it’s a good question to add to the library’s FAQ, may be this part is not emphasised enough in the article.

    0
  9. 9

    In better-dom every native DOM element has a related JavaScript object of type $Element. DOM.extend modifies an interface of these $Element objects, so it doesn’t touch any native objects and therefore it’s safe.

    0
  10. 10

    No, better-dom is not a polyfill of the Decorator spec (because the spec is a very early draft). But you can make polyfills with it :)

    Web Components are promising, but AFAK not all browser vendors accepted it. And they don’t solve all problems although can be useful in some cases.

    0
  11. 11

    Maksim Chemerisuk

    February 16, 2014 1:37 pm

    Yep, this is one of the primary use cases. I have several recommendations for your solution:

    1) never use private properties like _node, use the `legacy` method instead which is safer and it will work in future unlike private props that may change their names
    2) your live extension is very small, so I recommend to make it more generic and to put varying parts into a data-* attribute
    3) use do* methods for factories: they do not change interface and you can easily test them using DOM.mock.

    So the solution may look like below (not tested but you may get the idea):

    var factoryMap = {
    “app/view/slider”: “doInitSlider”
    // more factories
    };

    DOM.extend(“[data-amd-plugin]“, function() {
    constructor: function() {
    var pluginPath = this.data(“amd-plugin”),
    factory = this[factoryMap[pluginPath]];

    require([pluginPath], factory.bind(this));
    },
    doInitSlider: function(Slider) {
    this.legacy(function(target) {
    Slider.init( target );
    });
    }
    });

    0
  12. 12

    Maksim Chemerisuk

    February 22, 2014 3:47 am

    Hey Sylvain.

    I do agree that a widget should not always look and behave the same. But I do think that it should be the same on the same type of a device. The example from the article shows how you can normalize the date input element on desktop, but I’m not going into deep details there because its out of the main topic.

    I have developed the date input polyfill in the past (demo: http://chemerisuk.github.io/better-dateinput-polyfill/). If you try to open the demo on a mobile device you’ll just see a native control.

    > browser vendors provide an optimal and consistent input UI for each device
    Unfortunately no. Some browser vendors are a bit crazy. I have no idea why they don’t want just to take current best practices, to implement them on a browser level and to allow people to style any part of the widget with CSS. For now polyfills are the only way for developers who care about semantics.

    I agree with your point that Media Query Callbacks are not truly flexible, so they don’t fit well in some cases. But remember that regular users do not often play with a browser window resizing.

    0
  13. 13

    Sylvain Pollet-Villard

    February 23, 2014 5:44 am

    Certainly some browsers are disappointing but in some way, we have to trust them, just as the software choices of our visitors. I think there is a good reason why developers are not allowed to stylize the CSS of native datepickers : consistency. Just as we should not stylize the virtual keyboard or the URL bar. This is browser stuff. A web developper, even aware of the challenges of cross-platform web and accessibility, is not able to respond to all his site visitors contexts of use. There are just too many cases to cover, and it gets worse every day. Trust the browser will become increasingly necessary.

    “Remember that regular users do not often play with a browser window resizing” –> I tend to disagree. Devices and OS have evolved in a way where viewport resizing (and more generally adaptation to the context) are becoming more frequent and flexible. Think about it : orientation changes, opening virtual keyboard, Windows 8 style side panels, snap-resizing … I can not afford to ignore changes on the viewport. This is not for the “Wow responsive” effect, it has practical applications. Enough to convince me that adaptive loading can no longer be a one-shot gun.

    0
  14. 14

    Maksim Chemerisuk

    February 24, 2014 4:05 pm

    It’s OK if you have a different opinion :)

    0

Leave a Comment

Yay! You've decided to leave a comment. That's fantastic! Please keep in mind that comments are moderated and rel="nofollow" is in use. So, please do not use a spammy keyword or a domain as your name, or else it will be deleted. Let's have a personal and meaningful conversation instead. Thanks for dropping by!

↑ Back to top