网络是不断变化的。从速度到结构,再到质量,没有什么东西能长时间保持不变。说到结构,过去分散在多个页面上的内容现在可以浓缩在一个页面上。
在这篇文章中,我们将探讨单页应用程序,最常见的是SPA,以及它们给网页开发世界带来了哪些重大变化。
什么是SPA?
在深入研究之前,让我们看看什么是SPA,以及在它们之前开发者使用的是什么。
你知道在默认情况下,每当你想访问一个新页面时,浏览器是如何刷新的吗?有了SPA,情况就不同了--页面上的内容是从服务器上重写的,内容的变化不需要浏览器刷新。
如果你使用过GitHub、Twitter、甚至Gmail这样的网站,那么你已经接触过SPA了。当你在这些网站的标签之间导航时,你不会得到浏览器的重新加载。这些页面被连接在一起,就像它们都被写在一个HTML文件中一样(它们确实是这样)。
在SPA之前,人们使用多页面应用程序(MPA),有时今天仍在使用。大多数网络浏览器,都与MPA兼容,开发者不需要为它们编写额外的功能,就像SPA那样。在MPA中,当需要显示新的内容时,例如一个新的页面,会从服务器上请求新的页面。然后,浏览器显示新的页面,这将导致重新加载。
在这篇文章中,我们将创建一个以猫为主题的SPA,并使用GreenSock Animation Platform(GSAP)这个JavaScript动画库为其添加动画。下面的小GIF是我们最终网站的预览图。

在哪里使用SPA
尽管听起来很神奇,但SPA在某些方面并不能取代MPA。
电子书 Architect Modern Web Applications with ASP.NET Core and Azure中的一段摘录说,你可以在以下情况下使用SPA:
- 你的应用程序旨在展示一个具有多种功能的丰富界面
- 它是一个数据量较小的网站
- 你的网站不怎么依赖SEO。因为SPA涉及一个单一的URL,SEO优化确实很棘手,因为它缺乏索引和良好的分析能力
- 网站利用了实时提醒或通知等功能,类似于社交媒体平台
你知道是时候使用MPA了当:
- 你的网站的客户端要求很容易满足
- 你的应用程序不需要JavaScript就能在浏览器中运行
在移动设备上使用SPA
SPA还允许用户在移动设备应用程序中无缝浏览。如果你在手机上用浏览器登录你的Twitter账户,你仍然有SPA的效果。导航一如既往地顺畅,而且在浏览器标签中绝对不会出现重载的情况。
构建SPA的教程
现在我们要探讨构建单页应用程序的步骤。下面的步骤假定没有框架的知识,将涵盖用HTML、CSS和Vanilla JavaScript构建SPA。因为我们偶尔喜欢让事情变得漂亮,所以我们也会添加CSS页面过渡。
本教程包括:
- 创建项目目录
- 安装必要的依赖项。首先,通过在终端或命令行中输入
node -v,确保你的计算机上安装了Node。如果显示Node的一个版本,那么你就可以开始了。为Linux用户键入npm install node,或访问此链接以获得你所使用的任何操作系统的软件包管理器。 - 设置一个Express服务器
- 根据你的需要创建HTML和CSS文件。这可以是一些标签,也可以是一个小项目,以加深你的理解。这完全取决于你和你想要建立的东西。
- 在新创建的服务器上测试你的标记
- 潜心研究JavaScript方面的问题
本教程就到此为止。这些是你在互联网上看到的最简单的步骤。
开个玩笑。当然,我们将通过每个步骤。
全员上阵......我是说键盘
本节将分为两部分。首先,我们将只用香草的JavaScript来创建我们的SPA。你不需要任何先前的框架知识,因为我们将从头开始建立一切。
第二部分是我们将添加CSS页面过渡,以使导航更加流畅和花哨。
第一部分:结合HTML、CSS和JavaScript来创建一个SPA
一切从目录开始。我们希望在容器中存储我们的文件。我将使用CodeSandbox进行演示,但也可以随时打开你的代码编辑器并进行编码。
在你的终端,我们要创建一个名为SPA-tut 的目录,并在其中创建一个名为src 的目录:
terminal commands
mkdir SPA-tut
cd SPA-tut
mkdir src
cd src
用你选择的任何IDE打开SPA-tut 。因为没有一个index.html 文件的项目是不完整的,我们将在我们的src 文件夹中创建一个。
我们不会建立一个完整的网站,只是用一个小的导航条和一些链接来演示。在我们的index.html ,我们有以下的Markdown:
<nav class="navbar">
<div class="logo">
<p>Meowie</p>
</div>
<ul class="nav-items">
<li><a href="/" data-link>Home</a></li>
<li><a href="/about" data-link>About</a></li>
<li><a href="/contact" data-link>Contact us</a></li>
</ul>
</nav>
<div id="home"></div>
注意我们是如何添加data-link 数据属性的。这使用了历史APIpushState() 方法来导航到一个资源而不需要刷新。你现在不需要理解这个,但我们最终会达到这个目的。
我们的样式表也一样小:
*{
padding: 0;
margin: 0;
box-sizing: border-box;
}
.navbar{
background-color: pink;
padding: 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.nav-items{
display: flex;
}
a{
text-decoration: none;
color: white;
font-weight: bold;
font-size: x-large;
}
.link{
color: pink;
font-size: medium;
}
.logo{
color: white;
font-weight: bold;
font-size: xx-large;
font-style: oblique;
}
ul{
list-style: none;
}
.navbar ul li{
margin-left: 15px;
}
#home{
display: flex;
align-items: center;
margin: 20px;
}
.img{
width: 80%;
height: 80%;
}
我们的网站应该看起来像这样:

普通的HTML和CSS对我们没有好处,所以让我们跳入我们的JavaScript。我们将再创建两个文件夹:一个js 文件夹内的static 文件夹都在src 文件夹内,以及一个index.js 文件。现在,我们要通过使用一个script 标签来连接这个JS文件和我们的index.html ,就在关闭的body 标签上面:
<script type="module" src="/static/js/index.js"></script>
我们在这里添加了module 的类型,因为我们将在项目过程中使用ES6的导入和导出功能。
就本教程而言,我们的网站不是响应式的,但请随意成为更好的开发者,使你的网站与其他设备兼容。
用Express创建一个SPA服务器
对于这个SPA,我们将利用Express库来创建我们的服务器。不要担心,你不需要广泛的Express知识就可以跟上。
从源目录中创建一个server.js 文件。正如我之前提到的,在我们获得任何其他所需的包或库之前,我们将需要安装Node。在你的终端中:
- 键入
npm init -y来创建一个package.json - 键入
npm i express来安装 Express
如果你之前安装了Node,这些应该可以顺利安装,不会有太大问题。
对于服务器,我们需要在刚才的server.js 文件中创建一个Express应用程序:
const express = require('express');
const path = require('path');
const hostname = '127.0.0.1';
const port = 3000;
const app = express();
app.get("/*", (req, res) => {
res.sendFile(path.resolve(__dirname, 'src', 'index.html'));
});
//listen for request on port 3000
app.listen(port, hostname, () => {
console.log(`Server running at http://${hostname}:${port}/`);
});
为了导入Express,我们调用require() 。我们的应用程序监听任何通过我们上面指定的端口3000 发送的请求。app.get 从指定的 URL 获取请求。在这种情况下,它通过调用sendFile() 函数来实现,该函数使用path.resolve() 从左到右处理路径序列,直到创建绝对路径。我们的路径是/* ,因为我们希望无论我们在浏览器中给URL添加什么端点,页面都能重定向到index.html 。
app.listen 然后监听端口 上的任何请求,并记录一个传递主机名和端口号的消息。3000
如果你从你的终端复制路径并粘贴到浏览器,我们从index.html 文件的输出应该显示。它没有样式,但我们可以在最后轻松地解决这个问题。点击任何一个链接都只会刷新页面。

如果我们打开控制台,我们会看到一个错误,说Failed to load module script... 。
我们可以通过在我们的server.js 文件中的app.get 函数之前添加以下一行来解决这个问题:
app.use("/static", express.static(path.resolve(__dirname,'src', 'static')));
用JavaScript编辑客户端的SPA
对服务器的讨论已经足够了。让我们开始做一些工作吧。
第一步是创建一个路由器。我们将在一个异步函数中为每个视图(或页面)加载内容。它是异步的,因为我们可能想从服务器端加载某些内容。
为了定义各种路由,让我们创建一个对象数组,每个对象都是自己的一个路由:
const router = async () => {
const routes = [
{
path: "/",
view: view: () => console.log("Home")
},
{
path: "/about",
view: () => console.log("About us")
},
{
path: "/contact",
view: () => console.log("Contact")
}
];
};
view 在这一点上只是一个显示视图的函数。我们将在接下来的工作中调整它,以更好地服务于我们。
但是,假设我们把路由改成了完全脱离实际的东西(我指的是在我们的URL中添加 "无意义 "的端点)。我们想检查已经输入的路由是否是我们数组中的一个元素。为了做到这一点,我们将使用map 数组方法来检查每个数组元素,并返回一个新的对象:
//test each route for match
const checkMatches = routes.map(route => {
return{
route: route,
isMatch: location.pathname === route.path //returns a boolean value
};
});
console.log(checkMatches);
如果路由确实在数组中,checkMatches 将返回路由名称和一个布尔值 "true"。如果我们刷新浏览器,我们在控制台中没有看到什么。这是因为我们还没有调用路由器函数本身。
所以,在路由器函数之外,让我们这样做。
document.addEventListener('DOMContentLoaded', () => {
router();
})
当我们的页面加载时,路由器函数将运行。
现在,让我们自己试着实验一下:

你看,如果我们在URL中加入/about ,我们数组的第二个元素就是true ,其他的都被设置为false 。如果我们将URL设置为一个不在数组中的路由,那么isMatch 的所有值都是false 。
但是,假设我们只是想抓取路由名称并进行检查:
let match = checkMatches.find(checkMatch => checkMatch.isMatch);
console.log(match);
当我们现在输入路径时,find 函数会从数组中挑选出匹配的路由,并返回一个布尔值,告诉我们这个特定的路由是否在数组中。
但是,如果我们添加一个不在数组中的路由,我们在控制台中得到undefined 。我们需要定义一个not found 或404 根:
if(!match){
match = {
route: routes[0]
}
}
console.log(match.route.view);
如果没有匹配,我们的404页面就会变成数组索引为0的视图,也就是主页。你可以决定为这种情况创建一个自定义的404页面。
如果我们刷新浏览器并点击任何一个链接,那么我们上面声明的view 函数将运行,该视图的名称将显示在控制台。
使用历史API的导航
我们想在视图之间进行导航,改变URL,而不需要浏览器重新加载。要做到这一点,我们需要实现History API。在定义路由器函数之前,让我们定义一个新的函数,叫做navigateTo:
const navigateTo = url => {
history.pushState(null, null, url);
router();
};
为了调用这个函数,我们将创建一个事件监听器:
document.addEventListener('DOMContentLoaded', () => {
document.body.addEventListener('click', e => {
if(e.target.matches('[data-link]')){
e.preventDefault();
navigateTo(e.target.href);
}
})
router();
})
因此,我们的click 事件检查链接是否有data-link 属性。如果有,我们要停止默认的动作,也就是浏览器的重新加载,并以该链接的href 。这是一个委托的事件监听器,所以如果我们把内容添加到有这些链接的视图中,这些链接应该像我们一直以来描述的那样工作。
试着在你的页面上的链接之间进行导航,并把你的眼睛放在浏览器标签上。当你点击时,页面是否重新加载?从HTML中删除data-link ,然后再试着点击。
使用popstate 来查看你的SPA中的不同页面
当你在链接之间点击时,你也可以在控制台中看到变化。视图的名称被显示出来。如果我们尝试点击后退按钮去看以前的视图,router 函数就不会运行,因此,我们点击后退的新视图就不会显示。
因此,在我们的DOMContentLoaded 事件监听器之前,让我们听一下popstate 事件:
window.addEventListener('popstate', router);
刷新你的浏览器,并尝试在页面之间来回移动。你会看到,对于每一个页面,view 函数都会运行并显示在控制台。
显示你的SPA的视图
让我们从一个简单的console.log ,切换到实际显示视图的类。在js 目录中,我们将创建一个新的目录,称为pages 。
我们将为每个视图创建类,但首先,让我们创建一个类,其他的类将继承它。在pages 文件夹内创建一个view.js 文件:
export default class{
constructor(){
}
setTitle(title){
document.title = title;
}
async getHtml(){
return '';
}
}
setTitle 方法将在我们在视图之间导航时更新视图的页面标题。getHTML 方法是我们要为一个特定的视图放入HTML内容的地方。
现在让我们创建一个Home.js 文件,在这里我们将创建一个扩展上述view 的类:
import view from './view.js'
export default class extends view{
constructor(){
super();
this.setTitle("Home");
}
async getHtml(){
return `
<div class="text">
<h1>An album for your cuties</h1>
<p>Meowies lets you upload pictures of your cats, so that you never lose them. Lorem ipsum dolor sit amet consectetur adipisicing elit. Consequatur commodi eveniet fugit veritatis. Id temporibus explicabo enim sunt, officiis sapiente.
Read <a href="/about" data-link class="link">about</a> us here </p>
</div>
<div>
<img src="/static/cats.jpg" alt="cat in ribbons" class="img">
</div>
`;
}
}
在我们的构造函数中,我们已经将标题设置为Home ,以反映视图。在我们的getHTML 方法中,我们已经返回了一大块的HTML。
我们现在要把HTML注入到我们的路由器中。在index.html 文件的顶部,我们导入了Home 类:
import Home from './pages/Home.js'
在我们的路由数组中,我们把视图函数也改为Home 类:
{
path: "/",
view: Home
},
作为最后一步:
const view = new match.route.view();
document.querySelector("#home").innerHTML = await view.getHtml();
如果我们刷新浏览器,我们的主页视图看起来有点像这样:

注意到我在最后添加了一个about 的链接吗?如果你点击它,你会发现没有刷新浏览器。这是因为我之前提到的委托事件的缘故。
从这里开始,为其他视图创建类的过程将基本相同。我们将为每个新创建的视图扩展view 类,并在我们的index.html 文件中导入新视图的类。
用CSS编辑浏览器显示
让我们移动一下我们的CSS文件,使它在我们的index.html 文件中生效。让我们在静态文件夹内创建一个css 文件夹,并将我们的styles.css 文件移至其中。如果我们把我们的HTML文件与样式表连接起来,那么我们的网站就会变成这样:

这不是什么花哨的东西,但足以向你展示它是如何工作的。
要创建其他的视图,我们会像对Home.js 那样做。说这是本文的简单任务。你的应该比我的看起来更好。
在创建了所有的视图之后,我的 "**关于 "**和 "联系"部分看起来有点像这样:


就像我说的,没什么花哨的。你会看到链接改变了,页面标题也更新了(没有包括在我的截图中)。
你有了它。一个功能齐全的单页应用程序。现在要添加一些页面转换功能。
第二部分:CSS页面转换
这一部分是我一直以来的最爱。为了创建平滑的页面过渡,我们将使用一个JavaScript动画库,并使用一些CSS来帮助我们完成。
我将使用的动画库是GSAP。它使在你的网站上应用动画变得相对容易,而且只需几行JavaScript。要了解更多关于GSAP的信息并免费获得他们的超级作弊表,请在这里查找。
安装GreenSock动画平台(GSAP)
在本教程中,我们将使用CDN将GSAP引入我们的项目:
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.10.4/gsap.min.js"></script>
你可以在你的结尾<body> 标签之前添加这个。
有趣的部分:使用CSS的滑动效果
我们想在我们的应用程序中创建某种滑动效果。要做到这一点,我们需要在显示内容之前在我们的页面上滑动的东西。
让我们在我们的HTML底部添加一个额外的div 标签,并给它一个类别:slider:
<div class="slider"></div>
在我们的styles.css ,我们要为滑块添加样式:
.slider{
position:fixed;
top:0;
left: 0;
background-color: palevioletred;
width: 100%;
height: 100%;
transform: translateY(100%);
}
如果你刷新页面,其实没有什么变化。滑块就在那里,但transform 属性使它处于页面内容的下面,所以我们实际上看不到它。
我们希望滑块在我们第一次打开页面时能向上滑动。这就是GSAP发挥作用的时候了。在我们的index.js ,我们必须首先初始化GSAP,然后才能开始使用它。因为我们希望在页面加载时有动画,我们将在我们的类导入下面初始化我们的GSAP:
const tl = gsap.timeline({ defaults: {ease:'power2.out'} });
上面这一行简单地定义了我们的时间线,并将一切设置为默认值。
我们的第一个动画将是滑块,从页面上的位置滑上去:
tl.to('.slider', {y: "-100%", duration:1.2});
我们所做的是使用我们声明的时间线来针对滑块类。y: “-100%” ,取代了我们最初为CSS设置的translateY(100%) 。duration:1.2 是简单地设置为时间轴。所以当我们的页面第一次加载时,我们的滑块会在我们的页面内容上向上滑动。
但对我来说,这个动画看起来还是不太对劲。为了使它更好,我们希望我们的页面内容也能向上滑动,就在滑块上升之后。要做到这一点,我们必须在HTML中针对home ID,因为它包含我们的页面内容。
就在滑块动画之后,我们将添加:
tl.to('#home', {y: "0%", duration:1.2}, "-=1");
就像之前一样,我们以home ID为目标,并为其设置一个持续时间。额外的-=1 是为了让页面内容在滑块之后立即滑起来。如果我们把它拿出来,你会注意到一些延迟。
但这是它吗?
不,我们还没有添加我们所说的视图之间的平滑过渡。现在你已经看到了这个过程是如何工作的,为视图添加过渡就不那么难了。
我们想在用户点击链接的时候创造一个平滑的过渡,所以我们要在点击事件中添加动画。
然后,我们调整后的JavaScript将看起来像这样:
document.addEventListener('DOMContentLoaded', () => {
document.body.addEventListener('click', e => {
e.preventDefault();
tl.to('.slider', {y: "100%", duration:0});
if(e.target.matches('[data-link]')){
tl.to('.slider', {y: "-100%", duration:1.2});
tl.fromTo('#home', {opacity: 0}, {opacity:1, duration:0.5});
navigateTo(e.target.href);
}
})
router();
})
请记住,在我们的滑块升起后,它保持在原位。所以当用户点击的时候,滑块就会下来,然后再上去。第一个动画的持续时间被设置为零,所以你不会注意到它滑下来。这里设置的动画是为了让它在用户点击链接的每一次都能进行。
为了使事情更加顺利,一旦滑块因点击而上升,我们希望页面内容的不透明度能迅速改变,并创造一个漂亮的淡出:
tl.fromTo('#home', {opacity: 0}, {opacity:1, duration:0.5});
fromTo 标志着动画有一个开始和结束点。它从不透明度为0开始,在0.5秒内变为不透明度为1。
在调整了动画之后,我们的SPA现在看起来像这样:

我们在每个页面上都有一个漂亮的滑块和一个漂亮的渐变。
总结
这标志着这篇文章的结束。我们已经讨论了什么是SPA,如何从头开始创建一个,以及如何在视图之间添加平滑的页面过渡。
要查看这个项目的完整代码,请使用这里的Github repo链接。
我希望这篇文章对你有帮助。你可以根据需要进行修改,并查看GSAP的文档。他们有一个免费的小抄,可以帮助你使你的动画更流畅、更好。