DI для полностью переиспользуемых JSX-компонентов

в 11:24, , рубрики: dependency injection, javascript, react.js, ReactJS, Программирование

Dependency inception

Привет, меня зовут Сергей и мне интересна проблема переиспользования компонент в вебе. Глядя на то, как пытаются применить SOLID к реакту, я решил продолжить эту тему и показать, как можно достичь хорошей переиспользуемости, развивая идею внедрения зависимостей или DI.

DI как основа для построения фреймворка, применительно к вебу, довольно молодой подход. Что бы тут было понятней о чем идет речь, начну c привычных для react-разработчиков вещей.

От контекстов к DI

Уверен, что многие использовали контексты при работе с react. Если не напрямую, то наверняка через connect в redux или inject в mobx-react. Суть в том, что в одном компоненте (MessageList) мы объявляем нечто в контексте, а в другом (Button) — говорим, что хотим получить это нечто из контекста.

const PropTypes = require('prop-types');

const Button = ({children}, context) =>
  <button style={{background: context.color}}>
    {children}
  </button>;

Button.contextTypes = {color: PropTypes.string};

class MessageList extends React.Component {
  getChildContext() {
    return {color: "purple"};
  }

  render() {
    return <Button>Ok</Button>;
  }
}

Т.е. один раз в родительском компоненте задается context.color, а далее он автоматически пробрасывается любым нижележащим компонентам, в которых через contextTypes объявлена зависимость от color. Таким образом Button можно кастомизировать без прокидывания свойств по иерархии. Причем на любом уровне иерархии можно через getChildContext() ... переопределить color для всех дочерних компонент.

Такой подход лучше изолирует компоненты друг от друга, упрощая их настройку и переиспользование. В примере выше, достаточно в родительском компоненте определить color и все кнопки поменяют цвет. Причем компонент Button может быть в другой библиотеке, которую при этом не надо рефакторить.

Однако, для реакта, в виду недостаточной продуманности, этот подход пока развит слабо. Напрямую его использовать разработчики не рекомендуют:

It is an experimental API and it is likely to break in future releases of React

написано в документации. Оно experimental в текущем виде уже достаточно давно и ощущение, что разработка зашла в тупик. Контексты в компонентах сцеплены с инфраструктурой (getChildContext), псевдотипизацией через PropTypes и больше похожи на service locator, который некоторые считают антипаттерном. Роль контекстов, на мой взгляд, недооценена и в реакте второстепенная: локализация и темизация, а также биндинги к библиотекам вроде redux и mobx.

В других фреймворках подобные инструменты развиты лучше. Например, в vue: provide/inject, а в angular, его angular di это уже полноценный навороченный DI с поддержкой типов typescript. По-сути, начиная с Angular второй версии, разработчики попытались переосмыслить опыт бэкенда (где DI уже давно существует) применительно к фронтенду. А что если попытаться развить аналогичную идею для реакта и его клонов, какие проблемы бы решились?

Прибивать или нет, вот в чем вопрос

В полноценном реакт/redux-приложении не всё делают через redux-экшены. Состояние какой-нибудь малозначительной галочки удобнее реализовать через setState. Получается — через redux громоздко, а через setState не универсально, но проще, т.к. он всегда под рукой. Статья You Might Not Need Redux известного автора, как бы говорит "если вам не нужно масштабирование — не используйте redux", подтверждая эту двойственность. Проблема в том, что это сейчас не нужно, а завтра может быть понадобится прикрутить логирование состояния галочки.

В другой статье того же автора, Presentational and Container Components, говорится примерно, что "Все компоненты равны (Presentational), но некоторые равнее (Container)" и при этом высечены в граните (прибиты к redux, mobx, relay, setState). Кастомизация Container компонента усложняется — не предполагается его переиспользовать, он уже прибит к реализации состояния и контексту.

Что бы как-то упростить создание Container-компонент, придумали HOC, но по-сути мало что поменялось. Просто чистый компонент стали комбинировать через connect/inject с чем-то вроде redux, mobx, relay. А полученный монолитный Container использовать в коде.

Иными словами, говорим Presentational и Container, а подразумеваем — переиспользуемый и непереиспользуемый. Первый удобно кастомизировать т.к. все точки расширения в свойствах, а второй — рефакторить, т.к свойств меньше, за счет его прибитости к стейту и некоторой логике. Это — некий компромисс по решению двух противоположных проблем, плата за который — разделение компонент на два типа и жертвование принципом открытости/закрытости.

Например, как в статье Заменяй и властвуй — подход SOLID, где предлагается делать большинство компонент максимально простыми, ухудшая их целостность. Но, сложные компоненты из простых все-равно надо будет где-то собирать и при этом остается вопрос как их кастомизировать. Т.е. проблема переносится на другой уровень.

<ModalBackdrop onClick={() => this.setState({ dialogOpen: false })} />
    <ModalDialog open={this.state.dialogOpen} >
        <ModalDialogBox>
            <ModalDialogHeaderBox>
                <ModalDialogCloseButton onClick={() => this.setState({ dialogOpen: false })} />
                <ModalDialogHeader>Dialog header</ModalDialogHeader>
            </ModalDialogHeaderBox>
            <ModalDialogContent>Some content</ModalDialogContent>
            <ModalDialogButtonPanel>
                <Button onClick={() => this.setState({ dialogOpen: false })} key="cancel">
                    {resources.Navigator_ButtonClose}
                </Button>
                <Button disabled={!this.state.directoryDialogSelectedValue}
                    onClick={this.onDirectoryDialogSelectButtonClick} key="ok">
                    {resources.Navigator_ButtonSelect}
                </Button>
            </ModalDialogButtonPanel>
        </ModalDialogBox>
    </ModalDialog>
</ModalBackdrop>

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

Прототип

Развивая идею внедрения зависимостей, можно решить некоторые из этих проблем. Разберем решение на основе вот такого примера:

// @flow

// @jsx lom_h

// setup...

class HelloService {
    @mem name = ''
}

function HelloView(props: {greet: string}, service: HelloService) {
    return <div>
        {props.greet}, {service.name}
        <br/><input value={service.name} onChange={(e) => { service.name = e.target.value }} />
    </div>
}
// HelloView.deps = [HelloService]

ReactDOM.render(<HelloView greet="Hello"/>, document.getElementById('mount'))

fiddle

Здесь есть одна универсальная форма компонента в виде функции, независимо от того, работает он с состоянием или нет. Контексты используют типы. Из них автоматически генерируются описания зависимостей с помощью babel-plugin-transform-metadata. Аналогично typescript, который это делает, правда, только для классов. Хотя можно описывать аргументы и вручную: HelloView.deps = [HelloService]

Lifecycle

А как же быть с жизненным циклом компонента? А так ли нужна низкоуровневая работа с ним в коде? Посредством HOC как раз пытаются убрать эти lifecycle methods из основного кода, например, как в relay/graphql.

Идея в том, что актуализация данных — это не ответственность компонента. Если у вас загрузка данных происходит по факту доступа к этим данным (например, используется lazyObservable из mobx-utils), то componentDidMount в этом случае не нужен. Если надо прикрутить jquery-плагин, то есть свойство refs в элементе и т.д.

Предположим, что универсальный компонент, свободный от vendor lock-in реакта, теперь есть. Пусть, мы даже выделили его в отдельную библиотеку. Осталось решить, как расширять и настраивать то, что приходит в контекст. Ведь HelloService — это некая реализация по-умолчанию.

Поди туда — не знаю куда, принеси то — не знаю что

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

Представим на секунду, что в случае любого компонента нельзя заранее сказать, что у него будет кастомизироваться. И нужен способ менять любую внутреннюю часть компонента без рефакторинга (принцип открытости/закрытости), при этом не ухудшая его читабельности, не усложняя его исходную реализацию и не вкладываясь в переиспользуемость изначально (нельзя все предвидеть).

Например, без DI можно проектировать, подразумевая кастомизацию через наследование. Т.е. дробить содержимое на мелкие методы, теряя при этом в наглядности и иерархии. О минусах этого подхода пишет автор в статье Идеальный UI фреймворк:

class MyPanel extends React.Component {
    header() { return <div class="my-panel-header">{this.props.head}</div> }
    bodier() { return <div class="my-panel-bodier">{this.props.body}</div> }
    childs() { return [ this.header() , this.bodier() ] }
    render() { return <div class="my-panel">{this.childs()}</div>
}

class MyPanelExt extends MyPanel {
    footer() { return <div class="my-panel-footer">{this.props.foot}</div> }
    childs() { return [ this.header() , this.bodier() , this.footer() ] }
}

Надо сказать, что этот автор (@vintage), придумал формат tree, который позволяет описать вышеприведенный пример с сохранением иерархии. Несмотря на то, что многие критикуют этот формат, у него есть преимущество как раз в виде переопределяемости даже самых мелких деталей без специального разбиения на части и рефакторинга. Иными словами, это бесплатная (почти, кроме постижения новой необычной концепции) буковка O в SOLID.

Полностью перенести этот принцип на JSX невозможно, однако частично реализовать его через DI можно попытаться. Смысл в том, что любой компонент в иерархии — это еще и точка расширения, слот, если рассуждать в терминах vue. И мы в родительском компоненте можем поменять его реализацию, зная его идентификатор (исходная реализация или интерфейс). Примерно так работают многие контейнеры зависимостей, позволяя ассоциировать реализации с интерфейсами.

В js/ts, в runtime, без усложнений или внедрения строковых ключей, ухудшающих безопасность кода, нельзя ссылаться на интерфейс. Поэтому следующий пример не заработает в flow или typescript (но аналогичный заработает в C# или Dart):

interface ISome {}

class MySome implements ISome {}

const map = new Map()
map.set(ISome, MySome)

Однако, можно ссылаться на абстрактный класс или функцию.

class AbstractSome {}

class MySome extends AbstractSome {}

const map = new Map()
map.set(AbstractSome, MySome)

Т.к. создание объектов и компонент происходит внутри DI-контейнера, а там внутри может быть подобный map, то любую реализацию можно переопределить. А т.к. компоненты, кроме самых примитивных — функции, то их можно подменять на функции с таким же интерфейсом, но с другой реализацией.

Например, TodoResetButtonView является частью TodoView. Требуется переопределить TodoResetButtonView на кастомную реализацию.

function TodoResetButtonView({onClick}) {
  return <button onClick={onClick}>reset</button>
}

function TodoView({todo, desc, reset}) {
    return <li>
        <input
            type="checkbox"
            checked={todo.finished}
            onClick={() => todo.finished = !todo.finished}
        />{todo.title} #{todo.id} ({desc.title})
        <TodoResetButtonView>reset</TodoResetButtonView>
    </li>
}

Предположим у нас нет возможности править TodoView (он в другой библиотеке и мы не хотим его трогать, нарушая open/close принцип и заново тестировать 11 других проектов, которые его использовали со старой кнопкой).

Поэтому создаем новую кнопку и клонируем существующий TodoView, заменяя ее в клоне. Это наследование, только наглядность не нарушается — остается иерархия и не нужно специально заранее проектировать TodoView так, что бы можно было заменить кнопку.

function ClonedTodoResetButtonView({onClick}) {
  return <button onClick={onClick}>cloned reset</button>
}

const ClonedTodoView = cloneComponent(TodoView, [
    [TodoResetButtonView, ClonedTodoResetButtonView]
], 'ClonedTodoView')

const ClonedTodoListView = cloneComponent(TodoListView, [
    [TodoView, ClonedTodoView]
], 'ClonedTodoListView')

ReactDOM.render(<ClonedTodoListView todoList={store} />, document.getElementById('mount'));

fiddle

Переопределять иногда надо не только компоненты, но и их зависимости:

class AbstractHelloService {
    name: string
}

function HelloView(props: {greet: string}, service: AbstractHelloService) {
    return <div>
        {props.greet}, {service.name}
        <br/><input value={service.name} onChange={(e) => { service.name = e.target.value }} />
    </div>
}

class AppHelloService {
    @mem name = 'Jonny'
}

function AppView() {
  return <HelloView greet="Hello"/>
}
AppView.aliases = [
    [AbstractHelloService, AppHelloService]
]

fiddle

HelloView получит экземпляр класса AppHelloService. Т.к. AppView.aliases для всех дочерних компонент переопределяет AbstractHelloService.

Есть конечно и минус подхода "все кастомизируется" через наследование. Т.к. фреймворк предоставляет больше точек расширения, то, больше ответственности по кастомизации перекладывается на того, кто использует компонент, а не проектирует его. Переопределяя части компонента "таблица", без осознания смысла, можно ненароком превратить его в "список", а это плохой признак, т.к. является искажением исходного смысла (нарушается LSP принцип).

Разделение состояния

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

class HelloService {
    @mem name = 'John'
}

function HelloView(props: {greet: string}, service: HelloService) {
    return <div>
        {props.greet}, {service.name}
        <br/><input value={service.name} onChange={(e) => { service.name = e.target.value }} />
    </div>
}

class AppHelloService {
    @mem name = 'Jonny'
}

function AppView(_, service: HelloService) {
    return <div>
        <HelloView greet="Hello"/>
        <HelloView greet="Hi"/>
    </div>
}

fiddle

В такой конфигурации, оба HelloView разделяют общий экземпляр HelloService. Однако, без HelloService в AppView, на каждый дочерний компонент будет свой экземпляр.

function AppView() {
    return <div>
        <HelloView greet="Hello"/>
        <HelloView greet="Hi"/>
    </div>
}

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

Стили

Я не утверждаю, что подход css-in-js единственно правильный для использования в веб. Но и тут можно применить идею внедрения зависимостей. Проблема аналогична вышеописанной с redux/mobx и контекстами. Например, как и во многих подобных библиотеках, стили jss прибиваются к компоненту через обертку injectSheet и компонент связывается с конкретной реализацией стилей, с react-jss:

import React from 'react'
import injectSheet from 'react-jss'

const styles = {
  button: {
    background: props => props.color
  },
  label: {
    fontWeight: 'bold'
  }
}

const Button = ({classes, children}) => (
  <button className={classes.button}>
    <span className={classes.label}>
      {children}
    </span>
  </button>
)

export default injectSheet(styles)(Button)

Однако, эту прямую зависимость от jss и ему подобных можно убрать, перенеся эту ответственность на DI. В коде приложения достаточно определить функцию со стилями, как зависимость компонента и пометить ее соответственно.

// ... setup
import {action, props, mem} from 'lom_atom'
import type {NamesOf} from 'lom_atom'

class Store {
  @mem red = 140
}

function HelloTheme(store: Store) {
  return {
    wrapper: {
      background: `rgb(${store.red}, 0, 0)`
    }
  }
}
HelloTheme.theme = true

function HelloView(
  _,
  {store, theme}: {
    store: Store,
    theme: NameOf<typeof HelloTheme>
  }
) {
  return <div className={theme.wrapper}>
    color via css {store.red}: <input
      type="range"
      min="0"
      max="255"
      value={store.red}
      onInput={({target}) => { store.red = Number(target.value) }}
    />
  </div>
}

fiddle

Такой подход для стилей обладает всеми преимуществами DI, таким образом обеспечивается темизация и реактивность. В отличие от переменных в css, здесь работают типы в flow/ts. Из минусов — накладные расходы на генерацию и обновление css.

Итог

В попытке адаптировать идею внедрения зависимостей для компонентов, получилась библиотека reactive-di. Простые примеры в статье постронены на ее основе, но есть и более сложные, с загрузкой, обработкой статусов загрузки, ошибок и т.д. Есть todomvc бенчмарк для react, preact, inferno. В котором можно оценить оверхед от использования reactive-di. Правда, на 100 todos, погрешность измерений у меня была больше, чем этот оверхед.

Получился упрощенный Angular. Однако есть ряд особенностей, reactive-di

  1. Умеет интегрироваться с реактом и его клонами, оставаясь совместимым с legacy-компонентами на чистом реакте
  2. Позволяет писать на одних чистых компонентах, не оборачивая их в mobx/observe или подобное
  3. Хорошо работает с типами в flowtype не только для классов, но и для компонент-функций
  4. Ненавязчив: не требуется кучи декораторов в коде, компоненты абстрагируются от react, его можно поменять на свою реализацию, не затрагивая основной код
  5. Настраивается просто, не нужно региситрации зависимостей, provide/inject-подобных конструкций
  6. Позволяет доопределять содержимое компонента без его модификации, c сохранением иерархии его внутренностей
  7. Позволяет ненавязчиво, через интерфейсы, интегрировать css-in-js решения в компоненты

Почему до сих пор идею контекстов не развивали в этом ключе? Скорее всего непопулярность DI на фронтенде объясняется не повсеместным господством flow/ts и отсутствием стандартной поддержки интерфейсов на уровне метаданных. Попытками скопировать сложные реализации с других backend-ориентированных языков (как InversifyJS клон Ninject из C#) без глубокого переосмысления. А также пока недостаточным акцентом: например, некоторое подобие DI есть в react и vue, но там эти реализации являются неотделимой частью фреймворка и роль их второстепенная.

Хороший DI — это еще половина решения. В примерах выше часто мелькал декоратор @mem, который необходим для управления состоянием, построенном на идее ОРП. С помощью mem можно писать код в псевдосинхронном стиле, с простой, по-сравнению с mobx, обработкой ошибок и статусов загрузки. Про него я расскажу в следующей статье.

Автор: redyuf

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js