React 16.18 — первый стабильный релиз с поддержкой react hooks. Теперь хуки можно использовать не опасаясь, что API изменится кардинальным образом. И хотя команда разработчиков react советует использовать новую технологию лишь для новых компонентов, многим, в том числе и мне, хотелось бы их использовать и для старых компонентов использующих классы. Но поскольку ручной рефакторинг — процесс трудоемкий, мы попробуем его автоматизировать. Описанные в статье приемы подходят для автоматизации рефакторинга не только react компонентов, но и любого другого кода на JavaScript.


Особенности React Hooks


В статье Введение в React Hooks очень подробно рассказано, что это за хуки, и с чем их едят. В двух словах, это новая безумная технология создания компонентов, имеющих state, без использования классов.


Рассмотрим файл button.js:


import React, {Component} from 'react';
export default Button;

class Button extends Component {
    constructor() {
        super();

        this.state = {
            enabled: true
        };

        this.toogle = this._toggle.bind(this);
    }

    _toggle() {
        this.setState({
            enabled: false,
        });
    }

    render() {
        const {enabled} = this.state;

        return (
            <button
                enabled={enabled}
                onClick={this.toggle}
            />
        );
    }
}

С хуками он будет выглядеть таким образом:


import React, {useState} from 'react';
export default Button;

function Button(props) {
    const [enabled, setEnabled] = useState(true);

    function toggle() {
        setEnabled(false);
    }

    return (
        <button
            enabled={enabled}
            onClick={toggle}
        />
    );
}

Можно долго спорить насколько такой вид записи более очевиден для людей, незнакомых с технологией, но одно понятно сразу: код более лаконичен, и его проще переиспользовать. Интересные наборы пользовательских хуков можно найти на usehooks.com и streamich.github.io.


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


Лирическое отступление: нестандартное использование синтаксиса деструктуризации


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


const letters = ['a', 'b'];
const first = letters[0];
const second = letters[1];

Мы можем достать сразу все нужные элементы:


const letters = ['a', 'b'];
const [first, second] = letters;

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


Таким образом мы приходим к тому, что если бы не es2015 команда реакта не придумала такой необычный способ работы со стейтом.


Далее я хотел бы рассмотреть несколько библиотек которые используют похожий подход.


Try Catch


За полгода до объявления о хуках в реакте, мне пришла в голову идея, что деструктуризацию можно использовать не только для того, что бы достать из массива однородные данные, но и для того, что бы доставать информацию об ошибке или результат выполнения функции, по аналогии с коллбэками в node.js. К примеру, вместо того, что бы использовать синтаксическую конструкцию try-catch:


let data;
let error;

try {
    data = JSON.parse('xxxx');
} catch (e) {
    error = e;
}

Что выглядит очень громоздко, при этом несет достаточно мало информации, и заставляет нас использовать let, хотя менять значения переменных мы не планировали. Вместо этого, можно вызвать функцию try-catch, которая сделает все что нужно, избавив нас от перечисленных выше проблем:


const [error, data] = tryCatch(JSON.parse, 'xxxx');

Таким вот интересным способом мы избавились от всех не нужных синтаксических конструкций, оставив лишь необходимое. Такой способ обладает следующими преимуществами:


  • возможность задавать любые удобные нам имена переменных (при использовании деструктуризации объектов, у нас такой привилегии не было бы, вернее у нее была бы своя громоздкая цена);
  • возможность использовать константы для данных которые не меняются;
  • более лаконичный синтаксис, отсутствует все что можно было бы убрать;

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


const result = tryCatch(JSON.parse, 'xxxx');
const error = result[0];
const data = result[1];

Это все еще допустимый код, но он значительно теряет по сравнению с деструктуризацией. Хочу еще добавить пример работы библиотеки try-to-catch, с приходом async-await конструкция try-catch все еще актуальна, и может быть записана таким образом:


const [error, data] = await tryToCatch(readFile, path, 'utf8');

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


На этом лирическое отступление можно закончить и перейти к вопросу преобразования.


Преобразование класса в React Hooks


Для преобразования мы будем использовать AST-трансформатор putout, позволяющий менять только то, что необходимо и плагин @putout/plugin-react-hooks.


Для того, что бы преобразовать класс наследуемый от Component в функцию, использующую react-hooks необходимо проделать следующие шаги:


  • удалить bind
  • переименовать приватные методы в публичные (убрать "_");
  • поменять this.state на использование хуков
  • поменять this.setState на использование хуков
  • убрать this отовсюду
  • конвертировать class в функцию
  • в импортах использовать useState вместо Component

Подключение


Установим putout вместе с плагином @putout/plugin-react-hooks:


npm i putout @putout/plugin-react-hooks -D

Далее создадим файл .putout.json:


{
    "plugins": [
        "react-hooks"
    ]
}

После чего попробуем putout в действии.


Spoiler header
coderaiser@cloudcmd:~/example$ putout button.js
/home/coderaiser/putout/packages/plugin-react-hooks/button.js
 11:8   error   bind should not be used                                     react-hooks/remove-bind
 14:4   error   name of method "_toggle" should not start from under score  react-hooks/rename-method-under-score
 7:8    error   hooks should be used instead of this.state                  react-hooks/convert-state-to-hooks
 15:8   error   hooks should be used instead of this.setState               react-hooks/convert-state-to-hooks
 21:14  error   hooks should be used instead of this.state                  react-hooks/convert-state-to-hooks
 7:8    error   should be used "state" instead of "this.state"              react-hooks/remove-this
 11:8   error   should be used "toogle" instead of "this.toogle"            react-hooks/remove-this
 11:22  error   should be used "_toggle" instead of "this._toggle"          react-hooks/remove-this
 15:8   error   should be used "setState" instead of "this.setState"        react-hooks/remove-this
 21:26  error   should be used "state" instead of "this.state"              react-hooks/remove-this
 26:25  error   should be used "setEnabled" instead of "this.setEnabled"    react-hooks/remove-this
 3:0    error   class Button should be a function                           react-hooks/convert-class-to-function

 12 errors in 1 files
  fixable with the `--fix` option

putout нашел 12 мест которые можно поправить, попробуем:


putout --fix button.js

Теперь button.js выглядит таким образом:


import React, {useState} from 'react';
export default Button;

function Button(props) {
    const [enabled, setEnabled] = useState(true);

    function toggle() {
        setEnabled(false);
    }

    return (
        <button
            enabled={enabled}
            onClick={setEnabled}
        />
    );
}

Программная реализация


Рассмотрим детальнее несколько описанных выше правил.


Убрать this отовсюду


Поскольку классы мы не используем, все выражения вида this.setEnabled должны преобразоваться в setEnabled.


Для этого мы пройдем по узлам ThisExpression, которые, в свою очередь являются дочерними о отношению к MemberExpression, и располагаются в поле object, таким образом:


{
    "type": "MemberExpression",
    "object": {
        "type": "ThisExpression",
    },
    "property": {
        "type": "Identifier",
        "name": "setEnabled"
    }
}

Рассмотрим реализацию правила remove-this:


// информация для вывода в консоль
module.exports.report = ({name}) => `should be used "${name}" instead of "this.${name}"`;

// способ исправления правила
module.exports.fix = ({path}) => {
    // заменяем: MemberExpression -> Identifier 
    path.replaceWith(path.get('property'));
};

module.exports.find = (ast, {push}) => {
    traverseClass(ast, {
        ThisExpression(path) {
            const {parentPath} = path;
            const propertyPath = parentPath.get('property');

            //сохраняем найденную информацию для дальнейшей обработки
            const {name} = propertyPath.node;
            push({
                name,
                path: parentPath,
            });
        },
    });
};

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


Spoiler header
// Обходим классы один за другим
function traverseClass(ast, visitor) {
    traverse(ast, {
        ClassDeclaration(path) {
            const {node} = path;
            const {superClass} = node;

            if (!isExtendComponent(superClass))
                return;

            path.traverse(visitor);
        },
    });
};

// проверяем является ли класс наследуемым от Component
function isExtendComponent(superClass) {
    const name = 'Component';

    if (isIdentifier(superClass, {name}))
        return true;

    if (isMemberExpression(superClass) && isIdentifier(superClass.property, {name}))
        return true;

    return false;
}

Тест, в свою очередь, может выглядеть таким образом:


const test = require('@putout/test')(__dirname, {
    'remove-this': require('.'),
});

test('plugin-react-hooks: remove-this: report', (t) => {
    t.report('this', `should be used "submit" instead of "this.submit"`);
    t.end();
});

test('plugin-react-hooks: remove-this: transform', (t) => {
    const from = `
        class Hello extends Component {
            render() {
                return (
                    <button onClick={this.setEnabled}/>
                );
            }
        }
    `;

    const to = `
        class Hello extends Component {
            render() {
                return <button onClick={setEnabled}/>;
            }
        }
    `;
    t.transformCode(from, to);
    t.end();
});

В импортах использовать useState вместо Component


Рассмотрим реализацию правила convert-import-component-to-use-state.


Для того, что бы заменить выражения:


import React, {Component} from 'react'

на


import React, {useState} from 'react'

Необходимо обработать узел ImportDeclaration:


 {
    "type": "ImportDeclaration",
    "specifiers": [{
        "type": "ImportDefaultSpecifier",
        "local": {
            "type": "Identifier",
            "name": "React"
        }
      }, {
        "type": "ImportSpecifier",
        "imported": {
             "type": "Identifier",
             "name": "Component"
        },
        "local": {
            "type": "Identifier",
            "name": "Component"
        }
    }],
    "source": {
        "type": "StringLiteral",
        "value": "react"
    }
}

Нам нужно найти ImportDeclaration с source.value = react, после чего обойти массив specifiers в поисках ImportSpecifier с полем name = Component:


// вернем сообщение об ошибке
module.exports.report = () => 'useState should be used instead of Component';

// присвоим новое имя
module.exports.fix = (path) => {
    const {node} = path;

    node.imported.name = 'useState';
    node.local.name = 'useState';
};

// найдем нужный узел
module.exports.find = (ast, {push, traverse}) => {
    traverse(ast, {
        ImportDeclaration(path) {
            const {source} = path.node;

            // если не react, нет смысла продолжать
            if (source.value !== 'react')
                return;

            const name = 'Component';
            const specifiersPaths = path.get('specifiers');
            for (const specPath of specifiersPaths) {
                // если это не ImportSpecifier - выходим из итерации 
                if (!specPath.isImportSpecifier())
                    continue;

                // если это не Compnent - выходим из итерации
                if (!specPath.get('imported').isIdentifier({name}))
                    continue;

                push(specPath);
            }
        },
    });
};

Рассмотрим простейший тест:


const test = require('@putout/test')(__dirname, {
    'convert-import-component-to-use-state': require('.'),
});

test('plugin-react-hooks: convert-import-component-to-use-state: report', (t) => {
    t.report('component', 'useState should be used instead of Component');
    t.end();
});

test('plugin-react-hooks: convert-import-component-to-use-state: transform', (t) => {
    t.transformCode(`import {Component} from 'react'`, `import {useState} from 'react'`);
    t.end();
});

И так, мы рассмотрели в общих чертах программную реализацию нескольких правил, остальные строятся по аналогичной схеме. Ознакомится со всеми узлами дерева разбираемого файла button.js можно в astexplorer. Исходный код описанных плагинов можно найти в репозитории.


Заключение


Сегодня мы рассмотрели один из способов автоматизированного рефакторинга классов реакт на реакт хуки. В данный момент плагин @putout/plugin-react-hooks поддерживает лишь базовые механизмы, но он может быть существенно улучшен в случае заинтересованности и вовлеченности сообщества. Буду рад обговорить в комментариях замечания, идеи, примеры использования, а так же недостающий функционал.

Комментарии (24)


  1. mayorovp
    26.02.2019 13:05

    Почему вы в исходном варианте биндите _toggle в конструкторе, а в преобразованном — спокойно передаёте каждый раз новую функцию? Где useCallback?


    1. mn3m0n1c_3n3m1 Автор
      26.02.2019 13:14

      Для простоты изложения и наглядности, из всего что касается React Hook'ов использовалась лишь функция useState. useCallback можно будет добавить в будущем. Статья описывает способ, с помощью которого это можно реализовать, а архитектура плагина состоящая из достаточно простых правил к этому стимулирует.


      1. mayorovp
        26.02.2019 13:16

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


        1. mn3m0n1c_3n3m1 Автор
          26.02.2019 13:23

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


          1. mayorovp
            26.02.2019 13:25

            А ещё проще — ничего не трогать.


            1. mn3m0n1c_3n3m1 Автор
              26.02.2019 16:21

              Пример, описанный в статье — иллюстративный. Иногда выход новой мажорной версии библиотеки ломает обратную совместимость, и тогда варианта два:


              • использовать старую, неподдерживаемую, в которой могут быть уязвимости;
              • в ручную править все несовместимые места;

              Я же предлагаю третий вариант: автоматизировать. Я согласен, что React Hooks, это не мажорное изменение, и можно ничего не трогать. Ну так вас никто и не заставляет. Если все устраивает в старом подходе, а новый не подходит, конечно, ненужно ничего менять. Но если так получается, что не устраивает, то почему бы не автоматизировать переход? И использовать однородные компоненты по всей кодовой базе, а не какие придется.


          1. staticlab
            26.02.2019 13:27

            А если компоненты уже были оптимизированные? Если использовались PureComponent или shouldComponentUpdate?


            1. mn3m0n1c_3n3m1 Автор
              26.02.2019 13:31

              Для этого стоит написать новые правила, плагин @putout/plugin-react-hooks, представляет собой базовую реализацию, которую можно развивать для поддержки нужных условий.


              1. staticlab
                26.02.2019 13:51

                Так, а если сейчас попытаться преобразовать — putout всё это удалит или сообщит об ошибке?


                1. mn3m0n1c_3n3m1 Автор
                  26.02.2019 13:59

                  PureComponent проигнорирует, а shouldComponentUpdate удалит. Используя приемы описанные в статье, поддержку и того и другого легко добавить.


                  1. staticlab
                    26.02.2019 14:12

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


                    1. mn3m0n1c_3n3m1 Автор
                      26.02.2019 14:19

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


        1. mn3m0n1c_3n3m1 Автор
          26.02.2019 16:14
          -1

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


          1. mayorovp
            26.02.2019 17:11

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


          1. faiwer
            27.02.2019 15:34

            Is it OK to use arrow functions in render methods? ? Generally speaking, yes, it is OK, and it is often the easiest way to pass parameters to callback functions. If you do have performance issues, by all means, optimize!

            Вот что там написано на самом деле. Очень аккуратная формулировка "generally… it is OK". А вы чуть ли не обратное написали. Впрочем практически всякий раз когда я вижу эти слова ("преждевременная оптимизация"), я натыкаюсь на какой-нибудь очередной набор отмаз, почему стоит писать O(n^2) или O(n!) вместо O(1) :)


            А по сути, если вдаваться в детали: пока вы оперируете только immutable data имеет смысл использовать useMemo\Memo\useCallback как раз таки везде или почти везде. Прямо если возникает желание НЕ писать их, то надо найти достаточные для этого основания. В купе с hooks.macro разница между быстрым кодом и говмедленным кодом это разница в 1 импорт и пару символов.


  1. staticlab
    26.02.2019 13:26
    +2

    Хуки сами по себе выглядят как жутчайший невыпиливаемый костыль, который к тому же внутри реализованный довольно грязно. В каждом более-менее сложном компоненте придётся тащить как минимум useState, useCallback, useMemo, useEffect. Для замены shouldComponentUpdate придётся использовать связку React.memo для пропсов и useMemo для стейта. Сомневаюсь, что такой код будет намного проще и понятнее, чем на классах.


    1. mn3m0n1c_3n3m1 Автор
      26.02.2019 16:29
      -1

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


      Вы забываете, что есть еще кастомные хуки, вроде описанного Деном Абрамовым useInterval, в них можно сложить многие описанные вами проблемы, такие как useEffect, useMemo и прочее, взгляните на примеры с usehooks. Опять же, статья не о том, зачем нужны хуки, а том как автоматизировать рефакторинг, на примере хуков.


      1. staticlab
        26.02.2019 18:01

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


        1. mn3m0n1c_3n3m1 Автор
          26.02.2019 18:19

          А можно ли назвать такой подход автоматизацией, если в обязательном порядке нужно проверять код?

          Да можно.


          плагин @putout/plugin-react-hooks, представляет собой базовую реализацию, которую можно развивать для поддержки нужных условий.

          Если вас заинтересовала тема автоматизированного рефакторинга, вы так же можете написать интересующие правила, пул реквесты приветствуются.

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

          Вот код, который отвечает за конвертацию класса в функцию, если вам нужен инструмент, о котором мы сейчас говорим, и хуки вас интересуют, можете добавить нужную вам обработку. Код открыт, это опен сорс. В статье описано что и как делать :).


        1. VolCh
          27.02.2019 11:26

          Автоматизированный процесс и автоматический процесс — немного разные вещи. Второй — это полная автоматизация.


          1. staticlab
            27.02.2019 11:52

            Да, согласен. Но подход "Я вам тут тихонько всё удалю, что не знаю. Если заметишь и восстановишь — молодец" выглядит так себе.


      1. strannik_k
        27.02.2019 10:55

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

        Ну и пример с классами слишком утрированный. Можно ведь проще (по крайней мере с babel):
        class Button extends Component {
             state = { 
                 enabled: true,
             };
        
             toggle = () => {
                 this.setState({
                     enabled: false,
                 });
            };
        
            render() {
                return (
                    <button
                        enabled={this.state.enabled}
                        onClick={this.toggle}
                    />
                );
            }
        }
        

        Но может вы просто для примера так класс раздули. Но об этом не сказано.


        1. faiwer
          27.02.2019 15:42

          ИМХО, нет резона использовать для сложных компонент — классы. Всё решается и на хуках (кроме error boundaries, но это вроде бы временно).


    1. faiwer
      27.02.2019 15:41
      +1

      В каждом более-менее сложном компоненте придётся тащить как минимум useState, useCallback, useMemo, useEffect

      По опыту в пару месяцев работы с хуками (изначально относился к ним скептически): тащить сразу по 3-4 не приходится. Проще получается с композицией. Все эти use* в итоге группируются в отдельные новые use-hook-и и тот самый изначальный сложный класс-компонент превращается в несколько раздельных методов с ограниченной областью задач у каждого. На самом деле мне это показалось куда более удобным, чем возня с class components life cycle методами.


      Правда некоторое время нужно попрактиковаться. Скажем я открыл для себя необычное применение для useRef. Не для DOM-элементов, а для хранения того, что раньше было class instance variables.


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