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

相关推荐
NoloveisGod18 分钟前
Vue的基础使用
前端·javascript·vue.js
GISer_Jing20 分钟前
前端系统设计面试题(二)Javascript\Vue
前端·javascript·vue.js
海上彼尚1 小时前
实现3D热力图
前端·javascript·3d
杨过姑父1 小时前
org.springframework.context.support.ApplicationListenerDetector 详细介绍
java·前端·spring
理想不理想v1 小时前
使用JS实现文件流转换excel?
java·前端·javascript·css·vue.js·spring·面试
惜.己1 小时前
Jmeter中的配置原件(四)
java·前端·功能测试·jmeter·1024程序员节
EasyNTS1 小时前
无插件H5播放器EasyPlayer.js网页web无插件播放器vue和react详细介绍
前端·javascript·vue.js
老码沉思录1 小时前
React Native 全栈开发实战班 - 数据管理与状态之Zustand应用
javascript·react native·react.js
poloma1 小时前
五千字长文搞清楚 Blob File ArrayBuffer TypedArray 到底是什么
前端·javascript·ecmascript 6
老码沉思录1 小时前
React Native 全栈开发实战班 :数据管理与状态之React Hooks 基础
javascript·react native·react.js