如何写一个嵌套递归的组件

151 阅读3分钟

需求

项目里经常遇到一些嵌套递归的参数控制器 就是形如这样的组件

image.png

通常使用一个配置对象来进行描述 例如

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

image.png

注意事项

如果有onClick之类的事件 需要视情况调用e.stopPropagation
事件会沿组件树传播 反复触发