Давайте рассмотрим искусственный пример кода, который, как и в жизни, постепенно будет расширяться и усложняться, а наша задача, глядя на это всё, понять: не пора ли рефакторить. План наших действий: задача – решение – анализ – рефакторинг. Приступим.
Задача: в проект нужны тултипы. Сказано – сделано.
interface OwnProps {
hint: string
}
export const Tooltip: FC<OwnProps> = ({ hint, children }) => {
// допустим, в зависимости от кол-ва символов и пространства на экране
// производится позиционирование
const [config, setConfig] = useState(null)
const ref = useRef(null)
useLayoutEffect(() => {
// реализация алгоритма позиционирования
// ...
setConfig(someConfig)
}, [hint])
return (
<div ref={ref}>
{children}
<TooltipComponent config={config} hint={hint} />
</div>
)
Спустя какое-то время в проекте должен появиться ещё один тултип, он красивее и принимает обработчик клика. Самое простое и кратчайшее решение – изменить имеющийся компонент Tooltip.
interface TooltipProps {
hint: string
onClick?: () => void
}
export const Tooltip: FC<TooltipProps> = ({ hint, children, onClick }) => {
// допустим, в зависимости от кол-ва символов и пространства на экране
// производится позиционирование
const [config, setConfig] = useState(null)
const ref = useRef(null)
useLayoutEffect(() => {
// реализация алгоритма позиционирования
// ...
setConfig(someConfig)
}, [hint])
// А ВОТ И НОВЫЙ ВАРИАНТ ИСПОЛЬЗОВАНИЯ!!!
// в этом компоненте уже обязательно нужен onClick
if (onClick) {
return (
<div ref={ref}>
{children}
<AnotherTooltipComponent config={config} hint={hint} onClick={onClick} />
</div>
)
}
return (
<div ref={ref}>
{children}
<TooltipComponent config={config} hint={hint} />
</div>
)
}
Мы модифицировали старый компонент, добавили инструкцию if и всё заработало. Единственное, что несколько смущает на данном этапе, это то, что из интерфейса TooltipProps совсем не очевидно, что обработчик onClick на самом деле не просто опциональное свойство, а ещё и определитель: какой вариант тултипа нужно вернуть. В общем, может и не очевидно, а может и очевидно, ясно одно: Done is better than perfect.
И вот нас снова просят добавить новый тултип – DiscountTooltipComponent, который тоже обязательным свойством принимает обработчик onClick. Чтобы отличать два компонента DiscountTooltipComponent от AnotherTooltipComponent мы используем дополнительное свойство type.
interface TooltipProps {
hint: string
type?: 'another' | 'discount'
onClick?: () => void
}
export const Tooltip: FC<TooltipProps> = ({ type, hint, children, onClick }) => {
// допустим, в зависимости от кол-ва символов и пространства на экране
// производится позиционирование
const [config, setConfig] = useState(null)
const ref = useRef(null)
useLayoutEffect(() => {
// реализация алгоритма позиционирования
// ...
setConfig(someConfig)
}, [hint])
// А ВОТ И НОВЫЙ ВАРИАНТ ИСПОЛЬЗОВАНИЯ!!!
// в этом компоненте уже обязательно нужен onClick
if (type && onClick) {
return (
<div ref={ref}>
{children}
{type === 'another' ? (
<AnotherTooltipComponent config={config} hint={hint} onClick={onClick} />
) : (
<DiscountTooltipComponent config={config} hint={hint} onClick={onClick} />
}
</div>
)
}
return (
<div ref={ref}>
{children}
<TooltipComponent config={config} hint={hint} />
</div>
)
}
Условие в инструкции if усложнилось, внутри появился тернарный оператор, само собой код стало сложнее читать. И вот когда условия становятся сложнее и специфичнее, стоит это дело проанализировать.
Начнём сверху, с интерфейса TooltipProps. Глядя на него, совсем не очевидно, что поля type и onClick связаны между собой. Следовательно, не очевидны и варианты использования компонента Tooltip. Мы можем указать type = "another", но не передать onClick, и тогда typescript не выдаст ошибки.
Самое время обратиться к принципу разделения интерфейсов (Interface Segregation Principle), который на уровне компонентов называется принципом совместного повторного использования. Он гласит:
Не вынуждайте пользователей компонента зависеть от того, чего им не требуется.
Чтобы проблема стала видна отчётливее, представим, что прошло ещё немного времени.
Аналитики просят залогировать нажатие на DiscountTooltipComponent.
interface TooltipProps {
hint: string
type?: 'another' | 'discount'
onClick?: () => void
}
export const Tooltip: FC<TooltipProps> = ({ type, hint, children, onClick }) => {
// допустим, в зависимости от кол-ва символов и пространства на экране
// производится позиционирование
const [config, setConfig] = useState(null)
const ref = useRef(null)
useLayoutEffect(() => {
// реализация алгоритма позиционирования
// ...
setConfig(someConfig)
}, [hint])
// ЗДЕСЬ МЫ БУДЕМ ЛОГИРОВАТЬ
const handleClick = () => {
if (type === 'discount') {
// произвести логирование
}
if (onClick) {
onClick()
}
}
// А ВОТ И НОВЫЙ ВАРИАНТ ИСПОЛЬЗОВАНИЯ!!!
// в этом компоненте уже обязательно нужен onClick
if (type) {
return (
<div ref={ref}>
{children}
{type === 'another' ? (
<AnotherTooltipComponent config={config} hint={hint} onClick={handleClick} />
) : (
<DiscountTooltipComponent config={config} hint={hint} onClick={handleClick} />
}
</div>
)
}
return (
<div ref={ref}>
{children}
<TooltipComponent config={config} hint={hint} />
</div>
)
}
Теперь все, кто использовал Tooltip в его первозданном виде, получили в нагрузку handleClick, который ими никак не используется, но ресурсы на него расходуются. А те, кто использовал компонент с type='another', получили не нужную обертку handleClick. Что, если мы разделим интерфейсы, например:
interface Tooltip {
hint: string
}
interface TooltipInteractive extends Tooltip {
onClick: () => void
}
Теперь выделим общую логику в компонент TooltipSettings:
interface TooltipSettingsProps {
hint: string
render: (config: any, hint: string) => JSX.Element
}
export const TooltipSettings: FC<TooltipSettingsProps> = ({ render }) => {
// допустим в зависимости от кол-ва символов и пространства на экране
// производится позиционирование
const [config, setConfig] = useState(null)
const ref = useRef(null)
useLayoutEffect(() => {
// реализация алгоритма позиционирования
// ...
setConfig(someConfig)
}, [hint])
return (
<div ref={ref}>
{children}
{render(config, hint)}
</div>
)
}
Реализуем интерфейс Tooltip:
export const Tooltip: FC<Tooltip> = ({ hint }) => (
<TooltipSettings hint={hint} render={(config, hint) => <TooltipComponent config={config} hint={hint} />} />
)
Реализуем интерфейс TooltipInteractive:
export const AnotherTooltip: FC<TooltipInteractive> = ({ hint, onClick }) => (
<TooltipSettings
hint={hint}
render={(config, hint) => <AnotherTooltipComponent onClick={onClick} config={config} hint={hint} />}
/>
)
В частности DiscountTooltipComponent:
export const DiscountTooltip: FC<TooltipInteractive> = ({ hint, onClick }) => {
const handleClick = () => {
// произвести логирование
// вызвать обработчик
onClick()
}
return (
<TooltipSettings
hint={hint}
render={(config, hint) => <DiscountTooltipComponent onClick={handleClick} config={config} hint={hint} />}
/>
)
}
Ничто так не усложняет понимание кода, как обилие ветвлений – в том числе инструкций if, – и специфичных условий. Чем быстрее мы стараемся реализовать задачу, тем больше компонентов мы помещаем "под одной крышей". Это помогает выиграть время на короткой дистанции, но вероятность того, что однажды этот код станет легче полностью переписать, чем расширить или изменить, неуклонно возрастает. Предугадать, как будет развиваться проект даже в ближайшие год-два, задача нетривиальная в нашем быстро меняющемся мире, а вот вовремя реагировать на изменения – задача вполне посильная.