Read more

JavaScript without jQuery (presentation from 2019-01-21)

Henning Koch
January 21, 2019Software engineer at makandra GmbH

Summary

  • We want to move away from jQuery in future projects
  • Motivations are performance, bundle size and general trends for the web platform.
  • The native DOM API is much nicer than it used to be, and we can polyfill the missing pieces
  • Unpoly 0.60.0 works with or without jQuery

Is jQuery slow?

From: Sven
To: unpoly@googlegroups.com
Subject: performance on smartphones and tablets

Hello

I just used your framework in one project and must say,
I am really pleased with it -- but only on a desktop computer.

Have you benchmarked the framework also on smartphones and tablets?
Unfortunately I must say, that it does not feel snappy at all on
mobile devices. Actually very often it feels much slower, than
 without unpoly. This defies it's purpose. And it is nowhere on the
 page stated, that it is only snappy on a computer with a powerful
 cpu, as the JavsScript it otherwise to slow.

As this is probably a known issue, is the framework only meant to be
used for desktop pc's and not for mobile devices? 

The problem has nothing to do with the performance of my own site,
as the unpoly site itself feels very laggy on my mobile devices --
on a smartphone and on a tablet (no super highend devices, but
neither very slow ones).

Kind regards
Sven

The peformance impact of removing jQuery (with pictures)

Unpoly 0.37

  • jQuery DOM manipulation
  • jQuery AJAX
  • jQuery Promises

Image

Unpoly 0.50.0

  • jQuery DOM manipulation
  • Native AJAX
  • Native Promises
Illustration money motivation

Opscomplete powered by makandra brand

Save money by migrating from AWS to our fully managed hosting in Germany.

  • Trusted by over 100 customers
  • Ready to use with Ruby, Node.js, PHP
  • Proactive management by operations experts
Read more Show archive.org snapshot

Image

Unpoly 0.56.0

  • jQuery DOM manipulation
  • Optimized as far as possible without removing jQuery entirely
  • Many slow jQuery functions replaced (show(), hide(), addClass(), css())

Image

Unpoly 0.60.0

  • jQuery removed.
  • Everything is native.

Image

What makes jQuery burn so many cycles?

jQuery makes many checks for legacy browsers we don't care about anymore:

https://github.com/jquery/jquery/blob/354f6036f251a3ce9b24cd7b228b4c7a79001520/src/css/support.js Show archive.org snapshot

jQuery wraps native functionality with a lot of code:

https://github.com/jquery/jquery/blob/master/src/event.js Show archive.org snapshot

Overhead of adding and removing 1000 event listeners

Method duration
Element#addEventListener() 11.3 ms native code
$.fn.on() 60.3 ms ~ 1000 lines of userland code
up.on() 15.9 ms ~ 130 lines of userland code

Overhead of adding 200 elements to an Array vs. adding to a jQuery collection

Method duration
Array.prototype.push() 0 ms
$.fn.add() 28 ms

... Because jQuery collections are always in DOM order.

In general jQuery trades correctness in all edge cases and legacy browsers for performance.

jQuery parse time

See my earlier presentation about parse time impact.

jQuery is 86.9 kB minified, 30.4 kB gzipped.

Removing jQuery makes your app boot some seconds faster on slow devices, or frees up that budget for other libraries.

Working the DOM without jQuery (basics)

So you get a feel how working without jQuery looks like.

Selecting elements in the document

// jQuery
let $cards = $('.card')
let $firstCard = $('.card:first')

// Native
let cards = document.querySelectorAll('.card')
let firstCard = document.querySelector('.card')

Selecting descendants

// jQuery
let $form = $('form')
let $fields = $form.find('input, textarea, select')

// Native
let form = document.querySelector('form')
let fields = form.querySelectorAll('input, textarea, select')

Find closest ancestor or self

// jQuery
let $input = $('input')
let $form = $input.closest('form')

// Native
let field = document.querySelector('input')
let form = field.closest('form')

jQuery has influenced the native API a lot!

Check if element matches selector

// jQuery
let $button = $('button')
let isButtonActive = $button.is('.active')

// Native
let button = document.querySelector('button')
let isButtonActive = button.matches('.active')

Read and write attributes

// jQuery
let name = $element.attr('name')
$element.attr('name', 'new-name')

// Native
let name = element.getAttribute('name')
element.setAttribute('name', 'new-name')
// ... but you probably want to work with properties.

Read and write properties (!)

// jQuery
let value = $input.prop('value')
$element.prop('value', 'foo@bar.com')

// Native
let name = input.name
input.name = 'foo@bar.com'

// jQuery
let src = $image.prop('src')
$element.prop('src', 'foo.png')

// Native
let src = image.src
input.src = 'foo@.png'

Working with CSS class names

// jQuery
$input.hasClass('active')
$input.addClass('active')
$input.removeClass('active')
$input.toggleClass('active')

// Native
input.classList.contains('active')
input.classList.add('active')
input.classList.remove('active')
input.classList.toggle('active')

Creating a new element

// jQuery
let $div = $('<div></div>')
$div.appendTo(document.body)

// Native
let div = document.createElement('div')
document.body.appendChild(div)

Event listeners

// jQuery
let listener = (event) => { ... }
element.on('click', listener)
element.off('click', listener)

// Native
let listener = (event) => { ... }
element.addEventListener('click', listener)
element.removeEventListener('click', listener)

The Web platform moves away from wrapping

In our apps the same element often has three layers of wrapping:

  1. The native DOM element
    let element = ..
    
  2. The native element wrapped as a jQuery collection:
    let $element = $(element)
    
  3. An API object to call additional JavaScript behavior added by a library:
    var player = flowplayer($element)
    player.play()
    

Framework activation layers (like Angular directives, Unpoly compilers, React components, $.unobtrusive()) are all layer 3.

It's simpler to just have one layer: The native DOM element is its API

https://makandracards.com/makandra/62353-javascript-without-jquery-presentation-from-2019-01-21/attachments/7225

The future is Custom Elements

  • In the future library developers will no longer ship JavaScript as Angular directives, Unpoly compilers or anything framework-specific. It will all be Custom Elements.
  • Custom elements let you teach a new <tag> to a browser
  • The browser will automatically pair JavaScript and HTML when a matching element enters the DOM. No JavaScript activation required.

https://makandracards.com/makandra/62353-javascript-without-jquery-presentation-from-2019-01-21/attachments/7224

JavaScript libraries are already shipping as custom elements

jQuery vs. Custom Elements

  • jQuery has wrapped access to HTMLElement, but can't possible wrap custom element API.
  • To interact with custom elements, we need.
  • Same for new event types and properties that would need to be manually wrapped by $.Event.

Translating your jQuery habbits to native API

A good guide with before/after code is You Don't Need jQuery Show archive.org snapshot .

Note that some guides still hold compatibility to IE9/10, and we don't need it to support IE11!

Everything will become even easier when we kill IE11 for most apps in Feb 2020.

Supporting IE11

IE11 supports much, but not all of the DOM traversal/manipulation functions.

There are three functions that I miss daily in IE11:

  • Element#closest() (not supported in IE11)
  • Element#matches() (is Element#msMatches() in IE11)
  • Element#toggleClass(klass, newState) (second argument not supported in IE11)
  • NodeList#forEach() (not supported in IE11)

There are millions of Polyfills that will normalize DOM API support across all browsers, e.g. dom4 Show archive.org snapshot (10 kB minfied, 4.3 kB gzipped).

But just to take the magic out of this, here is a homegrown polyfill e.g. for Element#closest():

if (!Element.prototype.closest) {
  Element.prototype.closest = function(selector) {
    if (this.matches(selector)) {
      return this;
    } else if (this.parentElement) {
      this.parentElement.closest(selector)
    }
  }
}

... or, if you have Unpoly 0.60+:

if (!Element.prototype.closest) {
  Element.prototype.closest = function(selector) {
    return up.element.closest(this, ...args);
  }
}

DOM helper libs compared

Libary
jQuery 86.9 kB minified 30,4 kB gzipped
dom4 10.0 kB minfied 4.3 kB gzipped
up.element + up.event 16 kB minfied 5 KB gzipped
UmbrellaJS 8.0 kB minfied 2.5 kB gzipped

About up.element

A new, experimental up.element module offers functions for DOM manipulation and traversal.

It complements native Element methods Show archive.org snapshot and works across all supported browsers.

Method Description
up.element.first() Returns the first descendant element matching the given selector.
up.element.all() Returns all descendant elements matching the given selector.
up.element.subtree() Returns a list of the given parent's descendants matching the given selector. The list will also include the parent element if it matches the selector itself.
up.element.closest() Returns the first element that matches the selector by testing the element itself and traversing up through its ancestors in the DOM tree.
up.element.matches() Matches all elements that have a descendant matching the given selector.
up.element.get() Casts the given value to a native Element Show archive.org snapshot .
up.element.remove() Removes the given element from the DOM tree.
up.element.toggle() Display or hide the given element, depending on its current visibility.
up.element.toggleClass() Adds or removes the given class from the given element.
up.element.hide() Hides the given element.
up.element.show() Shows the given element.
up.element.setAttrs() Sets all key/values from the given object as attributes on the given element.
up.element.replace() Replaces the given old element with the given new element.
up.element.createFromSelector() Creates an element matching the given CSS selector.
up.element.setAttrs() Sets all key/values from the given object as attributes on the given element.
up.element.affix() Creates an element matching the given CSS selector and attaches it to the given parent element.
up.element.toSelector() Returns a CSS selector that matches the given element as good as possible.
up.element.createFromHtml() Creates an element from the given HTML fragment.
up.element.booleanAttr() Returns the value of the given attribute on the given element, cast as a boolean value.
up.element.numberAttr() Returns the value of the given attribute on the given element, cast to a number.
up.element.jsonAttr() Reads the given attribute from the element, parsed as JSON Show archive.org snapshot .
up.element.setTemporaryStyle() Temporarily sets the inline CSS styles on the given element.
up.element.style() Receives computed CSS styles Show archive.org snapshot for the given element.
up.element.styleNumber() Receives a computed CSS property value Show archive.org snapshot for the given element, casted as a number.
up.element.setStyle() Sets the given CSS properties as inline styles on the given element.
up.element.isVisible() Returns whether the given element is currently visible.
:has() A non-standard pseudo-class Show archive.org snapshot that matches all elements that have a descendant matching the given selector.

What jQuery developers are going to miss

Run code on page load

Instead use up.compiler() or

if (document.readyState != 'loading')
  callback()
else
  document.addEventListener('DOMContentLoaded', callback)

Fast element construction

This is something where jQuery really shines:

$('<div>foo</div>').css('color': 'red').attr('up-target', 'page').appendTo('body')

Instead use more code or up.element.affix():

up.element.affix(document.body, 'div[up-target=page]', style: { color: 'red' })

Non-standard pseudo-classes

Like "input:focus", "div:visible", ".form-group:has(.email)".

Instead filter results of querySelectorAll() in JavaScript.

up.element supports :has():

let formsWithButton = up.element.all('form:has(button)')

$.ajax()

Instead use standard fetch() Show archive.org snapshot or up.request().

$.Deferred()

Instead use native Promise.

JavaScript Animations

Use CSS Transitions:

function fadeIn(element) {
  element.style.opacity = '0'
  element.style.transition = 'opacity 1s ease'
  element.style.opacity = '1'
}

No automatic iteration

jQuery collections automatically apply an operation to all elements in the collection:

let $divs = $('div')
$divs.css({ color: 'red' }) // changes all divs

This is sometimes practical, but sometimes hides bugs when a function
built for a single element has undefined (but error-free) behavior
when called for multiple elements.

Instead use for or Array#forEach().

No automatic null-Object

jQuery collections automatically no-op on empty collections:

let $emptyCollection = $([])
$emptyCollection.css({ color: 'red' }) // does nothing. throws no error.

This is sometimes practical, but in my own code this mostly hides bugs
and delays crashes that could have happened earlier.

Recommendation for our projects

New projects

  • New projects should not include jQuery
  • Use a Polyfill like dom4 to normalize browser support
  • Use up.element, Umbrella.js oder even jQuery if you run into convenience issues
  • Hotline #sos

Existing projects

  • For existing projects with lots of jQuery code, it's up to the project lead to decide whether to removing jQuery is a long-term goal for you
  • If you're unsure:
    • Consider writing new functions with the native DOM API
    • Consider not using jQuery in the public signature of new functions
  • If you want to migrate your project now
    • Talk to your client about alotting time. This is a refactoring similar to removing resource_controller (but not a Rails upgrade).
    • Grep for $. This will show you all the affected lines of code.
    • Refactor functions to accept both jQuery and Elements (up.element.get() helps)
    • Remove jQuery from callers one at a time.
      • This is mostly repetitious work.
      • We will practice this below.
    • When all callers are changed, simplify receiving functions to only accept native elements

Unpoly 0.60.0 will work without jQuery

Starting from 0.60.0 (release this week), jQuery is no longer required to use Unpoly. Unpoly no longer has any dependencies.

Effort has been made to ensure that migrating to this version is smooth for existing apps that use jQuery.

All Unpoly functions that accept element arguments will accept both native elements and jQuery collections.

You will need to prefix some function calls with $ to have your callbacks called with jQuery collections instead of native elements:

  • The up.compiler() callback now receives a native element Show archive.org snapshot instead of a jQuery collection. For the old behavior, use up.$compiler().
  • The up.macro() callback now received a native element Show archive.org snapshot instead of a jQuery collection. For the old behavior, use up.$macro().
  • The event handler passed to up.on() now receives an element instead of a jQuery collection. For the old behavior, use up.$on().

Finally, all Unpoly events (up:*) are now triggered as native events that can be received with Element#addEventListener() Show archive.org snapshot . You may continue to use jQuery's jQuery#on() Show archive.org snapshot to listen to Unpoly events, but you need to access custom properties from event.originalEvent.

Exercise: Migrating to Unpoly 0.60 while removing jQuery

  • Show how to remove jQuery from unpoly-guide
  • Start with b496388be0d5c995ca60badf45f7acdbb76b284b
  • Just grep for "$" and rewrite occurences like a robot

Exercise: Migrating to Unpoly 0.60 while keeping jQuery

  • Show how to Upgrade Cards to Unpoly 0.60 while keeping all our jQuery code
  • Start with 8002f3ce18f5405c87b68174871a520d03fd3613
  • instead of up.compiler(), use up.$compiler()
  • instead of up.macro(), use up.$macro()
  • instead of up.on(), use up.$on()

Thank you!

Henning Koch
January 21, 2019Software engineer at makandra GmbH
Posted by Henning Koch to makandra dev (2019-01-21 13:36)