Hacking Scoped CSS Modules Into A Brownfield AngularJS 1.2.22 Application
I know that I write a lot about the new Angular hotness (and more recently about the new Vue.js hotness). But, the reality is, most of my day-to-day work on the InVision legacy team still involves AngularJS 1.2.22 code. Context switching between the old and the new frameworks is difficult because I keep trying to reach for the new features when I'm rolling around in the old codebase. For example, one feature of Angular that I've come to love and depend-on is the emulated encapsulation for CSS modules. In fact, I love that feature so much that I wanted to see if I could find a way to hack the concept of scoped CSS into my "brownfield" AngularJS 1.2.22 application.
Run this demo in my JavaScript Demos project on GitHub.
View this code in my JavaScript Demos project on GitHub.
Now, before anyone tells me that Webpack can do this for me, please understand that I am talking about an Angular.js application that is over 6-years old. It's compiled using a Gulp.js script that compiles several applications that all have to adhere to the same format, configuration, and process. So, I have very little wiggle-room to add new things.
In fact, my first attempt at hacking scoped CSS modules into AngularJS 1.2.22 was simply to add the CSS class at runtime (via the link() function) and then manually add the same CSS class prefix to all of the embedded elements within the component template.
So, for example, to create an element called "bn:name-tag", I would programmatically append the host-element class, "m-ee8f6b", where "ee8f6b" is the first 6-digits of MD5("bn:name-tag"). Then, I would manually define embedded elements with CSS class names like "m-ee8f6b__header" and "m-ee8f6b__name".
Essentially, I was doing "by hand" what a compiler would be doing for me in a more modern Angular framework context. To see this in action, I put together a demo that contains a single custom element, "bn:name-tag".
NOTE: Since I'm using inline LESS and HTML Templates, the color-coding here doesn't work properly. In production, you would not be using inline LESS, obviously.
<!doctype html>
<html lang="en" ng-app="Demo">
<head>
<meta charset="utf-8" />
<title>
Hacking Scoped CSS Modules Into A Brownfield AngularJS 1.2.22 Application
</title>
<link rel="stylesheet" type="text/css" href="https://fonts.googleapis.com/css?family=Permanent+Marker" />
</head>
<body>
<h1>
Hacking Scoped CSS Modules Into A Brownfield AngularJS 1.2.22 Application
</h1>
<h2>
Using "manual" Approach
</h2>
<bn:name-tag style="margin-right: 30px ;">
Ben
</bn:name-tag>
<bn:name-tag style="background-color: blue ;">
Kimmie
</bn:name-tag>
<!-- Load scripts. -->
<script type="text/javascript" src="../../../vendor/angularjs/angular-1.2.22.min.js"></script>
<script type="text/javascript">
// Create an application module for our demo.
var app = angular.module( "Demo", [] );
</script>
<script type="text/javascript">
app.directive(
"bnNameTag",
function bnNameTagDirective() {
// Return the directive configuration.
return({
link: link,
restrict: "E",
scope: {},
templateUrl: "bn-name-tag.htm",
transclude: true
});
// I bind the JavaScript events to the local scope.
function link( scope, element, attributes ) {
// Because we don't have native CSS module support, I am injecting
// the module class name on the host element at runtime. To keep
// the class names small and globally unique, I'm just taking the
// first 6-letters of the hash of the directive element name.
// --
// MD5 ("bn:name-tag") = ee8f6b215d4299bac64b16c3d804f7f8
element.addClass( "m-ee8f6b" );
}
}
);
</script>
<style type="text/less">
// NOTE: Because we're using BEM-style (Block Element Modifier) CSS architecture
// here, I only need to add the class prefix at the top. Then, every nested class
// just extends from there using "__" delimiters.
// --
.m-ee8f6b {
background-color: #ff0000 ;
border: 10px solid #ffffff ;
border-radius: 15px 15px 15px 15px ;
box-shadow: 0px 0px 6px rgba( 0, 0, 0, 0.2 ) ;
box-sizing: border-box ;
display: inline-block ;
min-width: 300px ;
&__wrapper {
padding: 0px 0px 13px 0px ;
}
&__header {
color: #ffffff ;
padding: 10px 0px 10px 0px ;
font-family: helvetica, arial, sans-serif ;
text-align: center ;
text-transform: uppercase ;
}
&__title {
display: block ;
font-size: 38px ;
font-weight: bold ;
line-height: 43px ;
letter-spacing: 4px ;
}
&__sub-title {
display: block ;
font-size: 18px ;
line-height: 22px ;
letter-spacing: 1px ;
}
&__name {
background-color: #ffffff ;
color: inherit ;
font-family: "Permanent Marker", sans-serif ;
font-size: 36px ;
letter-spacing: 3px ;
line-height: 41px ;
padding: 25px 20px 26px 20px ;
text-align: center ;
}
}
</style>
<script type="text/ng-template" id="bn-name-tag.htm">
<div class="m-ee8f6b__wrapper">
<header class="m-ee8f6b__header">
<span class="m-ee8f6b__title">
Hello
</span>
<span class="m-ee8f6b__sub-title">
My name is
</span>
</header>
<div class="m-ee8f6b__name" ng-transclude>
<!-- Name goes here. -->
</div>
</div>
</script>
<!-- The browser-based LESS compiler has to be included AFTER the LESS blocks. -->
<script type="text/javascript" src="../../../vendor/less/3.9.0/less.min.js"></script>
</body>
</html>
As you can see, in addition to injecting the "m-ee8f6b" class in the link() function, I also have to mirror the "m-ee8f6b" class in both my HTML markup and my LESS CSS markup. But, when we run this AngularJS 1.2.22 application in the browser, we get the following output:
Clearly, this works. And, to be honest, it's not really all that frictional, especially when you consider that I'm using BEM (Block Element Modifier) CSS via LESS - there's not that much repetition.
That said, I wanted to try and do better! When an Element is bootstrapped in an AngularJS application, it goes through both a "compile" phase and a "link" phase. The "compile" phase gives us an opportunity to augment the HTML before the element is applied to the rendered DOM (Document Object Model). We can use the compile phase to programmatically do some of what I was doing manually in the preceding demo.
In the modern Angular versions, the emulated CSS encapsulation is done via HTML Attributes, not CSS class names. Then, Angular decorates your CSS selectors with said attributes such that your CSS selectors become scoped to a particular element. In my AngularJS build, I don't have the opportunity to do anything programmatic with my CSS; but, I can definitely use the "compile" phase to alter the templates at runtime.
To see this in action, here's the same demo (bn:name-tag); but this time, instead of using the link() phase, we use the compile() phase and some slightly different LESS CSS:
<!doctype html>
<html lang="en" ng-app="Demo">
<head>
<meta charset="utf-8" />
<title>
Hacking Scoped CSS Modules Into A Brownfield AngularJS 1.2.22 Application
</title>
<link rel="stylesheet" type="text/css" href="https://fonts.googleapis.com/css?family=Permanent+Marker" />
</head>
<body>
<h1>
Hacking Scoped CSS Modules Into A Brownfield AngularJS 1.2.22 Application
</h1>
<h2>
Using "compile" Approach
</h2>
<bn:name-tag style="margin-right: 30px ;">
Ben
</bn:name-tag>
<bn:name-tag style="background-color: blue ;">
Kimmie
</bn:name-tag>
<!-- Load scripts. -->
<script type="text/javascript" src="../../../vendor/angularjs/angular-1.2.22.min.js"></script>
<script type="text/javascript">
// Create an application module for our demo.
var app = angular.module( "Demo", [] );
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// I return a directive compile function that injects the given CSS module name
// as an attribute on the host element and all of the embedded elements.
app.value(
"fakeCssModuleCompiler",
function fakeCssModuleCompiler( cssModuleName, link ) {
return( compileDirective );
function compileDirective( tElement, tAttributes ) {
// Add the CSS module name to the host element.
tElement
.addClass( cssModuleName )
.attr( cssModuleName, "" )
;
// Add the CSS module name to every embedded element.
angular.forEach(
tElement.find( "*" ),
function iterator( tChild ) {
tChild.setAttribute( cssModuleName, "" );
}
);
// Return optional post-link function.
return( link || undefined );
}
}
);
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
</script>
<script type="text/javascript">
app.directive(
"bnNameTag",
function bnNameTagDirective( fakeCssModuleCompiler ) {
// Return the directive configuration.
// --
// Because we don't have native CSS module support, we are compiling the
// module class name into the directive template and host element at
// runtime. To keep the class names small and globally unique, I'm just
// taking the first 6-letters of the hash of the directive element name.
// --
// MD5 ("bn:name-tag") = ee8f6b215d4299bac64b16c3d804f7f8
return({
compile: fakeCssModuleCompiler( "m-ee8f6b" ),
restrict: "E",
scope: {},
templateUrl: "bn-name-tag.htm",
transclude: true
});
}
)
</script>
<style type="text/less">
// Set embedded element styles based on the injected attribute. Since we want
// to tie these classes to this module, we are using the "&" (parent selector)
// in order to create a compound selector that won't leak outside of this
// pseudo module.
[m-ee8f6b] {
// Set HOST styles.
// --
// NOTE: The host element uses both a class AND an attribute. This allows us
// to use a class-based selector (which is faster than an attribute selector,
// or so I've read); and, it helps us differentiate the host element from the
// embedded elements.
&.m-ee8f6b {
background-color: #ff0000 ;
border: 10px solid #ffffff ;
border-radius: 15px 15px 15px 15px ;
box-shadow: 0px 0px 6px rgba( 0, 0, 0, 0.2 ) ;
box-sizing: border-box ;
display: inline-block ;
min-width: 300px ;
}
&.wrapper {
padding: 0px 0px 13px 0px ;
}
&.header {
color: #ffffff ;
padding: 10px 0px 10px 0px ;
font-family: helvetica, arial, sans-serif ;
text-align: center ;
text-transform: uppercase ;
}
&.title {
display: block ;
font-size: 38px ;
font-weight: bold ;
line-height: 43px ;
letter-spacing: 4px ;
}
&.sub-title {
display: block ;
font-size: 18px ;
line-height: 22px ;
letter-spacing: 1px ;
}
&.name {
background-color: #ffffff ;
color: inherit ;
font-family: "Permanent Marker", sans-serif ;
font-size: 36px ;
letter-spacing: 3px ;
line-height: 41px ;
padding: 25px 20px 26px 20px ;
text-align: center ;
}
}
</style>
<script type="text/ng-template" id="bn-name-tag.htm">
<div class="wrapper">
<header class="header">
<span class="title">
Hello
</span>
<span class="sub-title">
My name is
</span>
</header>
<div class="name" ng-transclude>
<!-- Name goes here. -->
</div>
</div>
</script>
<!-- The browser-based LESS compiler has to be included AFTER the LESS blocks. -->
<script type="text/javascript" src="../../../vendor/less/3.9.0/less.min.js"></script>
</body>
</html>
In this approach, my AngularJS element template contains "normal" CSS class names. But, when the element is being compiled, I pass control over to the fakeCssModuleCompiler() function, which, in turn, takes the Template Element, locates all of the embedded elements using .find(*), and injects an HTML Attribute, "m-ee8f6b", into each of them.
At this point, my LESS CSS then needs to define one host-level attribute selector after which it can use the "parent selector" (&) to define all nested selectors.
If we run this version of the scoped CSS modules in AngularJS 1.2.22, we get the following output:
As you can see, this approach renders the exact same visual experience. But, I didn't have to maintain the CSS classes in the HTML! This approach is much closer to the sweet encapsulated CSS provided by Angular 7. And, just for the mental reference, here is what the LESS CSS compiles down to in this version:
[m-ee8f6b].m-ee8f6b {
background-color: #ff0000;
border: 10px solid #ffffff;
border-radius: 15px 15px 15px 15px ;
box-shadow: 0px 0px 6px rgba(0, 0, 0, 0.2);
box-sizing: border-box;
display: inline-block;
min-width: 300px ;
}
[m-ee8f6b].wrapper {
padding: 0px 0px 13px 0px ;
}
[m-ee8f6b].header {
color: #ffffff;
padding: 10px 0px 10px 0px ;
font-family: helvetica, arial, sans-serif;
text-align: center ;
text-transform: uppercase ;
}
[m-ee8f6b].title {
display: block ;
font-size: 38px ;
font-weight: bold ;
line-height: 43px ;
letter-spacing: 4px ;
}
[m-ee8f6b].sub-title {
display: block ;
font-size: 18px ;
line-height: 22px ;
letter-spacing: 1px ;
}
[m-ee8f6b].name {
background-color: #ffffff;
color: inherit ;
font-family: "Permanent Marker", sans-serif;
font-size: 36px ;
letter-spacing: 3px ;
line-height: 41px ;
padding: 25px 20px 26px 20px ;
text-align: center ;
}
As you can see, each of my CSS selectors is prefixed with the attribute selector, which is how the "CSS encapsulation" is emulated in my component.
To be clear, compiling the element template at runtime does have some overhead! So, there may be a performance consideration if this is done at a large scale? I am not entirely sure - Angular is pretty smart in how much it can cache. This is something I will be experimenting with. But, at this time, I don't have the experience to say whether or not this is a horrible idea.
If nothing else, this was a very fun thought experiment. And, it is something that I plan to apply to some of my "new brownfield" work. If it proves to be a performance problem, I'll report back. But, for now, I'm excited to be able to pull in some of the best practices from my R&D and inject some fresh blood into this ancient application.
Want to use code from this post? Check out the license.
Reader Comments