styled-components 是社区中最流行的 CSS-in-JS 方案之一,我相信在初学者会认为它将字符串和函数结合的写法非常神奇,为了更好的掌握 styled-components 的使用和背后的魔法,实现一个简易版本的 styled-components 是个不错的 idea。
The big idea
让我们从官方示例开始,分析 styled-components 需要做到什么。
const Title = styled.h1` font-size: 1.5em; text-align: center; color: palevioletred; `; // 先简化一下,在最后补充 const Title = styled('h1')` font-size: 1.5em; text-align: center; color: palevioletred; `;
首先 styled-components 用到了一个生僻的被称作 “tagged templates” 的 JavaScript 特性,使这段代码等价于用一个函数进行处理。(如果你不了解 tagged templates,请先阅读链接的内容)
当我们用 styled-components 生成一个 React 函数组件时,背后发生了这些事情。
- 用
comeUpWithUniqueName
函数生成唯一 class name,也是StyledComponent
的 id
- 使用
reconcileStyles
处理 tagged templates 的参数生成 CSS 字符串
- 注入 CSS 字符串到 Document 中
- 将 uniqueClassName 作为 Tag 的 className,因此 Tag 可以是任意接收 className 的组件
const styled = (Tag) => (rawStyles, ...interpolations) => { const uniqueClassName = comeUpWithUniqueName(); const StyledComponent = (props) => { const styles = reconcileStyles(rawStyles) createAndInjectCSSClass(uniqueClassName, rawStyles); return <Tag {...props} className={uniqueClassName}/> } return StyledComponent }
根据 tagged templates 的用法我们可以知道参数
rawStyles
是字符串数组,interpolations
是插值数组。模版插值 & 引用组件
如果你更深入的阅读 styled-components 文档,可以了解到它最重要的两个特性:
让我们拓展一下已有的函数。
const styled = (Tag) => (rawStyles, ...interpolations) => { const uniqueClassName = comeUpWithUniqueName(); const StyledComponent = (props) => { const styles = reconcileStyles(rawStyles, interpolations, props) const processedStyles = runStylesThroughStylis(uniqueClassName, styles); createAndInjectCSSClass(processedStyles); return <Tag {...props} className={processedStyles} /> } StyledComponent.styledComponentId = uniqueClassName return StyledComponent }
在这个步骤中我们做了一些事:
- 拓展
reconcileStyles
函数 - 结合 CSS 字符串数组、插值数组计算 CSS 字符串,其中如果插值是函数,则将 Props 作为参数传入参与计算
- 插值为 StyledComponent 时(级联选择器),取其 uniqueId (即 class name) 替换
- 增加
runStylesThroughStylis
函数,基于 Stylis 处理组件间的引用
进一步优化
到这里我们的代码还有一些问题,例如:
- 组件本身存在 className 会被被覆盖
- 上文中
styled
函数还不支持styled.h1
的形式
让我们一起改进它!
const styled = (Tag) => (rawStyles, ...interpolations) => { const uniqueClassName = comeUpWithUniqueName(); const StyledComponent = (props) => { const styles = reconcileStyles(rawStyles, interpolations, props) const processedStyles = runStylesThroughStylis(uniqueClassName, styles); createAndInjectCSSClass(processedStyles); const combinedClasses = combineClassNames(props.className, uniqueClassName) return <Tag {...props} className={combinedClasses} /> } StyledComponent.styledComponentId = uniqueClassName return StyledComponent } const domElements = ['h1', 'div'] as const type DomElements = typeof domElements[number] type BaseStyled = typeof styled; type EnhancedStyled = { [key in DomElements]: ReturnType<BaseStyled>; }; const enhancedStyled = styled as BaseStyled & EnhancedStyled domElements.forEach(domElement => { styled[domElement] = styled(domElement) }) export default enhancedStyled
这一步骤主要做了两件事:
- 增加
combineClassNames
函数处理组件自身携带 className 的情况
- 将 domElements 绑定到
styled
函数,同时增加一些 ts 类型支持
结语
到这里我们的 styled-components 完成了!这个过程和 styled-components 的实现思路是接近的,虽然细节上还远远不足😅,但对于使用来说我们可以理解一些光看文档无法了解的事,例如:
- styled-components 存在一定运行时开销
- styled-components 对 tree-shaking 友好
- 当 props 变化频繁时,document 会有一堆无用的 style 标签
- styled-components SSR 的样式并不会打包到 bundle 中,预渲染时会出现 FOUC 问题,因此需要在服务端收集 runtime 生成的样式,注入到 HTML 中。
- 预渲染时 unique class name 的计算和顺序有关,可以尝试调整 SC 组件在文件中的前后顺序,观察其 class name 变化
拓展链接
- 完整代码:my-styled-components