
Neste artigo, mostrarei passo a passo como preparar o IndexDB (um banco de dados embutido em qualquer navegador moderno) para uso em projetos escritos no ReactJS. Como resultado, você pode usar os dados do IndexDB tão convenientemente como se estivessem no
Redux Store do seu aplicativo.
O IndexDB é um DBMS
orientado a documentos, uma ferramenta conveniente para armazenamento temporário de uma quantidade relativamente pequena (unidades e dezenas de megabytes) de dados estruturados no lado do navegador. A tarefa padrão para a qual eu tenho que usar o IndexDB é armazenar em cache os dados do diretório de negócios do lado do cliente (nomes de países, cidades, moedas por código etc.) Depois de copiá-las para o lado do cliente, você só pode ocasionalmente baixar atualizações desses diretórios do servidor (ou o todo - elas são pequenas) e não fazer isso sempre que abrir a janela do navegador.
Existem maneiras não padronizadas, muito controversas, mas úteis de usar o IndexDB:
- armazenar em cache dados sobre todos os objetos de negócios para que, no lado do navegador, use os extensos recursos de classificação e filtragem
- armazenando o status do aplicativo no IndexDB em vez do Redux Store
Três diferenças importantes entre o IndexDB e o Redux Store são importantes para nós:
- O IndexDB é um armazenamento externo que não é limpo ao sair da página. Além disso, é o mesmo para várias guias abertas (que às vezes levam a um comportamento inesperado)
- O IndexDB é um DBMS completamente assíncrono. Todas as operações - abertura, leitura, escrita, pesquisa - assíncronas.
- O IndexDB não pode (de maneira trivial) ser armazenado no JSON e usar técnicas de truque cerebral do Redux para criar instantâneos, facilidade de depuração e uma jornada ao passado.
Etapa 0: lista de tarefas
Já é um exemplo clássico com uma lista de tarefas. Variante com armazenamento de estado no estado do componente atual e único
Implementação de um componente da lista de tarefas que armazena a lista em um estado do 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 fonte do github )
Até o momento, todas as operações com tarefas são síncronas. Se a adição de uma tarefa demorar 3 segundos, o navegador congelará por 3 segundos. Obviamente, enquanto mantemos tudo em nossa memória, não conseguimos pensar nisso. Quando incluímos o processamento em um servidor ou em um banco de dados local, teremos que cuidar também do belo processamento da assincronia. Por exemplo, bloquear o trabalho com uma tabela (ou elementos individuais) ao adicionar ou remover elementos.
Para não repetir a descrição da interface do usuário no futuro, colocaremos em um componente TaskList separado, cuja única tarefa gerará o código HTML da lista de tarefas. Ao mesmo tempo, substituiremos os botões usuais por um invólucro especial ao redor do botão de inicialização, que bloqueará o botão até que o manipulador de botões conclua sua execução, mesmo que esse manipulador seja uma função assíncrona.
Implementando um componente que armazena uma lista de tarefas no estado de reação 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 fonte do github )
Implementação de um componente que exibe uma lista de tarefas e contém um formulário para adicionar um novo 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 fonte do github )
Já no código de amostra, você pode ver as palavras-chave assíncronas / aguardadas. Construções assíncronas / aguardam podem reduzir significativamente a quantidade de código que funciona com o Promises. A palavra-chave aguardar permite que você aguarde uma resposta de uma função retornando Promise, como se fosse uma função regular (em vez de aguardar um resultado em then ()). Obviamente, uma função assíncrona não se transforma magicamente em uma síncrona e, por exemplo, o encadeamento de execução será interrompido quando a espera for usada. Mas, então, o código se torna mais conciso e compreensível, e aguardar pode ser usado tanto em loops quanto em construções try / catch / finalmente.
Por exemplo,
TaskList
chama não apenas o manipulador
this.props.onAdd
, mas usa a palavra-chave
this.props.onAdd
. Nesse caso, se o manipulador for uma função normal que não retornará nada ou retornará qualquer valor que não seja
Promise
, o componente
TaskList
simplesmente continuará o método
handleAdd
da maneira usual. Mas se o manipulador retornar
Promise
(incluindo se o manipulador for declarado como uma função assíncrona), o
TaskList
aguardará o término da execução do manipulador e só então redefinirá os valores das
newTaskText
e
newTaskText
.
Etapa 1: adicionar o IndexDB ao componente React
Para simplificar nosso trabalho, primeiro escreveremos um componente simples que implementa métodos Promise para:
- abrir um banco de dados junto com manipulação trivial de erros
- procure itens no banco de dados
- adicionando itens ao banco de dados
O primeiro é o mais "não trivial" - até 5 manipuladores de eventos. No entanto, nenhuma ciência de foguetes:
openDatabasePromise () - abre um banco de dados 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 - o wrapper IndexDb chama no Promise Juntando tudo em uma classe IndexedDbRepository
IndexedDbRepository - wrapper em torno do IDBDatabase const DB_NAME = 'objectStore'; const OBJECT_STORE_NAME = 'objectStore'; export default class IndexedDbRepository { constructor( keyPath ) { this.error = null; this.keyPath = keyPath;
(
código fonte do github )
Agora você pode acessar o IndexDB a partir do código:
const db = new IndexedDbRepository( 'id' );
Conecte este "repositório" ao nosso componente. De acordo com as regras de reação, uma chamada para o servidor deve estar no método componentDidMount ():
import IndexedDbRepository from '../common/IndexedDbRepository'; componentDidMount() { this.repository = new IndexedDbRepository( 'id' );
Teoricamente, a função
componentDidMount()
pode ser declarada como assíncrona, e as construções async / waitit podem ser usadas em vez de then (). Mas ainda
componentDidMount()
não é a função “nossa”, mas chamada por React. Quem sabe como a biblioteca react 17.x se comportará em resposta a uma tentativa de retornar o
Promise
vez de
undefined
?
Agora, no construtor, em vez de preenchê-lo com uma matriz vazia (ou uma matriz com dados de teste), a preencheremos com nulo. E na renderização, ele processará esse nulo conforme a necessidade de aguardar o processamento de dados. Quem deseja, em princípio, pode colocar isso em bandeiras separadas, mas por que produzir 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></>; /* ... */ }
Resta implementar os
handleDelete
/
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() } ); }; }
Nos dois manipuladores, primeiro recorremos ao repositório para adicionar ou remover um item, depois limpamos o estado do componente atual e solicitamos novamente uma nova lista do repositório. Parece que as chamadas para setState () irão uma após a outra. Mas a palavra-chave wait nas últimas linhas dos manipuladores fará com que a segunda chamada setState () ocorra somente após a resolução da Promise () obtida do método findAll ().
Etapa 2. Ouça as alterações
Uma grande falha no código acima é que, primeiramente, o repositório está conectado em cada componente. Em segundo lugar, se um componente altera o conteúdo do repositório, o outro componente não o conhece até reler o estado como resultado de qualquer ação do usuário. Isso é inconveniente.
Para combater isso, apresentaremos o novo componente RepositoryListener e deixaremos fazer duas coisas. Este componente, em primeiro lugar, poderá se inscrever nas alterações no repositório. Em segundo lugar, o RepositoryListener notificará o componente que o criou dessas alterações.
Primeiro de tudo, adicionando a capacidade de registrar manipuladores no 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 fonte do github )
Passaremos um carimbo para os manipuladores, que serão alterados a cada chamada para onChange (). E modificamos o método _tx para que
onChange()
chamado para cada chamada em uma transação com o modo
readwrite
:
async _tx( txMode, callback ) { await this.openDatabasePromise;
(
código fonte do github )
Se ainda assim usamos
then()
/
catch()
para trabalhar com o Promise, teríamos que duplicar a chamada para
onChange()
ou usar polyfills especiais para Promise () que suportam
final()
. Felizmente, o assíncrono / espera permite que você faça isso de forma simples e sem código desnecessário.
O próprio componente RepositoryListener conecta um ouvinte de evento nos métodos componentDidMount e componentWillUnmount:
Código 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; } }
(
código fonte do github )
Agora, incluiremos o processamento de alterações do repositório em nosso componente principal e, guiados
pelo princípio DRY , removeremos o código correspondente do
handleDelete
/
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();
(
código fonte do github )
E adicionamos uma chamada para handleRepositoryChanged a partir do 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 fonte do github )
Etapa 3. Retire o carregamento dos dados e sua atualização em um componente separado
Escrevemos um componente que pode receber dados do repositório, pode alterar dados no repositório. Mas se você imaginar um projeto grande com mais de 100 componentes, verifica-se que
cada componente que exibe dados do repositório será forçado a:
- Verifique se o repositório está conectado corretamente a partir de um único ponto
- Forneça o carregamento inicial de dados no método
componentDidMount()
- Conecte o componente
RepositoryListener
, que fornece uma chamada de manipulador para recarregar as alterações
Existem muitas ações duplicadas? Parece que não. E se algo for esquecido? Se perder com copiar e colar?
Seria ótimo, de alguma forma, escrever uma vez a regra para obter a lista de tarefas do repositório, e algo mágico executar esses métodos, nos fornecer dados, processar alterações no repositório e, para o monte, ele também poderá conectar isso. repositório.
this.doFindAllTasks = ( repo ) => repo.findAll(); <DataProvider doCalc={ this.doFindAllTasks }> {(data) => <span>... -, data...</span>} </DataProvider>
O único momento não trivial na implementação desse componente é que doFindAllTasks () é Promise. Para facilitar nosso trabalho, criaremos um componente separado que aguarda a execução do Promise e chama um descendente com um valor calculado:
PromiseComponent Code 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 fonte do github )
Esse componente em sua lógica e estrutura interna é muito semelhante ao RepositoryListener. Porque um e o outro devem "assinar", "ouvir" os eventos e, de alguma forma, processá-los. E também lembre-se de que os eventos que você precisa ouvir podem mudar.
Além disso, o componente mágico DataProvider até agora parece muito simples:
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 fonte do github )
De fato, pegamos o repositório (e um repositório separado RepositoryHolder, que agora está sendo importado), chamado doCalc, isso permitirá que você transfira dados da tarefa para this.props.children e, portanto, desenhe uma lista de tarefas. Singlenton também parece simples:
const repository = new IndexedDbRepository( 'id' ); export default repository;
Agora substitua a chamada do banco de dados do componente principal pela chamada 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 fonte do github )
Isso pode parar. Acabou bem: descrevemos a regra para o recebimento de dados e um componente separado monitora o recebimento real desses dados, bem como a atualização. Havia literalmente algumas pequenas coisas:
- Para cada solicitação de dados, em algum lugar do código (mas não no método
render()
é necessário descrever a função de acessar o repositório e depois passar essa função para o DataProvider - A chamada para o DataProvider é ótima e bastante no espírito do React, mas muito feia do ponto de vista do JSX. Se você tiver vários componentes, dois ou três níveis de aninhamento de diferentes DataProviders o confundirão muito.
- É triste que o recebimento de dados seja feito em um componente (DataProvider) e suas alterações sejam feitas em outro (componente principal). Eu gostaria de descrevê-lo com o mesmo mecanismo.
Etapa 4. connect ()
Familiarizado com react-redux pelo título do título já adivinhou. Farei a seguinte dica para o resto: seria bom se, em vez de chamar children () com parâmetros, o componente de serviço DataProvider preenchesse as propriedades de nosso componente com base nas regras. E se algo mudou no repositório, simplesmente alteraria as propriedades com o mecanismo React padrão.
Para isso, usaremos o componente de ordem superior. Na verdade, não é nada complicado, é apenas uma função que usa uma classe de componente como parâmetro e fornece outro componente mais complexo. Portanto, nossa função que escrevemos será:
- Tome como argumento a classe de componente para onde passar os parâmetros
- Aceite um conjunto de regras, como obter dados do repositório e em quais propriedades colocá-lo
- Em seu uso, será semelhante à função connect () do react-redux .
A chamada para esta função terá a seguinte aparência:
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 );
As primeiras linhas
Step4
mapeamento entre o nome da propriedade do componente
Step4
e os valores que serão carregados no repositório. A seguir, vem o mapeamento das ações: o que acontecerá se
Step4
chamar
this.props.doAdd(...)
ou
this.props.doDelete(...)
no componente
Step4
. E a última linha reúne tudo e chama a função de conexão. O resultado é um novo componente (e é por isso que essa técnica é chamada de componente de ordem superior). E não exportaremos mais do arquivo o componente Step4 original, mas o wrapper em torno dele:
class Step4 extends PureComponent { } const Step4Connected = connect( )( Step4 ); export default Step4Connected;
E o componente de trabalhar com o TaskList agora parece um invólucro simples:
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 fonte do github )
E é isso. Sem construtores, sem manipuladores adicionais - tudo é colocado pela função connect () nos props do componente.
Resta ver como deve ser a função 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 fonte do github )
O código também parece não ser muito complicado ... embora se você começar a mergulhar, surgirão dúvidas. Você precisa ler a partir do final. É aí que a função
connect()
é definida. São necessários dois parâmetros e, em seguida, retorna uma função que retorna ... novamente uma função? Na verdade não. A última construção de
props => <Connected...
retorna não apenas uma função, mas um
componente React funcional .
Assim, quando incorporamos o ConnectedStep4 em uma árvore virtual, ele inclui:- pai -> componente funcional anônimo -> Conectar -> RepositoryListener -> PromisesComponent -> Etapa4
Até 4 classes intermediárias, mas cada uma desempenha sua função. Um componente não nomeado pega os parâmetros passados para a função connect()
, a classe do componente aninhado, propriedades que são transferidas para o próprio componente quando chamado (props) e os passa para o componente Connect
. O componente Connect
é responsável por obter um conjunto dos parâmetros passados Promise
(um objeto de dicionário com chaves de linha e valores prometidos). PromisesComponent
fornece o cálculo dos valores, transmitindo-os de volta ao componente Connect, que, juntamente com as propriedades transferidas originais (adereços), propriedades calculadas (valores) e propriedades-ações (ações), os transmite ao componente Step4
(por meio de uma chamada React.createElement(...)
). Bem, o componenteRepositoryListener
atualiza o componente, forçando a recalcular promis se algo mudou no repositório.Como resultado, se algum dos componentes quiser usar o repositório do IndexDb, será suficiente para ele conectar uma função connect()
, determinar o mapeamento entre as propriedades e as funções de obter propriedades do repositório e usá-las sem nenhuma dor de cabeça adicional.Finalmente, organizamos tudo isso na forma de uma biblioteca para que outros desenvolvedores possam usá-lo em seus projetos. O resultado desta etapa foi:Em vez de uma conclusão: o que resta ao mar
O código acima já é prático o suficiente para uso em soluções industriais. Mas ainda assim, não se esqueça das limitações:- Nem sempre a alteração das propriedades deve levar a novas promessas. Aqui você precisa usar a função de memorização, mas leve em consideração o sinalizador de alteração do banco de dados. É aqui que o carimbo do IndexedDbRepository é útil (talvez para alguns esse código parecesse redundante).
- Conectar o repositório através da importação, mesmo que em um local, esteja errado. É necessário olhar para o uso de contextos.
- , IndexedDB .
- : . . IndexDB «» , — .
- , IndexDB Redux Storage. IndexDB , .
Online-