Pular para o conteúdo

c

Obtendo dados do servidor

Já estamos trabalhando há um tempo apenas no "front-end", ou seja, com as funcionalidades do lado do cliente (navegador). Começaremos a trabalhar no "back-end", ou seja, com as funcionalidades do lado do servidor na Parte 3 deste curso. Contudo, agora daremos um passo nessa direção, assim familiarizando-nos com a comunicação do código executado no navegador com o back-end.

Vamos usar uma ferramenta destinada a ser usada durante a fase de desenvolvimento de software chamada JSON Server, que atuará como nosso servidor.

Crie um arquivo chamado db.json na raiz do diretório do projeto de notas com o seguinte conteúdo:

{
  "notes": [
    {
      "id": 1,
      "content": "HTML é fácil",
      "important": true
    },
    {
      "id": 2,
      "content": "O navegador só pode executar JavaScript",
      "important": false
    },
    {
      "id": 3,
      "content": "GET e POST são os métodos mais importantes do protocolo HTTP",
      "important": true
    }
  ]
}

É possível instalar globalmente um servidor JSON na sua máquina usando o comando npm install -g json-server. Uma instalação global requer privilégios administrativos, o que significa que não é possível fazer isso em computadores de faculdade, etc.

Após a instalação, execute o seguinte comando para executar o json-server. O json-server é executado na porta 3000 por padrão; porém, como projetos criados usando o "create-react-app" reservam a porta 3000 para si, devemos definir uma porta alternativa — como a porta 3001 — para o json-server. A opção --watch procura automaticamente por quaisquer alterações salvas no arquivo db.json.

json-server --port 3001 --watch db.json

Entretanto, não é necessária uma instalação global. A partir da raiz do diretório da sua aplicação, podemos executar o json-server usando o comando npx:

npx json-server --port 3001 --watch db.json

Vamos acessar o endereço http://localhost:3001/notes no navegador. Vemos que o json-server serve as notas que escrevemos anteriormente no arquivo em formato JSON:

fullstack content

Se o seu navegador não tiver um formatador para exibir os dados JSON, instale um plugin como o JSONVue para facilitar sua vida.

A partir de agora, a ideia será salvar as notas no servidor, que, neste caso, significa salvá-las no json-server. O código React busca as notas do servidor e as renderiza na tela. Sempre que uma nova nota é adicionada à aplicação, o código React também a envia ao servidor para que a nova nota persista (persist — leia mais sobre persistência de dados aqui) na "memória".

O json-server armazena todos os dados no arquivo db.json, que reside no servidor. No mundo real, os dados seriam armazenados em algum tipo de banco de dados. No entanto, o json-server é uma ferramenta muito útil que permite o uso da funcionalidade de um servidor na fase de desenvolvimento sem a necessidade de programar nenhum desses outros softwares.

Nos familiarizaremos com os princípios de implementação das funcionalidades de um servidor com mais detalhes na parte 3 deste curso.

O navegador como ambiente de execução

Nossa primeira tarefa é buscar as notas já existentes em nossa aplicação React a partir do endereço http://localhost:3001/notes.

Na projeto-exemplo da Parte 0, já aprendemos uma maneira de buscar dados de um servidor usando JavaScript. O código no exemplo estava buscando os dados usando XMLHttpRequest, também conhecido como uma "requisição HTTP" feita usando um objeto XHR. Esta é uma técnica introduzida em 1999, no qual todos os navegadores têm oferecido suporte a ela já faz um bom tempo.

Já não é mais recomendado o uso do objeto XHR, e a maioria dos navegadores já suportam amplamente o método fetch ("ir buscar" ou "buscar"), que é baseado em chamadas conhecidas como promessas (promises), ao invés do modelo de gerenciamento de eventos utilizado pelo XHR.

Como lembrete da Parte 0 (que deve ser lembrado de não ser usado sem um motivo plausível), os dados foram buscados usando o XHR da seguinte maneira:

const xhttp = new XMLHttpRequest()

xhttp.onreadystatechange = function () {
  if (this.readyState == 4 && this.status == 200) {
    const data = JSON.parse(this.responseText)
    // gerencia a resposta que é salva nos dados variáveis
  }
}

xhttp.open('GET', '/data.json', true)
xhttp.send()

Desde o início, registramos um gerenciador de evento ao objeto xhttp representando a requisição HTTP, que será chamado pelo ambiente de execução JavaScript sempre que o estado do objeto xhttp mudar. Se a mudança no estado significa que a resposta à requisição chegou, então os dados são lidos de acordo com o que foi estabelecido.

Vale a pena notar que o código no gerenciador de evento é definido antes da requisição ser enviada ao servidor. Mesmo assim, o código dentro do gerenciador de evento será executado em um momento posterior. Portanto, o código não executa sincronicamente "de cima para baixo", mas sim assincronamente (asynchronously). JavaScript chama em algum momento o gerenciador de evento que foi registrado para a requisição.

Uma forma comum de fazer requisições síncronas em Java, por exemplo, funcionaria da seguinte maneira (N.B. (Nota Bene): este código Java não funciona):

HTTPRequest request = new HTTPRequest();

String url = "https://studies.cs.helsinki.fi/exampleapp/data.json";
List<Note> notes = request.get(url);

notes.forEach(m => {
  System.out.println(m.content);
});

Em Java, o código é executado linha a linha e é interrompido para esperar pela requisição HTTP, o que significa esperar até o comando request.get(...) ser concluído. Os dados retornados pelo comando, neste caso as notas, são então armazenados em uma variável na qual podemos manipular os dados da maneira que desejarmos.

Por outro lado, os ambientes de tempo de execução JavaScript, ou "engines" (motores), seguem o modelo assíncrono. Em princípio, isso requer que todas as operações IO (com algumas exceções) sejam executadas como não-bloqueantes. Isso significa que a execução do código continua imediatamente após a chamada de uma função IO, sem esperar que ela termine.

Quando uma operação assíncrona é concluída, ou mais especificamente, algum tempo depois de sua conclusão, o que acontece é que o motor JavaScript chama os gerenciadores de evento registrados na operação.

Atualmente, os motores JavaScript são single-threaded (linha de execução única), o que significa que não podem executar código em paralelo. Como resultado, na prática, é uma exigência usar um modelo não-bloqueante para a execução de operações IO. Caso contrário, o navegador "congelaria" durante a busca de dados em um servidor, por exemplo.

Outra consequência da natureza single-threaded dos motores JavaScript é que se alguma execução de código levar muito tempo, o navegador ficará preso durante toda a execução. Se adicionássemos o seguinte código no topo de nossa aplicação...

setTimeout(() => {
  console.log('loop..')
  let i = 0
  while (i < 50000000000) {
    i++
  }
  console.log('fim do loop')
}, 5000)

... tudo funcionaria normalmente por 5 segundos. No entanto, quando a função definida como o parâmetro para setTimeout é executada, o navegador fica preso durante toda a execução do longo loop. Mesmo a aba do navegador não pode ser fechada durante a execução do loop, pelo menos não no Chrome.

Para o navegador permanecer responsivo, ou seja, ser capaz de reagir continuamente às operações do usuário com velocidade suficiente, a lógica do código precisa ser tal que nenhuma única computação tenha de levar tanto tempo para se realizar.

Existe uma série de materiais sobre o tema disponíveis na internet. Uma apresentação particularmente clara do tópico é a palestra de Philip Roberts chamada What the heck is the event loop anyway? (disponível em português).

É possível executar código paralelizado nos navegadores de hoje em dia com a ajuda dos chamados web workers. No entanto, o loop de eventos de uma única janela do navegador ainda é realizado como single thread.

npm

Vamos voltar ao assunto sobre obtenção de dados do servidor.

Poderíamos usar a função baseada em promessas fetch, mencionada anteriormente, para puxar (pull) os dados do servidor. Fetch é uma ótima ferramenta. É padronizada e tem suporte em todos os navegadores modernos (exceto o IE [Internet Explorer]).

Dito isso, usaremos a biblioteca axios para fazer essa comunicação entre navegador e servidor. Ela funciona como o fetch, mas é um pouco mais agradável de se usar. Outra boa razão para usar o axios é que nos familiarizaremos com a adição de bibliotecas externas em projetos React, conhecidas como pacotes npm (npm packages).

Hoje em dia, praticamente todos os projetos JavaScript são definidos usando o gerenciador de pacotes do Node, conhecido como npm (abreviação de "Node Package Manager"). Os projetos criados usando "create-react-app" também seguem o formato npm. Um indicador claro de que um projeto usa npm é o arquivo package.json localizado na raiz do projeto:

{
  "name": "notes-frontend",
  "version": "0.1.0",
  "private": true,
  "dependencies": {
    "@testing-library/jest-dom": "^5.16.5",
    "@testing-library/react": "^13.4.0",
    "@testing-library/user-event": "^13.5.0",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "react-scripts": "5.0.1",
    "web-vitals": "^2.1.4"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject"
  },
  "eslintConfig": {
    "extends": ["react-app", "react-app/jest"]
  },
  "browserslist": {
    "production": [">0.2%", "not dead", "not op_mini all"],
    "development": [
      "last 1 chrome version",
      "last 1 firefox version",
      "last 1 safari version"
    ]
  }
}

Neste ponto, o objeto dependencies, que é uma parte do documento package.json, é o que mais nos interessa agora, pois define quais são as dependências ou bibliotecas externas do projeto.

Agora queremos usar o axios. Teoricamente, poderíamos definir a biblioteca diretamente no arquivo package.json, mas é melhor instalá-la a partir da linha de comando.

npm install axios

N.B.: os comandos do npm sempre devem ser executados no diretório raiz do projeto, onde o arquivo package.json pode ser encontrado.

O axios agora está incluído entre as outras dependências:

{
  "name": "notes-frontend",
  "version": "0.1.0",
  "private": true,
  "dependencies": {
    "@testing-library/jest-dom": "^5.16.5",
    "@testing-library/react": "^13.4.0",
    "@testing-library/user-event": "^13.5.0",
    "axios": "^1.2.2",    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "react-scripts": "5.0.1",
    "web-vitals": "^2.1.4"
  }
  // ...
}

Além de adicionar o axios às dependências, o comando npm install também baixou o código da biblioteca. Como outras dependências, o código pode ser encontrado no diretório nodemodules localizado na raiz. É possível notar que o diretório nodemodules contém uma quantidade significativa de coisas interessantes.

Vamos fazer mais uma adição. Instale o json-server como uma dependência de desenvolvimento (usado apenas durante o desenvolvimento) executando o comando...

npm install json-server --save-dev

... e fazendo uma pequena adição ao objeto scripts do arquivo package.json:

{
  // ...
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject",
    "server": "json-server -p3001 --watch db.json"  }
}

Agora podemos, convenientemente e sem definições de parâmetros, iniciar o json-server a partir do diretório raiz do projeto com o comando:

npm run server

Vamos ficar mais familiarizados com a ferramenta npm na terceira parte do curso.

N.B.: O json-server que foi iniciado anteriormente deve ser encerrado antes de iniciar um novo; caso contrário, haverá problemas:

erro: cannot bind to port 3001

A mensagem de erro em vermelho nos informa sobre o problema:

Não é possível vincular-se ao número da porta 3001. Por favor, especifique outro número de porta, seja através do argumento --port ou através do arquivo de configuração json-server.json

Como podemos ver, a aplicação não é capaz de se vincular à porta (port). O motivo é que a porta 3001 já está ocupada pelo json-server iniciado anteriormente.

Usamos o comando npm install duas vezes, mas com pequenas modificações:

npm install axios
npm install json-server --save-dev

Há uma pequena diferença nos parâmetros. O axios é instalado como uma dependência de tempo de execução da aplicação, pois a execução do programa exige a existência da biblioteca. Por outro lado, o json-server foi instalado como uma dependência de desenvolvimento (--save-dev), uma vez que o próprio programa não o requer. Ele é usado para ajudar durante a fase de desenvolvimento do software. Mais há de ser dito sobre diferentes dependências na próxima parte do curso.

Axios e promessas (promises)

Estamos prontos para usar a biblioteca axios. A partir de agora, supõe-se que o json-server esteja rodando na porta 3001.

N.B.: Para executar o json-server e sua aplicação React simultaneamente, é possível que seja necessário usar duas janelas do terminal. Uma para manter o json-server em execução e outra para executar a aplicação React.

A biblioteca pode ser utilizada da mesma maneira que outras bibliotecas, como React, por exemplo, através de uma declaração import adequada.

Adicione o seguinte ao arquivo index.js:

import axios from 'axios'

const promise = axios.get('http://localhost:3001/notes')
console.log(promise)

const promise2 = axios.get('http://localhost:3001/foobar')
console.log(promise2)

Se você acessar o endereço http://localhost:3000 no navegador, deve ser impresso isso no console:

promessas impressas no console

O método get do Axios retorna uma promessa (promise).

A documentação no site da Mozilla afirma o seguinte sobre "promessas":

Uma Promise (promessa) é um objeto que representa a eventual conclusão ou falha de uma operação assíncrona.

Em outras palavras, uma promessa é um objeto que representa uma operação assíncrona. Uma promessa pode ter três estados distintos:

  1. A promessa está pendente (pending): significa que o valor final (uma das operações seguintes) ainda não está disponível.
  2. A promessa está realizada (fulfilled): significa que a operação foi concluída e o valor final está disponível, o que geralmente é uma operação bem-sucedida. Este estado às vezes também é chamado de resolvido(a) (resolved).
  3. A promessa está rejeitada (rejected): significa que um erro impediu que o valor final fosse determinado, o que geralmente representa uma operação falha.

A primeira promessa em nosso exemplo foi realizada, representando uma requisição bem-sucedida axios.get('http://localhost:3001/notes'). A segunda, no entanto, foi rejeitada e o console nos diz o motivo. Parece que estamos tentando fazer uma requisição HTTP GET a um endereço que não existe.

Se e quando quisermos acessar o resultado da operação representada pela promessa, devemos registrar um gerenciador de evento para ela. Isso é feito utilizando o método then:

const promise = axios.get('http://localhost:3001/notes')

promise.then((response) => {
  console.log(response)
})

O seguinte é impresso no console:

dados de um objeto json impressos no console

O ambiente de tempo de execução JavaScript chama a função callback (função de retorno de chamada) registrada pelo método then fornecendo-lhe um objeto response como parâmetro. O objeto response contém todos os dados essenciais relacionados à resposta de uma requisição HTTP GET, que incluiria os dados retornados data, o código de status e os cabeçalhos.

Em geral, armazenar o objeto de promessa em uma variável é desnecessário, e é uma prática comum encadear a chamada de método then à chamada de método axios, de modo que haja um seguimento lógico:

axios.get('http://localhost:3001/notes').then((response) => {
  const notes = response.data
  console.log(notes)
})

A função callback (função de retorno de chamada) pega os dados contidos dentro da resposta, armazena-os em uma variável e imprime as notas no console.

Uma maneira mais legível de formatar chamadas de método encadeadas é colocar cada chamada em sua própria linha:

axios.get('http://localhost:3001/notes').then((response) => {
  const notes = response.data
  console.log(notes)
})

Os dados retornados pelo servidor são texto simples ou texto puro (plain text), que é basicamente apenas uma string longa. A biblioteca axios ainda consegue analisar os dados em um array JavaScript, já que o servidor especificou que o formato de dados é application/json; charset=utf-8 (veja a imagem anterior) usando o cabeçalho content-type.

Podemos finalmente começar a usar os dados obtidos do servidor.

Vamos tentar requisitar as notas do nosso servidor local e renderizá-las utilizando inicialmente o componente App. Por favor, note que esta abordagem tem muitos problemas, pois estamos renderizando todo o componente App apenas quando recebemos uma resposta de uma operação bem-sucedida:

import ReactDOM from 'react-dom/client'
import axios from 'axios'
import App from './App'

axios.get('http://localhost:3001/notes').then((response) => {
  const notes = response.data
  ReactDOM.createRoot(document.getElementById('root')).render(
    <App notes={notes} />
  )
})

Este método poderia ser aceitável em algumas circunstâncias, mas é um tanto problemático. Em vez disso, vamos mover a busca de dados para o componente App.

Porém, o que não é imediatamente óbvio é onde o comando axios.get deve ser colocado dentro do componente.

Effect-hooks

Já usamos hooks de estado que foram introduzidos juntamente com a versão do React 16.8.0, que fornecem estado aos componentes React definidos como funções — os chamados componentes funcionais. A versão 16.8.0 também introduz effect hooks (ou "ganchos de efeito" ou "hooks de efeito") como uma nova funcionalidade. De acordo com a documentação oficial:

O Effect Hook (Hook de Efeito) te permite executar efeitos colaterais em componentes funcionais > Buscar dados, configurar uma subscription (assinatura), e mudar o DOM manualmente dentro dos componentes React são exemplos de efeitos colaterais.

Como tal, os hooks de efeito são precisamente a ferramenta certa a ser usada ao buscar dados de um servidor.

Vamos remover a busca de dados de index.js. Já que vamos buscar as notas do servidor, não há mais necessidade de passar dados como props para o componente App. Então, index.js pode ser simplificado desta forma:

ReactDOM.createRoot(document.getElementById('root')).render(<App />)

O componente App muda da seguinte maneira:

import { useState, useEffect } from 'react'import axios from 'axios'import Note from './components/Note'

const App = () => {
  const [notes, setNotes] = useState([])  const [newNote, setNewNote] = useState('')
  const [showAll, setShowAll] = useState(true)

  useEffect(() => {    console.log('effect (efeito)')    axios.get('http://localhost:3001/notes').then((response) => {      console.log('promise fulfilled (promessa resolvida)')      setNotes(response.data)    })  }, [])  console.log('render (renderiza)', notes.length, 'notes (notas)')
  // ...
}

Também adicionamos algumas impressões úteis no console, que esclarecem a progressão da execução.

Isto é impresso no console:


render (renderiza) 0 notes (notas)
effect (efeito)
promise fulfilled (promessa resolvida)
render (renderiza) 3 notes (notas)

Assim, o corpo da função que define o componente é executado e o componente é renderizado pela primeira vez. Neste ponto, render (renderiza) 0 notes (notas) é impresso, o que significa que os dados ainda não foram buscados no servidor.

Em seguida, a função — ou efeito, no linguajar React — ...

() => {
  console.log('effect (efeito)')
  axios.get('http://localhost:3001/notes').then((response) => {
    console.log('promise fulfilled (promessa resolvida)')
    setNotes(response.data)
  })
}

... é executada imediatamente após a renderização. A execução da função resulta na impressão de effect (efeito) no console, e o comando axios.get inicia a busca de dados no servidor, bem como registra a seguinte função como um gerenciador de evento para a operação:

response => {
  console.log('promise fulfilled (promessa resolvida)')
  setNotes(response.data)
})

Quando os dados chegam do servidor, o ambiente de execução JavaScript chama a função registrada como o gerenciador de evento, o que imprime promise fulfilled (promessa resolvida) no console e armazena as notas recebidas do servidor no estado usando a função setNotes(response.data).

Como sempre, uma chamada a uma função de atualização de estado gera a re-renderização do componente. Como resultado, render (renderiza) 3 notes (notas) é impresso no console e as notas buscadas do servidor são renderizadas na tela.

Por fim, vamos dar uma olhada na definição do hook de efeito como um todo:

useEffect(() => {
  console.log('effect (efeito)')
  axios.get('http://localhost:3001/notes').then((response) => {
    console.log('promise fulfilled (promessa resolvida)')
    setNotes(response.data)
  })
}, [])

Vamos reescrever o código de uma maneira um pouco diferente:

const hook = () => {
  console.log('effect (efeito)')
  axios.get('http://localhost:3001/notes').then((response) => {
    console.log('promise fulfilled (promessa resolvida)')
    setNotes(response.data)
  })
}

useEffect(hook, [])

Podemos ver claramente que a função useEffect ("usarEfeito") leva dois parâmetros. O primeiro é uma função, o próprio effect (efeito). De acordo com a documentação:

Por padrão, useEffect roda depois da primeira renderização e depois de toda atualização, mas é possível escolher rodá-lo somente quando determinados valores tenham mudado.

Portanto, por padrão, o efeito é sempre executado após a renderização do componente. No nosso caso, no entanto, só queremos executar o efeito junto à primeira renderização.

O segundo parâmetro de useEffect é usado para especificar com que frequência o efeito é executado. Se o segundo parâmetro é um array vazio [], então o efeito é executado junto com a primeira renderização do componente.

Existem muitos casos possíveis de uso para um hook de efeito, além de buscar dados do servidor. Contudo, este uso já é suficiente para nós, por enquanto.

Pense novamente na sequência de eventos que acabamos de discutir. Qual parte do código é executada? Em que ordem? Com qual frequência? Entender a ordem dos eventos é decisivo!

Observe que também poderíamos ter escrito o código da função de efeito (effect function) desta forma:

useEffect(() => {
  console.log('effect (efeito)')

  const eventHandler = (response) => {
    console.log('promise fulfilled (promessa resolvida)')
    setNotes(response.data)
  }

  const promise = axios.get('http://localhost:3001/notes')
  promise.then(eventHandler)
}, [])

Uma referência à função gerenciadora de evento é atribuída à variável eventHandler. A promessa retornada pelo método get do Axios é armazenada na variável promise. O registro do callback (retorno de chamada) acontece dando à variável eventHandler, que referencia a função gerenciadora de evento, como parâmetro para o método then da promessa. Em geral, não é necessário atribuir funções e promessas a variáveis, e uma forma mais compacta de representação de ações já é suficiente, como a exibida acima, por exemplo.

useEffect(() => {
  console.log('effect (efeito)')
  axios.get('http://localhost:3001/notes').then((response) => {
    console.log('promise fulfilled (promessa resolvida)')
    setNotes(response.data)
  })
}, [])

Ainda temos um problema com nossa aplicação. Ao adicionar novas anotações, elas não são armazenadas no servidor.

O código para a aplicação, como descrito até agora, pode ser encontrado na íntegra no github, na branch part2-4.

O Ambiente de Tempo de Execução de Desenvolvimento

Tornou-se cada vez mais complexa a configuração de toda a aplicação. Vamos revisar o que são e onde acontecem os eventos. A imagem a seguir descreve a composição da aplicação:

diagrama da composição da aplicação React

O código JavaScript que compõe nossa aplicação React é executado no navegador. O navegador obtém o JavaScript do servidor de desenvolvimento React (React dev server), que é a aplicação que é executada após a execução do comando npm start. O servidor de desenvolvimento transforma o JavaScript em um formato compreendido pelo navegador. Entre outras coisas, ele costura e junta o JavaScript de diferentes arquivos em um único arquivo. Discutiremos sobre o servidor de desenvolvimento React em mais detalhes na Parte 7 do curso.

A aplicação React em execução no navegador busca os dados no formato JSON do json-server, que está sendo executado na porta 3001 na máquina. O servidor a partir do qual requisitamos os dados — json-server — obtém seus dados do arquivo db.json.

Neste ponto do desenvolvimento, calha que todas as partes da aplicação residem na máquina do desenvolvedor, conhecido como "localhost". A situação muda quando a aplicação é implementada na internet. Faremos isso na Parte 3.