В данной заметке по шагам расскажу как приготовить IndexDB (база данных, которая встроена в любой современный браузер) для использования в проектах, написанных на ReactJS. В результате Вы сможете использовать данные из IndexDB так же удобно, как если бы они находились в Redux Store вашего приложения.

IndexDB — это документоориентированная СУБД, удобное средство для временного хранения относительно небольшого объёма (единицы и десятки мегабайт) структуированных данных на стороне браузера. К стандартной задаче, для которых мне приходится использовать IndexDB относится кэширование данных бизнес-справочников на стороне клиента (названия стран, городов, валют по коду и прочее). Скопировав их на сторону клиента потом можно лишь изредка загружать с сервера обновления этих справочников (либо целиком — они же небольшие) и не делать это при каждом открытии окна браузера.

Есть и нестандартные, весьма спорные, но рабочие способы использования IndexDB:

  • кэширование данных о всех бизнес-объектах, чтобы на стороне браузера использовать широкие возможности сортировки и фильтрации
  • хранение в IndexDB состояния приложения вместо Redux Store

Для нас важны три ключевых отличия IndexDB от Redux Store:

  1. IndexDB это внешнее хранилище, которое не очищается при уходе со страницы. Кроме того, оно одно и то же для нескольких открытых вкладок (что иногда приводит к несколько неожиданному поведению)
  2. IndexDB это полностью асинхронная СУБД. Все операции — открытие, чтение, запись, поиск — асинхронные.
  3. IndexDB нельзя (тривиальным способом) сохранять в JSON и применять мозговыносящие приёмы из Redux с целью создания Snapshot'ов, простоты отладки и путешествия в прошлое.

Шаг 0: список задач


Ставший уже классическим пример со списком задач. Вариант с хранением состояния в состоянии текущего и единственного компонента

Реализация компонента списка задач с хранением списка в состоянии компонента
import React, { PureComponent } from 'react';
import Button from 'react-bootstrap/Button';
import counter from 'common/counter';
import Form from 'react-bootstrap/Form';
import Table from 'react-bootstrap/Table';

export default class Step0 extends PureComponent {

  constructor() {
    super( ...arguments );

    this.state = {
      newTaskText: '',
      tasks: [
        { id: counter(), text: 'Sample task' },
      ],
    };

    this.handleAdd = () => {
      this.setState( state => ( {
        tasks: [ ...state.tasks, { id: counter(), text: state.newTaskText } ],
        newTaskText: '',
      } ) );
    };
    this.handleDeleteF = idToDelete => () => this.setState( state => ( {
      tasks: state.tasks.filter( ( { id } ) => id !== idToDelete ),
    } ) );
    this.handleNewTaskTextChange = ( { target: { value } } ) => this.setState( {
      newTaskText: value || '',
    } );
  }

  render() {
    return <Table bordered hover striped>
      <thead><tr>
        <th>#</th><th>Text</th><th />
      </tr></thead>
      <tbody>
        { this.state.tasks.map( task => <tr key={task.id}>
          <td>{task.id}</td>
          <td>{task.text}</td>
          <td><Button
            onClick={this.handleDeleteF( task.id )}
            type="button"
            variant="danger">Удалить</Button></td>
        </tr> ) }
        <tr key="+1">
          <td />
          <td><Form.Control
            onChange={this.handleNewTaskTextChange}
            placeholder="Текст новой задачи"
            type="text"
            value={this.state.newTaskText || ''} /></td>
          <td><Button
            onClick={this.handleAdd}
            type="button"
            variant="primary">Добавить</Button></td>
        </tr>
      </tbody>
    </Table>;
  }

}
(исходный код на github)

Пока что все операции с задачами синхронные. Если добавление задачи займёт 3 секунды, то браузер на 3 секунды просто подвиснет. Конечно, пока мы храним всё в памяти об этом можно не думать. Когда же мы включаем в обработку работу с сервером или с локальной базой данных, нам придётся позаботиться и о красивой обработки асинхронности. Например, блокируя работу с таблицей (или отдельными элементами) на время добавления или удаления элементов.

Чтобы в будущем не повторять описание UI, вынесем его в отдельный компонент TaskList, единственная задача которого будет генерировать HTML-код списка задач. Заодно заменим обычные кнопки на специальную обёртку вокруг bootstrap Button, которая будет блокировать кнопку до тех пор, пока обработчик кнопки не завершит своё выполнение, даже если этот обработчик будет асинхронной функцией.

Реализация компонента, хранящего список задач в react state
import React, { PureComponent } from 'react';
import counter from 'common/counter';
import TaskList from '../common/TaskList';

export default class Step01 extends PureComponent {

  constructor() {
    super( ...arguments );
    this.state = { tasks: [
      { id: counter(), text: 'Sample task' },
    ] };

    this.handleAdd = newTaskText => {
      this.setState( state => ( {
        tasks: [ ...state.tasks, { id: counter(), text: newTaskText } ],
      } ) );
    };
    this.handleDelete = idToDelete => this.setState( state => ( {
      tasks: state.tasks.filter( ( { id } ) => id !== idToDelete ),
    } ) );
  }

  render() {
    return <>
      <h1>Вариант с хранением списка задач в компоненте</h1>
      <h2>Обработка списка задач и отображение в разных компонентах</h2>
      <TaskList
        onAdd={this.handleAdd}
        onDelete={this.handleDelete}
        tasks={this.state.tasks} />
    </>;
  }

}
(исходный код на github)

Реализация компонента, отображающего список задач и содержащего форму добавления новой
import React, { PureComponent } from 'react';
import Button from './AutoDisableButtonWithSpinner';
import Form from 'react-bootstrap/Form';
import Table from 'react-bootstrap/Table';

export default class TaskList extends PureComponent {

  constructor() {
    super( ...arguments );

    this.state = {
      newTaskAdding: false,
      newTaskText: '',
    };

    this.handleAdd = async() => {
      this.setState( { newTaskAdding: true } );
      try {
        // сначала ждём добавления, только потом очищаем поле
        await this.props.onAdd( this.state.newTaskText );
        this.setState( { newTaskText: '' } );
      } finally {
        this.setState( { newTaskAdding: false } );
      }
    };

    this.handleDeleteF = idToDelete =>
      async() => await this.props.onDelete( idToDelete );

    this.handleNewTaskTextChange = ( { target: { value } } ) => this.setState( {
      newTaskText: value || '',
    } );
  }

  render() {
    return <Table bordered hover striped>
      <thead><tr>
        <th>#</th><th>Text</th><th />
      </tr></thead>
      <tbody>
        { this.props.tasks.map( task => <tr key={task.id}>
          <td>{task.id}</td>
          <td>{task.text}</td>
          <td><Button
            onClick={this.handleDeleteF( task.id )}
            type="button"
            variant="danger">Удалить</Button></td>
        </tr> ) }
        <tr key="+1">
          <td />
          <td><Form.Control
            disabled={this.state.newTaskAdding}
            onChange={this.handleNewTaskTextChange}
            placeholder="Текст новой задачи"
            type="text"
            value={this.state.newTaskText || ''} /></td>
          <td><Button
            onClick={this.handleAdd}
            type="button"
            variant="primary">Добавить</Button></td>
        </tr>
      </tbody>
    </Table>;
  }

}
(исходный код на github)

Уже сейчас в коде примеров можно увидеть ключевые слова async / await. Конструкции async / await позволяют значительно сократить количество кода, работающего с Promise'ами. Ключевое слово await позволяет ждать ответа функции, возвращающей Promise, как будто это обычная функция (вместо ожидания результата в then()). Разумеется, асинхронная функция не превращается магическим образом в синхронную, и, например, поток исполнения будет прерван в момент использования await. Но зато код становится более кратким и понятным, а использовать await можно и в циклах, и в конструкциях try/catch/finally.

Например, компонент TaskList вызывает не просто обработчик this.props.onAdd, а делает это с использованием ключевого слова await. В этом случае если обработчик это обычная функция, которая ничего не вернёт, или вернёт любое значение, отличное от Promise, то компонент TaskList просто продолжит работу метода handleAdd в обычном порядке. А вот если обработчик вернёт Promise (в том числе если обработчик объявлен как async-функция), то TaskList будет ждать окончания выполнения обработчика, и только после этого сбросит значения переменных newTaskAdding и newTaskText.

Шаг 1: добавляем IndexDB в компонент React


Чтобы упростить себе работу, для начала напишем простой компонент, реализующий Promise-методы для:

  • открытия базы данных вместе с тривиальной обработкой ошибок
  • поиск элементов в базе данных
  • добавление элементов в базу данных

Первое самое «нетривиальное» — целых 5 обработчиков событий. Впрочем, никакого rocket science:

openDatabasePromise() -- открытие базы данных
function openDatabasePromise( keyPath ) {
  return new Promise( ( resolve, reject ) => {
    const dbOpenRequest = window.indexedDB.open( DB_NAME, '1.0.0' );

    dbOpenRequest.onblocked = () => {
      reject( 'Требуется обновление структуры базы данных, хранимой в вашем браузере, ' +
        'но браузер уведомил о блокировке базы данных.' );
    };

    dbOpenRequest.onerror = err => {
      console.log( 'Unable to open indexedDB ' + DB_NAME );
      console.log( err );
      reject( 'Невозможно открыть базу данных, либо при её открытии произошла неисправимая ошибка.' +
       ( err.message ? 'Техническая информация: ' + err.message : '' ) );
    };

    dbOpenRequest.onupgradeneeded = event => {
      const db = event.target.result;
      try {
        db.deleteObjectStore( OBJECT_STORE_NAME );
      } catch ( err ) { console.log( err ); }
      db.createObjectStore( OBJECT_STORE_NAME, { keyPath } );
    };

    dbOpenRequest.onsuccess = () => {
      console.info( 'Successfully open indexedDB connection to ' + DB_NAME );
      resolve( dbOpenRequest.result );
    };

    dbOpenRequest.onerror = reject;
  } );
}

getAllPromise / getPromise / putPromise -- обёртка вызовов IndexDb в Promise
// Оборачиваем функции от ObjectStore, поддерживающие интерфейс IDBRequest
// в вызов с использованием Promise
function wrap( methodName ) {
  return function() {
    const [ objectStore, ...etc ] = arguments;
    return new Promise( ( resolve, reject ) => {
      const request = objectStore[ methodName ]( ...etc );
      request.onsuccess = () => resolve( request.result );
      request.onerror = reject;
    } );
  };
}
const deletePromise = wrap( 'delete' );
const getAllPromise = wrap( 'getAll' );
const getPromise = wrap( 'get' );
const putPromise = wrap( 'put' );
}

Собираем всё вместе в один класс IndexedDbRepository

IndexedDbRepository -- обёртка вокруг IDBDatabase
const DB_NAME = 'objectStore';
const OBJECT_STORE_NAME = 'objectStore';
/* ... */
export default class IndexedDbRepository {
  /* ... */
  constructor( keyPath ) {
    this.error = null;
    this.keyPath = keyPath;

    // конструктор нельзя объявить как async
    // поэтому вынесено в отдельную функцию
    this.openDatabasePromise = this._openDatabase();
  }

  async _openDatabase( keyPath ) {
    try {
      this.dbConnection = await openDatabasePromise( keyPath );
    } catch ( error ) {
      this.error = error;
      throw error;
    }
  }

  async _tx( txMode, callback ) {
    await this.openDatabasePromise; // await db connection
    const transaction = this.dbConnection.transaction( [ OBJECT_STORE_NAME ], txMode );
    const objectStore = transaction.objectStore( OBJECT_STORE_NAME );
    return await callback( objectStore );
  }

  async findAll() {
    return this._tx( 'readonly', objectStore => getAllPromise( objectStore ) );
  }

  async findById( key ) {
    return this._tx( 'readonly', objectStore => getPromise( objectStore, key ) );
  }

  async deleteById( key ) {
    return this._tx( 'readwrite', objectStore => deletePromise( objectStore, key ) );
  }

  async save( item ) {
    return this._tx( 'readwrite', objectStore => putPromise( objectStore, item ) );
  }
}
(исходный код на github)

Теперь обращаться к IndexDB из кода можно будет просто:

    const db = new IndexedDbRepository( 'id' ); // поле, используемое как ключ объекта
    await db.save( { id: 42, text: 'Task text' } );
    const item = await db.findById( 42 );
    const items = await db.findAll();

Подключим данный «репозиторий» в наш компонент. По правилам react'а обращение к серверу должно быть в методе componentDidMount():

import IndexedDbRepository from '../common/IndexedDbRepository';
/*...*/
  componentDidMount() {
    this.repository = new IndexedDbRepository( 'id' );
    // Заполняем состояние компонента задачами
    this.repository.findAll().then( tasks => this.setState( { tasks } ) );
  }

Теоретически функцию componentDidMount() можно объявить как async, тогда вместо then() можно использовать конструкции async / await. Но всё-таки componentDidMount() это не «наша» функция, а вызываемая React'ом. Кто знает, как будет вести себя библиотека react 17.x в ответ на попытку возврата Promise вместо undefined?

Теперь в конструкторе вместо заполнения пустым массивом (или массивом с тестовыми данными) будем заполнять null'ом. А в render'е будет обрабатывать этот null как необходимость ожидания обработки данных. Желающие, в принципе, могут вынести это в отдельные флаги, но к чему плодить сущности?

  constructor() {
    super( ...arguments );
    this.state = { tasks: null };
    /* ... */
  }
  /* ... */
  render() {
    if ( this.state.tasks === null )
      return <><Spinner animation="border" aria-hidden="true" as="span" role="status" /><span> Идёт загрузка...</span></>;
    /* ... */
  }

Осталось реализовать обработчики handleAdd / handleDelete:

  constructor() {
    /* ... */
    this.handleAdd = async( newTaskText ) => {
      await this.repository.save( { id: counter(), text: newTaskText } );
      this.setState( { tasks: null } );
      this.setState( { tasks: await this.repository.findAll() } );
    };
    this.handleDelete = async( idToDelete ) => {
      await this.repository.deleteById( idToDelete );
      this.setState( { tasks: null } );
      this.setState( { tasks: await this.repository.findAll() } );
    };
  }

В обоих обработчиках сначала обращаемся к репозиторию для добавления или удаления элемента, а потом очищаем состояние текущего компонента и заново запрашиваем из репозитория новый список. Вроде кажется, что вызовы setState() пойдут один за другим. Но ключевое слово await в последних строках обработчиков приведёт к тому, что второй вызов setState() будет происходить только после того, как Promise(), полученный из метода findAll(), будет resolved.

Шаг 2. Слушаем изменения


Большой недостаток кода выше, что, во-первых, репозиторий подключается в каждом компоненте. Во-вторых, если один компонент поменяет содержимое репозитория, то другой компонент об этом не узнает до тех пор, пока не перечитает состояние в результате каких-либо действий пользователя. Это неудобно.

Чтобы с этим бороться, введём новый компонент RepositoryListener, и позволим ему сделать две вещи. Этот компонент, во-первых, будет уметь подписываться на изменения в репозитории. Во-вторых RepositoryListener будет уведомлять об этих изменениях создавший его компонент.

Первым делом добавив возможность регистрировать обработчики в IndexedDbRepository:

export default class IndexedDbRepository {
  /*...*/
  constructor( keyPath ) {
  /*...*/
    this.listeners = new Set();
    this.stamp = 0;
  /*...*/
  }
  /*...*/
  addListener( listener ) {
    this.listeners.add( listener );
  }
  onChange() {
    this.stamp++;
    this.listeners.forEach( listener => listener( this.stamp ) );
  }
  removeListener( listener ) {
    this.listeners.delete( listener );
  }
}

(исходный код на github)

Будем передавать в обработчики некий stamp, который будет изменяться с каждым вызовом onChange(). И модифицируем метод _tx, чтобы для каждого вызова в рамках транзакции с режимом readwrite вызывался метод onChange():

  async _tx( txMode, callback ) {
    await this.openDatabasePromise; // await db connection
    try {
      const transaction = this.dbConnection.transaction( [ OBJECT_STORE_NAME ], txMode );
      const objectStore = transaction.objectStore( OBJECT_STORE_NAME );
      return await callback( objectStore );
    } finally {
      if ( txMode === 'readwrite' )
        this.onChange(); // notify listeners
    }
  }

(исходный код на github)

Если бы мы всё ещё использовали then() / catch() для работы с Promise, то пришлось бы либо дублировать обращение к onChange(), либо использовать специальные polyfill'ы для Promise(), поддерживающие final(). К счастью, async / await позволяют это сделать просто и без излишнего кода.

Сам компонент RepositoryListener подключает слушателя событий в методах componentDidMount и componentWillUnmount:

Код RepositoryListener
import IndexedDbRepository from './IndexedDbRepository';
import { PureComponent } from 'react';

export default class RepositoryListener extends PureComponent {

  constructor() {
    super( ...arguments );

    this.prevRepository = null;
    this.repositoryListener = repositoryStamp => this.props.onChange( repositoryStamp );
  }

  componentDidMount() {
    this.subscribe();
  }

  componentDidUpdate() {
    this.subscribe();
  }

  componentWillUnmount() {
    this.unsubscribe();
  }

  subscribe() {
    const { repository } = this.props;
    if ( repository instanceof IndexedDbRepository && this.prevRepository !== repository ) {
      if ( this.prevRepository !== null ) {
        this.prevRepository.removeListener( this.repositoryListener );
      }
      this.prevRepository = repository;
      repository.addListener( this.repositoryListener );
    }
  }

  unsubscribe( ) {
    if ( this.prevRepository !== null ) {
      this.prevRepository.removeListener( this.repositoryListener );
      this.prevRepository = null;
    }
  }

  render() {
    return this.props.children || null;
  }
}
(исходный код на github)

Теперь включим обработку изменений репозитория в наш главный компонент, причём, руководствуясь принципом DRY, удалим соответствующий код из обработчиком handleAdd / handleDelete:

  constructor() {
    super( ...arguments );
    this.state = { tasks: null };

    this.handleAdd = async( newTaskText ) => {
      await this.repository.save( { id: counter(), text: newTaskText } );
    };
    this.handleDelete = async( idToDelete ) => {
      await this.repository.deleteById( idToDelete );
    };
    this.handleRepositoryChanged = async() => {
      this.setState( { tasks: null } );
      this.setState( { tasks: await this.repository.findAll() } );
    };
  }

  componentDidMount() {
    this.repository = new IndexedDbRepository( 'id' );
    this.handleRepositoryChanged(); // initial load
  }

(исходный код на github)

И добавляем вызов к handleRepositoryChanged из подключённого RepositoryListener'а:

  render() {
    /* ... */
    return <RepositoryListener onChange={this.handleRepoChanged} repository={this.repository}>
      <TaskList
        onAdd={this.handleAdd}
        onDelete={this.handleDelete}
        tasks={this.state.tasks} />
    </RepositoryListener>;
  }

(исходный код на github)

Шаг 3. Вынесем загрузку данных и их обновление в отдельный компонент


Мы написали компонент, который умеет получать данные из репозитория, умеет менять данные в репозитории. Но если представить себе большой проект на 100+ компонентов, то получится, что каждый компонент, который показывает данные из репозитория, будет вынужден:

  • Озаботиться правильным подключением репозитория из какой-то единой точки
  • Обеспечить начальную загрузку данных в методе componentDidMount()
  • Подключать компонент RepositoryListener, обеспечивающий вызов обработчика для перезагрузки изменений

Не слишком ли много дублирующихся действий? Вроде не очень. А если что-то забудется? Потеряется при копипасте?

Было бы замечательно, как-то сделать так, чтобы мы один раз написали правило получения списка задач из репозитория, а что-то магическое выполняло эти методы, отдавало нам данные, обрабатывало бы изменения в репозитории, а для кучи может ещё и само подключать этот репозиторий.

this.doFindAllTasks = ( repo ) => repo.findAll();
/*...*/
<DataProvider
  doCalc={ this.doFindAllTasks }>
{(data) => <span>...тут можно вывести что-то, зависящее от data...</span>}
</DataProvider>

Единственный нетривиальный момент в реализации данного компонента в том, что doFindAllTasks() это Promise. Чтобы облегчить себе работу, сделаем отдельный компонент, который ждёт выполнения Promise'а, и вызывает потомка с вычисленным значением:

Код PromiseComponent
import { PureComponent } from 'react';

export default class PromiseComponent extends PureComponent {

  constructor() {
    super( ...arguments );
    this.state = {
      error: null,
      value: null,
    };
    this.prevPromise = null;
  }

  componentDidMount() {
    this.subscribe();
  }

  componentDidUpdate( ) {
    this.subscribe();
  }

  componentWillUnmount() {
    this.unsubscribe();
  }

  subscribe() {
    const { cleanOnPromiseChange, promise } = this.props;
    if ( promise instanceof Promise && this.prevPromise !== promise ) {
      if ( cleanOnPromiseChange ) this.setState( { error: null, value: null } );
      this.prevPromise = promise;
      promise.then( value => {
        if ( this.prevPromise === promise ) {
          this.setState( { error: null, value } );
        }
      } )
        .catch( error => {
          if ( this.prevPromise === promise ) {
            this.setState( { error, value: null } );
          }
        } );
    }
  }

  unsubscribe( ) {
    if ( this.prevPromise !== null ) {
      this.prevPromise = null;
    }
  }

  render() {
    const { children, fallback } = this.props;
    const { error, value } = this.state;

    if ( error !== null ) {
      throw error;
    }
    if ( value === undefined || value === null ) {
      return fallback || null;
    }
    return children( value );

  }
}

(исходный код на github)

Данный компонент по своей логике и внутренней структуре очень похож на RepositoryListener. Потому что и тот, и другой должны «подписываться», «слушать» события и как-то их обрабатывать. А также должны учитывать, что то, чьи события нужно слушать, мог измениться.

Далее тот самый магический компонент DataProvider пока выглядит ну очень простым:

import repository from './RepositoryHolder';
/*...*/
export default class DataProvider extends PureComponent {

  constructor() {
    super( ...arguments );
    this.handleRepoChanged = () => this.forceUpdate();
  }

  render() {
    return <RepositoryListener onChange={this.handleRepoChanged} repository={repository}>
      <PromiseComponent promise={this.props.doCalc( repository )}>
        {data => this.props.children( data )}
      </PromiseComponent>
    </RepositoryListener>;
  }
}

(исходный код на github)

Действительно, взяли репозиторий (и отдельного singlenton'а RepositoryHolder, который теперь в import'ах), вызвали doCalc, это позволит передать данные о задачах в this.props.children, а значит, нарисует список задач. Singlenton же выглядит тоже просто:

const repository = new IndexedDbRepository( 'id' );
export default repository;

Теперь заменим обращение к базе данных из основного компонента на обращение к DataProvider:

import repository from './RepositoryHolder';
/* ... */
export default class Step3 extends PureComponent {

  constructor() {
    super( ...arguments );
    this.doFindAllTasks = repository => repository.findAll();
    /* ... */
  }

  render() {
    return <DataProvider
        doCalc={this.doFindAllTasks}
        fallback={<><Spinner animation="border" aria-hidden="true" as="span" role="status" /><span> Идёт загрузка...</span></>}>
        { tasks => <TaskList
          onAdd={this.handleAdd}
          onDelete={this.handleDelete}
          tasks={tasks} /> }
      </DataProvider>;
  }
}

(исходный код на github)

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

  • Для каждого запроса к данным нужно где-то в коде (но не в методе render()) описывать функцию обращения к репозиторию, а потом передавать эту функцию в DataProvider
  • Обращение к DataProvider это здорово и вполне в духе React'а, но очень некрасиво с точки зрения JSX. Если у вас несколько компонент, то два-три уровня вложенности разных DataProvider'ов запутают вас очень сильно.
  • Печально, что получение данных делается в одном компоненте (DataProvider), а их изменение — в другом (основной компонент). Хотелось бы это описывать одинаковым механизмом.

Шаг 4. connect()


Знакомые с react-redux по названию заголовка уже догадались. А остальным сделаю следующую подсказку: было бы неплохо, если бы вместо вызова children() с параметрами служебный компонент DataProvider заполнял бы свойства нашего компонента на основании правил. А если в репозитории что-то поменялось, то просто менял бы свойства стандартным механизмом React.

Для этого мы будем использовать Higher Order Component. На самом деле ничего сложного, просто это функция, которая принимает класс компонента в качестве параметра, и отдаёт другой, более сложный компонент. Поэтому, наша функция, которую мы напишем, будет:

  • Принимать в качестве аргумента класс компонента, куда передать параметры
  • Принимать набор правил, как из репозитория получить данные, и в какие свойства их положить
  • По своему использованию будет похожа на функцию connect() из react-redux.

Вызов этой функции будет выглядеть вот так:

const mapRepoToProps = repository => ( {
  tasks: repository.findAll(),
} );
const mapRepoToActions = repository => ( {
  doAdd: ( newTaskText ) => repository.save( { id: counter(), text: newTaskText } ),
  doDelete: ( idToDelete ) => repository.deleteById( idToDelete ),
} );
const Step4Connected = connect( mapRepoToProps, mapRepoToActions )( Step4 );

В первых строках задаётся маппинг между названием свойств компонента Step4 и значениями, которые будут загружаться из репозитория. Далее идёт маппинг уже для действий: что будет происходить, если изнутри компонента Step4 вызвать this.props.doAdd(...) или this.props.doDelete(...). А последняя строка собирает всё вместе и вызывает функцию connect. Результатом является новый компонент (именно поэтому такая техника называется Higher Order Component). И экспортировать из файла мы будем уже не оригинальный компонент Step4, а обёртку вокруг него:

/*...*/
class Step4 extends PureComponent {
/*...*/
}
/*...*/
const Step4Connected = connect( /*...*/ )( Step4 );
export default Step4Connected;

А сам компонент работы с TaskList теперь выглядит как простая обёртка:

class Step4 extends PureComponent {
  render() {
    return this.props.tasks === undefined || this.props.tasks === null
        ? <><Spinner animation="border" aria-hidden="true" as="span" role="status" /><span> Идёт загрузка...</span></>
        : <TaskList
          onAdd={this.props.doAdd}
          onDelete={this.props.doDelete}
          tasks={this.props.tasks} />;
  }
}

(исходный код на github)

И всё. Никаких конструкторов, никаких дополнительных обработчиков — всё помещается функцией connect() в props компонента.

Осталось посмотреть, как должна выглядеть функция connect().

Код connect()
import repository from './RepositoryHolder';
/* ... */

class Connected extends PureComponent {

  constructor() {
    super( ...arguments );
    this.handleRepoChanged = () => this.forceUpdate();
  }

  render() {
    const { childClass, childProps, mapRepoToProps, mapRepoToActions } = this.props;
    const promises = mapRepoToProps( repository, childProps );
    const actions = mapRepoToActions( repository, childProps );

    return <RepositoryListener onChange={this.handleRepoChanged} repository={repository}>
      <PromisesComponent promises={promises}>
        { values => React.createElement( childClass, {
          ...childProps,
          ...values,
          ...actions,
        } )}
      </PromisesComponent>
    </RepositoryListener>;
  }
}

export default function connect( mapRepoToProps, mapRepoToActions ) {
  return childClass => props => <Connected
    childClass={childClass}
    childProps={props}
    mapRepoToActions={mapRepoToActions}
    mapRepoToProps={mapRepoToProps} />;
}
(исходный код на github)

Код тоже кажется не очень сложным… хотя если начать погружаться, вопросы возникнут. Читать нужно с конца. Именно там определена та самая функция connect(). Она принимает два параметра, а потом возвращает функцию, которая возвращает… снова функцию? Не совсем. Последняя конструкция props => <Connected... возвращает не просто функцию, а функциональный компонент React. Таким образом, когда мы будем вставлять ConnectedStep4 в виртуальное дерево, оно будет в себя включать:

  • parent → безымянный функциональный компонент → Connect → RepositoryListener → PromisesComponent → Step4

Целых 4 промежуточных класса, но каждый выполняет свою функцию. Безымянный компонент берёт параметры, передающиеся в функцию connect(), класс вложенного компонента, свойства, которые передаются в сам компонент при вызове (props) и передаёт их уже в компонент Connect. Компонент Connect отвечает за то, чтобы из переданных параметров получить набор Promise (объект-словарь со ключами-строками и значениями-promise'ами). PromisesComponent обеспечивает вычисление значений, отдавая их обратно в компонент Connect, который вместе с оригинальными переданными свойствами (props), вычисленными свойствами (values) и свойствами-действиями (actions) передаёт их компоненту Step4 (через вызов React.createElement(...)). Ну а компонент RepositoryListener обновляет компонент, заставляя заново вычислять promis'ы, если что-то поменялось в репозитории.

В результате, если любой из компонент захочет использовать репозиторий от IndexDb, ему будет достаточно подключить одну функцию connect(), определить маппинг между свойствами и функциями получения свойств из репозитория, и использовать их без дополнительной головной боли.

Шаг 5. @vlsergey/react-indexdb-repo


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



Вместо заключения: что осталось за бортом


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

  • Не всегда изменение свойств должно приводить к получению новых promise'ов. Здесь нужно использовать функцию мемоизации, но учитывать флаг изменения базы данных. Вот тут как раз пригодится stamp из IndexedDbRepository (возможно, кому-то этот кусок кода показался избыточным).
  • Подключать репозиторий через импорт, пусть даже и в одном месте — неправильно. Нужно смотреть в сторону использования контекстов.
  • Текущая реализация никак не учитывает, что одна база данных IndexedDB может быть открыта на нескольких вкладках.
  • В данной статье никак не показано самое важное с точки зрения функционала: синхронизация данного репозитория и сервера. Корректная синхронизация требует отдельной статьи. Ведь часто IndexDB это не более чем кеш «настоящих» данных с сервера, а инвалидация кеша — одна из двух самых главных проблем программирования.
  • Также помните, что IndexDB не является заменой Redux Storage. Для очень редких случаев использование IndexDB даёт очень хорошие преимущества, но она не является серебряной пулей или заменой на все случаи жизни.

Исходный код всех шагов

Online-версия

Комментарии (0)