Panda CSS 🐼,更现代的CSS-in-JS方案

前言

去年11月份的时候做过一次Atomic CSS-in-JS的调研分享:迈向Atomic CSS-in-JS:从运行时回到编译时,彼时的社区已经涌现出styleX/style9Stitchesvanilla-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组件库面临的困境,主要可以分为三类问题:

  1. 以往各个组件库大规模使用的runtime CSS-in-JS方案(styled-component,emotion)在Server Component中无法使用,以至于每个组件库总有一个Pined Issue是和这个相关
  2. 支持更全面的主题系统、开放更多的样式定制
  3. 如何支持更复杂的组件同时保持API的直观和易于维护

在其中最为困难的就是runtime CSS-in-JS的替换上,ChakraUI团队决定开发一个新的zero-runtime CSS-in-JS方案,也就是本文要介绍的Panda CSS,经过8个多月的开发后终于在今年6月正式推出,目前仍在非常活跃地迭代中。

Panda CSS

https://panda-css.com/

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 cliweb playground

Pandas CSS实现shadcn/ui Card Demo
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开发人员可以更好地控制级联。任何不在图层中的样式都被收集在一起,并放置在单个匿名图层中,该图层位于所有声明的图层之后,命名图层和匿名图层。这意味着在图层外部声明的任何样式都将覆盖在图层中声明的样式,无论其特异性如何。

--MDNMDN Learn CSS

去年3月,CSS规范正式推出的Cascade Layers让开发者可以用模块化来组织和为组件编写样式,同时避免"臭名昭著"的优先级问题(CSS specificity issue)。

浏览器通过优先级来判断哪些属性值与一个元素最为相关,从而在该元素上应用这些属性值。优先级是基于不同种类选择器组成的匹配规则。

无论选择器写的多花里胡哨,只要Layers在下面的都不会覆盖掉上面的(想象成画图软件里的图层),Specificity只会作用于Layer内,而不会作用在Layer之间。比如定义一个@layer app在最上面,就可以不用担心和组件库的样式冲突

Panda CSS充分利用了这一现代特性,给予开发者更高的自由度。其中定义了5种Layers,从优先级高到低分别为

css 复制代码
@layer reset, base, tokens, recipes, utilities
  1. @layer utilities:atomic class在这里,只有代码中声明了才会用到
  2. @layer recipes:Config Recipe中的样式,比如自定义组件和它的variant
  3. @layer tokens:theme token的css 变量
  4. @layer base:少量的全局样式
  5. @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和字符串拼接)运行时生成的。

github.com/chakra-ui/p...

因此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了。

相关推荐
GISer_Jing1 小时前
前端面试通关:Cesium+Three+React优化+TypeScript实战+ECharts性能方案
前端·react.js·面试
落霞的思绪2 小时前
CSS复习
前端·css
咖啡の猫4 小时前
Shell脚本-for循环应用案例
前端·chrome
百万蹄蹄向前冲7 小时前
Trae分析Phaser.js游戏《洋葱头捡星星》
前端·游戏开发·trae
朝阳5817 小时前
在浏览器端使用 xml2js 遇到的报错及解决方法
前端
GIS之路7 小时前
GeoTools 读取影像元数据
前端
ssshooter8 小时前
VSCode 自带的 TS 版本可能跟项目TS 版本不一样
前端·面试·typescript
你的人类朋友8 小时前
【Node.js】什么是Node.js
javascript·后端·node.js
Jerry8 小时前
Jetpack Compose 中的状态
前端
dae bal9 小时前
关于RSA和AES加密
前端·vue.js