引言
leader:我们现在要补足我们平台的移动端能力,你们需要多久的开发时间?
研发:实现界面 --- 补充逻辑 --- 接口连调,还是需要 xx 天的
leader:这么久嘛?移动端的逻辑不是跟 pc 一样嘛?你们还要再写一遍?你们的系统怎么设计的啊?
研发:...
在目前移动互联网高度发达的背景下,为了满足不同类型的客户,移动端+PC 端的多端应用越来越多,很多同样或基本类似的功能需要在双端同时实现,同时维护 2 份代码的成本越来越高,那么你是不是有一个想法,如果能把 UI 以外的逻辑全部抽离成公共的,那么虽然 PC 和移动端 UI 不同,但是他们可以调用公共的逻辑,那么这样开发、后续的维护成本将会是个质变。
那么知道了解决思路,现在有哪些代码复用的方案呢?我们先来盘点一下
代码复用方案
封装组件
大家平时开发最常用的开发方案,将公共模块提取为一个单个组件进行维护,保证高 内聚,组件中包含样式、交互、业务逻辑等,扩展性主要依赖props
typescript
import React, { useState } from 'react'
import './index.less'
interface IDemoProps {}
const Demo: React.FC<IDemoProps> = props => {
const [count, setCount] = useState(0)
return (
<div className="demo">
<button onClick={() => setCount(count + 1)}>count is {count}</button>
</div>
)
}
export default Demo
最普遍的应用场景就是大家目前使用的 UI 组件库, eg:Ant Design,Arco Design,Semi Design,从如下表格可以发现这几个组件库虽然交互逻辑基本是一样的,但是样式上差距却不小,也因此会导致系统设计风格受制于组件库,定制化困难
Ant Design | Arco Design | Semi Design | |
---|---|---|---|
Select选择器 | ![]() |
![]() |
![]() |
Menu菜单 | ![]() |
![]() |
![]() |
综上所述,封装 组件 最大的优点就是开箱即用 ,生态齐全 ,但是他也有很多缺点:
- 组件扩展全部依赖 props,灵活性差
- 样式定制困难,因为天生布局+样式都受到了组件库本身的设计限制
HOC
在 hooks 出现前,是被大家用的最多的逻辑复用方案,主要作用是将公共逻辑从交互中抽离维护
typescript
import React, { useState } from 'react'
import './index.less'
interface IDemoProps {
count: number
changeCount: () => void
}
// 这里是HOC部分的逻辑,主要作用为接收组件,并入逻辑后吐出新组件,起了一个adpater的作用
export const withHOCDemo = (Component: React.FC<IDemoProps): React.ReactNode => {
const [count, setCount] = useState(0)
const changeCount = () => {
setCount(count + 1)
}
return <Component count={count} changeCount={changeCount}></Component>
}
// 以下为在业务组件中的使用
const Demo: React.FC<IDemoProps> = props => {
const { count, changeCount } = props
return (
<div className="demo">
<button onClick={changeCount}>count is {count}</button>
</div>
)
}
// 这里为最后包装后的业务组件
export default withHOCDemo(Demo)
这种 HOC 也存在一定的缺点 ,如果 HOC 过多,那么就会出现如下代码withA(withB(withC(A)))
,这种类似"洋葱"的结构会导致数据流问题排查困难,且可读性、可维护性均存在问题
Hooks
hooks 官方就是为了解决状态复用的,所以利用它是可以直接将组件中的逻辑直接提出来的
typescript
import React, { useState } from 'react'
import './index.less'
interface IDemoProps {
count: number
changeCount: () => void
}
export const useCount = () => {
const [count, setCount] = useState(0)
const changeCount = () => {
setCount(count + 1)
}
return {
count,
changeCount,
}
}
const Demo: React.FC<IDemoProps> = props => {
const { count, changeCount } = useCount()
return (
<div className="demo">
<button onClick={changeCount}>count is {count}</button>
</div>
)
}
export default Demo
这么看,hooks 是可以满足要求的,所以 hooks 本身其实就是 headless 实现的一环,但是如果只使用 hooks 封装 逻辑复用,当业务逻辑复杂度增高时,在业务组件中组装 hooks 的成本也会相应增高,也会导致维护成本的上升
综合看,上述方案基本都存在一定的问题,而这里我们提到了一个新的解决方案,Headless UI,他是一个更上层的代码复用解决方案,那么说一千道一万,我们来了解一下这玩意儿到底是个啥
Headless UI 介绍
什么是 Headless UI
Headless UI 全称是 Headless User Interface (无头用户界面),无 UI 组件,是一种前端开发的方法论(亦或者是一种设计模式),其核心思想是将 UI 的逻辑和交互行为 与 视觉表现(CSS 样式) 分离开来;
换句话说,Headless UI 提供了一种方式来构建只包含逻辑和功能的组件,而内部不实现具体的 UI。它们包含了一些交互逻辑和状态管理,但没有任何与视觉样式相关的代码。
一定要注意 Headless UI 并不特指某个组件库/npm 包,他是一种设计思想、方案,需要区别于 Headless UI (这个只是 headless UI 下用户交互的一种实现,是由 tailwind 团队基于该思想设计的一套无头组件库)
标准 DEMO
我们这里直接来看一个标准的实现 demo 更深入的理解一下他是啥样的
咱们就直接以 Headless UI 的 Dropdown Menu 为例(完整版更多细节可以直接去原地址查看)
javascript
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react'
import {ArchiveBoxXMarkIcon,ChevronDownIcon,PencilIcon,Square2StackIcon,TrashIcon,} from '@heroicons/react/16/solid'
export default function Example() {
return (
{/* 这里是最外层容器,className下的类名都是一些tailwind的预制格式化类名 */}
<div className="fixed top-24 w-52 text-right">
{/* Menu组件容器,包含控制显隐的data-open等props */}
<Menu>
{/* Menu的下拉触发按钮 */}
<MenuButton className="inline-flex items-center gap-2 rounded-md bg-gray-800 px-3 py-1.5 text-sm/6 font-semibold text-white shadow-inner shadow-white/10 focus:not-data-focus:outline-none data-focus:outline data-focus:outline-white data-hover:bg-gray-700 data-open:bg-gray-700">
Options
<ChevronDownIcon className="size-4 fill-white/60" />
</MenuButton>
{/* Menu下拉菜单容器,transtion为动画,anchor为触发下拉的方向 */}
<MenuItems
transition
anchor="bottom end"
className="w-52 origin-top-right rounded-xl border border-white/5 bg-white/5 p-1 text-sm/6 text-white transition duration-100 ease-out [--anchor-gap:--spacing(1)] focus:outline-none data-closed:scale-95 data-closed:opacity-0"
>
{/* Menus菜单项 */}
<MenuItem>
<button className="group flex w-full items-center gap-2 rounded-lg px-3 py-1.5 data-focus:bg-white/10">
<PencilIcon className="size-4 fill-white/30" />
Edit
<kbd className="ml-auto hidden font-sans text-xs text-white/50 group-data-focus:inline">⌘E</kbd>
</button>
</MenuItem>
<MenuItem>
<button className="group flex w-full items-center gap-2 rounded-lg px-3 py-1.5 data-focus:bg-white/10">
<Square2StackIcon className="size-4 fill-white/30" />
Duplicate
<kbd className="ml-auto hidden font-sans text-xs text-white/50 group-data-focus:inline">⌘D</kbd>
</button>
</MenuItem>
<div className="my-1 h-px bg-white/5" />
<MenuItem>
<button className="group flex w-full items-center gap-2 rounded-lg px-3 py-1.5 data-focus:bg-white/10">
<ArchiveBoxXMarkIcon className="size-4 fill-white/30" />
Archive
<kbd className="ml-auto hidden font-sans text-xs text-white/50 group-data-focus:inline">⌘A</kbd>
</button>
</MenuItem>
<MenuItem>
<button className="group flex w-full items-center gap-2 rounded-lg px-3 py-1.5 data-focus:bg-white/10">
<TrashIcon className="size-4 fill-white/30" />
Delete
<kbd className="ml-auto hidden font-sans text-xs text-white/50 group-data-focus:inline">⌘D</kbd>
</button>
</MenuItem>
</MenuItems>
</Menu>
</div>
)
}
最后实现效果如下:

通过这个标准 case 我们可以更清晰的发现,Menu
、MenuButton
、MenuItems
、MenuItem
这些组件实际只是实现了显隐切换、结构控制、用户交互, 组件的真正内容以及样式实际都是在这些组件 children 下 实现的,这些组件本质上更像是起了 Provider 的作用,最后的定制化展示交给了业务方
与传统 UI 组件对比
在看完了上述 demo 后,我们再来与传统组件做一个对比
传统 UI 组件 ![]() |
Headless UI ![]() |
|
---|---|---|
优点 | * 主流组件库生态齐全,eg:Ant Design、Element UI 等 * 开箱即用,安装依赖后,根据需求直接引入对应组件即可 | * 可定制度高,逻辑与样式分离,可以根据不同平台做修改 * 组件轻量,低耦合,复用性好 |
缺点 | * 样式定制困难,组件库样式风格修改成本高 * 耦合度高,交互、数据逻辑耦合在一起,复用困难 | * 开发成本高,需要对组件做高度抽象 * 有一定的学习成本,不像现有的组件库,没有现成的轮子 |
什么时候使用 Headless?
通过上述的对比,我们可以知道,以下场景适用 Headless UI
- 跨平台应用: 如果同一套逻辑需要在多个端实现,那么样式、逻辑分离是最佳的复用方案
- 跨项目组件复用场景多: 同样的,这种组件的样式跟逻辑分离更易于复用
- 界面定制化高: 基本组件样式功能全部需要自己实现,逻辑样式耦合会导致组件过重,此时 Headless UI 也是一个不错的选择
上文我们主要带大家深入了解了 Headless UI 的核心概念,接下来我们来看我们在一个实际业务中的具体应用,帮助我们进一步了解他适用的场景
实际案例
这里以我们的实际业务场景来一步一步看看如何利用 Headless UI 来解决现有问题
业务背景
比如我们有一个编辑金额的功能,该功能在 PC、H5 均需要实现(这其实就是经典的 Headless UI 的应用场景),交互如下:
![]() |
![]() |
---|
根据设计稿,我们整理出业务逻辑

可以发现,除了交互形式以外,实际的业务逻辑在 pc 和 h5 基本是一致的
技术难点
- 两个平台的业务逻辑相似,但 h5 和 pc 是两个代码仓库,代码逻辑分别维护会导致 2 份类似代码
- 页面涉及一定复杂度的浮点数计算,这些逻辑在双端一致,如果分别实现出问题会导致两边计算值甚至出现差异(舍入问题)
- 交互有类似的地方,但是又有较大的区别(PC 和 H5 本身平台的特征导致的),在复用上需要考虑 ROI
这里的业务场景更趋向于多端复用,减少维护成本 这个点,所以 Headless UI 很适合解决该场景下的问题
实现方案
这里从架构,数据流、功能逐步拆解
架构设计
结合项目,我们需要对现有的项目架构做如下调整,抽离出 Headless UI 层

在项目中改造后,headless 结构如下
scss
|--otrade_libs
|--|--.npmignore
|--|--dist
|--|--node_modules
|--|--README.md
|--|--edenx.config.ts // Headless打包配置
|--|--package.json
|--|--.eslintrc.js
|--|--tsconfig.json
|--|--src
|--|--|--components // 这里是Headless组件目录
|--|--|--|--allocate-credit-line // 本次新增的Headless模块
|--|--|--|--index.ts // 导出所有Headless组件
|--|--|--index.ts // npm包入口
数据流设计
接下来我们需要关注 headless 改造后数据流向应该如何设计,这样才能确定 headless 部分要做的功能(该做什么,不该做什么)

功能设计
经过系统架构调整和数据流设计后,我们 Headless 的框子已经出来了,接下来就是根据业务功能来拆分对应的功能组件

代码实现
最后是落地的代码(只摘录部分,仅供参考,需要根据实际业务调整)
Headless 组件
Hook
以 useAllocateCreditLineSummary 为例
typescript
import { useContext } from 'react'
import numeral from 'numeral'
import { AllocateCreditLineContext } from '@/components/allocate-credit-line/allocate-credit-line/context'
interface IUseAllocateCreditLineSummaryProps {
// 入参,标识当前pa
paId: string | number
}
export const useAllocateCreditLineSummary = (props: IUseAllocateCreditLineSummaryProps) => {
const { paId } = props
// 从全局context中获取全局数据
const { allocatePercentage, companyCreditLine } = useContext(AllocateCreditLineContext)
// 处理数据作为新字段
const curPercent = Number(allocatePercentage[paId]) || 0
const curTotal = numeral(companyCreditLine.total_amount).multiply(curPercent).divide(100).value()
// 吐出新字段
return {
curPercent,
curTotal,
}
}
component
以 AllocateCreditLineSummary 为例
typescript
import React from 'react'
import { useAllocateCreditLineSummary } from '@/components/allocate-credit-line/allocate-credit-line-summary/hooks/use-allocate-credit-line-summary'
import { AllocateCreditLineContext } from '@/components/allocate-credit-line/allocate-credit-line/context'
import { useAllocateCreditLine } from '@/components/allocate-credit-line/allocate-credit-line/hooks/use-allocate-credit-line'
// 接收外部参数
export interface IAllocateCreditLineSummaryProps {
// 标识当前是哪个pa
paId: string | number。
// 数据插槽
children: (value: Partial<ReturnType<typeof useAllocateCreditLine>> & Partial<ReturnType<typeof useAllocateCreditLineSummary>>) => React.ReactNode
}
const AllocateCreditLineSummary: React.FC<IAllocateCreditLineSummaryProps> = (props) => {
const { paId, children } = props
// 从hook中获取处理后的数据
const { curPercent, curTotal } = useAllocateCreditLineSummary({ paId })
return (
{/* 需要消费全局数据源数据,使用Consumer,或者直接用useContext获取也可以 */}
<AllocateCreditLineContext.Consumer>
{/* 组装全局数据+处理后的hook数据,传递给children插槽 */}
{({ paCreditLineList, currentCurrency }) => children({ paCreditLineList, curPercent, curTotal, currentCurrency })}
</AllocateCreditLineContext.Consumer>
)
}
export default AllocateCreditLineSummary
业务组件
这里会贴上 PC+H5 的完整代码,让大家更清楚的体验到 Headless 完整处理后的复杂业务组件代码究竟有什么优势
PC
typescript
import React, { useEffect } from 'react'
import { HeadlessAllocateCreditLine } from '@otrade/libs/components'
import './index.less'
enum AllocateTypeEnum {
AllocatedToYour = 1,
Unallocated = 2,
AllocatedToOther = 3,
}
const { Title, Paragraph, Text } = Typography
interface IProps {
form?: UseFormReturn
paId?: string
}
const AllocateCreditLine: React.FC<IProps> = (props) => {
const { paId, form } = props
const { platform } = usePaContext()
const {
formState: { isValid },
} = form!
// 获取后端数据
const { loading, data } = useRequest(paService.queryPaCreditlineAllocation, {
defaultParams: [paId ? { Context: { pa_id: paId } } : {}],
refreshDeps: [paId],
})
// 进度条样式部分逻辑维护
const AllocateProgressList = [
{
type: AllocateTypeEnum.AllocatedToYour,
color: platform === PlatformEnum.TTS ? '#009995' : '#4D65C3',
text: starling('otrade_pa_allocated_credit_line_to_pa'),
},
{
type: AllocateTypeEnum.Unallocated,
color: platform === PlatformEnum.TTS ? '#88CFCD' : '#95AEFE',
text: starling('otrade_pa_unallocated_credit_line_rate'),
},
{
type: AllocateTypeEnum.AllocatedToOther,
color: platform === PlatformEnum.TTS ? '#D0E7E6' : '#D6E2FA',
text: starling('otrade_pa_allocated_credit_line_to_other_pa'),
},
]
// 处理组件loading状态的表单初始化
useEffect(() => {
if (loading) {
form?.setError('init', { type: 'required' })
} else {
form?.clearErrors('init')
}
}, [form, loading])
// 处理组件loading状态的交互
if (loading) {
return (
<Space style={{ width: '100%', minHeight: '240px' }} direction="vertical" align="center">
<Spin size={40} style={{ lineHeight: '240px' }} />
</Space>
)
}
// ...
return (
{/* 模块Headless组件,用于注入全局数据 */}
<HeadlessAllocateCreditLine.AllocateCreditLine paId={paId} paCreditLineAllocationData={data!}>
<div className="allocate-credit-line">
{/* 描述列表Headless组件 */}
<HeadlessAllocateCreditLine.AllocateCreditLineRules>
{({ limitMin }) => (
{/* 获取到需要数据后的实际列表交互实现 */}
<>
<Title heading={6} style={{ marginTop: '-8px' }}>
{starling('otrade_pa_allocate_credit_line_rules')}
</Title>
<Paragraph>
<ul style={{ listStyle: 'disc' }}>
<li>{starling('otrade_pa_allocate_credit_line_rules_1')}</li>
<li>{starling('otrade_pa_allocate_credit_line_rules_2')}</li>
<li>
{starling('otrade_pa_allocate_credit_line_rules_3', {
num: limitMin + '%',
})}
</li>
</ul>
</Paragraph>
</>
)}
</HeadlessAllocateCreditLine.AllocateCreditLineRules>
{/* 进度条Headless组件 */}
<HeadlessAllocateCreditLine.AllocateCreditLineProgress>
<>
{/* 进度条Title部分 */}
<HeadlessAllocateCreditLine.AllocateCreditLineProgressTitle>
{({ companyCreditLine, currentCurrency }) => (
{/* 进度条title交互实现 */}
<>
<Title heading={6}>{starling('otrade_pa_total_credit_line')}</Title>
<Space direction="horizontal" size="mini" align="baseline">
<Text bold style={{ fontSize: '24px' }}>
{companyCreditLine?.total_amount}
</Text>
<Text style={{ fontSize: '16px' }}>{currentCurrency}</Text>
</Space>
</>
)}
</HeadlessAllocateCreditLine.AllocateCreditLineProgressTitle>
{/* 进度条内容 */}
<HeadlessAllocateCreditLine.AllocateCreditLineProgressContent>
{({ progressInfo, limitToYourPa }) => (
{/* 进度条内容部分交互实现 */}
<>
<div style={{ height: '24px', marginTop: '8px' }}>
{AllocateProgressList.map((item) => {
return (
<div
key={item.type}
style={{
display: 'inline-block',
width: progressInfo?.[item.type] + '%',
maxWidth: item.type === AllocateTypeEnum.AllocatedToYour ? limitToYourPa + '%' : 'unset',
height: '24px',
backgroundColor: item.color,
}}
/>
)
})}
</div>
<Space size={24} style={{ marginTop: '12px' }}>
{AllocateProgressList.map((item) => {
return (
<Space key={item.type} direction="vertical" size={6}>
<Space size={4}>
<Badge color={item.color} />
<Text style={{ color: 'var(--otrade-color-text-3)' }}>{item.text}</Text>
</Space>
<Text
style={{
color: !isValid && item.type === AllocateTypeEnum.AllocatedToYour ? 'var(--otrade-error-text-color)' : undefined,
}}
bold
>
{progressInfo?.[item.type] + '%'}
</Text>
</Space>
)
})}
</Space>
{/* 错误信息 */}
{!isValid && (
<Space size={4} style={{ fontSize: '12px', marginTop: '8px', color: 'var(--otrade-error-text-color)' }}>
<img width="16px" src={RUNTIME_SVG_MAP.AlertErrorSvg} />
<div>{starling('otrade_pa_total_allocated_credit_line_max', { num: limitToYourPa })}</div>
</Space>
)}
</>
)}
</HeadlessAllocateCreditLine.AllocateCreditLineProgressContent>
</>
</HeadlessAllocateCreditLine.AllocateCreditLineProgress>
{/* 数据表格Headless组件 */}
<HeadlessAllocateCreditLine.AllocateCreditLineTable>
{({ paCreditLineList }) => (
/* 表单部分 */
<HeadlessAllocateCreditLine.AllocateCreditLineForm form={form!}>
{/* 表格交互实现 */}
<Table
style={{ marginTop: '16px' }}
pagination={false}
data={paCreditLineList}
columns={[
{
title: starling('otrade_pa_common_payment_account'),
dataIndex: 'pa_id',
width: '238px',
render: (_val, record) => {
return (
<div>
<div>{record.pa_name}</div>
<div style={{ fontSize: '12px', color: 'var(--otrade-color-text-3)' }}>{`ID: ${record.pa_id}`}</div>
</div>
)
},
},
{
title: starling('otrade_pa_allocate_credit_line_percent'),
dataIndex: 'allocate_percentage',
render: (_val, record) => {
return (
/* 表单元素------输入部分 */
<HeadlessAllocateCreditLine.AllocateCreditLineFormItem paCreditLine={record}>
{({ field, min, max, onChange, onBlur }) => (
{/* 输入框部分交互实现 */}
<Space direction="vertical" size={4} style={{ width: '100%' }}>
<InputNumber
suffix="%"
precision={2}
min={min}
// for 交互体验: 如果编辑单条数据,不限制输入上限,而是在验证时提示
max={max}
{...field}
onChange={(value) => {
// 直接调用headless中封好的逻辑即可
onChange?.(value)
field.onChange(value)
}}
// 如果手动清空输入值,在blur时手动赋值min
onBlur={onBlur}
/>
{/* 表单校验 */}
<HeadlessAllocateCreditLine.AllocateCreditLineFormItemError>
{({ hasError, hasWarning, limitToYourPa }) => (
{/* 表单错误时的交互 */}
<>
{hasError ? (
<WtAlert
type="error"
content={starling('otrade_pa_allocated_credit_line_exceed_limit', { num: limitToYourPa })}
/>
) : (
hasWarning && (
<WtAlert type="warning" content={starling('otrade_pa_allocated_credit_line_not_enough_forecast')} />
)
)}
</>
)}
</HeadlessAllocateCreditLine.AllocateCreditLineFormItemError>
</Space>
)}
</HeadlessAllocateCreditLine.AllocateCreditLineFormItem>
)
},
},
{
title: (
<TextWithTooltip
textStyle={{ fontSize: '14px' }}
text={starling('otrade_pa_allocated_credit_line_breakdown')}
tooltip={starling('otrade_pa_allocated_credit_line_tips')}
/>
),
dataIndex: 'show_more',
width: '300px',
render: (_val, record) => {
return (
/* 数据汇总Headlesss */
<HeadlessAllocateCreditLine.AllocateCreditLineSummary paId={record.pa_id}>
{({ curPercent, curTotal, currentCurrency }) => (
{/* 数据汇总部分的交互实现 */}
<Space direction="vertical" size="mini">
<div>
<span style={{ color: 'var(--otrade-color-text-3)' }}>{starling('otrade_pa_total_allocated_credit_line')}:</span>
<span>
{formatDisplayAmountWithCurrency(curTotal!.value()!, currentCurrency)} {currentCurrency}
{`(${curPercent}%)`}
</span>
</div>
<div>
<span style={{ color: 'var(--otrade-color-text-3)' }}>{starling('otrade_pa_common_used')}:</span>
<span>
{formatDisplayAmountWithCurrency(record.used_amount, currentCurrency)} {currentCurrency}
{`(${+record.used_percentage > 0 ? numeral(record.used_percentage).multiply(100).value()!.toFixed(2) : 0}%)`}
</span>
</div>
<div>
<span style={{ color: 'var(--otrade-color-text-3)' }}>{starling('otrade_pa_common_available')}:</span>
<span>
{formatDisplayAmountWithCurrency(curTotal!.subtract(record.used_amount).value()!, currentCurrency)}
{currentCurrency}
</span>
</div>
</Space>
)}
</HeadlessAllocateCreditLine.AllocateCreditLineSummary>
)
},
},
]}
/>
</HeadlessAllocateCreditLine.AllocateCreditLineForm>
)}
</HeadlessAllocateCreditLine.AllocateCreditLineTable>
</div>
</HeadlessAllocateCreditLine.AllocateCreditLine>
)
}
// ...
H5
javascript
import React, { useContext } from 'react'
import { HeadlessAllocateCreditLine } from '@otrade/libs/components'
import './index.less'
const CreditLine = () => {
const { paymentAccount } = useContext<IGlobalInfo>(GlobalInfo)
const { inApp, history } = useEnvState()
const form = useForm({ mode: 'all' })
const {
getValues,
formState: { isValid },
} = form
// 获取接口数据
const { data, loading } = useRequest(paService.getPaCreditlineAllocation, { auto: true })
// 定义业务接口
const { run: updateCreditLine } = useRequest(paService.updatePaCreditlineAllocation, { auto: false })
// 返回
const goBack = () => {
if (inApp) {
x.close()
return
} else {
history.goBack()
return
}
}
// 提交逻辑
const handleConfirm = async () => {
// 校验失败
if (!isValid) {
return
}
const params = {
pa_percentage_list: Object.entries(getValues()).map((item) => {
return {
pa_id: item[0],
allocate_percentage: numeral(item[1]).divide(100).value()?.toString() || '0',
}
}),
}
try {
await updateCreditLine(params)
TUXToast.success({
message: starling('otrade_payment_edit_success'),
})
setTimeout(() => {
goBack()
}, 1000)
} catch (e) {
TUXToast.error({
message: starling('otrade_payment_edit_failed'),
})
}
}
// ...
return !loading ? (
{/* 模块Headless组件,注入全局数据 */}
<HeadlessAllocateCreditLine.AllocateCreditLine paId={paymentAccount?.pa_info?.pa_id} paCreditLineAllocationData={data!}>
<div className="credit-line">
<TopBar title={starling('otrade_payment_edit_credit_line')} onBack={goBack} />
<div className="credit-line__content">
{/* 进度条部分 */}
<HeadlessAllocateCreditLine.AllocateCreditLineProgress>
<>
{/* 进度条部分交互实现 */}
<div className="credit-line__overview">
{/* 进度条Title部分 */}
<HeadlessAllocateCreditLine.AllocateCreditLineProgressTitle>
{({ companyCreditLine, currentCurrency }) => (
{/* 进度条标题交互实现 */}
<>
<div className="credit-line__overview__label">
{starling('otrade_pa_total_credit_line')}
</div>
<div className="credit-line__overview__value">
<span className="credit-line__overview__value_amount">{companyCreditLine?.total_amount}</span>
<span className="credit-line__overview__value_currency">{currentCurrency}</span>
</div>
</>
)}
</HeadlessAllocateCreditLine.AllocateCreditLineProgressTitle>
{/* 进度条内容部分 */}
<HeadlessAllocateCreditLine.AllocateCreditLineProgressContent>
{({ progressInfo, limitToYourPa }) => (
{/* 进度条内容部分交互实现 */}
<>
<div className="credit-line__overview__progress">
{AllocateProgressList.map((item) => {
return (
<div
key={item.type}
style={{
display: 'inline-block',
width: progressInfo?.[item.type] + '%',
maxWidth: item.type === AllocateTypeEnum.AllocatedToYour ? limitToYourPa + '%' : 'unset',
height: '24px',
backgroundColor: item.color,
}}
/>
)
})}
</div>
<div className="credit-line__overview__detail">
{
AllocateProgressList.map((item) => (
<div key={item.type} className="credit-line__overview__detail__item">
<div className="credit-line__overview__detail__item__label">
<span
style={{
display: 'inline-block',
width: '6px',
height: '6px',
borderRadius: '50%',
marginRight: '4px',
backgroundColor: item.color
}}
/>
{item.text}
</div>
<div
className="credit-line__overview__detail__item__value"
style={{
color: !isValid && item.type === AllocateTypeEnum.AllocatedToYour ? 'var(--otrade-error-text-color)' : undefined,
}}
>
{progressInfo?.[item.type] + '%'}
</div>
</div>
))
}
</div>
</>
)}
</HeadlessAllocateCreditLine.AllocateCreditLineProgressContent>
</div>
</>
</HeadlessAllocateCreditLine.AllocateCreditLineProgress>
</div>
<div>
{/* 表格部分 */}
<HeadlessAllocateCreditLine.AllocateCreditLineTable>
{({ paCreditLineList }) =>
paCreditLineList?.map((record) => (
{/* 表单部分 */}
<HeadlessAllocateCreditLine.AllocateCreditLineForm form={form} key={record.pa_id}>
{/* 表单部分交互实现 */}
<div className="credit-line__form">
<div className="credit-line__form_title">{starling('otrade_payment_to_pa', { name: record.pa_name })}</div>
{/* 表单元素------输入框 */}
<HeadlessAllocateCreditLine.AllocateCreditLineFormItem paCreditLine={record}>
{({ hasWarning, hasError, field, onChange, onBlur }) => (
{/* 输入框交互实现 */}
<div className={`credit-line__form__item ${hasError || hasWarning ? 'credit-line__form__item--error' : ''}`}>
<>
<TUXTextFieldWIP
{...field}
title={starling('otrade_pa_allocate_credit_line_percent')}
type="number"
inputMode="numeric"
end="%"
hasBottomBorder={false}
hasClearButton
hasError={hasError || hasWarning}
shouldPreventInputEventFn={shouldPreventInputEventFn}
onChange={(e) => {
const val = e.target.value
// 直接调用headless封装好的逻辑
onChange?.(val)
field.onChange(val)
}}
// 如果手动清空输入值,在blur时手动赋值min
onBlur={onBlur}
/>
{/* 表单错误 */}
<HeadlessAllocateCreditLine.AllocateCreditLineFormItemError>
{({ hasError, hasWarning, limitToYourPa }) => (
{/* 表单错误交互实现 */}
<>
{hasError ? (
<CreditLineFormErrorMsg message={starling('otrade_pa_allocated_credit_line_exceed_limit', { num: limitToYourPa })} />
) : (
hasWarning && <CreditLineFormErrorMsg message={starling('otrade_pa_allocated_credit_line_not_enough_forecast')} />
)}
</>
)}
</HeadlessAllocateCreditLine.AllocateCreditLineFormItemError>
</>
</div>
)}
</HeadlessAllocateCreditLine.AllocateCreditLineFormItem>
</div>
</HeadlessAllocateCreditLine.AllocateCreditLineForm>
))
}
</HeadlessAllocateCreditLine.AllocateCreditLineTable>
</div>
<div>
{/* 描述列表部分 */}
<HeadlessAllocateCreditLine.AllocateCreditLineRules>
{({ limitMin }) => (
{/* 描述列表交互实现 */}
<div className="credit-line__rules">
<ul>
<li>{starling('otrade_pa_allocate_credit_line_rules_2')}</li>
<li>
{starling('otrade_pa_allocate_credit_line_rules_3', {
num: limitMin
})}
</li>
</ul>
</div>
)}
</HeadlessAllocateCreditLine.AllocateCreditLineRules>
</div>
<div>
{/* 表格数据消费需要,需要增加一层Table部分的headless */}
<HeadlessAllocateCreditLine.AllocateCreditLineTable>
{({ paCreditLineList }) => paCreditLineList?.map(record => (
{/* 数据汇总部分 */}
<HeadlessAllocateCreditLine.AllocateCreditLineSummary paId={record.pa_id} key={record.pa_id}>
{({ curTotal, currentCurrency }) => (
{/* 数据汇总交互实现 */}
<div className="credit-line__footer">
<div className="credit-line__footer__amount">
<div className="credit-line__footer__amount__detail">
{formatDisplayAmountWithCurrency(curTotal!.value()!, currentCurrency)} {currentCurrency}
</div>
<div className="credit-line__footer__amount__view" onClick={showCreditLineInfoDrawer}>
{starling('otrade_payment_view_allocation_details')}
<span style={{ display: 'flex', padding: '0 4px', alignItems: 'center' }}>
<IconUp size={14} />
</span>
</div>
</div>
<div className="credit-line__footer__btn" onClick={handleConfirm}>
{starling('otrade.pa.bc.common_confirm')}
</div>
</div>
)}
</HeadlessAllocateCreditLine.AllocateCreditLineSummary>
))}
</HeadlessAllocateCreditLine.AllocateCreditLineTable>
</div>
</div>
</HeadlessAllocateCreditLine.AllocateCreditLine>
) : null
}
export default CreditLine
后续维护
得益于Headless改造,公共的逻辑实际已经作为独立模块提出了,所以在后续维护时,我们分如下两种情况来分别看
修改已有模块逻辑
在涉及的模块下(在hooks/component中修改)修改即可,修改后发布即刻生效
- 在components中修改
javascript
import React from 'react'
const AllocateCreditLineSummary: React.FC<IAllocateCreditLineSummaryProps> = (props) => {
const { paId, children } = props
// 修改hooks的话需要在这里获取新修改后暴露的参数
const { curPercent, curTotal } = useAllocateCreditLineSummary({ paId })
// 在这里增加需要变化的逻辑
// ...
return (
<AllocateCreditLineContext.Consumer>
{/* 在这里修改需要暴露的参数 */}
{({ paCreditLineList, currentCurrency }) => children({ paCreditLineList, curPercent, curTotal, currentCurrency })}
</AllocateCreditLineContext.Consumer>
)
}
export default AllocateCreditLineSummary
- 在hooks中修改
typescript
import { useContext } from 'react'
interface IUseAllocateCreditLineSummaryProps {
paId: string | number
}
export const useAllocateCreditLineSummary = (props: IUseAllocateCreditLineSummaryProps) => {
const { paId } = props
const { allocatePercentage, companyCreditLine } = useContext(AllocateCreditLineContext)
const curPercent = Number(allocatePercentage[paId]) || 0
const curTotal = numeral(companyCreditLine.total_amount).multiply(curPercent).divide(100).value()
// 在这里新增需要处理的逻辑
// ...
// 增加需要暴露的新字段,在component中使用时也需要获取到新字段
return {
curPercent,
curTotal,
}
}
-
增加新的模块/逻辑
主要参考上述功能设计和已有模块 ,拆分出新增模块的hooks 、context 、components,最后增加该功能模块的导出,发布即可
javascript
import HeadlessAllocateCreditLine from './allocate-credit-line'
// 在下面新增导出的模块
// 导出组件
export { HeadlessAllocateCreditLine }
小结
通过设计与最后的 code 展示/维护,大家可以发现,业务组件中最后只剩下了对应平台的交互样式部分,逻辑实际都被包装在了平台无关的 Headless 下,很好的达成了逻辑共用的设计目标,也解决了我们的业务痛点,后续如果大家遇到类似的场景,不妨也考虑下 Headless 方案
总结
Headless UI 是一种设计思路,本质上就是分离视觉样式 相关的代码,将交互逻辑、状态管理、用户交互独立出去作为原子化的可复用模块,实际可以根据业务场景灵活实现,并没有固定的模板
用一张图来帮助大家做个总结

最后,切记 Headless UI 不是银弹,我们在开发时不能为了使用它而使用它,他还是作为特定业务场景下的解决方案而出现的,当他的成本超出收益时,我们还是应该去选择现有的成熟 UI 组件库