التوجيه من جانب العميل وعرض الصفحات باستخدام History API والواردات الديناميكية





يوم جيد ، أيها الأصدقاء!



في هذه المقالة ، أود أن أعرض لكم بعض إمكانيات JavaScript الحديثة والواجهات التي يوفرها المتصفح المتعلقة بتوجيه الصفحات وعرضها دون الاتصال بالخادم.



كود المصدر على جيثب .



يمكنك اللعب بالرمز في CodeSandbox .



قبل الشروع في تنفيذ التطبيق أود أن أشير إلى ما يلي:



  • سنقوم بتنفيذ أحد أبسط خيارات التوجيه والعرض للعميل ، ويمكن العثور على طريقتين أكثر تعقيدًا وتنوعًا (قابلة للتطوير ، إذا صح التعبير) هنا
  • . : , .. , ( -, .. , ). index.html .
  • حيثما كان ذلك ممكنًا ومناسبًا ، سنستخدم عمليات الاستيراد الديناميكية. يسمح لك بتحميل الموارد المطلوبة فقط (في السابق كان يمكن القيام بذلك فقط عن طريق تقسيم الكود إلى أجزاء (أجزاء) باستخدام منشئي الوحدات مثل 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. كل صفحة عبارة عن كائن افتراضي تم تصديره بخصائص "المحتوى" و "عنوان URL".



home.js:



export default {
  content: `<h1>Welcome to the Home Page</h1>`,
  url: 'home'
}

      
      





مشروع. 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 ('اسم الصفحة')).



بالنسبة إلى واجهة برمجة التطبيقات للتاريخ ، سنستخدم طريقتين لكائن السجل الذي توفره واجهة History : replaceState و pushState .



تأخذ كلتا الطريقتين معلمتين مطلوبتين وواحدة اختيارية: كائن الحالة والعنوان والمسار (URL) - history.pushState (الحالة ، العنوان [، url]).



يتم استخدام كائن الحالة عند التعامل مع حدث "popstate" الذي يحدث على كائن "window" عندما ينتقل المستخدم إلى حالة جديدة (على سبيل المثال ، عند الضغط على زر الرجوع في لوحة تحكم المتصفح) لعرض الصفحة السابقة.



يتم استخدام عنوان URL لتخصيص المسار المعروض في شريط عنوان المتصفح.



يرجى ملاحظة أنه بفضل الاستيراد الديناميكي ، نقوم بتحميل صفحة واحدة فقط عند بدء تشغيل التطبيق: إما الصفحة الرئيسية ، إذا كان المستخدم قد زار الموقع لأول مرة ، أو الصفحة التي شاهدها آخر مرة. يمكنك التحقق من تحميل الموارد التي تحتاجها فقط عن طريق فحص محتويات علامة التبويب "الشبكة" الخاصة بأدوات المطور.



إنشاء 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 / قوالب / 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)
})()

      
      





نعيد تشغيل الخادم.







يعمل التطبيق ، لكن توافق على أن بنية الكود في شكله الحالي تترك الكثير مما هو مرغوب فيه. يمكن تحسينه ، على سبيل المثال ، من خلال تقديم فئة إضافية "جهاز توجيه" ، والتي ستجمع بين توجيه الصفحات والمشاركات. ومع ذلك ، سوف نمر بالبرمجة الوظيفية.



لنقم بإنشاء وظيفة مساعدة أخرى - 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 / template / 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. يمكن الاطلاع على النتائج هنا .



أتمنى أن تجد شيئًا ممتعًا لنفسك. شكرآ لك على أهتمامك.



All Articles