- Published on
Odwrócenie zależności
- Authors
- Name
- Marcin Parda
To jest tłumaczenie orginalnego posta Inversion of Control autorstwa Kent C. Dodds.
Obejrzyj "Implement Inversion of Control" na egghead.io
Jeśli kiedykolwiek tworzyłeś kod, który był używany w więcej niż jednym miejscu, to prawdopodobnie znasz tę historię:
- Tworzysz kod wielokrotnego użytku (funkcję, komponent React lub React hook, itp.) I udostępniasz go (współpracownikom lub publikujesz go jako kod Open Source).
- Ktoś podchodzi do ciebie z nowym przypadkiem użycia, którego twój kod nie do końca obsługuje, ale mógłby z niewielką poprawką.
- Dodajesz kolejny argument/prop/opcję do kodu wielokrotnego użytku i powiązanej logiki, aby obsługiwać ten przypadek użycia.
- Powtórz kroki 2 i 3 kilka razy (lub wiele razy 😬).
- Kod wielokrotnego użytku jest teraz koszmarem do użycia i konserwacji 😭
Co dokładnie sprawia, że ten kod jest koszmarem do użycia i konserwacji? Istnieje kilka rzeczy, które mogą być problemem:
- 😵 Rozmiar pakietu i/lub wydajność: Jest po prostu więcej kodu do uruchomienia przez urządzenia, co może wpływać na wydajność w negatywny sposób. Czasami może być na tyle źle, że ludzie decydują się nawet nie rozważać użycia Twojego kodu ze względu na te problemy.
- 😖 Nadmiar konserwacji: Wcześniej twój kod wielokrotnego użytku miał tylko kilka opcji i skupiał się na tym, aby dobrze wykonywać jedną rzecz, ale teraz może zrobić wiele różnych rzeczy i potrzebujesz dokumentacji dla tych funkcji. Ponadto będziesz otrzymywać wiele pytań od osób, które chcą użyć go do swoich konkretnych przypadków użycia, które mogą lub nie mogą dobrze odzwierciedlać przypadki użycia, które już obsługujesz. Może nawet zdarzyć się, że dwie funkcje pozwalają na to samo, ale nieco inaczej, więc będziesz odpowiadać na pytania, która jest lepsza.
- 🐛 Złożoność implementacji: To nigdy nie jest "tylko instrukcja
if
". Każda gałąź logiki w Twoim kodzie łączy się z istniejącymi gałęziami logiki. Faktycznie istnieją sytuacje, w których możesz obsługiwać kombinację argumentów/opcji/propsów, których nikt nie używa, ale musisz upewnić się, że ich nie zepsujesz, gdy dodajesz nowe funkcje, ponieważ nie wiesz, czy ktoś używa tej kombinacji czy nie. - 😕 Złożoność API: Każdy nowy argument/opcja/prop, który dodajesz do kodu wielokrotnego użytku, sprawia, że jest trudniejszy do użycia, ponieważ masz teraz ogromną README/dokumentację, która dokumentuje wszystkie dostępne funkcje, a ludzie muszą nauczyć się wszystkiego, co jest dostępne, aby używać ich efektywnie. Jest mniej przyjemności z korzystania z kodu, ponieważ często złożoność twojego API trafia do kodu dewelopera aplikacji w sposób, który sprawia, że ich kod jest bardziej złożony.
Więc teraz wszyscy są smutni z tego powodu. Można powiedzieć, że dostarczanie jest najważniejsza, gdy tworzymy aplikacje. Ale myślę, że byłoby fajnie, gdybyśmy mogli być świadomi naszych abstrakcji (czytaj AHA Programming) i dostarczali nasze aplikacje. Jeśli istnieje coś, co możemy zrobić, aby zredukować problemy z kodem wielokrotnego użytku, jednocześnie czerpiąc korzyści z tych abstrakcji.
Wstęp: Odwrócenie zależności
Jedną z zasad, której nauczyłem się, która jest naprawdę skutecznym mechanizmem prostoty abstrakcji, jest "Odwrócenie zależności". Oto, co strona Wikipedii o odwróceniu zależności mówi o tym:
...w tradycyjnym programowaniu kod, który wyraża cel programu, wywołuje biblioteki, aby wykonać zadania ogólne, ale w przypadku odwrócenia kontroli to framework wywołuje kod lub specyficzny dla zadania.
Możesz o tym myśleć tak: "Spraw, aby twoja abstrakcja robiła mniej rzeczy, a osoby używającej jej robią to zamiast niej". Może to wydawać się sprzeczne, ponieważ częścią tego, co sprawia, że abstrakcje są takie wspaniałe, jest to, że możemy obsługiwać wszystkie złożone i powtarzające się zadania w ramach abstrakcji, dzięki czemu reszta naszego kodu może być "prosta", "porządna" lub "czysta". Ale jak już doświadczyliśmy, tradycyjne abstrakcje czasami tak nie działają.
Co to jest odwrócenie kontroli w kodzie?
Najpierw oto super wymyślony przykład:
// udawajmy że Array.prototype.filter nie istnieje
function filter(array) {
let newArray = []
for (let index = 0; index < array.length; index++) {
const element = array[index]
if (element !== null && element !== undefined) {
newArray[newArray.length] = element
}
}
return newArray
}
Teraz zagrajmy w typowy "cykl życia abstrakcji", rzucając tonę nowych powiązanych przypadków użycia w tej abstrakcji i "bezmyślnie ją ulepszając", aby obsługiwać te nowe przypadki użycia:
// udawajmy że Array.prototype.filter nie istnieje
function filter(
array,
{ filterNull = true, filterUndefined = true, filterZero = false, filterEmptyString = false } = {}
) {
let newArray = []
for (let index = 0; index < array.length; index++) {
const element = array[index]
if (
(filterNull && element === null) ||
(filterUndefined && element === undefined) ||
(filterZero && element === 0) ||
(filterEmptyString && element === '')
) {
continue
}
newArray[newArray.length] = element
}
return newArray
}
filter([0, 1, undefined, 2, null, 3, 'four', ''])
// [0, 1, 2, 3, 'four', '']
filter([0, 1, undefined, 2, null, 3, 'four', ''], { filterNull: false })
// [0, 1, 2, null, 3, 'four', '']
filter([0, 1, undefined, 2, null, 3, 'four', ''], { filterUndefined: false })
// [0, 1, 2, undefined, 3, 'four', '']
filter([0, 1, undefined, 2, null, 3, 'four', ''], { filterZero: true })
// [1, 2, 3, 'four', '']
filter([0, 1, undefined, 2, null, 3, 'four', ''], { filterEmptyString: true })
// [0, 1, 2, 3, 'four']
Dobrze, więc faktycznie potrzebujemy tylko sześciu przypadków użycia, o których aplikacja się troszczy, ale faktycznie obsługujemy dowolną kombinację tych funkcji, co daje razem 25 kombinacji (jeśli dobrze policzyłem).
I to w dodatku jest prosta abstrakcja. Jestem pewien, że można by ją uprościć. Ale często, gdy wracasz do abstrakcji po tym, jak minęło trochę czasu, okazuje się, że można ją znacznie uprościć dla przypadków użycia, które faktycznie obsługuje. Niestety, gdy abstrakcja obsługuje coś (takiego jak {filterZero: true, filterUndefined: false}
), boimy się usunąć tę funkcję z obawy przed zepsuciem aplikacji używającej naszej abstrakcji.
Nawet napiszemy testy dla przypadków użycia, których tak naprawdę jeszcze nie mamy, tylko dlatego, że nasza abstrakcja je obsługuje i "może" być to potrzebne w przyszłości. A kiedy przypadki użycia nie są już potrzebne, nie usuwamy ich obsługi, ponieważ po prostu zapominamy, myślimy, że może ich potrzebować w przyszłości, lub boimy się dotykać kodu.
Dobrze, więc teraz, rozważmy trochę przemyślanej abstrakcji na tej funkcji i zastosujmy odwrócenie kontroli, aby obsługiwać wszystkie te przypadki użycia:
// udawajmy że Array.prototype.filter nie istnieje
function filter(array, filterFn) {
let newArray = []
for (let index = 0; index < array.length; index++) {
const element = array[index]
if (filterFn(element)) {
newArray[newArray.length] = element
}
}
return newArray
}
filter([0, 1, undefined, 2, null, 3, 'four', ''], (el) => el !== null && el !== undefined)
// [0, 1, 2, 3, 'four', '']
filter([0, 1, undefined, 2, null, 3, 'four', ''], (el) => el !== undefined)
// [0, 1, 2, null, 3, 'four', '']
filter([0, 1, undefined, 2, null, 3, 'four', ''], (el) => el !== null)
// [0, 1, 2, undefined, 3, 'four', '']
filter(
[0, 1, undefined, 2, null, 3, 'four', ''],
(el) => el !== undefined && el !== null && el !== 0
)
// [1, 2, 3, 'four', '']
filter(
[0, 1, undefined, 2, null, 3, 'four', ''],
(el) => el !== undefined && el !== null && el !== ''
)
// [0, 1, 2, 3, 'four']
Świetnie! To dużo prostsze. To, co zrobiliśmy, to odwróciliśmy kontrolę! Zmieniliśmy odpowiedzialność za decydowanie, który element trafia do nowej tablicy z funkcji filter
na tę, która wywołuje funkcję filter
. Zauważ, że funkcja filter
jest nadal przydatną abstrakcją sama w sobie, ale jest znacznie bardziej zdolna.
Ale czy poprzednia wersja tej abstrakcji była aż tak zła? Może nie. Ale ponieważ odwróciliśmy kontrolę, możemy teraz obsługiwać znacznie bardziej unikalne przypadki użycia:
filter(
[
{ name: 'dog', legs: 4, mammal: true },
{ name: 'dolphin', legs: 0, mammal: true },
{ name: 'eagle', legs: 2, mammal: false },
{ name: 'elephant', legs: 4, mammal: true },
{ name: 'robin', legs: 2, mammal: false },
{ name: 'cat', legs: 4, mammal: true },
{ name: 'salmon', legs: 0, mammal: false },
],
(animal) => animal.legs === 0
)
// [
// {name: 'dolphin', legs: 0, mammal: true},
// {name: 'salmon', legs: 0, mammal: false},
// ]
Wyobraź sobie, że musisz dodać obsługę tego wcześniej, przed odwróceniem kontroli? To byłoby po prostu głupie...
Gorsze API?
Jedną z częstych skarg, które słyszę od ludzi na temat API z odwróconą kontrolą, które stworzyłem, jest: "Tak, ale teraz jest trudniejsze w użyciu niż wcześniej". Weźmy ten przykład:
// wcześniej
filter([0, 1, undefined, 2, null, 3, 'four', ''])
// teraz
filter([0, 1, undefined, 2, null, 3, 'four', ''], (el) => el !== null && el !== undefined)
Tak, jedno z nich jest wyraźnie łatwiejsze w użyciu niż drugie. Ale oto rzecz dotycząca API z odwróconą kontrolą, możesz ich użyć do ponownej implementacji poprzedniego API i zwykle jest to dość trywialne. Na przykład:
function filterWithOptions(
array,
{ filterNull = true, filterUndefined = true, filterZero = false, filterEmptyString = false } = {}
) {
return filter(
array,
(element) =>
!(
(filterNull && element === null) ||
(filterUndefined && element === undefined) ||
(filterZero && element === 0) ||
(filterEmptyString && element === '')
)
)
}
Fajnie, prawda!? Więc możemy budować abstrakcje na podstawie API z odwróconą kontrolą, które dają prostsze API, których ludzie szukają. I co więcej, jeśli nasze "prostsze" API nie jest wystarczające dla ich przypadku użycia, to mogą użyć tych samych bloczków budulcowych, których użyliśmy do zbudowania naszego API wyższego poziomu, aby wykonać swoje bardziej złożone zadanie. Nie muszą prosić nas o dodanie nowej funkcji do filterWithOptions
i czekać, aż zostanie to zakończone. Mają narzędzia, których potrzebują, aby wysłać swoje rzeczy, ponieważ daliśmy im je.
Aha, i dla zabawy:
function filterByLegCount(array, legCount) {
return filter(array, (animal) => animal.legs === legCount)
}
filterByLegCount(
[
{ name: 'dog', legs: 4, mammal: true },
{ name: 'dolphin', legs: 0, mammal: true },
{ name: 'eagle', legs: 2, mammal: false },
{ name: 'elephant', legs: 4, mammal: true },
{ name: 'robin', legs: 2, mammal: false },
{ name: 'cat', legs: 4, mammal: true },
{ name: 'salmon', legs: 0, mammal: false },
],
0
)
// [
// {name: 'dolphin', legs: 0, mammal: true},
// {name: 'salmon', legs: 0, mammal: false},
// ]
Możesz komponować te rzeczy tak, jak chcesz, aby rozwiązać wspólne przypadki użycia, których potrzebujesz.
Ok, ale teraz na serio?
Odwrócenie zależności działa dla prostego przypadku użycia, ale do czego to jest dobre w prawdziwym świecie? Prawdopodobnie używasz API z odwróconą kontrolą cały czas, nie zauważając. Na przykład faktyczna funkcja Array.prototype.filter
odwraca kontrolę. Tak samo jak funkcja Array.prototype.map
.
Istnieją również wzorce, z którymi możesz być zaznajomiony, które są w zasadzie formą odwrócenia kontroli.
Moje dwa ulubione wzorce to "Compound Components" i "State Reducers". Oto szybki przykład, jak można ich użyć.
Compound Components
Powiedzmy, że chcesz zbudować komponent Menu
, który ma przycisk do otwierania menu i listę elementów menu do wyświetlenia po kliknięciu. Następnie, gdy zostanie wybrany element, zostanie wykonana pewna akcja. Powszechnym podejściem do tego rodzaju komponentu jest utworzenie propsów dla każdej z tych rzeczy:
function App() {
return (
<Menu
buttonContents={
<>
Actions <span aria-hidden>▾</span>
</>
}
items={[
{ contents: 'Download', onSelect: () => alert('Download') },
{ contents: 'Create a Copy', onSelect: () => alert('Create a Copy') },
{ contents: 'Delete', onSelect: () => alert('Delete') },
]}
/>
)
}
Dzięki temu możemy dostosować wiele rzeczy w naszym elemencie Menu. Ale co, jeśli chcielibyśmy wstawić linię przed elementem menu Delete? Czy musielibyśmy dodać opcję do tablicy obiektów elementów? Na przykład: precedeWithLine
? O matko. Może mielibyśmy specjalny rodzaj elementu menu, który jest {contents: <hr />}
. Myślę, że to by zadziałało, ale wtedy musielibyśmy obsłużyć przypadek, w którym nie jest dostarczony onSelect
. I to jest naprawdę nieporęczne API.
Gdy myślisz o tym, jak stworzyć ładne API dla osób, które próbują robić rzeczy nieco inaczej, zamiast sięgać po instrukcje if
i operator trójskładniowy (ternary operator), rozważ możliwość odwrócenia kontroli. W tym przypadku, co jeśli byśmy dali odpowiedzialność renderowania użytkownikowi naszych komponentów? Wykorzystajmy jedną z największych zalet Reacta, jaką jest komponowalność:
function App() {
return (
<Menu>
<MenuButton>
Actions <span aria-hidden>▾</span>
</MenuButton>
<MenuList>
<MenuItem onSelect={() => alert('Download')}>Download</MenuItem>
<MenuItem onSelect={() => alert('Copy')}>Create a Copy</MenuItem>
<MenuItem onSelect={() => alert('Delete')}>Delete</MenuItem>
</MenuList>
</Menu>
)
}
Kluczową rzeczą do zauważenia tutaj jest to, że nie ma stanu widocznego dla użytkownika komponentów. Stan jest niejawnie współdzielony między tymi komponentami. To jest główną wartością wzorca komponentów złożonych. Korzystając z tej możliwości, oddaliśmy niektóre zadania renderowania użytkownikowi naszych komponentów, a teraz dodanie dodatkowej linii (lub czegokolwiek innego) jest dość trywialne i intuicyjne. Nie ma dokumentacji API do wyszukiwania, a nie ma dodatkowych funkcji, kodu ani testów do dodania. Duży sukces dla wszystkich.
Możesz przeczytać więcej na temat tego wzorca na moim blogu. Podziękowania dla Ryana Florence, który mnie tego wzorca nauczył.
State Reducer
Jest to wzorzec, który wymyśliłem, aby rozwiązać problem dostosowywania logiki komponentu. Więcej na ten temat możesz przeczytać w moim poście na blogu "The State Reducer Pattern", ale podstawową ideą jest to, że miałem bibliotekę wyszukiwania/wpisywania/autouzupełniania wejścia o nazwie Downshift
, a ktoś budował wersję wielokrotnego wyboru komponentu, więc chcieli, aby menu pozostało otwarte nawet po wybraniu elementu.
W Downshift
mieliśmy logikę, która mówiła, że powinna zamknąć się, gdy zostanie dokonany wybór. Osoba potrzebująca funkcji zaproponowała dodanie propa o nazwie closeOnSelection
. Odrzuciłem to, ponieważ byłem już na tej drodze apropcalypse i chciałem tego uniknąć.
Zamiast tego wymyśliłem API dla osób, które chcą kontrolować, jak zmiana stanu następuje. Pomyśl o reduktorze stanu jako o funkcji, która jest wywoływana za każdym razem, gdy stan komponentu się zmienia i daje deweloperowi aplikacji szansę na zmodyfikowanie zmiany stanu, która ma się wydarzyć.
Oto przykład tego, co zrobisz, jeśli chcesz, aby Downshift nie zamykał menu po wybraniu elementu przez użytkownika:
function stateReducer(state, changes) {
switch (changes.type) {
case Downshift.stateChangeTypes.keyDownEnter:
case Downshift.stateChangeTypes.clickItem:
return {
...changes,
// jesteśmy w porządku z dowolnymi zmianami, które Downshift chce wprowadzić
// z wyjątkiem tego, że pozostawimy isOpen i highlightedIndex bez zmian.
isOpen: state.isOpen,
highlightedIndex: state.highlightedIndex,
}
default:
return changes
}
}
// a następnie podczas renderowania komponentu
// <Downshift stateReducer={stateReducer} {...restOfTheProps} />
Po dodaniu tego propa dostaliśmy ZNACZNIE mniej żądań dostosowania komponentu. Stał się ZNACZNIE bardziej zdolny i o wiele prostszy dla ludzi, aby zrobić z nim cokolwiek chcieli.
Render Props
Chciałbym tylko krótko wspomnieć o wzorcu render props, który jest doskonałym przykładem odwrócenia kontroli, ale nie potrzebujemy go tak często, więc nie będę o nich mówić.
Przeczytaj, dlaczego już nie potrzebujemy Render Props tak często
Słowo ostrzeżenia
Odwrócenie kontroli jest fantastycznym sposobem na obejście problemu dokonywania nieprawidłowych założeń dotyczących przyszłych przypadków użycia naszego kodu wielokrotnego użytku. Ale zanim pójdziesz, chcę ci dać kilka rad. Wróćmy na chwilę do naszego wymyślonego przykładu:
// udawajmy że Array.prototype.filter nie istnieje
function filter(array) {
let newArray = []
for (let index = 0; index < array.length; index++) {
const element = array[index]
if (element !== null && element !== undefined) {
newArray[newArray.length] = element
}
}
return newArray
}
// przypadki użycia:
filter([0, 1, undefined, 2, null, 3, 'four', ''])
// [0, 1, 2, 3, 'four', '']
Co, jeśli to wszystko, czego kiedykolwiek potrzebowaliśmy, aby filter
działał i nigdy nie napotkaliśmy sytuacji, w której potrzebowaliśmy filtrować cokolwiek innego niż null
i undefined
? W takim przypadku dodanie odwrócenia kontroli dla pojedynczego przypadku użycia sprawiłoby, że kod byłby bardziej skomplikowany i nie zapewniłby wiele wartości.
Jak w przypadku każdej abstrakcji, proszę o rozwagę i zastosowanie zasady AHA Programming i unikanie pochopnych abstrakcji!
Podsumowanie
Mam nadzieję, że to ci pomoże. Pokazałem Ci kilka wzorców w ekosystemie React, które wykorzystują koncepcję odwrócenia kontroli. Istnieją inne, a koncepcja ta dotyczy nie tylko React (jak widzieliśmy na przykładzie filter
). Następnym razem, gdy dodasz kolejne instrukcje if
do funkcji coreBusinessLogic
swojej aplikacji, rozważ, jak możesz odwrócić kontrolę i przenieść logikę do miejsca, w którym jest używana (lub jeśli jest używana w wielu miejscach, możesz zbudować bardziej dopasowaną do tego konkretnego przypadku użycia abstrakcję).
Jeśli chcesz pobawić się przykładem w tym poście na blogu, zapraszam tutaj:
Powodzenia!
P.S. Jeśli podobał Ci się ten post na blogu, prawdopodobnie spodoba Ci się ta rozmowa:
Widzisz błąd / literówkę w artykule? Zgłoś poprawkę lub dodaj komentarz na dole.
Zobacz post na GitHubie