DEV Community

Oleg Gromov
Oleg Gromov

Posted on

Observing Style Changes 👁

While working on one of my inspirational OSS projects, I found out that there's currently no way to observe element style changes. At least I couldn't find any mentions of library-like solutions for that. I assume that the reason for that might be the fact it's hard to understand whether or not the styles have changed.

So, I decided to write my own library and called it SauronStyle. Please take a look and give it a try if you need anything like that for your project.

How to Observe

Leaving the why? behind the scene, let's jump right to how. There're a few ways to update element styling I could remember:

  • update its class or style directly
  • update its parents' attributes, respectively
  • insert or remove style or link elements anywhere in the document

In order to watch any of those, we need MutationObserver support - a DOM change observing interface supported in modern browsers (IE11+). I suppose that's the same that allows you to watch subtree or attribute modification in Elements pane of your favorite DevTools.

So what does it provide us with? Simply the ability to listen to attribute changes (class and style fall in this category) as well as subtree modifications (external stylesheet insertion on removal lives here).

How to Check for a Difference

When we know something has changed, we should check if there are any actual changes since the changes we noticed might be totally unrelated. To do so, we will use getComputedStyle - a useful method on window supported by any modern browser starting IE9. What it does, is it returns a flat object of all CSS properties with values in a similar to CSS computed tab in Chrome.

Importantly, it returns a live CSSStyleDeclaration instance, which changes over time forcing us to keep a copy of it.

Implementation sneak-peek

The actual source code lives in the repository, being rather compact by the way, but it might be interesting for you to see some details.

First of all, I want to observe the watched element attributes changes. This is achieved easily:

this.mutationObserver = new window.MutationObserver(this.checkDiff)
this.mutationObserver.observe(this.node, {
  attributes: true,
  attributeFilter: ['style', 'class']
})
Enter fullscreen mode Exit fullscreen mode

What this code does, is it creates a new instance of MutationObserver class and sends it a callback, this.checkDiff, as the only argument. Then it says: watch this.node for the changes in style and class attributes only and invoke the callback on these changes.

Later, in this.checkDiff we want to see if the actual styles have changed:

checkDiff () {
  const newStyle = this.getStyle()
  const diff = getDiff(this.style, newStyle)

  if (Object.keys(diff).length) {
    if (this.subscriber) {
      this.subscriber(diff)
    }
    this.style = newStyle
  }
}
Enter fullscreen mode Exit fullscreen mode

The code above gets the current style and compares it against the stored copy. Then, if there's any difference, we store the new one for the future and invoke a subscriber function if it has been set already.

this.getStyle returns a shallow copy of this.computedStyle.

getStyle () {
  return getCopy(this.computedStyle)
}
Enter fullscreen mode Exit fullscreen mode

Where this.computedStyle which is a reference to the mentioned above CSSStyleDeclaration instance:

this.computedStyle = window.getComputedStyle(this.node)
Enter fullscreen mode Exit fullscreen mode

Observing Other Elements

It would be more or less it if we didn't care about other elements like parents' attribute changes or style/link[rel=stylesheet] insertion on removal. To do so, we need another entity, which I called DocumentObserver, to watch document subtree modifications including attribute changes. It looks like this in the class constructor:

this.observer = new window.MutationObserver(mutations => mutations.forEach(this.observe.bind(this)))
this.observer.observe(window.document, {
  attributes: true,
  attributeFilter: ['class'],
  childList: true,
  subtree: true
})
Enter fullscreen mode Exit fullscreen mode

It's quite similar to the other MutationObserver use case but here we treat every mutation separately and watch changes on window.document. Here we say roughly this: observe class attribute modifications and children insertion/removal for window.document and its children. Then call this.observe for any relevant mutation.

Observation code is very simple:

observe (mutation) {
  if (mutation.type === 'childList') {
    this.checkElements(mutation)
  } else if (mutation.type === 'attributes') {
    this.invokeAll()
  }
}
Enter fullscreen mode Exit fullscreen mode

Essentially, it checks the type of the mutation and proceeds to a corresponding branch. It's either call to this.invokeAll, which just invokes all subscribers, or a few additional checks aimed to call this.invokeAll only when a link or a style element is inserted.

This part, the DocumentObserver, is used from within SauronStyle like that:

this.documentObserver = getDocumentObserver()
this.listenerId = this.documentObserver.addListener(this.checkDiff)
Enter fullscreen mode Exit fullscreen mode

First, we use it as a singleton because we only have one document. Second, we subscribe the same this.checkDiff to relevant changes to the document.

Issues

Well, this seems to work decently well but are there any problems?

First of all, the performance is low. We often call getComputedStyle and a call takes a few milliseconds, from 1 to 5-6 on my MacBook '2013. It's slow. Imagine a few thousand elements on a page which you want to observe. Will it take a few seconds to react to a DOM change? Yes, it will.

Second, the algorithm is more of proof-of-concept quality rather than production-ready. We call checkDiff method extensively, for any change in DOM that sometimes won't be related at all to the element we observe. I guess this additional computational complexity can be eliminated by computing and storing element styles outside DOM. But this could lead to more mistakes in difference detection and much bigger comprehension complexity.

I'm also not quite sure that I haven't forgotten any other ways to affect element styles.

How to Help

  • tell me if you have ever needed anything like that
  • think and share your thoughts about any other possible ways of detecting style changes
  • give the library a star on GitHub
  • actually use it in one of your projects! 👻

Thanks for your attention!

P.S. There's also a cross-posting of this article to my personal blog. Please take a look if you're interested in other development-related articles or just want to get in touch with me.

Top comments (10)

Collapse
 
moopet profile image
Ben Sinclair

This is neat, (if problematic because of the overhead).

I can't think of any time I've needed to know if a style changed, though - checking for inserted content is a more common use case and that's covered by the MutationObserver or a good old-fashioned setInterval poll for last century's browsers.

What was the use case that prompted this?

Collapse
 
oleggromov profile image
Oleg Gromov

Ben, thanks for your response! It's very valuable for me to know what people think.

I want to watch styles for changes to know if elements might have moved on the page. Say, initially an element's position was just "stay in normal flow" but then somebody applied a class to its parent which makes it 2 times smaller - that's how Twitter header behaves, for example.

Collapse
 
patrickcole profile image
Patrick Cole

Do the cases in which you need to observe always be for computed style data? What I mean is can the library be setup to be more generic such as watching for:

  • <link rel="stylesheet"> element is added/removed to <head> (or other element)
  • A class is added/removed from an element.
  • An inline style has been applied / updated on an element.

Just looking at this from another potential point of view.

Thread Thread
 
oleggromov profile image
Oleg Gromov

Hey Patrick,

Thanks for your reply! For my original task I wanted to supply with this library, I think it makes even more sense to just know about the possibility of style change like the ones you listed. If a stylesheet was added or removed, or a classname, or so on.

For a general-purpose tool, I don't know. Maybe if performance becomes a real issue for a real user (which I don't believe exists now) and I don't find a way to tackle the problem in any other way, it might be a solution. But then even the MutationObserver usage itself may become a bottleneck.

Regardless of this reasoning, thanks for your ideas - they're really valuable to me.
If you see any way in which this tool can be tailored to your personal needs, feel free to raise an issue or even a PR on GitHub.

Kind regards,
Oleg

Thread Thread
 
patrickcole profile image
Patrick Cole

Ah I see, thanks for clarifying. I think the idea could be served as a general purpose for specifying cases in which you'd expect something to change. For example, if an external stylesheet was injected into the page via a widget. That widget might present some style issues that you would need to counter or clean up.

The library could be constructed to establish rule cases to watch for, using the MutationObserver. When one of those cases rings true, it could then perform an implemented callback function.

I've found the MutationObserver an excellent tool, as long as it's used liberally on the page. I seem to run into issues when I try to use it for everything on the page. However, breaking it down into smaller cases to watch for didn't hurt performance as much.

Just some additional thoughts. I dig what you are trying to do here and appreciate the thinking going into this. Hopefully through collaboration you get what you are looking for.

Regards,
Patrick

Collapse
 
cesabal profile image
Cexar A Landazábal C

Suppose the case of a service that is contracted through a control that provides another page, this security provider gives a system that integrate the websites to hire, and this is done by the security provider through an html popup by ajax, it is necessary to determine any change of the page that consumes the service to the popup, and for this securization it is usually important to know the changes css

Collapse
 
ben profile image
Ben Halpern

Super useful!

Collapse
 
trusktr profile image
Joe Pea • Edited

we use it as a singleton because we only have one document

We would also need to observe every ShadowRoot besides the top level document.

I'm also not quite sure that I haven't forgotten any other ways to affect element styles.

There are indeed other ways to affect styling. F.e. modifying the CSS OM directly.

I've been tracking a list of things to do to catch all cases here:

github.com/lume/lume/issues/159#is...

Please drop a comment if you notice something missing! Would like to eventually make something similar myself.

You can also perhaps get some more performance if you skip checking all the CSS properties, and only check the ones that the user opts into. Updating the example from the project README, it could be:

const sauronStyle = new SauronStyle(document.querySelector('#item'))
sauronStyle.subscribe(['transform', 'opacity'], diff => {
  console.log(diff)
})
Enter fullscreen mode Exit fullscreen mode

That would only check for differences in the transform and opacity properties and would not need to clone an object and loop on that object every time. There would just be a single previousValues object that only has the specified properties.

Collapse
 
injamulrgb profile image
Injamul-rgb

Actually there is a huge catch, styling of an element can be changed by a lot, lot means a lot of ways. Thanks to those spacial selector of css like, [src]{color: red} it will change all the elements style with the src attribute. There are also some complex pseudo selectors like :has, : nth-of-type etc etc.

your's is obviously a great to approach but this complexities making it narrow for broad use cases.

Collapse
 
dy profile image
Dmitry IV.

What about element.style.setProperty(prop, value)?
Patching DOM?