
En este artículo, le diré paso a paso cómo preparar IndexDB (una base de datos integrada en cualquier navegador moderno) para usar en proyectos escritos en ReactJS. Como resultado, puede usar los datos de IndexDB tan convenientemente como si estuvieran en la
Tienda Redux de su aplicación.
IndexDB es un DBMS
orientado a documentos, una herramienta conveniente para el almacenamiento temporal de una cantidad relativamente pequeña (unidades y decenas de megabytes) de datos estructurados en el lado del navegador. La tarea estándar para la que tengo que usar IndexDB incluye el almacenamiento en caché de los directorios de empresas en el lado del cliente (nombres de países, ciudades, monedas por código, etc.). Después de copiarlos en el lado del cliente, solo puede descargar ocasionalmente actualizaciones de estos directorios desde el servidor (o en su conjunto, son pequeños) y no hacer esto cada vez que abra la ventana del navegador.
Hay formas no estándar, muy controvertidas, pero que funcionan de usar IndexDB:
- almacenamiento en caché de datos sobre todos los objetos comerciales para que, en el lado del navegador, utilice las amplias capacidades de clasificación y filtrado
- almacenar el estado de la aplicación en IndexDB en lugar de Redux Store
Tres diferencias clave entre IndexDB y Redux Store son importantes para nosotros:
- IndexDB es un almacenamiento externo que no se borra al salir de la página. Además, es lo mismo para múltiples pestañas abiertas (lo que a veces conduce a un comportamiento algo inesperado)
- IndexDB es un DBMS completamente asíncrono. Todas las operaciones: apertura, lectura, escritura, búsqueda, asíncronas.
- IndexDB no puede (de manera trivial) almacenarse en JSON y utilizar técnicas de engaño mental de Redux para crear instantáneas, facilidad de depuración y un viaje al pasado.
Paso 0: lista de tareas
Ya es un ejemplo clásico con una lista de tareas. Variante con almacenamiento de estado en el estado del componente actual y único
Implementación de un componente de lista de tareas que almacena la lista en un estado de componenteimport 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>; } }
(
código fuente de github )
Hasta ahora, todas las operaciones con tareas son sincrónicas. Si agregar una tarea toma 3 segundos, entonces el navegador se congelará por 3 segundos. Por supuesto, mientras mantenemos todo en nuestra memoria, no podemos pensar en ello. Cuando incluimos el procesamiento con un servidor o con una base de datos local, tendremos que ocuparnos también del hermoso procesamiento de la asincronía. Por ejemplo, bloquear el trabajo con una tabla (o elementos individuales) al agregar o eliminar elementos.
Para no repetir la descripción de la interfaz de usuario en el futuro, la colocaremos en un componente separado de la Lista de tareas, cuya única tarea generará el código HTML de la lista de tareas. Al mismo tiempo, reemplazaremos los botones habituales con un envoltorio especial alrededor del botón de arranque, que bloqueará el botón hasta que el controlador de botones complete su ejecución, incluso si este controlador es una función asincrónica.
Implementación de un componente que almacena una lista de tareas en estado de reacción 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} /> </>; } }
(
código fuente de github )
Implementación de un componente que muestra una lista de tareas y contiene un formulario para agregar uno nuevo 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 {
(
código fuente de github )
Ya en el código de muestra puede ver las palabras clave async / wait. Las construcciones asíncronas / en espera pueden reducir significativamente la cantidad de código que funciona con Promises. La palabra clave await le permite esperar una respuesta de una función que devuelve Promise, como si fuera una función normal (en lugar de esperar un resultado en then ()). Por supuesto, una función asincrónica no se convierte mágicamente en una síncrona, y, por ejemplo, el hilo de ejecución se interrumpirá cuando se use el modo de espera. Pero luego el código se vuelve más conciso y comprensible, y la espera puede usarse tanto en bucles como en construcciones try / catch / finally.
Por ejemplo,
TaskList
llama no solo al controlador
this.props.onAdd
, sino que lo hace usando la palabra clave
this.props.onAdd
. En este caso, si el controlador es una función normal que no devolverá nada, o devolverá cualquier valor que no sea
Promise
, entonces el componente
TaskList
simplemente continuará el método
handleAdd
de la manera habitual. Pero si el controlador devuelve
Promise
(incluso si el controlador se declara como una función asíncrona), la
TaskList
esperará a que el controlador termine de ejecutarse, y solo entonces restablecerá los valores de las
newTaskText
newTaskAdding
y
newTaskText
.
Paso 1: Agregue IndexDB al componente React
Para simplificar nuestro trabajo, primero escribiremos un componente simple que implemente métodos Promise para:
- abrir una base de datos junto con un manejo trivial de errores
- buscar elementos en la base de datos
- Agregar elementos a la base de datos
El primero es el más "no trivial": hasta 5 controladores de eventos. Sin embargo, no hay ciencia espacial:
openDatabasePromise () - abre una base de datos 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 - reinicia las llamadas IndexDb en Promise Poniendo todo junto en una clase IndexedDbRepository
IndexedDbRepository - contenedor alrededor de IDBDatabase const DB_NAME = 'objectStore'; const OBJECT_STORE_NAME = 'objectStore'; export default class IndexedDbRepository { constructor( keyPath ) { this.error = null; this.keyPath = keyPath;
(
código fuente de github )
Ahora puede acceder a IndexDB desde el código:
const db = new IndexedDbRepository( 'id' );
Conecte este "repositorio" a nuestro componente. De acuerdo con las reglas de reacción, una llamada al servidor debe estar en el método componentDidMount ():
import IndexedDbRepository from '../common/IndexedDbRepository'; componentDidMount() { this.repository = new IndexedDbRepository( 'id' );
Teóricamente, la función
componentDidMount()
se puede declarar como asíncrona, luego se pueden usar construcciones async / wait en lugar de then (). Pero aún así
componentDidMount()
no es "nuestra" función, sino que es llamada por React. ¿Quién sabe cómo se comportará la biblioteca react 17.x en respuesta a un intento de devolver
Promise
lugar de
undefined
?
Ahora en el constructor, en lugar de llenar con una matriz vacía (o una matriz con datos de prueba), la llenaremos con nulo. Y en render, procesará este nulo como la necesidad de esperar el procesamiento de datos. Los que desean, en principio, pueden poner esto en banderas separadas, pero ¿por qué producir entidades?
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></>; /* ... */ }
Queda por implementar los
handleAdd
/
handleAdd
:
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() } ); }; }
En ambos controladores, primero recurrimos al repositorio para agregar o eliminar un elemento, y luego borramos el estado del componente actual y nuevamente solicitamos una nueva lista del repositorio. Parece que las llamadas a setState () irán una tras otra. Pero la palabra clave en espera en las últimas líneas de los controladores hará que la segunda llamada setState () suceda solo después de que se resuelva el Promise () obtenido del método findAll ().
Paso 2. Escucha los cambios
Una gran falla en el código anterior es que, en primer lugar, el repositorio está conectado en cada componente. En segundo lugar, si un componente cambia el contenido del repositorio, el otro componente no lo sabe hasta que vuelve a leer el estado como resultado de cualquier acción del usuario. Esto es inconveniente.
Para combatir esto, presentaremos el nuevo componente RepositoryListener y dejaremos que haga dos cosas. Este componente, en primer lugar, podrá suscribirse a los cambios en el repositorio. En segundo lugar, RepositoryListener notificará al componente que lo creó sobre estos cambios.
En primer lugar, agregar la capacidad de registrar controladores en 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 ); } }
(
código fuente de github )
Le pasaremos un sello a los controladores, que cambiará con cada llamada a onChange (). Y modificamos el método _tx para que se
onChange()
para cada llamada en una transacción con modo
readwrite
:
async _tx( txMode, callback ) { await this.openDatabasePromise;
(
código fuente de github )
Si todavía usáramos
then()
/
catch()
para trabajar con Promise, tendríamos que duplicar la llamada a
onChange()
, o usar polyfills especiales para Promise () que admitan
final()
. Afortunadamente, async / await le permite hacer esto de manera simple y sin código innecesario.
El componente RepositoryListener en sí conecta un detector de eventos en los métodos componentDidMount y componentWillUnmount:
RepositoryListener Code 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; } }
(
código fuente de github )
Ahora, incluiremos el procesamiento de cambios de repositorio en nuestro componente principal y, guiados
por el principio DRY , eliminaremos el código correspondiente del
handleDelete
handleAdd
/
handleAdd
:
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();
(
código fuente de github )
Y agregamos una llamada a handleRepositoryChanged desde el RepositoryListener conectado:
render() { return <RepositoryListener onChange={this.handleRepoChanged} repository={this.repository}> <TaskList onAdd={this.handleAdd} onDelete={this.handleDelete} tasks={this.state.tasks} /> </RepositoryListener>; }
(
código fuente de github )
Paso 3. Saque la carga de datos y su actualización en un componente separado
Escribimos un componente que puede recibir datos del repositorio, puede cambiar los datos en el repositorio. Pero si imagina un proyecto grande con más de 100 componentes, resulta que
cada componente que muestra datos del repositorio se verá obligado a:
- Asegúrese de que el repositorio esté conectado correctamente desde un único punto
- Proporcionar carga inicial de datos en el método
componentDidMount()
- Conecte el componente
RepositoryListener
, que proporciona una llamada de controlador para recargar los cambios.
¿Hay demasiadas acciones duplicadas? Parece que no es así. ¿Y si algo se olvida? Perderse con copiar y pegar?
Sería genial asegurarse de alguna manera de que escribamos una vez que la regla de obtener la lista de tareas del repositorio, y algo mágico ejecute estos métodos, nos brinde datos, procese cambios en el repositorio, y para el montón también puede conectar esto repositorio.
this.doFindAllTasks = ( repo ) => repo.findAll(); <DataProvider doCalc={ this.doFindAllTasks }> {(data) => <span>... -, data...</span>} </DataProvider>
El único momento no trivial en la implementación de este componente es que doFindAllTasks () es Promise. Para facilitar nuestro trabajo, crearemos un componente separado que está esperando que Promise se ejecute y llame a un descendiente con un valor calculado:
Promesa Código de componente 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 ); } }
(
código fuente de github )
Este componente en su lógica y estructura interna es muy similar a RepositoryListener. Debido a que uno y el otro deben "firmar", "escuchar" los eventos y de alguna manera procesarlos. Y también tenga en cuenta que los eventos de los que necesita escuchar podrían cambiar.
Además, el componente muy mágico DataProvider hasta ahora parece muy simple:
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>; } }
(
código fuente de github )
De hecho, tomamos el repositorio (y el RentonoryHolder singlenton separado, que ahora está en importación), llamado doCalc, esto nos permitirá transferir datos de tareas a this.props.children y, por lo tanto, dibujar una lista de tareas. Singlenton también parece simple:
const repository = new IndexedDbRepository( 'id' ); export default repository;
Ahora reemplace la llamada de la base de datos del componente principal con la llamada 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>; } }
(
código fuente de github )
Esto podría parar. Resultó bastante bien: describimos la regla para recibir datos, y un componente separado monitorea la recepción real de estos datos, así como la actualización. Había literalmente un par de pequeñas cosas:
- Para cada solicitud de datos, en algún lugar del código (pero no en el método
render()
debe describir la función de acceso al repositorio y luego pasar esta función al DataProvider - La llamada al DataProvider es excelente y bastante en el espíritu de React, pero muy fea desde el punto de vista de JSX. Si tiene varios componentes, entonces dos o tres niveles de anidamiento de diferentes DataProviders lo confundirán mucho.
- Es triste que la recepción de datos se realice en un componente (DataProvider), y su cambio se realice en otro (componente principal). Me gustaría describirlo con el mismo mecanismo.
Paso 4. connect ()
Familiarizado con react-redux por el título del título ya adivinado. Haré la siguiente sugerencia al resto: sería bueno si, en lugar de llamar a children () con parámetros, el componente de servicio DataProvider completara las propiedades de nuestro componente según las reglas. Y si algo ha cambiado en el repositorio, simplemente cambiaría las propiedades con el mecanismo React estándar.
Para esto usaremos el componente de orden superior. En realidad, no es nada complicado, es solo una función que toma una clase de componente como parámetro y le da otro componente más complejo. Por lo tanto, nuestra función que escribamos será:
- Tome como argumento la clase de componente donde pasar los parámetros
- Acepte un conjunto de reglas, cómo obtener datos del repositorio y en qué propiedades colocar
- En su uso, será similar a la función connect () de react-redux .
La llamada a esta función se verá así:
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 );
Las primeras líneas
Step4
asignación entre el nombre de propiedad del componente
Step4
y los valores que se cargarán desde el repositorio. Luego viene el mapeo de acciones: qué sucederá si llama
this.props.doAdd(...)
o
this.props.doDelete(...)
desde el componente
Step4
. Y la última línea reúne todo y llama a la función de conexión. El resultado es un nuevo componente (por lo que esta técnica se llama Componente de orden superior). Y ya no exportaremos desde el archivo el componente Step4 original, sino el contenedor que lo rodea:
class Step4 extends PureComponent { } const Step4Connected = connect( )( Step4 ); export default Step4Connected;
Y el componente de trabajar con TaskList ahora parece un simple contenedor:
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} />; } }
(
código fuente de github )
Y eso es todo. Sin constructores, sin controladores adicionales: todo se coloca mediante la función connect () en los accesorios del componente.
Queda por ver cómo debería verse la función connect ().
Código 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} />; }
(
código fuente de github )
El código tampoco parece muy complicado ... aunque si comienzas a bucear, surgirán preguntas. Necesitas leer desde el final. Es allí donde se define la función
connect()
. Toma dos parámetros y luego devuelve una función que devuelve ... ¿otra vez una función? En realidad no La última construcción de
props => <Connected...
devuelve no solo una función, sino un
componente React funcional .
Por lo tanto, cuando incrustemos ConnectedStep4 en un árbol virtual, incluirá:- padre -> componente funcional anónimo -> Conectar -> RepositoryListener -> PromisesComponent -> Step4
Hasta 4 clases intermedias, pero cada una realiza su función. Un componente sin nombre toma los parámetros pasados a la función connect()
, la clase del componente anidado, las propiedades que se transfieren al componente en sí mismo cuando se llama (props) y las pasa ya al componente Connect
. El componente Connect
es responsable de obtener un conjunto de los parámetros pasados Promise
(un objeto de diccionario con claves de fila y valores de promesa). PromisesComponent
proporciona el cálculo de valores, pasándolos de nuevo al componente Connect, que, junto con las propiedades transferidas originales (accesorios), las propiedades calculadas (valores) y las propiedades-acciones (acciones), las pasa al componente Step4
(a través de una llamada React.createElement(...)
). Bueno, el componenteRepositoryListener
actualiza el componente, lo que obliga a recalcular de manera promisoria si algo ha cambiado en el repositorio.Como resultado, si alguno de los componentes quiere usar el repositorio de IndexDb, será suficiente para que él conecte una función connect()
, determine la asignación entre las propiedades y las funciones de obtener propiedades del repositorio y las use sin ningún dolor de cabeza adicional.Finalmente, organizamos todo esto en forma de biblioteca para que otros desarrolladores puedan usarlo en sus proyectos. El resultado de este paso fue:En lugar de una conclusión: lo que queda por la borda
El código anterior ya es lo suficientemente práctico como para usarlo en soluciones industriales. Pero aún así, no te olvides de las limitaciones:- No siempre cambiar las propiedades debería conducir a nuevas promesas. Aquí debe usar la función de memorización, pero tenga en cuenta el indicador de cambio de la base de datos. Aquí es donde el sello de IndexedDbRepository es útil (tal vez para algunos este código parece redundante).
- Conectar el repositorio a través de la importación, incluso si está en un lugar, está mal. Es necesario mirar hacia el uso de contextos.
- , IndexedDB .
- : . . IndexDB «» , — .
- , IndexDB Redux Storage. IndexDB , .
Online-