目錄
現代JavaScript具有無需依賴框架即可構建完整的單頁應用(SPA)體驗的所有必要功能。瞭解如何使用模塊和Web組件等最新語言功能來處理模板、動畫、路由和數據綁定。
存在現代JavaScript框架來解決HTML5、JavaScript、CSS和WebAssembly提供的開箱即用功能的缺陷。與早期版本相比,JavaScript的最新穩定版本(ECMAScript®2015)進行了重大改進,可以更好地控制範圍,強大的字符串處理功能、解構、參數增強以及類和模塊的內置實現(不再存在需要使用IIFE或立即調用的函數表達式)。這篇文章的目的是探討如何使用最新的JavaScript功能構建現代應用程序。
項目
我實現了一個完全基於純JavaScript(“Vanilla.js”)的單頁應用程序(SPA)應用程序。它包括路由(您可以爲頁面加書籤和導航)、數據綁定、可重用的Web組件,並使用JavaScript的本地模塊功能。您可以在此處運行和安裝該應用程序(它是漸進式Web應用程序或PWA):
源代碼存儲庫位於此處:
如果打開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.js模塊包含一個簡單實現的觀察者模式。一個類包裝一個值,並在值更改時通知訂閱者。計算的可觀察值可用,可以處理從其他可觀察值派生的值(例如,正在觀察變量的方程式的結果)。
簡單瞭解一下數據綁定如何與純JavaScript實現一起工作。
支持聲明式數據綁定
databinding.js模塊提供給應用程序數據綁定服務。有一對方法execute和executeInContext,它們用於評估具有指定this的腳本。本質上,每個“幻燈片(slide)”都有一個上下文,該上下文用於設置數據綁定表達式,並且幻燈片中包含的腳本在該上下文中運行。上下文在“幻燈片(slide)”類中定義,稍後將進行探討。
重要的是要注意這不提供安全性:惡意腳本仍可以執行;它僅用於提供數據綁定範圍。爲生產而構建將需要更多的過程來解析僅“可接受的”表達式,以避免安全漏洞。
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);
};
}
從找到具有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) }。然後在輸入字段和可觀察對象之間建立綁定。對於列表,將基於數據綁定模板評估每個列表項以獲取相應的值。這裏缺少的是幻燈片上存在的“上下文”。接下來讓我們看看slide和sideLoader模塊。
將幻燈片(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模塊就是加載的幻燈片。它假設它們存在於後綴爲.html的slides子目錄中。這段代碼讀取幻燈片並創建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模塊負責處理路由。它具有兩個主要功能:
- 將路由(哈希)設置爲與當前幻燈片相對應
- 通過引發自定義事件來響應導航,以通知訂閱者其路由已更改
構造函數使用“虛擬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”類已添加到元素。這將觸發動畫。
- 動畫結束時,將刪除事件偵聽器,關閉轉換標誌,並從元素中刪除“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。它根據正在顯示的幻燈片確定要啓用或禁用的按鈕,並且還更新y的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可以使您更輕鬆地解決問題並提供更好的功能理解(例如,瞭解如何實現數據綁定可以增進您對如何在框架中使用它的理解)。