前言
去年11月份的时候做过一次Atomic CSS-in-JS的调研分享:迈向Atomic CSS-in-JS:从运行时回到编译时,彼时的社区已经涌现出styleX/style9
、Stitches
、vanilla-extract
等库。在相互借鉴学习中,相关API定义也趋于成熟,但似乎离大规模应用于生产仍有距离,总差那么临门一脚:
- meta的styleX至今没有正式开源
- style9体量较小,对不同bundler支持较差
- Stitches不再维护
- vanilla-extract的atomic方案Sprinkles本身只是一层封装,能力较弱
契机
去年10月Next.js 13发布以来,React Server Component逐渐进入React开发者视野,在今年5月份的next.js 13.4中App Router正成为稳定版和主推路径,同时也对React社区生态带来了非常巨大的挑战。 今年3月,Chakra UI作者 Adebayo Segun 发表了文章《Chakra UI的未来》,阐述了当前React组件库面临的困境,主要可以分为三类问题:
- 以往各个组件库大规模使用的runtime CSS-in-JS方案(styled-component,emotion)在Server Component中无法使用,以至于每个组件库总有一个Pined Issue是和这个相关
- 支持更全面的主题系统、开放更多的样式定制
- 如何支持更复杂的组件同时保持API的直观和易于维护
在其中最为困难的就是runtime CSS-in-JS的替换上,ChakraUI团队决定开发一个新的zero-runtime CSS-in-JS方案,也就是本文要介绍的Panda CSS,经过8个多月的开发后终于在今年6月正式推出,目前仍在非常活跃地迭代中。
Panda CSS
Panda CSS是一个现代CSS-in-JS的样式库,支持通过静态分析在build time生成仅需要的atomic class(Just-in-Time),没有传统CSS-in-JS的插入 styles的运行时开销。并且提供样式的类型安全、更高的可读性、更友好的开发体验、更好的兼容性。
值得一提的是Panda CSS作为新秀,文档和官网反而是做的最好、最完善的,就像ChakraUI的文档一样优秀。
甚至提供了在线的PlayGround和视频教程:play.panda-css.com/、panda-css.com/learn
Panda CSS支持多种使用方式,非常灵活:
- Style Function
css
,就是普通的CSS-in-JS,支持media-query、pseudo-class等
tsx
import { css } from "./styled-system/css";
import { circle, stack } from "./styled-system/patterns";
function App() {
return (
<div className={stack({ direction: "row", p: "4" })}>
<div className={circle({ size: "5rem", overflow: "hidden" })}>
<img src="https://via.placeholder.com/150" alt="avatar" />
</div>
<div className={css({ mt: "4", fontSize: "xl", fontWeight: "semibold" })}>
John Doe
</div>
<div className={css({ mt: "2", fontSize: "sm", color: "gray.600" })}>
john@doe.com
</div>
</div>
);
}
- Style Props,类似CharkUI的,样式通过JSX Props传入
tsx
import { Box, Stack, Circle } from './styled-system/jsx'
function App() {
return (
<Stack direction="row" p="4" rounded="md" shadow="lg" bg="white">
<Circle size="5rem" overflow="hidden">
<img src="https://via.placeholder.com/150" alt="avatar" />
</Circle>
<Box mt="4" fontSize="xl" fontWeight="semibold">
John Doe
</Box>
<Box mt="2" fontSize="sm" color="gray.600">
john@doe.com
</Box>
</Stack>
)
}
- Recipes,提前创建支持多个变种或参数的样式集
tsx
import { styled } from './styled-system/jsx'
import { cva } from './styled-system/css'
export const badge = cva({
base: {
fontWeight: 'medium',
borderRadius: 'md',
},
variants: {
status: {
default: {
color: 'white',
bg: 'gray.500',
},
success: {
color: 'white',
bg: 'green.500',
},
},
}
})
export const Badge = styled('span', badge)
实践
Panda CSS内置了theme和variant的方案,非常适合搭配RadixUI这类Headless UI使用。下面就用Panda CSS来实现最近大热的shadcn/ui (Radix UI + Tailwind CSS)作为例子简要介绍一下。
最基础的Button,shadcn/ui 使用了class-variance-authority来定义default、destructive、outline、secondary、ghost、link等variant(样式不同)
Theme & Variants
tsx
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-slate-950 disabled:pointer-events-none disabled:opacity-50 dark:focus-visible:ring-slate-300",
{
variants: {
variant: {
default:
"bg-slate-900 text-slate-50 shadow hover:bg-slate-900/90 dark:bg-slate-50 dark:text-slate-900 dark:hover:bg-slate-50/90",
destructive:
"bg-red-500 text-slate-50 shadow-sm hover:bg-red-500/90 dark:bg-red-900 dark:text-slate-50 dark:hover:bg-red-900/90",
outline:
"border border-slate-200 bg-transparent shadow-sm hover:bg-slate-100 hover:text-slate-900 dark:border-slate-800 dark:hover:bg-slate-800 dark:hover:text-slate-50",
secondary:
"bg-slate-100 text-slate-900 shadow-sm hover:bg-slate-100/80 dark:bg-slate-800 dark:text-slate-50 dark:hover:bg-slate-800/80",
ghost: "hover:bg-slate-100 hover:text-slate-900 dark:hover:bg-slate-800 dark:hover:text-slate-50",
link: "text-slate-900 underline-offset-4 hover:underline dark:text-slate-50",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }
tsx
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-[20px] w-[36px] shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }
而Panda CSS中内置了类似的用于创建variant的cva
方法和theme token,如颜色的slate.100 ~ 900,文本,阴影等
tsx
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, cx, type RecipeVariantProps } from "styled-system/css";
const buttonVariants = cva({
base: {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
rounded: "md",
fontSize: "sm",
lineHeight: "sm",
fontWeight: "medium",
transitionProperty:
"color, background-color, border-color, text-decoration-color, fill, stroke",
transitionTimingFunction: "cubic-bezier(.4,0,.2,1)",
transitionDuration: ".15s",
_focusVisible: { ring: "none", ringOffset: "none", shadow: "1" },
_disabled: { pointerEvents: "none", opacity: "0.5" },
},
variants: {
variant: {
default: {
bgColor: "slate.900",
color: "slate.50",
shadow: "sm",
_hover: {
bgColor: "slate.800",
_dark: { bgColor: "slate.50/90" },
},
_dark: { bgColor: "slate.50", color: "slate.900" },
},
destructive: {
bgColor: "red.500",
color: "slate.50",
shadow: "sm",
_hover: { bgColor: "red.500/90", _dark: { bgColor: "red.900/90" } },
_dark: { bgColor: "red.900", color: "slate.50" },
},
outline: {
borderWidth: "1px",
borderColor: "slate.200",
bgColor: "transparent",
shadow: "sm",
_hover: {
bgColor: "slate.100",
color: "slate.900",
_dark: { bgColor: "slate.800", color: "slate.50" },
},
_dark: { borderColor: "slate.800" },
},
secondary: {
bgColor: "slate.100",
color: "slate.900",
shadow: "sm",
_hover: { bgColor: "slate.100", _dark: { bgColor: "slate.800/80" } },
_dark: { bgColor: "slate.800", color: "slate.50" },
},
ghost: {
_hover: {
bgColor: "slate.100",
color: "slate.900",
_dark: { bgColor: "slate.800", color: "slate.50" },
},
},
link: {
color: "slate.900",
textUnderlineOffset: "4px",
_hover: { textDecorationLine: "underline" },
_dark: { color: "slate.50" },
},
},
size: {
default: { h: "9", pl: "4", pr: "4", pt: "2", pb: "2" },
sm: {
h: "8",
rounded: "md",
pl: "3",
pr: "3",
fontSize: "xs",
lineHeight: "xs",
},
lg: { h: "10", rounded: "md", pl: "8", pr: "8" },
icon: { h: "9", w: "9" },
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});
type ButtonVariants = RecipeVariantProps<typeof buttonVariants> & {};
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
ButtonVariants {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cx(buttonVariants({ variant, size }), className)}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };
tsx
import { css } from '../styled-system/css'
import * as React from 'react'
import * as SwitchPrimitives from '@radix-ui/react-switch'
import { cn } from '@/lib/utils'
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
css({
display: 'inline-flex',
h: '20px',
w: '36px',
flexShrink: '0',
cursor: 'pointer',
alignItems: 'center',
rounded: 'full',
borderWidth: '2px',
borderColor: 'transparent',
shadow: 'sm',
transitionProperty: 'color, background-color, border-color, text-decoration-color, fill, stroke',
transitionTimingFunction: 'colors',
transitionDuration: 'colors',
_focusVisible: { ring: 'none', ringOffset: 'none', shadow: '2' },
_disabled: { cursor: 'not-allowed', opacity: '0.5' },
}),
className,
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
css({
pointerEvents: 'none',
display: 'block',
h: '4',
w: '4',
rounded: 'full',
shadow: '0',
transitionProperty: 'transform',
transitionTimingFunction: 'transform',
transitionDuration: 'transform',
'data-[state=checked]': {
transform:
'translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))',
},
'data-[state=unchecked]': {
transform:
'translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))',
},
}),
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }
Panda CSS提供了tailwind css 转 panda css的tw2panda cli和web playground。
typescript
// panda.config.ts
export default defineConfig({
theme: {
tokens: {
colors: {
primary: { value: '#0FEE0F' },
secondary: { value: '#EE0F0F' }
},
fonts: {
body: { value: 'system-ui, sans-serif' }
}
}
}
})
所有内置或自定义的theme token最终都会通过code gen生成Typescript定义,有很好的自动补全和类型安全(个人感觉比Tailwind CSS通过插件提供补全响应更快,但没有注释说明)
除了Recipe,还有支持作用于组件不同部分的Slot Recipe,适用于更复杂的组件,比如Compound Component范式,这里不做展开
jsx
import { checkbox } from './checkbox.recipe'
const Checkbox = () => {
const classes = checkbox({ size: 'sm' })
return (
<label className={classes.root}>
<input type="checkbox" className={css({ srOnly: true })} />
<div className={classes.control} />
<span className={classes.label}>Checkbox Label</span>
</label>
)
}
Pattern
Pattern是用于布局的工具方法,预设了部分样式(可以通过入参改变),返回的也是普通的Style Object。非常适合已经有组件库,日常只需要简单调整布局的情况。
javascript
const stackConfig = {
transform(props) {
const { align, justify, direction = "column", gap = "10px", ...rest } = props;
return {
display: "flex",
flexDirection: direction,
alignItems: align,
justifyContent: justify,
gap,
...rest
};
}}
export const getStackStyle = (styles = {}) => stackConfig.transform(styles, { map: mapObject })
export const stack = (styles) => css(getStackStyle(styles))
jsx
import { stack } from '../styled-system/patterns'
function App() {
return (
<div className={stack({ gap: '6', padding: '4' })}>
<div>First</div>
<div>Second</div>
<div>Third</div>
</div>
)
}
产物分析
从上面例子可以看出,所有的方法都是从import自项目中的styled-system
,而且pandaCSS也是作为devDependencies安装。
运行时
perl
styled-system/
├── css
├── jsx
├── patterns
├── tokens
└── types
6 directories
每次运行panda codegen
都会重新静态分析代码,并生成:
css
,轻量的JS runtime用于合并class name
javascript
import { createCss, createMergeCss, hypenateProperty, withoutSpace } from '../helpers.mjs';
import { sortConditions, finalizeConditions } from './conditions.mjs';
const utilities = "aspectRatio:aspect,boxDecorationBreak:decoration,zIndex:z,boxSizing:box,objectPosition:object,objectFit:object,overscrollBehavior:overscroll,overscrollBehaviorX:overscroll-x,overscrollBehaviorY:overscroll-y,position:pos/1,top:top,left:left,insetInline:inset-x,insetBlock:inset-y,inset:inset,insetBlockEnd:inset-b,insetBlockStart:inset-t,insetInlineEnd:end/insetEnd/1,insetInlineStart:start/insetStart/1,right:right,bottom:bottom,insetX:inset-x,insetY:inset-y,float:float,visibility:vis,display:d,hideFrom:hide,hideBelow:show,flexBasis:basis,flex:flex,flexDirection:flex/flexDir,flexGrow:grow,flexShrink:shrink,gridTemplateColumns:grid-cols,gridTemplateRows:grid-rows,gridColumn:col-span,gridRow:row-span,gridColumnStart:col-start,gridColumnEnd:col-end,gridAutoFlow:grid-flow,gridAutoColumns:auto-cols,gridAutoRows:auto-rows,gap:gap,gridGap:gap,gridRowGap:gap-x,gridColumnGap:gap-y,rowGap:gap-x,columnGap:gap-y,justifyContent:justify,alignContent:content,alignItems:items,alignSelf:self,padding:p/1,paddingLeft:pl/1,paddingRight:pr/1,paddingTop:pt/1,paddingBottom:pb/1,paddingBlock:py/1/paddingY,paddingBlockEnd:pb,paddingBlockStart:pt,paddingInline:px/paddingX/1,paddingInlineEnd:pe/1/paddingEnd,paddingInlineStart:ps/1/paddingStart,marginLeft:ml/1,marginRight:mr/1,marginTop:mt/1,marginBottom:mb/1,margin:m/1,marginBlock:my/1/marginY,marginBlockEnd:mb,marginBlockStart:mt,marginInline:mx/1/marginX,marginInlineEnd:me/1/marginEnd,marginInlineStart:ms/1/marginStart,outlineWidth:ring/ringWidth,outlineColor:ring/ringColor,outline:ring/1,outlineOffset:ring/ringOffset,divideX:divide-x,divideY:divide-y,divideColor:divide,divideStyle:divide,width:w/1,inlineSize:w,minWidth:min-w/minW,minInlineSize:min-w,maxWidth:max-w/maxW,maxInlineSize:max-w,height:h/1,blockSize:h,minHeight:min-h/minH,minBlockSize:min-h,maxHeight:max-h/maxH,maxBlockSize:max-b,color:text,fontFamily:font,fontSize:fs,fontWeight:font,fontSmoothing:smoothing,fontVariantNumeric:numeric,letterSpacing:tracking,lineHeight:leading,textAlign:text,textDecoration:text-decor,textDecorationColor:text-decor,textEmphasisColor:text-emphasis,textDecorationStyle:decoration,textDecorationThickness:decoration,textUnderlineOffset:underline-offset,textTransform:text,textIndent:indent,textShadow:text-shadow,textOverflow:text,verticalAlign:align,wordBreak:break,textWrap:text,truncate:truncate,lineClamp:clamp,listStyleType:list,listStylePosition:list,listStyleImage:list-img,backgroundPosition:bg/bgPosition,backgroundPositionX:bg-x/bgPositionX,backgroundPositionY:bg-y/bgPositionY,backgroundAttachment:bg/bgAttachment,backgroundClip:bg-clip/bgClip,background:bg/1,backgroundColor:bg/bgColor,backgroundOrigin:bg-origin/bgOrigin,backgroundImage:bg-img/bgImage,backgroundRepeat:bg-repeat/bgRepeat,backgroundBlendMode:bg-blend/bgBlendMode,backgroundSize:bg/bgSize,backgroundGradient:bg-gradient/bgGradient,textGradient:text-gradient,gradientFrom:from,gradientTo:to,gradientVia:via,borderRadius:rounded/1,borderTopLeftRadius:rounded-tl/roundedTopLeft,borderTopRightRadius:rounded-tr/roundedTopRight,borderBottomRightRadius:rounded-br/roundedBottomRight,borderBottomLeftRadius:rounded-bl/roundedBottomLeft,borderTopRadius:rounded-t/roundedTop,borderRightRadius:rounded-r/roundedRight,borderBottomRadius:rounded-b/roundedBottom,borderLeftRadius:rounded-l/roundedLeft,borderStartStartRadius:rounded-ss/roundedStartStart,borderStartEndRadius:rounded-se/roundedStartEnd,borderStartRadius:rounded-s/roundedStart,borderEndStartRadius:rounded-es/roundedEndStart,borderEndEndRadius:rounded-ee/roundedEndEnd,borderEndRadius:rounded-e/roundedEnd,border:border,borderColor:border,borderInline:border-x/borderX,borderInlineWidth:border-x/borderXWidth,borderInlineColor:border-x/borderXColor,borderBlock:border-y/borderY,borderBlockWidth:border-y/borderYWidth,borderBlockColor:border-y/borderYColor,borderLeft:border-l,borderLeftColor:border-l,borderInlineStart:border-s/borderStart,borderInlineStartWidth:border-s/borderStartWidth,borderInlineStartColor:border-s/borderStartColor,borderRight:border-r,borderRightColor:border-r,borderInlineEnd:border-e/borderEnd,borderInlineEndWidth:border-e/borderEndWidth,borderInlineEndColor:border-e/borderEndColor,borderTop:border-t,borderTopColor:border-t,borderBottom:border-b,borderBottomColor:border-b,borderBlockEnd:border-be,borderBlockEndColor:border-be,borderBlockStart:border-bs,borderBlockStartColor:border-bs,boxShadow:shadow/1,boxShadowColor:shadow/shadowColor,mixBlendMode:mix-blend,filter:filter,brightness:brightness,contrast:contrast,grayscale:grayscale,hueRotate:hue-rotate,invert:invert,saturate:saturate,sepia:sepia,dropShadow:drop-shadow,blur:blur,backdropFilter:backdrop,backdropBlur:backdrop-blur,backdropBrightness:backdrop-brightness,backdropContrast:backdrop-contrast,backdropGrayscale:backdrop-grayscale,backdropHueRotate:backdrop-hue-rotate,backdropInvert:backdrop-invert,backdropOpacity:backdrop-opacity,backdropSaturate:backdrop-saturate,backdropSepia:backdrop-sepia,borderCollapse:border,borderSpacing:border-spacing,borderSpacingX:border-spacing-x,borderSpacingY:border-spacing-y,tableLayout:table,transitionTimingFunction:ease,transitionDelay:delay,transitionDuration:duration,transitionProperty:transition-prop,transition:transition,animation:animation,animationDelay:animation-delay,transformOrigin:origin,scale:scale,scaleX:scale-x,scaleY:scale-y,translate:translate,translateX:translate-x/x,translateY:translate-y/y,accentColor:accent,caretColor:caret,scrollBehavior:scroll,scrollbar:scrollbar,scrollMargin:scroll-m,scrollMarginX:scroll-mx,scrollMarginY:scroll-my,scrollMarginLeft:scroll-ml,scrollMarginRight:scroll-mr,scrollMarginTop:scroll-mt,scrollMarginBottom:scroll-mb,scrollMarginBlock:scroll-my,scrollMarginBlockEnd:scroll-mb,scrollMarginBlockStart:scroll-mt,scrollMarginInline:scroll-mx,scrollMarginInlineEnd:scroll-me,scrollMarginInlineStart:scroll-ms,scrollPadding:scroll-p,scrollPaddingBlock:scroll-pb,scrollPaddingBlockStart:scroll-pt,scrollPaddingBlockEnd:scroll-pb,scrollPaddingInline:scroll-px,scrollPaddingInlineEnd:scroll-pe,scrollPaddingInlineStart:scroll-ps,scrollPaddingX:scroll-px,scrollPaddingY:scroll-py,scrollPaddingLeft:scroll-pl,scrollPaddingRight:scroll-pr,scrollPaddingTop:scroll-pt,scrollPaddingBottom:scroll-pb,scrollSnapAlign:snap,scrollSnapStop:snap,scrollSnapType:snap,scrollSnapStrictness:strictness,scrollSnapMargin:snap-m,scrollSnapMarginTop:snap-mt,scrollSnapMarginBottom:snap-mb,scrollSnapMarginLeft:snap-ml,scrollSnapMarginRight:snap-mr,touchAction:touch,userSelect:select,fill:fill,stroke:stroke,srOnly:sr,debug:debug,appearance:appearance,backfaceVisibility:backface,clipPath:clip-path,hyphens:hyphens,mask:mask,maskImage:mask-image,maskSize:mask-size,textSizeAdjust:text-size-adjust,textStyle:textStyle"
const classNames = new Map()
const shorthands = new Map()
utilities.split(',').forEach((utility) => {
const [prop, meta] = utility.split(':')
const [className, ...shorthandList] = meta.split('/')
classNames.set(prop, className)
if (shorthandList.length) {
shorthandList.forEach((shorthand) => {
shorthands.set(shorthand === '1' ? className : shorthand, prop)
})
}
})
const resolveShorthand = (prop) => shorthands.get(prop) || prop
const context = {
conditions: {
shift: sortConditions,
finalize: finalizeConditions,
breakpoints: { keys: ["base","sm","md","lg","xl","2xl"] }
},
utility: {
transform: (prop, value) => {
const key = resolveShorthand(prop)
const propKey = classNames.get(key) || hypenateProperty(key)
return { className: `${propKey}_${withoutSpace(value)}` }
},
hasShorthand: true,
resolveShorthand: resolveShorthand,
}
}
export const css = createCss(context)
css.raw = (styles) => styles
export const { mergeCss, assignCss } = createMergeCss(context)
javascript
var sanitize = (value) => typeof value === "string" ? value.replaceAll(/[\n\s]+/g, " ") : value;
function createCss(context) {
const { utility, hash, conditions: conds = fallbackCondition } = context;
const formatClassName = (str) => [utility.prefix, str].filter(Boolean).join("-");
const hashFn = (conditions, className) => {
let result;
if (hash) {
const baseArray = [...conds.finalize(conditions), className];
result = formatClassName(toHash(baseArray.join(":")));
} else {
const baseArray = [...conds.finalize(conditions), formatClassName(className)];
result = baseArray.join(":");
}
return result;
};
return (styleObject = {}) => {
const normalizedObject = normalizeStyleObject(styleObject, context);
const classNames = /* @__PURE__ */ new Set();
walkObject(normalizedObject, (value, paths) => {
const important = isImportant(value);
if (value == null)
return;
const [prop, ...allConditions] = conds.shift(paths);
const conditions = filterBaseConditions(allConditions);
const transformed = utility.transform(prop, withoutImportant(sanitize(value)));
let className = hashFn(conditions, transformed.className);
if (important)
className = `${className}!`;
classNames.add(className);
});
return Array.from(classNames).join(" ");
};
}
css
方法没有自带缓存,在React Render中直接使用会重复计算class name。如果对性能敏感可以用useMemo
或者放在组件外面调用先计算出class name。
pattern
,简单的transform方法tokens
,css变量;css keyframe;token方法获取theme token值,包含了所有token的key的常量,一般用于pandaCSS以外,比如style属性或者和其他CSS-in-JS混用
jsx
const tokens = {
// ...
"blurs.sm": {
"value": "4px",
"variable": "var(--blurs-sm)"
},
// ...
}
types
, 所有的类型定义,输出到单独的.d.ts
jsx
,一些内置的Styled React组件
CSS
在next.js 13中,生成的css位于.next/static/css/app/layout.css
css
@layer utilities {
.underline-offset_4px {
text-underline-offset: 4px
}
.pl_4 {
padding-left: var(--spacing-4)
}
.pr_4 {
padding-right: var(--spacing-4)
}
.pt_2 {
padding-top: var(--spacing-2)
}
.pb_2 {
padding-bottom: var(--spacing-2)
}
.h_8 {
height: var(--sizes-8)
}
}
所有代码中通过pandaCSS运行时指定的样式都会生成一个atomic class。theme token则会全量输出为css vars。(如果只用到了部分,可以覆写theme来实现部分输出)
深入一点点
Cascade Layers
级联层中的规则级联在一起,让Web开发人员可以更好地控制级联。任何不在图层中的样式都被收集在一起,并放置在单个匿名图层中,该图层位于所有声明的图层之后,命名图层和匿名图层。这意味着在图层外部声明的任何样式都将覆盖在图层中声明的样式,无论其特异性如何。
去年3月,CSS规范正式推出的Cascade Layers让开发者可以用模块化来组织和为组件编写样式,同时避免"臭名昭著"的优先级问题(CSS specificity issue)。
浏览器通过优先级来判断哪些属性值与一个元素最为相关,从而在该元素上应用这些属性值。优先级是基于不同种类选择器组成的匹配规则。
无论选择器写的多花里胡哨,只要Layers在下面的都不会覆盖掉上面的(想象成画图软件里的图层),Specificity只会作用于Layer内,而不会作用在Layer之间。比如定义一个@layer app
在最上面,就可以不用担心和组件库的样式冲突
Panda CSS充分利用了这一现代特性,给予开发者更高的自由度。其中定义了5种Layers,从优先级高到低分别为
css
@layer reset, base, tokens, recipes, utilities
@layer utilities
:atomic class在这里,只有代码中声明了才会用到@layer recipes
:Config Recipe中的样式,比如自定义组件和它的variant@layer tokens
:theme token的css 变量@layer base
:少量的全局样式@layer reset
:覆写HTML的默认样式
PostCSS
PostCSS是一个用JS来处理CSS的平台,支持大量的插件
Panda CSS本质上是一个PostCSS的插件,fork了postcss-js,将CSS-in-JS静态分析编译成CSS。因此Panda CSS不会对源代码做任何改写,不像vanilla-extract这些方案那样会在build time evaluate并改写成实际class name。Panda CSS中所有class name都是在上面的css
方法(核心就是获取Key-Value和字符串拼接)运行时生成的。
因此Qwik团队的开发者尝试用Rust在build time来转换PandaCSS的css
方法调用为实际class name,从而可以节省掉5KB运行时开销。
静态分析也意味着Panda CSS不支持使用runtime值或引用值,比如React State,只能使用常量
jsx
// ❌ Avoid: Runtime value (without config.`staticCss`)
const Button = () => {
const [color, setColor] = useState('red.300')
return <styled.button color={color} />
}
// ❌ Avoid: Referenced value (not statically analyzable or from another file)
<styled.div color={getColor()} />
<styled.div color={colors[getColorName()]} />
<styled.div color={colors[colorFromAnotherFile]} />
const CustomCircle = (props) => {
const { circleSize = '3' } = props
return (
<Circle
// ❌ Avoid: Panda can't determine the value of circleSize at build-time
size={circleSize}
/>
)
}
相比之下vanilla-extract通过将CSS-in-JS单独放在.css.ts
文件中区分编译时和运行时,可以evaluate整个文件,所以能够支持引用值、跨文件import,函数计算值等用法。
当然,PostCSS插件也有好处。Panda CSS能够以相对低的成本接入更多的框架、bundler、测试环境,毕竟tailwind CSS也只是PostCSS插件。而其他重编译时的方案就难得多了,一般也就提供webpack接入,很少能支持上新一点的esbuild和swc,长远来看年久失修的概率更高。
题外话
年初的时候shadcn的一条Tailwind CSS的推文引起了较大的讨论,喜欢和讨厌Tailwind CSS的战成一团
主要争论点无非以下几种:
- 可读性太差(如上图shadcn/ui的真实例子😆)
- 不是真的CSS(指要多学一层抽象)
但不可否认的是Tailwind CSS确实越来越火,下载量从22年初至今翻了5倍
针对上面的两个缺点,其实PandaCSS这类现代CSS-in-JS/TS已经解决的很好了
- 写的依旧是CSS(不过是Object形式)
- Object形式可读性远高Tailwind CSS的字符串
- 强约束和类型安全
- 无需编辑器插件的自动补全
- 可以使用Prettier等来格式化
目前Panda CSS最大的缺点就是仅作为了PostCSS的插件,zero-runtime不够彻底,因此MUI团队调研之后也放弃了使用PandaCSS,转而自己开发一套新的方案(RFC)。
好在PostCSS已经有了Rust替代者Lightning CSS,且上个月的vite 4.4已经实验性支持,或许不久的未来就会有完全使用Rust或者Bun(Zig)实现AST分析、转换的真zero-runtime CSS-in-JS了。