Роутинг и рендеринг страниц на стороне клиента с помощью History API и динамического импорта

от автора

Доброго времени суток, друзья!

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

Надеюсь, вы нашли для себя что-нибудь интересное. Благодарю за внимание.

ссылка на оригинал статьи https://habr.com/ru/post/530746/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *