Reatividade JavaScript: um exemplo simples e intuitivo

Muitas estruturas de front-end do JavaScript (como Angular, React e Vue) têm seus próprios sistemas de reatividade. Compreender os recursos desses sistemas será útil para qualquer desenvolvedor, o ajudará a usar com mais eficiência as estruturas JS modernas.



O material, cuja tradução publicamos hoje, mostra um exemplo passo a passo do desenvolvimento de um sistema de reatividade em JavaScript puro. Este sistema implementa os mesmos mecanismos usados ​​no Vue.

Sistema de reatividade


Para alguém que encontra o sistema de reatividade Vue pela primeira vez, pode parecer uma caixa preta misteriosa. Considere um aplicativo Vue simples. Aqui está a marcação:

<div id="app">    <div>Price: ${{ price }}</div>    <div>Total: ${{ price*quantity }}</div>    <div>Taxes: ${{ totalPriceWithTax }}</div> </div> 

Aqui está o comando de conexão da estrutura e o código do aplicativo.

 <script src="https://cdn.jsdelivr.net/npm/vue"></script> <script>   var vm = new Vue({       el: '#app',       data: {           price: 5.00,           quantity: 2       },       computed: {           totalPriceWithTax() {               return this.price * this.quantity * 1.03           }       }   }) </script> 

De alguma forma, Vue descobre que, quando o price muda, o mecanismo precisa fazer três coisas:

  1. Atualize o valor do price na página da web.
  2. Recalcule a expressão na qual o price é multiplicado pela quantity e exiba o valor resultante na página.
  3. Chame a função totalPriceWithTax e, novamente, coloque o que ela retorna na página.

O que acontece aqui é mostrado na ilustração a seguir.


Como o Vue sabe o que fazer quando a propriedade price muda?

Agora, temos perguntas sobre como o Vue sabe exatamente o que precisa ser atualizado quando o price muda e como o mecanismo rastreia o que está acontecendo na página. O que você pode observar aqui não se parece com um aplicativo JS comum.

Talvez isso ainda não seja óbvio, mas o principal problema que precisamos resolver aqui é que os programas JS geralmente não funcionam assim. Por exemplo, vamos executar o seguinte código:

 let price = 5 let quantity = 2 let total = price * quantity //  10 price = 20; console.log(`total is ${total}`) 

O que você acha que será exibido no console? Como nada é usado aqui, exceto o JS comum, 10 chegarão ao console.


O resultado do programa

E ao usar os recursos do Vue, em uma situação semelhante, podemos implementar um cenário em que o valor total é recontado quando as variáveis ​​de price ou quantity mudam. Ou seja, se o sistema de reatividade fosse usado na execução do código acima, não 10, mas 40 seriam exibidos no console:


Saída do console gerada por código hipotético usando um sistema de reatividade

O JavaScript é uma linguagem que pode funcionar tanto procedural quanto orientada a objetos, mas não possui um sistema de reatividade integrado; portanto, o código que consideramos ao alterar o price não exibirá o número 40 no console. Para que o indicador total seja recalculado quando o price ou a quantity mudar, precisaremos criar um sistema de reatividade por conta própria e, assim, alcançar o comportamento de que precisamos. Vamos abrir o caminho para esse objetivo em vários pequenos passos.

Tarefa: armazenamento de regras para cálculo de indicadores


Precisamos de um local para salvar informações sobre como o indicador total é calculado, o que nos permitirá recalculá-lo ao alterar os valores das variáveis ​​de price ou quantity .

▍Solução


Primeiro, precisamos informar ao aplicativo o seguinte: "Aqui está o código que vou executar, salve-o, talvez seja necessário executá-lo outra vez". Então, precisamos executar o código. Posteriormente, se os indicadores de price ou quantity tiverem sido alterados, será necessário chamar o código salvo para recalcular o total . É assim:


O código de cálculo total precisa ser salvo em algum lugar para poder acessá-lo posteriormente

O código que você pode chamar em JavaScript para executar alguma ação é formatado como funções. Portanto, escreveremos uma função que lida com o cálculo do total e também criaremos um mecanismo para armazenar funções que precisaremos posteriormente.

 let price = 5 let quantity = 2 let total = 0 let target = null target = function () {   total = price * quantity } record() //       ,       target() //   

Observe que armazenamos a função anônima na variável de target e, em seguida, chamamos a função de record . Falaremos sobre isso abaixo. Também gostaria de observar que a função de target , usando a sintaxe das funções de seta ES6, pode ser reescrita da seguinte maneira:

 target = () => { total = price * quantity } 

Aqui está a declaração da função de record e a estrutura de dados usada para armazenar as funções:

 let storage = [] //     target function record () { // target = () => { total = price * quantity }   storage.push(target) } 

Usando a função de record , salvamos a função de target (no nosso caso { total = price * quantity } ) no array de storage , o que nos permite chamar essa função posteriormente, possivelmente usando a função de replay , cujo código é mostrado abaixo. Isso nos permitirá chamar todas as funções armazenadas no storage .

 function replay () {   storage.forEach(run => run()) } 

Aqui, examinamos todas as funções anônimas armazenadas na matriz de storage e executamos cada uma delas.

Então, em nosso código, podemos fazer o seguinte:

 price = 20 console.log(total) // 10 replay() console.log(total) // 40 

Nem tudo parece tão difícil, parece? Aqui está o código inteiro, cujos fragmentos discutimos acima, caso seja mais conveniente para você finalmente lidar com ele. A propósito, esse código não foi escrito acidentalmente dessa maneira.

 let price = 5 let quantity = 2 let total = 0 let target = null let storage = [] function record () {   storage.push(target) } function replay () {   storage.forEach(run => run()) } target = () => { total = price * quantity } record() target() price = 20 console.log(total) // 10 replay() console.log(total) // 40 

É isso que será exibido no console do navegador após o início.


Resultado do código

Desafio: uma solução confiável para armazenar funções


Podemos continuar anotando as funções de que precisamos quando necessário, mas seria bom se tivéssemos uma solução mais confiável que possa ser dimensionada com o aplicativo. Talvez seja uma classe que mantém uma lista de funções originalmente gravadas na variável de target e que recebe notificações se precisarmos executar novamente essas funções.

:Solução: classe de dependência


Uma abordagem para resolver o problema acima é encapsular o comportamento que precisamos em uma classe, que pode ser chamada de dependência. Esta classe implementará o padrão de programação do observador padrão.

Como resultado, se criarmos uma classe JS usada para gerenciar nossas dependências (que estarão próximas de como mecanismos semelhantes são implementados no Vue), pode ser assim:

 class Dep { // Dep -    Dependency   constructor () {       this.subscribers = [] //  ,                               //    notify()   }   depend () { //   record       if (target && !this.subscribers.includes(target)){           //    target                //                this.subscribers.push(target)       }   }   notify () { //   replay       this.subscribers.forEach(sub => sub())       //  -     } } 

Observe que, em vez da matriz de storage , agora armazenamos nossas funções anônimas na matriz de subscribers . Em vez da função de record , o método depend é agora chamado. Também aqui, em vez da função de replay , a função de notify é notify . Veja como executar nosso código usando a classe Dep :

 const dep = new Dep() let price = 5 let quantity = 2 let total = 0 let target = () => { total = price * quantity } dep.depend() //   target    target() //     total console.log(total) // 10 -   price = 20 console.log(total) // 10 -    ,    dep.notify() //   -  console.log(total) // 40 -    

Nosso novo código funciona da mesma maneira que antes, mas agora é melhor projetado e parece melhor para reutilização.

A única coisa que parece estranha até agora é trabalhar com uma função armazenada na variável de target .

Tarefa: mecanismo para criar funções anônimas


No futuro, precisaremos criar um objeto da classe Dep para cada variável. Além disso, seria bom encapsular o comportamento de criar funções anônimas em algum lugar, que deve ser chamado ao atualizar os dados relevantes. Talvez isso nos ajude com uma função adicional, que chamaremos de watcher . Isso levará ao fato de que podemos substituir essa construção do exemplo anterior por uma nova função:

 let target = () => { total = price * quantity } dep.depend() target() 

De fato, uma chamada para a função de watcher que substitui esse código ficará assim:

 watcher(() => {   total = price * quantity }) 

▍ Solução: função de observador


Dentro da função watcher , cujo código é apresentado abaixo, podemos executar várias ações simples:

 function watcher(myFunc) {   target = myFunc //   target   myFunc   dep.depend() //  target      target() //     target = null //   target } 

Como você pode ver, a função watcher usa, como argumento, a função myFunc , grava-a na variável de target global, chama dep.depend() para adicionar essa função à lista de assinantes, chama essa função e redefine a variável de target .
Agora obtemos todos os mesmos valores 10 e 40 se executarmos o seguinte código:

 price = 20 console.log(total) dep.notify() console.log(total) 

Talvez você esteja se perguntando por que implementamos o target como uma variável global, em vez de passar essa variável para nossas funções, se necessário. Temos boas razões para fazer exatamente isso, depois você entenderá.

Tarefa: próprio objeto Dep para cada variável


Temos um único objeto da classe Dep . E se precisarmos que cada uma de nossas variáveis ​​tenha seu próprio objeto de classe Dep ? Antes de continuarmos, vamos mover os dados com os quais trabalhamos para as propriedades do objeto:

 let data = { price: 5, quantity: 2 } 

Imagine por um momento que cada uma de nossas propriedades ( price e quantity ) tenha seu próprio objeto interno da classe Dep .


Propriedades de preço e quantidade

Agora podemos chamar a função watcher assim:

 watcher(() => {   total = data.price * data.quantity }) 

Como trabalhamos aqui com o valor da propriedade data.price , precisamos que o objeto da classe Dep da propriedade price coloque uma função anônima (armazenada no target ) em sua matriz de assinantes (chamando dep.depend() ). Além disso, como estamos trabalhando com data.quantity , precisamos do objeto Dep da propriedade quantity para colocar uma função anônima (novamente, armazenada no target ) em sua matriz de assinantes.

Se você descrever isso na forma de um diagrama, obterá o seguinte.


Funções caem em matrizes de assinantes de objetos da classe Dep correspondentes a propriedades diferentes

Se tivermos mais uma função anônima na qual trabalhamos apenas com a propriedade data.price , a função anônima correspondente deve ir apenas para o depósito do objeto dessa propriedade.


Observadores adicionais podem ser adicionados a apenas uma das propriedades disponíveis.

Quando você pode precisar chamar dep.notify() para funções inscritas nas alterações na propriedade price ? Isso será necessário ao alterar o price . Isso significa que, quando nosso exemplo estiver completamente pronto, o código a seguir deverá funcionar para nós.


Aqui, ao alterar o preço, você precisa chamar dep.notify () para todas as funções inscritas na alteração de preço

Para que tudo funcione dessa maneira, precisamos interceptar eventos de acesso à propriedade (no nosso caso, é price ou quantity ). Isso permitirá, quando isso acontecer, salvar a função de target em uma matriz de assinantes e, quando a variável correspondente for alterada, executar a função armazenada nessa matriz.

Solução: Object.defineProperty ()


Agora, precisamos nos familiarizar com o método ES5 padrão Object.defineProperty (). Permite atribuir getters e setters às propriedades dos objetos. Permita-me, antes de prosseguirmos para seu uso prático, demonstrar a operação desses mecanismos com um exemplo simples.

 let data = { price: 5, quantity: 2 } Object.defineProperty(data, 'price', { //       price   get() { //        console.log(`I was accessed`)   },   set(newVal) { //        console.log(`I was changed`)   } }) data.price //       data.price = 20 //      

Se você executar esse código no console do navegador, ele exibirá o texto a seguir.


Resultados de Getter e Setter

Como você pode ver, nosso exemplo simplesmente imprime algumas linhas de texto no console. No entanto, ele não lê ou define valores, pois redefinimos a funcionalidade padrão de getters e setters. Restauraremos a funcionalidade desses métodos. Nomeadamente, espera-se que os getters retornem os valores dos métodos correspondentes e os setters os definam. Portanto, adicionaremos uma nova variável, internalValue , ao código, que usaremos para armazenar o valor atual do price .

 let data = { price: 5, quantity: 2 } let internalValue = data.price //   Object.defineProperty(data, 'price', { //       price   get() { //        console.log(`Getting price: ${internalValue}`)       return internalValue   },   set(newVal) {       console.log(`Setting price to: ${newVal}`)       internalValue = newVal   } }) total = data.price * data.quantity //       data.price = 20 //      

Agora que o getter e o setter funcionam da maneira que deveriam funcionar, o que você acha que entrará no console quando esse código for executado? Veja a figura a seguir.


Saída de dados para o console

Portanto, agora temos um mecanismo que permite receber notificações ao ler valores de propriedade e quando novos valores são gravados neles. Agora, depois de refazer um pouco o código, podemos equipar getters e setters com todas as propriedades do objeto de data . Aqui vamos usar o método Object.keys() , que retorna uma matriz de chaves do objeto passado para ele.

 let data = { price: 5, quantity: 2 } Object.keys(data).forEach(key => { //        data   let internalValue = data[key]   Object.defineProperty(data, key, {       get() {           console.log(`Getting ${key}: ${internalValue}`)           return internalValue       },       set(newVal) {           console.log(`Setting ${key} to: ${newVal}`)           internalValue = newVal       }   }) }) let total = data.price * data.quantity data.price = 20 

Agora todas as propriedades do objeto de data têm getters e setters. É isso que aparece no console após a execução desse código.


Saída de dados para o console por getters e setters

Montagem do sistema de reatividade


Quando um fragmento de código como total = data.price * data.quantity e total = data.price * data.quantity valor da propriedade price , precisamos da propriedade price para "lembrar" a função anônima correspondente ( target no nosso caso). Como resultado, se a propriedade price for alterada, ou seja, configurada para um novo valor, isso levará a uma chamada para esta função para repetir as operações executadas por ela, pois sabe que uma determinada linha de código depende dela. Como resultado, as operações executadas em getters e setters podem ser imaginadas da seguinte maneira:

  • Getter - você precisa se lembrar da função anônima, que chamaremos novamente quando o valor for alterado.
  • Setter - é necessário executar a função anônima armazenada, o que levará a uma alteração no valor resultante correspondente.

Se você usa a classe Dep já conhecida por você nesta descrição, obtém o seguinte:

  • Ao ler um valor de propriedade, dep.depend() é chamado para salvar a função de target atual.
  • Quando um valor é gravado em uma propriedade, dep.notify() é chamado para reiniciar todas as funções armazenadas.

Agora vamos combinar essas duas idéias e, finalmente, chegaremos ao código que nos permite alcançar nosso objetivo.

 let data = { price: 5, quantity: 2 } let target = null //  -    ,     class Dep {   constructor () {       this.subscribers = []   }   depend () {       if (target && !this.subscribers.includes(target)){           this.subscribers.push(target)       }   }   notify () {       this.subscribers.forEach(sub => sub())   } } //      ,  //      Object.keys(data).forEach(key => {   let internalValue = data[key]   //         //   Dep   const dep = new Dep()   Object.defineProperty(data, key, {       get() {           dep.depend() //    target           return internalValue       },       set(newVal) {           internalValue = newVal           dep.notify() //           }   }) }) //   watcher   dep.depend(), //        function watcher(myFunc){   target = myFunc   target()   target = null } watcher(() => {   data.total = data.price * data.quantity }) 

Vamos experimentar esse código no console do navegador.


Experiências de código prontas

Como você pode ver, funciona exatamente como precisamos! As propriedades de price e quantity tornaram-se reativas! Todo o código responsável por gerar o total quando o price ou a quantity alterados repetidamente.

Agora, depois de escrevermos nosso próprio sistema de reatividade, esta ilustração da documentação do Vue parecerá familiar e compreensível para você.


Sistema de reatividade Vue

Vê este lindo círculo roxo que diz contendo getters e setters? Agora ele deve estar familiarizado com você. Cada instância do componente possui uma instância do método observador (círculo azul), que coleta dependências em getters (linha vermelha). Quando, posteriormente, o setter é chamado, ele notifica o método observador, o que leva à nova renderização do componente. Aqui está o mesmo esquema, fornecido com explicações conectando-o ao nosso desenvolvimento.


Diagrama de reatividade Vue com explicações

Acreditamos que agora, depois de escrevermos nosso próprio sistema de reatividade, esse esquema não precisará de explicações adicionais.

Obviamente, no Vue, tudo isso é mais complicado, mas agora você deve entender o mecanismo subjacente aos sistemas de reatividade.

Sumário


Depois de ler este material, você aprendeu o seguinte:

  • Como criar uma classe Dep que coleta funções usando o método depend e, se necessário, as chama novamente usando o método de notify .
  • Como criar uma função de watcher que permite controlar o código que executamos (esta é a função de target ), que talvez você precise salvar no objeto da classe Dep .
  • Como usar o método Object.defineProperty() para criar getters e setters.

Tudo isso, compilado em um único exemplo, levou à criação de um sistema de responsividade em JavaScript puro, entendendo que você pode entender os recursos do funcionamento de tais sistemas usados ​​em estruturas da Web modernas.

Caros leitores! Se, antes de ler este material, você mal imaginou as características dos mecanismos dos sistemas de reatividade, diga-me, agora você conseguiu lidar com eles?

Source: https://habr.com/ru/post/pt418633/


All Articles