使用Vanilla.js构建单页应用程序(SPA)网站

目录

项目

带有模块的组织代码

以可观察的方式应对变化

支持声明式数据绑定

将幻灯片(Slides)托管和加载为“页面”

使用路由器处理导航

带有CSS3动画的转换时间线

管理“Deck”的导航器

键盘支持

点击控件

结论


现代JavaScript具有无需依赖框架即可构建完整的单页应用(SPA)体验的所有必要功能。了解如何使用模块和Web组件等最新语言功能来处理模板、动画、路由和数据绑定。

存在现代JavaScript框架来解决HTML5JavaScriptCSSWebAssembly提供的开箱即用功能的缺陷。与早期版本相比,JavaScript的最新稳定版本(ECMAScript®2015)进行了重大改进,可以更好地控制范围,强大的字符串处理功能、解构、参数增强以及类和模块的内置实现(不再存在需要使用IIFE或立即调用的函数表达式)。这篇文章的目的是探讨如何使用最新的JavaScript功能构建现代应用程序。

项目

我实现了一个完全基于纯JavaScriptVanilla.js)的单页应用程序(SPA)应用程序。它包括路由(您可以为页面加书签和导航)、数据绑定、可重用的Web组件,并使用JavaScript的本地模块功能。您可以在此处运行和安装该应用程序(它是渐进式Web应用程序或PWA):

源代码存储库位于此处:

如果打开index.html,您会注意到脚本包含了特殊类型的模块

<script type="module" src="./js/app.js"></script>

该模块仅从其他几个模块导入并激活Web组件。

带有模块的组织代码

本地JavaScript模块类似于普通的JavaScript文件,但有一些关键区别。它们应加载type="module"修饰符。一些开发人员更喜欢使用.mjs后缀将它们与其他JavaScript来源区分开,但这不是必需的。模块在以下几种方面是唯一的:

  • 默认情况下,它们以严格模式进行解析和执行
  • 模块可以提供导出以供其他模块使用
  • 模块可以从子模块导入变量、函数和对象
  • 模块在自己的作用域内运行,不必包装在立即调用的函数表达式中

模块的生命周期包括四个步骤:

  1. 首先,对模块进行解析和验证。
  2. 其次,加载模块。
  3. 第三,相关模块根据其进出口进行链接。
  4. 最后,执行模块。

未包装在函数中的所有代码将在步骤4中立即执行。

这是父级app.js模块的样子:

import { registerDeck } from "./navigator.js"
import { registerControls } from "./controls.js"
import { registerKeyHandler } from "./keyhandler.js"

const app = async () => {
    registerDeck();
    registerControls();
    registerKeyHandler();
};

document.addEventListener("DOMContentLoaded", app);

退后一步,应用程序的整体结构或层次结构如下所示:

app.js 
-- navigator.js 
   -- slideLoader.js
      .. slide.js ⤵
   -- slide.js
      -- dataBinding.js
         -- observable.js
   -- router.js
   -- animator.js
-- controls.js
   .. navigator.js ⤴
-- keyhandler.js
   .. navigator.js ⤴

这篇文章将从下而上探索模块,从没有依赖性的模块开始,然后逐步发展到navigator.js Web组件。

以可观察的方式应对变化

observable.js模块包含一个简单实现的观察者模式。一个类包装一个值,并在值更改时通知订阅者。计算的可观察值可用,可以处理从其他可观察值派生的值(例如,正在观察变量的方程式的结果)。

简单了解一下数据绑定如何与纯JavaScript实现一起工作。

支持声明式数据绑定

databinding.js模块提供给应用程序数据绑定服务。有一对方法executeexecuteInContext,它们用于评估具有指定this的脚本。本质上,每个幻灯片(slide)”都有一个上下文,该上下文用于设置数据绑定表达式,并且幻灯片中包含的脚本在该上下文中运行。上下文在幻灯片(slide)”类中定义,稍后将进行探讨。

重要的是要注意这不提供安全性:恶意脚本仍可以执行;它仅用于提供数据绑定范围。为生产而构建将需要更多的过程来解析仅可接受的表达式,以避免安全漏洞。

observablecomputed方法只是助手来创建相关类的新实例。在幻灯片中使用它们来设置数据绑定表达式。这做起来比说起来容易,因此我将在不久之后提供一个端到端示例。

bindValue方法在实例HTMLInputElementObservable实例之间建立双向数据绑定。在此示例中,只要输入值发生更改,它将使用onkeyup事件发出信号。转换器有助于处理绑定到number类型的特殊情况。

bindValue(input, observable) {
   const initialValue = observable.value;
   input.value = initialValue;
   observable.subscribe(() => input.value = observable.value);
   let converter = value => value;
   if (typeof initialValue === "number") {
      converter = num => isNaN(num = parseFloat(num)) ? 0 : num;
   }
   input.onkeyup = () => {
      observable.value = converter(input.value);
   };
}

从找到具有data-bind属性的任何元素的bindObservables方法中调用它。再次注意,此代码已简化,因为它假定元素是输入元素,并且不进行任何验证。

bindObservables(elem, context) {
   const dataBinding = elem.querySelectorAll("[data-bind]");
   dataBinding.forEach(elem => {
      this.bindValue(elem, 
         context[elem.getAttribute("data-bind")]);
   });
}

bindLists方法稍微复杂一些。假定它将迭代(不可观察的)列表。首先,找到具有repeat属性的任何元素。该值假定为列表引用,并进行迭代以生成子元素列表。正则表达式用于使用executeInContext将绑定语句{{item.x}}替换为实际值。

在这个阶段,退后一步,看到更大的图景是有意义的。您可以在此处运行数据绑定示例。

HTML中,n1的数据绑定声明如下:

<label for="first">
   <div>Number:</div>
   <input type="text" id="first" data-bind="n1"/>
</label>

script标记中,其设置如下:

const n1 = this.observable(2);
this.n1 = n1;

上下文存在于幻灯片上:slide.ctx = {}因此,在评估脚本时,它变为slide.ctx = { n1: Observable(2) }。然后在输入字段和可观察对象之间建立绑定。对于列表,将基于数据绑定模板评估每个列表项以获取相应的值。这里缺少的是幻灯片上存在的上下文。接下来让我们看看slidesideLoader模块。

将幻灯片(Slides)托管和加载为页面

slide.js中的Slide类是一个简单的类,其用来保存表示在应用了幻灯片(slide)”的信息。它具有从实际幻灯片中读取的_text属性。例如,这是001-title.html的原始文本。

<title>Vanilla.js: Modern 1st Party JavaScript</title>
<h1>Vanilla.js: Modern 1st Party JavaScript</h1>
<img src="images/vanillin.png" class="anim-spin" alt="Vanillin molecule" 

     title="Vanillin molecule"/>
<h2>Jeremy Likness</h2>
<h3>Cloud Advocate, Microsoft</h3>
<next-slide>020-angular-project</next-slide>
<transition>slide-left</transition>

_context用于执行脚本(只是传递this给评估的一个空对象),_title是从幻灯片内容中解析出来的,而_dataBinding属性则包含该幻灯片的数据绑定帮助程序的实例。如果指定了转换,则在_transition中保留转换的名称,如果有下一张幻灯片,则在_nextSlideName中保留其名称。

最重要的属性是_html属性。这是包装幻灯片内容的div元素。将幻灯片内容分配给该innerHTML属性,以创建一个活动的DOM节点,可以在浏览幻灯片时轻松地将其交换进出。构造函数中的以下代码设置了HTML DOM

this._html = document.createElement('div');
this._html.innerHTML = text;

如果幻灯片中有<script>标签,则会在幻灯片的上下文中对其进行解析。调用数据绑定帮助器以解析所有属性并呈现关联的列表,并在输入元素和可观察数据之间创建双向绑定。

const script = this._html.querySelector("script");
if (script) {
   this._dataBinding.executeInContext(script.innerText, this._context, true);
   this._dataBinding.bindAll(this._html, this._context);
}

这将幻灯片设置为“天生就绪”模式,等待显示。slideLoader.js模块就是加载的幻灯片。它假设它们存在于后缀为.htmlslides子目录中。这段代码读取幻灯片并创建Slide类的新实例。

async function loadSlide(slideName) {
    const response = await fetch(`./slides/${slideName}.html`);
    const slide = await response.text();
    return new Slide(slide);
}

main函数获取第一张幻灯片,然后通过读取该nextSlide属性来迭代所有幻灯片。为了避免陷入无限循环,cycle对象会跟踪已加载的幻灯片,并在有重复的幻灯片或没有更多要解析的幻灯片时停止加载。

export async function loadSlides(start) {
    var next = start;
    const slides = [];
    const cycle = {};
    while (next) {
        if (!cycle[next]) {
            cycle[next] = true;
            const nextSlide = await loadSlide(next);
            slides.push(nextSlide);
            next = nextSlide.nextSlide;
        }
        else {
            break;
        }
    }
    return slides;
}

该加载程序由navigator.js模块使用,稍后将进行探讨。

使用路由器处理导航

router.js模块负责处理路由。它具有两个主要功能:

  1. 将路由(哈希)设置为与当前幻灯片相对应
  2. 通过引发自定义事件来响应导航,以通知订阅者其路由已更改

构造函数使用虚拟DOM节点(从未渲染的div元素)来设置自定义routechanged事件。

this._eventSource = document.createElement("div");
this._routeChanged = new CustomEvent("routechanged", {
   bubbles: true,
   cancelable: false
});
this._route = null;

然后,它侦听浏览器导航(popstate事件),并且如果路由(幻灯片)已更改,它将更新路由并引发自定义routechanged事件。

window.addEventListener("popstate", () => {
   if (this.getRoute() !== this._route) {
         this._route = this.getRoute();
         this._eventSource.dispatchEvent(this._routeChanged);
   }
});

其他模块使用路由器在更改幻灯片时设置路由,或在更改路由时显示正确的幻灯片(即,用户导航到书签或使用前进/后退按钮)。

带有CSS3动画的转换时间线

animator.js模块用于幻灯片之间句柄转换。通过在幻灯片中设置next-slide元素来指示转换。按照惯例,一个转换将存在两个动画:anim-{transition}-begin设置当前幻灯片的动画,然后anim-{transition}-end设置下一个幻灯片的动画。对于左幻灯片,当前幻灯片以零偏移开始,并向左移动,直到屏幕外。然后,新幻灯片从屏幕外偏移开始,然后向左移动,直到完全显示在屏幕上。视图宽度的一个称为vw的特殊单元用于确保转换在任何屏幕大小上工作。

这套动画的CSS如下所示:

@keyframes slide-left {
    from {
        margin-left: 0vw;
    }
    to {
        margin-left: -100vw;
    }
}

@keyframes enter-right {
    from {
        margin-left: 100vw;
    }
    to {
        margin-left: 0vw;
    }
}

.anim-slide-left-begin {
    animation-name: slide-left;
    animation-timing-function: ease-in;
    animation-duration: 0.5s;
}

.anim-slide-left-end {
    animation-name: enter-right;
    animation-timing-function: ease-out;
    animation-duration: 0.3s;
}

该模块通过执行以下操作来管理转换:

  1.  使用动画名称和回调来调用beginAnimation
  2. _begin_end类设置为跟踪它们。
  3. 设置标志以指示正在进行转换。这样可以防止在现有转换事件期间进行其他导航。
  4. 事件侦听器附加到HTML元素,当关联的动画结束时将触发该事件。
  5. 动画begin类已添加到元素。这将触发动画。
  6. 动画结束时,将删除事件侦听器,关闭转换标志,并从元素中删除begin类。回调被触发。
beginAnimation(animationName, host, callback) {
   this._transitioning = true;
   this._begin = `anim-${animationName}-begin`;
   this._end = `anim-${animationName}-end`;
   const animationEnd = () => {
      host.removeEventListener("animationend", animationEnd);
      host.classList.remove(this._begin);
      this._transitioning = false;
      callback();
   }
   host.addEventListener("animationend", animationEnd, false);
   host.classList.add(this._begin);
}

回调将通知主机转换已完成。在这种情况下,navigator.js将传递一个回调。回调使幻灯片前进,然后调用endAnimation。该代码类似于开始动画,但它会在完成后重置所有属性。

endAnimation(host) {
   this._transitioning = true;
   const animationEnd = () => {
      host.removeEventListener("animationend", animationEnd);
      host.classList.remove(this._end);
      this._transitioning = false;
      this._begin = null;
      this._end = null;
   }
   host.addEventListener("animationend", animationEnd, false);
   host.classList.add(this._end);
}

当您看到接下来介绍的导航器模块如何处理代码时,这些步骤将更加清晰。

管理“Deck”的导航器

navigator.js主模块,其控制甲板。它负责显示幻灯片并处理幻灯片之间的移动。这是我们将检查的第一个模块,将其作为可重复使用的Web组件公开。因为它是一个Web组件,所以类定义扩展了HTMLElement

export class Navigator extends HTMLElement { }

该模块提供了注册Web组件的registerDeck函数。我选择创建一个新的HTML元素<slide-deck/>,因此其注册方式如下:

export const registerDeck = () => 
    customElements.define('slide-deck', Navigator);

构造函数调用浏览器中内置的父构造函数来初始化HTML元素。然后,它创建路由器和动画的实例并获取当前路由。它公开一个自定义slideschanged事件,然后侦听路由器的routetchanged事件,并在触发它时前进到适当的幻灯片。

super();
this._animator = new Animator();
this._router = new Router();
this._route = this._router.getRoute();
this.slidesChangedEvent = new CustomEvent("slideschanged", {
   bubbles: true,
   cancelable: false
});
this._router.eventSource.addEventListener("routechanged", () => {
   if (this._route !== this._router.getRoute()) {
         this._route = this._router.getRoute();
         if (this._route) {
            const slide = parseInt(this._route) - 1;
            this.jumpTo(slide);
         }
   }
});

要加载幻灯片,需要定义一个自定义start属性。主index.html按如下方式设置Web组件:

<slide-deck id="main" start="001-title">
   <h1>DevNexus | Vanilla.js: Modern 1st Party JavaScript</h1>
   <h2>Setting things up ...</h2>
</slide-deck>

请注意,该元素具有与其他HTMLElement元素一样的innerHTML元素,因此将呈现HTML,直到替换它为止。解析属性需要两个步骤。首先,必须遵守属性。按照惯例,这是通过static属性observedAttributes 来完成的:

static get observedAttributes() {
   return ["start"];
}

接下来,实现一个回调,只要属性发生更改(包括首次解析和设置属性),就将调用该回调。此回调用于获取start属性值并加载幻灯片,然后根据是否通过路由调用来显示适当的幻灯片。

async attributeChangedCallback(attrName, oldVal, newVal) {
   if (attrName === "start") {
      if (oldVal !== newVal) {
            this._slides = await loadSlides(newVal);
            this._route = this._router.getRoute();
            var slide = 0;
            if (this._route) {
               slide = parseInt(this._route) - 1;
            }
            this.jumpTo(slide);
            this._title = document.querySelectorAll("title")[0];
      }
   }
}

其余属性和方法处理当前幻灯片、幻灯片总数和导航。例如,hasPrevious将返回true除第一张幻灯片。hasNext涉及更多。对于诸如显示卡片或一次列出一项的事情,可以应用名为appear的类。它隐藏了元素,但是当幻灯片是高级的并且该类中存在一个元素时,它将被删除。这导致该元素出现。该检查首先检查类是否存在于任何元素上,然后检查索引是否在最后一张幻灯片上。

get hasNext() {
   const host = this.querySelector("div");
   if (host) {
      const appear = host.querySelectorAll(".appear");
      if (appear && appear.length) {
            return true;
      }
   }
   return this._currentIndex < (this.totalSlides - 1);
}

jumpTo方法导航到新幻灯片。如果正在进行转换,它将忽略该请求。否则,它将清除父容器的内容并附加新的幻灯片。它更新页面标题并引发slideschanged事件。如果跳转发生在转换的结尾,则它将开始结束动画。

jumpTo(slideIdx) {
   if (this._animator.transitioning) {
      return;
   }
   if (slideIdx >= 0 && slideIdx < this.totalSlides) {
      this._currentIndex = slideIdx;
      this.innerHTML = '';
      this.appendChild(this.currentSlide.html);
      this._router.setRoute((slideIdx + 1).toString());
      this._route = this._router.getRoute();
      document.title = `${this.currentIndex + 1}/${this.totalSlides}: 
                        ${this.currentSlide.title}`;
      this.dispatchEvent(this.slidesChangedEvent);
      if (this._animator.animationReady) {
            this._animator.endAnimation(this.querySelector("div"));
      }
   }
}

next函数负责从一张幻灯片到下一张幻灯片的普通流程。如果appear类中有一个元素,它将简单地删除该类使其显示。否则,它将检查是否有后续幻灯片。如果幻灯片具有动画,则它将在开始动画时以回调形式启动,并在动画完成时跳至下一张幻灯片(该跳转将运行结束动画)。如果没有转换,它将直接跳到幻灯片。

next() {
   if (this.checkForAppears()) {
      this.dispatchEvent(this.slidesChangedEvent);
      return;
   }
   if (this.hasNext) {
      if (this.currentSlide.transition !== null) {
            this._animator.beginAnimation(
               this.currentSlide.transition,
               this.querySelector("div"),
               () => this.jumpTo(this.currentIndex + 1));
      }
      else {
            this.jumpTo(this.currentIndex + 1);
      }
   }
}

Web组件托管幻灯片平台。还有两个与之配合使用的组件可以控制幻灯片:用于键盘导航的按键处理程序,以及可以单击或点击的一组控件。

键盘支持

keyhandler.js模块是被定义为<key-handler/>的另一Web组件。

export const registerKeyHandler = 
    () => customElements.define('key-handler', KeyHandler);

在主页上:

<key-handler deck="main"></key-handler>

它有一个属性名为deck,其指向一个navigator.js实例的id。设置后,它将保存对deck的引用。然后,它会侦听向右箭头(代码39)或空格键(代码32)以推进deck,或侦听向左箭头(代码37)以移至上一张幻灯片。

async attributeChangedCallback(attrName, oldVal, newVal) {
   if (attrName === "deck") {
      if (oldVal !== newVal) {
            this._deck = document.getElementById(newVal);
            this._deck.parentElement.addEventListener("keydown", key => {
               if (key.keyCode == 39 || key.keyCode == 32) {
                  this._deck.next();
               }
               else if (key.keyCode == 37) {
                  this._deck.previous();
               }
            });
      }
   }
}

该代码是有意简化的。它假定id正确设置了,并且不检查是否找到了元素,并且该元素是<slide-deck/>的实例。它也可能从输入框内部触发,这不是理想的用户体验。

点击控件

最后一个模块,也是一个Web组件,是平台的控件。这被注册为<slide-controls/>

export const registerControls = 
    () => customElements.define('slide-controls', Controls);

这是主页声明:

<slide-controls deck="main" class="footer center">
---
</slide-controls>

通过插入Web组件生命周期方法connectedCallback,该模块将在父元素插入DOM后动态加载控件模板,并插入事件侦听器。

async connectedCallback() {
   const response = await fetch("./templates/controls.html");
   const template = await response.text();
   this.innerHTML = "";
   const host = document.createElement("div");
   host.innerHTML = template;
   this.appendChild(host);
   this._controlRef = {
      first: document.getElementById("ctrlFirst"),
      prev: document.getElementById("ctrlPrevious"),
      next: document.getElementById("ctrlNext"),
      last: document.getElementById("ctrlLast"),
      pos: document.getElementById("position")
   };
   this._controlRef.first.addEventListener("click", 
       () => this._deck.jumpTo(0));
   this._controlRef.prev.addEventListener("click", 
       () => this._deck.previous());
   this._controlRef.next.addEventListener("click", 
       () => this._deck.next());
   this._controlRef.last.addEventListener("click", 
       () => this._deck.jumpTo(this._deck.totalSlides - 1));
   this.refreshState();
}

请注意,这些按钮只是调用了navigator.js模块公开的现有方法。设置deck属性后,将引用该模块。该代码保存引用并侦听slideschanged事件。

async attributeChangedCallback(attrName, oldVal, newVal) {
   if (attrName === "deck") {
      if (oldVal !== newVal) {
            this._deck = document.getElementById(newVal);
            this._deck.addEventListener("slideschanged", 
                () => this.refreshState());
      }
   }
}

最后,在初始化时以及幻灯片更改时都会调用refreshState。它根据正在显示的幻灯片确定要启用或禁用的按钮,并且还更新yx文本。

refreshState() {
   if (this._controlRef == null) {
      return;
   }
   const next = this._deck.hasNext;
   const prev = this._deck.hasPrevious;
   this._controlRef.first.disabled = !prev;
   this._controlRef.prev.disabled = !prev;
   this._controlRef.next.disabled = !next;
   this._controlRef.last.disabled = 
       this._deck.currentIndex === (this._deck.totalSlides - 1);
   this._controlRef.pos.innerText = 
       `${this._deck.currentIndex + 1} / ${this._deck.totalSlides}`;
}

因为该控件是一个Web组件,所以如果需要,可以轻松地将第二个实例放置在页面顶部,以提供更多的导航选项。

结论

该项目的目的是展示纯现代JavaScript可能实现的功能。框架仍然占有一席之地,但重要的是要了解使用本地功能编写可移植和可维护的代码的可能性(例如,类是任何框架中的类)。精通JavaScript可以使您更轻松地解决问题并提供更好的功能理解(例如,了解如何实现数据绑定可以增进您对如何在框架中使用它的理解)。

发布了69 篇原创文章 · 获赞 146 · 访问量 49万+

猜你喜欢

转载自blog.csdn.net/mzl87/article/details/104734498