Home > Designing, Others > A Proof of Concept for Making Sass Faster

A Proof of Concept for Making Sass Faster

September 17th, 2019 Leave a comment Go to comments

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

  1. Go to the online Microthemer playground.
  2. Enable support for Sass via General ? Preferences ? CSS / SCSS ? Enable SCSS.
  3. Go to View ? full code editor ? on to add global variables, mixins, and functions.
  4. Switch back to the main UI view (View ? full code editor ? off).
  5. Create selectors via the Target button.
  6. Add Sass code via the editor to the left of the Font property group.
  7. After each change, you can see what code Microthemer included in the compile process via View ? Generated CSS ? Previous SCSS compile.
  8. 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.

  1. projectCode: Stores all project code partitioned into discreet items for individual selectors.
  2. projectEntities: Stores all variables, functions, mixins, extends and imports used in the project, as well as the locations of where these entities are used.
  3. connectedEntities: Stores the connections one piece of code has with project Sass entities.
  4. 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, fileswould 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 the projectEntities 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: Our rounded mixin has a side effect of updating the global $secondary-color variable to blue, hence the blue error button. This side effect makes the rounded and $secondary-color entities co-dependent. So, when the $secondary-color variable is included, the roundedmixin 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 the projectCode 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 the toRem 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: the rounded mixin is related to the $secondary-color variable. It updates that variable using the global flag. The two entities are co-dependent; they should always compile together. But in our example, the .btn selector makes no use of the rounded mixin. So, the related 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 the compiledCSS property of the relevant projectCodeitem.
    • sassCode: Used to build the sassString for compilation. We append a CSS comment to each piece (/*MTPART*/) . This comment is used to split the combined CSS output into the cssParts array.
  • sassString: A string of Sass code that compiles to CSS.
  • cssParts: CSS output in the form of an array. The array keys for cssParts line up with the compileParts 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?

  1. full_code: The toRem Sass entity changed and the full_code resource contains the toRem function declaration.
  2. .btn: the selector was edited.
  3. .btn-success: Uses @extend .btn and so it must always compile with .btn. The combined selector becomes .btn, .btn-success.
  4. .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.

  1. .entry-title
  2. .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, using absoluteIndex 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

  1. A user keystroke fires the textarea change event.
  2. We convert the single selector being edited into a sassEntities object. This allows for comparison with the pre-edit Sass entities: projectCode > dataItem > sassEntities.
  3. 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 rebuild the projectEntities object by looping over the projectCode object.
    • We populate connectedEntities > changed with the change analysis.
    • If extend, variable, function, or mixin entities are present:

      • We populate connectedEntities > coDependent with the relevant entities.
  4. The recursive gatherCompileResources function uses the connectedEntities object to populate the compileResources object.
  5. We concatenate the compileResources > compileParts array into a single Sass string.
  6. We compile the single Sass string to CSS.
  7. We use comment dividers to split the output into an array: compileResources > cssParts. This array lines up with the compileResources > compileParts array via matching array keys.
  8. We use resource paths to update the projectCode object with compiled CSS.
  9. 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.

Categories: Designing, Others Tags:
  1. No comments yet.
  1. No trackbacks yet.
You must be logged in to post a comment.