使用古老的 JS(ES6)创建单页应用程序
演示:https://src-brsetrrnrp.now.sh/
仓库:https://github.com/rishavs/vanillajs-spa
如今,开发 SPA(单页应用)风靡一时。SPA 更简洁,用户体验更好,开发和部署也更简单。
但是,开发 SPA 通常需要大量语义庞大的框架,例如 React、Vue 等。而且,为了正确使用这些框架,你需要掌握比核心语言本身更多的框架知识。
通过这篇文章,我想向你展示,你无需了解多个生态系统和框架就能开发简单的前端应用。你只需要简单的 JS 代码。而且我向你保证,整个应用会变得更加简洁,更容易理解。
几个月前,我用 Crystal(我很喜欢这门语言)开发了一个 WeABPP,一开始只是一个简单的基于服务器的 Web 应用,但很快意识到我编写的 JS 代码量太大了。我的服务器代码库实际上要小得多。
所以,我理所当然地告诉自己,不如直接用 JS 来开发整个应用。
(注意:这是一个值得怀疑的逻辑,但我编写代码是为了好玩,用纯原始 JS 创建一个简单的 SPA 听起来是有史以来最有趣的想法!)
(“旁白:不,不是”)。
令人惊讶的是,关于使用 vanillaJS 制作 SPA 的文章并不多,而且一些指导读者的文章往往使用了很多库。为什么我要强调使用原生 JS?本页面提供了一些令人信服的论据。
我的计划是不使用任何库,除非我清楚地知道
——我浪费了太多时间重新发明轮子
——这超出了我的能力范围。
所以,先手工编写 Vanilla JS 代码。只在需要的时候使用库。
你使用的每一段第三方代码都有其自身的负担,你应该始终检查这些负担是否小于使用它的收益。
我正在开发一个简单的博客/Hackernews类型的应用,它也很有帮助;一个页面显示文章列表,另一个页面显示文章详情。新版本的JS(ES6)非常适合处理这些功能。
如果您必须处理需要实时更新 DOM 的 UX,那么您真的应该坚持使用 React、Vue 或(我个人最喜欢的)Mithril 等框架。
TLDR:简单明了是好的。简单明了并不总是好的。
了解你的需求以及实现这些需求所需的工具。
在开始实施之前,让我们先看看我们正在开发什么。
该应用将包含以下页面:
-- 首页:包含所有帖子列表。显示从 API 获取并呈现在此处的数据。--
特定 ID 的帖子:包含该帖子的详细信息。显示动态 URL 解析。--
关于:仅包含一些文本。这是为了展示路由器
。-- 机密:此页面不在我的组合路由中,将用于显示 404 错误处理。--
注册:包含一个表单,单击按钮时显示表单数据。
我的应用程序中的每个页面本身都具有以下结构:
--导航栏
--内容部分
--页脚
我的应用程序中的每条路线都有以下结构:
——资源
——标识符
——动词
例如,
如果 url 是“localhost:8080/#/users/rishav/edit”,则
资源 =“users”,标识符 =“rishav”和动词 =“edit”。
由于我支持的 URL 结构非常固定,因此为其创建基于哈希的路由器变得非常容易。在我的路由中,资源和动词字符串是预定义的,但标识符是动态的。
你可以在这里查看演示应用:
请注意,我并没有把它做得很漂亮,因为这不是本文的目的。而且,我很懒 :)
现在你已经尝试了一下,让我们开始深入细节。
目前,我使用live-server在开发环境中为我的 spa 提供服务。你可以使用任何你想要的服务器。在这里的示例中,我将使用“localhost:8080”作为我的开发域名。
我还使用https://www.mockapi.io通过 REST API 向我的应用填充示例数据。
本文将重点介绍 spa 的以下主要方面:
-- 路由器(使用 url hash)和
-- 模板(使用 ES6 模板文字)和
-- 项目架构(使用 ES6 模块)
路由器
路由器的核心思想是在 URL 中使用井号“#”。每当浏览器在 URL 中遇到此字符时,都会跳过其后的所有内容。因此,从浏览器的角度来看,“localhost:8080/#/nowhere”和“localhost:8080/#/somewhere”是相同的,它不会发送服务器请求来获取整个服务器端路由。
您可以在https://en.wikipedia.org/wiki/Fragment_identifier上阅读更多相关信息。
我们的代码的路由部分是;
// List of supported routes. Any url other than these routes will throw a 404 error
const routes = {
'/' : Home
, '/about' : About
, '/p/:id' : PostShow
, '/register' : Register
};
// The router code. Takes a URL, checks against the list of supported routes and then renders the corresponding content page.
const router = async () => {
// Lazy load view element:
const content = null || document.getElementById('page_container');
// Get the parsed URl from the addressbar
let request = Utils.parseRequestURL()
// Parse the URL and if it has an id part, change it with the string ":id"
let parsedURL = (request.resource ? '/' + request.resource : '/') + (request.id ? '/:id' : '') + (request.verb ? '/' + request.verb : '')
// Get the page from our hash of supported routes.
// If the parsed URL is not in our list of supported routes, select the 404 page instead
let page = routes[parsedURL] ? routes[parsedURL] : Error404
content.innerHTML = await page.render();
await page.after_render();
}
// Listen on hash change:
window.addEventListener('hashchange', router);
// Listen on page load:
window.addEventListener('load', router);
在这段代码中,我引用了另一个文件中的一个简单函数;
parseRequestURL : () => {
let url = location.hash.slice(1).toLowerCase() || '/';
let r = url.split("/")
let request = {
resource : null,
id : null,
verb : null
}
request.resource = r[1]
request.id = r[2]
request.verb = r[3]
return request
}
让我们来看看当用户在地址栏中输入以下 URL 并点击输入时会发生什么;
-- localhost:8080/#/About
首先,这部分代码window.addEventListener('load', router);
会在浏览器加载事件发生时触发。
然后它会调用 router 函数。router
函数首先从地址栏获取 URL,并使用 functionparseRequestURL
将其分解为包含资源、标识符和动词的路由模式。
然后,通过连接每个 URL 模式元素来重新构建 URL。
最终的 URL 字符串会与我们支持的现有路由映射进行比较。
const routes = {
'/' : Home
, '/about' : About
, '/p/:id' : PostShow
, '/register' : Register
};
这里,每个支持的路由都映射到一个内容页面。let page = routes[parsedURL] ? routes[parsedURL] : Error404
然后,我们检查解析后的 URL 字符串是否作为路由映射中的键存在。
如果存在,则变量page
获取相应的值。否则,它将获取 error404 页面的值。
在我们的示例中,资源是“关于”,标识符为 nil,动词也为 nil。
因此,我们在路由图中检查“关于”,并使用以下命令渲染该页面:
content.innerHTML = await page.render();
现在让我们考虑一个动态路由“localhost:8080 /#/p/1234”。
这里资源 = "p",标识符 = "1234",动词为 nil。
由于我们检测到了一个标识符(可以是任意字符串),因此需要用一个固定字符串替换它。因此,我只需用一个固定字符串“:id”替换任何标识符即可。
这样,我就可以定义一个简单的路由,"/p/1234" : PostShow
PostShow 页面将根据特定的标识符动态显示数据。
当然,我们还需要确保所有应用内链接也包含“#”。例如,这是导航栏中“关于”页面链接的 HTML;
<a class="navbar-item" href="/#/about">
About
</a>
当用户点击此链接时,我们定义的第二个全局事件window.addEventListener('hashchange', router);
会被触发。然后,路由器函数会再次被调用,诸如此类。
因此,本质上,每次用户更改 URL 并加载页面(例如输入、刷新等),或者使用应用内超链接进行导航时,我们都会重新渲染应用的页面内容。
当然,我们的导航状态也会保存在浏览器历史记录中,因此您可以使用后退/前进浏览器按钮浏览历史记录状态。
如果用户尝试像“localhost:1234/nowhere”这样的 URL(URL 中 origin 后面根本没有 # 符号)会怎么样?
这不是我们纯客户端路由器能够处理的。在大多数 SPA 托管解决方案(例如 Netifly)中,您可以指定一个 404 页面(我们在前端应用中使用的页面的预渲染版本),以便在这种情况中使用。
模板
让我们看一下我们正在生成的一个非常简单的页面,即“关于”页面;
let About = {
render : async () => {
let view = /*html*/`
<section class="section">
<h1> About </h1>
</section>
`
return view
}
}
export default About;
我正在使用 ES6 模板文字来定义我的 html 模板。该/*html*/
位只是一个指令,我的 VSCode 扩展使用它在编辑器中正确格式化 html 内容。
异步位在这里没有多大意义,但对于将从远程源获取和显示数据的页面来说是必需的。我将我的“关于”页面视为页面模板,每次我需要向我的应用添加新页面时,我只需进行一些复制-粘贴-重命名操作。
如果您注意到,模板文字字符串位于函数内部。这意味着只有在需要时才会评估渲染函数。
之前我刚刚做过类似的事情;
let About = /*html*/`
<section class="section">
<h1> About </h1>
</section>
`
export default About;
这是一个大问题,因为无论我在应用中的哪个位置,它都会评估这个页面。如果我的应用中有 20 个页面,其中很多页面都会获取一些数据,那么无论我渲染应用的哪个区域,它每次都会评估整个应用(并获取所有我不需要的页面的数据)。
函数式方法还意味着,如果我们想在页面中放置一些小组件,可以通过 params 函数将数据传递给每个组件进行渲染。例如,如果我想让主页中的每个链接都变成一张卡片,我只需为每个链接调用 render 函数,card.render(data)
并以编程方式读取数据并进行相应的渲染即可。
现在,下一步要解决的是为页面添加交互控件。例如,假设我们的“关于”页面上有一个按钮,点击该按钮会触发浏览器警报,那么我们应该把与该按钮相关的代码放在哪里呢?
之前,我在基于字符串的 HTML 代码中添加了一个 script 标签,但无论我怎么操作,里面的代码都无法运行。
最后,另一个论坛的一位先生帮我解决了问题。原来,通过 innerHTML 方法添加的 script 标签不会被执行!我知道,这太疯狂了!-__-
由于存在安全隐患,JavaScript 中充满了这些小陷阱。
所以,这个选项不行。
我也不能直接在全局范围内添加实际代码,然后通过按钮上的 onclick 属性来引用它。
为页面控件添加事件处理程序的方法是向页面对象添加另一个函数,如下所示;
let About = {
render : async () => {
let view = /*html*/`
<section class="section">
<h1> About </h1>
<button id="myBtn"> Button</button>
</section>
`
return view
},
after_render: async () => {
document.getElementById("myBtn").addEventListener ("click", () => {
console.log('Yo')
alert('Yo')
})
}
}
export default About;
并在路由器中使用它;
content.innerHTML = await page.render();
await page.after_render();
这使我能够定义按钮按下的代码,并且该代码在我的 dom 呈现后立即初始化。
应用程序结构
这就是我构建应用程序的方式。views 文件夹包含我的动态内容(页面)和一些较小的组件(组件)。如果我添加可重用的组件,例如卡片、评论等,我会在这里添加它们。
如果你注意到的话,我们没有使用任何像 parceljs、webpack 之类的打包工具,因为从 ES6 开始我真的不需要了。我可以使用 script 标签 type=module 这个简单的 HTML 指令来告诉浏览器,我们的 JS 代码中有一个模块化的应用,它需要考虑 ES6 的 import/export 命令,并相应地将模块拼接起来。
这就是我们在核心 HTML 文件中声明应用根目录的方式;
<script type="module" src="/app.js"></script>
</head>
<body>
<div id="header_container"></div>
<div id="page_container" class="container pageEntry" >
<article> Loading....</article>
</div>
<div id="footer_container"></div>
</body>
如果您对“header_container”/“footer_container”感兴趣,它们是包含导航栏和页脚的 DOM 元素。每个路由的导航栏和页脚都是相同的,内容部分是动态的,会根据当前选择的路由而变化。
我以声明和使用我的页面的相同方式声明这些组件。
例如,这是一个页脚;
let Bottombar = {
render: async () => {
let view = /*html*/`
<footer class="footer">
<div class="content has-text-centered">
<p>
This is my foot. There are many like it, but this one is mine.
</p>
</div>
</footer>
`
return view
},
after_render: async () => { }
}
export default Bottombar;
这是我修改后的路由器,我在其中使用这个页面;
const router = async () => {
// Lazy load view element:
const header = null || document.getElementById('header_container');
const content = null || document.getElementById('page_container');
const footer = null || document.getElementById('footer_container');
// Render the Header and footer of the page
header.innerHTML = await Navbar.render();
await Navbar.after_render();
footer.innerHTML = await Bottombar.render();
await Bottombar.after_render();
因此,无论通过 url 输入什么路由,我总是会呈现导航栏和页脚。
当然,你可能仍然想使用打包工具来压缩、gzip 压缩你的 js 文件,这确实是一个很棒的解决方案。我个人目前不太在意这些优化,而且摆脱 webpack 配置 > babel 的束缚真的让我轻松很多。
呼!看看时间!
我们简单的博客式水疗中心终于完成了!
*我强烈建议您查看https://github.com/rishavs/vanillajs-spa上的实际代码*
代码简洁,注释清晰,与我在本指南中写的内容一致。希望它能帮助你在纷繁复杂的 JS 框架丛林中,找到一条清晰的路径。
信用:
为了制作这个应用,我自己参考了很多指南和文章。
对于路由器,这个指南很有帮助:
https://medium.com/@bryanmanuele/how-i-implemented-my-own-spa-routing-system-in-vanilla-js-49942e3c4573