Roteamento do lado do cliente e renderização de páginas usando a API History e importações dinâmicas





Bom dia amigos!



Neste artigo, quero mostrar alguns dos recursos do JavaScript moderno e as interfaces fornecidas pelo navegador relacionadas ao roteamento e renderização de páginas sem entrar em contato com o servidor.



Código-fonte no GitHub .



Você pode brincar com o código em CodeSandbox .



Antes de prosseguir com a implementação do aplicativo, gostaria de observar o seguinte:



  • Implementaremos uma das opções mais simples de roteamento e renderização do cliente, alguns métodos mais complexos e versáteis (escaláveis, se preferir) podem ser encontrados aqui
  • . : , .. , ( -, .. , ). index.html .
  • Sempre que possível e apropriado, usaremos importações dinâmicas. Ele permite que você carregue apenas os recursos solicitados (anteriormente, isso só podia ser feito dividindo o código em partes (pedaços) usando construtores de módulo como Webpack), o que é bom para o desempenho. O uso de importações dinâmicas tornará quase todo o nosso código assíncrono, o que, em geral, também é bom, pois evita o bloqueio do fluxo do programa.


Então vamos.



Vamos começar com o servidor.



Crie um diretório, vá até ele e inicialize o projeto:



mkdir client-side-rendering
cd !$
yarn init -yp
// 
npm init -y

      
      





Instale dependências:



yarn add express nodemon open-cli
// 
npm i ...

      
      





  • express - framework Node.js que torna a construção de um servidor muito mais fácil
  • nodemon - uma ferramenta para iniciar e reiniciar automaticamente um servidor
  • open-cli - uma ferramenta que permite abrir uma guia do navegador no endereço onde o servidor está rodando


Às vezes (muito raramente) open-cli abre uma guia do navegador mais rápido do que o nodemon inicia o servidor. Nesse caso, basta recarregar a página.



Crie index.js com o seguinte conteúdo:



const express = require('express')
const app = express()
const port = process.env.PORT || 1234

// src - ,       ,  index.html
//      , , public
//     index.html      src
app.use(express.static('src'))

//         index.html,    
app.get('*', (_, res) => {
  res.sendFile(`${__dirname}/index.html`, null, (err) => {
    if (err) console.error(err)
  })
})

app.listen(port, () => {
  console.log(`Server is running on port ${port}`)
})

      
      





Crie index.html ( Bootstrap será usado para o estilo principal do aplicativo ):



<head>
  ...
  <!-- Bootstrap CSS -->
  <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/3.4.1/css/bootstrap.min.css" integrity="sha384-HSMxcRTRxnN+Bdg0JdbxYKrThecOKuH5zCYotlSAcp1+c8xmyTe9GYg1l9a69psu" crossorigin="anonymous" />

  <link rel="stylesheet" href="style.css" />
</head>
<body>
  <header>
    <nav>
      <!--   "data-url"       -->
      <a data-url="home">Home</a>
      <a data-url="project">Project</a>
      <a data-url="about">About</a>
    </nav>
  </header>

  <main></main>

  <footer>
    <p>© 2020. All rights reserved</p>
  </footer>

  <!--   "type"   "module"   -->
  <script src="script.js" type="module"></script>
</body>

      
      





Para estilos adicionais, crie src / style.css:



body {
  min-height: 100vh;
  display: grid;
  justify-content: center;
  align-content: space-between;
  text-align: center;
  color: #222;
  overflow: hidden;
}

nav {
  margin-top: 1rem;
}

a {
  font-size: 1.5rem;
  cursor: pointer;
}

a + a {
  margin-left: 2rem;
}

h1 {
  font-size: 3rem;
  margin: 2rem;
}

div {
  margin: 2rem;
}

div > article {
  cursor: pointer;
}
/* ! .  */
div > article > * {
  pointer-events: none;
}

footer p {
  font-size: 1.5rem;
}

      
      





Adicione um comando para iniciar o servidor e abrir uma guia do navegador em package.json:



"scripts": {
  "dev": "open-cli http://localhost:1234 && nodemon index.js"
}

      
      





Nós executamos este comando:



yarn dev
// 
npm run dev

      
      





Se movendo.



Crie um diretório src / pages com três arquivos: home.js, project.js e about.js. Cada página é um objeto exportado padrão com as propriedades "conteúdo" e "url".



home.js:



export default {
  content: `<h1>Welcome to the Home Page</h1>`,
  url: 'home'
}

      
      





project.js:



export default {
  content: `<h1>This is the Project Page</h1>`,
  url: 'project',
}

      
      





about.js:



export default {
  content: `<h1>This is the About Page</h1>`,
  url: 'about',
}

      
      





Vamos passar para o script principal.



Nele, usaremos o armazenamento local para salvar e então (após o retorno do usuário ao site) recuperar a página atual e a API de histórico para gerenciar o histórico do navegador.



Quanto ao armazenamento, o método setItem é utilizado para escrever dados , que leva dois parâmetros: o nome dos dados armazenados e os próprios dados, convertidos em uma string JSON - localStorage.setItem ('pageName', JSON.stringify (url)).



Para obter dados, use o método getItem , que leva o nome dos dados; os dados recebidos do armazenamento como uma string JSON são convertidos em uma string regular (no nosso caso): JSON.parse (localStorage.getItem ('pageName')).



Quanto à API de histórico, usaremos dois métodos do objeto de histórico fornecido pela interface de histórico : replaceState e pushState .



Ambos os métodos levam dois parâmetros obrigatórios e um opcional: um objeto de estado, título e caminho (URL) - history.pushState (estado, título [, url]).



O objeto de estado é usado ao manipular o evento "popstate" que ocorre no objeto "janela" quando o usuário faz a transição para um novo estado (por exemplo, quando o botão Voltar de um painel de controle do navegador é pressionado) para processar a página anterior.



O URL é usado para personalizar o caminho exibido na barra de endereço do navegador.



Observe que, graças à importação dinâmica, carregamos apenas uma página ao iniciar o aplicativo: ou a página inicial, se o usuário visitou o site pela primeira vez, ou a página que ele visualizou pela última vez. Você pode verificar se apenas os recursos necessários estão sendo carregados examinando o conteúdo da guia Rede das ferramentas do desenvolvedor.



Crie src / script.js:



class App {
  //  
  #page = null

  //    :
  //      
  constructor(container, page) {
    this.$container = container
    this.#page = page

    //  
    this.$nav = document.querySelector('nav')

    //    
    //         -  
    this.route = this.route.bind(this)

    //    
    //  
    this.#initApp(this.#page)
  }

  //  
  //  url  
  async #initApp({ url }) {
    //      
    // localhost:1234/home
    history.replaceState({ pageName: `${url}` }, `${url} page`, url)

    //   
    this.#render(this.#page)

    //      
    this.$nav.addEventListener('click', this.route)

    //   "popstate" -    
    window.addEventListener('popstate', async ({ state }) => {
      //    
      const newPage = await import(`./pages/${state.page}.js`)

      //      
      this.#page = newPage.default

      //   
      this.#render(this.#page)
    })
  }

  //  
  //      
  #render({ content }) {
    //    
    this.$container.innerHTML = content
  }

  // 
  async route({ target }) {
    //      
    if (target.tagName !== 'A') return

    //    
    const { url } = target.dataset

    //    
    //     
    //   
    if (this.#page.url === url) return

    //    
    const newPage = await import(`./pages/${url}.js`)

    //      
    this.#page = newPage.default

    //   
    this.#render(this.#page)

    //   
    this.#savePage(this.#page)
  }

  //    
  #savePage({ url }) {
    history.pushState({ pageName: `${url}` }, `${url} page`, url)

    localStorage.setItem('pageName', JSON.stringify(url))
  }
}

//  
;(async () => {
  //     
  const container = document.querySelector('main')

  //          "home"
  const pageName = JSON.parse(localStorage.getItem('pageName')) ?? 'home'

  //   
  const pageModule = await import(`./pages/${page}.js`)

  //   
  const pageToRender = pageModule.default

  //   ,        
  new App(container, pageToRender)
})()

      
      





Altere o texto h1 na marcação:



<h1>Loading...</h1>

      
      





Reiniciamos o servidor.







Excelente. Tudo funciona conforme o esperado.



Até agora, lidamos apenas com conteúdo estático, mas e se precisarmos renderizar páginas com conteúdo dinâmico? É possível, neste caso, limitar-se ao cliente ou esta tarefa apenas o servidor pode fazer?



Vamos supor que a página principal exiba uma lista de postagens. Quando você clica em uma postagem, a página com seu conteúdo deve ser renderizada. A página de postagem também deve persistir em localStorage e renderizar após o recarregamento da página (aba do navegador fechar / abrir).



Criamos um banco de dados local na forma de um módulo JS nomeado - src / data / db.js:



export const posts = [
  {
    id: '1',
    title: 'Post 1',
    text: 'Some cool text 1',
    date: new Date().toLocaleDateString(),
  },
  {
    id: '2',
    title: 'Post 2',
    text: 'Some cool text 2',
    date: new Date().toLocaleDateString(),
  },
  {
    id: '3',
    title: 'Post 3',
    text: 'Some cool text 3',
    date: new Date().toLocaleDateString(),
  },
]

      
      





Crie um gerador de post template (também na forma de exportações nomeadas: para importações dinâmicas, as exportações nomeadas são um pouco mais convenientes do que o padrão) - src / templates / post.js:



//         
export const postTemplate = ({ id, title, text, date }) => ({
  content: `
  <article id="${id}">
    <h2>${title}</h2>
    <p>${text}</p>
    <time>${date}</time>
  </article>
  `,
  //    ,     
  //    : `post/${id}`,      post
  //         
  //        
  url: `post#${id}`,
})

      
      





Crie uma função auxiliar para encontrar uma postagem por seu ID - src / helpers / find-post.js:



//    
import { postTemplate } from '../templates/post.js'

export const findPost = async (id) => {
  //         
  //           
  //  
  //    ,          
  const { posts } = await import('../data/db.js')

  //   
  const postToShow = posts.find((post) => post.id === id)
  //   
  return postTemplate(postToShow)
}

      
      





Vamos fazer alterações em src / pages / home.js:



//  
import { postTemplate } from '../templates/post.js'

//      
export default {
  content: async () => {
    //  
    const { posts } = await import('../data/db.js')

    //  
    return `
    <h1>Welcome to the Home Page</h1>
    <div>
      ${posts.reduce((html, post) => (html += postTemplate(post).content), '')}
    </div>
    `
  },
  url: 'home',
}

      
      





Vamos corrigir um pouco src / script.js:



//   
import { findPost } from './helpers/find-post.js'

class App {
  #page = null

  constructor(container, page) {
    this.$container = container
    this.#page = page

    this.$nav = document.querySelector('nav')

    this.route = this.route.bind(this)
    //    
    //        
    this.showPost = this.showPost.bind(this)

    this.#initApp(this.#page)
  }

  #initApp({ url }) {
    history.replaceState({ page: `${url}` }, `${url} page`, url)

    this.#render(this.#page)

    this.$nav.addEventListener('click', this.route)

    window.addEventListener('popstate', async ({ state }) => {
      //    
      const { page } = state

      //    post
      if (page.includes('post')) {
        //  
        const id = page.replace('post#', '')
        //      
        this.#page = await findPost(id)
      } else {
        // ,   
        const newPage = await import(`./pages/${state.page}.js`)
        //      
        this.#page = newPage.default
      }

      this.#render(this.#page)
    })
  }

  async #render({ content }) {
    this.$container.innerHTML =
      // ,    ,
      // ..     
      typeof content === 'string' ? content : await content()

    //         
    this.$container.addEventListener('click', this.showPost)
  }

  async route({ target }) {
    if (target.tagName !== 'A') return

    const { url } = target.dataset
    if (this.#page.url === url) return

    const newPage = await import(`./pages/${url}.js`)
    this.#page = newPage.default

    this.#render(this.#page)

    this.#savePage(this.#page)
  }

  //   
  async showPost({ target }) {
    //      
    //     : div > article > * { pointer-events: none; } ?
    //    ,  ,   article,
    //   , ..   e.target
    if (target.tagName !== 'ARTICLE') return

    //      
    this.#page = await findPost(target.id)

    this.#render(this.#page)

    this.#savePage(this.#page)
  }

  #savePage({ url }) {
    history.pushState({ page: `${url}` }, `${url} page`, url)

    localStorage.setItem('pageName', JSON.stringify(url))
  }
}

;(async () => {
  const container = document.querySelector('main')

  const pageName = JSON.parse(localStorage.getItem('pageName')) ?? 'home'

  let pageToRender = ''

  //      "post"  ..
  // .  popstate
  if (pageName.includes('post')) {
    const id = pageName.replace('post#', '')

    pageToRender = await findPost(id)
  } else {
    const pageModule = await import(`./pages/${pageName}.js`)

    pageToRender = pageModule.default
  }

  new App(container, pageToRender)
})()

      
      





Reiniciamos o servidor.







O aplicativo funciona, mas concorda que a estrutura do código em sua forma atual deixa muito a desejar. Pode ser melhorado, por exemplo, introduzindo uma classe adicional "Roteador", que combinará o roteamento de páginas e posts. No entanto, passaremos pela programação funcional.



Vamos criar outra função auxiliar - src / helpers / check-page-name.js:



//    
import { findPost } from './find-post.js'

export const checkPageName = async (pageName) => {
  let pageToRender = ''

  if (pageName.includes('post')) {
    const id = pageName.replace('post#', '')

    pageToRender = await findPost(id)
  } else {
    const pageModule = await import(`../pages/${pageName}.js`)

    pageToRender = pageModule.default
  }

  return pageToRender
}

      
      





Vamos mudar src / templates / post.js um pouco, a saber: substitua o atributo “id” da tag “article” pelo atributo “data-url” com o valor “post # $ {id}”:



<article data-url="post#${id}">

      
      





A revisão final de src / script.js se parece com isto:



import { checkPageName } from './helpers/check-page-name.js'

class App {
  #page = null

  constructor(container, page) {
    this.$container = container
    this.#page = page

    this.route = this.route.bind(this)

    this.#initApp()
  }

  #initApp() {
    const { url } = this.#page

    history.replaceState({ pageName: `${url}` }, `${url} page`, url)

    this.#render(this.#page)

    document.addEventListener('click', this.route, { passive: true })

    window.addEventListener('popstate', async ({ state }) => {
      const { pageName } = state

      this.#page = await checkPageName(pageName)

      this.#render(this.#page)
    })
  }

  async #render({ content }) {
    this.$container.innerHTML =
      typeof content === 'string' ? content : await content()
  }

  async route({ target }) {
    if (target.tagName !== 'A' && target.tagName !== 'ARTICLE') return

    const { link } = target.dataset
    if (this.#page.url === link) return

    this.#page = await checkPageName(link)

    this.#render(this.#page)

    this.#savePage(this.#page)
  }

  #savePage({ url }) {
    history.pushState({ pageName: `${url}` }, `${url} page`, url)

    localStorage.setItem('pageName', JSON.stringify(url))
  }
}

;(async () => {
  const container = document.querySelector('main')

  const pageName = JSON.parse(localStorage.getItem('pageName')) ?? 'home'

  const pageToRender = await checkPageName(pageName)

  new App(container, pageToRender)
})()

      
      





Como você pode ver, a API de histórico, em conjunto com a importação dinâmica, nos fornece recursos bastante interessantes que facilitam muito o processo de criação de aplicativos de página única (SPA) quase sem envolvimento do servidor.



Se você não sabe por onde começar a desenvolver seu aplicativo, comece com o Modern HTML Starter Template .



Recentemente, concluí uma pequena pesquisa sobre padrões de projeto JavaScript. Os resultados podem ser vistos aqui .



Espero que você tenha encontrado algo interessante para você. Obrigado pela atenção.



All Articles