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
в действии.
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
для нахождения класса, она не так важна для общего понимания, но все же ее имеет смысл привести, для большей точности:
// Обходим классы один за другим
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
поддерживает лишь базовые механизмы, но он может быть существенно улучшен в случае заинтересованности и вовлеченности сообщества. Буду рад обговорить в комментариях замечания, идеи, примеры использования, а так же недостающий функционал.