阅读原文,体验更佳 👉 www.xiaojun.im/posts/2023-...
有很长一段时间,我都想在博客中集成拟物化的访问计数器用于增加一些趣味性,可是我这网站一开始是纯静态的,没用到任何数据库,所以后边不了了之,但最近我在博客中赋予了一些动态能力,这个想法随之也就又浮现了出来。
这个创意最初来自大佬 Joshua Comeau 开源的 react-retro-hit-counter,但后续我产生了自己的一些想法。
本教程不会涉及任何关于数据库的东西,我假设你已经准备了一个数字,不关心你的数据来源,这里就以1024
来做演示啦~
认识七段数码管
最初我只想实现一个类似计算器那种数字显示效果,它专业点叫做七段数码管(Seven-segment display),你可以在 wikipedia 上见到具体介绍,它一般长下边这种样子,地球人都见过:
这种形态还是比较好处理的,让我们先实现这个效果,最终要实现的霓虹灯效果也是以此为基础才行。
以下所有组件皆是用
tailwindcss
+react
编写,为了教程简练省略了部分代码,具体请阅读源码。
SevenSegmentDisplay 组件开发
开发之前让我们先分析该组件有哪些部分构成,它可以拆分为哪些子组件?
- 入口组件,也就是父组件,我们将它命名为
SevenSegmentDisplay.jsx
。 - 数字单元组件,我们将它命名为
Digit.jsx
。 - 数字单元的片段,每个数字有 7 个片段,我们将它命名为
Segment.jsx
。
SevenSegmentDisplay
作为入口组件,它负责接收所有的 props 配置,并且将传入的 value 分解为单个数字后传给 Digit 组件。
jsx
import React, { useMemo } from 'react'
import Digit from './Digit'
const SevenSegmentDisplay = props => {
const {
value, // 要展示的数字
minLength = 4, // 最小长度,不足则前补 0
digitSize = 40, // 数字大小(高度)
digitSpacing = digitSize / 4, // 数字之间的间距
segmentThickness = digitSize / 8, // 片段厚度
segmentSpacing = segmentThickness / 4, // 片段之间的缝隙大小
segmentActiveColor = '#adb0b8', // 片段激活时候的颜色
segmentInactiveColor = '#eff1f5', // 片段未激活时候的颜色
backgroundColor = '#eff1f5', // 背景色
padding = digitSize / 4, // 整个组件的 padding
glow = false, // 微光效果,其实就是阴影效果
} = props
// 将传入的 number 类型数字转为 string 并且根据 minLength 传入的长度进行前补 0
const paddedValue = useMemo(() => value.toString().padStart(minLength, '0'), [value, minLength])
// 将补 0 后的数字转为单个字符
const individualDigits = useMemo(() => paddedValue.split(''), [paddedValue])
return (
<div
className="inline-flex items-center justify-between"
style={{ padding, backgroundColor, gap: digitSpacing }}
>
{individualDigits.map((digit, idx) => (
<Digit
key={idx}
value={Number(digit)}
digitSize={digitSize}
segmentThickness={segmentThickness}
segmentSpacing={segmentSpacing}
segmentActiveColor={segmentActiveColor}
segmentInactiveColor={segmentInactiveColor}
glow={glow}
/>
))}
</div>
)
}
export default SevenSegmentDisplay
Digit
一个 Digit 包含 7 个 Segment,通过控制不同 Segment 的点亮状态,便可以模拟数字显示。
jsx
import React from 'react'
import Segment from './Segment'
// Segment 排布规则
//
// A
// F B
// G
// E C
// D
//
const segmentsByValue = {
[0]: ['a', 'b', 'c', 'd', 'e', 'f'],
[1]: ['b', 'c'],
[2]: ['a', 'b', 'g', 'e', 'd'],
[3]: ['a', 'b', 'g', 'c', 'd'],
[4]: ['f', 'g', 'b', 'c'],
[5]: ['a', 'f', 'g', 'c', 'd'],
[6]: ['a', 'f', 'g', 'c', 'd', 'e'],
[7]: ['a', 'b', 'c'],
[8]: ['a', 'b', 'c', 'd', 'e', 'f', 'g'],
[9]: ['a', 'b', 'c', 'd', 'f', 'g'],
}
const isSegmentActive = (segmentId, value) => segmentsByValue[value].includes(segmentId)
const segments = ['a', 'b', 'c', 'd', 'e', 'f', 'g']
const Digit = props => {
const { value, digitSize } = props
return (
<div className="relative w-6 h-8" style={{ width: digitSize * 0.5, height: digitSize }}>
{segments.map(segment => (
<Segment
key={segment}
segmentId={segment}
isActive={isSegmentActive(segment, value)}
segmentThickness={segmentThickness}
segmentSpacing={segmentSpacing}
segmentActiveColor={segmentActiveColor}
segmentInactiveColor={segmentInactiveColor}
glow={glow}
/>
))}
</div>
)
}
export default Digit
Segment
根据 segmentId
以及激活状态用 SVG 渲染出对应的 Segment,这是一个不复杂但是比较繁琐的工作 🤖。
jsx
import React, { useMemo } from 'react'
import color from 'color'
const Segment = props => {
const {
segmentId,
isActive,
digitSize,
segmentThickness,
segmentSpacing,
segmentActiveColor,
segmentInactiveColor,
glow,
} = props
const halfThickness = segmentThickness / 2
const width = digitSize * 0.5
const segments = {
a: {
top: 0,
left: 0,
},
b: {
top: 0,
left: width,
transform: 'rotate(90deg)',
transformOrigin: 'top left',
},
c: {
top: width * 2,
left: width,
transform: 'rotate(270deg) scaleY(-1)',
transformOrigin: 'top left',
},
d: {
top: width * 2,
left: width,
transform: 'rotate(180deg)',
transformOrigin: 'top left',
},
e: {
top: width * 2,
left: 0,
transform: 'rotate(270deg)',
transformOrigin: 'top left',
},
f: {
top: 0,
left: 0,
transform: 'rotate(90deg) scaleY(-1)',
transformOrigin: 'top left',
},
g: {
top: width - halfThickness,
left: 0,
},
}
// a, d
const path_ad = `
M ${segmentSpacing} ${0}
L ${width - segmentSpacing} 0
L ${width - segmentThickness - segmentSpacing} ${segmentThickness}
L ${segmentThickness + segmentSpacing} ${segmentThickness} Z
`
// b, c, e, f
const path_bcef = `
M ${segmentSpacing} ${0}
L ${width - halfThickness - segmentSpacing} 0
L ${width - segmentSpacing} ${halfThickness}
L ${width - halfThickness - segmentSpacing} ${segmentThickness}
L ${segmentThickness + segmentSpacing} ${segmentThickness} Z
`
// g
const path_g = `
M ${halfThickness + segmentSpacing} ${halfThickness}
L ${segmentThickness + segmentSpacing} 0
L ${width - segmentThickness - segmentSpacing} 0
L ${width - halfThickness - segmentSpacing} ${halfThickness}
L ${width - segmentThickness - segmentSpacing} ${segmentThickness}
L ${segmentThickness + segmentSpacing} ${segmentThickness} Z
`
const d = useMemo(
() =>
({
a: path_ad,
b: path_bcef,
c: path_bcef,
d: path_ad,
e: path_bcef,
f: path_bcef,
g: path_g,
}[segmentId]),
[path_ad, path_bcef, path_g, segmentId],
)
return (
<svg
className="absolute"
style={{
...segments[segmentId],
// 此处用到了 color 库,它可以很方便的对颜色进行调整。
filter:
isActive && glow
? `
drop-shadow(0 0 ${segmentThickness * 1.5}px ${color(segmentActiveColor).fade(0.25).hexa()})
`
: 'none',
zIndex: isActive ? 1 : 0,
}}
width={width}
height={segmentThickness}
viewBox={`0 0 ${width} ${segmentThickness}`}
xmlns="http://www.w3.org/2000/svg"
>
<path fill={isActive ? segmentActiveColor : segmentInactiveColor} d={d} />
</svg>
)
}
export default Segment
基础效果展示
到此,基础的显示组件已经完成了,让我们测试一下显示效果:
这是它的配置参数 👇
jsx
<SevenSegmentDisplay
value={1024}
minLength={6}
digitSize={18}
digitSpacing={4}
segmentThickness={2}
segmentSpacing={0.5}
segmentActiveColor="#ff5e00"
segmentInactiveColor="#161616"
backgroundColor="#0c0c0c"
padding="10px 14px"
glow
/>
粗略一看还不错,但这与霓虹效果还相差甚远,因为它看起来有些扁平,边缘过于"锐利",不够真实,所以接下来的目标是要把它变得更真实拟物一些。
如果你不需要霓虹效果,其实到这一步就足够了 😣,在我的网站中浅色模式也是使用的扁平风格,只有在切换到深色模式才会显示为拟物风格,算是一个小小的彩蛋吧。
霓虹灯效果
先分析一下为什么上边的样式看上去不够真实?
- 也许是曝光问题?真实世界中发光物本身相对于它的边缘来说看上去会更亮、更白,并且会稍微模糊一些。
- 很多情况下发光源做不到均匀照射到所有地方,所以会产生一片区域亮一片区域稍暗的效果,如果你留意过,很多透字键盘背光灯就是这样。
基于以上两点,接下来就想办法用 CSS 将它模拟的更真实一些。
让我们在 SevenSegmentDisplay
组件的基础上再封装一个 NeonHitCounter
组件。
模拟曝光过度效果
我们可以使用 CSS 中的 backdrop-filter
属性模拟过曝效果。
jsx
const NeonHitCounter = () => {
return (
<div className="relative">
<SevenSegmentDisplay
value={1024}
minLength={6}
digitSize={18}
digitSpacing={4}
segmentThickness={2}
segmentSpacing={0.5}
segmentActiveColor="#ff5e00"
segmentInactiveColor="#161616"
backgroundColor="#0c0c0c"
padding="10px 14px"
glow
/>
<div className="absolute inset-0 z-10 backdrop-blur-[0.25px] backdrop-brightness-150 pointer-events-none"></div>
</div>
)
}
export default NeonHitCounter
在上边代码中我们新建了一个 div 盖在 SevenSegmentDisplay
上边并使用 badckdrop-filter
使组件变亮变模糊,看上去效果已经好了不少。
模拟亮度不均匀效果
让我们将组件中间部分变得更亮,用于模拟亮度不均匀的效果。我们可以用 radial-gradient
创建一个白色径向渐变盖在它上边,然后通过 mix-blend-mode
来控制混合模式,这里用 overlay
比较合适。
有关
mix-blend-mode
的更多详细介绍你可以参考这篇文章。
jsx
const NeonHitCounter = () => {
return (
<div className="relative">
<SevenSegmentDisplay
value={1024}
minLength={6}
digitSize={18}
digitSpacing={4}
segmentThickness={2}
segmentSpacing={0.5}
segmentActiveColor="#ff5e00"
segmentInactiveColor="#161616"
backgroundColor="#0c0c0c"
padding="10px 14px"
glow
/>
<div
className="absolute inset-0 z-10 mix-blend-overlay pointer-events-none"
style={{
// 通过 luminosity 获取颜色相对亮度,如果一个颜色很亮,我们则减少亮度增益
background: `radial-gradient(rgba(255, 255, 255, ${
1 - color('#ff5e00').luminosity()
}), transparent 50%)`,
}}
></div>
<div className="absolute inset-0 z-10 backdrop-blur-[0.25px] backdrop-brightness-150 pointer-events-none"></div>
</div>
)
}
export default NeonHitCounter
在上边代码中又创建了一层 div,它利用 radial-gradient
+ mix-blend-mode: overlay
实现局部颜色增亮,并且根据颜色相对亮度动态判断增益比例,看起来是不是更真实了 👇
了解相对亮度 👉 developer.mozilla.org/en-US/docs/...
模拟玻璃质感
为了模拟透明玻璃质感,我用 Figma 画了一个 SVG 背景(也可以用 CSS 实现,我偷懒了),另外又用 conic-gradient
实现了 4 颗螺丝效果。
svg
<svg width="76" height="38" viewBox="0 0 76 38" fill="none" xmlns="http://www.w3.org/2000/svg">
<g opacity="0.68" clip-path="url(#clip0_467_36)">
<rect width="76" height="38" fill="url(#paint0_radial_467_36)"/>
<rect width="76" height="38" fill="white" fill-opacity="0.01"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M-80.0879 0H191.953V272.041H-80.0879V0ZM54.9326 263.211C125.178 263.211 182.124 206.266 182.124 136.021C182.124 65.7744 125.178 8.8291 54.9326 8.8291C-15.3135 8.8291 -72.2588 65.7744 -72.2588 136.021C-72.2588 206.266 -15.3135 263.211 54.9326 263.211Z" fill="url(#paint1_linear_467_36)"/>
</g>
<defs>
<radialGradient id="paint0_radial_467_36" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(38 19) scale(38 19)">
<stop stop-color="white" stop-opacity="0"/>
<stop offset="1" stop-color="white" stop-opacity="0.05"/>
</radialGradient>
<linearGradient id="paint1_linear_467_36" x1="-8.40528" y1="-21.8896" x2="68.8142" y2="-4.89117e-06" gradientUnits="userSpaceOnUse">
<stop offset="0.199944" stop-color="white" stop-opacity="0.26"/>
<stop offset="1" stop-color="white" stop-opacity="0"/>
</linearGradient>
<clipPath id="clip0_467_36">
<rect width="76" height="38" fill="white"/>
</clipPath>
</defs>
</svg>
jsx
import React from 'react'
import SevenSegmentDisplay from '@/components/SevenSegmentDisplay'
import clsx from 'clsx'
import color from 'color'
const Screw = props => {
const { className } = props
return (
<div
className={clsx(className, 'w-[5px] h-[5px] rounded-full ring-1 ring-zinc-800')}
style={{ background: `conic-gradient(#333, #666, #333, #666, #333)` }}
></div>
)
}
const NeonHitCounter = () => {
return (
<div className="relative">
<SevenSegmentDisplay
value={1024}
minLength={6}
digitSize={18}
digitSpacing={4}
segmentThickness={2}
segmentSpacing={0.5}
segmentActiveColor="#ff5e00"
segmentInactiveColor="#161616"
backgroundColor="#0c0c0c"
padding="10px 14px"
glow
/>
<div
className="absolute inset-0 z-10 mix-blend-overlay pointer-events-none"
style={{
background: `radial-gradient(rgba(255, 255, 255, ${
1 - color('#ff5e00').luminosity()
}), transparent 50%)`,
}}
></div>
<div
className="absolute inset-0 z-10 backdrop-blur-[0.25px] backdrop-brightness-150 pointer-events-none"
style={{
backgroundImage: 'url(/hit-counter-glass-cover.svg)',
backgroundSize: 'cover',
backgroundPosition: 'center',
boxShadow: `
0 0 1px rgba(255, 255, 255, 0.1) inset,
0 1px 1px rgba(255, 255, 255, 0.1) inset
`,
}}
>
<Screw className="absolute left-1 top-1 -rotate-45" />
<Screw className="absolute left-1 bottom-1 rotate-45" />
<Screw className="absolute right-1 top-1 rotate-45" />
<Screw className="absolute right-1 bottom-1 -rotate-45" />
</div>
</div>
)
}
export default NeonHitCounter
大功告成 ✨