Optimize css()#216
Conversation
51cf81d to
5c6106b
Compare
| // the second character to avoid colliding with Moz-prefixed | ||
| // styles. Let's find its original style's sort order. | ||
| originalStyle = elementNames[i][2].toLowerCase() + elementNames[i].slice(3); | ||
| } |
There was a problem hiding this comment.
Looks like coverage is failing branch coverage for the else case here. Any thoughts on how to fix this?
There was a problem hiding this comment.
I decided to change it from an else if to an else.
aa483df to
187056b
Compare
| /* @flow */ | ||
| /* global Map */ | ||
|
|
||
| export default class OrderedElements { |
There was a problem hiding this comment.
The branch coverage on this file falls to 75%, but I can't see any missed branches in the HTML report. I'm guessing it might not be getting the coverage right with the Babel transform happening here. Thoughts?
There was a problem hiding this comment.
Figured it out. The constructor was never called with arguments, so I removed the args.
187056b to
678f11e
Compare
678f11e to
13a7422
Compare
24ab852 to
2325e6d
Compare
|
Super excited for this! |
|
@xymostech Are there any changes you'd like me to make to this PR before merging? |
|
@lencioni Sorry, I'd like to review this before merging but I've just been terribly busy. I'll review this today or over the weekend. |
|
Hi! My computer broke over the weekend, so I didn't get to reviewing this... It's fixed now, I'll review this today. |
|
@xymostech no worries! I'm sorry to hear about your computer troubles. :( Glad to hear that you are back in action. |
xymostech
left a comment
There was a problem hiding this comment.
Wow! This is an insane amount of optimization, thank you so much for doing all of this!
I'm requesting changes because I think there's a few major impacts that this is going to have, and one critical bug that I noticed (the deep merge thing). I have a bunch of general questions and some nits, but overall I'm super happy with the direction of this!
| const merged /* : OrderedElements */ = styleTypes.reduce( | ||
| recursiveMerge, | ||
| new OrderedElements()); | ||
| const merged = new OrderedElements(); |
There was a problem hiding this comment.
This is no longer doing a recursive merge.
There was a problem hiding this comment.
That's right. When I did this, I thought it would be alright since the tests pass, but I think actually this is a gap in the tests. I've fixed this, and in the process I think I found and fixed an existing bug where nested objects are mutated. My new implementation does not feel the cleanest anymore, so it could probably use a little refactoring.
| // value that was added by prefixAll. Let's try to figure out where it | ||
| // goes. | ||
| let originalStyle; | ||
| if (elementNames[i][0] === 'W') { |
There was a problem hiding this comment.
This seems vaguely fragile. Are we really sure that the only things that are not going to be in the original array here are prefixed things, not like, renamed properties or something?
There was a problem hiding this comment.
Can you expand on what you mean by renamed properties?
If you look at line 284 you'll see if (!originalElements.hasOwnProperty(elementNames[i])) { which means that this should only be running on properties that were not there before the prefixAll() call but were there after it.
There was a problem hiding this comment.
I actually don't remember what I meant by renamed properties.. :P
What I meant in general is that are we sure that the only properties that were not there before the prefixAll() call are ones that start with Webkit, Moz, or ms? Could there be others?
There was a problem hiding this comment.
I just looked through the code of inline-style-prefixer and I was unable to find any others. @rofrischmann can you confirm?
| // calculation above. | ||
| return sortOrder[key]; | ||
| if (originalStyle && originalElements.hasOwnProperty(originalStyle)) { | ||
| const originalIndex = handledDeclarations.keyOrder.indexOf(originalStyle); |
There was a problem hiding this comment.
Did your benchmark include any number of things that get prefixed? I'd imagine this might be slower in that case. On the other hand, are people going to be generating a whole bunch of styles that get prefixed?
There was a problem hiding this comment.
Yep, my benchmark included some things that are prefixed and some things that are not prefixed. This is faster for both because it gets all of the work done in fewer loops and avoids creating new objects in favor of mutating.
| .map(([key, value]) => `${kebabifyStyleName(key)}:${transformValue(key, value)};`) | ||
| .join(""); | ||
| const rules = []; | ||
| for (let i = 0; i < handledDeclarations.keyOrder.length; i ++) { |
| // multiple rules for the same key. Here we flatten to multiple | ||
| // pairs with the same key. | ||
| for (let j = 0; j < value.length; j++) { | ||
| rules.push(transformRule(key, value[j], transformValue)); |
There was a problem hiding this comment.
This is just going to put them in the same order that inline-style-prefixer gave them to us, correct? Is that order in the order that we want it to be?
There was a problem hiding this comment.
I think that's correct. At least in the examples I was looking at, this seemed to be the order we wanted. Although I'm not sure if that is always true or if it will remain stable. It might be worth adding a check here to ensure that the original or unprefixed value is last.
| } | ||
|
|
||
| export const flattenDeep = (list /* : any[] */) /* : any[] */ => | ||
| list.reduce((memo, x) => memo.concat(Array.isArray(x) ? flattenDeep(x) : x), []); |
There was a problem hiding this comment.
oh yeah, I forgot how horribly inefficient this function was. Thanks!
| }, | ||
| "dependencies": { | ||
| "asap": "^2.0.3", | ||
| "can-use-dom": "^0.1.0", |
There was a problem hiding this comment.
how big is this dependency? Can we just replace this with a typeof document !== "undefined" check like we do in other places? (or replace those other checks with this?)
There was a problem hiding this comment.
The dependency is very small. Here's the whole thing:
var canUseDOM = !!(
typeof window !== 'undefined' &&
window.document &&
window.document.createElement
);
module.exports = canUseDOM;I think it is best to prefer dependencies over code duplication, but if you feel strongly about it, I can inline it into this project.
There was a problem hiding this comment.
Nope, using that module seems fine! I'm just cautious about including something if it's trying to do something more complicated.
| // prefixed styles. | ||
| const prefixer = new Prefixer({ | ||
| keepUnprefixed: true, | ||
| //userAgent: navigator.userAgent, |
There was a problem hiding this comment.
nit: this is commented out.
|
|
||
| // We need keepUnprefixed so our sorting code knows how to order the | ||
| // prefixed styles. | ||
| const prefixer = new Prefixer({ |
There was a problem hiding this comment.
Aside from server-side rendering, I know one of the reasons we initially didn't go with this approach is because the code to determine which prefixes were needed (which we're doing a build time in staticPrefixData) took up a lot of space. Can you build aphrodite before and after this change and see how much bigger the resulting dist file is?
There was a problem hiding this comment.
Yeah, that's a fair point. I'll look into this when I have a moment.
There was a problem hiding this comment.
Looks like this takes the build size from 73 KiB to 134 KiB.
There was a problem hiding this comment.
:( That's a pretty big size bump. I'd like to get this landed because of the bug fix in it, but I'm worried about throwing this significant change onto users. Could we maybe pull this change out into a separate PR to discuss?
| const originalUserAgent = global.navigator && global.navigator.userAgent; | ||
| let prefix; | ||
|
|
||
| beforeEach(() => { |
ebf5d3a to
a54fd13
Compare
| key, [val[key]], selectorHandlers, stringHandlers, false); | ||
| }); | ||
|
|
||
| if (val instanceof OrderedElements) { |
There was a problem hiding this comment.
I'm not sure if we can change things so that this fork doesn't need to happen. If we can't it might be worth adding a utility function that takes either a plain object or OrderedElements instance and iterates over the keys/values for convenience.
There was a problem hiding this comment.
Seems fine for now. Maybe add a TODO for later?
There was a problem hiding this comment.
Yes, I will add a TODO.
My main concern is less about this specific piece of code and more about what this means for folks who have added their own string handlers that will also need to account for this.
| for (let i = 0; i < this.keyOrder.length; i++) { | ||
| callback(this.keyOrder[i], this.elements[this.keyOrder[i]]); | ||
| // (value, key) to match Map's API | ||
| callback(this.elements[this.keyOrder[i]], this.keyOrder[i]); |
There was a problem hiding this comment.
Note: I changed the order of arguments here to match how Map works.
| }], '.foo{color:blue !important;}'); | ||
| }); | ||
|
|
||
| it('does not mutate nested objects', () => { |
There was a problem hiding this comment.
Note: this test exposes a bug that is currently in master that this commit fixes.
xymostech
left a comment
There was a problem hiding this comment.
These changes seem fine now! And I'd like to get them out soon so we can get that bug fixed. :) I would like to pull out the prefixer change so that we can think about that separately before doubling the size of Aphrodite. Sound good?
Thank you so much for all the work you've been doing. I'm learning a lot about performance optimization!
| // value that was added by prefixAll. Let's try to figure out where it | ||
| // goes. | ||
| let originalStyle; | ||
| if (elementNames[i][0] === 'W') { |
There was a problem hiding this comment.
I actually don't remember what I meant by renamed properties.. :P
What I meant in general is that are we sure that the only properties that were not there before the prefixAll() call are ones that start with Webkit, Moz, or ms? Could there be others?
| "caniuse-api": "^1.5.3", | ||
| "chai": "^3.3.0", | ||
| "coveralls": "^2.12.0", | ||
| "es6-shim": "^0.35.3", |
There was a problem hiding this comment.
Oh, interesting. I guess if it's available in node 4, it's probably fine for us to remove it.
| flushToStyleTag(); | ||
|
|
||
| assertStylesInclude('@keyframes keyframe_1kmnkfo'); | ||
| assertStylesInclude('@keyframes keyframe_tmjr6'); |
There was a problem hiding this comment.
I don't remember exactly anymore, but I think it was because previously the nested objects were plain objects and part of my change (making things actually recursive) caused them to be OrderedElements objects, so they serialized differently. This is related to the code I commented on here: https://github.com/Khan/aphrodite/pull/216/files#r107288563
| if (!this.elements.hasOwnProperty(key)) { | ||
| this.keyOrder.push(key); | ||
| } | ||
|
|
There was a problem hiding this comment.
It seems non-intuitive that .set() does a deep merge of the elements. Could we maybe pull this deep merge into a separate method, or if this isn't being used anywhere else, rename this?
There was a problem hiding this comment.
Yeah this makes sense to me. I'll try to look into this soon.
| stringHandlers, useImportant); | ||
|
|
||
| injectGeneratedCSSOnce(key, generated); | ||
| if (alreadyInjected[key]) { |
There was a problem hiding this comment.
Nah, that's fine, I was just curious. :)
| }, | ||
| "dependencies": { | ||
| "asap": "^2.0.3", | ||
| "can-use-dom": "^0.1.0", |
There was a problem hiding this comment.
Nope, using that module seems fine! I'm just cautious about including something if it's trying to do something more complicated.
|
|
||
| // We need keepUnprefixed so our sorting code knows how to order the | ||
| // prefixed styles. | ||
| const prefixer = new Prefixer({ |
There was a problem hiding this comment.
:( That's a pretty big size bump. I'd like to get this landed because of the bug fix in it, but I'm worried about throwing this significant change onto users. Could we maybe pull this change out into a separate PR to discuss?
| key, [val[key]], selectorHandlers, stringHandlers, false); | ||
| }); | ||
|
|
||
| if (val instanceof OrderedElements) { |
There was a problem hiding this comment.
Seems fine for now. Maybe add a TODO for later?
lencioni
left a comment
There was a problem hiding this comment.
I think I'm going to split this up into a few separate PRs so we can focus on the specific changes a bit better.
| // value that was added by prefixAll. Let's try to figure out where it | ||
| // goes. | ||
| let originalStyle; | ||
| if (elementNames[i][0] === 'W') { |
There was a problem hiding this comment.
I just looked through the code of inline-style-prefixer and I was unable to find any others. @rofrischmann can you confirm?
| key, [val[key]], selectorHandlers, stringHandlers, false); | ||
| }); | ||
|
|
||
| if (val instanceof OrderedElements) { |
There was a problem hiding this comment.
Yes, I will add a TODO.
My main concern is less about this specific piece of code and more about what this means for folks who have added their own string handlers that will also need to account for this.
| if (!this.elements.hasOwnProperty(key)) { | ||
| this.keyOrder.push(key); | ||
| } | ||
|
|
There was a problem hiding this comment.
Yeah this makes sense to me. I'll try to look into this soon.
|
|
||
| // We need keepUnprefixed so our sorting code knows how to order the | ||
| // prefixed styles. | ||
| const prefixer = new Prefixer({ |
| flushToStyleTag(); | ||
|
|
||
| assertStylesInclude('@keyframes keyframe_1kmnkfo'); | ||
| assertStylesInclude('@keyframes keyframe_tmjr6'); |
There was a problem hiding this comment.
I don't remember exactly anymore, but I think it was because previously the nested objects were plain objects and part of my change (making things actually recursive) caused them to be OrderedElements objects, so they serialized differently. This is related to the code I commented on here: https://github.com/Khan/aphrodite/pull/216/files#r107288563
a54fd13 to
893386a
Compare
|
Once #233 is merged, I'll rebase this again and it will be only 2 commits. |
893386a to
7831e48
Compare
|
Thanks @lencioni 🍻 Any chance someone from Khan can review it ? (@xymostech) This PR fixes #231. |
We only use this in one place, so I decided to rewrite this for the specific use-case to improve performance. While I was at it, I found and fixed a bug that causes nested objects to be mutated. I decided to change the signature of OrderedElements's forEach callback to match that of Map's forEach. In my benchmark this brings css() down from 1470ms to 1350ms.
In my profiling, calling flattenDeep in here takes 1437ms out of 3418ms, and that's with already flattened arrays. By refactoring this code, we can actually avoid a lot of the work done here and make this faster. This change drops css() down from 3418ms to 2045ms.
7831e48 to
0700a57
Compare
lencioni
left a comment
There was a problem hiding this comment.
@xymostech I've rebased this but didn't make any code changes.
| // utility method that can iterate over either a plain object, an | ||
| // instance of OrderedElements, or a Map, and then use that here and | ||
| // elsewhere. | ||
| if (val instanceof OrderedElements) { |
There was a problem hiding this comment.
This is the bit that I am mostly worried about. But, it looks like string handlers can't be added by extensions, so maybe this is safe?
|
this looks great I support this |
|
I have @xymostech's approval for these changes, so I'm going to merge this. But @xymostech or someone else with access will either need to publish a new version or give me access (I'm lencioni on npm). |
|
@lencioni Just gave you access! The version bump should happen with |
|
Great! I should have some time this afternoon to take care of this. |
After the 1.2.0 release, I noticed that most of my optimizations were
completely offset by the ordering bugfixes that were added. After some
profiling, I noticed that generateCSSRuleset was ripe for optimization.
My approach here is to reduce the amount of work that needs to be done
to a bare minimum. I accomplish this by being smarter about when to loop
and how many loops to run. In my benchmark, this PR reduces the runtime
of css() from 2750 ms to 900 ms, which is 3.05x faster.
Some of this performance improvement will only be seen when running on the client,
but on the server this should still result in ~2x faster improvement.
This is probably best reviewed commit-by-commit.
Before:
After:
@xymostech