接下来着重讲解关于这两部的具体代码和相关变量的含义。
第一部分中的 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;
`
),
]
);
主要为 margin
、font-size
、font-family
、line-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 类,分别为 tiny
、small
、medium
、large
、huge
,定义字体、代码字体 等。
定义与注册 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
相关的,以及 Button
中 common
相关的统一变量、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 虚线形式下,对不同状态 标准 、pressed
、hover
、focus
、 disabled
等处理相关的 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 形式进行展示和普通形式展示下,五种不同状态 标准 、pressed
、hover
、focus
、disabled
等情况下的处理。
这里 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,
};
主要处理 width
、height
、font-size
、padding
、icon
、border
相关的大小内容,其中 margin
在挂载全局默认样式的时候进行了处理,默认为 0。
小结
通过上面三步走:
- 挂载 button 相关的样式类骨架,留出大量 CSS Variables 用于自定义样式
- 挂载全局默认样式
- 组装、定义相关的 CSS Variables 来填充样式类骨架
我们就成功应用 CSS Render、 BEM plugin、CSS Variables 完成了 Button 整体样式的设计,它既易于理解、还易于定制。
不过也值得注意的是,纵观上述组件中样式的处理逻辑,只定义在 setup
里,也少用生命周期相关的钩子,其实也可以看出 CSS Render 的主要使用场景:即事先将所有的情况都规范好,相关的 CSS Variables 都预设好,然后给出
必要的事件处理也不能少
Naive UI 主要提供了以下几类事件的处理:
mousedown
:handleMouseDown
keyup
:handleKeyUp
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();
}
});
}
};
主要处理 Enter
、NumpadEnter
键,判断是否支持键盘处理,并在合适的情况下激活按钮点击。
handleKeyDown
:
const handleKeyDown = (e) => {
switch (e.code) {
case "Enter":
case "NumpadEnter":
if (!props.keyboard) return;
e.preventDefault();
enterPressedRef.value = true;
}
};
主要处理 Enter
、NumpadEnter
键,判断是否支持键盘处理,并在合适的情况下更新 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 整体的源码、工程化方向建设的兴趣,你完全可以按照这个逻辑去理解其他组件的设计原理,正如我在开始放的那张图一样,你了解整体代码的过程中会感觉越来越简单:
了解优秀库的源码设计、研读大牛的源码可以帮助我们了解业界最佳实践、优秀的设计思想和改进编写代码的方式,成为更加优秀的开发者,你我共勉💪!
参考资料
- https://css-tricks.com/bem-101/
- https://www.smashingmagazine.com/2018/06/bem-for-beginners/
- http://getbem.com/introduction/
- https://necolas.github.io/normalize.css/
- https://www.naiveui.com/zh-CN/os-theme/components/button
- https://github.com/07akioni/css-render
- http://www.woshipm.com/ucd/4243012.html
- http://getbem.com/introduction/
/感谢支持/
以上便是本次分享的全部内容,希望对你有所帮助ღ( ´・ᴗ・` )
喜欢的话别忘了分享、点赞、收藏三连哦~
欢迎关注公众号 程序员巴士,来自字节、虾皮、招银的三端兄弟,分享编程经验、技术干货与职业规划,帮助你少走弯路进大厂。