[Перевод] React, я люблю тебя, но ты сводишь меня с ума

jchwst6a3nwlxlscnmuw5tdhmry.png


Привет, друзья!

Представляю вашему вниманию перевод этой статьи, вызывавшей определенный резонанс в сообществе React-разработчиков.

Дорогой React, мы встречаемся уже почти 10 лет. Мы прошли долгий путь вместе. Но ситуация вышла из-под контроля. Нам нужно поговорить.


Ты был единственным

Ты у меня не первый. До тебя у меня были длительные отношения с jQuery, Backbone и Angular. Я знал, чего следует ожидать от фреймворка: лучший пользовательский интерфейс, продуктивность и опыт разработки, но также фрустрация от необходимости изменения способа написания кода в угоду парадигме фреймворка.

Когда я тебя встретил, я находился в длительных отношениях с Angular. Я был измотан watch и digest, не говоря уже о scope. Я искал то, что не заставляло бы меня чувствовать себя несчастным.

Это была любовь с первого взгляда. Твое однонаправленное связывание данных было таким освежающим по сравнению с тем, что я знал. Целая категория проблем с синхронизацией данных и производительностью для тебя просто не существовала. Ты был чистым JavaScript, а не его жалким подобием, представленным строкой в элементе HTML. У тебя была вещь под названием «декларативный компонент», которая была настолько прекрасной, что все на тебя засматривались.


fqu3rfvc8o6lpmwjpxnwap8addq.png

Понять тебя было не просто. Мне пришлось поработать над своими привычками кодирования, чтобы поладить с тобой, но это того стоило! Поначалу я был так счастлив с тобой, что все время рассказывал о тебе окружающим.


Герои новых форм

Странности начались, когда я попросил тебя обработать отправку формы. На чистом JS с формами и инпутами работать сложно, на React — еще сложнее.

Сначала разработчик должен выбрать между управляемыми и неуправляемыми инпутами. Оба подхода имеют свои недостатки и баги в пограничных ситуациях. Но почему я должен делать такой выбор? Два различных подхода — это слишком много.

«Рекомендуемым» подходом являются управляемые компоненты, которые очень многословны. Для того, чтобы отправить форму, требуется написать такой код:

import React, { useState } from 'react';

export default () => {
    const [a, setA] = useState(1);
    const [b, setB] = useState(2);

    function handleChangeA(event) {
        setA(+event.target.value);
    }

    function handleChangeB(event) {
        setB(+event.target.value);
    }

    return (
        

{a} + {b} = {a + b}

); };

И если бы существовало только два подхода, я был бы счастлив. Но и-за огромного количества кода, который требуется для разработки реальной формы с дефолтными значениями, валидацией, зависимыми инпутами и сообщениями об ошибках, я вынужден использовать сторонние библиотеки для работы с формами. Каждая из них имеет свои недостатки:


  • Redux-form была естественным выбором при использовании Redux, но однажды ее ведущий разработчик покинул проект, чтобы заняться разработкой
  • React-final-form, которая содержала большое количество багов и… ведущий разработчик снова ушел. После этого я стал использовать
  • Formik, популярное, но тяжелое решение, медленное для больших форм с ограниченным функционалом. Поэтому я решил использовать
  • React-hook-form, которая является быстрой, но содержит скрытые баги и имеет документацию, похожую на лабиринт.

После многих лет работы с формами в React, я все еще не знаю, как делать это правильно. Когда я смотрю на то, как работает с формами Svelte, я начинаю чувствовать, что в React применяется неправильная абстракция. Взгляните на этот код:






{a} + {b} = {a + b}


Ты слишком чувствителен к контексту

Вскоре после нашего знакомства, ты представил мне своего щенка Redux. Ты не мог никуда без него пойти. Поначалу я не возражал, поскольку это было очень мило. Но вскоре я осознал, что вокруг него вращается твой мир. Кроме того, это сильно усложняло разработку фреймворка — другие разработчики не могли легко настраивать существующие редукторы.

Ты тоже это заметил и решил избавиться от Redux в пользу собственного useContext. Но в useContext отсутствует критически важная особенность Redux — возможность реагировать на изменения только части контекста. Приведенные ниже строки кода не эквиваленты с точки зрения производительности:

// Redux
const name = useSelector(state => state.user.name);
// контекст React
const { name } = useContext(UserContext);

В первом случае компонент будет подвергнут повторному рендерингу только при изменении имени. Во втором — при изменении любых данных пользователя. Это очень важно. Доходит до того, что приходится разделять контекст на части для предотвращения повторного рендеринга:

// Это безумие, но по-другому никак
export const CoreAdminContext = (props) => {
    const {
        authProvider,
        basename,
        dataProvider,
        i18nProvider,
        store,
        children,
        history,
        queryClient,
    } = props;

    return (
        
            
                
                    
                        
                            
                                
                                    
                                        {children}
                                    
                                
                            
                        
                    
                
            
        
    );
};


ju1jfzvnf5jvuzj0f5y-tl2atee.gif

В подавляющем большинстве случаев причиной проблем с производительностью является огромный контекст, поэтому у меня нет другого выбора, кроме как разделять его на части.

Я не хочу использовать useMemo() и useCallback(). Лишние ререндеринги — это твоя проблема, а не моя. Но ты заставляешь меня делать это. Посмотри, как мне приходится разрабатывать простую форму, чтобы она работала достаточно быстро:

// Источник: https://react-hook-form.com/advanced-usage/#FormProviderPerformance
const NestedInput = memo(
    ({ register, formState: { isDirty } }) => (
        
{isDirty &&

This field is dirty

}
), (prevProps, nextProps) => prevProps.formState.isDirty === nextProps.formState.isDirty, ); export const NestedInputContainer = ({ children }) => { const methods = useFormContext(); return ; };

Прошло почти 10 лет, а проблема остается. Насколько сложно разработать useContextSelector()?

Разумеется, ты знаешь об этом. Но ты занимаешься чем угодно, кроме этого, хотя, вероятно, это твое самое узкое место с точки зрения производительности.


Мне не нужно ничего из этого

Ты объяснил мне, что я не должен обращаться к узлам DOM напрямую. Я никогда не считал DOM грязным (dirty), но поскольку тебя это тревожило, я перестал это делать. Теперь я использую refs (ссылки), как ты меня просил.

Но эти ссылки распространяются как вирус. В большинстве случаев при использовании ссылок компонентом, он передает их потомкам. Если потомком является компонент React, он должен перенаправить (передать) ссылку (forward ref) другому компоненту и т.д. до тех пор, пока один из компонентов, наконец, не отрендерит соответствующий элемент HTML.

Перенаправление ссылок могло бы выглядеть так:

const MyComponent = (props) => 
Hello, {props.name}!
;

Но это было бы слишком просто. Вместо этого следует использовать (в оригинале abomination — мерзость) React.forwardRef:

const MyComponent = React.forwardRef((props, ref) => (
    
Hello, {props.name}!
));

В чем проблема? — спросишь ты. В том, что forwardRef() (в случае с TypeScript) не позволяет создавать общие (generic) компоненты:

// Как мне передать ссылку в этом случае?
const MyComponent = (props: ) => (
    
Hello, {props.name}!
);

Более того, ты решил, что ссылки предназначены не только для узлов DOM, но также являются эквивалентом this в функциональных компонентах. Или, другими словами, ссылка — это «состояние, изменение которого не влечет повторный рендеринг». На мой взгляд, мне приходится использовать ссылки по той причине, что твой интерфейс useEffect() является слишком странным. Другими словами, refs — это решение созданной тобой же проблемы.


Эффект бабочки (в оригинале используется игра слов — the butterfly (use) effect)

У меня несколько вопросов к useEffect(). Я понимаю, что useEffect() — это элегантное решение, унификация обработки событий монтирования, размонтирования и обновления в одном интерфейсе. Но как такое можно считать прогрессом?

// колбек жизненного цикла
class MyComponent {
    componentWillUnmount: () => {
        // ...
    };
}

// useEffect
const MyComponent = () => {
    useEffect(() => {
        return () => {
            // ...
        };
    }, []);
};

}, []); — от одной этой строчки мне становится дурно.

Я вижу эти загадочные наборы каббалистических символов по всему моему коду. Более того, ты заставляешь меня следить за зависимостями моего кода, например:

// Меняем страницу при отсутствии данных
useEffect(() => {
    if (
        query.page <= 0 ||
        (!isFetching && query.page > 1 && data?.length === 0)
    ) {
        // Запрашиваем страницу, которой не существует, устанавливаем `page` в значение `1`
        queryModifiers.setPage(1);
        return;
    }
    if (total == null) {
        return;
    }
    const totalPages = Math.ceil(total / query.perPage) || 1;
    if (!isFetching && query.page > totalPages) {
        // Запрашиваем страницу за пределами диапазона, устанавливаем значение `page` в значение последней существующей страницы
        // Выполняется при удалении последнего элемента на последней странице
        queryModifiers.setPage(totalPages);
    }
}, [isFetching, query.page, query.perPage, data, queryModifiers, total]);

Видите последнюю строку? Я должен быть уверен, что включил все реактивные переменные в массив зависимостей. А мне казалось, что подсчет ссылок (reference counting) является встроенной возможностью всех языков программирования со сборщиком мусора. Но нет, я вынужден сам управлять зависимостями, поскольку ты не умеешь этого делать.


jfrcykgif3cusuzryadjfwh9n8w.gif

Часто одной из зависимостей является созданная мной функция. Поскольку ты не видишь разницы между переменной и функцией, мне приходится предотвращать повторный рендеринг с помощью useCallback(), который также требует наличия массива зависимостей:

const handleClick = useCallback(
    async event => {
        event.persist();
        const type =
            typeof rowClick === 'function'
                ? await rowClick(id, resource, record)
                : rowClick;
        if (type === false || type == null) {
            return;
        }
        if (['edit', 'show'].includes(type)) {
            navigate(createPath({ resource, id, type }));
            return;
        }
        if (type === 'expand') {
            handleToggleExpand(event);
            return;
        }
        if (type === 'toggleSelection') {
            handleToggleSelection(event);
            return;
        }
        navigate(type);
    },
    [
        // О, Боже, нет
        rowClick,
        id,
        resource,
        record,
        navigate,
        createPath,
        handleToggleExpand,
        handleToggleSelection,
    ],
);

Простой компонент с несколькими обработчиками событий и колбеками жизненного цикла становится кучей тарабарского кода благодаря необходимости управлять этим адом зависимостей. А все из-за твоего решения о том, что компонент может рендериться произвольное количество раз.

Например, если я хочу создать счетчик, значение которого увеличивается каждую секунду и при каждом нажатии пользователем кнопки, я должен сделать следующее:

function Counter() {
    const [count, setCount] = useState(0);

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

    useEffect(() => {
        const id = setInterval(() => {
            setCount(count => count + 1);
        }, 1000);
        return () => clearInterval(id);
    }, [setCount]);

    useEffect(() => {
        console.log('Значение счетчика:', count);
    }, [count]);

    return ;
}

Если бы ты знал, как управлять зависимостями, я мог бы написать что-то вроде:

function Counter() {
    const [count, setCount] = createSignal(0);

    const handleClick = () => setCount(count() + 1);

    const timer = setInterval(() => setCount(count() + 1), 1000);

    onCleanup(() => clearInterval(timer));

    createEffect(() => {
        console.log('Значение счетчика:', count());
    });

    return ;
}

К слову, это валидный код Solid.js.


6nmazdns6fvpfzuly1p-7ivdulg.gif

Наконец, мудрое использование useEffect() требует прочтения 53-страничного руководства. По-моему, это ужасно. Если для правильного применения инструмента необходимо изучить такой объем материала, не говорит ли это о том, что данный инструмент не очень хорошо спроектирован?


Прими решение, наконец

Ты пытался улучшить useEffect и представил мне useEvent, useInsertionEffect, useDeferredValue, useSyncExternalStore и другие уловки.

Они позволяют тебе выглядеть красиво:

function subscribe(callback) {
    window.addEventListener('online', callback);
    window.addEventListener('offline', callback);
    return () => {
        window.removeEventListener('online', callback);
        window.removeEventListener('offline', callback);
    };
}

function useOnlineStatus() {
    return useSyncExternalStore(
        subscribe, // React не будет выполнять повторную подписку при передаче одной и той же функции
        () => navigator.onLine, // Как получать значение на клиенте
        () => true, // Как получать значение на сервере
    );
}

Но, на мой взгляд, это помада на свинье. Если бы реактивные эффекты было легче использовать, другие хуки просто бы не потребовались.

Другими словами: тебе приходится расширять основной интерфейс все больше и больше. Для людей, вроде меня, поддерживающих огромные кодовые базы, это постоянная инфляция API является ночным кошмаром. Непрестанное увеличение косметики постоянно напоминает о том, что ты пытаешься скрыть.


Ты слишком строгий

Хуки — это отличная идея, но они имеют свою цену. И эта цена — правила использования хуков. Их нелегко запомнить, еще сложнее применять их на практике. Но они заставляют меня тратить на код гораздо больше времени, чем нужно.

Например, у меня есть «компонент-инспектор», который является перетаскиваемым. Пользователи также могут скрывать этот компонент. В скрытом состоянии ничего не рендерится. Поэтому мне бы хотелось осуществлять «ранний выход», чтобы не регистрировать обработчики для ничего:

const Inspector = ({ isVisible }) => {
    if (!isVisible) {
        // ранний выход
        return null;
    }
    useEffect(() => {
        // Добавляем обработчики
        return () => {
            // Удаляем обработчики
        };
    }, []);
    return 
...
; };

Но нет, согласно правилам useEffect и другие хуки не могут вызываться условно. Поэтому мне приходится добавлять условие раннего выхода во все эффекты в случае, когда проп isVisible имеет значение false:

const Inspector = ({ isVisible }) => {
    useEffect(() => {
        if (!isVisible) {
            return;
        }
        // Добавляем обработчики
        return () => {
            // Удаляем обработчики
        };
    }, [isVisible]);

    if (!isVisible) {
        // Выход не такой ранний, каким мог бы быть
        return null;
    }
    return 
...
; };

Как следствие, все эффекты будут иметь isVisible в качестве зависимости. Потенциально эти эффекты могут запускаться слишком часто, что повредит производительности. Я знаю, что я должен создать промежуточный компонент для предотвращения рендеринга чего-либо в случае, когда isVisible === false. Но почему я должен это делать? Это всего лишь один пример сложностей, возникающих в связи с необходимостью соблюдения правил использования хуков — существует много других примеров. В результате большАя часть моего кода направлена исключительно на удовлетворение правил.

Правила хуков — это следствие деталей реализации — реализации, выбранной тобой для хуков. Но так быть не должно.


Ты слишком старый

Ты появился в 2013 году и стараешься обеспечивать обратную совместимость новых фич так долго, как это только возможно. И я благодарен тебе за это — это одна причин, по которой я смог разработать огромную кодовую базу с тобой. Но обеспечение обратной совместимости имеет свою цену: документация и многие ресурсы сообщества, в лучшем случае, устарели, в худшем — вводят в заблуждение.

Например, когда я ищу «Отслеживание позиции курсора в React» на «StackOverflow», первым результатом является решение, устаревшее еще 100 лет назад:

class ContextMenu extends React.Component {
    state = {
        visible: false,
    };

    render() {
        return (
            
        );
    }

    startDrawing(e) {
        console.log(
            e.clientX - e.target.offsetLeft,
            e.clientY - e.target.offsetTop,
        );
    }

    drawPen(cursorX, cursorY) {
        // Для отображение информации в подписи
        this.context.updateDrawInfo({
            cursorX: cursorX,
            cursorY: cursorY,
            drawingNow: true,
        });

        // Рисование
        const canvas = this.refs.canvas;
        const canvasContext = canvas.getContext('2d');
        canvasContext.beginPath();
        canvasContext.arc(
            cursorX,
            cursorY /* начальная позиция */,
            1 /* радиус */,
            0 /* начальный угол */,
            2 * Math.PI /* конечный угол */,
        );
        canvasContext.stroke();
    }
}


blvi-rkb9f2giq25kwviy8a_ba0.gif

Когда я ищу npm-пакет для определенной фичи React, я, в основном, нахожу заброшенные пакеты со старым синтаксисом. Например, react-draggable. Это де-факто стандарт для реализации перетаскивания в React. У него много открытых вопросов и низкая активность разработки. Вероятно, старый синтаксис (классы) не привлекает новых участников (contributors).

Официальная документация по-прежнему рекомендует использовать componentDidMount и componentWillUnmount вместо useEffect. Команда разработчиков React работает над новой версией документации под названием Beta docs на протяжении последних двух лет. А воз и ныне там.

Наконец, долгий переход на хуки до сих пор не завершен, что приводит к фрагментации сообщества. Новые разработчики изо всех сил пытаются найти свой путь в экосистеме React, а старые — стараются идти в ногу со временем.


Семейное дело

Поначалу твой отец Facebook выглядел очень круто. Facebook хотел «Сблизить людей» — я в деле! Когда бы я ни приезжал к твоим родителям, я всегда встречал новых друзей.

Но затем все стало очень плохо. Твои родители участвовали в схеме манипулирования толпой. Они изобрели концепцию «Фейковых новостей». Они начали собирать обо всех информацию без их согласия. Посещать твоих родителей стало опасно — до такой степени, что несколько лет назад я удалил свой аккаунт Facebook.

Я понимаю, что дети не отвечают за поступки родителей. Но ты все еще живешь с ними. Они финансируют твою разработку. Они — твои крупнейшие пользователи. Ты зависишь от них. Если в один прекрасный день они поплатятся за свое поведение, ты пострадаешь вместе с ними.

Другие основные JS-фреймворки смогли освободиться от родителей. Они стали независимыми и присоединились к организации под названием OpenJS Foundation. Node.js, Electron, Webpack, Lodash, ESlint и даже Jest теперь финансируются коллективом компаний и частных лиц. Раз смогли они, ты тоже сможешь. Но ты этого не делаешь. Ты остаешься с родителями. Почему?


9gb4fj6tmn4wzixaldntxjhrbik.gif

Это не я, это ты

У нас с тобой одинаковые цели в жизни — помогать разработчикам создавать лучший UI. Я делаю это с помощью React-admin. Поэтому я понимаю вызовы, с которыми ты сталкиваешься, и компромиссы, на которые ты вынужден идти. Твоя работа не из легких, и, вероятно, ты решаешь множество проблем, о которых я не имею ни малейшего понятия.

Но я все время стараюсь скрывать твои недостатки. Когда я говорю о тебе, я никогда не упоминаю указанных выше проблем — я притворяюсь, что мы — отличная пара, без облаков на горизонте. В react-admin я предоставляю интерфейс, который избавляет пользователей от необходимости прямого взаимодействия с тобой. И когда люди жалуются на react-admin, я делаю все возможное, чтобы решить их проблемы, хотя в большинстве случаев эти проблемы связаны с тобой. Будучи разработчиком фреймворка, я тоже нахожусь на передовой. Я сталкиваюсь с новыми проблемами одним из первых.

Я изучал другие фреймворки. Каждый из них имеет свои недостатки: Svelte — это не JavaScript, SolidJS содержит неприятные ловушки, например:

// это работает в `SolidJS`
const BlueText = props => {props.text};

// а это нет
const BlueText = ({ text }) => {text};

Но у них нет твоих недостатков. Недостатков, от которых мне иногда хочется плакать. Недостатков, которые заставляют меня искать другие решения.


Я не могу бросить тебя, детка

Проблема в том, что я не могу тебя бросить.

Во-первых, я люблю твоих друзей. MUI, Remix, react-query, react-testing-library, react-table… Когда я с этими парнями, я всегда делаю потрясающие вещи. Они делают меня лучшим разработчиком — они делают меня лучше как человека. Я не могу бросить тебя, не бросив их.

«Это экосистема, идиот».

Я не спорю, что у тебя лучшее сообщество и лучшие сторонние модули. Но, честно говоря, тот факт, что разработчики выбирают тебя не за твои качества, а за качества твоей экосистемы, вызывает жалость.

Во-вторых, я слишком много в тебя вложил. Я разработал огромную кодовую базу, которую нельзя перенести на другой фреймворк, не сойдя с ума. Я построил бизнес вокруг тебя, который позволяет мне стабильно разрабатывать открытое программное обеспечение.

Я завишу от тебя.


Позвони мне

Я был предельно честен с тобой. Теперь твой черед. Собираешься ли ты решать названные мной проблемы и, если собираешься, то когда? Что ты думаешь о разработчиках библиотек, вроде меня? Должен ли я забыть о тебе? Или мы должны остаться вместе и работать над нашими отношениями?

Что дальше? Скажи мне.


1cn-uic2lc-umnecih8wdwwumam.gif

p-u9l27ynelxi92bcmdxhu76ma8.png

© Habrahabr.ru