Enrutamiento y representación del lado del cliente de páginas utilizando la API de historial e importaciones dinámicas





¡Buen dia amigos!



En este artículo, quiero mostrarle algunas de las capacidades del JavaScript moderno y las interfaces proporcionadas por el navegador relacionadas con el enrutamiento y la representación de páginas sin contactar con el servidor.



Código fuente en GitHub .



Puedes jugar con el código en CodeSandbox .



Antes de continuar con la implementación de la aplicación, me gustaría señalar lo siguiente:



  • Implementamos una de las opciones de enrutamiento y renderización del lado del cliente más simples, un par de métodos más complejos y versátiles (escalables, si lo desea) se pueden encontrar aquí
  • . : , .. , ( -, .. , ). index.html .
  • Siempre que sea posible y apropiado, utilizaremos importaciones dinámicas. Le permite cargar solo los recursos solicitados (anteriormente, esto solo se podía hacer dividiendo el código en partes (fragmentos) utilizando constructores de módulos como Webpack), lo cual es bueno para el rendimiento. Usar importaciones dinámicas hará que casi todo nuestro código sea asíncrono, lo que, en general, también es bueno, ya que evita bloquear el flujo del programa.


Entonces vamos.



Empecemos por el servidor.



Cree un directorio, vaya a él e inicialice el proyecto:



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

      
      





Instalar dependencias:



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

      
      





  • express : marco de Node.js que facilita la creación de un servidor
  • nodemon : una herramienta para iniciar y reiniciar automáticamente un servidor
  • open-cli : una herramienta que le permite abrir una pestaña del navegador en la dirección donde se ejecuta el servidor


A veces (muy raramente) open-cli abre una pestaña del navegador más rápido de lo que nodemon inicia el servidor. En este caso, simplemente vuelva a cargar la página.



Crea index.js con el siguiente contenido:



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}`)
})

      
      





Cree index.html ( Bootstrap se utilizará para el estilo principal de la aplicación ):



<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 un estilo adicional, cree 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;
}

      
      





Agregue un comando para iniciar el servidor y abra una pestaña del navegador en package.json:



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

      
      





Ejecutamos este comando:



yarn dev
// 
npm run dev

      
      





Hacia adelante.



Cree un directorio src / pages con tres archivos: home.js, project.js y about.js. Cada página es un objeto exportado predeterminado con propiedades de "contenido" y "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',
}

      
      





Pasemos al guión principal.



En él, usaremos el almacenamiento local para guardar y luego (después de que el usuario regrese al sitio) obtendremos la página actual y la API de historial para administrar el historial del navegador.



En cuanto al almacenamiento, el método setItem se usa para escribir datos , que toma dos parámetros: el nombre de los datos almacenados y los datos en sí, convertidos a una cadena JSON - localStorage.setItem ('pageName', JSON.stringify (url)).



Para obtener datos, use el método getItem , que toma el nombre de los datos; los datos recibidos del almacenamiento como una cadena JSON se convierten en una cadena normal (en nuestro caso): JSON.parse (localStorage.getItem ('pageName')).



En cuanto a la API de historial, utilizaremos dos métodos del objeto de historial proporcionado por la interfaz de historial : replaceState y pushState .



Ambos métodos toman dos parámetros obligatorios y uno opcional: un objeto de estado, título y ruta (URL): history.pushState (estado, título [, url]).



El objeto de estado se usa cuando se maneja el evento "popstate" que ocurre en el objeto de "ventana" cuando el usuario pasa a un nuevo estado (por ejemplo, cuando se presiona el botón Atrás de un panel de control del navegador) para representar la página anterior.



La URL se utiliza para personalizar la ruta que se muestra en la barra de direcciones del navegador.



Tenga en cuenta que gracias a la importación dinámica, solo cargamos una página al iniciar la aplicación: la página de inicio si el usuario visitó el sitio por primera vez o la página que vio por última vez. Puede verificar que solo se cargan los recursos que necesita examinando el contenido de la pestaña Red de las herramientas para desarrolladores.



Cree 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)
})()

      
      





Cambie el texto h1 en el marcado:



<h1>Loading...</h1>

      
      





Reiniciamos el servidor.







Excelente. Todo funciona como se esperaba.



Hasta ahora, solo nos hemos ocupado de contenido estático, pero ¿y si necesitamos renderizar páginas con contenido dinámico? ¿Es posible en este caso limitarse al cliente o esta tarea solo puede realizarla el servidor?



Supongamos que la página principal debe mostrar una lista de publicaciones. Cuando haces clic en una publicación, se debe renderizar la página con su contenido. La página de publicación también debe persistir en localStorage y procesarse después de recargar la página (cerrar / abrir la pestaña del navegador).



Creamos una base de datos local en forma de un módulo JS con nombre - 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(),
  },
]

      
      





Cree un generador de plantilla de publicación (también en forma de exportaciones con nombre: para importaciones dinámicas, las exportaciones con nombre son algo más convenientes que la predeterminada) - 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}`,
})

      
      





Creemos una función auxiliar para encontrar una publicación por su 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)
}

      
      





Hagamos cambios en 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',
}

      
      





Arreglemos un poco 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 el servidor.







La aplicación funciona, pero conviene que la estructura del código en su forma actual deja mucho que desear. Se puede mejorar, por ejemplo, introduciendo una clase adicional "Enrutador", que combinará el enrutamiento de páginas y publicaciones. Sin embargo, pasaremos por la programación funcional.



Creemos otra función 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
}

      
      





Cambiemos un poco src / templates / post.js, a saber: reemplace el atributo "id" de la etiqueta "article" con el atributo "data-url" con el valor "post # $ {id}":



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

      
      





La revisión final de src / script.js se ve así:



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 puede ver, la API de historial, junto con la importación dinámica, nos proporciona características bastante interesantes que facilitan enormemente el proceso de creación de aplicaciones de una sola página (SPA) casi sin participación del servidor.



Si no sabe por dónde empezar a desarrollar su aplicación, empiece con la plantilla de inicio HTML moderno .



Recientemente completé una pequeña investigación sobre patrones de diseño de JavaScript. Los resultados se pueden ver aquí .



Espero que hayas encontrado algo interesante para ti. Gracias por su atención.



All Articles