使用 Vanilla.js 构建单页应用程序 (SPA) 网站
现代 JavaScript 框架的存在是为了弥补 HTML5、JavaScript、CSS 和 WebAssembly 提供的开箱即用功能的不足。JavaScript 的最新稳定版本(ECMAScript® 2015 修正版:ECMAScript® 2019)与早期版本相比有了显著的改进,包括对作用域的更佳控制、强大的字符串操作功能、解构、参数增强以及类和模块的内置实现(不再需要使用 IIFE 或立即调用的函数表达式)。本文旨在探讨如何使用最新的 JavaScript 功能构建现代应用程序。
项目
我实现了一个完全基于纯 JavaScript(“Vanilla.js”)的单页应用(SPA)。它包含路由(可以添加书签和导航页面)、数据绑定、可复用的 Web 组件,并使用 JavaScript 的原生模块功能。您可以在此处运行并安装该应用(它是一个渐进式 Web 应用,简称 PWA):
源代码存储库位于此处:
https://github.com/jeremylikness/vanillajs-deck
如果你打开,index.html
你会注意到一个脚本包含一种特殊类型的“模块”:
<script type="module" src="./js/app.js"></script>
该模块只是从其他几个模块导入并激活 Web 组件。
使用模块组织代码
原生 JavaScript 模块与普通 JavaScript 文件类似,但有一些关键区别。它们应该使用type="module"
修饰符加载。有些开发者喜欢使用.mjs
后缀来区分它们与其他 JavaScript 源文件,但这不是必需的。模块在某些方面是独一无二的:
- 默认情况下,它们以“严格模式”解析和执行
- 模块可以提供供其他模块使用的导出
- 模块可以从子模块导入变量、函数和对象
- 模块在其自己的范围内运行,不必包装在立即调用的函数表达式中
模块的生命周期有四个步骤。
- 首先,解析并验证模块
- 二、模块加载
- 第三,相关模块根据其导入和导出进行链接
- 最后执行模块
任何未包装在函数中的代码都会在步骤 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 来响应变化
该observable.js
模块包含观察者模式的简单实现。一个类包装一个值,并在值发生变化时通知订阅者。计算型可观察变量可以处理从其他可观察变量派生的值(例如,一个方程式的结果,其中变量被观察)。我在之前的文章中深入介绍了这个实现:
简单了解一下数据绑定如何与纯 JavaScript 实现一起工作。
支持声明式数据绑定
该databinding.js
模块为应用程序提供数据绑定服务。方法对execute
和executeInContext
用于执行指定参数的脚本this
。本质上,每个“幻灯片”都有一个用于设置数据绑定表达式的上下文,幻灯片中包含的脚本在该上下文中运行。该上下文在“幻灯片”类中定义,稍后将进行探讨。
需要注意的是,这并不能提供安全性:恶意脚本仍然可以执行;它仅用于提供数据绑定作用域。生产环境的构建需要一个更加复杂的过程来解析出“可接受”的表达式,以避免安全漏洞。
observable
和方法computed
只是帮助创建相关类的新实例。在幻灯片中,它们用于设置数据绑定表达式。这“看得比说得容易”,所以我很快会提供一个端到端的示例。
该方法在和实例bindValue
之间设置了双向数据绑定。在本例中,它使用事件来在输入值发生变化时发出信号。转换器有助于处理绑定到类型的特殊情况。HTMLInputElement
Observable
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);
};
}
bindObservables
该方法由一个查找任何具有属性的元素的方法调用data-bind
。再次注意,此代码已简化,因为它假定这些元素是输入元素,并且不进行任何验证。
bindObservables(elem, context) {
const dataBinding = elem.querySelectorAll("[data-bind]");
dataBinding.forEach(elem => {
this.bindValue(elem,
context[elem.getAttribute("data-bind")]);
});
}
该bindLists
方法稍微复杂一些。它假设将迭代一个(非可观察的)列表。首先,repeat
找到所有具有属性的元素。该值被假定为列表引用,并被迭代以生成子元素列表。使用正则表达式将绑定语句替换{{item.x}}
为实际值executeInContext
。
在这个阶段,退一步来看全局是有意义的。你可以在这里运行数据绑定示例。
在 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) }
。然后在输入字段和可观察对象之间建立绑定。对于列表,每个列表项都会根据数据绑定模板进行评估,以获取相应的值。这里缺少的是幻灯片上存在的“上下文”。接下来,我们来看看slide
和sideLoader
模块。
将幻灯片作为“页面”托管和加载
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>
A_context
用于执行脚本(只是一个传递给this
评估的空对象),a_title
从幻灯片内容中解析出来,a_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
模块负责加载幻灯片。它假设幻灯片位于一个slides
带有.html
后缀的子目录中。这段代码读取幻灯片并创建该类的新实例Slide
。
async function loadSlide(slideName) {
const response = await fetch(`./slides/${slideName}.html`);
const slide = await response.text();
return new Slide(slide);
}
主函数获取第一张幻灯片,然后通过读取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
模块负责处理路由。它有两个主要功能:
- 设置路线(哈希)以对应当前幻灯片
- 通过引发自定义事件来响应导航,以通知订阅者路线已更改
构造函数使用“幻影 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;
}
该模块通过执行以下操作来管理转换:
beginAnimation
使用动画名称和回调来调用。- 设置
_begin
和_end
类来跟踪它们。 - 设置一个标志来指示过渡正在进行中。这可以防止在现有过渡事件期间进行额外的导航。
- 事件监听器附加到 HTML 元素,当相关动画结束时将触发该事件监听器。
- 动画“begin”类被添加到元素上。这将触发动画。
- 动画结束时,事件监听器会被移除,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);
}
回调函数会通知宿主动画已完成。在本例中,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);
}
当您看到接下来介绍的导航器模块如何处理代码时,这些步骤将更加清晰。
管理“甲板”的导航器
是navigator.js
控制幻灯片组的“主模块”。它负责显示幻灯片并处理幻灯片之间的移动。这是我们要研究的第一个模块,它将自身暴露为可复用的Web 组件。由于它是一个 Web 组件,因此类定义扩展了HTMLElement
:
export class Navigator extends HTMLElement { }
该模块公开了一个registerDeck
注册 Web 组件的函数。我选择创建一个新的 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
组件的设置如下:
<slide-deck id="main" start="001-title">
<h1>DevNexus | Vanilla.js: Modern 1st Party JavaScript</h1>
<h2>Setting things up ...</h2>
</slide-deck>
注意,该元素与innerHTML
其他元素一样HTMLElement
,其 HTML 属性会一直渲染,直到被替换。解析该属性需要两个步骤。首先,必须观察该属性。按照惯例,这可以通过静态属性来实现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
模块是另一个定义为的 Web 组件<key-handler/>
。
export const registerKeyHandler =
() => customElements.define('key-handler', KeyHandler);
这是主页:
<key-handler deck="main"></key-handler>
它有一个名为的属性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();
}
});
}
}
}
这段代码是故意简化的。它假设
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
在初始化和幻灯片切换时调用。它会根据当前显示的幻灯片确定启用或禁用哪些按钮,并更新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}`;
}
因为该控件是一个 Web 组件,所以可以轻松地将第二个实例放置在页面顶部,以提供更多的导航选项(如果需要)。
结论
该项目旨在展示纯现代 JavaScript 的潜力。框架仍然有其存在的意义,但重要的是要了解原生功能如何帮助编写可移植且可维护的代码(例如,在任何框架中,类都是类)。掌握 JavaScript 可以让你更轻松地解决问题,并更好地理解各种功能(例如,了解如何实现数据绑定可能会加深你对如何在框架中使用它的理解)。
你怎么看?请在下方分享你的想法和评论。
问候,
文章来源:https://dev.to/jeremylikness/build-a-single-page-application-spa-site-with-vanilla-js-4g2l