В этом статье я покажу, как для 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
});