[Разработка веб-сайтов, JavaScript, Программирование] Роутинг и рендеринг страниц на стороне клиента с помощью History API и динамического импорта

Автор Сообщение
news_bot ®

Стаж: 6 лет 9 месяцев
Сообщений: 27286

Создавать темы news_bot ® написал(а)
01-Дек-2020 15:31


Доброго времени суток, друзья!
В данной статье я хочу показать вам некоторые возможности современного 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
===========

Похожие новости: Теги для поиска: #_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-Ноя 17:23
Часовой пояс: UTC + 5