背景
之前我发布了文章《我们用48h,合作创造了一款Web游戏:Dice Crush,参加国际赛事》,介绍了我们一起做的游戏。
本文分享一项技术方案,正是我开发上述游戏时用到的:
不用React Vue,只用原生JS,如何开发单页面应用?
什么是单页面应用
单页面应用(Single-Page Application)是个相对古老的传统的多页面应用(Multi-Page Application)的名词。
以前我们访问网页,每个页面是一个html文件。点击某个超链接,就跳转到新的html页面。每次浏览器访问html时,需要重新下载整个html文档、JS和CSS依赖,才能展现出整个页面。这个效率很低。
随着异步请求AJAX等技术的兴起、HTML5规范的出现,开发者有了更优秀的页面加载方案:一个网站的所有页面,都是同一份html文档,用JS判断路由,并动态展示内容。通过预加载等方式,把整个网站的页面都下载到内存中。每当用户点击超链接,准备切换页面时,通过history API使浏览器更新URL而不必重新下载html文档,然后JS只要把现有的页面卸载(隐藏),再把内存中的东西展示出来即可。这个过程完全避免了网络请求,极大提高了网站用户体验。
采用上述方案实现的Web应用就是单页面应用。
React和Vue开发的基本都是单页面应用
现代Web开发,大多数网站是用React或Vue开发的,它们基本都是单页面应用。
开发者可以很方便的使用React、Vue开发单页面应用,是因为React Router和Vue Router帮开发者实现了单页面应用的核心逻辑。所以开发者不必关心细节,只要会用React Router和Vue Router即可。
这就导致一个问题:如果我们不用React或Vue(例如我的游戏《Dice Crush》是用原生JS实现),没有React Router和Vue Router的能力,该怎么开发单页面应用呢?
开发单页面应用,有哪些难题
在聊怎么实现之前,我们要先想明白:开发单页面应用,需要解决哪些难题?
- 多个页面如何定义?
- 页面切换时,不可以使用
location.replace('新的网址')
或document.href = '新的网址'
,因为它会使浏览器下载html文档。我们需要用HTML5的History API,修改网址。 <a>
标签导航时,不能使用原生的href
属性,因为它会使浏览器下载html文档。我们需要监听onclick
事件,在里面调用History API修改网址。- 使用History API修改网址后,页面不会有任何变化,只是浏览器URL变了。我们需要手动操控当前页面DOM的销毁、新页面DOM的生成。
- 使用History API修改网址后,当用户点击浏览器的「返回」、「前进」时,页面不会刷新,只是浏览器URL变了。我们需要监听事件
onpopstate
,即监听用户点击浏览器的「返回」、「前进」,然后操控当前页面DOM的销毁、新页面DOM的生成。
以上是一些最基本的难题,如果你要追求极致用户体验,还需要解决下面的难题:
<a>
标签导航,需要借助href
属性,给予用户在新窗口打开链接的权利。- 当用户切换路由时,如果发生了临界事件,要能够做好兼容。例如,用户点击了链接,准备渲染新页面,此时立马点击了旧页面某个按钮,要执行旧页面某个按钮的回调函数。这可能有超出预期的结果。我们需要在切换路由后,就禁止旧页面的一切事件回调。
1、定义多个页面
每个页面是由HTML JS CSS组成的。每个页面需要对应一个路由。
我说一下我在游戏《Dice Crush》中的做法。
它有3个页面:主页、选择关卡页面、游戏页面。如下图:
我给每个页面定义了一个template.js,用于存放html字符串。比如:
代码语言:javascript复制const template = `<div>
<h1>Dice Crush</h1>
<button>开始游戏</button>
</div>`;
之后渲染页面时,只需要document.body.innerHtml = template
,就可以把该页面的模板渲染到html文档上了。当然,渲染页面时,还需要给button绑定click事件。
因此,我们给每个页面声明一个template
,再声明一个用于渲染该页面的函数(功能主要是给document.body.innerHtml赋值、给button添加click事件),就可以了。之后需要渲染哪个页面,就调用哪个页面的渲染方法。
2、页面切换,使用History API切换URL
需要切换页面时,我们需要使用history.pushState(null, '', '新的页面URL')
来修改浏览器URL,同时调用上述渲染页面方法,把页面渲染在浏览器中。
3、a标签的问题
我们需要注意,如果给<a>
标签添加了href
,最好给它绑定这样的click事件:
linkElement.onclick = function (event) {
if (event.button !== 0) return;
if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return;
event.preventDefault();
window.history.pushState(null, '', 'new-page.html');
// 手动渲染新的页面
};
event.button
表示按下的是鼠标哪个按键(0是主按键,通常指鼠标左键或默认值)。如果用户是鼠标中键按下a标签、或者用户同时按下了Ctrl(Windos)、Command(Mac)、Shift,那么他应该期望是在新窗口打开,我们使用href
原生行为即可。如果用户同时按下了Option,那么他应该期望是打开菜单栏,我们也执行原生行为。其它情况,都表明用户要在本页面点开那个网址,我们拦截原生的href
,通过history.pushState
实现,并手动渲染新的页面。
4、手动加载新页面、卸载旧页面
由于我们页面渲染是通过document.body.innerHtml
实现的,所以会在加载新页面时自动卸载旧页面。
当然,如果你的旧页面在window上添加了一些事件监听器、计时器,也要记得手动卸载掉。做好清除工作,不然会出问题。
5、页面初次加载与监听事件onpopstate
页面初次加载时,我们需要根据路由渲染一个页面,示例代码如下:
代码语言:javascript复制const init = () => {
if (window.location.pathname.includes('play')) {
renderGame();
} else if (window.location.pathname.includes('select')) {
renderSelect();
} else {
renderHome();
}
};
init();
同样,当页面onpopstate
时,即用户点击了「返回」、「前进」,依然停留在本页面时,我们也需要重新根据当前路由渲染一下页面。需要执行如下逻辑:
window.onpopstate = init;
至此,我们手写的一个单页面应用就开发完成啦~这也是我在游戏《Dice Crush》中使用的方案,你学会了吗?
写在最后
我是HullQin,公众号线下聚会游戏的作者(欢迎关注我,交个朋友)。转发本文前需获得作者HullQin授权。我独立开发了《联机桌游合集》,是个网页,可以很方便的跟朋友联机玩斗地主、五子棋等游戏,不收费无广告。还开发了《Dice Crush》参加Game Jam 2022。喜欢可以关注我噢~我有空了会分享做游戏的相关技术,会在这个专栏里分享:《教你做小游戏》。