深入浅出 标签模板字符串 和 💅styled-components 💅

React 组件设计实践总结 03 - 样式的管理一文中吹了一波 styled-components 后,本文想深入来了解一下 styled-components 的原理. 如果你对 styled-components 还不了解,建议先阅读一下官方文档或前面的文章.

本文基于 styled-components v4.13 版本

目录




从 Tagged Template Literals 说起

标签模板字面量(Tagged Template Literals)是 ES6 新增的特性,它允许你自定义字符串的内插(interpolation)规则, styled-components 正是基于这个特性构建:



它的原理非常简单,所有静态字符串会被拆分出来合并成为数组, 作为第一个参数传入到目标函数,而内插(interpolation)表达式的值则会作为 rest 参数传入:



标签模板字面量相比普通的模板字面量更加灵活. 普通模板字符串会将所有内插值转换为字符串,而标签模板字面量则由你自己来控制:



因为标签模板字符串简洁的语法和灵活性,它比较适用于作为DSL, 不需要在语言层面进行支持,比如前阵子preact作者开发的htm, 口号就是”取代 JSX,而且不需要编译器支持”, 通过这种方式是可以优雅地实现‘你的网站或许不需要前端构建’.



另一个典型的例子就是 jest表格测试, 这样形式可读性更高:



标签模板字面量的脑洞还在继续,比如可以用来写 markdown,再生成 react 组件。限于篇幅就不啰嗦了

扩展:




源码导读

现在来看一下 styled-components 的实现。为了行文简洁,我们只关心 styled-components 的核心逻辑,所以我对源代码进行了大量的简化,比如忽略掉服务端渲染、ReactNative 实现、babel 插件等等.

1. 处理标签模板字面量

先从 styled 构造函数看起:



styled 构造函数接收一个包装组件 target,而标签模板字面量则由css函数进行处理的. 这个函数在 styled-components 中非常常用,类似于 SCSSmixin 角色. css 函数会标签模板字面量规范化, 例如:



css 实现也非常简单:



interleave函数将将静态字符串数组和内插值’拉链式‘交叉合并为单个数组, 比如[1, 2] + [a, b]会合并为[1, a, 2, b]

关键在于如何将数组进行扁平化, 这个由 flatten 函数实现. flatten 函数会将嵌套的 css(数组形式)递归 concat 在一起,将 StyledComponent 组件转换为类名引用、还有处理 keyframe 等等. 最终剩下静态字符串和函数, 输出结果如上所示。

实际上 styled-components 会进行两次 flatten,第一次 flatten 将能够静态化的都转换成字符串,将嵌套的 css 结构打平, 只剩下一些函数,这些函数只能在运行时(比如在组件渲染时)执行;第二次是在运行时,拿到函数的运行上下文(props、theme 等等)后, 执行所有函数,将函数的执行结果进行递归合并,最终生成的是一个纯字符串数组. 对于标签模板字面量的处理大概都是这个过程. 看看 flatten 的实现:



总结一下标签模板字面量的处理流程大概是这样子:






2. React 组件的封装

现在看看如何构造出 React 组件。styled-components 通过 createStyledComponent 高阶组件将组件封装为 StyledComponent 组件:



createStyledComponent 是一个典型的高阶组件,它在执行期间会生成一个唯一的组件 id 和创建ComponentStyle对象. ComponentStyle 对象用于维护 css 函数生成的 cssRules, 在运行时(组件渲染时)得到执行的上下文后生成最终的样式和类名。

再来看看 StyledComponent 的实现, StyledComponent 在组件渲染时,将当前的 props+theme 作为 context 传递给 ComponentStyle,生成类名.






3. 样式和类名的生成

上面看到 StyleComponent 通过 ComponentStyle 类来构造样式表并生成类名, ComponentStyle 拿到 context 后,再次调用 flatten 将 css rule 扁平化,得到一个纯字符串数组。通过使用 hash 算法生成类名, 并使用stylis 对样式进行预处理. 最后通过 StyleSheet 对象将样式规则插入到 DOM 中



stylis是一个 3kb 的轻量的 CSS 预处理器, styled-components 所有的 CSS 特性都依赖于它, 例如嵌套规则(a {&:hover{}})、厂商前缀、压缩等等.




4. DOM 层操作

现在来看一下 StyleSheet, StyleSheet 负责收集所有组件的样式规则,并插入到 DOM 中



看看简化版的 makeTag






5. 总结

代码可能看晕了,通过流程图来梳理一下过程.

上一篇文章技术地图 - vue-cli一点代码也没有罗列,只有一个流程图, 读者可能一下子就傻眼了, 不知道在说些什么; 而且这个流程图太大,在移动端不好阅读. 这期稍微改进一下,新增’源码导读‘一节,代码表达能力毫无疑问是胜于流程图的,但是代码相对比较细节琐碎,所以第一是将代码进行简化,留下核心的逻辑,第二是使用流程图表示大概的程序流程,以及流程主体之间的关系.


如上图 styled-components 主要有四个核心对象:

  • WrappedComponent: 这是 createStyledComponent 创建的包装组件,这个组件保存的被包装的 target、并生成组件 id 和 ComponentStyle 对象
  • StyledComponent: 这是样式组件,在它 render 时会将 props 作为 context 传递给 ComponentStyle,并生成类名
  • ComponentStyle: 负责生成最终的样式表和唯一的类名,并调用 StyleSheet 将生成的样表注入到文档中
  • StyleSheet: 负责管理已生成的样式表, 并注入到文档中


styled-components 性能优化建议

styled-components 每次渲染都会重新计算 cssRule,并进行 hash 计算出 className,如果已经对应的 className 还没插入到样式表中,则使用 stylis 进行预处理,并插入到样式表中;

另外 styled-components 对静态 cssRule(没有任何内插函数)进行了优化,它们不会监听 ThemeContext 变化, 且在渲染时不会重新计算。

通过这些规则可以得出以下性能优化的建议:

  • 静态化的 cssRule 性能是最好的
  • 降低 StyledComponent 状态复杂度. styled-components 并不会对已有的不变的样式规则进行复用,一旦状态变化 styled-component 会生成一个全新的样式规则和类名. 这是最简单的一种实现, 避免了样式复用的复杂性,同时保持样式的隔离性, 问题就是会产生样式冗余。 例如

    const Foo = styled.div<{ active: boolean }>`
    color: red;
    background: ${props => (props.active ? 'blue' : 'red')};
    `;

    active 切换之间会生成两个类名:

    .cQAOKL {
    color: red;
    background: red;
    }
    .kklCtT {
    color: red;
    background: blue;
    }

    如果把 StyledComponent 看做是一个状态机,那么 styled-components 可能会为每一个可能的状态生成独立的样式. 如果 StyledComponent 样式很多, 而且状态比较复杂,那么会生成很多冗余的样式.

  • 不要用于动画。上面了解到 styled-component 会为每个状态生成一个样式表. 动画一般会有很多中间值,在短时间内进行变化,如果动画值通过props传入该StyledComponent来应用样式,这样会生成很多样式,性能非常差:

    const Bar = styled.div<{ width: boolean }>`
    color: red;
    // 千万别这么干
    width: ${props => props.width};
    `;

    这种动画场景最好使用 style 内联样式来做


OK, 行文结束。styled-components 不过如此是吧?




技术地图

  • CSS 相关
    • @emotion/unitless 判断属性值是否需要单位
    • css-to-react-native 将 css 转换为 ReactNative style 属性
    • stylis 轻量的 CSS 预处理器
  • React 相关
    • @emotion/is-prop-valid 判断是否是合法的 DOM 属性
    • hoist-non-react-statics 提升React组件的静态属性,用于高阶组件场景
    • react-is: 判断各种 React 组件类型
    • react-primitives 这是一个有意思的库,这个库试图围绕着构建 React 应用提出一套理想的原语,通俗的说就是通过它可以导入不同平台的组件。
    • react-frame-component 将react渲染到iframe中。也是一个比较有意思的库
    • react-live react实时编辑器和展示,主要用于文档
  • 构建相关
    • bundlesize 检查包大小
    • codemod 使用babel-plugin来重写Javascript或Typescript代码, 一般用于制作升级脚本
    • microbundle 一个零配置的打包器,基于Rollup,可以用于库的打包和开发, preact作者开发必属精品