[Разработка веб-сайтов, JavaScript, Программирование] Роутинг и рендеринг страниц на стороне клиента с помощью History API и динамического импорта
Автор
Сообщение
news_bot ®
Стаж: 6 лет 9 месяцев
Сообщений: 27286
Доброго времени суток, друзья!
В данной статье я хочу показать вам некоторые возможности современного JavaScript и интерфейсов, предоставляемых браузером, связанные с маршрутизацией и отрисовкой страниц без обращения к серверу.
Исходный код на GitHub.
Поиграть к кодом можно на CodeSandbox.
Прежде чем приступить к реализации приложения, хотелось бы отметить следующее:
- Мы реализуем один из самых простых вариантов клиентской маршрутизации и рендеринга, парочку более сложных и универсальных (если угодно, масштабируемых) способов можно найти здесь
- Обойтись совсем без сервера не получится. Мы будет манипулировать историей текущей сессии браузера: при ручной перезагрузке страницы браузер отдает предпочтение серверу, т.е. пытается получить несуществующую страницу, что приводит к печальным последствиям в виде невозможности установить соединение (мои попытки обмануть браузер с помощью сервис-воркера, т.е. проксировать отправляемые им запросы, не увенчались успехом). Единственной задачей нашего примитивного сервера будет ответ в виде index.html на любой запрос. Это позволит браузеру перейти к выполнению клиенского скрипта
- Везде, где это возможно и уместно, мы будет использовать динамический импорт. Он позволяет загружать только запрашиваемые ресурсы (раньше это можно было реализовать только посредством разделения кода на части (chunks) с помощью сборщиков модулей типа Webpack), что хорошо сказывается на производительности. Использование динамического импорта сделает почти весь наш код асинхронным, что, в целом, тоже неплохо, поскольку позволяет избежать блокировки потока выполнения программы
Итак, поехали.
Начнем с сервера.
Создаем директорию, переходим в нее и инициализируем проект:
mkdir client-side-rendering
cd !$
yarn init -yp
// или
npm init -y
Устанавливаем зависимости:
yarn add express nodemon open-cli
// или
npm i ...
- express — Node.js-фреймворк, значительно облегчающий создание сервера
- nodemon — инструмент для запуска и автоматической перезагрузки сервера
- open-cli — инструмент, позволяющий открыть вкладку браузера по адресу, на котором запущен сервер
Иногда (очень редко) open-cli открывает вкладку браузера быстрее, чем nodemon запускает сервер. В этом случае просто перезагрузите страницу.
Создаем index.js следующего содержания:
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}`)
})
Создаем index.html (для основной стилизации приложения будет использоваться Bootstrap):
<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>
Для дополнительной стилизации создаем 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;
}
Добавляем команду для запуска сервера и открытия вкладки браузера в package.json:
"scripts": {
"dev": "open-cli http://localhost:1234 && nodemon index.js"
}
Выполняем данную команду:
yarn dev
// или
npm run dev
Двигаемся дальше.
Создаем директорию src/pages с тремя файлами: home.js, project.js и about.js. Каждая страница представляет собой экспортируемый по умолчанию объект со свойствами «content» и «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',
}
Переходим к основному скрипту.
В нем мы будем использовать локальное хранилище для сохранения и последующего (после возвращения пользователя на сайт) получения текущей страницы и History API для управления историей браузера.
Что касается хранилища, то для записи данных используется метод setItem, принимающий два параметра: название сохраняемых данных и сами данные, преобразованные в JSON-строку — localStorage.setItem('pageName', JSON.stringify(url)).
Для получения данных используется метод getItem, принимающий название данных; данные, полученные из хранилища в виде JSON-строки, преобразуются в обычную строку (в нашем случае): JSON.parse(localStorage.getItem('pageName')).
Что касается History API, то мы будет использовать два метода объекта history, предоставляемого интерфейсом History: replaceState и pushState.
Оба метода принимают два обязательных и один опциональный параметр: объект состояния, заголовок и путь (URL-адрес) — history.pushState(state, title[, url]).
Объект состояния используется при обработке события «popstate», возникающего на объекте «window» при переходе пользователя к новому состоянию (например, при нажатии кнопки «Назад» панели управления браузера), для рендерига предыдущей страницы.
URL-адрес используется для кастомизации пути, отображаемого в адресной строке браузера.
Обратите внимание, что благодаря динамическому импорту при запуске приложения мы загружаем только одну страницу: либо домашнюю, если пользователь зашел на сайт впервые, либо ту страницу, которую он просматривал последней. Убедиться в загрузке только необходимых ресурсов можно, проанализировав содержимое вкладки «Network» (Сеть) инструментов разработчика.
Создаем 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)
})()
Изменяем текст h1 в разметке:
<h1>Loading...</h1>
Перезапускаем сервер.
Отлично. Все работает, как ожидается.
До сих пор мы имели дело только со статическим контентом, но что если нам необходимо рендерить страницы с динамическим содержимым? Можно ли в этом случае ограничиться клиентом или данная задача под силу только серверу?
Предположим, что на главной странице должен отображаться список постов. При клике по посту должна рендериться страница с его контентом. Страница поста также должна сохраняться в localStorage и отрисовываться после перезагрузки страницы (закрытия/открытия вкладки браузера).
Создаем локальную базу данных в форме именованного JS-модуля — 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(),
},
]
Создаем генератор шаблона поста (также в форме именованного экспорта: при динамическом импорте именованный экспорт несколько удобнее дефолтного) — 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}`,
})
Создаем вспомогательную функцию для поиска поста по идентификатору — 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)
}
Внесем изменения в 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',
}
Немного поправим 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)
})()
Перезапускаем сервер.
Приложение работает, но, согласитесь, что структура кода в нынешнем виде оставляет желать лучшего. Ее можно усовершенствовать, например, введением дополнительного класса «Router», который объединит в себе маршрутизацию страниц и постов. Однако, мы пойдем путем функционального программирования.
Создаем еще одну вспомогательную функцию — 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
}
Немного изменим src/templates/post.js, а именно: атрибут «id» тега «article» заменим на атрибут «data-url» со значением «post#${id}»:
<article data-url="post#${id}">
Окончательная редакция src/script.js выглядит следующим образом:
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)
})()
Как видите, History API в совокупности с динамическим импортом предоставляют в наше распоряжение довольно интересные возможности, значительно облегчающие процесс создания одностраничных приложений (SPA) почти без участия сервера.
Не знаете с чего начать разработку приложения, тогда начните с Современного стартового HTML-шаблона.
На днях завершил небольшое исследование, посвященное паттернам проектирования JavaScript. Результаты можно посмотреть здесь.
Надеюсь, вы нашли для себя что-нибудь интересное. Благодарю за внимание.
===========
Источник:
habr.com
===========
Похожие новости:
- [Управление разработкой, Управление персоналом] Как проводить собеседования разработчиков
- [Программирование, Java, Анализ и проектирование систем] Микросервисная авторизация для чайников для чайников
- [Настройка Linux, Сетевые технологии, Программирование микроконтроллеров] Делаем из ENC28J60 внешнюю USB сетевую карту
- [Программирование, ERP-системы, Управление разработкой, Управление персоналом, Читальный зал] Пузырь, соломинка и лапоть. Что происходит с программистами 1С
- [Производство и разработка электроники, Процессоры, IT-компании] «Байкал электроникс» представила три новых процессора — «Байкал-М/2», «Байкал-М/2+» и «Байкал-S», они выйдут в 2021 году
- [Разработка веб-сайтов, JavaScript] WebStorm 2020.3: обновленный интерфейс, поддержка Tailwind CSS и другие улучшения
- [JavaScript, ReactJS] Все ли вы знаете о useCallback
- [Программирование, Карьера в IT-индустрии] One way ticket, или как переехать в другую страну по работе: истории разработчиков
- [C++, Разработка робототехники, Программирование микроконтроллеров, DIY или Сделай сам] ESP32 в окружении VSCode
- [Программирование, Java] Ещё больше строковых оптимизаций
Теги для поиска: #_razrabotka_vebsajtov (Разработка веб-сайтов), #_javascript, #_programmirovanie (Программирование), #_javascript, #_programmirovanie (программирование), #_razrabotka (разработка), #_routing, #_rendering, #_clientside, #_routing (роутинг), #_rendering (рендеринг), #_marshrutizatsija (маршрутизация), #_otrisovka (отрисовка), #_history, #_history_api, #_istorija (история), #_import, #_import (импорт), #_razrabotka_vebsajtov (
Разработка веб-сайтов
), #_javascript, #_programmirovanie (
Программирование
)
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 22-Ноя 11:26
Часовой пояс: UTC + 5
Автор | Сообщение |
---|---|
news_bot ®
Стаж: 6 лет 9 месяцев |
|
Доброго времени суток, друзья! В данной статье я хочу показать вам некоторые возможности современного JavaScript и интерфейсов, предоставляемых браузером, связанные с маршрутизацией и отрисовкой страниц без обращения к серверу. Исходный код на GitHub. Поиграть к кодом можно на CodeSandbox. Прежде чем приступить к реализации приложения, хотелось бы отметить следующее:
Итак, поехали. Начнем с сервера. Создаем директорию, переходим в нее и инициализируем проект: mkdir client-side-rendering
cd !$ yarn init -yp // или npm init -y Устанавливаем зависимости: yarn add express nodemon open-cli
// или npm i ...
Иногда (очень редко) open-cli открывает вкладку браузера быстрее, чем nodemon запускает сервер. В этом случае просто перезагрузите страницу. Создаем index.js следующего содержания: 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}`) }) Создаем index.html (для основной стилизации приложения будет использоваться Bootstrap): <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> Для дополнительной стилизации создаем 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; } Добавляем команду для запуска сервера и открытия вкладки браузера в package.json: "scripts": {
"dev": "open-cli http://localhost:1234 && nodemon index.js" } Выполняем данную команду: yarn dev
// или npm run dev Двигаемся дальше. Создаем директорию src/pages с тремя файлами: home.js, project.js и about.js. Каждая страница представляет собой экспортируемый по умолчанию объект со свойствами «content» и «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', } Переходим к основному скрипту. В нем мы будем использовать локальное хранилище для сохранения и последующего (после возвращения пользователя на сайт) получения текущей страницы и History API для управления историей браузера. Что касается хранилища, то для записи данных используется метод setItem, принимающий два параметра: название сохраняемых данных и сами данные, преобразованные в JSON-строку — localStorage.setItem('pageName', JSON.stringify(url)). Для получения данных используется метод getItem, принимающий название данных; данные, полученные из хранилища в виде JSON-строки, преобразуются в обычную строку (в нашем случае): JSON.parse(localStorage.getItem('pageName')). Что касается History API, то мы будет использовать два метода объекта history, предоставляемого интерфейсом History: replaceState и pushState. Оба метода принимают два обязательных и один опциональный параметр: объект состояния, заголовок и путь (URL-адрес) — history.pushState(state, title[, url]). Объект состояния используется при обработке события «popstate», возникающего на объекте «window» при переходе пользователя к новому состоянию (например, при нажатии кнопки «Назад» панели управления браузера), для рендерига предыдущей страницы. URL-адрес используется для кастомизации пути, отображаемого в адресной строке браузера. Обратите внимание, что благодаря динамическому импорту при запуске приложения мы загружаем только одну страницу: либо домашнюю, если пользователь зашел на сайт впервые, либо ту страницу, которую он просматривал последней. Убедиться в загрузке только необходимых ресурсов можно, проанализировав содержимое вкладки «Network» (Сеть) инструментов разработчика. Создаем 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) })() Изменяем текст h1 в разметке: <h1>Loading...</h1>
Перезапускаем сервер. Отлично. Все работает, как ожидается. До сих пор мы имели дело только со статическим контентом, но что если нам необходимо рендерить страницы с динамическим содержимым? Можно ли в этом случае ограничиться клиентом или данная задача под силу только серверу? Предположим, что на главной странице должен отображаться список постов. При клике по посту должна рендериться страница с его контентом. Страница поста также должна сохраняться в localStorage и отрисовываться после перезагрузки страницы (закрытия/открытия вкладки браузера). Создаем локальную базу данных в форме именованного JS-модуля — 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(), }, ] Создаем генератор шаблона поста (также в форме именованного экспорта: при динамическом импорте именованный экспорт несколько удобнее дефолтного) — 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}`, }) Создаем вспомогательную функцию для поиска поста по идентификатору — 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) } Внесем изменения в 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', } Немного поправим 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) })() Перезапускаем сервер. Приложение работает, но, согласитесь, что структура кода в нынешнем виде оставляет желать лучшего. Ее можно усовершенствовать, например, введением дополнительного класса «Router», который объединит в себе маршрутизацию страниц и постов. Однако, мы пойдем путем функционального программирования. Создаем еще одну вспомогательную функцию — 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 } Немного изменим src/templates/post.js, а именно: атрибут «id» тега «article» заменим на атрибут «data-url» со значением «post#${id}»: <article data-url="post#${id}">
Окончательная редакция src/script.js выглядит следующим образом: 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) })() Как видите, History API в совокупности с динамическим импортом предоставляют в наше распоряжение довольно интересные возможности, значительно облегчающие процесс создания одностраничных приложений (SPA) почти без участия сервера. Не знаете с чего начать разработку приложения, тогда начните с Современного стартового HTML-шаблона. На днях завершил небольшое исследование, посвященное паттернам проектирования JavaScript. Результаты можно посмотреть здесь. Надеюсь, вы нашли для себя что-нибудь интересное. Благодарю за внимание. =========== Источник: habr.com =========== Похожие новости:
Разработка веб-сайтов ), #_javascript, #_programmirovanie ( Программирование ) |
|
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 22-Ноя 11:26
Часовой пояс: UTC + 5