接下来着重讲解关于这两部的具体代码和相关变量的含义。

第一部分中的 button 相关的样式如下:

import { c, cB, cE, cM, cNotM } from "../../../_utils/cssr";

import fadeInWidthExpandTransition from "../../../_styles/transitions/fade-in-width-expand.cssr";

import iconSwitchTransition from "../../../_styles/transitions/icon-switch.cssr";



export default c([

  cB(

    "button",

    `

    font-weight: var(--font-weight);

    line-height: 1;

    font-family: inherit;

    padding: var(--padding);

    // .... 更多的定义

    `, [

    // border ,边框相关的样式类骨架

      cM("color", [

        cE("border", {

          borderColor: "var(--border-color)",

        }),

        cM("disabled", [

          cE("border", {

            borderColor: "var(--border-color-disabled)",

          }),

        ]),

        cNotM("disabled", [

          c("&:focus", [

            cE("state-border", {

              borderColor: "var(--border-color-focus)",

            }),

          ]),

          c("&:hover", [

            cE("state-border", {

              borderColor: "var(--border-color-hover)",

            }),

          ]),

          c("&:active", [

            cE("state-border", {

              borderColor: "var(--border-color-pressed)",

            }),

          ]),

          cM("pressed", [

            cE("state-border", {

              borderColor: "var(--border-color-pressed)",

            }),

          ]),

        ]),

      ]),

      // icon 相关的样式类骨架

      cE(

        "icon",

        `

      margin: var(--icon-margin);

      margin-left: 0;

      height: var(--icon-size);

      width: var(--icon-size);

      max-width: var(--icon-size);

      font-size: var(--icon-size);

      position: relative;

      flex-shrink: 0;

    `,

        [

          cB(

            "icon-slot",

            `

        height: var(--icon-size);

        width: var(--icon-size);

        position: absolute;

        left: 0;

        top: 50%;

        transform: translateY(-50%);

        display: flex;

      `,

            [

              iconSwitchTransition({

                top: "50%",

                originalTransform: "translateY(-50%)",

              }),

            ]

          ),

          fadeInWidthExpandTransition(),

        ]

      ),

      // content 子元素内容相关的样式类骨架

      cE(

        "content",

        `

      display: flex;

      align-items: center;

      flex-wrap: nowrap;

    `,

        [

          c("~", [

            cE("icon", {

              margin: "var(--icon-margin)",

              marginRight: 0,

            }),

          ]),

        ]

      ),

      // 更多的关于 backgroundColor、base-wave 点击反馈波纹,icon,content,block 相关的样式定义

    ],

    // 动画、过渡相关的样式类骨架

    c("@keyframes button-wave-spread", {

    from: {

      boxShadow: "0 0 0.5px 0 var(--ripple-color)",

    },

    to: {

      // don't use exact 5px since chrome will display the animation with glitches

      boxShadow: "0 0 0.5px 4.5px var(--ripple-color)",

    },

  }),

  c("@keyframes button-wave-opacity", {

    from: {

      opacity: "var(--wave-opacity)",

    },

    to: {

      opacity: 0,

    },

  }),

]);

上面的 CSS Render 相关的代码最终会产出类型下面的内容:

.n-button {

    font-weight: var(--font-weight);

    line-height: 1;

    font-family: inherit;

    padding: var(--padding);

    transition:

      color .3s var(--bezier),

      background-color .3s var(--bezier),

      opacity .3s var(--bezier),

      border-color .3s var(--bezier);

  

}



.n-button.n-button--color .n-button__border {

  border-color: var(--border-color);

}



.n-button.n-button--color.n-button--disabled .n-button__border {

  border-color: var(--border-color-disabled);

}



.n-button.n-button--color:not(.n-button--disabled):focus .n-button__state-border {

  border-color: var(--border-color-focus);

}





.n-button .n-base-wave {



      pointer-events: none;

      top: 0;

      right: 0;

      bottom: 0;

      left: 0;

      animation-iteration-count: 1;

      animation-duration: var(--ripple-duration);

      animation-timing-function: var(--bezier-ease-out), var(--bezier-ease-out);

    

}



.n-button .n-base-wave.n-base-wave--active {

  z-index: 1;

  animation-name: button-wave-spread, button-wave-opacity;

}



.n-button .n-button__border, .n-button .n-button__state-border {



      position: absolute;

      left: 0;

      top: 0;

      right: 0;

      bottom: 0;

      border-radius: inherit;

      transition: border-color .3s var(--bezier);

      pointer-events: none;

    

}



.n-button .n-button__icon {



      margin: var(--icon-margin);

      margin-left: 0;

      height: var(--icon-size);

      width: var(--icon-size);

      max-width: var(--icon-size);

      font-size: var(--icon-size);

      position: relative;

      flex-shrink: 0;

    

}



.n-button .n-button__icon .n-icon-slot {



        height: var(--icon-size);

        width: var(--icon-size);

        position: absolute;

        left: 0;

        top: 50%;

        transform: translateY(-50%);

        display: flex;

      

}



.n-button .n-button__icon.fade-in-width-expand-transition-enter-active {



      overflow: hidden;

      transition:

        opacity .2s cubic-bezier(.4, 0, .2, 1) .1s,

        max-width .2s cubic-bezier(.4, 0, .2, 1),

        margin-left .2s cubic-bezier(.4, 0, .2, 1),

        margin-right .2s cubic-bezier(.4, 0, .2, 1);

    

}



.n-button .n-button__content {



      display: flex;

      align-items: center;

      flex-wrap: nowrap;

    

}



.n-button .n-button__content ~ .n-button__icon {

  margin: var(--icon-margin);

  margin-right: 0;

}



.n-button.n-button--block {



      display: flex;

      width: 100%;

    

}



.n-button.n-button--dashed .n-button__border, .n-button.n-button--dashed .n-button__state-border {

  border-style: dashed !important;

}



.n-button.n-button--disabled {

  cursor: not-allowed;

  opacity: var(--opacity-disabled);

}



@keyframes button-wave-spread {

  from {

    box-shadow: 0 0 0.5px 0 var(--ripple-color);

  }

  to {

    box-shadow: 0 0 0.5px 4.5px var(--ripple-color);

  }

}



@keyframes button-wave-opacity {

  from {

    opacity: var(--wave-opacity);

  }

  to {

    opacity: 0;

  }

}

可以看到 button 相关的样式使用 BEM 命名风格处理了各种场景:

  • border 与 state-border ,关于 disabled、pressed、hover 、active 等状态下的样式
.n-button.n-button--color:not(.n-button--disabled):focus .n-button__state-border {

  border-color: var(--border-color-focus);

}
  • 按钮被点击时出现波纹的样式 .n-button .n-base-wave
  • 按钮中的 icon 相关的样式 .n-button .n-button__icon
  • 按钮中的文本等内容的样式 .n-button .n-button__content

同时可以看到在样式中为各种属性预留了对应的 CSS Variables,包括 box-shadow 的 --ripple-color ,icon 宽高的 --icon-size ,过渡动画 transition--bezier ,这些变量是为后面定制各种样式、主题留出空间。

即在设计一个组件库的样式系统时,组件相关的样式模板使用 BEM 风格提前就定义好,然后对于需要定制的主题相关的变量等通过 CSS Variables 来进行个性化的更改,达到定制主题的效果。

挂载全局样式

全局相关的样式主要是一些简单的基础配置,代码如下:

import { c } from "../../_utils/cssr";

import commonVariables from "../common/_common";



export default c(

  "body",

  `

  margin: 0;

  font-size: ${commonVariables.fontSize};

  font-family: ${commonVariables.fontFamily};

  line-height: ${commonVariables.lineHeight};

  -webkit-text-size-adjust: 100%;

`,

  [

    c(

      "input",

      `

    font-family: inherit;

    font-size: inherit;

  `

    ),

  ]

);

主要为 marginfont-sizefont-familyline-height 等相关的内容,是为了兼容浏览器所必要进行的 CSS 代码标准化,比较典型的有 Normalize.css: Make browsers render all elements more consistently.

其中 commonVariables 如下:

export default {

  fontFamily:

    'v-sans, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"',

  fontFamilyMono: "v-mono, SFMono-Regular, Menlo, Consolas, Courier, monospace",



  fontWeight: "400",

  fontWeightStrong: "500",



  cubicBezierEaseInOut: "cubic-bezier(.4, 0, .2, 1)",

  cubicBezierEaseOut: "cubic-bezier(0, 0, .2, 1)",

  cubicBezierEaseIn: "cubic-bezier(.4, 0, 1, 1)",



  borderRadius: "3px",

  borderRadiusSmall: "2px",



  fontSize: "14px",

  fontSizeTiny: "12px",

  fontSizeSmall: "14px",

  fontSizeMedium: "14px",

  fontSizeLarge: "15px",

  fontSizeHuge: "16px",



  lineHeight: "1.6",



  heightTiny: "22px",

  heightSmall: "28px",

  heightMedium: "34px",

  heightLarge: "40px",

  heightHuge: "46px",



  transformDebounceScale: "scale(1)",

};

上述的通用变量是 UI 组件库向上构建的一些最基础的 “原材料”,也是默认不建议修改的、业界的最佳实践,如定义 size 有 5 类,分别为 tinysmallmediumlargehuge ,定义字体、代码字体 等。

定义与注册 CSS Variables

这块的主要代码为:

const NConfigProvider = inject(configProviderInjectionKey, null);

const mergedThemeRef = computed(() => {

const {

  theme: { common: selfCommon, self, peers = {} } = {},

  themeOverrides: selfOverrides = {},

  builtinThemeOverrides: builtinOverrides = {},

} = props;

const { common: selfCommonOverrides, peers: peersOverrides } =

  selfOverrides;

const {

  common: globalCommon = undefined,

  [resolveId]: {

    common: globalSelfCommon = undefined,

    self: globalSelf = undefined,

    peers: globalPeers = {},

  } = {},

} = NConfigProvider?.mergedThemeRef.value || {};

const {

  common: globalCommonOverrides = undefined,

  [resolveId]: globalSelfOverrides = {},

} = NConfigProvider?.mergedThemeOverridesRef.value || {};

const {

  common: globalSelfCommonOverrides,

  peers: globalPeersOverrides = {},

} = globalSelfOverrides;

const mergedCommon = merge(

  {},

  selfCommon || globalSelfCommon || globalCommon || defaultTheme.common,

  globalCommonOverrides,

  globalSelfCommonOverrides,

  selfCommonOverrides

);



const mergedSelf = merge(

  // {}, executed every time, no need for empty obj

  (self || globalSelf || defaultTheme.self)?.(mergedCommon),

  builtinOverrides,

  globalSelfOverrides,

  selfOverrides

);

return {

  common: mergedCommon,

  self: mergedSelf,

  peers: merge({}, defaultTheme.peers, globalPeers, peers),

  peerOverrides: merge({}, globalPeersOverrides, peersOverrides),

};

首先是从通过 inject 拿到 configProviderInjectionKey 相关的内容,其中 configProviderInjectionKey 相关内容定义在如下:

provide(configProviderInjectionKey, {

  mergedRtlRef,

  mergedIconsRef,

  mergedComponentPropsRef,

  mergedBorderedRef,

  mergedNamespaceRef,

  mergedClsPrefixRef,

  mergedLocaleRef: computed(() => {

    // xxx

  }),

  mergedDateLocaleRef: computed(() => {

    // xxx

  }),

  mergedHljsRef: computed(() => {

    // ...

  }),

  mergedThemeRef,

  mergedThemeOverridesRef

})

可以看到包含 rtl、icon、border、namespace、clsPrefix、locale(国际化)、date、theme、themeOverrides 等几乎所有的配置,而这里主要是想拿到主题相关的配置:

  • mergedThemeRef :可调整的主题,如
<template>

  <n-config-provider :theme="darkTheme">

    <app />

  </n-config-provider>

</template>



<script>

  import { darkTheme } from 'naive-ui'



  export default {

    setup() {

      return {

        darkTheme

      }

    }

  }

</script>
  • mergedThemeOverridesRef :可调整的主题变量,如
const themeOverrides = {

    common: {

      primaryColor: '#FF0000'

    },

    Button: {

      textColor: '#FF0000'

      backgroundColor: '#FFF000',

    },

    Select: {

      peers: {

        InternalSelection: {

          textColor: '#FF0000'

        }

      }

    }

    // ...

  }

上述的这两者有主要包含全局 common 相关的,以及 Buttoncommon 相关的统一变量、self 相关的 Button 自定义的一些变量,以及 Button 在与其他组件使用时涉及相关限制的 peers 变量。

useTheme 钩子函数中返回 themeRef 之后,themeRef 相关的内容会拿来组装 Button 涉及到的各种样式,主要从以下四个方向进行处理:

  • fontProps
  • colorProps
  • borderProps
  • sizeProps
cssVars: computed(() => {

  // fontProps

  // colorProps

  // borderProps

  // sizeProps

  

  return {

  // 处理 动画过渡函数、透明度相关的变量

    "--bezier": cubicBezierEaseInOut,

      "--bezier-ease-out": cubicBezierEaseOut,

      "--ripple-duration": rippleDuration,

      "--opacity-disabled": opacityDisabled,

      "--wave-opacity": waveOpacity,

      // 处理字体、颜色、边框、大小相关的变量

    ...fontProps,

    ...colorProps,

    ...borderProps,

    ...sizeProps,

  };

});

fontProps 相关代码如下:

const theme = themeRef.value;

const {

  self,

} = theme;

const {

  rippleDuration,

  opacityDisabled,

  fontWeightText,

  fontWeighGhost,

  fontWeight,

} = self;

const { dashed, type, ghost, text, color, round, circle } = props;

        // font

const fontProps = {

  fontWeight: text

    ? fontWeightText

    : ghost

    ? fontWeighGhost

    : fontWeight,

};

主要判断当 Button 以 text 节点进行展示时、以透明背景进行展示时、标准状态下的字体相关的 CSS 变量与值。

colorProps 相关代码如下

let colorProps = {

  "--color": "initial",

  "--color-hover": "initial",

  "--color-pressed": "initial",

  "--color-focus": "initial",

  "--color-disabled": "initial",

  "--ripple-color": "initial",

  "--text-color": "initial",

  "--text-color-hover": "initial",

  "--text-color-pressed": "initial",

  "--text-color-focus": "initial",

  "--text-color-disabled": "initial",

};



if (text) {

  const { depth } = props;

  const textColor =

    color ||

    (type === "default" && depth !== undefined

      ? self[createKey("textColorTextDepth", String(depth))]

      : self[createKey("textColorText", type)]);

  colorProps = {

    "--color": "#0000",

    "--color-hover": "#0000",

    "--color-pressed": "#0000",

    "--color-focus": "#0000",

    "--color-disabled": "#0000",

    "--ripple-color": "#0000",

    "--text-color": textColor,

    "--text-color-hover": color

      ? createHoverColor(color)

      : self[createKey("textColorTextHover", type)],

    "--text-color-pressed": color

      ? createPressedColor(color)

      : self[createKey("textColorTextPressed", type)],

    "--text-color-focus": color

      ? createHoverColor(color)

      : self[createKey("textColorTextHover", type)],

    "--text-color-disabled":

      color || self[createKey("textColorTextDisabled", type)],

  };

} else if (ghost || dashed) {

  colorProps = {

    "--color": "#0000",

    "--color-hover": "#0000",

    "--color-pressed": "#0000",

    "--color-focus": "#0000",

    "--color-disabled": "#0000",

    "--ripple-color": color || self[createKey("rippleColor", type)],

    "--text-color": color || self[createKey("textColorGhost", type)],

    "--text-color-hover": color

      ? createHoverColor(color)

      : self[createKey("textColorGhostHover", type)],

    "--text-color-pressed": color

      ? createPressedColor(color)

      : self[createKey("textColorGhostPressed", type)],

    "--text-color-focus": color

      ? createHoverColor(color)

      : self[createKey("textColorGhostHover", type)],

    "--text-color-disabled":

      color || self[createKey("textColorGhostDisabled", type)],

  };

} else {

  colorProps = {

    "--color": color || self[createKey("color", type)],

    "--color-hover": color

      ? createHoverColor(color)

      : self[createKey("colorHover", type)],

    "--color-pressed": color

      ? createPressedColor(color)

      : self[createKey("colorPressed", type)],

    "--color-focus": color

      ? createHoverColor(color)

      : self[createKey("colorFocus", type)],

    "--color-disabled": color || self[createKey("colorDisabled", type)],

    "--ripple-color": color || self[createKey("rippleColor", type)],

    "--text-color": color

      ? self.textColorPrimary

      : self[createKey("textColor", type)],

    "--text-color-hover": color

      ? self.textColorHoverPrimary

      : self[createKey("textColorHover", type)],

    "--text-color-pressed": color

      ? self.textColorPressedPrimary

      : self[createKey("textColorPressed", type)],

    "--text-color-focus": color

      ? self.textColorFocusPrimary

      : self[createKey("textColorFocus", type)],

    "--text-color-disabled": color

      ? self.textColorDisabledPrimary

      : self[createKey("textColorDisabled", type)],

  };

}

主要处理在四种形式:普通、text 节点、ghost 背景透明、dashed 虚线形式下,对不同状态 标准 、pressedhoverfocusdisabled 等处理相关的 CSS 属性和值

borderProps 相关的代码如下:

let borderProps = {

  "--border": "initial",

  "--border-hover": "initial",

  "--border-pressed": "initial",

  "--border-focus": "initial",

  "--border-disabled": "initial",

};

if (text) {

  borderProps = {

    "--border": "none",

    "--border-hover": "none",

    "--border-pressed": "none",

    "--border-focus": "none",

    "--border-disabled": "none",

  };

} else {

  borderProps = {

    "--border": self[createKey("border", type)],

    "--border-hover": self[createKey("borderHover", type)],

    "--border-pressed": self[createKey("borderPressed", type)],

    "--border-focus": self[createKey("borderFocus", type)],

    "--border-disabled": self[createKey("borderDisabled", type)],

  };

}

主要处理在以 text 形式进行展示和普通形式展示下,五种不同状态 标准 、pressedhoverfocusdisabled等情况下的处理。

这里 borderProps 其实主要是定义整个 border 属性,而边框颜色相关的属性其实是通过 setup 里面的 customColorCssVars 进行定义的,代码如下:

customColorCssVars: computed(() => {

    const { color } = props;

    if (!color) return null;

    const hoverColor = createHoverColor(color);

    return {

      "--border-color": color,

      "--border-color-hover": hoverColor,

      "--border-color-pressed": createPressedColor(color),

      "--border-color-focus": hoverColor,

      "--border-color-disabled": color,

    };

  })

sizeProps 相关的代码如下:

const sizeProps = {

  "--width": circle && !text ? height : "initial",

  "--height": text ? "initial" : height,

  "--font-size": fontSize,

  "--padding": circle

    ? "initial"

    : text

    ? "initial"

    : round

    ? paddingRound

    : padding,

  "--icon-size": iconSize,

  "--icon-margin": iconMargin,

  "--border-radius": text

    ? "initial"

    : circle || round

    ? height

    : borderRadius,

};

主要处理 widthheightfont-sizepaddingiconborder 相关的大小内容,其中 margin 在挂载全局默认样式的时候进行了处理,默认为 0。

小结

通过上面三步走:

  1. 挂载 button 相关的样式类骨架,留出大量 CSS Variables 用于自定义样式
  2. 挂载全局默认样式
  3. 组装、定义相关的 CSS Variables 来填充样式类骨架

我们就成功应用 CSS Render、 BEM plugin、CSS Variables 完成了 Button 整体样式的设计,它既易于理解、还易于定制。

不过也值得注意的是,纵观上述组件中样式的处理逻辑,只定义在 setup 里,也少用生命周期相关的钩子,其实也可以看出 CSS Render 的主要使用场景:即事先将所有的情况都规范好,相关的 CSS Variables 都预设好,然后给出

必要的事件处理也不能少

Naive UI 主要提供了以下几类事件的处理:

  • mousedown : handleMouseDown
  • keyuphandleKeyUp
  • keydown: handleKeyDown
  • click : handleClick
  • blur : handleBlur

可以来分别看一下其中的代码:

handleMouseDown

const handleMouseDown = (e) => {

  e.preventDefault();

  if (props.disabled) {

    return;

  }

  if (mergedFocusableRef.value) {

    selfRef.value?.focus({ preventScroll: true });

  }

};

主要处理 disabled 情况下不响应、以及如果可以 focus 情况下,调用 selfRef 进行 focus,并激活对应的样式。

handleKeyUp

const handleKeyUp = (e) => {

  switch (e.code) {

    case "Enter":

    case "NumpadEnter":

      if (!props.keyboard) {

        e.preventDefault();

        return;

      }

      enterPressedRef.value = false;

      void nextTick(() => {

        if (!props.disabled) {

          selfRef.value?.click();

        }

      });

  }

};

主要处理 EnterNumpadEnter 键,判断是否支持键盘处理,并在合适的情况下激活按钮点击。

handleKeyDown

const handleKeyDown = (e) => {

  switch (e.code) {

    case "Enter":

    case "NumpadEnter":

      if (!props.keyboard) return;

      e.preventDefault();

      enterPressedRef.value = true;

  }

};

主要处理 EnterNumpadEnter 键,判断是否支持键盘处理,并在合适的情况下更新 enterPressedRef 的值,标志当前是 keydown 过。

handleClick

const handleClick = (e) => {

  if (!props.disabled) {

    const { onClick } = props;

    if (onClick) call(onClick, e);

    if (!props.text) {

      const { value } = waveRef;

      if (value) {

        value.play();

      }

    }

  }

};

根据状态调用对应的点击处理函数,以及非 text 节点下播放按钮的点击波纹动效。

handleBlur

const handleBlur = () => {

  enterPressedRef.value = false;

};

更新 enterPressedRef 的值,标志当前是 blur 了。

总结与展望

本文通过一层层、源码级剖析了 Naive UI 的 Button 完整过程,可以发现对于组件库这个领域来说,绝大部分的构思都是花在如何设计可扩展的样式系统上,从 Ant Design、Element UI 使用 Less 来组织样式系统,再到 Material Design 使用 CSS-in-JS,如 styled-components 来组织样式系统,再到现在 Naive UI 使用 CSS Render 来组织样式系统,虽然组织样式系统的形式繁多,但实际上就我理解而言,在设计样式类、对应的样式、样式的扩展和主题定制上应该大体保持相似。

如果你能通过这篇杂乱的文章理解了 Button 运行的整个过程,还保持着对 Naive UI 整体的源码、工程化方向建设的兴趣,你完全可以按照这个逻辑去理解其他组件的设计原理,正如我在开始放的那张图一样,你了解整体代码的过程中会感觉越来越简单:

了解优秀库的源码设计、研读大牛的源码可以帮助我们了解业界最佳实践、优秀的设计思想和改进编写代码的方式,成为更加优秀的开发者,你我共勉💪!

参考资料

/感谢支持/

以上便是本次分享的全部内容,希望对你有所帮助ღ( ´・ᴗ・` )

喜欢的话别忘了分享、点赞、收藏三连哦~

欢迎关注公众号 程序员巴士,来自字节、虾皮、招银的三端兄弟,分享编程经验、技术干货与职业规划,帮助你少走弯路进大厂。