让 Sass 更快的概念证明

Avatar of Sebastian Webb
Sebastian Webb

DigitalOcean 为您的旅程各个阶段提供云产品。 立即开始使用 $200 免费额度!

在开始新项目时,Sass 编译眨眼间就完成了。 这感觉很棒,尤其是在与 Browsersync 配合使用时,Browsersync 会在浏览器中为我们重新加载样式表。 但是,随着 Sass 数量的增加,编译时间也会随之增加。 这绝非理想状态。

当编译时间超过一两秒时,这确实令人头疼。 对我来说,这已经足够让我在漫长的一天结束时失去专注力。 因此,我想分享一个在 WordPress CSS 编辑器 Microthemer 中提供的解决方案,作为概念证明。

这是一篇两部分的文章。 第一部分针对 Sass 用户。 我们将介绍基本原理、性能结果和一个交互式演示。 第二部分介绍了 Microthemer 如何让 Sass 更快的具体细节。 并考虑如何将其作为 npm 包实施,以将快速、可扩展的 Sass 提供给更广泛的开发者社区。

Microthemer 如何瞬间编译 Sass

在某些方面,这种性能优化很简单。 Microthemer 只编译较少的 Sass 代码。 它不会干预 Sass 的内部编译过程。

为了向 Sass 编译器提供更少的代码,Microthemer 会了解整个代码库中使用的 Sass 实体,例如变量和 mixin。 当编辑选择器时,Microthemer 只会编译该单个选择器,以及任何相关的选择器。 如果选择器使用相同的变量,或者一个选择器扩展了另一个选择器,则它们是相关的。 使用此系统,Sass 编译速度与 3000 个选择器一样快,与少量选择器一样快。

性能结果

对于 3000 个选择器,编译时间约为 0.05 秒。 当然,它会有所不同。 有时它可能更接近 0.1 秒。 其他时候,编译速度快至 0.01 秒(10 毫秒)。

要亲自尝试,您可以 观看视频演示。 或者使用在线 Microthemer 操场(请参阅以下说明)。

在线 Microthemer 操场

在线操场让您自己轻松体验 Microthemer。

说明

  1. 访问 在线 Microthemer 操场.
  2. 通过 常规 → 首选项 → CSS / SCSS → 启用 SCSS 启用对 Sass 的支持。
  3. 访问 视图 → 全部代码编辑器 → 开启 以添加全局变量、mixin 和函数。
  4. 切换回主 UI 视图(视图 → 全部代码编辑器 → 关闭)。
  5. 通过目标按钮创建选择器。
  6. 通过字体属性组左侧的编辑器添加 Sass 代码。
  7. 在每次更改后,您可以在 视图 → 生成的 CSS → 上次 SCSS 编译 中查看 Microthemer 在编译过程中包含的代码。
  8. 要查看此功能如何大规模运行,您可以通过 包 → 导入 → CSS 样式表 将大型样式表中的普通 CSS 导入 Microthemer(目前不支持导入 Sass)。

您希望将其作为 npm 包吗?

Microthemer 的选择性编译技术也可以作为 npm 包提供。 但问题是,您认为有这种需求吗? 您的本地 Sass 环境是否需要提速? 如果是,请在下方留言。

本文的其余部分针对那些为社区开发工具的人。 以及那些可能对如何解决这一挑战感兴趣的人。

Microthemer 编译 Sass 的方法

我们很快就会进入一些代码示例。 但首先,让我们考虑主要应用目标。

1. 编译最少代码

如果编辑的选择器与其他选择器没有关系,则我们希望编译正在编辑的单个选择器,或者编译具有相关 Sass 实体的多个选择器——但绝不超过必要程度。

2. 对代码更改做出响应

我们希望消除等待 Sass 编译的任何感知。 我们也不希望在用户按键之间处理太多数据。

3. 相同的 CSS 输出

我们希望返回与完整编译生成的 CSS 相同的 CSS,但仅针对代码子集。

Sass 示例

以下代码将作为本文的参考点。 它涵盖了我们的选择性编译器需要处理的所有情况。 例如全局变量、mixin 副作用和扩展选择器。

变量、函数和 mixin

$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;
}

选择器

.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;
    }
  }
}

Microthemer 界面

Microthemer 有两个主要的编辑视图。

视图 1:全部代码

我们以与普通 Sass 文件相同的方式编辑全部代码编辑器。 这就是全局变量、函数、mixin 和导入所在的地方。

视图 2:视觉

视觉视图采用单选择器架构。每个 CSS 选择器都是一个独立的 UI 选择器。这些 UI 选择器被组织成文件夹。

由于 Microthemer 对单个选择器进行分割,因此分析发生在非常细粒度的级别——一次一个选择器。

这里有一个快速的小测验问题。$secondary-color 变量在完整代码视图的顶部设置为 red。那么为什么之前的屏幕截图中的错误按钮是蓝色的呢?提示:这与混合的副作用有关。稍后将详细介绍。

第三方库

衷心感谢以下 Microthemer 使用的 JavaScript 库的作者。

  • Gonzales PE – 将 Sass 代码转换为抽象语法树 (AST) JavaScript 对象。
  • Sass.js – 在浏览器中将 Sass 转换为 CSS 代码。它使用 Web Workers 在单独的线程上运行编译。

数据对象

现在进入具体细节。找出合适的 数据结构 需要一些反复试验。但一旦确定,应用程序逻辑就会自然地落位。因此,我们将首先解释主要数据存储,然后以对处理步骤的简要总结结束。

Microthemer 使用四个主要的 JavaScript 对象来存储应用程序数据。

  1. projectCode:存储所有项目代码,并将其划分为独立的项,用于单个选择器。
  2. projectEntities:存储项目中使用的所有变量、函数、混合、扩展和导入,以及这些实体使用位置。
  3. connectedEntities:存储代码段与项目 Sass 实体之间的连接关系。
  4. compileResources:存储代码库更改后选择性编译数据。

projectCode

projectCode 对象允许我们快速检索 Sass 代码段。然后,我们将这些代码段组合成一个字符串以进行编译。

  • files:在 Microthemer 中,它存储添加到前面提到的完整代码视图中的代码。在 npm 实现中,files 将与实际的 .sass 或 .scss 系统文件相关联。
  • folders:Microthemer 的 UI 文件夹,其中包含分段的 UI 选择器。
  • index:文件夹或文件夹内选择器的顺序。
  • itemData:项目的实际代码,将在下一段代码中进一步解释。
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
        }
      }
    }
  }
};

.btn-success 选择器的 itemData

以下代码示例显示了 .btn-success 选择器的 itemData

  • sassCode:用于构建编译字符串。
  • compiledCSS:存储已编译的 CSS,用于写入样式表或文档头部的样式节点。
  • sassEntities:单个选择器或文件的 Sass 实体。允许进行更改前后分析,并用于构建 projectEntities 对象。
  • mediaQueries:与上述数据相同,但用于媒体查询中的选择器。
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

projectEntities 对象允许我们检查哪些选择器使用特定的 Sass 实体。

  • variablefunctionmixinextend:Sass 实体类型。
  • 例如 primary_color:Sass 实体名称。Microthemer 会规范化带连字符的名称,因为 Sass 可以互换地使用连字符和下划线。
  • values:声明值或实例的数组。实例由数字 1 表示。Gonzales PE Sass 解析器将数字声明值转换为字符串。因此,我选择使用整数 1 来标记实例。
  • itemDeps:使用 Sass 实体的选择器数组。这将在下一段代码中进一步解释。
  • relatedEntities:我们的 rounded 混合具有更新全局 $secondary-color 变量为 blue 的副作用,因此错误按钮是蓝色的。这种副作用使得 rounded$secondary-color 实体相互依赖。因此,当包含 $secondary-color 变量时,也应该包含 rounded 混合,反之亦然。
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
    }
  }
};

$primary-color Sass 实体的 itemDeps

以下代码示例显示了 $primary-colorprimary_color)变量的 itemDeps$primary-color 变量由 .btn-success 选择器的两种形式使用,包括 min-width(960px) 媒体查询中的选择器。

  • path:用于从 projectCode 对象中检索选择器数据。
  • mediaQuery:用于更新样式节点或写入 CSS 样式表。
var itemDeps = [
  {
    path: ["folders", 'header', 'selectors', '.btn-success'],
  },
  {
    path: ["folders", 'header', 'selectors', '.btn-success', 'mediaQueries', 'min-width(960px)'],
    mediaQuery: 'min-width(960px)'
  }
];

connectedEntities

connectedEntities 对象允许我们找到相关的代码段。我们会在代码库更改后填充它。因此,如果我们要从 .btn 选择器中删除 font-size 声明,代码将从以下内容更改为:

.btn {
    display: inline-block;
    padding: 1em;
    color: white;
    text-decoration: none;
    font-size: toRem(21);
}

…更改为以下内容

.btn {
    display: inline-block;
    padding: 1em;
    color: white;
    text-decoration: none;
}

然后,我们将 Microthemer 的分析存储在以下 connectedEntities 对象中。

  • changed:更改分析,它捕获了对 toRem 函数的删除。

    • actions:用户操作数组。
    • form:声明(例如 $var: 18px)或实例(例如 font-size: $var)。
    • value:声明的文本值,或实例的整数 1。
  • coDependent:扩展选择器必须始终与扩展选择器一起编译,反之亦然。这种关系是相互依赖的。变量、函数和混合只是半依赖的。实例必须与声明一起编译,但声明不需要与实例一起编译。但是,为了简单起见,Microthemer 将它们视为相互依赖的。将来,将添加逻辑来过滤掉不必要的实例,但这是在第一个版本中省略的。
  • relatedrounded 混合与 $secondary-color 变量相关。它使用 global 标志更新该变量。这两个实体是相互依赖的;它们应该始终一起编译。但在我们的示例中,.btn 选择器没有使用 rounded 混合。因此,下面的 related 属性没有填充任何内容。
var connectedEntities = {
  changed: {
    function: {
      toRem: {
        actions: [{
          action: "removed",
          form: "instance",
          value: 1
        }]
      }
    }
  },
  coDependent: {
    extend: {
      '.btn': {}
    }
  },
  related: {}
};

compileResources

compileResources 对象允许我们按正确顺序编译代码子集。在上一节中,我们删除了 font-size 声明。以下代码显示了更改后 compileResources 对象的外观。

  • compileParts:要编译的资源数组。

    • path:用于更新相关 projectCode 项的 compiledCSS 属性。
    • sassCode:用于构建 sassString 以进行编译。我们在每个代码段中附加一个 CSS 注释(/*MTPART*/)。此注释用于将组合的 CSS 输出拆分为 cssParts 数组。
  • sassString:编译为 CSS 的 Sass 代码字符串。
  • cssParts:CSS 输出形式为数组。cssParts 的数组键与 compileParts 数组一致。
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*/"
  ]
};

为什么包含了四个资源?

  1. full_codetoRem Sass 实体发生了更改,并且 full_code 资源包含 toRem 函数声明。
  2. .btn:选择器被编辑了。
  3. .btn-success:使用 @extend .btn,因此它必须始终与 .btn 一起编译。组合选择器变为 .btn, .btn-success
  4. .btn-error:这也使用 @extend .btn,因此出于与 .btn-success 相同的原因,它必须包含在内。

两个选择器未包含在内,因为它们与.btn选择器无关。

  1. .entry-title
  2. .btn-success(在媒体查询内)

递归资源收集

除了数据结构之外,最耗时的挑战是弄清楚如何提取正确的 Sass 代码子集。当一段代码连接到另一段代码时,我们需要检查第二段代码的连接。这是一个连锁反应。为了支持这一点,以下gatherCompileResources函数是递归的。

  • 我们循环遍历connectedEntities对象,直到 Sass 实体名称级别。
  • 如果函数或 mixin 具有副作用(例如更新全局变量),我们使用递归。
  • checkObject函数返回特定深度的对象的 value,如果不存在 value 则返回 false。
  • updateObject函数设置特定深度的对象的 value。
  • 我们使用absoluteIndex作为键,将依赖资源添加到compileParts数组中。
  • Microthemer 通过将文件夹索引添加到选择器索引来计算absoluteIndex。这是有效的,因为文件夹索引以数百递增,每个文件夹的选择器数量最大为 40,少于一百。
  • 如果添加到compileParts数组中的资源也具有共同依赖关系,我们使用递归。
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;
}

应用程序流程

我们现在已经涵盖了技术方面。要了解这一切如何联系在一起,让我们来了解一下数据处理步骤。

从击键到样式渲染

  1. 用户击键会触发文本区域更改事件。
  2. 我们将正在编辑的单个选择器转换为sassEntities对象。这允许与预编辑 Sass 实体进行比较:projectCode > dataItem > sassEntities
  3. 如果任何 Sass 实体发生了变化

    • 我们更新projectCode > dataItem > sassEntities
    • 如果@extend规则发生了变化
      • 我们搜索projectCode对象以查找匹配的选择器。
      • 我们将匹配选择器的path存储在当前数据项上:projectCode > dataItem > sassEntities > extend > target > [ path ]
    • 我们通过循环遍历projectCode对象来重建projectEntities对象。
    • 我们用更改分析填充connectedEntities > changed
    • 如果存在extendvariablefunctionmixin实体

      • 我们用相关实体填充connectedEntities > coDependent
  4. 递归的gatherCompileResources函数使用connectedEntities对象来填充compileResources对象。
  5. 我们将compileResources > compileParts数组连接成一个单独的 Sass 字符串。
  6. 我们将单个 Sass 字符串编译成 CSS。
  7. 我们使用注释分隔符将输出拆分成一个数组:compileResources > cssParts。此数组通过匹配的数组键与compileResources > compileParts数组对齐。
  8. 我们使用资源路径来更新projectCode对象以包含已编译的 CSS。
  9. 我们将给定文件夹或文件的 CSS 写入文档头中的样式节点,以立即渲染样式。在服务器端,我们将所有 CSS 写入单个样式表。

npm 注意事项

对于 npm 包,有一些额外的注意事项。使用典型的 NodeJS 开发环境

  • 用户会将选择器作为较大文件的一部分进行编辑,而不是单独编辑。
  • Sass 导入很可能会发挥更大的作用。

代码分割

Microthemer 的可视视图分割了单个选择器。这使得将代码解析为sassEntities对象非常快。解析整个文件可能会有不同的情况,尤其是大型文件。

也许存在虚拟分割系统文件的方法?但假设无法避免解析整个文件。或者这对于第一个版本来说是合理的。也许建议最终用户保持 Sass 文件较小以获得最佳效果就足够了。

Sass 导入

在撰写本文时,Microthemer 不会分析导入文件。相反,它会在选择器使用任何 Sass 实体时包含所有 Sass 导入。这是一个临时的第一个版本解决方案,在 Microthemer 的背景下是可以的。但我认为 npm 实现应该跟踪所有项目文件中的 Sass 使用情况。

我们的projectCode对象已经有一个用于存储文件数据的files属性。我建议根据主 Sass 文件计算文件索引。例如,第一个@import规则中使用的文件将具有index: 0,下一个文件将具有index: 1,依此类推。我们需要扫描导入文件@import规则以正确计算这些索引。

我们还需要以不同的方式计算absoluteIndex。与 Microthemer 文件夹不同,系统文件可以包含任意数量的选择器。compileParts数组可能需要是一个对象,为每个文件存储一个部件数组。这样,我们只需要跟踪给定文件中的选择器索引,然后按文件顺序连接compileParts数组。

结论

本文介绍了一种新的方式来选择性地编译 Sass 代码。对于 Microthemer 来说,近乎即时的 Sass 编译是必要的,因为它是一个实时 CSS 编辑器。而“实时”这个词带有对速度的某些期望。但这对于其他环境(如 Node.js)也可能是理想的。这取决于 Node.js 和 Sass 用户来决定,并希望他们在下面的评论中分享他们的想法。如果需求存在,我希望 npm 开发人员能够利用我分享的要点。

请随时在我论坛中发布任何关于此的问题。我总是乐于帮助。