-
Notifications
You must be signed in to change notification settings - Fork 198
Long Term Goals
This is where I see elm-css going in the long term.
tl;dr basically like CSS Modules, except you can define the styles in the same file as your view, yet still compile them to a separate .css file. (Dead code elimination means nothing elm-css related ends up in compiled .js files, just classname literals.)
The big question is whether styles should be assembled at build time or at runtime. It turns out to be a complicated question with a clear answer at the end. The rest of this document explains the reasoning that leads to the conclusion.
There are a ton of different approaches to CSS styling that are seeing use in the industry, and it's important to consider their benefits and drawbacks.
Prior to Christopher Chedeau's presentation about "CSS in JS" in 2014, the consensus best practice for years had been to put all your CSS in a separate .css file, with the possible exception of a few situational inline styles. The presentation calls out some shortcomings of the .css file approach:
- Global Namespace
- Dependencies
- Dead Code Elimination
- Minification
- Sharing Constants
- Non-deterministic Resolution
- Breaking Isolation
I have some thoughts on how this list pertains to elm-css.
-
elm-csshasnamespaceto help with the Global Namespace problem (#1), but it's not a bulletproof solution. We can do better. - Because
elm-csslets you write stylesheets in Elm, we get dependencies (#2) and sharing constants (#5) for free. - I was surprised how in the presentation, "Dead Code Elimination" (#3) turned out to be "Dead Code Elimination where the programmer does it by hand"—and since
elm-csseasily lets you sharing constants between view code and stylesheets, manual cleanup gets a lot easier. That said, in the future, automatic dead code elimination onelm-cssstylesheets seems plausible—courtesy of a futureelm-makerelease. - I doubt minifying classnames (#4) moves the needle for most of us, especially once the stylesheets are gzipped. At Facebook's scale, though, the traffic coefficient means that every byte shaved is a bunch of money, so maybe this is a reasonable thing to consider at their size.
- Non-deterministic Resolution (#6) and Breaking Isolation (#7) could be resolved by a helper library that exposes a "safe subset" of
elm-csswhere doing things like increasing specificity or breaking isolation is a validation error. I'm not sure if something like this should be in scope forelm-cssitself, but it seems reasonable. It's doable regardless of whether stylesheets are assembled at build time or at runtime.
Radium uses the "embrace inline styles" approach this presentation discusses.
The "inline styles" approach has some serious drawbacks. One is performance; Pete Hunt noted:
inserting huge strings of inline styles caused serious performance problems (several hundred millisecond pauses or more) at Smyte.
This approach seems to perform particularly slowly in comparison to .css files.
Another downside (also noted in Pete's article) is that you can't use pseudo-classes, pseudo-elements, or media queries in inline styles. This is a big usability hit.
One way to "get the best of both worlds" is to have a library build up per-element descriptions of the styles you want, and then inject them into a <style> element which the library controls. It can automatically generate classes behind the scenes to attach the right styles to the right elements, so you get the UX of inline styles but with better performance and while getting to use pseudo-classes, media queries, etc.
Aphrodite, jsxstyle, and styled-components, among others, use this approach.
The "preprocess a different language into a .css file at build time" approach has been around for a long time. elm-css was originally built to be a preprocessor, and was directly influenced by Sass and Stylus. Since then PostCSS has been released as well.
To differentiate between this approach and the above two approaches (inline styles and <style> element injection), I'll refer to preprocessors (and handwritten .css files) as "build-time CSS" and to the others as "runtime CSS."
One problem build-time CSS has always had is namespace collisions. What if you name a class .menu in one file and then also name a class .menu in a different file, but they're meant to style different menus? The browser will not complain if you do this; instead, you'll probably get some nasty styling bugs. Trying to avoid this is why elm-css ships with a namespace transformation, but it's not a bulletproof solution, and using it takes nontrivial effort.
It would be better if build-time CSS didn't have this downside, and CSS Modules offers a solution. It's a preprocessor that auto-generates classnames at build time, based on the hash of the declarations in that style. It also automatically synchronizes those classnames with the classnames used in the corresponding JS code, making namespace collisions effectively impossible.
Generating classnames removes the downside that made build-time CSS more error-prone than runtime CSS. elm-css can and should offer something like this, but does not yet.
Unlike most other preprocessors (jss being an exception), elm-css at build-time has no difference in expressivity compared to runtime. In either case you have access to the complete Elm language—modules, functions, Elm's entire package ecosystem, etc. Also, because elm-css styles are written in Elm, you can put your styles in the same files as your views and still compile them to .css files. Dead Code Elimination can make this have no impact on the compiled .js file; to end users, it would be as if the styles had been defined in a completely separate file all along.
Even in the world where elm-css auto-generates classnames (meaning no more namespace conflicts, and less having to name things), and auto-generates filenames (relevant in a post-Elm 0.19 world, where Elm does asset management and can presumably take care of loading the appropriate auto-generated .css files when they're needed) and has Dead Code Elimination (meaning you can define your styles in the same file as your view logic, without runtime cost), runtime CSS still maintains at least one advantage over build-time CSS:
Properties can be set based on runtime state (e.g. setting opacity to be model.opacity rather than to a constant that was fixed at build time).
In the same way that it's essentially impossible for runtime CSS to have the performance benefits (a single hardcoded string literal instead of parsing and/or assembling data structures on the fly) or caching benefits (given that browsers cache .css files, but don't cache style information contained on elements) of build-time CSS, it's also essentially impossible for build-time CSS to set properties dynamically based on runtime state. There's no real way to design around this tradeoff.
Another problem that elm-css has which, say, styled-components does not, is that elm-css aspires to present a reliable interface to the enormous CSS spec. This means that elm-css has (and will always have) a lot of functions in it. This means that even assuming ideal Dead Code Elimination, anyone who uses elm-css for runtime style creation will accumulate a larger and larger JS download the more different CSS properties they happen to be using.
It's possible to further optimize time to interaction by separating "critical styles" from the rest and loading them differently.
Fortunately for those who need this, it's already possible in elm-css.
Another factor to consider is that precompiled styles benefit more from gzip. The reason elm-css supports mixin (but not an inheritance feature like @extend from Sass or composes from CSS Modules) is that in practice these inheritance features are worse for both performance and download size (because of gzip) than mixins, making them turn out to be footguns that should never be used for any reason.
Regardless of which approach is best in the here and now, all of the following seem worth pursuing as future goals:
- Dead Code Elimination
-
elm-csscan auto-generate classnames (and animation names) -
elm-csscan auto-generate compiled.cssfilenames, and Elm can automatically load them when necessary
Given that we are already working toward this world, then what should we work towards in terms of build-time CSS versus runtime CSS?
Here are the pros and cons in this world, assuming we are talking about the sort of runtime CSS that uses style tag injection:
- Properties can be set based on runtime state (e.g. setting
opacityto bemodel.opacityrather than to a constant that was fixed at build time)
- Less CPU intensive. The browser is dealing with a hardcoded string literal (the classname) which indexes into a lookup table of cached styles. Systems which construct and combine objects to accomplish the same thing are always going to be more CPU-intensive.
- Less JS to download. In the runtime CSS approach, the actual functions have to exist in the JS, meaning there are more functions for the end user to download.
- Parallelization. Browsers can download CSS files and parse them in parallel with
scripttags on the page. - Caching. Browsers cache
.cssfiles and do not cache styles that are attached to elements.
We can boil this comparison down to:
- Runtime CSS can dynamically set properties based on current state
- Build-time CSS has many performance benefits
In practice, the number of properties that need to be set based on current state tends to be extremely small. With a library like mdgriffith/elm-style-animation to do things like animating opacity or left, it becomes vanishingly small.
For those few cases where it's still required, the performance penalty of using the style attribute seems likely to be insignificant. Having those few styles unverified (and thus more error-prone) is probably not the end of the world if it's isolated to that one tiny piece of the code base, and even if it were, writing a tiny library that only added types to those few properties (opacity, left, etc.) would probably be a more reasonable solution than importing all of elm-css.
An important question to ask about any question where the tradeoff is "one has worse performance" is: if the performance ever gets bad, what will we do to optimize it? In the case of runtime CSS, the answer is almost certainly "convert large chunks of styles to build-time CSS until the performance becomes acceptable." This would lead to inconsistencies across the code base, and would be more or less work depending on how often we'd (unnecessarily) coupled our styles to runtime state just because we could.
Conversely, if we embrace build-time CSS and ever need state-based declarations, it is easy to opt into them on a case-by-case basis.
With all this in mind, it seems clear that build-time CSS is the right default. There are many significant performance benefits, and the drawbacks are both minor in comparison and easy to work around.