Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

如何设计一个组件?常见的 React 组件设计模式 --- 复合组件 #101

Open
peng-yin opened this issue Mar 20, 2023 · 0 comments

Comments

@peng-yin
Copy link
Owner

peng-yin commented Mar 20, 2023

如何设计一个组件?常见的 React 组件设计模式 --- 复合组件

react 以组件构成页面,极大提高了开发效率和代码维护难度,但是不注重组件设计方法,容易导致难以理解、扩展性差、难以复用的组件。

设计一个组件前,需要考的事情:

  • 重用:如何设计以适应多种需求(用例)变化吗?

对需求的理解是否全面、是否考虑了可能的需求变化,很大程度上决定了组件的是否能重用。

  • 易用:如何设计组件的 API,才能保持简单、符合直觉?

props 多少和复杂程度,决定了组件是否易用。

  • 扩展:如何保证不重写组件以满足未来的需求变化?

组件设计的复杂度往往度往往决定是是否可扩展。

另外,控制反转越大,复用性和扩展性越好。

控制反转:程序如何工作,不是由开发者控制,而是程序调用者有更多控制权。可简单理解为程序能实现高度定制化,用户能实现他想要的很多需求。程序开发者做得很少,但是提供接口给程序调用者实现很多需求

回答以上三个问题,人们总结了一些设计组件的思路。

实际开发中,开发完一个组件,遇到新需求变化,我们才来回答第三个问题,此时往往很难得到满意的答案,又开始编写满足新需求的组件,但是不知道组件设计方法,仍然很容易出现之前的问题。可见学习常见的组件设计方法极为必要

复合组件

复合组件:多个独立的组件组合使用,这些组件隐式地共享状态和行为。复合组件语法符合自觉,易用,灵活,容易扩展

常规实现方式

export default function Select({ value = [], onChange, options = [] }) {
  const [filter, setFilter] = useState('')
  const [optionList, setOptionList] = useState(options)
  useEffect(() => {
    if (filter) {
      const value = options.filter(item => item.value.toLowerCase().includes(filter.toLowerCase()))
      setOptionList(value)
    }
  }, [filter])
  return (
    <div className='select-container'>
      <input
        type='text'
        placeholder='支持模糊搜索'
        value={filter}
        onChange={event => {
          setFilter(event.currentTarget.value)
        }}
      />
      <div className='options-container'>
        {optionList.map(item => {
          return (
            <label className={value.includes(item.value) ? 'option option-selected' : 'option'} key={item.value}>
              <input
                className='checkbox'
                type='checkbox'
                checked={value.includes(item.value)}
                onChange={event => {
                  if (event.currentTarget.checked) onChange([...value, item.value])
                  else onChange(value => value.filter(val => val !== item.value))
                }}
              />
              {item.label}
            </label>
          )
        })}
      </div>
    </div>
  )
}

用法:

<Select
  value={value}
  onChange={setValue}
  options={[
    { value: 'apples', label: 'Apples' },
    { value: 'oranges', label: 'Oranges' },
    { value: 'Peaches', label: 'Peaches' },
    { value: 'Grapes', label: 'Grapes' },
    { value: 'Plums', label: 'Plums' },
  ]}
/>

缺点:

  1. 不易用:不符合自觉

这种符合直觉。

<select>
  <option value="apples">Apples</option>
  <option value="oranges">Oranges</option>
  <option value="pears">Pears</option>
</select>
  1. 不够灵活,比如想要给下拉应用样式,变得复杂。

  2. 实现比较复杂

使用复合组件模式改进

操作孩子组件实现

import React, { useState } from 'react'

export default function MySelect({ children, value, onChange }) {
  const [filter, setFilter] = useState('')
  const newChildren = React.Children.map(children, child => {
    const newChild = React.cloneElement(child, {
      filter,
      selectedValue: value,
      onChange,
    })
    return newChild
  })
  return (
    <div>
      <input value={filter} onChange={event => setFilter(event.currentTarget.value)} />
      {newChildren}
    </div>
  )
}
MySelect.Option = Option

function Option({ value, children, onChange, selectedValue, filter }) {
  if (!value.toLowerCase().includes(filter.toLowerCase())) return null
  return (
    <label>
      <input
        type='checkbox'
        checked={selectedValue.includes(value)}
        onChange={event => onChange(value, event.currentTarget.checked)}
      />
      {children}
    </label>
  )
}

用法:

import React, { useState } from 'react'
import Select from './index'

export default function Example() {
  const [value, setValue] = useState([])
  return (
    <div>
      <Select
        value={value}
        onChange={(optionValue, selected) => {
          // console.log(optionValue, selected)
          if (selected) {
            setValue([...value, optionValue])
          } else {
            setValue(value.filter(ele => ele !== optionValue))
          }
        }}>
        <Select.Option value='apples'>Apples</Select.Option>
        <Select.Option value='oranges'>Oranges</Select.Option>
        <Select.Option value='peaches'>Peaches</Select.Option>
        <Select.Option value='grapes'>Grapes</Select.Option>
        <Select.Option value='plums'>Plums</Select.Option>
      </Select>
    </div>
  )
}

缺点

  1. 不够灵活:使用了操作孩子组件的方法,限制了 Select 的直接子组件只能是 Option,想要使用 div 包裹 Option ,无法实现。

  2. 复制组件,有内存开销。

  3. 实现比较复杂,不易懂

使用 context 改进上一个方案

主要代码

import type { ReactNode } from 'react'
import { createContext, useState, useContext } from 'react'
import '../../components/MySelect/index.less'

type selectContextType = {
  isSelected: ((key: string) => boolean) | null
  setSelected: ((key: string, selected: boolean) => void) | null
  filter: string
}

const initialContext = {
  isSelected: null,
  setSelected: null,
  filter: '',
} as const

const SelectContext = createContext<selectContextType>(initialContext)
SelectContext.displayName = 'SelectContext' // 方便调试,不设置 显示 context

type propsType = {
  value: string[]
  onChange: (value: string[]) => void
  children: ReactNode
}

export default function Select({ children, value, onChange }: propsType) {
  const [filter, setFilter] = useState('')
  return (
    <SelectContext.Provider
      // NOTE 公共行为和状态
      value={{
        isSelected: key => value.includes(key),
        setSelected: (optionValue, selected) => {
          if (selected) {
            onChange([...value, optionValue])
          } else {
            const selectedValue = value.filter(val => val !== optionValue)
            onChange(selectedValue)
          }
        },
        filter,
      }}>
      <div className='select-container'>
        <input type='text' placeholder='支持模糊搜索' value={filter} onChange={evt => setFilter(evt.target.value)} />
        <div className='options-container'>{children}</div>
      </div>
    </SelectContext.Provider>
  )
}

Select.Option = Option

type optionPropsType = {
  children: ReactNode
  value: string
}

function Option({ children, value }: optionPropsType) {
  // NOTE 在后代组件中获取公共的行为和状态
  const { isSelected, setSelected, filter } = useContext(SelectContext)

  if (!value.toLowerCase().includes(filter.toLowerCase())) return null

  return (
    <label className={isSelected!(value) ? 'option option-selected' : 'option'}>
      <input
        type='checkbox'
        className='checkbox'
        checked={isSelected!(value)}
        onChange={evt => setSelected!(value, evt.currentTarget.checked)}
      />
      {children}
    </label>
  )
}

用法:

<Select value={selection} onChange={setSelection}>
  <Select.Option value='apples'>Apples</Select.Option>
  <Select.Option value='oranges'>Oranges</Select.Option>
  <Select.Option value='peaches'>Peaches</Select.Option>
  <Select.Option value='grapes'>Grapes</Select.Option>
  <Select.Option value='plums'>Plums</Select.Option>
</Select>

<Select value={selection} onChange={setSelection}>
  <div
    style={{
      // 可方便地修改样式
      display: 'flex',
      width: '500px',
      justifyContent: 'flex-start',
    }}
  >
    <Select.Option value='apples'>Apples</Select.Option>
    <Select.Option value='oranges'>Oranges</Select.Option>
    <Select.Option value='peaches'>Peaches</Select.Option>
    <Select.Option value='grapes'>Grapes</Select.Option>
    <Select.Option value='plums'>Plums</Select.Option>
  </div>
</Select>

优点:

  1. API 简单:避免了 props 地狱,符合直觉,易用;
  2. 灵活:用户可灵活控制子组件的顺序以及展示内容;
  3. 代码简洁:数据交互更加清楚,指责划分明确。

缺点:

  1. context 使得数据来源不清晰,这个缺点相比有点,几乎可忽略;

使用复合组件需要注意什么

使用前问问自己:

  1. 两个以上组件能更好地实现需求吗?设计良好的单个组件可满足当前需求吗?未来需求变化大吗?

  2. 采用复合组件实现易用吗?数据交互简单吗?

哪些场景适合复合组件

一个父组件和多个子组件一起使用能更好实现的需求。

  1. 表格:需要组件使用者提供数据、排序、过滤、自定义列等
<Table>
  <Row></Row>
</Table>
  1. 表单

  2. 滚动分页

<BestScroll>
  <Table>
    <Row></Row>
  </Table>
</BestScroll>
  1. 下拉多选、复选框、单选

  2. Tab 标签

  3. 菜单

总结

复合组件适合多个组件配合才能完成的需求。

角色划分

  1. 父组件通过 context 提供状态和行为
  2. 子组件通过行为改变数据
  3. 当前的状态决定子组件的渲染
  4. 复合组件形成了一个小的有状态的系统

如何设计一个组件?常见的 React 组件设计模式 --- control-props

复合组件维护自己地状态,是一个非受控组件,但是实际业务中,组件的使用者需要组件的当前状态做其他业务,因此,复合组件的状态最好能被组件外部控制,将复合组件改成受控组件。

import React, { useEffect, useRef, useState, useCallback } from 'react'
import styled from 'styled-components'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faPlus, faMinus } from '@fortawesome/free-solid-svg-icons'
import useCounterContext, { CounterProvider } from './useCounterContext'

library.add(faPlus)
library.add(faMinus)

function Counter({ children, onChange, value = null, initialValue = 0 }) {
  const [count, setCount] = useState(initialValue)
  // 判断是否为受控组件
  const isControlled = useRef(value !== null && typeof onChange === 'function')
  useEffect(() => {
    isControlled.current && onChange(count)
  }, [count])

  const handleIncrement = useCallback(() => {
    setCount(count => count + 1)
  }, [1])

  const handleDecrement = useCallback(() => {
    setCount(count => Math.max(0, count - 1))
  }, [1])

  console.log('Counter render')
  return (
    // FIXME 如何优化性能 会渲染两次
    <CounterProvider value={{ count, handleDecrement, handleIncrement }}>
      <StyledCounter>{children}</StyledCounter>
    </CounterProvider>
  )
}
export default Counter

Counter.Count = Count
Counter.Label = Label
Counter.Increment = Increment
Counter.Decrement = Decrement

function Count({ max }) {
  // NOTE 共享 context
  const { count } = useCounterContext()
  // TODO 类似 vue 的计算属性
  const hasError = max ? count >= max : false
  return <StyledCount hasError={hasError}>{count}</StyledCount>
}
const StyledCount = styled.div`
  background-color: ${({ hasError }) => (hasError ? '#bd2130' : '#17a2b8')};
  color: white;
  padding: 5px 7px;
`
// 中间的 Label
function Label({ children }) {
  // children 是用户传递的内容
  return <StyledLabel>{children}</StyledLabel>
}
const StyledLabel = styled.div`
  background-color: #e9ecef;
  color: #495057;
  padding: 5px 7px;
`
// 加减按钮
function Increment({ icon = 'plus' }) {
  // NOTE 共享行为
  const { handleIncrement } = useCounterContext()
  console.log('Increment')
  return (
    <StyledButton onClick={handleIncrement}>
      <FontAwesomeIcon color='#17a2b8' icon={icon} />
    </StyledButton>
  )
}
function Decrement({ icon = 'minus' }) {
  console.log('Decrement')
  const { handleDecrement } = useCounterContext()
  return (
    <StyledButton onClick={handleDecrement}>
      <FontAwesomeIcon color='#17a2b8' icon={icon} />
    </StyledButton>
  )
}

const StyledButton = styled.button`
  background-color: white;
  border: none;
  &:hover {
    cursor: pointer;
  }
  &:active,
  &:focus {
    outline: none;
  }
`
const StyledCounter = styled.div`
  display: inline-flex;
  border: 1px solid #17a2b8;
  line-height: 1.5;
  border-radius: 0.25rem;
  overflow: hidden;
`

用法

import React, { useState } from 'react'
import Counter from './Counter'

export default function Usage() {
  const [value, setValue] = useState(0)
  return (
    <div>
      <h3>受控组件</h3>
      <Counter
        value={value}
        onChange={value => {
          console.log(value)
          setValue(value)
        }}>
        <Counter.Decrement />
        <Counter.Label>计数器</Counter.Label>
        <Counter.Count max={10} />
        <Counter.Increment />
      </Counter>
      <hr />
      <Counter>
        <Counter.Decrement />
        <Counter.Increment />
        <Counter.Count max={10} />
        <Counter.Label>counter</Counter.Label>
      </Counter>
    </div>
  )
}

优点:

  1. 给用户更多控制权;
  2. 复合组件有的优点它都有;

缺点:
复合组件的缺点它都有。

参考文章

Making good component design decisions in React

Quick guide to React compound components

Advanced React Component Patterns

React Hooks: Compound Components

How To Master Advanced React Design Patterns — Compound Components

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant