This is a presentation from 2019-01-21.
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
Unpoly 0.50.0
- jQuery DOM manipulation
- Native AJAX
- Native Promises
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()
)
Unpoly 0.60.0
- jQuery removed.
- Everything is native.
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:
- The native DOM element
let element = ..
- The native element wrapped as a jQuery collection:
let $element = $(element)
- 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
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.
JavaScript libraries are already shipping as custom elements
- For example Trix from Basecamp Show archive.org snapshot (integrated as ActionText in Rails 6).
- By choosing custom elements as their delivery method, they work in all JavaScript frameworks or without any framework.
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()
(isElement#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
- Talk to your client about alotting time. This is a refactoring similar to removing
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, useup.$compiler()
. - The
up.macro()
callback now received a native element Show archive.org snapshot instead of a jQuery collection. For the old behavior, useup.$macro()
. - The event handler passed to
up.on()
now receives an element instead of a jQuery collection. For the old behavior, useup.$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()
, useup.$compiler()
- instead of
up.macro()
, useup.$macro()
- instead of
up.on()
, useup.$on()