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

2025-05-25

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

现代 JavaScript 框架的存在是为了弥补 HTML5、JavaScript、CSS 和 WebAssembly 提供的开箱即用功能的不足。JavaScript 的最新稳定版本(ECMAScript® 2015 修正版:ECMAScript® 2019)与早期版本相比有了显著的改进,包括对作用域的更佳控制、强大的字符串操作功能、解构、参数增强以及类和模块的内置实现(不再需要使用 IIFE 或立即调用的函数表达式)。本文旨在探讨如何使用最新的 JavaScript 功能构建现代应用程序。

香兰素分子

项目

我实现了一个完全基于纯 JavaScript(“Vanilla.js”)的单页应用(SPA)。它包含路由(可以添加书签和导航页面)、数据绑定、可复用的 Web 组件,并使用 JavaScript 的原生模块功能。您可以在此处运行并安装该应用(它是一个渐进式 Web 应用,简称 PWA):

https://jlik.me/vanilla-js

源代码存储库位于此处:

https://github.com/jeremylikness/vanillajs-deck

如果你打开,index.html你会注意到一个脚本包含一种特殊类型的“模块”:

<script type="module" src="./js/app.js"></script>
Enter fullscreen mode Exit fullscreen mode

该模块只是从其他几个模块导入并激活 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);

Enter fullscreen mode Exit fullscreen mode

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

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 ⤴
Enter fullscreen mode Exit fullscreen mode

这篇文章将从下往上探索模块,从没有依赖关系的模块开始,一直到navigator.jsWeb 组件。

使用 Observable 来响应变化

observable.js模块包含观察者模式的简单实现。一个类包装一个值,并在值发生变化时通知订阅者。计算型可观察变量可以处理从其他可观察变量派生的值(例如,一个方程式的结果,其中变量被观察)。我在之前的文章中深入介绍了这个实现:

无需框架的客户端 JavaScript 数据绑定

无需框架的客户端 JavaScript 数据绑定

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

支持声明式数据绑定

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

需要注意的是,这并不能提供安全性:恶意脚本仍然可以执行;它仅用于提供数据绑定作用域。生产环境的构建需要一个更加复杂的过程来解析出“可接受”的表达式,以避免安全漏洞。

observable方法computed只是帮助创建相关类的新实例。在幻灯片中,它们用于设置数据绑定表达式。这“看得比说得容易”,所以我很快会提供一个端到端的示例。

该方法在和实例bindValue之间设置了双向数据绑定。在本例中,它使用事件来在输入值发生变化时发出信号。转换器有助于处理绑定到类型的特殊情况HTMLInputElementObservableonkeyupnumber

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);
   };
}
Enter fullscreen mode Exit fullscreen mode

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

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

Enter fullscreen mode Exit fullscreen mode

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

在这个阶段,退一步来看全局是有意义的。你可以在这里运行数据绑定示例。

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

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

script标签中设置如下:

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

Enter fullscreen mode Exit fullscreen mode

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

将幻灯片作为“页面”托管和加载

Slide中的类slide.js一个简单的类,用于保存应用程序中代表“幻灯片”的信息。它有一个_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>
Enter fullscreen mode Exit fullscreen mode

A_context用于执行脚本(只是一个传递给this评估的空对象),a_title从幻灯片内容中解析出来,a_dataBinding属性保存幻灯片数据绑定助手的实例。如果指定了过渡,则过渡的名称保存在 中_transition;如果是“下一张幻灯片”,则名称保存在 中_nextSlideName

最重要的属性是_html属性。这是一个div包裹幻灯片内容的元素。幻灯片内容被赋值给innerHTML属性,以创建一个活动的 DOM 节点,该节点可以在幻灯片导航时轻松地切换。构造函数中的这段代码设置了 HTML DOM:

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

Enter fullscreen mode Exit fullscreen mode

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

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

Enter fullscreen mode Exit fullscreen mode

这段代码将幻灯片设置为“天生就绪”模式,等待显示。slideLoader.js模块负责加载幻灯片。它假设幻灯片位于一个slides带有.html后缀的子目录中。这段代码读取幻灯片并创建该类的新实例Slide

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

Enter fullscreen mode Exit fullscreen mode

主函数获取第一张幻灯片,然后通过读取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;
}
Enter fullscreen mode Exit fullscreen mode

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;

Enter fullscreen mode Exit fullscreen mode

然后它会监听浏览器导航(popstate事件),如果路线(幻灯片)发生了变化,它就会更新路线并引发自定义routechanged事件。

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

Enter fullscreen mode Exit fullscreen mode

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

使用 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;
}
Enter fullscreen mode Exit fullscreen mode

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

  1. beginAnimation使用动画名称和回调来调用。
  2. 设置_begin_end类来跟踪它们。
  3. 设置一个标志来指示过渡正在进行中。这可以防止在现有过渡事件期间进行额外的导航。
  4. 事件监听器附加到 HTML 元素,当相关动画结束时将触发该事件监听器。
  5. 动画“begin”类被添加到元素上。这将触发动画。
  6. 动画结束时,事件监听器会被移除,transition标志会被关闭,并且“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);
}
Enter fullscreen mode Exit fullscreen mode

回调函数会通知宿主动画已完成。在本例中,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);
}
Enter fullscreen mode Exit fullscreen mode

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

管理“甲板”的导航器

navigator.js控制幻灯片组的“主模块”。它负责显示幻灯片并处理幻灯片之间的移动。这是我们要研究的第一个模块,它将自身暴露为可复用的Web 组件。由于它是一个 Web 组件,因此类定义扩展了HTMLElement

export class Navigator extends HTMLElement { }

Enter fullscreen mode Exit fullscreen mode

该模块公开了一个registerDeck注册 Web 组件的函数。我选择创建一个新的 HTML 元素<slide-deck/>,以便像这样注册:

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

Enter fullscreen mode Exit fullscreen mode

该构造函数调用浏览器内置的父构造函数来初始化 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);
         }
   }
});
Enter fullscreen mode Exit fullscreen mode

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

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

注意,该元素与innerHTML其他元素一样HTMLElement,其 HTML 属性会一直渲染,直到被替换。解析该属性需要两个步骤。首先,必须观察该属性。按照惯例,这可以通过静态属性来实现observedAttributes

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

Enter fullscreen mode Exit fullscreen mode

接下来,实现一个回调函数,每当属性发生变化时(包括第一次解析和设置属性时)都会调用该回调函数。此回调函数用于获取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];
      }
   }
}
Enter fullscreen mode Exit fullscreen mode

其余属性和方法处理当前幻灯片、幻灯片总数和导航。例如,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);
}
Enter fullscreen mode Exit fullscreen mode

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"));
      }
   }
}
Enter fullscreen mode Exit fullscreen mode

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);
      }
   }
}
Enter fullscreen mode Exit fullscreen mode

此 Web 组件托管幻灯片。此外,还有两个组件与其配合控制幻灯片:一个用于键盘导航的按键处理程序,以及一组可点击或轻触的控件。

键盘支持

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

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

Enter fullscreen mode Exit fullscreen mode

这是主页:

<key-handler deck="main"></key-handler>
Enter fullscreen mode Exit fullscreen mode

它有一个名为的属性deck,指向id实例的navigator.js。设置该属性后,它会保存对卡片组的引用。然后,它会监听右箭头(代码 39)或空格键(代码 32)来推进卡片组,或监听左箭头(代码 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();
               }
            });
      }
   }
}
Enter fullscreen mode Exit fullscreen mode

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

点击和轻触控件

最后一个模块也是一个 Web 组件,用于控制卡片组。它注册为<slide-controls/>

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

Enter fullscreen mode Exit fullscreen mode

以下是主页声明:

<slide-controls deck="main" class="footer center">
   ---
</slide-controls>
Enter fullscreen mode Exit fullscreen mode

通过插入 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();
}
Enter fullscreen mode Exit fullscreen mode

请注意,这些按钮只是调用了模块公开的现有方法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());
      }
   }
}
Enter fullscreen mode Exit fullscreen mode

最后,refreshState在初始化和幻灯片切换时调用。它会根据当前显示的幻灯片确定启用或禁用哪些按钮,并更新x 轴上的文本。

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}`;
}
Enter fullscreen mode Exit fullscreen mode

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

结论

该项目旨在展示纯现代 JavaScript 的潜力。框架仍然有其存在的意义,但重要的是要了解原生功能如何帮助编写可移植且可维护的代码(例如,在任何框架中,类都是类)。掌握 JavaScript 可以让你更轻松地解决问题,并更好地理解各种功能(例如,了解如何实现数据绑定可能会加深你对如何在框架中使用它的理解)。

你怎么看?请在下方分享你的想法和评论。

问候,

杰里米·莱克尼斯

文章来源:https://dev.to/jeremylikness/build-a-single-page-application-spa-site-with-vanilla-js-4g2l
PREV
我在学习基于 Vue 的 React 时遇到的 5 个困难
NEXT
7句伟大的编程名言