The Internals of Styled Components
There are quite a few solutions that are categorized as CSS-in-JS, Styled Components is one of them. In this episode, we’ll dive into the details of Styled Components to see how it works under the hood and hopefully we can be more sure to choose from various technologies.
- 1. Motivation of Styled Components
- 2. How does Styled Components work internally?
- 2.1 Let’s try to implement something similar
- 2.2
styled.tagName
are wrapped template functions. - 2.3
createStyledComponent()
creates the actual component - 2.4 The generation of component id.
- 2.5 CSS injection in ComponentStyle
- 2.6 Server-Side rendering is supported by assigning unique component id during compiling.
- 3. Summary
1. Motivation of Styled Components
Let’s take a look at its claims.
styled-components is the result of wondering how we could enhance CSS for styling React component systems
CSS itself has its flaws, especially at scaling, these issues become more visible in the era of component-based JavaScript application. There are many solutions that we’ll cover in turn, here let’s take a look at how Styled Components sells itself.
Automatic critical CSS: styled-components keeps track of which components are rendered on a page and injects their styles and nothing else, fully automatically. Combined with code splitting, this means your users load the least amount of code necessary.
🤔 This is not hard to implement if we dynamically inject the styles. But it has its cost, React mentions that injecting styles are not good. Also for scaling, solutions like Atomic CSS works pretty good so it is actually not a big issue to load a bunch of CSS before hand.
No class name bugs: styled-components generates unique class names for your styles. You never have to worry about duplication, overlap or misspellings.
🤔 Spending time in choosing CSS class name is indeed a waste of time. Styled Component do feel more straightforward than solutions like CSS modules. But from my experience, I still need to waste time in choosing a component name for Styled Component.
Easier deletion of CSS: it can be hard to know whether a class name is used somewhere in your codebase. styled-components makes it obvious, as every bit of styling is tied to a specific component. If the component is unused (which tooling can detect) and gets deleted, all its styles get deleted with it.
🙂 Love it. Co-location is great in styling.
Simple dynamic styling: adapting the styling of a component based on its props or a global theme is simple and intuitive without having to manually manage dozens of classes.
🙂 Love it. It feels straightforward.
Painless maintenance: you never have to hunt across different files to find the styling affecting your component, so maintenance is a piece of cake no matter how big your codebase is.
🙂 Again, it is about co-location.
Automatic vendor prefixing: write your CSS to the current standard and let styled-components handle the rest.
🤔 Well, we can achieve this simply by postCSS.
So co-location and dynamic styling would be the top 2 reasons that I’d choose Styled Components over CSS Module. Now it’s time for us to dive into its internals.
2. How does Styled Components work internally?
Let’s follow the demo that renders 2 buttons.
Inspect above demo we can see that it generates some CSS class names as below.
html
<div><button class="sc-cyRfQX OQOsV">Normal</button><button class="sc-cyRfQX hhpfzZ">Primary</button></div>
html
<div><button class="sc-cyRfQX OQOsV">Normal</button><button class="sc-cyRfQX hhpfzZ">Primary</button></div>
The first class is "sc-cyRFQX"
which is the id for the component, so both buttons
have it. The second class varies which defines the different CSS. Here is the guess
of how it works.
styled.button
injects the CSS code into <head>
and
returns a button
element with that class name.
2.1 Let’s try to implement something similar
It is actually pretty simple to implement a basic component doing so. Below is a working demo.
Let’s explain it a little bit.
import { useInsertionEffect } from "react";function getClassName(css) {return "s" + Math.floor(Math.random() * 10 ** 8).toString(16);------------------------------------------------------------It is better to hash css code but here we just
return random name for demo purpose
}function injectCSS(css) {const style = document.createElement("style");style.type = "text/css";style.textContent = css;document.head.append(style);---------------------------We don't do cache here
}function styledButton(strings, ...expressions) {------------------------This is basics for tagged templates
return (props) => {let css = "";let i = 0;while (i < strings.length) {css += strings[i];if (i < expressions.length) {css += expressions[i](props);}i += 1;}const cssClassName = getClassName(css);useInsertionEffect(() => {injectCSS(`.${cssClassName} {${css}}`);}, [css]);const { className, ...rest } = props;const mergedClassNames = [cssClassName, className].filter(Boolean).join(",");Simple core logic!
return <button className={mergedClassNames} {...rest} />;};}const Button = styledButton`background: ${(props) => (props.$primary ? "#BF4F74" : "white")};color: ${(props) => (props.$primary ? "white" : "#BF4F74")};font-size: 1em;margin: 1em;padding: 0.25em 1em;border: 2px solid #bf4f74;border-radius: 3px;`;export default function App() {return (<div><Button>Normal</Button><Button $primary>Primary</Button></div>);}
import { useInsertionEffect } from "react";function getClassName(css) {return "s" + Math.floor(Math.random() * 10 ** 8).toString(16);------------------------------------------------------------It is better to hash css code but here we just
return random name for demo purpose
}function injectCSS(css) {const style = document.createElement("style");style.type = "text/css";style.textContent = css;document.head.append(style);---------------------------We don't do cache here
}function styledButton(strings, ...expressions) {------------------------This is basics for tagged templates
return (props) => {let css = "";let i = 0;while (i < strings.length) {css += strings[i];if (i < expressions.length) {css += expressions[i](props);}i += 1;}const cssClassName = getClassName(css);useInsertionEffect(() => {injectCSS(`.${cssClassName} {${css}}`);}, [css]);const { className, ...rest } = props;const mergedClassNames = [cssClassName, className].filter(Boolean).join(",");Simple core logic!
return <button className={mergedClassNames} {...rest} />;};}const Button = styledButton`background: ${(props) => (props.$primary ? "#BF4F74" : "white")};color: ${(props) => (props.$primary ? "white" : "#BF4F74")};font-size: 1em;margin: 1em;padding: 0.25em 1em;border: 2px solid #bf4f74;border-radius: 3px;`;export default function App() {return (<div><Button>Normal</Button><Button $primary>Primary</Button></div>);}
It looks pretty simple, but obviously we don’t touch autoprefixer, SSR and so many other things. Yet with this code in mind, we can start looking at the source code of styled components
2.2 styled.tagName
are wrapped template functions.
Let’s start with how we write style components - styled.{tagName}
.
const baseStyled = <Target extends WebTarget>(tag: Target) =>constructWithOptions<'web', Target>(createStyledComponent, tag);---------------------const styled = baseStyled as typeof baseStyled & {[E in SupportedHTMLElements]: Styled<'web', E, JSX.IntrinsicElements[E]>;};// Shorthands for all valid HTML ElementsdomElements.forEach(domElement => {styled[domElement] = baseStyled<typeof domElement>(domElement);});So each tag is wrapped like this, why don't we use Proxy?
export default styled;
const baseStyled = <Target extends WebTarget>(tag: Target) =>constructWithOptions<'web', Target>(createStyledComponent, tag);---------------------const styled = baseStyled as typeof baseStyled & {[E in SupportedHTMLElements]: Styled<'web', E, JSX.IntrinsicElements[E]>;};// Shorthands for all valid HTML ElementsdomElements.forEach(domElement => {styled[domElement] = baseStyled<typeof domElement>(domElement);});So each tag is wrapped like this, why don't we use Proxy?
export default styled;
export default function constructWithOptions<R extends Runtime,Target extends StyledTarget<R>,OuterProps extends object = Target extends KnownTarget? React.ComponentPropsWithRef<Target>: BaseObject,OuterStatics extends object = BaseObject,>(componentConstructor: IStyledComponentFactory<R, StyledTarget<R>, object, any>,tag: StyledTarget<R>,options: StyledOptions<R, OuterProps> = EMPTY_OBJECT): Styled<R, Target, OuterProps, OuterStatics> {/* This is callable directly as a template function */const templateFunction = <Props extends object = BaseObject, Statics extends object = BaseObject>(initialStyles: Styles<Substitute<OuterProps, Props>>,...interpolations: Interpolation<Substitute<OuterProps, Props>>[]) =>componentConstructor<Substitute<OuterProps, Props>, Statics>(-------------------createStyledComponent() in our case
tag,options as StyledOptions<R, Substitute<OuterProps, Props>>,css<Substitute<OuterProps, Props>>(initialStyles, ...interpolations)--------------------------------------------------------------------This is important, css() handles the static & dynamic rules
);This is similar to the template function we created
templateFunction.withConfig = (config: StyledOptions<R, OuterProps>) =>-----------This extra modifier is critical in SSR support, explained later.
constructWithOptions<R, Target, OuterProps, OuterStatics>(componentConstructor, tag, {...options,...config,});return templateFunction;}
export default function constructWithOptions<R extends Runtime,Target extends StyledTarget<R>,OuterProps extends object = Target extends KnownTarget? React.ComponentPropsWithRef<Target>: BaseObject,OuterStatics extends object = BaseObject,>(componentConstructor: IStyledComponentFactory<R, StyledTarget<R>, object, any>,tag: StyledTarget<R>,options: StyledOptions<R, OuterProps> = EMPTY_OBJECT): Styled<R, Target, OuterProps, OuterStatics> {/* This is callable directly as a template function */const templateFunction = <Props extends object = BaseObject, Statics extends object = BaseObject>(initialStyles: Styles<Substitute<OuterProps, Props>>,...interpolations: Interpolation<Substitute<OuterProps, Props>>[]) =>componentConstructor<Substitute<OuterProps, Props>, Statics>(-------------------createStyledComponent() in our case
tag,options as StyledOptions<R, Substitute<OuterProps, Props>>,css<Substitute<OuterProps, Props>>(initialStyles, ...interpolations)--------------------------------------------------------------------This is important, css() handles the static & dynamic rules
);This is similar to the template function we created
templateFunction.withConfig = (config: StyledOptions<R, OuterProps>) =>-----------This extra modifier is critical in SSR support, explained later.
constructWithOptions<R, Target, OuterProps, OuterStatics>(componentConstructor, tag, {...options,...config,});return templateFunction;}
2.3 createStyledComponent()
creates the actual component
function createStyledComponent<Target extends WebTarget,OuterProps extends object,Statics extends object = BaseObject,>(target: Target,options: StyledOptions<'web', OuterProps>,rules: RuleSet<OuterProps>-----This is from css(), keep in mind it might have functions as the dynamic rules.
): ReturnType<IStyledComponentFactory<'web', Target, OuterProps, Statics>> {...const {attrs = EMPTY_ARRAY,componentId = generateId(options.displayName, options.parentComponentId),------------------------------------------------------------------------Here it generates the component id we see from html like
"sc-cyRfQX"
which is stable.Notice by default, the option is empty, also it depends on displayName as well
displayName = generateDisplayName(target),} = options;const styledComponentId =options.displayName && options.componentId? `${escape(options.displayName)}-${options.componentId}`: options.componentId || componentId;...const componentStyle = new ComponentStyle(rules,-----styledComponentId,-----------------component id is important
isTargetStyledComp ? (styledComponentTarget.componentStyle as ComponentStyle) : undefined);ComponentStyle handles everything related to CSS
function forwardRefRender(props: ExecutionProps & OuterProps, ref: Ref<Element>) {return useStyledComponentImpl<OuterProps>(WrappedStyledComponent, props, ref);-----------------------}/*** forwardRef creates a new interim component, which we'll take advantage of* instead of extending ParentComponent to create _another_ interim class*/let WrappedStyledComponent = React.forwardRef(forwardRefRender) as unknown as IStyledComponent<----------------ok, we forgot this in our implementation
'web',any> &Statics;WrappedStyledComponent.attrs = finalAttrs;WrappedStyledComponent.componentStyle = componentStyle;WrappedStyledComponent.shouldForwardProp = shouldForwardProp;// this static is used to preserve the cascade of static classes for component selector// purposes; this is especially important with usage of the css propWrappedStyledComponent.foldedComponentIds = isTargetStyledComp? joinStrings(styledComponentTarget.foldedComponentIds, styledComponentTarget.styledComponentId): '';WrappedStyledComponent.styledComponentId = styledComponentId;// fold the underlying StyledComponent target up since we folded the stylesWrappedStyledComponent.target = isTargetStyledComp ? styledComponentTarget.target : target;...return WrappedStyledComponent;}
function createStyledComponent<Target extends WebTarget,OuterProps extends object,Statics extends object = BaseObject,>(target: Target,options: StyledOptions<'web', OuterProps>,rules: RuleSet<OuterProps>-----This is from css(), keep in mind it might have functions as the dynamic rules.
): ReturnType<IStyledComponentFactory<'web', Target, OuterProps, Statics>> {...const {attrs = EMPTY_ARRAY,componentId = generateId(options.displayName, options.parentComponentId),------------------------------------------------------------------------Here it generates the component id we see from html like
"sc-cyRfQX"
which is stable.Notice by default, the option is empty, also it depends on displayName as well
displayName = generateDisplayName(target),} = options;const styledComponentId =options.displayName && options.componentId? `${escape(options.displayName)}-${options.componentId}`: options.componentId || componentId;...const componentStyle = new ComponentStyle(rules,-----styledComponentId,-----------------component id is important
isTargetStyledComp ? (styledComponentTarget.componentStyle as ComponentStyle) : undefined);ComponentStyle handles everything related to CSS
function forwardRefRender(props: ExecutionProps & OuterProps, ref: Ref<Element>) {return useStyledComponentImpl<OuterProps>(WrappedStyledComponent, props, ref);-----------------------}/*** forwardRef creates a new interim component, which we'll take advantage of* instead of extending ParentComponent to create _another_ interim class*/let WrappedStyledComponent = React.forwardRef(forwardRefRender) as unknown as IStyledComponent<----------------ok, we forgot this in our implementation
'web',any> &Statics;WrappedStyledComponent.attrs = finalAttrs;WrappedStyledComponent.componentStyle = componentStyle;WrappedStyledComponent.shouldForwardProp = shouldForwardProp;// this static is used to preserve the cascade of static classes for component selector// purposes; this is especially important with usage of the css propWrappedStyledComponent.foldedComponentIds = isTargetStyledComp? joinStrings(styledComponentTarget.foldedComponentIds, styledComponentTarget.styledComponentId): '';WrappedStyledComponent.styledComponentId = styledComponentId;// fold the underlying StyledComponent target up since we folded the stylesWrappedStyledComponent.target = isTargetStyledComp ? styledComponentTarget.target : target;...return WrappedStyledComponent;}
The code here is a bit confusing. WrappedStyledComponent
is forwardRef()
of forwardRefRender()
,
and forwardRefRender()
internally uses WrappedStyledComponent()
too.
If we look at the internals of useStyledComponentImpl()
we can see that WrappedStyledComponent()
here is only used as a holder for the properties, it is not recursively used in rendering.
useStyledComponentImpl()
actually does the rendering.
function useStyledComponentImpl<Props extends object>(forwardedComponent: IStyledComponent<'web', Props>,props: ExecutionProps & Props,forwardedRef: Ref<Element>) {const {attrs: componentAttrs,componentStyle,defaultProps,foldedComponentIds,styledComponentId,target,} = forwardedComponent;const contextTheme = React.useContext(ThemeContext);const ssc = useStyleSheetContext();const shouldForwardProp = forwardedComponent.shouldForwardProp || ssc.shouldForwardProp;// NOTE: the non-hooks version only subscribes to this when !componentStyle.isStatic,// but that'd be against the rules-of-hooks. We could be naughty and do it anyway as it// should be an immutable value, but behave for now.const theme = determineTheme(props, contextTheme, defaultProps) || EMPTY_OBJECT;const context = resolveContext<Props>(componentAttrs, props, theme);const elementToBeCreated: WebTarget = context.as || target;const propsForElement: Dict<any> = {};for (const key in context) {if (context[key] === undefined) {// Omit undefined values from props passed to wrapped element.// This enables using .attrs() to remove props, for example.} else if (key[0] === '$' || key === 'as' || key === 'theme') {// Omit transient props and execution props.} else if (key === 'forwardedAs') {propsForElement.as = context.forwardedAs;} else if (!shouldForwardProp || shouldForwardProp(key, elementToBeCreated)) {propsForElement[key] = context[key];...}}const generatedClassName = useInjectedStyle(componentStyle, context);-----------------------------------------Alright, we found the injection, notice it has also
context
as argumentlet classString = joinStrings(foldedComponentIds, styledComponentId);if (generatedClassName) {classString += ' ' + generatedClassName;---------------------------------------}if (context.className) {classString += ' ' + context.className;---------------------------------------}propsForElement[// handle custom elements which React doesn't properly aliasisTag(elementToBeCreated) &&!domElements.has(elementToBeCreated as Extract<typeof domElements, string>)? 'class': 'className'] = classString;propsForElement.ref = forwardedRef;return createElement(elementToBeCreated, propsForElement);--------------------------------------------------}
function useStyledComponentImpl<Props extends object>(forwardedComponent: IStyledComponent<'web', Props>,props: ExecutionProps & Props,forwardedRef: Ref<Element>) {const {attrs: componentAttrs,componentStyle,defaultProps,foldedComponentIds,styledComponentId,target,} = forwardedComponent;const contextTheme = React.useContext(ThemeContext);const ssc = useStyleSheetContext();const shouldForwardProp = forwardedComponent.shouldForwardProp || ssc.shouldForwardProp;// NOTE: the non-hooks version only subscribes to this when !componentStyle.isStatic,// but that'd be against the rules-of-hooks. We could be naughty and do it anyway as it// should be an immutable value, but behave for now.const theme = determineTheme(props, contextTheme, defaultProps) || EMPTY_OBJECT;const context = resolveContext<Props>(componentAttrs, props, theme);const elementToBeCreated: WebTarget = context.as || target;const propsForElement: Dict<any> = {};for (const key in context) {if (context[key] === undefined) {// Omit undefined values from props passed to wrapped element.// This enables using .attrs() to remove props, for example.} else if (key[0] === '$' || key === 'as' || key === 'theme') {// Omit transient props and execution props.} else if (key === 'forwardedAs') {propsForElement.as = context.forwardedAs;} else if (!shouldForwardProp || shouldForwardProp(key, elementToBeCreated)) {propsForElement[key] = context[key];...}}const generatedClassName = useInjectedStyle(componentStyle, context);-----------------------------------------Alright, we found the injection, notice it has also
context
as argumentlet classString = joinStrings(foldedComponentIds, styledComponentId);if (generatedClassName) {classString += ' ' + generatedClassName;---------------------------------------}if (context.className) {classString += ' ' + context.className;---------------------------------------}propsForElement[// handle custom elements which React doesn't properly aliasisTag(elementToBeCreated) &&!domElements.has(elementToBeCreated as Extract<typeof domElements, string>)? 'class': 'className'] = classString;propsForElement.ref = forwardedRef;return createElement(elementToBeCreated, propsForElement);--------------------------------------------------}
The code is not difficult. It injects the CSS, get the class name and pass the rest props to the intrinsic HTML element.
2.4 The generation of component id.
From above section, we see the importance of component id, it is an unique identifier to
mark each styled component, since by default style.{tagName}
is just an anonymous function.
js
componentId = generateId(options.displayName, options.parentComponentId)
js
componentId = generateId(options.displayName, options.parentComponentId)
By default options
is empty, generateId()
needs to make sure there is no duplication.
const identifiers: { [key: string]: number } = {};-----------Alright this is a simple global counter to handle id duplication
/* We depend on components having unique IDs */function generateId(displayName?: string | undefined,parentComponentId?: string | undefined): string {const name = typeof displayName !== 'string' ? 'sc' : escape(displayName);--------------------------------------This is why we see prefix of
sc
in the demo, since there is no displayName by default// Ensure that no displayName can lead to duplicate componentIdsidentifiers[name] = (identifiers[name] || 0) + 1;-------------------------------------------------So in theory, each component has id of
sc0
sc1
, .etcconst componentId = `${name}-${generateComponentId(// SC_VERSION gives us isolation between multiple runtimes on the page at once// this is improved further with use of the babel plugin "namespace" featureSC_VERSION + name + identifiers[name])}`;return parentComponentId ? `${parentComponentId}-${componentId}` : componentId;}
const identifiers: { [key: string]: number } = {};-----------Alright this is a simple global counter to handle id duplication
/* We depend on components having unique IDs */function generateId(displayName?: string | undefined,parentComponentId?: string | undefined): string {const name = typeof displayName !== 'string' ? 'sc' : escape(displayName);--------------------------------------This is why we see prefix of
sc
in the demo, since there is no displayName by default// Ensure that no displayName can lead to duplicate componentIdsidentifiers[name] = (identifiers[name] || 0) + 1;-------------------------------------------------So in theory, each component has id of
sc0
sc1
, .etcconst componentId = `${name}-${generateComponentId(// SC_VERSION gives us isolation between multiple runtimes on the page at once// this is improved further with use of the babel plugin "namespace" featureSC_VERSION + name + identifiers[name])}`;return parentComponentId ? `${parentComponentId}-${componentId}` : componentId;}
import generateAlphabeticName from './generateAlphabeticName';import { hash } from './hash';export default function generateComponentId(str: string) {-------------------Just need to know that it hashes a string and returns the string from the hash
return generateAlphabeticName(hash(str) >>> 0);}
import generateAlphabeticName from './generateAlphabeticName';import { hash } from './hash';export default function generateComponentId(str: string) {-------------------Just need to know that it hashes a string and returns the string from the hash
return generateAlphabeticName(hash(str) >>> 0);}
From the generation of component id, we can see too things
- it is unique by global counter
- it is not SSR safe because of global counter, we’ve talked about why counter is not SSR safe in How does useId() work internally in React?
2.5 CSS injection in ComponentStyle
function useInjectedStyle<T extends ExecutionContext>(componentStyle: ComponentStyle,resolvedAttrs: T) {const ssc = useStyleSheetContext();const className = componentStyle.generateAndInjectStyles(resolvedAttrs,ssc.styleSheet,--------------Remember this context, it holds all info about CSS
ssc.stylis);return className;}
function useInjectedStyle<T extends ExecutionContext>(componentStyle: ComponentStyle,resolvedAttrs: T) {const ssc = useStyleSheetContext();const className = componentStyle.generateAndInjectStyles(resolvedAttrs,ssc.styleSheet,--------------Remember this context, it holds all info about CSS
ssc.stylis);return className;}
export default class ComponentStyle {baseHash: number;baseStyle: ComponentStyle | null | undefined;componentId: string;isStatic: boolean;rules: RuleSet<any>;staticRulesId: string;constructor(rules: RuleSet<any>, componentId: string, baseStyle?: ComponentStyle | undefined) {this.rules = rules;this.staticRulesId = '';this.isStatic =process.env.NODE_ENV === 'production' &&(baseStyle === undefined || baseStyle.isStatic) &&isStaticRules(rules);-------------This checks if rules are static and could be cached
this.componentId = componentId;this.baseHash = phash(SEED, componentId);----------------------------------------baseHash is from componentId, it is stable based on the componentId
this.baseStyle = baseStyle;// NOTE: This registers the componentId, which ensures a consistent order// for this component's styles compared to othersStyleSheet.registerId(componentId);}generateAndInjectStyles(executionContext: ExecutionContext,styleSheet: StyleSheet,stylis: Stringifier): string {let names = this.baseStyle? this.baseStyle.generateAndInjectStyles(executionContext, styleSheet, stylis): '';// force dynamic classnames if user-supplied stylis plugins are in useif (this.isStatic && !stylis.hash) {if (this.staticRulesId && styleSheet.hasNameForId(this.componentId, this.staticRulesId)) {names = joinStrings(names, this.staticRulesId);} else {So the StyleSheetContext holds the stable ids for static styles
Which is reasonable, if only static rules are used in styled component
We don't need to create new rules and class names every time.
const cssStatic = joinStringArray(flatten(this.rules, executionContext, styleSheet, stylis) as string[]);const name = generateName(phash(this.baseHash, cssStatic) >>> 0);if (!styleSheet.hasNameForId(this.componentId, name)) {const cssStaticFormatted = stylis(cssStatic, `.${name}`, undefined, this.componentId);styleSheet.insertRules(this.componentId, name, cssStaticFormatted);}names = joinStrings(names, name);this.staticRulesId = name;}} else {let dynamicHash = phash(this.baseHash, stylis.hash);let css = '';for (let i = 0; i < this.rules.length; i++) {const partRule = this.rules[i];if (typeof partRule === 'string') {css += partRule;if (process.env.NODE_ENV !== 'production') dynamicHash = phash(dynamicHash, partRule);} else if (partRule) {const partString = joinStringArray(flatten(partRule, executionContext, styleSheet, stylis) as string[]The
flatten()
here is NOT Array.prototype.flat().);// The same value can switch positions in the array, so we include "i" in the hash.dynamicHash = phash(dynamicHash, partString + i);css += partString;}}if (css) {const name = generateName(dynamicHash >>> 0);if (!styleSheet.hasNameForId(this.componentId, name)) {-------------------------------------------------This makes sure no duplication in the injection
styleSheet.insertRules(this.componentId,name,stylis(css, `.${name}`, undefined, this.componentId));}names = joinStrings(names, name);}}return names;}}
export default class ComponentStyle {baseHash: number;baseStyle: ComponentStyle | null | undefined;componentId: string;isStatic: boolean;rules: RuleSet<any>;staticRulesId: string;constructor(rules: RuleSet<any>, componentId: string, baseStyle?: ComponentStyle | undefined) {this.rules = rules;this.staticRulesId = '';this.isStatic =process.env.NODE_ENV === 'production' &&(baseStyle === undefined || baseStyle.isStatic) &&isStaticRules(rules);-------------This checks if rules are static and could be cached
this.componentId = componentId;this.baseHash = phash(SEED, componentId);----------------------------------------baseHash is from componentId, it is stable based on the componentId
this.baseStyle = baseStyle;// NOTE: This registers the componentId, which ensures a consistent order// for this component's styles compared to othersStyleSheet.registerId(componentId);}generateAndInjectStyles(executionContext: ExecutionContext,styleSheet: StyleSheet,stylis: Stringifier): string {let names = this.baseStyle? this.baseStyle.generateAndInjectStyles(executionContext, styleSheet, stylis): '';// force dynamic classnames if user-supplied stylis plugins are in useif (this.isStatic && !stylis.hash) {if (this.staticRulesId && styleSheet.hasNameForId(this.componentId, this.staticRulesId)) {names = joinStrings(names, this.staticRulesId);} else {So the StyleSheetContext holds the stable ids for static styles
Which is reasonable, if only static rules are used in styled component
We don't need to create new rules and class names every time.
const cssStatic = joinStringArray(flatten(this.rules, executionContext, styleSheet, stylis) as string[]);const name = generateName(phash(this.baseHash, cssStatic) >>> 0);if (!styleSheet.hasNameForId(this.componentId, name)) {const cssStaticFormatted = stylis(cssStatic, `.${name}`, undefined, this.componentId);styleSheet.insertRules(this.componentId, name, cssStaticFormatted);}names = joinStrings(names, name);this.staticRulesId = name;}} else {let dynamicHash = phash(this.baseHash, stylis.hash);let css = '';for (let i = 0; i < this.rules.length; i++) {const partRule = this.rules[i];if (typeof partRule === 'string') {css += partRule;if (process.env.NODE_ENV !== 'production') dynamicHash = phash(dynamicHash, partRule);} else if (partRule) {const partString = joinStringArray(flatten(partRule, executionContext, styleSheet, stylis) as string[]The
flatten()
here is NOT Array.prototype.flat().);// The same value can switch positions in the array, so we include "i" in the hash.dynamicHash = phash(dynamicHash, partString + i);css += partString;}}if (css) {const name = generateName(dynamicHash >>> 0);if (!styleSheet.hasNameForId(this.componentId, name)) {-------------------------------------------------This makes sure no duplication in the injection
styleSheet.insertRules(this.componentId,name,stylis(css, `.${name}`, undefined, this.componentId));}names = joinStrings(names, name);}}return names;}}
flatten()
basically does the evaluation for dynamic parts.
export default function flatten<Props extends object>(chunk: Interpolation<object>,executionContext?: (ExecutionContext & Props) | undefined,styleSheet?: StyleSheet | undefined,stylisInstance?: Stringifier | undefined): RuleSet<Props> {if (isFalsish(chunk)) {return [];}/* Handle other components */if (isStyledComponent(chunk)) {return [`.${(chunk as unknown as IStyledComponent<'web', any>).styledComponentId}`];}/* Either execute or defer the function */if (isFunction(chunk)) {if (isStatelessFunction(chunk) && executionContext) {const result = chunk(executionContext);return flatten<Props>(result, executionContext, styleSheet, stylisInstance);} else {return [chunk as unknown as IStyledComponent<'web'>];}}if (chunk instanceof Keyframes) {if (styleSheet) {chunk.inject(styleSheet, stylisInstance);return [chunk.getName(stylisInstance)];} else {return [chunk];}}/* Handle objects */if (isPlainObject(chunk)) {return objToCssArray(chunk as StyledObject<Props>);}if (!Array.isArray(chunk)) {return [chunk.toString()];}return flatMap(chunk, chunklet =>flatten<Props>(chunklet, executionContext, styleSheet, stylisInstance));}
export default function flatten<Props extends object>(chunk: Interpolation<object>,executionContext?: (ExecutionContext & Props) | undefined,styleSheet?: StyleSheet | undefined,stylisInstance?: Stringifier | undefined): RuleSet<Props> {if (isFalsish(chunk)) {return [];}/* Handle other components */if (isStyledComponent(chunk)) {return [`.${(chunk as unknown as IStyledComponent<'web', any>).styledComponentId}`];}/* Either execute or defer the function */if (isFunction(chunk)) {if (isStatelessFunction(chunk) && executionContext) {const result = chunk(executionContext);return flatten<Props>(result, executionContext, styleSheet, stylisInstance);} else {return [chunk as unknown as IStyledComponent<'web'>];}}if (chunk instanceof Keyframes) {if (styleSheet) {chunk.inject(styleSheet, stylisInstance);return [chunk.getName(stylisInstance)];} else {return [chunk];}}/* Handle objects */if (isPlainObject(chunk)) {return objToCssArray(chunk as StyledObject<Props>);}if (!Array.isArray(chunk)) {return [chunk.toString()];}return flatMap(chunk, chunklet =>flatten<Props>(chunklet, executionContext, styleSheet, stylisInstance));}
Let’s summarize a little bit.
- the class name is generated based on component id and the CSS rules.
- if rules are static(no dynamic parts like
${props => ...}
), then the whole string is used. For dynamic ones, they have to be evaluated first. - same CSS won’t be injected twice.
2.6 Server-Side rendering is supported by assigning unique component id during compiling.
As mentioned about, the global counter for component id is not SSR safe because the execution of component is not guaranteed to be stable.
Notice that for generateId()
it accepts options which could skip the counter.
js
componentId = generateId(options.displayName, options.parentComponentId)
js
componentId = generateId(options.displayName, options.parentComponentId)
Thus SSR is supported by a babel plugin that transform our code by adding extra line that sets the component id.
Check out the test cases we can see what is being auto added.
js
const Test = styled.div`width: 100%;`
js
const Test = styled.div`width: 100%;`
Above code is compiled to
js
const Test = styled.div.withConfig({displayName: "Test",componentId: "sc-1cza72q-0"})`width:100%;`;
js
const Test = styled.div.withConfig({displayName: "Test",componentId: "sc-1cza72q-0"})`width:100%;`;
Both displayName
and componentId
are automatically added. We’ve seen how withConfig()
works in
section 2.2.
The transformation could be easily found in babel-plugin-styled-components.
export default t => (path, state) => {if (path.node.tag? isStyled(t)(path.node.tag, state): /* styled()`` */ (isStyled(t)(path.node.callee, state) &&path.node.callee.property &&path.node.callee.property.name !== 'withConfig') ||// styled(x)({})(isStyled(t)(path.node.callee, state) &&!t.isMemberExpression(path.node.callee.callee)) ||// styled(x).attrs()({})(isStyled(t)(path.node.callee, state) &&t.isMemberExpression(path.node.callee.callee) &&path.node.callee.callee.property &&path.node.callee.callee.property.name &&path.node.callee.callee.property.name !== 'withConfig') ||// styled(x).withConfig({})(isStyled(t)(path.node.callee, state) &&t.isMemberExpression(path.node.callee.callee) &&path.node.callee.callee.property &&path.node.callee.callee.property.name &&path.node.callee.callee.property.name === 'withConfig' &&path.node.callee.arguments.length &&Array.isArray(path.node.callee.arguments[0].properties) &&!path.node.callee.arguments[0].properties.some(prop =>['displayName', 'componentId'].includes(prop.key.name)))) {const displayName =useDisplayName(state) &&getDisplayName(t)(path, useFileName(state) && state)addConfig(t)(path,displayName && displayName.replace(/[^_a-zA-Z0-9-]/g, ''),useSSR(state) && getComponentId(state))}}
export default t => (path, state) => {if (path.node.tag? isStyled(t)(path.node.tag, state): /* styled()`` */ (isStyled(t)(path.node.callee, state) &&path.node.callee.property &&path.node.callee.property.name !== 'withConfig') ||// styled(x)({})(isStyled(t)(path.node.callee, state) &&!t.isMemberExpression(path.node.callee.callee)) ||// styled(x).attrs()({})(isStyled(t)(path.node.callee, state) &&t.isMemberExpression(path.node.callee.callee) &&path.node.callee.callee.property &&path.node.callee.callee.property.name &&path.node.callee.callee.property.name !== 'withConfig') ||// styled(x).withConfig({})(isStyled(t)(path.node.callee, state) &&t.isMemberExpression(path.node.callee.callee) &&path.node.callee.callee.property &&path.node.callee.callee.property.name &&path.node.callee.callee.property.name === 'withConfig' &&path.node.callee.arguments.length &&Array.isArray(path.node.callee.arguments[0].properties) &&!path.node.callee.arguments[0].properties.some(prop =>['displayName', 'componentId'].includes(prop.key.name)))) {const displayName =useDisplayName(state) &&getDisplayName(t)(path, useFileName(state) && state)addConfig(t)(path,displayName && displayName.replace(/[^_a-zA-Z0-9-]/g, ''),useSSR(state) && getComponentId(state))}}
Basically it says if styled components, create displayName
by getDisplayName()
,
create component id by getComponentId()
and modify the AST by addConfig()
.
File name and variable name are considered in getDisplayName()
, so we see displayName: "Test",
because of const Test
.
const getDisplayName = t => (path, state) => {const { file } = stateconst componentName = getName(t)(path)if (file) {const blockName = getBlockName(file, useMeaninglessFileNames(state))if (blockName === componentName) {return componentName}return componentName? `${prefixLeadingDigit(blockName)}__${componentName}`: prefixLeadingDigit(blockName)} else {return componentName}}
const getDisplayName = t => (path, state) => {const { file } = stateconst componentName = getName(t)(path)if (file) {const blockName = getBlockName(file, useMeaninglessFileNames(state))if (blockName === componentName) {return componentName}return componentName? `${prefixLeadingDigit(blockName)}__${componentName}`: prefixLeadingDigit(blockName)} else {return componentName}}
const getNextId = state => {const id = state.file.get(COMPONENT_POSITION) || 0state.file.set(COMPONENT_POSITION, id + 1)return id}const getComponentId = state => {// Prefix the identifier with a character because CSS classes cannot start with a numberreturn `${useNamespace(state)}sc-${getFileHash(state)}-${getNextId(state)}`}
const getNextId = state => {const id = state.file.get(COMPONENT_POSITION) || 0state.file.set(COMPONENT_POSITION, id + 1)return id}const getComponentId = state => {// Prefix the identifier with a character because CSS classes cannot start with a numberreturn `${useNamespace(state)}sc-${getFileHash(state)}-${getNextId(state)}`}
Well, there is another counter here to generate the component id. But this is during compiling not in runtime, so it works perfectly.
The rest of SSR is simple now. We just need to
- extract the CSS code and put them into html.
- serve the class name to the context to avoid CSS injection.
import { renderToString } from 'react-dom/server';import { ServerStyleSheet, StyleSheetManager } from 'styled-components';const sheet = new ServerStyleSheet();try {const html = renderToString(<StyleSheetManager sheet={sheet.instance}><YourApp /></StyleSheetManager>);const styleTags = sheet.getStyleTags(); // or sheet.getStyleElement();} catch (error) {// handle errorconsole.error(error);} finally {sheet.seal();}
import { renderToString } from 'react-dom/server';import { ServerStyleSheet, StyleSheetManager } from 'styled-components';const sheet = new ServerStyleSheet();try {const html = renderToString(<StyleSheetManager sheet={sheet.instance}><YourApp /></StyleSheetManager>);const styleTags = sheet.getStyleTags(); // or sheet.getStyleElement();} catch (error) {// handle errorconsole.error(error);} finally {sheet.seal();}
Here it uses StyleSheetManager
in which StyleSheetContext
is used.
export const StyleSheetContext = React.createContext<IStyleSheetContext>({shouldForwardProp: undefined,styleSheet: mainSheet,stylis: mainStylis,});
export const StyleSheetContext = React.createContext<IStyleSheetContext>({shouldForwardProp: undefined,styleSheet: mainSheet,stylis: mainStylis,});
And remember in CSS injection we use StyleSheetContext
to dedupe.
3. Summary
As one of popular CSS-in-JS solutions, styled components takes advantages of tagged templates to bind CSS code to a component. It generate class names by hashing the component id and css code and injects CSS code during rendering. By default component id is created by global counter, thus to support CSS a babel plugin is needed to assign a stable id to each styled component.
Based on the internals, we can see that it actually has heavy logic in runtime, including the hashing and CSS injection. The problem of tagged templates is that it has no restriction of the dynamic part, we only know the dynamic functions return a string, but we don’t know what they are, this means it is not possible to do static improvement(related discussion). If you are to use styled components, you should use SSR by default, it drastically improves the performance, but only for the initial load.