O gerenciamento de estado é uma das partes mais importantes de uma aplicação web. Desde o uso de variáveis globais para ganchos React até o uso de bibliotecas de terceiros como MobX, Redux ou XState, para citar apenas esses 3, é um dos tópicos que alimenta mais discussões, pois é importante dominá-lo para projetar um aplicação confiável e eficiente.
Hoje, proponho construir uma minibiblioteca de gerenciamento de estado em menos de 50 linhas de JavaScript com base no conceito de observáveis. Este certamente pode ser usado como está para pequenos projetos, mas além deste exercício educacional, ainda recomendo que você recorra a soluções mais padronizadas para seus projetos reais.
Ao iniciar um novo projeto de biblioteca, é importante definir como seria sua API desde o início para congelar seu conceito e orientar seu desenvolvimento antes mesmo de pensar nos detalhes técnicos de implementação. Para um projeto real, é até possível começar a escrever testes neste momento para validar a implementação da biblioteca conforme ela é escrita de acordo com uma abordagem TDD.
Aqui queremos exportar uma única classe que chamaremos de State que será instanciada com um objeto contendo o estado inicial e um único método de observação que nos permite assinar mudanças de estado com observadores. Esses observadores só devem ser executados se uma de suas dependências for alterada.
Para alterar o estado, queremos usar as propriedades da classe diretamente, em vez de usar um método como setState.
Como um trecho de código vale mais que mil palavras, esta é a aparência de nossa implementação final em uso:
const state = new State({ count: 0, text: '', }); state.observe(({ count }) => { console.log('Count changed', count); }); state.observe(({ text }) => { console.log('Text changed', text); }); state.count = 1; state.text = 'Hello, world!'; state.count = 1; // Output: // Count changed 1 // Text changed Hello, world! // Count changed 2
Vamos começar criando uma classe State que aceita um estado inicial em seu construtor e expõe um método observe que implementaremos posteriormente.
class State { constructor(initialState = {}) { this.state = initialState; this.observers = []; } observe(observer) { this.observers.push(observer); } }
Aqui escolhemos usar um objeto de estado intermediário interno que nos permitirá manter os valores do estado. Também armazenamos os observadores em um array interno de observadores que será útil quando concluirmos esta implementação.
Como essas 2 propriedades serão usadas apenas dentro desta classe, poderíamos declará-las como privadas com um pouco de açúcar sintático, prefixando-as com um # e adicionando uma declaração inicial na classe:
class State { #state = {}; #observers = []; constructor(initialState = {}) { this.#state = initialState; this.#observers = []; } observe(observer) { this.#observers.push(observer); } }
Em princípio esta seria uma boa prática, mas usaremos Proxies na próxima etapa e eles não são compatíveis com propriedades privadas. Sem entrar em detalhes e para facilitar essa implementação, usaremos propriedades públicas por enquanto.
Quando descrevemos as especificações deste projeto, queríamos acessar os valores de estado diretamente na instância da classe e não como uma entrada para seu objeto de estado interno.
Para isso, usaremos um objeto proxy que será retornado quando a classe for inicializada.
Como o próprio nome sugere, um Proxy permite criar um intermediário para um objeto interceptar certas operações, incluindo seus getters e setters. No nosso caso, criamos um Proxy expondo um primeiro getter que nos permite expor as entradas do objeto state como se pertencessem diretamente à instância State.
class State { constructor(initialState = {}) { this.state = initialState; this.observers = []; return new Proxy(this, { get: (target, prop) => { if (prop in target.state) { return target.state[prop]; } return target[prop]; }, }); } observe(observer) { this.observers.push(observer); } } const state = new State({ count: 0, text: '', }); console.log(state.count); // 0
Agora podemos definir um objeto de estado inicial ao instanciar State e então recuperar seus valores diretamente dessa instância. Agora vamos ver como manipular seus dados.
Adicionamos um getter, então o próximo passo lógico é adicionar um setter que nos permita manipular o objeto de estado.
Primeiro verificamos se a chave pertence a este objeto, depois verificamos se o valor realmente mudou para evitar atualizações desnecessárias e, finalmente, atualizamos o objeto com o novo valor.
class State { constructor(initialState = {}) { this.state = initialState; this.observers = []; return new Proxy(this, { get: (target, prop) => { if (prop in target.state) { return target.state[prop]; } return target[prop]; }, set: (target, prop, value) => { if (prop in target.state) { if (target.state[prop] !== value) { target.state[prop] = value; } } else { target[prop] = value; } }, }); } observe(observer) { this.observers.push(observer); } } const state = new State({ count: 0, text: '', }); console.log(state.count); // 0 state.count = 1; console.log(state.count); // 1
Agora concluímos a parte de leitura e gravação de dados. Podemos alterar o valor do estado e então recuperar essa alteração. Até agora nossa implementação não é muito útil, então vamos implementar observadores agora.
Já temos um array contendo as funções observadores declaradas em nossa instância, então tudo o que precisamos fazer é chamá-las uma por uma sempre que um valor for alterado.
class State { constructor(initialState = {}) { this.state = initialState; this.observers = []; return new Proxy(this, { get: (target, prop) => { if (prop in target.state) { return target.state[prop]; } return target[prop]; }, set: (target, prop, value) => { if (prop in target.state) { if (target.state[prop] !== value) { target.state[prop] = value; this.observers.forEach((observer) => { observer(this.state); }); } } else { target[prop] = value; } }, }); } observe(observer) { this.observers.push(observer); } } const state = new State({ count: 0, text: '', }); state.observe(({ count }) => { console.log('Count changed', count); }); state.observe(({ text }) => { console.log('Text changed', text); }); state.count = 1; state.text = 'Hello, world!'; // Output: // Count changed 1 // Text changed // Count changed 1 // Text changed Hello, world!
Ótimo, agora estamos reagindo às alterações de dados!
Pequeno problema, no entanto. Se você prestou atenção até agora, originalmente queríamos executar os observadores apenas se uma de suas dependências mudasse. No entanto, se executarmos este código, veremos que cada observador é executado sempre que uma parte do estado é alterada.
Mas então como podemos identificar as dependências dessas funções?
Mais uma vez, os Proxies vêm em nosso socorro. Para identificar as dependências de nossas funções de observação, podemos criar um proxy de nosso objeto de estado, executá-lo com ele como argumento e observar quais propriedades eles acessaram.
Simples, mas eficaz.
Ao chamar observadores, tudo o que precisamos fazer é verificar se eles têm dependência da propriedade atualizada e acioná-los somente se houver.
Aqui está a implementação final de nossa minibiblioteca com esta última parte adicionada. Você notará que o array observadores agora contém objetos que permitem manter as dependências de cada observador.
class State { constructor(initialState = {}) { this.state = initialState; this.observers = []; return new Proxy(this, { get: (target, prop) => { if (prop in target.state) { return target.state[prop]; } return target[prop]; }, set: (target, prop, value) => { if (prop in target.state) { if (target.state[prop] !== value) { target.state[prop] = value; this.observers.forEach(({ observer, dependencies }) => { if (dependencies.has(prop)) { observer(this.state); } }); } } else { target[prop] = value; } }, }); } observe(observer) { const dependencies = new Set(); const proxy = new Proxy(this.state, { get: (target, prop) => { dependencies.add(prop); return target[prop]; }, }); observer(proxy); this.observers.push({ observer, dependencies }); } } const state = new State({ count: 0, text: '', }); state.observe(({ count }) => { console.log('Count changed', count); }); state.observe(({ text }) => { console.log('Text changed', text); }); state.observe((state) => { console.log('Count or text changed', state.count, state.text); }); state.count = 1; state.text = 'Hello, world!'; state.count = 1; // Output: // Count changed 0 // Text changed // Count or text changed 0 // Count changed 1 // Count or text changed 1 // Text changed Hello, world! // Count or text changed 1 Hello, world! // Count changed 2 // Count or text changed 2 Hello, world!
E aí está, em 45 linhas de código implementamos uma minibiblioteca de gerenciamento de estado em JavaScript.
Se quiséssemos ir mais longe, poderíamos adicionar sugestões de tipo com JSDoc ou reescrever esta em TypeScript para obter sugestões sobre propriedades da instância de estado.
Também poderíamos adicionar um método unobserve que seria exposto em um objeto retornado por State.observe.
Também pode ser útil abstrair o comportamento do setter em um método setState que nos permite modificar várias propriedades de uma vez. Atualmente, temos que modificar cada propriedade do nosso estado, uma por uma, o que pode acionar vários observadores se alguns deles compartilharem dependências.
De qualquer forma, espero que você tenha gostado deste pequeno exercício tanto quanto eu e que ele tenha permitido que você se aprofundasse um pouco mais no conceito de Proxy em JavaScript.
Isenção de responsabilidade: Todos os recursos fornecidos são parcialmente provenientes da Internet. Se houver qualquer violação de seus direitos autorais ou outros direitos e interesses, explique os motivos detalhados e forneça prova de direitos autorais ou direitos e interesses e envie-a para o e-mail: [email protected]. Nós cuidaremos disso para você o mais rápido possível.
Copyright© 2022 湘ICP备2022001581号-3