A Proof of Concept for Making Sass Faster
Publikováno: 17.9.2019
At the start of a new project, Sass compilation happens in the blink of an eye. This feels great, especially when it’s paired with Browsersync, which reloads the stylesheet for us in the browser. But, as the amount of Sass grows, compilation time increases. This is far from ideal.
It can be a real pain when the compilation time creeps over one or two seconds. For me, that's enough time to lose focus at the end of a long day. … Read article
The post A Proof of Concept for Making Sass Faster appeared first on CSS-Tricks.
At the start of a new project, Sass compilation happens in the blink of an eye. This feels great, especially when it’s paired with Browsersync, which reloads the stylesheet for us in the browser. But, as the amount of Sass grows, compilation time increases. This is far from ideal.
It can be a real pain when the compilation time creeps over one or two seconds. For me, that's enough time to lose focus at the end of a long day. So, I would like to share a solution that's available in a WordPress CSS Editor called Microthemer, as a proof of concept.
This is a two-part article. The first part is for the attention of Sass users. We will introduce the basic principles, performance results, and an interactive demo. The second part covers the nuts and bolts of how Microthemer makes Sass faster. And considers how to implement this as an npm package that can deliver fast, scalable Sass to a much broader community of developers.
How Microthemer compiles Sass in an instant
In some ways, this performance optimisation is simple. Microthemer just compiles less Sass code. It doesn’t intervene with Sass’ internal compilation process.
In order to feed the Sass compiler less code, Microthemer maintains an understanding of Sass entities used throughout the code base, like variables and mixins. When a selector is edited, Microthemer only compiles that single selector, plus any related selectors. Selectors are related if they make use of the same variables for instance, or one selector extends another. With this system, Sass compilation remains as fast with 3000 selectors as it does with a handful.
Performance results
With 3000 selectors, the compile time is around 0.05 seconds. It varies, of course. Sometimes it might be closer to 0.1 seconds. Other times the compilation happens as fast as 0.01 seconds (10ms).
To see for yourself, you can watch a video demonstration. Or mess around with the online Microthemer playground (see instructions below).
Online Microthemer playground
The online playground makes it easy to experiment with Microthemer yourself.
Instructions
- Go to the online Microthemer playground.
- Enable support for Sass via General → Preferences → CSS / SCSS → Enable SCSS.
- Go to View → full code editor → on to add global variables, mixins, and functions.
- Switch back to the main UI view (View → full code editor → off).
- Create selectors via the Target button.
- Add Sass code via the editor to the left of the Font property group.
- After each change, you can see what code Microthemer included in the compile process via View → Generated CSS → Previous SCSS compile.
- To see how this works at scale, you can import vanilla CSS from a large stylesheet into Microthemer via Packs → Import → CSS stylesheet (importing Sass isn't supported - yet).
Do you want this as an npm package?
Microthemer’s selective compilation technique could also be delivered as an npm package. But the question is, do you see a need for this? Could your local Sass environment do with a speed boost? If so, please leave a comment below.
The rest of this article is aimed at those who develop tools for the community. As well as those who might be curious about how this challenge was tackled.
The Microthemer way to compile Sass
We will move on to some code examples shortly. But first, let's consider the main application goals.
1. Compile minimal code
We want to compile the one selector being edited if it has no relationship with other selectors, or multiple selectors with related Sass entities — but no more than necessary.
2. Responsive to code changes
We want to remove any perception of waiting for Sass to compile. We also don't want to crunch too much data between user keystrokes.
3. Equal CSS output
We want to return the same CSS a full compile would generate, but for a subset of code.
Sass examples
The following code will serve as a point of reference throughout this article. It covers all of the scenarios our selective compiler needs to handle. Such as global variables, mixin side-effects, and extended selectors.
Variables, functions, and mixins
$primary-color: green;
$secondary-color: red;
$dark-color: black;
@function toRem($px, $rootSize: 16){
@return #{$px / $rootSize}rem;
}
@mixin rounded(){
border-radius: 999px;
$secondary-color: blue !global;
}
Selectors
.entry-title {
color: $dark-color;
}
.btn {
display: inline-block;
padding: 1em;
color: white;
text-decoration: none;
}
.btn-success {
@extend .btn;
background-color: $primary-color;
@include rounded;
}
.btn-error {
@extend .btn;
background-color: $secondary-color;
}
// Larger screens
@media (min-width: 960px) {
.btn-success {
border:4px solid darken($primary-color, 10%);
&::before {
content: "\2713"; // unicode tick
margin-right: .5em;
}
}
}
The Microthemer interface
Microthemer has two main editing views.
View 1: Full code
We edit the full code editor in the same way as a regular Sass file. That’s where global variables, functions, mixins, and imports go.
View 2: Visual
The visual view has a single selector architecture. Each CSS selector is a separate UI selector. These UI selectors are organized into folders.
Because Microthemer segments individual selectors, analysis happens at a very granular scale — one selector at a time.
Here’s a quick quiz question for you. The $secondary-color
variable is set to red
at the top of the full code view. So why is the error button in the previous screenshots blue? Hint: it has to do with mixin side effects. More on that shortly.
Third party libraries
A huge thanks to the authors of the following JavaScript libraries Microthemer uses:
- Gonzales PE - This converts Sass code to an abstract syntax tree (AST) JavaScript object.
- Sass.js - This converts Sass to CSS code in the browser. It uses web workers to run the compilation on a separate thread.
Data objects
Now for the nitty gritty details. Figuring out an appropriate data structure took some trial and error. But once sorted, the application logic fell into place naturally. So we’ll start by explaining the main data stores, and then finish with a brief summary of the processing steps.
Microthemer uses four main JavaScript objects for storing application data.
projectCode
: Stores all project code partitioned into discreet items for individual selectors.projectEntities
: Stores all variables, functions, mixins, extends and imports used in the project, as well as the locations of where these entities are used.connectedEntities
: Stores the connections one piece of code has with project Sass entities.compileResources
: Stores the selective compile data following a change to the code base.
projectCode
The projectCode
object allows us to quickly retrieve pieces of Sass code. We then combine these pieces into a single string for compilation.
files
: With Microthemer, this stores the code added to the full code view described earlier. With an npm implementation,files
would relate to actual .sass or .scss system files.folders
: Microthemer’s UI folders that contain segmented UI selectors.index
: The order of a folder, or a selector within a folder.itemData
: The actual code for the item, explained further in the next code snippet.
var projectCode = {
// Microthemer full code editor
files: {
full_code: {
index: 0,
itemData: itemData
}
},
// Microthemer UI folders and selectors
folders: {
content_header: {
index:100,
selectors: {
'.entry-title': {
index:0,
itemData: itemData
},
}
},
buttons: {
index:200,
selectors: {
'.btn': {
index:0,
itemData: itemData
},
'.btn-success': {
index:1,
itemData: itemData
},
'.btn-error': {
index:2,
itemData: itemData
}
}
}
}
};
itemData for .btn-success selector
The following code example shows the itemData
for the .btn-success
selector.
sassCode
: Used to build the compilation string.compiledCSS
: Stores compiled CSS for writing to a stylesheet or style node in the document head.sassEntities
: Sass entities for single selector or file. Allows for before and after change analysis, and is used to build theprojectEntities
object.mediaQueries
: Same data as above, but for a selector used inside a media query.
var itemData = {
sassCode: ".btn-success { @extend .btn; background-color: $primary-color; @include rounded; }",
compiledCSS: ".btn-success { background-color: green; border-radius: 999px; }",
sassEntities: {
extend: {
'.btn': {
values: ['.btn']
}
},
variable: {
primary_color: {
values: [1]
}
},
mixin: {
rounded: {
values: [1]
}
}
},
mediaQueries: {
'min-width(960px)': {
sassCode: ".btn-success { border:4px solid darken($primary-color, 10%); &::before { content: '\\2713'; margin-right: .5em; } }",
compiledCSS: ".btn-success::before { content: '\\2713'; margin-right: .5em; }",
sassEntities: {
variable: {
primary_color: {
values: [1]
}
},
function: {
darken: {
values: [1]
}
}
}
}
}
};
projectEntities
The projectEntities
object allows us to check which selectors use particular Sass entities.
variable
,function
,mixin
,extend
: The type of Sass entity.- E.g.
primary_color
: The Sass entity name. Microthemer normalizes hyphenated names because Sass uses hyphens and underscores interchangeably. values
: An array of declaration values or instances. Instances are represented by the number 1. The Gonzales PE Sass parser converts numeric declaration values to strings. So I’ve elected to use the integer 1 to flag instances.itemDeps
: An array of selectors that makes use of the Sass entity. This is explained further in the next code snippet.relatedEntities
: Ourrounded
mixin has a side effect of updating the global$secondary-color
variable toblue
, hence the blue error button. This side effect makes therounded
and$secondary-color
entities co-dependent. So, when the$secondary-color
variable is included, therounded
mixin should be included too, and vice versa.
var projectEntities = {
variable: {
primary_color: {
values: ['green', 1],
itemDeps: itemDeps
},
secondary_color: {
values: ["red", "blue !global", 1],
itemDeps: itemDeps,
relatedEntities: {
mixin: {
rounded: {}
}
}
},
dark_color: {
values: ["black", 1],
itemDeps: itemDeps
}
},
function: {
darken: {
values: [1]
},
toRem: {
values: ["@function toRem($px, $rootSize: 16){↵ @return #{$px / $rootSize}rem;↵}", 1],
itemDeps: itemDeps
}
},
mixin: {
rounded: {
values: ["@mixin rounded(){↵ border-radius:999px;↵ $secondary-color: blue !global;↵}", 1],
itemDeps: itemDeps,
relatedEntities: {
variable: {
secondary_color: {
values: ["blue !global"],
}
}
}
}
},
extend: {
'.btn': {
values: ['.btn', '.btn'],
itemDeps: itemDeps
}
}
};
itemDeps for the $primary-color Sass entity
The following code example shows the itemDeps
for the $primary-color
(primary_color
) variable. The $primary-color
variable is used by two forms of the .btn-success
selector, including a selector inside the min-width(960px)
media query.
path
: Used to retrieve selector data from theprojectCode
object.mediaQuery
: Used when updating style nodes or writing to a CSS stylesheet.
var itemDeps = [
{
path: ["folders", 'header', 'selectors', '.btn-success'],
},
{
path: ["folders", 'header', 'selectors', '.btn-success', 'mediaQueries', 'min-width(960px)'],
mediaQuery: 'min-width(960px)'
}
];
connectedEntities
The connectedEntities
object allows us to find related pieces of code. We populate it following a change to the code base. So, if we were to remove the font-size
declaration from the .btn
selector, the code would change from this:
.btn {
display: inline-block;
padding: 1em;
color: white;
text-decoration: none;
font-size: toRem(21);
}
...to this:
.btn {
display: inline-block;
padding: 1em;
color: white;
text-decoration: none;
}
And we would store Microthemer’s analysis in the following connectedEntities
object.
changed
: The change analysis, which captures the removal of thetoRem
function.actions
: an array of user actions.form
: Declaration (e.g.$var: 18px
) or instance (e.g.font-size: $var
).value
: A text value for a declaration, or the integer 1 for an instance.
coDependent
: Extended selectors must always compile with the extending selector, and vice versa. The relationship is co-dependent. Variables, functions, and mixins are only semi-dependent. Instances must compile with declarations, but declarations do not need to compile with instances. However, Microthemer treats them as co-dependent for the sake of simplicity. In the future, logic will be added to filter out unnecessary instances, but this has been left out for the first release.related
: therounded
mixin is related to the$secondary-color
variable. It updates that variable using theglobal
flag. The two entities are co-dependent; they should always compile together. But in our example, the.btn
selector makes no use of therounded
mixin. So, therelated
property below is not populated with anything.
var connectedEntities = {
changed: {
function: {
toRem: {
actions: [{
action: "removed",
form: "instance",
value: 1
}]
}
}
},
coDependent: {
extend: {
'.btn': {}
}
},
related: {}
};
compileResources
The compileResources
object allows us to compile a subset of code in the correct order. In the previous section we removed the font-size declaration. The code below shows how the compileResources
object would look after that change.
compileParts
: An array of resources to be compiled.path
: Used to update thecompiledCSS
property of the relevantprojectCode
item.sassCode
: Used to build thesassString
for compilation. We append a CSS comment to each piece (/*MTPART*/
) . This comment is used to split the combined CSS output into thecssParts
array.
sassString
: A string of Sass code that compiles to CSS.cssParts
: CSS output in the form of an array. The array keys forcssParts
line up with thecompileParts
array.
var compileResources = {
compileParts: [
{
path: ["files", "full_code"],
sassCode: "/*MTFILE*/$primary-color: green; $secondary-color: red; $dark-color: black; @function toRem($px, $rootSize: 16){ @return #{$px / $rootSize}rem; } @mixin rounded(){ border-radius:999px; $secondary-color: blue !global;}/*MTPART*/"
},
{
path: ["folders", "buttons", ".btn"],
sassCode: ".btn { display: inline-block; padding: 1em; color: white; text-decoration: none; }/*MTPART*/"
},
{
path: ["folders", "buttons", ".btn-success"],
sassCode: ".btn-success { @extend .btn; background-color: $primary-color; @include rounded; }/*MTPART*/"
},
{
path: ["folders", "buttons", ".btn-error"],
sassCode: ".btn-error { @extend .btn; background-color: $secondary-color; }/*MTPART*/"
}
],
sassString:
"/*MTFILE*/$primary-color: green; $secondary-color: red; $dark-color: black; @function toRem($px, $rootSize: 16){ @return #{$px / $rootSize}rem; } @mixin rounded(){ border-radius:999px; $secondary-color: blue !global;}/*MTPART*/"+
".btn { display: inline-block; padding: 1em; color: white; text-decoration: none;}/*MTPART*/"+
".btn-success {@extend .btn; background-color: $primary-color; @include rounded;}/*MTPART*/"+
".btn-error {@extend .btn; background-color: $secondary-color;}/*MTPART*/",
cssParts: [
"/*MTFILE*//*MTPART*/",
".btn, .btn-success, .btn-error { display: inline-block; padding: 1em; color: white; text-decoration: none;}/*MTPART*/",
".btn-success { background-color: green; border-radius: 999px;}/*MTPART*/",
".btn-error { background-color: blue;}/*MTPART*/"
]
};
Why were four resources included?
full_code
: ThetoRem
Sass entity changed and thefull_code
resource contains thetoRem
function declaration..btn
: the selector was edited..btn-success
: Uses@extend .btn
and so it must always compile with.btn
. The combined selector becomes.btn, .btn-success
..btn-error
: This also uses@extend .btn
and so must be included for the same reasons as.btn-success
.
Two selectors are not included because they are not related to the .btn
selector.
.entry-title
.btn-success
(inside the media query)
Recursive resource gathering
Aside from the data structure, the most time consuming challenge was figuring out how to pull in the right subset of Sass code. When one piece of code connects to another piece, we need to check for connections on the second piece too. There is a chain reaction. To support this, the following gatherCompileResources
function is recursive.
- We loop the
connectedEntities
object down to the level of Sass entity names. - We use recursion if a function or mixin has side effects (like updating global variables).
- The
checkObject
function returns the value of an object at a particular depth, or false if no value exists. - The
updateObject
function sets the value of an object at a particular depth. - We add dependent resources to the
compileParts
array, usingabsoluteIndex
as the key. - Microthemer calculates
absoluteIndex
by adding the folder index to the selector index. This works because folder indexes increment in hundreds, and the maximum number of selectors per folder is 40, which is fewer than one hundred. - We use recursion if resources added to the
compileParts
array also have co-dependent relationships.
function gatherCompileResources(compileResources, connectedEntities, projectEntities, projectCode, config){
let compileParts = compileResources.compileParts;
// reasons: changed / coDependent / related
const reasons = Object.keys(connectedEntities);
for (const reason of reasons) {
// types: variable / function / mixin / extend
const types = Object.keys(connectedEntities[reason]);
for (const type of types) {
// names: e.g. toRem / .btn / primary_color
const names = Object.keys(connectedEntities\[reason\][type]);
for (const name of names) {
// check side-effects for Sass entity (if not checked already)
if (!checkObject(config.relatedChecked, [type, name])){
updateObject(config.relatedChecked, [type, name], 1);
const relatedEntities = checkObject(projectEntities, [type, name, 'relatedEntities']);
if (relatedEntities){
compileParts = gatherCompileResources(
compileResources, { related: relatedEntities }, projectEntities, projectCode, config
);
}
}
// check if there are dependent pieces of code
const itemDeps = checkObject(projectEntities, [type, name, 'itemDeps']);
if (itemDeps && itemDeps.length > 0){
for (const dep of itemDeps) {
let path = dep.path,
resourceID = path.join('.');
if (!config.resourceAdded[resourceID]){
// if we have a valid resource
let resource = checkObject(projectCode, path);
if (resource){
config.resourceAdded[resourceID] = 1;
// get folder index + resource index
let absoluteIndex = getAbsoluteIndex(path);
// add compile part
compileParts[absoluteIndex] = {
sassCode: resource.sassCode,
mediaQuery: resource.mediaQuery,
path: path
};
// if resource is co-dependent, pull in others
let coDependent = getCoDependent(resource);
if (coDependent){
compileParts = gatherCompileResources(
compileResources, { coDependent: coDependent }, projectEntities, projectCode, config
);
}
}
}
}
}
}
}
}
return compileParts;
}
The application process
We’ve covered the technical aspects now. To see how it all ties together, let’s walk through the data processing steps.
From keystroke to style render
- A user keystroke fires the textarea change event.
- We convert the single selector being edited into a
sassEntities
object. This allows for comparison with the pre-edit Sass entities:projectCode > dataItem > sassEntities
. -
If any Sass entities changed:
- We update
projectCode > dataItem > sassEntities
. - If an
@extend
rule changed:- We search the
projectCode
object to find matching selectors. - We store the
path
for matching selectors on the current data item:projectCode > dataItem > sassEntities > extend > target > [ path ]
.
- We search the
- We rebuild the
projectEntities
object by looping over theprojectCode
object. - We populate
connectedEntities > changed
with the change analysis. -
If
extend
,variable
,function
, ormixin
entities are present:- We populate
connectedEntities > coDependent
with the relevant entities.
- We populate
- We update
- The recursive
gatherCompileResources
function uses theconnectedEntities
object to populate thecompileResources
object. - We concatenate the
compileResources > compileParts
array into a single Sass string. - We compile the single Sass string to CSS.
- We use comment dividers to split the output into an array:
compileResources > cssParts
. This array lines up with thecompileResources > compileParts
array via matching array keys. - We use resource paths to update the
projectCode
object with compiled CSS. - We write the CSS for a given folder or file to a style node in the document head for immediate style rendering. On the server-side, we write all CSS to a single stylesheet.
Considerations for npm
There are some extra considerations for an npm package. With a typical NodeJS development environment:
- Users edit selectors as part of a larger file, rather than individually.
- Sass imports are likely to play a larger role.
Segmentation of code
Microthemer’s visual view segments individual selectors. This makes parsing code to a sassEntities
object super fast. Parsing whole files might be a different story, especially large ones.
Perhaps techniques exist for virtually segmenting system files? But suppose there is no way around parsing whole files. Or it just seems sensible for a first release. Maybe it’s sufficient to advise end users to keep Sass files small for best results.
Sass imports
At the time of writing, Microthemer doesn’t analyse import files. Instead, it includes all Sass imports whenever a selector makes use of any Sass entities. This is an interim first release solution that is okay in the context of Microthemer. But I believe an npm implementation should track Sass usage across all project files.
Our projectCode
object already has a files
property for storing file data. I propose calculating file indexes relative to the main Sass file. For instance, the file used in the first @import
rule would have index: 0
, the next, index: 1
, and so on. We would need to scan @import
rules within imported files to calculate these indexes correctly.
We would need to calculate absoluteIndex
differently, too. Unlike Microthemer folders, system files can have any number of selectors. The compileParts
array might need to be an object, storing an array of parts for each file. That way, we only keep track of selector indexes within a given file and then concatenate the compileParts
arrays in the order of the files.
Conclusion
This article introduces a new way to compile Sass code, selectively. Near instant Sass compilation was necessary for Microthemer because it is a live CSS editor. And the word 'live' carries certain expectations of speed. But it may also be desirable for other environments, like Node.js. This is for Node.js and Sass users to decide, and hopefully share their thoughts in the comments below. If the demand is there, I hope an npm developer can take advantage of the points I’ve shared.
Please feel free to post any questions about this in my forum. I'm always happy to help.
The post A Proof of Concept for Making Sass Faster appeared first on CSS-Tricks.