需求
项目里经常遇到一些嵌套递归的参数控制器 就是形如这样的组件
通常使用一个配置对象来进行描述 例如
const config = [
{
type: 'group',
label: '几何体',
key: 'mesh',
children: [
{ type: 'number', key: 'size[0]', min: 1, max: 100, label: '尺寸(X)' },
{ type: 'number', key: 'size[1]', min: 1, max: 100, label: '尺寸(Y)' },
// xxx
],
},
// xxx
]
type为group的映射为配置组 其余的则映射为单个配置
根据我的经验 这种控制器很容易在拓展和修改过程中变成一坨 所以一开始规划好是很重要的
实施步骤
配置项类型
export type ArgOptionType = {
label: string
key: string | string[]
} & ArgOptionMap[keyof ArgOptionMap]
type ArgOptionMap = {
[key in keyof ArgTypeMap]: {
type: key
} & ArgTypeMap[key]
}
type ArgTypeMap = {
/** 组合类型 */
group: {
children?: ArgOptionType[]
}
/** 颜色类型 */
color: {}
/** 数字类型 */
number: {
min: number
max: number
}
}
配置项被定义为一个数组 每项都有label key type 三个属性
type='group'表示这是一个组合配置项 否则表示单个配置项 不同type具有不同的额外数据
key是用于获取和修改变量 当前key和所有父级的key组合起来就是当前的路径 会被用于lodash的get和set函数
ArgTypeMap是核心类型 拓展和修改组件修改ArgTypeMap即可保证类型正确 也可以用interface拓展
// type.ts
export interface ArgTypeMap {
}
// components/NumberComp/index.tsx
declare module '../../type' {
interface OptionTypeMap {
number: {
min: number
max: number
}
}
}
不过在组件内拓展类型后 依旧要修改实际渲染的组件
控制器类型
对于不同类型的控制器定义类型如下
type ArgCompMap = {
[key in keyof ArgTypeMap]: FC<
{
className?: string
style?: CSSProperties
label?: string
value?: any
onChange?: (val: any) => void
} & ArgTypeMap[key]
>
}
以颜色控制器为例 可以这样写
export const ColorComp: ArgCompMap['color'] = (props) => {
return (
<ColorPicker
value={props.value}
onChange={(val) => {
props.onChange?.(val.toHexString())
}}
showText
></ColorPicker>
)
}
完成组件
import { produce } from 'immer'
import { get, set } from 'lodash-es'
import { create } from 'zustand'
const ArgItem: FC<{
value: any
onChange: (newVal: any) => void
option: ArgOptionType
}> = memo(function ArgItemInner(props) {
const { option, value, onChange } = props
switch (option.type) {
case 'color':
return <ColorComp value={value} onChange={onChange}></ColorComp>
case 'number':
return (
<NumberComp
value={value}
onChange={onChange}
min={option.min}
max={option.max}
label={option.label}
></NumberComp>
)
case 'group':
default:
throw new Error('类型错误')
}
})
export function createArgsController<T>(defaultValue: T) {
type Store = {
value: T
setValue: (newVal: any, path: string[]) => void
setValueFn: (fn: (draft: T) => void) => void
}
const useStore = create<Store>((setStore) => {
return {
value: defaultValue,
setValue: (newVal, path) => {
setStore((state) => {
const value = produce(state.value, (draft: any) => {
set(draft, path, newVal)
})
return { value }
})
},
setValueFn: (fn) => {
setStore((state) => {
const value = produce(state.value, fn)
return { value }
})
},
}
})
function useValue(): [T, Store['setValueFn']]
function useValue<V>(selector: (value: T) => V): [V, Store['setValueFn']]
/** 对外暴露store */
function useValue<V>(selector?: (value: T) => V) {
const value = useStore((state) => {
if (typeof selector === 'function') {
return selector(state.value)
} else {
return state.value
}
})
const setValueFn = useStore((state) => state.setValueFn)
const setValue: Store['setValueFn'] = useCallback(
(fn) => {
setValueFn(fn)
},
[setValueFn],
)
return [value, setValue] as const
}
/** 内部使用 */
const useValueInner = (path: string[]) => {
const value = useStore((state) => {
return get(state.value, path)
})
const _setValue = useStore((state) => {
return state.setValue
})
const setValue = useCallback(
(newVal: any) => {
_setValue(newVal, path)
},
[_setValue, path],
)
return [value, setValue] as const
}
/** 多参数控制器 */
const ArgsController: FC<{
options: ArgOptionType[]
parentPath?: string[]
}> = memo(function ArgsControllerInner(props) {
const { parentPath, options } = props
const contentList = useMemo(() => {
const res = options.map((option) => {
const currentPath = (parentPath ?? []).concat(option.key)
const currentKey = currentPath.join('.')
const content = (() => {
if (option.type === 'group') {
if (!option.children || option.children.length === 0) {
return null
} else {
return (
<Collapse
key={currentKey}
items={[
{
label: option.label,
key: currentKey,
children: (
<ArgsController
options={option.children}
parentPath={currentPath}
></ArgsController>
),
},
]}
></Collapse>
)
}
}
return (
<ArgController
key={currentKey}
option={option}
currentPath={currentPath}
></ArgController>
)
})()
return content
})
return res
}, [options, parentPath])
return <div className='flex flex-col p-2'> {contentList} </div>
})
/** 单参数控制器 */
const ArgController: FC<{
option: ArgOptionType
currentPath: string[]
}> = memo(function ArgItemInner(props) {
const { option, currentPath } = props
const [value, setValue] = useValueInner(currentPath)
return <ArgItem value={value} onChange={setValue} option={option}></ArgItem>
})
return { ArgsController, useValue } as const
}
使用例
const { ArgsController, useValue } = createArgsController({
value1: { a: '#4096ff' },
value2: 1,
})
const options: ArgOptionType[] = [
{
type: 'group',
label: 'value1',
key: 'value1',
children: [{ type: 'color', label: 'a', key: 'a' }],
},
{ type: 'number', label: 'value2', key: 'value2', min: 0, max: 10 },
]
const Page: FC = () => {
const [value, setValue] = useValue()
console.log(value)
return (
<>
<Button
onClick={() =>
setValue((draft) => {
draft.value2 += 1
})
}
>
{value.value2}
</Button>
<ArgsController options={options} />
</>
)
}
export default Page
注意事项
如果有onClick之类的事件 需要视情况调用e.stopPropagation
事件会沿组件树传播 反复触发