Gerenciando o estado compartilhado no Vue 3
Publicados: 2022-03-10Estado pode ser difícil. Quando iniciamos um projeto Vue simples, pode ser simples manter nosso estado de trabalho em um componente específico:
setup() { let books: Work[] = reactive([]); onMounted(async () => { // Call the API const response = await bookService.getScienceBooks(); if (response.status === 200) { books.splice(0, books.length, ...response.data.works); } }); return { books }; },
Quando seu projeto é uma única página de exibição de dados (talvez para classificá-los ou filtrá-los), isso pode ser atraente. Mas neste caso, este componente obterá dados em cada solicitação. E se você quiser mantê-lo por perto? É aí que entra a gestão estatal. Como as conexões de rede geralmente são caras e ocasionalmente não confiáveis, seria melhor manter esse estado enquanto você navega por um aplicativo.
Outro problema é a comunicação entre os componentes. Embora você possa usar eventos e adereços para se comunicar com pais-filhos diretos, lidar com situações simples como tratamento de erros e sinalizadores de ocupado pode ser difícil quando cada uma de suas visualizações/páginas são independentes. Por exemplo, imagine que você tenha um controle de nível superior conectado para mostrar o erro e carregar a animação:
// App.vue <template> <div class="container mx-auto bg-gray-100 p-1"> <router-link to="/"><h1>Bookcase</h1></router-link> <div class="alert" v-if="error">{{ error }}</div> <div class="alert bg-gray-200 text-gray-900" v-if="isBusy"> Loading... </div> <router-view :key="$route.fullPath"></router-view> </div> </template>
Sem uma maneira eficaz de lidar com esse estado, pode sugerir um sistema de publicação/assinatura, mas, na verdade, o compartilhamento de dados é mais direto em muitos casos. Se quiser ter um estado compartilhado, como você faz isso? Vejamos algumas maneiras comuns de fazer isso.
Observação : você encontrará o código para esta seção na ramificação “principal” do projeto de exemplo no GitHub.
Estado compartilhado no Vue 3
Desde que mudei para o Vue 3, migrei completamente para usar a API de composição. Para o artigo, também estou usando o TypeScript, embora isso não seja necessário para os exemplos que estou mostrando. Embora você possa compartilhar o estado da maneira que quiser, vou mostrar várias técnicas que acho os padrões mais usados. Cada um tem seus prós e contras, então não tome nada do que eu falo aqui como dogma.
As técnicas incluem:
- Fábricas,
- Singletons compartilhados,
- Vuex 4,
- Vuex 5.
Nota : Vuex 5, no momento da redação deste artigo, está no estágio RFC (Request for Comments), então quero prepará-lo para onde o Vuex está indo, mas no momento não há uma versão funcional dessa opção.
Vamos cavar…
Fábricas
Nota : O código para esta seção está na ramificação “Factories” do projeto de exemplo no GitHub.
O padrão de fábrica trata apenas de criar uma instância do estado com o qual você se importa. Nesse padrão, você retorna uma função muito parecida com a função inicial na API de composição. Você criaria um escopo e construiria os componentes do que está procurando. Por exemplo:
export default function () { const books: Work[] = reactive([]); async function loadBooks(val: string) { const response = await bookService.getBooks(val, currentPage.value); if (response.status === 200) { books.splice(0, books.length, ...response.data.works); } } return { loadBooks, books }; }
Você pode pedir apenas as partes dos objetos criados na fábrica que você precisa assim:
// In Home.vue const { books, loadBooks } = BookFactory();
Se adicionarmos um sinalizador isBusy
para mostrar quando a solicitação de rede ocorrer, o código acima não será alterado, mas você poderá decidir onde exibirá o isBusy
:
export default function () { const books: Work[] = reactive([]); const isBusy = ref(false); async function loadBooks(val: string) { isBusy.value = true; const response = await bookService.getBooks(val, currentPage.value); if (response.status === 200) { books.splice(0, books.length, ...response.data.works); } } return { loadBooks, books, isBusy }; }
Em outra visão (vue?) você poderia simplesmente pedir o sinalizador isBusy sem precisar saber como o resto da fábrica funciona:
// App.vue export default defineComponent({ setup() { const { isBusy } = BookFactory(); return { isBusy } }, })
Mas você pode ter notado um problema; toda vez que chamamos a fábrica, estamos obtendo uma nova instância de todos os objetos. Há momentos em que você deseja que uma fábrica retorne novas instâncias, mas no nosso caso estamos falando de compartilhar o estado, então precisamos mover a criação para fora da fábrica:
const books: Work[] = reactive([]); const isBusy = ref(false); async function loadBooks(val: string) { isBusy.value = true; const response = await bookService.getBooks(val, currentPage.value); if (response.status === 200) { books.splice(0, books.length, ...response.data.works); } } export default function () { return { loadBooks, books, isBusy }; }
Agora a fábrica está nos dando uma instância compartilhada, ou um singleton, se você preferir. Embora esse padrão funcione, pode ser confuso retornar uma função que não cria uma nova instância todas as vezes.
Como os objetos subjacentes são marcados como const
, você não deve poder substituí-los (e quebrar a natureza singleton). Portanto, este código deve reclamar:
// In Home.vue const { books, loadBooks } = BookFactory(); books = []; // Error, books is defined as const
Portanto, pode ser importante garantir que o estado mutável possa ser atualizado (por exemplo, usando books.splice()
em vez de atribuir os livros).
Outra maneira de lidar com isso é usar instâncias compartilhadas.
Instâncias compartilhadas
O código para esta seção está na ramificação “SharedState” do projeto de exemplo no GitHub.
Se você estiver compartilhando o estado, também pode ser claro sobre o fato de que o estado é um singleton. Nesse caso, ele pode ser importado apenas como um objeto estático. Por exemplo, eu gosto de criar um objeto que pode ser importado como um objeto reativo:
export default reactive({ books: new Array<Work>(), isBusy: false, async loadBooks() { this.isBusy = true; const response = await bookService.getBooks(this.currentTopic, this.currentPage); if (response.status === 200) { this.books.splice(0, this.books.length, ...response.data.works); } this.isBusy = false; } });
Nesse caso, basta importar o objeto (que estou chamando de store neste exemplo):
// Home.vue import state from "@/state"; export default defineComponent({ setup() { // ... onMounted(async () => { if (state.books.length === 0) state.loadBooks(); }); return { state, bookTopics, }; }, });
Então fica fácil vincular-se ao estado:
<!-- Home.vue --> <div class="grid grid-cols-4"> <div v-for="book in state.books" :key="book.key" class="border bg-white border-grey-500 m-1 p-1" > <router-link :to="{ name: 'book', params: { id: book.key } }"> <BookInfo :book="book" /> </router-link> </div>
Assim como os outros padrões, você obtém o benefício de poder compartilhar esta instância entre visualizações:
// App.vue import state from "@/state"; export default defineComponent({ setup() { return { state }; }, })
Em seguida, isso pode se vincular ao que é o mesmo objeto (seja um pai do Home.vue
ou outra página no roteador):
<!-- App.vue --> <div class="container mx-auto bg-gray-100 p-1"> <router-link to="/"><h1>Bookcase</h1></router-link> <div class="alert bg-gray-200 text-gray-900" v-if="state.isBusy">Loading...</div> <router-view :key="$route.fullPath"></router-view> </div>
Se você usa o padrão de fábrica ou a instância compartilhada, ambos têm um problema comum: estado mutável. Você pode ter efeitos colaterais acidentais de associações ou estado de mudança de código quando não quiser. Em um exemplo trivial como estou usando aqui, não é complexo o suficiente para se preocupar. Mas à medida que você está construindo aplicativos cada vez maiores, você vai querer pensar sobre a mutação de estado com mais cuidado. É aí que o Vuex pode vir em socorro.
Vuex 4
O código para esta seção está na ramificação “Vuex4” do projeto de exemplo no GitHub.
Vuex é o gerenciador de estado do Vue. Ele foi construído pela equipe principal, embora seja gerenciado como um projeto separado. O objetivo do Vuex é separar o estado das ações que você deseja fazer no estado. Todas as mudanças de estado precisam passar pelo Vuex, o que significa que é mais complexo, mas você obtém proteção contra mudanças de estado acidentais.
A ideia do Vuex é fornecer um fluxo previsível de gerenciamento de estado. As visualizações fluem para as ações que, por sua vez, usam Mutações para alterar o estado que, por sua vez, atualiza a visualização. Ao limitar o fluxo de mudança de estado, você deve ter menos efeitos colaterais que alteram o estado de seus aplicativos; portanto, ser mais fácil construir aplicativos maiores. O Vuex tem uma curva de aprendizado, mas com essa complexidade você obtém previsibilidade.
Além disso, o Vuex suporta ferramentas de tempo de desenvolvimento (através das Ferramentas Vue) para trabalhar com o gerenciamento de estado, incluindo um recurso chamado viagem no tempo. Isso permite que você visualize um histórico do estado e volte e avance para ver como isso afeta o aplicativo.
Há momentos também em que o Vuex também é importante.
Para adicioná-lo ao seu projeto Vue 3, você pode adicionar o pacote ao projeto:
> npm i vuex
Ou, alternativamente, você pode adicioná-lo usando o Vue CLI:
> vue add vuex
Ao usar a CLI, ela criará um ponto de partida para sua loja Vuex, caso contrário, você precisará conectá-la manualmente ao projeto. Vamos ver como isso funciona.
Primeiro, você precisará de um objeto de estado criado com a função createStore do Vuex:
import { createStore } from 'vuex' export default createStore({ state: {}, mutations: {}, actions: {}, getters: {} });
Como você pode ver, a loja requer que várias propriedades sejam definidas. O estado é apenas uma lista dos dados aos quais você deseja dar acesso ao seu aplicativo:
import { createStore } from 'vuex' export default createStore({ state: { books: [], isBusy: false }, mutations: {}, actions: {} });
Observe que o estado não deve usar wrappers de referência ou reativos . Esses dados são o mesmo tipo de dados de compartilhamento que usamos com instâncias compartilhadas ou fábricas. Este armazenamento será um singleton em seu aplicativo, portanto, os dados no estado também serão compartilhados.
Em seguida, vamos ver as ações. Ações são operações que você deseja habilitar que envolvem o estado. Por exemplo:
actions: { async loadBooks(store) { const response = await bookService.getBooks(store.state.currentTopic, if (response.status === 200) { // ... } } },
As ações são passadas a uma instância da loja para que você possa obter o estado e outras operações. Normalmente, desestruturaríamos apenas as partes de que precisamos:
actions: { async loadBooks({ state }) { const response = await bookService.getBooks(state.currentTopic, if (response.status === 200) { // ... } } },
A última parte disso são as Mutações. Mutações são funções que podem mudar de estado. Apenas mutações podem afetar o estado. Então, para este exemplo, precisamos de mutações que mudem o estado:
mutations: { setBusy: (state) => state.isBusy = true, clearBusy: (state) => state.isBusy = false, setBooks(state, books) { state.books.splice(0, state.books.length, ...books); } },
As funções de mutação sempre passam no objeto de estado para que você possa alterar esse estado. Nos dois primeiros exemplos, você pode ver que estamos definindo explicitamente o estado. Mas no terceiro exemplo, estamos passando no estado para definir. As mutações sempre recebem dois parâmetros: state e o argumento ao chamar a mutação.
Para chamar uma mutação, você usaria a função commit na loja. No nosso caso, vou apenas adicioná-lo à desestruturação:
actions: { async loadBooks({ state, commit }) { commit("setBusy"); const response = await bookService.getBooks(state.currentTopic, if (response.status === 200) { commit("setBooks", response.data); } commit("clearBusy"); } },
O que você verá aqui é como o commit requer o nome da ação. Existem truques para fazer isso não apenas usar cordas mágicas, mas vou pular isso por enquanto. Esse uso de strings mágicas é uma das limitações do uso do Vuex.
Embora o uso de commit possa parecer um wrapper desnecessário, lembre-se de que o Vuex não permitirá que você altere o estado, exceto dentro da mutação, portanto, apenas as chamadas através do commit o farão.
Você também pode ver que a chamada para setBooks recebe um segundo argumento. Este é o segundo argumento que está chamando a mutação. Se você precisasse de mais informações, precisaria empacotá-las em um único argumento (outra limitação do Vuex atualmente). Supondo que você precise inserir um livro na lista de livros, você pode chamá-lo assim:
commit("insertBook", { book, place: 4 }); // object, tuple, etc.
Então você poderia apenas desestruturar nas peças que você precisa:
mutations: { insertBook(state, { book, place }) => // ... }
Isso é elegante? Não exatamente, mas funciona.
Agora que temos nossa ação trabalhando com mutações, precisamos ser capazes de usar a loja Vuex em nosso código. Existem realmente duas maneiras de chegar à loja. Primeiro, ao registrar a loja com o aplicativo (por exemplo, main.ts/js), você terá acesso a uma loja centralizada à qual terá acesso em todos os lugares do seu aplicativo:
// main.ts import store from './store' createApp(App) .use(store) .use(router) .mount('#app')
Observe que isso não está adicionando Vuex, mas sua loja real que você está criando. Depois que isso for adicionado, você pode simplesmente chamar useStore
para obter o objeto de armazenamento:
import { useStore } from "vuex"; export default defineComponent({ components: { BookInfo, }, setup() { const store = useStore(); const books = computed(() => store.state.books); // ...
Isso funciona bem, mas eu prefiro apenas importar a loja diretamente:
import store from "@/store"; export default defineComponent({ components: { BookInfo, }, setup() { const books = computed(() => store.state.books); // ...
Agora que você tem acesso ao objeto de armazenamento, como você o usa? Para estado, você precisará envolvê-los com funções computadas para que as alterações sejam propagadas para suas ligações:
export default defineComponent({ setup() { const books = computed(() => store.state.books); return { books }; }, });
Para chamar ações, você precisará chamar o método dispatch :
export default defineComponent({ setup() { const books = computed(() => store.state.books); onMounted(async () => await store.dispatch("loadBooks")); return { books }; }, });
As ações podem ter parâmetros que você adiciona após o nome do método. Por fim, para mudar de estado, você precisará chamar commit assim como fizemos dentro do Actions. Por exemplo, tenho uma propriedade de paginação na loja e posso alterar o estado com commit :
const incrementPage = () => store.commit("setPage", store.state.currentPage + 1); const decrementPage = () => store.commit("setPage", store.state.currentPage - 1);
Observe que chamá-lo assim geraria um erro (porque você não pode alterar o estado manualmente):
const incrementPage = () => store.state.currentPage++; const decrementPage = () => store.state.currentPage--;
Este é o verdadeiro poder aqui, queremos controlar onde o estado é alterado e não ter efeitos colaterais que produzam erros mais adiante no desenvolvimento.
Você pode estar sobrecarregado com o número de peças em movimento no Vuex, mas pode realmente ajudar a gerenciar o estado em projetos maiores e mais complexos. Eu não diria que você precisa disso em todos os casos, mas haverá grandes projetos em que isso o ajudará em geral.
O grande problema do Vuex 4 é que trabalhar com ele em um projeto TypeScript deixa muito a desejar. Você certamente pode criar tipos TypeScript para ajudar no desenvolvimento e na compilação, mas isso requer muitas peças móveis.
É aí que o Vuex 5 pretende simplificar como o Vuex funciona no TypeScript (e em projetos JavaScript em geral). Vamos ver como isso funcionará quando for lançado em seguida.
Vuex 5
Nota : O código para esta seção está na ramificação “Vuex5” do projeto de exemplo no GitHub.
No momento deste artigo, o Vuex 5 não é real. É um RFC (Request for Comments). É um plano. É um ponto de partida para a discussão. Portanto, muito do que posso explicar aqui provavelmente mudará um pouco. Mas para prepará-lo para a mudança no Vuex, eu queria lhe dar uma visão de onde está indo. Por causa disso, o código associado a este exemplo não é compilado.
Os conceitos básicos de como o Vuex funciona permaneceram um pouco inalterados desde o início. Com a introdução do Vue 3, o Vuex 4 foi criado principalmente para permitir que o Vuex trabalhe em novos projetos. Mas a equipe está tentando analisar os verdadeiros pontos problemáticos do Vuex e resolvê-los. Para isso, estão planejando algumas mudanças importantes:
- Não há mais mutações: as ações podem mudar o estado (e possivelmente qualquer um).
- Melhor suporte a TypeScript.
- Melhor funcionalidade de várias lojas.
Então, como isso funcionaria? Vamos começar com a criação da loja:
export default createStore({ key: 'bookStore', state: () => ({ isBusy: false, books: new Array<Work>() }), actions: { async loadBooks() { try { this.isBusy = true; const response = await bookService.getBooks(); if (response.status === 200) { this.books = response.data.works; } } finally { this.isBusy = false; } } }, getters: { findBook(key: string): Work | undefined { return this.books.find(b => b.key === key); } } });
A primeira mudança a ser observada é que cada loja agora precisa de sua própria chave. Isso permite que você recupere várias lojas. Em seguida, você notará que o objeto de estado agora é uma fábrica (por exemplo, retorna de uma função, não criada na análise). E não há mais seção de mutações. Por fim, dentro das ações, você pode ver que estamos acessando o estado apenas como propriedades no ponteiro this
. Chega de ter que passar no estado e se comprometer com ações. Isso ajuda não apenas a simplificar o desenvolvimento, mas também facilita a inferência de tipos para o TypeScript.
Para registrar o Vuex em seu aplicativo, você registrará o Vuex em vez de sua loja global:
import { createVuex } from 'vuex' createApp(App) .use(createVuex()) .use(router) .mount('#app')
Por fim, para usar a loja, você importará a loja e criará uma instância dela:
import bookStore from "@/store"; export default defineComponent({ components: { BookInfo, }, setup() { const store = bookStore(); // Generate the wrapper // ...
Observe que o que é retornado da loja é um objeto de fábrica que retorna essa instância da loja, não importa quantas vezes você chame a fábrica. O objeto retornado é apenas um objeto com as ações, estado e getters como cidadãos de primeira classe (com informações de tipo):
onMounted(async () => await store.loadBooks()); const incrementPage = () => store.currentPage++; const decrementPage = () => store.currentPage--;
O que você verá aqui é que state (por exemplo currentPage
) são apenas propriedades simples. E ações (por exemplo loadBooks
) são apenas funções. O fato de você estar usando uma loja aqui é um efeito colateral. Você pode tratar o objeto Vuex como apenas um objeto e continuar seu trabalho. Esta é uma melhoria significativa na API.
Outra mudança que é importante destacar é que você também pode gerar sua loja usando uma sintaxe semelhante à API de composição:
export default defineStore("another", () => { // State const isBusy = ref(false); const books = reactive(new Array≷Work>()); // Actions async function loadBooks() { try { this.isBusy = true; const response = await bookService.getBooks(this.currentTopic, this.currentPage); if (response.status === 200) { this.books = response.data.works; } } finally { this.isBusy = false; } } findBook(key: string): Work | undefined { return this.books.find(b => b.key === key); } // Getters const bookCount = computed(() => this.books.length); return { isBusy, books, loadBooks, findBook, bookCount } });
Isso permite que você construa seu objeto Vuex exatamente como faria com suas visualizações com a API de composição e, sem dúvida, é mais simples.
Uma desvantagem principal neste novo design é que você perde a não mutabilidade do estado. Há discussões acontecendo sobre a possibilidade de habilitar isso (somente para desenvolvimento, assim como o Vuex 4), mas não há consenso sobre a importância disso. Pessoalmente, acho que é um benefício importante para o Vuex, mas teremos que ver como isso se desenrola.
Onde estamos?
Gerenciar o estado compartilhado em aplicativos de página única é uma parte crucial do desenvolvimento para a maioria dos aplicativos. Ter um plano de jogo sobre como você deseja fazer isso no Vue é um passo importante no design de sua solução. Neste artigo, mostrei vários padrões para gerenciar o estado compartilhado, incluindo o que está por vir para o Vuex 5. Espero que agora você tenha o conhecimento necessário para tomar a decisão certa para seus próprios projetos.