Repository navigation
Parse declarations with 2.3x fewer allocations (−32% per email through premailer) #194
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a5aff14
03c70d2
3f55f1b
6335c03
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -19,6 +19,9 @@ class RuleSet | |||||
| NUMBER_OF_DIMENSIONS = 4 | ||||||
|
|
||||||
| NORMALIZED_PROPERTY_RX = /\A[-a-z0-9_]+\z/.freeze | ||||||
| BLANK_VALUE_RX = /\A[\s\0]*\z/.freeze | ||||||
| SURROUNDING_WHITESPACE_RX = /\A[\s\0]|[\s\0]\z/.freeze | ||||||
| TRAILING_SEMICOLON_RX = /\s*;\s*\Z/.freeze | ||||||
|
|
||||||
| DIMENSIONS = [ | ||||||
| ['margin', %w[margin-top margin-right margin-bottom margin-left]], | ||||||
|
|
@@ -37,6 +40,8 @@ class RuleSet | |||||
| RPAREN = ')'.freeze | ||||||
| IMPORTANT = '!important'.freeze | ||||||
| class Declarations | ||||||
| EMPTY_DECLARATIONS = {}.freeze | ||||||
|
|
||||||
| class Value | ||||||
| attr_reader :value | ||||||
| attr_accessor :important | ||||||
|
|
@@ -47,12 +52,17 @@ def initialize(value, important: nil) | |||||
| end | ||||||
|
|
||||||
| def value=(value) | ||||||
| value = value.to_s.sub(/\s*;\s*\Z/, '') | ||||||
| self.important = !value.slice!(CssParser::IMPORTANT_IN_PROPERTY_RX).nil? | ||||||
| value.strip! | ||||||
| # sub and strip always return a copy, and the caller's string must not be mutated: | ||||||
| # only copy when there is something to remove, so a clean value allocates nothing | ||||||
| value = value.to_s | ||||||
| value = value.sub(TRAILING_SEMICOLON_RX, '') if value.match?(TRAILING_SEMICOLON_RX) | ||||||
| self.important = value.match?(CssParser::IMPORTANT_IN_PROPERTY_RX) | ||||||
| value = value.sub(CssParser::IMPORTANT_IN_PROPERTY_RX, '') if important | ||||||
| value = value.strip if value.match?(SURROUNDING_WHITESPACE_RX) | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. is this not the same ?
Suggested change
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sadly not quite: when nothing was trimmed before, |
||||||
| raise ArgumentError, 'value is empty' if value.empty? | ||||||
|
|
||||||
| @value = value.freeze | ||||||
| # value may still be the caller's string: -value freezes a copy instead of it | ||||||
| @value = -value | ||||||
| end | ||||||
|
|
||||||
| def to_s | ||||||
|
|
@@ -70,7 +80,7 @@ def ==(other) | |||||
|
|
||||||
| def_delegators :declarations, :each, :each_value | ||||||
|
|
||||||
| def initialize(declarations = {}) | ||||||
| def initialize(declarations = EMPTY_DECLARATIONS) | ||||||
| self.declarations = {} | ||||||
| declarations.each { |property, value| add_declaration!(property, value) } | ||||||
| end | ||||||
|
|
@@ -100,7 +110,7 @@ def []=(property, value) | |||||
|
|
||||||
| if value.is_a?(Value) && (!currently_important || value.important) | ||||||
| declarations[property] = value | ||||||
| elsif value.to_s.strip.empty? | ||||||
| elsif value.to_s.match?(BLANK_VALUE_RX) | ||||||
| delete property | ||||||
| else | ||||||
| value = Value.new(value) | ||||||
|
|
@@ -164,18 +174,19 @@ def replace_declaration!(property, replacements, preserve_importance: false) | |||||
|
|
||||||
| # We should preserve subsequent declarations of the same properties | ||||||
| # and prior important ones if replacement one is not important | ||||||
| replacements = replacement_declarations.each.with_object({}) do |(key, replacement), result| | ||||||
| replacements = {} | ||||||
| replacement_declarations.declarations.each do |key, replacement| | ||||||
| existing = declarations[key] | ||||||
|
|
||||||
| # No existing -> set | ||||||
| unless existing | ||||||
| result[key] = replacement | ||||||
| replacements[key] = replacement | ||||||
| next | ||||||
| end | ||||||
|
|
||||||
| # Replacement more important than existing -> replace | ||||||
| if replacement.important && !existing.important | ||||||
| result[key] = replacement | ||||||
| replacements[key] = replacement | ||||||
| replaced_index = replacement_keys.index(key) | ||||||
| replacement_keys.delete_at(replaced_index) | ||||||
| replacement_values.delete_at(replaced_index) | ||||||
|
|
@@ -188,7 +199,7 @@ def replace_declaration!(property, replacements, preserve_importance: false) | |||||
|
|
||||||
| # Existing and replacement importance are the same, | ||||||
| # value which is declared later wins | ||||||
| result[key] = replacement if property_index > replacement_keys.index(key) | ||||||
| replacements[key] = replacement if property_index > replacement_keys.index(key) | ||||||
| end | ||||||
|
|
||||||
| return if replacements.empty? | ||||||
|
|
@@ -199,13 +210,15 @@ def replace_declaration!(property, replacements, preserve_importance: false) | |||||
| replacement_values.delete_at(property_index) | ||||||
| replacement_values.insert(property_index, *replacements.values) | ||||||
|
|
||||||
| self.declarations = replacement_keys.zip(replacement_values).to_h | ||||||
| self.declarations = {} | ||||||
| replacement_keys.each_with_index { |key, index| declarations[key] = replacement_values[index] } | ||||||
| end | ||||||
|
|
||||||
| def to_s(options = {}) | ||||||
| str = declarations.reduce(+'') do |memo, (prop, value)| | ||||||
| str = +'' | ||||||
| declarations.each do |prop, value| | ||||||
| importance = options[:force_important] || value.important ? ' !important' : '' | ||||||
| memo << "#{prop}: #{value.value}#{importance}; " | ||||||
| str << prop << ': ' << value.value << importance << '; ' | ||||||
| end | ||||||
| str.gsub!(/^[\s^({)]+|[\n\r\f\t]+/, '') | ||||||
| str.strip! | ||||||
|
|
@@ -667,12 +680,13 @@ def parse_declarations!(block) # :nodoc: | |||||
| next unless (colon = decs.index(COLON)) | ||||||
|
|
||||||
| property = decs[0, colon] | ||||||
| value = decs[(colon + 1)..] | ||||||
| value = decs[colon + 1, decs.length] | ||||||
| property.strip! | ||||||
| value.strip! | ||||||
| next if property.empty? || value.empty? || value.casecmp?(IMPORTANT) | ||||||
| # casecmp? case-folds both strings, allocating two of them per declaration | ||||||
| next if property.empty? || value.empty? || value.casecmp(IMPORTANT).zero? | ||||||
|
|
||||||
| add_declaration!(property, value) | ||||||
| add_declaration!(property, value.freeze) | ||||||
| continuation = nil | ||||||
| end | ||||||
| end | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| require_relative '../test_helper' | ||
|
|
||
| class RuleSetDeclarationsAllocationsTest < Minitest::Test | ||
| include CssParser | ||
| include AllocationCounting | ||
|
|
||
| PADDINGS = Array.new(20) { |i| "padding-#{%w[top right bottom left][i % 4]}: #{i}px" }.join('; ') | ||
| BLOCK = "#{PADDINGS}; color: red !important".freeze | ||
|
|
||
| def test_parsing_declarations_allocates_less_than_six_objects_per_declaration | ||
| assert_operator allocations { RuleSet.new(block: BLOCK) }, :<, 6 * 21 | ||
| end | ||
|
|
||
| def test_serializing_declarations_does_not_allocate_per_declaration | ||
| rule_set = RuleSet.new(block: BLOCK) | ||
|
|
||
| assert_operator allocations { rule_set.declarations_to_s }, :<, 10 | ||
| end | ||
|
|
||
| def test_frozen_values_are_kept_without_copy | ||
| value = '1px' | ||
|
|
||
| assert_same value, RuleSet::Declarations::Value.new(value).value | ||
| end | ||
|
|
||
| def test_unfrozen_values_are_neither_frozen_nor_mutated | ||
| clean = +'1px' | ||
| padded = +' 1px !important; ' | ||
|
|
||
| RuleSet::Declarations::Value.new(clean) | ||
| RuleSet::Declarations::Value.new(padded) | ||
|
|
||
| assert_equal [['1px', false], [' 1px !important; ', false]], [[clean, clean.frozen?], [padded, padded.frozen?]] | ||
| end | ||
| end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
not a fan of this double-checking and hand-optimizing 😢
if it really makes a difference leave some inline comments splitting the logic up explaining why it needs to be weird to be more performance
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fair point, thank you! I dropped the
include?pre-checks: they madevalue=alone 15–19% faster but didn't show on a whole parse. I kept only thematch?guards, which keep clean values at zero allocations (4.4 vs 6.4 objects per declaration), and explained them in a comment.