- PVSM.RU - https://www.pvsm.ru -
Всем привет! Меня зовут Артур, я работаю ВКонтакте в команде мобильного веба, занимаюсь проектом VKUI [1] — библиотекой React-компонентов, с помощью которой написаны некоторые наши интерфейсы в мобильных приложениях. Вопрос работы с глобальным стейтом у нас пока открыт. Существует несколько известных подходов: Redux, MobX, Context API. Недавно я наткнулся на статью André Gardi State Management with React Hooks — No Redux or Context API [2], в которой автор предлагает использовать React Hooks для управления стейтом приложения.
Хуки стремительно врываются в жизнь разработчиков, предлагая новые способы решения или переосмысления разных задач и подходов. Они меняют наше представление не только о том, как описывать компоненты, но и о том, как работать с данными. Перевод статьи и комментарий переводчика читайте под катом.
Сегодня мы изучим React Hooks и разработаем кастомный хук для управления глобальным стейтом приложения, который будет проще Redux-реализации и производительнее Context API.
Можете пропустить эту часть, если уже знакомы с хуками.
До появления хуков у функциональных компонентов не было возможности задавать локальный стейт. Ситуация изменилась с появлением useState()
.
Данный вызов возвращает массив. Первым его элементом является переменная, предоставляющая доступ к значению стейта. Второй элемент — это функция, которая обновляет стейт и перерисовывает компонент для отражения изменений.
import React, { useState } from 'react';
function Example() {
const [state, setState] = useState({counter:0});
const add1ToCounter = () => {
const newCounterValue = state.counter + 1;
setState({ counter: newCounterValue});
}
return (
<div>
<p>You clicked {state.counter} times</p>
<button onClick={add1ToCounter}>
Click me
</button>
</div>
);
}
Классовые компоненты реагируют на сайд-эффекты, используя lifecycle-методы, такие как componentDidMount()
. Хук useEffect()
позволяет делать то же самое в функциональных компонентах.
По умолчанию эффекты запускаются после каждой перерисовки. Но вы можете сделать так, чтобы они выполнялись только после изменения значений конкретных переменных, передавая их вторым опциональным параметром в виде массива.
// Вызов без второго параметра
useEffect(() => {
console.log('Я буду запускаться после каждого рендера');
});
// Со вторым параметром
useEffect(() => {
console.log('Я вызовусь только при изменении valueA');
}, [valueA]);
Чтобы достичь результата, аналогичного componentDidMount()
, мы передадим пустой массив вторым параметром. Так как содержимое пустого массива всегда остаётся неизменным, эффект выполнится лишь один раз.
// Вызов с пустым массивом
useEffect(() => {
console.log('Я запущусь только первый раз');
}, []);
Мы увидели, что стейт хуков работает так же, как стейт классового компонента. Каждый экземпляр компонента имеет собственное внутреннее состояние.
Для шаринга стейта между компонентами мы создадим собственный хук.
Идея состоит в том, чтобы создать массив слушателей и только один стейт. Каждый раз, когда компонент меняет стейт, все подписавшиеся компоненты вызывают свой getState()
и за счёт этого обновляются.
Мы можем добиться этого, вызывая useState()
внутри нашего кастомного хука. Но вместо того чтобы возвращать функцию setState()
, мы добавим её в массив слушателей и вернём функцию, которая внутри себя обновляет объект стейта и вызывает всех слушателей.
Да, вы правы. Я создал NPM-пакет [3], инкапсулирующий всю описанную логику.
Вам не придётся реализовывать её в каждом проекте. Если вы больше не хотите тратить время на чтение и желаете посмотреть на финальный результат, просто добавьте этот пакет в ваше приложение.
npm install -s use-global-hook
Чтобы понять, как работать с пакетом, изучите примеры в документации. А сейчас предлагаю сфокусироваться на том, как пакет устроен внутри.
import { useState, useEffect } from 'react';
let listeners = [];
let state = { counter: 0 };
const setState = (newState) => {
state = { ...state, ...newState };
listeners.forEach((listener) => {
listener(state);
});
};
const useCustom = () => {
const newListener = useState()[1];
useEffect(() => {
listeners.push(newListener);
}, []);
return [state, setState];
};
export default useCustom;
import React from 'react';
import useCustom from './customHook';
const Counter = () => {
const [globalState, setGlobalState] = useCustom();
const add1Global = () => {
const newCounterValue = globalState.counter + 1;
setGlobalState({ counter: newCounterValue });
};
return (
<div>
<p>
counter:
{globalState.counter}
</p>
<button type="button" onClick={add1Global}>
+1 to global
</button>
</div>
);
};
export default Counter;
Эта версия уже обеспечивает шаринг стейта. Вы можете добавить произвольное количество счётчиков в ваше приложение, и они все будут иметь общий глобальный стейт.
Чего хочется:
initialState
с помощью параметров;
Мы уже выяснили, что вызов useEffect(function, [])
с пустым массивом работает так же, как componentDidMount()
. Но если функция, переданная в первом параметре, возвращает другую функцию, то вторая функция будет вызвана прямо перед размонтированием компонента. В точности как componentWillUnmount()
.
Значит, в коде второй функции можно написать логику удаления компонента из массива слушателей.
const useCustom = () => {
const newListener = useState()[1];
useEffect(() => {
// Вызывается сразу после монтирования
listeners.push(newListener);
return () => {
// Вызывается прямо перед размонтированием
listeners = listeners.filter(listener => listener !== newListener);
};
}, []);
return [state, setState];
};
Помимо этого обновления мы также планируем:
initalState
;store
, который будет содержать значение state
и функцию setState()
;setState()
и useCustom()
, чтобы можно было связать store
с this
.function setState(newState) {
this.state = { ...this.state, ...newState };
this.listeners.forEach((listener) => {
listener(this.state);
});
}
function useCustom(React) {
const newListener = React.useState()[1];
React.useEffect(() => {
// Вызывается сразу после монтирования
this.listeners.push(newListener);
return () => {
// Вызывается прямо перед размонтированием
this.listeners = this.listeners.filter(listener => listener !== newListener);
};
}, []);
return [this.state, this.setState];
}
const useGlobalHook = (React, initialState) => {
const store = { state: initialState, listeners: [] };
store.setState = setState.bind(store);
return useCustom.bind(store, React);
};
export default useGlobalHook;
Если вы когда-нибудь работали со сложными библиотеками для управления стейтом, то вы знаете, что манипулировать глобальным стейтом из компонентов – не лучшая идея.
Правильнее будет отделить бизнес логику, создав экшены для изменения стейта. Поэтому я хочу, чтобы последняя версия пакета предоставляла компонентам доступ не к setState()
, а к набору экшенов.
Для этого снабдим наш useGlobalHook(React, initialState, actions)
третьим аргументом. Сразу хочется добавить пару замечаний.
store
. Таким образом, экшены смогут читать содержимое store.state
, обновлять стейт вызовом store.setState()
и даже вызывать другие экшены, обращаясь к store.actions
.actions.addToCounter(amount
) в подобъект со всеми экшенами счетчика: actions.counter.add(amount)
.
Следующий сниппет является актуальной версией NPM пакета use-global-hook
.
function setState(newState) {
this.state = { ...this.state, ...newState };
this.listeners.forEach((listener) => {
listener(this.state);
});
}
function useCustom(React) {
const newListener = React.useState()[1];
React.useEffect(() => {
this.listeners.push(newListener);
return () => {
this.listeners = this.listeners.filter(listener => listener !== newListener);
};
}, []);
return [this.state, this.actions];
}
function associateActions(store, actions) {
const associatedActions = {};
Object.keys(actions).forEach((key) => {
if (typeof actions[key] === 'function') {
associatedActions[key] = actions[key].bind(null, store);
}
if (typeof actions[key] === 'object') {
associatedActions[key] = associateActions(store, actions[key]);
}
});
return associatedActions;
}
const useGlobalHook = (React, initialState, actions) => {
const store = { state: initialState, listeners: [] };
store.setState = setState.bind(store);
store.actions = associateActions(store, actions);
return useCustom.bind(store, React);
};
export default useGlobalHook;
Вам больше не придётся иметь дело с useGlobalHook.js
. Теперь вы можете сфокусироваться на вашем приложении. Ниже представлены два примера использования пакета.
Добавьте столько счётчиков, сколько хотите: у них у всех будет глобальное значение. Каждый раз, когда один из счётчиков будет делать инкремент глобального стейта, все остальные будут перерисовываться. При этом родительскому компоненту перерисовка не понадобится.
Живой пример [4].
Поиск GitHub-репозиториев по имени пользователя. Обрабатываем ajax-запросы асинхронно с помощью async/await. Обновляем счетчик запросов при каждом новом поиске.
Живой пример [4].
Теперь у нас есть собственная библиотека по управлению стейтом на React Hooks.
Большинство существующих решений — по сути, отдельные библиотеки. В этом смысле подход, описанный автором, интересен тем, что в нём используются только встроенные возможности React. Кроме того, по сравнению с тем же Context API, который тоже идёт из коробки, данный подход уменьшает количество ненужных перерисовок и потому выигрывает в производительности.
Автор: ArthurSupertramp
Источник [5]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/javascript/319499
Ссылки в тексте:
[1] VKUI: https://github.com/VKCOM/VKUI
[2] State Management with React Hooks — No Redux or Context API: https://medium.com/javascript-in-plain-english/state-management-with-react-hooks-no-redux-or-context-api-8b3035ceecf8
[3] NPM-пакет: https://www.npmjs.com/package/use-global-hook
[4] Живой пример: https://codesandbox.io/s/wqvykj5497?fontsize=14
[5] Источник: https://habr.com/ru/post/454348/?utm_campaign=454348
Нажмите здесь для печати.