写在前面
现在切一个小组件, 和以前有很大不同
在最开始, 都是 css,scss,less 和 html 进行组合, 然后使用 js 添加交互
后来, 有了 .vue,.jsx,tsx, 将 html 和 js 部分合并到一个文件 (.vue 还可以将样式一同合入, 但其实还是独立的一部分, 只是方便了引入和隔离)
再后来, jsx 发展出了 styled-component 和 headless-component, 前者以字符串模板的方式将样式也写入 jsx 中, 而后者则纯粹的抽象出业务逻辑
同时, 为了不写 css, 出现了原子度极高, 同时也和构建相结合输出样式的 tailwindcss
发展趋势偏向于 all in one (jsx), dev with build, 前端可以说写的更少, 但要懂得更多
在我最近的项目里, 使用的是 UI 库 (Mantine) + 原子样式 (Tailwind) 的模式, 追求简洁的代码, 复杂的逻辑, 只有 .tsx 和很少一些图片, 大部分的图片也输出为 Svg 的组件
设计图
设计稿内的组件部分是这样的:
但在实际页面中, 是这样的:
以组件部分 (汇率输入框) 为原子进行切图, 然后再封装一个包含顶部左右部分的业务组件
原子组件 -- 汇率输入框
分为 3 部分:
- 容器
- 输入框
- 底部汇率展示部分或操作部分
考虑拓展和变化:
- 容器样式
- 输入框样式和配置
- 输入框焦点状态需要控制容器样式
- 底部汇率的样式
- 操作部分的控制
先上代码, 再解释
'use client'
import {
type ElementProps,
NumberInput,
type NumberInputProps,
Stack,
type StackProps,
Text,
type TextProps,
} from '@mantine/core'
import { useState } from 'react'
import { cn } from '../../utils/className'
export interface ExchangeInputProps
extends NumberInputProps,
ElementProps<'input', keyof NumberInputProps> {
stackProps?: StackProps & ElementProps<'div', keyof StackProps>
exchangeContent?: string
exchangeContentProps?: TextProps & ElementProps<'div', keyof TextProps>
exchangeExternal?: React.ReactNode
}
export const ExchangeInput: React.FC<ExchangeInputProps> = ({
value,
onChange,
stackProps,
exchangeContent,
exchangeContentProps,
exchangeExternal,
...inputProps
}) => {
const [focused, setFocused] = useState(false)
return (
<Stack
{...stackProps}
className={cn(
' border-bd1 bg-bg1 gap-1 rounded-xl border border-solid px-3 py-2 transition',
focused && 'border-t1',
stackProps?.className,
)}>
<NumberInput
value={value}
onChange={event => {
onChange?.(event)
}}
placeholder="0"
{...inputProps}
hideControls
variant="unstyled"
thousandSeparator=" "
className={cn('', inputProps?.className)}
classNames={{
...inputProps?.classNames,
// TODO 有可能是个 function, 如果需要合并, 需要处理一下
input: cn(' placeholder:text-t3 text-t1 h-10 text-right text-[32px] font-bold'),
}}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
/>
{exchangeContent && (
<Text
{...exchangeContentProps}
className={cn(' text-t2 text-right text-sm', exchangeContentProps?.className)}>
{exchangeContent}
</Text>
)}
{exchangeExternal}
</Stack>
)
}
代码解释
使用的 Mantine 的组件, 容器是 Stack, 输入框是 NumberInput, 汇率部分是 Text
接口定义部分
考虑 ExchangeInput 原子组件本体是个 NumberInput 的拓展, 所以 props 继承自 NumberInputProps, 可以参考 Mantine 多态组件继承: mantine.dev/guides/type…
汇率部分 exchangeContent 和对应的 exchangeContentProps (控制 Text)
操作部分 exchangeExternal 作为整体全部由外部传入
内部状态
从 inputProps 中拆出 value 和 onChange, 因为有可能会修改它, 产生中间状态, 或拦截状态改变
增加一个 focused 状态, 因为无法用 tailwind 由子控制父 (可以使用 group 由父控制子)
样式部分
className 使用 cn (clsx + tailwind-merge 的组合) 合并后赋值
classNames 先只简单在 NumberInput 里处理一下, 认为它只传入字符串, 如果后续会使用函数再做处理,
展示一下
示例代码:
'use client'
import { Badge, ExchangeInput, Group, Stack, Text } from '@hidden-name/mantine'
import { BigNumber } from '@hidden-name/number'
import { useState } from 'react'
const exchange = (value: string | number) => {
return `≈ ${BigNumber(value).times(0.0001).toFormat(3)} ETH`
}
export const UIInputs: React.FC = () => {
const [value1, setValue1] = useState<string | number>('')
const [content1, setContent1] = useState<string>(exchange(0))
const [value2, setValue2] = useState<string | number>(222222)
const [content2, setContent2] = useState<string>(exchange(value2))
const [value3, setValue3] = useState<string | number>(123123123)
const [content3, setContent3] = useState<string>(exchange(value3))
const [value4, setValue4] = useState<string | number>('')
const [content4, setContent4] = useState<string>(exchange(0))
const [percentList] = useState([25, 50, 75, 100])
const handlePercentClick = (percent: number) => {
setValue4(percent * 1000)
setContent4(exchange(percent * 1000))
}
const [value5, setValue5] = useState<string | number>('')
const handleChange =
(setValue: (payload: string | number) => void, setContent: (payload: string) => void) =>
(value: string | number) => {
if (value) {
setValue(value)
setContent(exchange(value))
} else {
setValue('')
setContent(exchange(0))
}
}
return (
<Stack>
<ExchangeInput
value={value1}
exchangeContent={content1}
onChange={handleChange(setValue1, setContent1)}
/>
<ExchangeInput
value={value2}
exchangeContent={content2}
onChange={handleChange(setValue2, setContent2)}
/>
<ExchangeInput
value={value3}
exchangeContent={content3}
onChange={handleChange(setValue3, setContent3)}
/>
<ExchangeInput
value={value4}
onChange={handleChange(setValue4, setContent4)}
exchangeExternal={
<Group className=" justify-end gap-1">
<Text className=" text-t2 text-xs">{content4}</Text>
{percentList.map(percent => (
<Badge
className=" text-l1 cursor-pointer px-2"
key={percent}
variant="outline"
color="#5386A6"
onClick={() => handlePercentClick(percent)}>
{percent < 100 ? `${percent}%` : 'MAX'}
</Badge>
))}
</Group>
}
/>
<ExchangeInput value={value5} onChange={setValue5} />
</Stack>
)
}