Styled Component

难以隔离的 CSS

你可能已经知道了,CSS 样式的应用是基于选择器的。例如如下代码:

1
2
3
div {
// ...
}

会为 div 元素应用一段 CSS 代码。但显而易见的是,这种设计会导致不可避免的问题——冲突
事实上,原生 CSS 无法提供模块隔离的特性。我们必须借助额外的设计来处理这个问题。特别是在传统的 React 中,我们经常需要将逻辑、样式等代码写在一起,这就会导致很麻烦的样式冲突问题。
也许你听说过 BEM,这种方法提出了一些原则,程序员只要遵守这个原则,就可以写出低冲突风险的代码:

1
2
3
.Block__Emelemt--Modifier {
// ...
}

但这也意味着用户需要手动去处理各种命名,这是很麻烦的事情。也许我们可以采用更加自动化的方式来处理这个事情。
Styled-Component 是 React 生态下一个比较好的处理方案。尽管它本质上也是通过处理 class 来完成样式的应用和隔离,但因为导出的是 React 组件,且拥有继承的特性,效果还是很好的。

Styled-Component 源码

因为这是个比较大的项目,所以这边的源码分析只专注于最核心的东西。为了说明一些问题,可能会对源码进行修改,只留下最本质的东西。但这并不意味着那些被删除的代码不重要。

两个核心问题

  1. 如何处理语法
  2. 如何处理 css 匹配
  3. ComponentStyle
  4. useStyledComponentImpl

Tag function

一个很常见的 styled-components 组件的写法是:

1
2
3
const Button = styled.button`
color: grey;
`;

而这个看似 怪异 的写法实际上是 ES6 的 tag function 特性。

简单来说,你可以调用一个函数来解析模板字符串。这个模板字符串将会被作为第一个参数而传入函数。值得注意的是,这个解析函数并不一定需要再次返回字符串。

下面是一个例子:

1
2
3
4
5
function sayHello(name) {
return `Hello, my name is ${name}`
}

sayHello`Spike` // Hello, my name is Spike

不过这只是最基础的用法。真正让这个特性发挥威力的是它对含参模板字符串的解析逻辑。同时,这也是理解 styled-components 原理的基础。

下面是一个例子:

1
2
3
4
5
6
7
8
9
10
11
function parser(strings, ...args) {
console.log("strings: ", strings);
console.log("args", args);

return `Hello, ${strings[0]}`;
}

console.log(parser`Jet ${nice}`);
// strings: ["Jet", ""]
// args: ["nice"]
// Hello, Jet

有几个需要注意的地方:

  • 传入解析函数的模板字符串会以变量处为分界线,拆分为多个字符串,以数组的方式传入
  • 传入解析函数的模板字符串的变量将会被分别传入

所以如果你能确定传入的变量数量时,你也可以这样写这个解析函数:

1
2
3
function parser(strings, arg1, arg2) {
// ...
}

经由上面的内容,我们就可以分析一下一个常规的 styled-components 将会如何接收函数。

假设我们有如下代码:

1
2
3
styled.div`
background-color: red;
`

解析函数将会接收到这样的参数:

1
2
3
[`
background-color: red;
`]

当我们在使用 styled-compoent 时使用了变量,比如:

1
2
3
4
const color = "red";
styled.button`
background-color: ${color};
`

那接收到的参数将会像是这样:

1
2
[`background-color" `, `;`]
['red']

另外的,通过这些写法,可以很容易猜到 styled-components 是如何组织这些解析函数的:

1
2
3
4
5
const styled = {
div: (strings, ...vals) => {},
button: (strings, ...vals) => {},
// ...
}

简单来说,这些解析函数将会接收你传入的信息,解析它们,并返回一个 React Component。

styled 绑定的一组解析函数

在 styled-component 中,我们可以写出这样的代码:

1
2
3
const Button = styled.button`
// ...
`

也许你已经能猜到了,我们可以这样写的原因无非是我们将各种原生 Element 都绑定在了 styled 上,类似这样:

1
2
3
4
5
6
7
const styled = {
div,
button,
h1,
h2,
// ...
}

不过手动绑定有点繁琐,那么看看源码中是怎么处理的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const domElements = [
'a',
'div',
// ...
]

const styled = <Props>(tag: WebTarget) =>
constructWithOptions<IStyledComponentFactory, Props>(
createStyledComponent, tag
)
type BaseStyled = typeof styled;
const enhancedStyled = styled as BaseStyled &
{
[key in typeof domElements[number]]: ReturnType<BaseStyled>
}

domElements.forEach(domElement => {
enhancedStyled[domElement] = styled(domElement);
})

constructWithOptions

从上面的代码可以看出,我们通过一个方法生成了 styled,下面看看这个方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
export default function constructWithOptions<
Constructor extends Function = IStyledComponentFactory,
OuterProps = undefined
>(componentConstructor: Constructor, tag: WebTarget, options: Options = EMPTY_OBJECT as Object) {
// ...

const templateFunction = <Props = OuterProps>(
initialStyles: TemplateStringsArray | SrtledObject | StyledFunction<Props>,
...interpolations: Interpolation<Props>[]
) => componentConstructor(tag, options, css(initialStyles, ...interpolations))

// Modifiy/inject new props at runtime
templateFunction.attrs = <Props = OuterProps>(attrs: Attrs<Props>) =>
constructWithOptions<Constructor, Props>(componentConstructor, tag, {
...options,
attrs: Array.prototype.concat(options.attrs, attrs).filter(Boolean)
})

// If config methods are called, wrap up a new template function and merge options
templateFunction.withConfig = (config: Options) =>
constructWithOptions<Constructor, OuterProps>(componentConstructor, tag, {
...options,
...config
})

return templateFunction
}

注意到 templateFunction.attrstemplateFunction.withConfig 中, attrswithConfig 会被合并到 options 中。另外再递归调用 constructWithOptions,这就允许我们在定义 styled component 时可以写链式调用的代码了。

constructWithOptions 最核心的代码就是这句:

1
componentConstructor(tag, options, css(initialStyles, ...interpolations))

我们注意到这实际上是两步。第一步,调用 css 生成样式代码。第二部,调用 componentConstructor 生成 Styled Component

css

那么,先来看看样式代码的生成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
export default function css(
styles: Styles,
...interpolations: Array<Interpolation>
): FlattenerResult {
if (isFunction(styles) || isPlainObject(styles)) {
const styleFunctionOrObject = styles as Function | ExtensibleObject
return flatten(interleave(EMPTY_ARRAY as string[], [styleFunctionOrObject, ...interpolations]))
}

const styleStringArray = styles as string[]

if (
interpolations.length === 0 &&
styleStringArray.length === 1 &&
typeof styleStringArray[0] === 'string'
) {
return styleStringArray
}

return flatten(interleave(styleStringArray, interpolations))
}

事实上,flatten 函数就可以处理各种情况的样式输入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
export default function flatten(
chunk: Interpolation,
executionContext?: ExtensibleObject,
styleSheet?: StyleSheet,
stylisInstance?: Stringifier
): RuleSet | string | IStyledComponent | keyframes {
if (Array.isArray(chunk)) {
// ...
}

if (isFalsish(chunk)) {
// ...
}

if (isStyledComponent(chunk)) {
// ...
}

if (isFunction(chunk)) {
// ...
}

if (chunk instanceof Keyframes) {
// ...
}

return isPlainObject(chunk) ? objToCssArray(chunk as ExtensibleObject) : chunk.toString()
}

createStyledComponent

在获得了样式代码之后,我们可以看一下如何创建一个组件。这里调用了 createStyledComponent 函数来创建,看一下源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
const createStyledComponent: IStyledComponentFactory = (
target, options, rules
) => {
// ...

const {
attrs = EMPTY_ARRAY,
// 组件 id
componentId = generateId(options.displayName, options.parentComponentId),
displayName = generateDisplayName(target)
} = options

const styledComponentId =
options.displayName && options.componentId
? `${escape(options.displayName)}-${options.componentId}`
: options.componentId || componentId;

const componentStyle = new ComponentStyle(
rules,
styledComponentId,
isTargetStyledComp ? (styledComponentTarget.componentStyle as ComponentStyle) : undefined
)

// ...

let WrappedStyledComponent: IStyledComponent
function forwardRef(props: ExtensibleObject, ref: Ref<Element>) {
return useStyledComponentImpl(WrappedStyledComponent, props, ref, isStatic);
}

// ...

// 传递组件引用,以便于可以使用 ref 来操作元素
WrappedStyledComponent = React.forwardRef(forWardRef) as unknown as IStyledComponent;
WrappedStyledComponent.attrs = finalAttrs;
WrappedStyledComponent.componentStyle = componentStyle;
WrappedStyledComponent.displayName = displayName;

// ...

WrappedStyledComponent.withComponent = function withComponent(tag: WebTarget) {
const { componentId: previousComponentId, ...optionsToCopy } = options;

const newComponentId =
previousComponentId &&
`${previousComponentId}-${isTag(tag) ? tag : escape(getComponentName(tag))}`;

const newOptions = {
...optionsToCopy,
attrs: finalAttrs,
componentId: newComponentId,
};

return createStyledComponent(tag, newOptions, rules);
};

return WrappedStyledComponent;
}

注意到 componentStyle 的实现,是实例化了 ComponentStyle 类。那么,看一下这个类的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
export default class ComponentStyle {
baseHash: number;
baseStyle: ComponentStyle | null | undefined;
componentId: string;
isStatic: boolean;
rules: RuleSet;
staticRulesId: string;

constructor(rules: RuleSet, componentId: string, baseStyle?: ComponentStyle) {
this.rules = rules;
this.staticRulesId = '';
this.isStatic =
process.env.NODE_ENV === 'production' &&
(baseStyle === undefined || baseStyle.isStatic) &&
isStaticRules(rules);
this.componentId = componentId;

// SC_VERSION gives us isolation between multiple runtimes on the page at once
// this is improved further with use of the babel plugin "namespace" feature
this.baseHash = phash(SEED, componentId);

this.baseStyle = baseStyle;

// NOTE: This registers the componentId, which ensures a consistent order
// for this component's styles compared to others
StyleSheet.registerId(componentId);
}

generateAndInjectStyles(
executionContext: Object,
styleSheet: StyleSheet,
stylis: Stringifier
): string {
// ...
styleSheet.insertRules(componentId, name, cssFormatted)
// ...
}
}

实际上,我们是通过 React.forwardRef 传递了组件引用,在该函数中,实现了 styled-component 。下面来看一下其具体实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
function useStyledComponentImpl(
forwardedComponent: IStyledComponent,
props: ExtensibleObject,
forwardedRef: Ref<Element>,
isStatic: boolean
) {
// ...

const generatedClassName = useInjectedStyle(
componentStyle,
isStatic,
context,
process.env.NODE_ENV !== 'production' ? forwardedComponent.warnTooManyClasses : undefined
)

const elementToBeCreated: WebTarget = attrs.$as || props.$as || attrs.as || props.as || target;

propsForElement[
isTargetTag &&
domElements.indexOf(elementToBeCreated as unknown as Extract<typeof domElements, string>) === -1
? 'class'
: 'className'
] = (foldedComponentIds as string[])
.concat(
styledComponentId,
(generatedClassName !== styledComponentId ? generatedClassName : null) as string,
props.className,
attrs.className
)
.filter(Boolean)
.join(' ');

return createElement(elementToBeCreated, propsForElement);
}

其中,生成类名的方法 useInjectedStyle 的实现是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function useInjectedStyle<T>(
componentStyle: ComponentStyle,
isStatic: boolean,
resolvedAttrs: T,
warnTooManyClasses?: ReturnType<typeof createWarnTooManyClasses>
) {
const styleSheet = useStyleSheet();
const stylis = useStylis();

const className = isStatic
? componentStyle.generateAndInjectStyles(EMPTY_OBJECT, styleSheet, stylis)
: componentStyle.generateAndInjectStyles(resolvedAttrs, styleSheet, stylis);

return className
}

这里调用的 createElement 方法是 React 提供的,其 api 为:

1
2
3
4
5
React.createElement(
type,
[props],
[...children]
)
Author: ShroXd
Link: http://www.bebopser.com/2021/05/16/styledcomponent/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.