[Разработка веб-сайтов, JavaScript, Проектирование и рефакторинг, ReactJS] Вариант повторного использования кода компонентов, который упустили в веб-разработке
Автор
Сообщение
news_bot ®
Стаж: 6 лет 9 месяцев
Сообщений: 27286
В этом статье я покажу, как для React-компонентов реализовать один из подходов на основе сущностей и их составляющих. Как я упоминал в предыдущей статье "Техники повторного использования кода" в главе "Entity Component (EC)", я не знаю точное название подхода. В статье я буду называть его Entity Component (EC). Entity Component используется для решения той же проблемы, что HOC и Custom hooks – повторно использовать код между множеством однотипных объектов/функций и разбить сложный объект на более простые составляющие. Эта необходимость появилась довольно давно, гораздо раньше, чем с ней столкнулись в вебе. И давно были придуманы эффективные решения.Custom hooks и Entity Component - оба добавляют к объекту некий функционал. Тем самым они близки к паттерну «стратегия». Но, Entity Component – решение для объектов (судя по тому, что я встречал), а Custom hooks - для функций. Даже если этот подход вам не интересен, как минимум вы увидите, как можно переделать структуру функциональных компоненты и компонентов-классов под свои нужды. Узнаете нестандартные приемы, которые можно использовать при разработке компонентов.
Те, кто пишет компоненты-классы, узнает, как повторно использовать код более эффективным способом, чем HOC, не создавая лишние компоненты-обертки.Если разобраться, то подход довольно прост. Но, возможно, я не смогу его достаточно понятно объяснить. Он давно используется в геймдеве, в том числе в разработке UI. Здесь многие возразят: "это же подход из другой области и вряд ли будет хорош в вебе". Это так, если окружение и разработка сильно отличаются. Здесь же они похожи. На странице и в игровой сцене используется дерево объектов (DOM и дерево объектов сцены). Компоненты в вебе состоят из вложенных объектов и объекты в играх тоже состоят из вложенных объектов. Компоненты в вебе могут состоять из составляющих (атрибуты/директивы или хуки), и объекты в играх тоже состоят из составляющих. Отличия есть, но не такие, чтобы нельзя было применять общие подходы. Просто их нужно адаптировать под используемую область.Исходники и примеры компонентов, созданных описываемым подходом, доступны по ссылкам ниже.Код из статьи: github
Более полная реализация: github, codesanboxСодержание
- Недостатки React-компонентов и хуков. Преимущества и недостатки моей реализации Entity Component.
- Основные программные сущности в моей реализации Entity Component
- Изменяем react-компоненты. Часть 1: Event emitter
- Изменяем react-компоненты. Часть 2: создание объекта-контейнера.
- Изменяем react-компоненты. Часть 3: связывание компонента с логикой контейнера.
- Изменяем react-компоненты. Часть 4: создание базового объекта (behaviour) для переиспользования логики в компонентах.
- Изменяем react-компоненты. Часть 5: Примеры создания компонентов. Итоговая схема.
- Дополнение. Группировка props.
- Дополнение. Директивы - это не то, за что их принимают. Так почему бы и нет?
- Заключение. В каких проектах Entity Component может быть полезен.
Недостатки React-компонентов и хуков. Преимущества и недостатки моей реализации Entity ComponentПреимущества компонентов и хуков я не буду расписывать. Их не сложно найти. Про недостатки скажу, что они не заметны в небольших компонентах и далеко не всегда ведут к проблемам.Недостатки компонентов (на мой взгляд):
- Логика компонента объединены с View. Когда-то view слоем была вся клиентская часть. Сейчас к View слою обычно относят компонент с его логикой. В будущем View слоем будет часть компонента без логики. В Vue и Angular View часть уже отделена от компонента, что дает больше гибкости.
- Слишком большая ответственность (нарушение первого принципа SOLID). Компонент является как-бы контейнером для функций с логикой (custom hooks), содержит реализацию View, содержит свою логику, обрабатывает события жизненного цикла. Это очень странно, учитывая популярность Redux, где много бойлерплейта и пропагандируется чистота функций в редьюсерах. В случае функциональных компонентов все в точности да наоборот - грязные функции-компоненты и маленький размер компонентов.
- У компонентов нет четкой зоны ответственности. Программист всегда стоит перед выбором, где лучше написать код – в пользовательском хуке или в компоненте. Плохо, когда один и тот же код используется в сущностях, у которых разное назначение. У кода любого должно быть свое определенное место в программе.
Недостатки хуков (на мой взгляд):
- При необходимости расширения, придеться изменять код внутри компонента или хука. Это нарушение второго принципа SOLID. К каким последствиям это может привести? Допустим вы полгода пользовались сторонним сложным компонентом. Затем от заказчика пришло требование, которое нельзя реализовать с помощью этого компонента. Хорошо, если у автора компонента все разбито на независимые хуки, и он предоставил возможность из них собрать свой. Если нет, то придется или писать свой компонент с нуля, или же делать форк. В теории же можно было бы реализовать в хуках возможность их удаления из компонента или добавление.
- Код в функциональных компонентах выполняется при каждом рендеринге. Это не приводит к серьезным проблемам производительности, но приводит к ошибкам и усложнению кода. Например, в случае useState(), надо следить, чтобы кто-нибудь случайно не передавал props useState(props.value). Или же могут быть лишние перерисовки, если в массивы зависимостей компонента передаются не мемоизированные функции и данные. В книге Крокфорд Дугла «Как устроен JavaScript [2019]» в главе «Как работают генераторы» я столкнулся с такой фразой: «В Structured Revolution утверждалось, что потоки управления должны быть абсолютно понятными и предсказуемыми.» Другими словами, хороший код – как можно более предсказуемый и понятный. Любой хук же надо читать как условный код: If (isFirstRender) { ... } else { ...}. А ведь можно было бы в компоненте-объекте сделать что-то вроде addEffect(callback, dependencies) и избежать подобной ситуации.
Моя реализация Entity Component имеет несколько преимуществ по сравнению с custom hooks и нынешними react-компонентами:
- Можно добавлять и удалять логику в существующем компоненте, чего не позволяют custom hooks. Изменять логику компонента можно делать даже в запущенном приложении.
- View отделен от компонента, что дает дополнительную гибкость.
- Объекты с логикой отделены от компонента и избегается написание пользовательской логики в самом компоненте.
- Нет необходимости использовать что-то вроде useRef, useCallback, т.к. в отличие от функций, в объектах переменные и функции можно легко привязать к объекту обычным присваиванием.
Недостатки моей реализации Entity Component:
- Производительность. Я не занимался оптимизацией. Если оптимизировать мой код, то по скорости он скорее всего будет близок к компонентам-классам. Большую производительность можно было бы получить, если бы подход был бы реализован в самом React.
- Больше кода, чем в функциональных компонентах. Примерно, как в компонентах-классах.
- Возможно не очень удобная реализация. Для повышения удобства потребуется потратить больше времени. Т.к. решение не встроено в фреймворк, а сделано поверх существующих типов компонентов, компонент разделен на большее число составляющих, чем нужно. К тому же я делал так, чтобы как можно больше кода работало с обоими типами компонентов (с компонентами-классами и с функциональными).
- Кому-то не понравится возврат к методам жизненного цикла вместо использования эффектов. Это опционально. Можно реализовать аналоги эффектов и других хуков, хотя, так же кратко может не получиться. С точки зрения разработки, эффекты - это другая парадигма. Но, с точки зрения реализации - это просто синтаксический сахар над событиями жизненного цикла. Да и использование методов жизненного цикла - это не плохой подход, а просто другой.
Основные программные сущности в моей реализации Entity Component
- React-компонент – просто содержит ссылку на объект-контейнер и используется для его инициализации. Также используется react-ом для рендеринга. Нужен только потому, что это неотъемленная часть React.
- Container – объект без пользовательской логики, который содержит объекты behaviours с пользовательской логикой.
- Behaviour (поведение) – объект с логикой или частью логики компонента. Что-то вроде пользовательских хуков, но является объектом. В компоненте может быть несколько таких объектов, каждый из которых реализует определенный функционал.
- render – просто функция с JSX кодом. Не компонент! Может быть объявлена вне компонента и использоваться в нескольких разных компонентах. Через параметры получает данные и функции, которые использует в своем теле.
- Config – объект с параметрами, по которым на момент создания определяется функционал компонента. Задается в компоненте, но может быть вынесен отдельно. В нем указывается behaviours и render-функция, которые использует данный компонент, а также другие опции.
- Event emitter – используется для проброса событий жизненного цикла компонента в объекты с логикой (behaviours).
Важные нюансы реализации:
- Хоть это и не обязательно, но для более читабельного кода используются классы. Классы-наследники создаются относительно медленно, но пока не будет создаваться хотя бы несколько тысяч экземпляров классов, падение производительности не будет заметным даже на мобильных.
- Вместо конструктора в классах используется init. Это нужно, чтобы можно было использовать this до вызова родительского конструктора super(). Иногда это необходимо.
- Стрелочные функции не используются при объявлении методов в базовых классах. Если их использовать, тогда нельзя будет переопределить this для этого метода в наследнике, и он всегда будет указывать на родительский класс.
- field – с помощью "_" указывается protected свойство или метод.
Большая часть кода применима к обоим типам компонентов – к функциональным и к компонентам-классам. Отличающиеся места будут описаны по ходу.За идеи хранения нужного функционала в useRef и проброса событий из хуков – спасибо @Alexandroppolus! Его коммент мне очень помог в реализации версии на функциональных компонентах.Изменяем react-компоненты. Часть 1: Event emitterТ.к. логика будет находиться не в компоненте, а в других объектах, то нужно как-то пробрасывать в них события жизненного цикла компонента. Этим будет заниматься класс EventEmitter. Имя события в нем - это то же самое, что имя метода жизненного цикла в behaviours.В моем репозитории есть примеры двух реализаций эмиттеров событий. Здесь же пример одного из них, который попроще. В примере 2 простых метода:
1) callMethodInBehaviour – вызывает метод в behaviour, имя которого совпадает с именем события.
2) callMethodInAllBehaviours – вызывает метод во всех behaviours компонента.EventEmitter
export class SimpleEventEmitter {
_behaviourArray;
init(behaviourArray) {
this._behaviourArray = behaviourArray;
}
callMethodInBehaviour(methodName, behaviourInstance, args = []) {
const behaviourMethod = behaviourInstance[methodName];
if (behaviourMethod) {
behaviourMethod.apply(behaviourInstance, args);
}
}
callMethodInAllBehaviours(methodName, args = []) {
this._behaviourArray.forEach(beh => {
if (beh[methodName]) {
beh[methodName](...args);
}
});
}
}
Далее задан перечень событий жизненного цикла. Добавлены новые события, вызываемые при удалении и добавлении behaviour, т.к. те могут быть добавлены или удалены во время существования компонента.LifeCycleEvents
export const LifeCycleEvents = {
BEHAVIOUR_ADDED: 'behaviourAdded',
COMPONENT_INITIALIZED: 'componentInitialized',
COMPONENT_DID_MOUNT: 'componentDidMount',
COMPONENT_DID_UPDATE: 'componentDidUpdate',
// для вызова из useEfect()
COMPONENT_DID_UPDATE_EFFECT: 'componentDidUpdateEffect',
BEHAVIOUR_WILL_REMOVED: 'behaviourWillRemoved',
COMPONENT_WILL_UNMOUNT: 'componentWillUnmount',
// остальные события не реализованы в примерах
};
Изменяем react-компоненты. Часть 2: создание объекта-контейнераОбъект контейнер используется для:
- хранения всех behaviours компонента и для доступа к ним
- хранения объекта-словаря состояний для всех его behaviours
- создания и удаления behaviours из своих списков
- хранения объекта config
- хранения эмиттера событий
- получения данных из behaviours
- вызова функции render из config, передачи в нее данных из behaviours, затем возврата получившегося JSX кода в компонент.
В методе render контейнера вызывается метод mapToMixedRenderData. В данном примере этот метод вызывает в каждом behaviour метод mapToRenderData и смешивает все возвращаемые данные в один объект. Это похоже на optionMergeStrategiesв Vue. Подобные стратегии для решения конфликтов пересечения имен у меня реализованы в другой ветке. В коде к статье эта часть убрана.Пример абстрактного базового класса для контейнеров: AbstractContainer
import { LifeCycleEvents } from './LifeCycleEvents';
import { SimpleEventEmitter } from './eventEmitters/SimpleEventEmitter';
export class AbstractContainer {
_eventEmitter;
_config;
// Array with all behaviours of component
behaviourArray = [];
// Object (dictionary) with all behaviours of container.
// To simplify access to behaviour by name
behs = {};
// Object (dictionary) with pairs [behaviourName]: behParamsObject
behsParams = {};
get eventEmitter() {
return this._eventEmitter;
}
get config() {
return this._config;
}
get state() {
console.error('container state getter is not implemented');
}
get props() {
console.error('container props getter is not implemented');
}
init(config, props) {
this._eventEmitter = new SimpleEventEmitter();
this._eventEmitter.init(this.behaviourArray);
this._config = config;
this._createBehaviours(props);
}
_createBehaviours(props) {
const defaultBehaviours = props?.defaultBehaviours;
const allBehParams = defaultBehaviours
|| this.config.behaviours || [];
// create behaviours
allBehParams.forEach(oneBehParams => {
const { behaviour, initData, ...passedBehParams } = oneBehParams;
this.addBehaviour(behaviour, props, initData, passedBehParams);
});
this._eventEmitter.callMethodInAllBehaviours(
LifeCycleEvents.COMPONENT_INITIALIZED,
[props],
);
}
setState(stateOrUpdater){
console.error('container setState is not implemented');
}
addBehaviour(behaviour, props, initData, behaviourParams = {}) {
const newBeh = new behaviour();
this.behaviourArray.push(newBeh);
this.behs[ newBeh.name ] = newBeh;
this.behsParams[ newBeh.name ] = behaviourParams;
if (newBeh.init) {
newBeh.init(this, props, initData, behaviourParams);
}
this._eventEmitter.callMethodInBehaviour(
LifeCycleEvents.BEHAVIOUR_ADDED, newBeh);
return newBeh;
}
removeBehaviour(behaviourInstance) {
const foundIndex = this.behaviourArray.indexOf(behaviourInstance);
if (foundIndex > -1) {
this._eventEmitter.callMethodInBehaviour(
LifeCycleEvents.BEHAVIOUR_WILL_REMOVED, behaviourInstance);
this.behaviourArray.splice(foundIndex, 1);
delete this.behs[behaviourInstance.name];
delete this.behsParams[behaviourInstance.name];
} else {
console.warn(
`removeBehaviour error: ${behaviourInstance.name} not found`
);
}
}
// Return all behaviours renderData mixed in single object.
_mapToMixedRenderData() {
let retRenderData = this.behaviourArray.reduce((mixedData, beh) => {
const behRenderData = beh.mapToRenderData();
Object.assign(mixedData, behRenderData);
return mixedData;
}, {});
return retRenderData;
}
render() {
const renderFunc = this.config.render
? this.config.render
: ({ props }) => props?.children;
return renderFunc({
props: this.props,
...this._mapToMixedRenderData(this)
});
};
}
И конкретные классы для компонентов-классов и для функциональных компонентов. Они не сильно отличаются. Контейнер для компонентов-классов обращается к компоненту при использовании props, state, setState. Контейнер для функциональных компонентов сам хранит полученные от компонента props, а также state и setState, получаемые извне от хука useState.ContainerForClassComponent и ContainerForFunctionalComponent
import { AbstractContainer } from '../AbstractContainer';
export class ContainerForClassComponent extends AbstractContainer {
_component;
get state() {
return this._component.state;
}
get props() {
return this._component.props;
}
setState = (stateOrUpdater) =>{
this._component.setState(stateOrUpdater);
};
init(config, props, component) {
this._component = component;
super.init(config, props);
}
}
import { AbstractContainer } from '../AbstractContainer';
export class ContainerForFunctionalComponent
extends AbstractContainer {
_props;
_state;
init(config, props, state, setState) {
this._props = props;
this._state = state;
this.setState = setState;
super.init(config, props);
}
get state() {
return this._state;
}
set state(state) {
this._state = state;
}
get props() {
return this._props;
}
set props(props) {
this._props = props;
}
}
Изменяем react-компоненты. Часть 3: связывание компонента с логикой контейнераКлассы контейнеры созданы. Осталось использовать их в компонентах.Для компонентов-классов создадим класс ComponentWithContainer, в котором инициализируем контейнер и в методах жизненного цикла компонента вызываем соответствующие методы в behaviours с помощью event emitter-а.Чтобы при создании компонентов-классов писать меньше кода, создание класса обернуто в функцию createComponentWithContainer.
Таким образом, при создании компонентов больше не нужно создавать классы-наследники. Для всех компонентов будет используется один общий класс - ComponentWithContainer. Компоненты будут отличаться только передаваемым набором параметров в config.ComponentWithContainer
import { LifeCycleEvents } from '../LifeCycleEvents';
import { ContainerForClassComponent } from './ContainerForClassComponent';
class ComponentWithContainer extends React.Component {
_container;
constructor(props, context, config) {
super(props, context);
this._container = new ContainerForClassComponent();
this._container.init(config, props, this);
}
componentDidMount() {
this._container.eventEmitter.callMethodInAllBehaviours(
LifeCycleEvents.COMPONENT_DID_MOUNT,
);
}
componentDidUpdate(...args) {
this._container.eventEmitter.callMethodInAllBehaviours(
LifeCycleEvents.COMPONENT_DID_UPDATE, args,
);
}
componentWillUnmount() {
this._container.eventEmitter.callMethodInAllBehaviours(
LifeCycleEvents.BEHAVIOUR_WILL_REMOVED,
);
this._container.eventEmitter.callMethodInAllBehaviours(
LifeCycleEvents.COMPONENT_WILL_UNMOUNT,
);
}
render() {
return this._container.render();
}
}
export const createComponentWithContainer = (componentName, config) => {
return class extends ComponentWithContainer {
constructor(props, context) {
super(props, context, config);
}
static displayName = componentName;
};
};
Теперь аналог для функциональных компонентов.
Чтобы где-то хранить контейнер, понадобиться хук useRef.
А чтобы хранить общее состояние в виде словаря для behaviours и изменять его, понадобиться хук useState.Для проброса событий жизненного цикла компонента, понадобится хук useEffect. На самом деле понадобиться еще useLayout для более корректного проброса событий, но для краткости я пропущу этот момент. В отдельной ветке в репозитории используются оба хука.useBehaviours
import { useRef, useState, useEffect } from 'react';
import { LifeCycleEvents } from '../LifeCycleEvents';
import { ContainerForFunctionalComponent }
from './ContainerForFunctionalComponent';
export const useBehaviours = (config = {behaviours: []}, props) =>{
let isFirstRender = false;
const ref = useRef();
// create shared state
const [state, setState] = useState({});
// get exist or get new passed initial config
const initialConfig = ref.current
? ref.current.config
: config;
if (!ref.current) {
ref.current = new ContainerForFunctionalComponent();
ref.current.init(initialConfig, props, state, setState);
isFirstRender = true;
} else {
// update state and props in container
ref.current.state = state;
ref.current.props = props;
}
const container = ref.current;
callLifeCycleEvents(
container.eventEmitter, initialConfig, isFirstRender);
return container.render();
};
const callLifeCycleEvents =
(eventEmitter, initialConfig, isFirstRender) => {
// on mount, unmount
useEffect(() => {
eventEmitter.callMethodInAllBehaviours(
LifeCycleEvents.COMPONENT_DID_MOUNT);
return () => {
eventEmitter.callMethodInAllBehaviours(
LifeCycleEvents.BEHAVIOUR_WILL_REMOVED);
eventEmitter.callMethodInAllBehaviours(
LifeCycleEvents.COMPONENT_WILL_UNMOUNT);
}
}, []);
// on update
useEffect(() => {
if (!isFirstRender) {
eventEmitter.callMethodInAllBehaviours(
LifeCycleEvents.COMPONENT_DID_UPDATE_EFFECT);
}
});
};
Изменяем react-компоненты. Часть 4: создание базового объекта (behaviour) для переиспользования логики в компонентахКак я уже писал, для написания пользовательской логики и для ее повторного использования в других компонентов, используется специальный объект – behaviour.
Ниже пример базового класса для создания таких объектов.BaseBehaviour
import lowerFirst from "lodash/lowerFirst";
export class BaseBehaviour {
// необязательное поле на тот случай, если понадобиться сравнивать
// по типу.
type = Object.getPrototypeOf(this).constructor.name;
// используется как идентификатор
name = lowerFirst(this.type);
// Данные и функции, которые передается в компонент через функцию
// mapToRenderData. Используется когда нужно, чтобы при каждом
// рендеринге не создавались новые объекты и функции, а также для
// их передачи в props дочерних компонентов. Таких образом, это может
// помочь избежать лишних перерисовок. useCallback в таком случае
// становится ненужным.
passedToRender = {};
init(container, props, initData = {}, config) {
this.container = container;
if (initData.defaultState) {
this.defaultState = initData.defaultState;
}
}
// об этом позже
get ownProps() {
const propBehaviourName = `bh-${this.name}`;
return this.container.props?.[propBehaviourName];
}
// Эмуляция собственного состояния для каждого behaviour.
// На самом деле используется объект-словарь, хранящийся в контейнере
// или в компоненте. Каждое поле в объекте-словаре указывает
// на состояние в одном из behaviour. Состояние behaviour передается
// в компонент с помощью метода mapToRenderData.
get state() {
const defaultValue = this.defaultState;
return this.container.state
? this.container.state[this.name] || defaultValue
: defaultValue;
}
// Изменяет состояние behaviour и вызывает обновление компонента.
// Cигнатура этого метода эквивалентна методу setState
// компонента-класса.
setState(stateOrUpdater, callback) {
if (typeof stateOrUpdater === 'function') {
const updater = stateOrUpdater;
this.container.setState((prevState) => {
return {
...prevState,
[ this.name ]: updater(prevState[ this.name ])
};
},
callback);
return;
}
const newPartialState = stateOrUpdater;
this.container.setState((prevState) => {
return {
...prevState,
[this.name]: newPartialState
};
});
}
// Возвращает данные и функции, которые в итоге передадуться
// в функцию render, указанную в config компоненте
mapToRenderData() {
return {
...this.state,
...this.passedToRender,
};
}
// Очистка состояния при удалении
behaviourWillRemoved() {
this.setState(undefined);
}
}
Изменяем react-компоненты. Часть 5: Примеры создания компонентов. Итоговая схемаРеализация подхода Entity Component закончена. Теперь можно создавать компоненты. Рассмотрим создание компонента-класса и функционального компонента на примере простого счетчика. Больше примеров использования можно найти в репозитории, ссылки на который указаны в начале статьи.Примеры компонентов и behaviour
import { createComponentWithContainer }
from "../core/forClassComponent/createComponentWithContainer";
import { BaseBehaviour } from "../core/BaseBehaviour";
// Здесь пишется логика компонента и описываются данные, используемые
// в функции render
class CounterBehaviour extends BaseBehaviour {
defaultState = { count: 0 };
passedToRender = {
setCount: value => {
this.setState({ count: value });
}
};
}
export const CounterExample = createComponentWithContainer(
'CounterExample', {
behaviours: [{ behaviour: CounterBehaviour }],
render: ({ count, setCount }) => (
<>
<h3>Counter Example</h3>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>Click me</button>
</>
),
});
export const CounterExampleWithHooks = (props) => {
return useBehaviours({
behaviours: [{ behaviour: CounterBehaviour }],
render: ({ count, setCount }) => (
<>
<h3>Counter Example</h3>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>Click me</button>
</>
),
},
props);
};
Как видно из примера, behaviours могут быть использованы в обоих типах компонентов. Большинство часто используемых методов жизненного цикла имеют одинаковые имена. Одинаковые функцию render и объект config можно вынести вне компонента и использовать в похожих компонентах.Т.к. в behaviour метод mapToRenderData вызывается при каждом рендере компонента, в нем можно использовать хуки. Это может помочь сэкономить время, если уже есть много готовых хуков. Также это позволит получить другие преимущества хуков. Пример использования хуков, а также всех методов жизненного цикла есть в \src\LifeCycleExampleWithHooks\ LifeCycleExampleWithHooks.js
Но, я рекомендую не усложнять и не использовать несколько подходов в одном проекте.Для разработчика потоки данных в приведенных составляющих компонента выглядят примерно, как на схеме ниже. Синим отмечен поток при вызове методов жизненного цикла и создании компонента. Зеленым отмечен поток возвращаемых данных при рендеринге компонента. Разработчику практически нет необходимости взаимодействовать с контейнером вручную.
- Сначала компонент получает props.
- behaviour при необходимости берет props из компонента, либо получает их при вызове методов жизненного цикла.
- При рендере компонента вызывается функция render из config, затем вызывается метод mapToRenderData в behaviour. mapToRenderData считывает объекты state и passedToRender, объединяет их в объект renderData и передает дальше.
- Далее функция render в config-е получает объект renderData и использует его в JSX коде.
- Далее в компонент возвращается готовый JSX код.
Дополнение. Группировка propsВ BaseBehaviour вы уже видели геттер ownProps:
get ownProps() {
const propBehaviourName = `bh-${this.name}`;
return this.container.props?.[propBehaviourName];
}
Пришло время рассказать для чего он нужен. Он позволяет задавать props, которые нужны только текущему bahaviour. Например:
<Form
bh-bindModel={customModel}
bh-formController={{onSubmit: customAction }}
/>
Через префикс "bh-" указаны имена 2-х behaviours и объект с данными, которые нужны только им. Меня мотивировала создать этот геттер работа с фреймворком React-Admin. В некоторых его компонентах очень много props и сложно разобраться, к какому компоненту/хуку/HOC они относятся. Пример такого компонента: https://github.com/marmelab/react-admin/blob/master/packages/ra-ui-materialui/src/form/SimpleForm.tsxДополнение. Директивы - это не то, за что их принимают. Так почему бы и нет?Грубо говоря, директивы в Angular и в Vue являются дополнительной программной сущностью для повторного использования кода в компонентах. Очень здорово, но я считаю ненужным усложнением вводить дополнительную сущность для этого. Для повторного использования кода достаточно одного вида сущностей.Директива в моем понимании – это средство расширения функционала компонента через атрибут (props в react-компоненте). Директивы в моем примере – это просто средство задания behaviours компонента через props. В React решили отказаться от директив. Скорее всего у авторов React не такое виденье директив, как у меня. Атрибуты - это неотъемлемая часть HTML. Вполне естественно использовать их для задания поведения компонента. Да, можно без них. Но тогда вместо создания 5 типов компонентов и 5 типов директив может потребоваться создание 50-ти типов компонентов.
Вернемся к моему коду. В методе контейнера вы уже видели следующий код:
_createBehaviours(props) {
const defaultBehaviours = props?.defaultBehaviours;
const allBehParams = defaultBehaviours || this.config.behaviours || [];
То есть behaviours компонента можно задать через свойство defaultBehaviours. Это позволяет переопеределить behaviours, заданные при объявлении компонента. Это позволяет в разных частях проекта использовать один и тот же тип компонента, но с разным поведением.CounterBehaviour из примера выше можно задать так, а не в коде самого компонента:
<CounterExample defaultBehaviours={[
{behaviour: CounterBehaviour, initData: {count: 0}
]}
/>
Заключение. В каких проектах Entity Component может быть полезенЯ особо не планирую развивать данный проект. Если кто-то с хорошим опытом хочет заняться развитием проекта, пишите в личку. Как разработчику, использовавшему подход Entity Component в другом стеке и оценившему его преимущества, мне было интересно реализовать его для объектов со схожей структурой (компонент с логикой и с вложенными компонентами) и продемонстрировать остальным.Я не думаю, что этот подход станет популярным в веб-разработке. По крайней мере, не в ближайшем будущем. Для продвижения потребовалось бы потратить много сил и времени. Решение не совсем завершено и сделано только в целях демонстрации подхода. Хотя, и этого вполне достаточно для использования в проектах. Этот подход может пригодиться в больших компаниях, где не принято использовать сторонние компоненты, а где разрабатывают собственную библиотеку компонентов, которую используют другие команды. EC гибче популярных в вебе подходов, т.к. он направлен не на создание компонентов, а на уровень ниже - на создании составляющих компонентов.
Также данный подход будет полезен, если вы создаете компоненты под разные платформы (React Native и Web) и вам нужно немного разное поведение одного компонента и немного разный JSX код. Я уже писал в одном комментарии такой пример:
const renderButtonWeb = ({ propA, propB, ...props}) => (<div> ... </div>);
const renderButtonNative = ({ propA, propB, ...props}) => (<div> ... </div>);
const WebButton = createContainerComponent("WebButton", {
behaviours: [
{ behaviour: CommonButtonMethods },
{ behaviour: WebButtonMethods }
],
render: renderButtonWeb
});
const NativeButton = createContainerComponent("NativeButton", {
behaviours: [
{ behaviour: CommonButtonMethods },
{ behaviour: NativeButtonMethods }
],
render: renderButtonNative
});
===========
Источник:
habr.com
===========
Похожие новости:
- [Разработка веб-сайтов, JavaScript, TypeScript] Exports в package.json
- [Ajax, Разработка веб-сайтов, API] Как работать с ошибками бизнес-логики через HTTP
- [Ajax, PHP, JavaScript, CRM-системы] Обзор разработки дополнений для amoCRM, с использованием webHook и виджетов
- [JavaScript, API, Rust, Микросервисы] GraphQL на Rust (перевод)
- [JavaScript, Программирование] Программная генерация изображений с помощью API CSS Painting (перевод)
- [Разработка веб-сайтов, JavaScript, Angular, TypeScript] Как мы делаем базовые компоненты в Taiga UI более гибкими: концепция контроллеров компонента в Angular
- [JavaScript, ReactJS, TypeScript] Структура React REST API приложения + TypeScript + Styled-Components
- [Работа с 3D-графикой, Проектирование и рефакторинг, CAD/CAM] SOLIDWORKS Simulation 2021: быстрое, стабильное и точное моделирование контактов
- [JavaScript, Программирование, Расширения для браузеров, Браузеры] Hello, Word! Разрабатываем браузерное расширение в 2021-м
- [JavaScript, ReactJS] Небольшая практика с JS Proxy для оптимизации перерисовок React компонентов при использовании useContext
Теги для поиска: #_razrabotka_vebsajtov (Разработка веб-сайтов), #_javascript, #_proektirovanie_i_refaktoring (Проектирование и рефакторинг), #_reactjs, #_react, #_javascript, #_proektirovanie (проектирование), #_razrabotka_vebsajtov (
Разработка веб-сайтов
), #_javascript, #_proektirovanie_i_refaktoring (
Проектирование и рефакторинг
), #_reactjs
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 23-Ноя 00:45
Часовой пояс: UTC + 5
Автор | Сообщение |
---|---|
news_bot ®
Стаж: 6 лет 9 месяцев |
|
В этом статье я покажу, как для React-компонентов реализовать один из подходов на основе сущностей и их составляющих. Как я упоминал в предыдущей статье "Техники повторного использования кода" в главе "Entity Component (EC)", я не знаю точное название подхода. В статье я буду называть его Entity Component (EC). Entity Component используется для решения той же проблемы, что HOC и Custom hooks – повторно использовать код между множеством однотипных объектов/функций и разбить сложный объект на более простые составляющие. Эта необходимость появилась довольно давно, гораздо раньше, чем с ней столкнулись в вебе. И давно были придуманы эффективные решения.Custom hooks и Entity Component - оба добавляют к объекту некий функционал. Тем самым они близки к паттерну «стратегия». Но, Entity Component – решение для объектов (судя по тому, что я встречал), а Custom hooks - для функций. Даже если этот подход вам не интересен, как минимум вы увидите, как можно переделать структуру функциональных компоненты и компонентов-классов под свои нужды. Узнаете нестандартные приемы, которые можно использовать при разработке компонентов. Те, кто пишет компоненты-классы, узнает, как повторно использовать код более эффективным способом, чем HOC, не создавая лишние компоненты-обертки.Если разобраться, то подход довольно прост. Но, возможно, я не смогу его достаточно понятно объяснить. Он давно используется в геймдеве, в том числе в разработке UI. Здесь многие возразят: "это же подход из другой области и вряд ли будет хорош в вебе". Это так, если окружение и разработка сильно отличаются. Здесь же они похожи. На странице и в игровой сцене используется дерево объектов (DOM и дерево объектов сцены). Компоненты в вебе состоят из вложенных объектов и объекты в играх тоже состоят из вложенных объектов. Компоненты в вебе могут состоять из составляющих (атрибуты/директивы или хуки), и объекты в играх тоже состоят из составляющих. Отличия есть, но не такие, чтобы нельзя было применять общие подходы. Просто их нужно адаптировать под используемую область.Исходники и примеры компонентов, созданных описываемым подходом, доступны по ссылкам ниже.Код из статьи: github Более полная реализация: github, codesanboxСодержание
1) callMethodInBehaviour – вызывает метод в behaviour, имя которого совпадает с именем события. 2) callMethodInAllBehaviours – вызывает метод во всех behaviours компонента.EventEmitter export class SimpleEventEmitter {
_behaviourArray; init(behaviourArray) { this._behaviourArray = behaviourArray; } callMethodInBehaviour(methodName, behaviourInstance, args = []) { const behaviourMethod = behaviourInstance[methodName]; if (behaviourMethod) { behaviourMethod.apply(behaviourInstance, args); } } callMethodInAllBehaviours(methodName, args = []) { this._behaviourArray.forEach(beh => { if (beh[methodName]) { beh[methodName](...args); } }); } } export const LifeCycleEvents = {
BEHAVIOUR_ADDED: 'behaviourAdded', COMPONENT_INITIALIZED: 'componentInitialized', COMPONENT_DID_MOUNT: 'componentDidMount', COMPONENT_DID_UPDATE: 'componentDidUpdate', // для вызова из useEfect() COMPONENT_DID_UPDATE_EFFECT: 'componentDidUpdateEffect', BEHAVIOUR_WILL_REMOVED: 'behaviourWillRemoved', COMPONENT_WILL_UNMOUNT: 'componentWillUnmount', // остальные события не реализованы в примерах };
import { LifeCycleEvents } from './LifeCycleEvents';
import { SimpleEventEmitter } from './eventEmitters/SimpleEventEmitter'; export class AbstractContainer { _eventEmitter; _config; // Array with all behaviours of component behaviourArray = []; // Object (dictionary) with all behaviours of container. // To simplify access to behaviour by name behs = {}; // Object (dictionary) with pairs [behaviourName]: behParamsObject behsParams = {}; get eventEmitter() { return this._eventEmitter; } get config() { return this._config; } get state() { console.error('container state getter is not implemented'); } get props() { console.error('container props getter is not implemented'); } init(config, props) { this._eventEmitter = new SimpleEventEmitter(); this._eventEmitter.init(this.behaviourArray); this._config = config; this._createBehaviours(props); } _createBehaviours(props) { const defaultBehaviours = props?.defaultBehaviours; const allBehParams = defaultBehaviours || this.config.behaviours || []; // create behaviours allBehParams.forEach(oneBehParams => { const { behaviour, initData, ...passedBehParams } = oneBehParams; this.addBehaviour(behaviour, props, initData, passedBehParams); }); this._eventEmitter.callMethodInAllBehaviours( LifeCycleEvents.COMPONENT_INITIALIZED, [props], ); } setState(stateOrUpdater){ console.error('container setState is not implemented'); } addBehaviour(behaviour, props, initData, behaviourParams = {}) { const newBeh = new behaviour(); this.behaviourArray.push(newBeh); this.behs[ newBeh.name ] = newBeh; this.behsParams[ newBeh.name ] = behaviourParams; if (newBeh.init) { newBeh.init(this, props, initData, behaviourParams); } this._eventEmitter.callMethodInBehaviour( LifeCycleEvents.BEHAVIOUR_ADDED, newBeh); return newBeh; } removeBehaviour(behaviourInstance) { const foundIndex = this.behaviourArray.indexOf(behaviourInstance); if (foundIndex > -1) { this._eventEmitter.callMethodInBehaviour( LifeCycleEvents.BEHAVIOUR_WILL_REMOVED, behaviourInstance); this.behaviourArray.splice(foundIndex, 1); delete this.behs[behaviourInstance.name]; delete this.behsParams[behaviourInstance.name]; } else { console.warn( `removeBehaviour error: ${behaviourInstance.name} not found` ); } } // Return all behaviours renderData mixed in single object. _mapToMixedRenderData() { let retRenderData = this.behaviourArray.reduce((mixedData, beh) => { const behRenderData = beh.mapToRenderData(); Object.assign(mixedData, behRenderData); return mixedData; }, {}); return retRenderData; } render() { const renderFunc = this.config.render ? this.config.render : ({ props }) => props?.children; return renderFunc({ props: this.props, ...this._mapToMixedRenderData(this) }); }; } import { AbstractContainer } from '../AbstractContainer';
export class ContainerForClassComponent extends AbstractContainer { _component; get state() { return this._component.state; } get props() { return this._component.props; } setState = (stateOrUpdater) =>{ this._component.setState(stateOrUpdater); }; init(config, props, component) { this._component = component; super.init(config, props); } } import { AbstractContainer } from '../AbstractContainer'; export class ContainerForFunctionalComponent extends AbstractContainer { _props; _state; init(config, props, state, setState) { this._props = props; this._state = state; this.setState = setState; super.init(config, props); } get state() { return this._state; } set state(state) { this._state = state; } get props() { return this._props; } set props(props) { this._props = props; } } Таким образом, при создании компонентов больше не нужно создавать классы-наследники. Для всех компонентов будет используется один общий класс - ComponentWithContainer. Компоненты будут отличаться только передаваемым набором параметров в config.ComponentWithContainer import { LifeCycleEvents } from '../LifeCycleEvents';
import { ContainerForClassComponent } from './ContainerForClassComponent'; class ComponentWithContainer extends React.Component { _container; constructor(props, context, config) { super(props, context); this._container = new ContainerForClassComponent(); this._container.init(config, props, this); } componentDidMount() { this._container.eventEmitter.callMethodInAllBehaviours( LifeCycleEvents.COMPONENT_DID_MOUNT, ); } componentDidUpdate(...args) { this._container.eventEmitter.callMethodInAllBehaviours( LifeCycleEvents.COMPONENT_DID_UPDATE, args, ); } componentWillUnmount() { this._container.eventEmitter.callMethodInAllBehaviours( LifeCycleEvents.BEHAVIOUR_WILL_REMOVED, ); this._container.eventEmitter.callMethodInAllBehaviours( LifeCycleEvents.COMPONENT_WILL_UNMOUNT, ); } render() { return this._container.render(); } } export const createComponentWithContainer = (componentName, config) => { return class extends ComponentWithContainer { constructor(props, context) { super(props, context, config); } static displayName = componentName; }; }; Чтобы где-то хранить контейнер, понадобиться хук useRef. А чтобы хранить общее состояние в виде словаря для behaviours и изменять его, понадобиться хук useState.Для проброса событий жизненного цикла компонента, понадобится хук useEffect. На самом деле понадобиться еще useLayout для более корректного проброса событий, но для краткости я пропущу этот момент. В отдельной ветке в репозитории используются оба хука.useBehaviours import { useRef, useState, useEffect } from 'react';
import { LifeCycleEvents } from '../LifeCycleEvents'; import { ContainerForFunctionalComponent } from './ContainerForFunctionalComponent'; export const useBehaviours = (config = {behaviours: []}, props) =>{ let isFirstRender = false; const ref = useRef(); // create shared state const [state, setState] = useState({}); // get exist or get new passed initial config const initialConfig = ref.current ? ref.current.config : config; if (!ref.current) { ref.current = new ContainerForFunctionalComponent(); ref.current.init(initialConfig, props, state, setState); isFirstRender = true; } else { // update state and props in container ref.current.state = state; ref.current.props = props; } const container = ref.current; callLifeCycleEvents( container.eventEmitter, initialConfig, isFirstRender); return container.render(); }; const callLifeCycleEvents = (eventEmitter, initialConfig, isFirstRender) => { // on mount, unmount useEffect(() => { eventEmitter.callMethodInAllBehaviours( LifeCycleEvents.COMPONENT_DID_MOUNT); return () => { eventEmitter.callMethodInAllBehaviours( LifeCycleEvents.BEHAVIOUR_WILL_REMOVED); eventEmitter.callMethodInAllBehaviours( LifeCycleEvents.COMPONENT_WILL_UNMOUNT); } }, []); // on update useEffect(() => { if (!isFirstRender) { eventEmitter.callMethodInAllBehaviours( LifeCycleEvents.COMPONENT_DID_UPDATE_EFFECT); } }); }; Ниже пример базового класса для создания таких объектов.BaseBehaviour import lowerFirst from "lodash/lowerFirst";
export class BaseBehaviour { // необязательное поле на тот случай, если понадобиться сравнивать // по типу. type = Object.getPrototypeOf(this).constructor.name; // используется как идентификатор name = lowerFirst(this.type); // Данные и функции, которые передается в компонент через функцию // mapToRenderData. Используется когда нужно, чтобы при каждом // рендеринге не создавались новые объекты и функции, а также для // их передачи в props дочерних компонентов. Таких образом, это может // помочь избежать лишних перерисовок. useCallback в таком случае // становится ненужным. passedToRender = {}; init(container, props, initData = {}, config) { this.container = container; if (initData.defaultState) { this.defaultState = initData.defaultState; } } // об этом позже get ownProps() { const propBehaviourName = `bh-${this.name}`; return this.container.props?.[propBehaviourName]; } // Эмуляция собственного состояния для каждого behaviour. // На самом деле используется объект-словарь, хранящийся в контейнере // или в компоненте. Каждое поле в объекте-словаре указывает // на состояние в одном из behaviour. Состояние behaviour передается // в компонент с помощью метода mapToRenderData. get state() { const defaultValue = this.defaultState; return this.container.state ? this.container.state[this.name] || defaultValue : defaultValue; } // Изменяет состояние behaviour и вызывает обновление компонента. // Cигнатура этого метода эквивалентна методу setState // компонента-класса. setState(stateOrUpdater, callback) { if (typeof stateOrUpdater === 'function') { const updater = stateOrUpdater; this.container.setState((prevState) => { return { ...prevState, [ this.name ]: updater(prevState[ this.name ]) }; }, callback); return; } const newPartialState = stateOrUpdater; this.container.setState((prevState) => { return { ...prevState, [this.name]: newPartialState }; }); } // Возвращает данные и функции, которые в итоге передадуться // в функцию render, указанную в config компоненте mapToRenderData() { return { ...this.state, ...this.passedToRender, }; } // Очистка состояния при удалении behaviourWillRemoved() { this.setState(undefined); } } import { createComponentWithContainer }
from "../core/forClassComponent/createComponentWithContainer"; import { BaseBehaviour } from "../core/BaseBehaviour"; // Здесь пишется логика компонента и описываются данные, используемые // в функции render class CounterBehaviour extends BaseBehaviour { defaultState = { count: 0 }; passedToRender = { setCount: value => { this.setState({ count: value }); } }; } export const CounterExample = createComponentWithContainer( 'CounterExample', { behaviours: [{ behaviour: CounterBehaviour }], render: ({ count, setCount }) => ( <> <h3>Counter Example</h3> <p>You clicked {count} times</p> <button onClick={() => setCount(count + 1)}>Click me</button> </> ), }); export const CounterExampleWithHooks = (props) => { return useBehaviours({ behaviours: [{ behaviour: CounterBehaviour }], render: ({ count, setCount }) => ( <> <h3>Counter Example</h3> <p>You clicked {count} times</p> <button onClick={() => setCount(count + 1)}>Click me</button> </> ), }, props); }; Но, я рекомендую не усложнять и не использовать несколько подходов в одном проекте.Для разработчика потоки данных в приведенных составляющих компонента выглядят примерно, как на схеме ниже. Синим отмечен поток при вызове методов жизненного цикла и создании компонента. Зеленым отмечен поток возвращаемых данных при рендеринге компонента. Разработчику практически нет необходимости взаимодействовать с контейнером вручную.
get ownProps() {
const propBehaviourName = `bh-${this.name}`; return this.container.props?.[propBehaviourName]; } <Form
bh-bindModel={customModel} bh-formController={{onSubmit: customAction }} /> Вернемся к моему коду. В методе контейнера вы уже видели следующий код: _createBehaviours(props) {
const defaultBehaviours = props?.defaultBehaviours; const allBehParams = defaultBehaviours || this.config.behaviours || []; <CounterExample defaultBehaviours={[
{behaviour: CounterBehaviour, initData: {count: 0} ]} /> Также данный подход будет полезен, если вы создаете компоненты под разные платформы (React Native и Web) и вам нужно немного разное поведение одного компонента и немного разный JSX код. Я уже писал в одном комментарии такой пример: const renderButtonWeb = ({ propA, propB, ...props}) => (<div> ... </div>);
const renderButtonNative = ({ propA, propB, ...props}) => (<div> ... </div>); const WebButton = createContainerComponent("WebButton", { behaviours: [ { behaviour: CommonButtonMethods }, { behaviour: WebButtonMethods } ], render: renderButtonWeb }); const NativeButton = createContainerComponent("NativeButton", { behaviours: [ { behaviour: CommonButtonMethods }, { behaviour: NativeButtonMethods } ], render: renderButtonNative }); =========== Источник: habr.com =========== Похожие новости:
Разработка веб-сайтов ), #_javascript, #_proektirovanie_i_refaktoring ( Проектирование и рефакторинг ), #_reactjs |
|
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 23-Ноя 00:45
Часовой пояс: UTC + 5