
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.