原生实现简单的SPA(Single-page application)

337 阅读4分钟

前言

本文仅作为学习经验分享,本人才疏学浅,如有不正确之处,欢迎批评指正。

今日在学习前端工程化的知识时,认识了SPA(单页面应用),MPA(多页面应用)这两种前端架构模式。其实这两种网站的模式很好理解。mpa就是传统的多页面应用,通过文档之间的跳转进行页面切换。spa就是单个html的应用,通过动态更新DOM组件实现界面切换。在使用react,vue这样的前端框架时,构建的项目也都是spa,其中控制页面切换的重要部分就是前端的路由管理。比如react-router,vue-router。

于是我就在想,能不能使用原生的技术实现一个类似前端框架一样的SPA呢。结论是可以的! 即使我没有看过主流框架的源代码,我仍然照葫芦画瓢实现了一个简单的版本。

正文

先列举一下整个的项目结构

project----
        |-----Pages-----
                      |----home.js
                      |----user.js
                      |----about.js
                      |----contact.js
        |-----index.html
        |-----logic.js
        |-----system.js

其中index.html相当于SPA的骨架,logic.js是主要的逻辑文件,维护路由页面跳转,system.js里面用来存放一些api函数。Pages目录存放页面组件,每个组件使用一个.js文件。

<!DOCTYPE html>
<html>
	<head>
		<meta charset="UTF-8">
		<meta name="viewport" content="width=device-width, initial-scale=1.0">
		<title>csr-spa-demo</title>
	</head>
	<body>
		<div id="container"></div>
		<!---渲染页面内容--->
		<script type="module" src="logic.js" ></script>
		<!---路由管理--->
	</body>
</html>

实现思路: 采用window.location.hash作为路由,当hash改变时,触发windowhashchange事件,根据不同的路由,将不同的组件(dom元素)appendChildidcontainerdiv中。

const container = document.querySelector('#container');
import HomePage from './Pages/home.js';
import AboutPage from './Pages/about.js';
import ContactPage from './Pages/contact.js';
import UserPage from './Pages/user.js';
//初始化界面默认home
window.addEventListener('load', () => {
	window.location.hash = '#home';
	container.appendChild(HomePage());
});
//路由管理
window.addEventListener('hashchange', () => {
	const hash = window.location.hash;
	let router,param;
	console.log(hash);
	if(hash.includes('/')){
		[router,param]=hash.slice(1).split('/');
	}else{
		router=hash.slice(1);
	};
	container.innerHTML = '';
	switch (router) {
		case 'home': 
			container.appendChild(HomePage());
		break;
		case 'about': 
			container.appendChild(AboutPage());
		break;
		case 'contact':
			container.appendChild(ContactPage());
		break;
		case 'user':
			container.appendChild(UserPage(param));
		break;
	}
});

路由参数通过分解hash获得,比如用户访问user/123这个路由,就将123解析出来,传给User(123);

每个组件返回的是一个编辑好内容的div元素。拿home.js举例

import {navigate} from '../system.js';
export default function Home() {
	const container=document.createElement('div');
	container.innerHTML=`
		<nav>
		    <a href="#about">About</a>
		    <a href="#contact">Contact</a>
		</nav>
		<div class="content" id="content">Welcome to the Home page!</div>
		<span>Input user's id</span>
		<input type='text'/>
		<button>Click Me</button>
	`;
	container.style.backgroundColor='orange';
	
	container.querySelector('button').addEventListener('click',()=>{
		let userId=container.querySelector('input').value;
		if(!userId){
			userId=0;
		}
		navigate('user/'+userId);
	});
	return container; 
}

我们通过手动编写该组件的innerHTML,style还有内部的逻辑实现组件化。 这里的navigate函数就是system.js中定义的路由跳转函数。

export function navigate(router){
	window.location.hash='#'+router;
}

react中,有一个useEffect()的状态函数,可以实现组件初次加载后执行一些逻辑,比如页面组件加载后,进行ajax请求获取数据,然后渲染页面。

我在user.js中也需要使用这个功能,于是我经过查询,采用MutationObserver对象来监听指定父组件的childList变化,当变化时,触发传入的callback函数,然后解除监听。我在system.js中封装成了onLoad函数.

export function onLoad(node, callback) {
	const config = {
		childList: true
	};
	const oberver = new MutationObserver((mutationsList, observer)=>{
		callback();//执行加载后逻辑
		oberver.disconnect();
	});
	oberver.observe(node,config);
}

然后我在user.js中调用onLoad实现了user.js初次加载后的ajax

export default function User(userid) {
	const container=document.createElement('div');
	container.innerHTML=`
		<nav>
		    <a href="#home">Back to Home</a>
		</nav>
		<div class="content" id="content"></div>
		<button>Click Me</button>
	`;
	container.style.backgroundColor='lightGreen';
	container.querySelector('button').addEventListener('click',()=>{
		alert('User');
	});
	onLoad(document.querySelector('#container'),async()=>{
        //该组件加载后触发
		const response = await fetch('http://localhost:3000/user/'+userid, {
			method: 'GET'
		});
		if(response.ok){
			const data=await response.json();
			container.querySelector('#content').textContent=data.userid;
		}
	});
	return container; 
}

由于onLoad中的监听绑定是先于logic.js中的appendChild,所以当组件加载到父组件(页面组件的父组件是index.html#container)后,就会触发callback,这样就实现了一个组件初次加载后立即执行逻辑。

该SPA的效果如下

页面初始为#home image.png 点击对应的a标签或者触发navigate都会修改hash,达到界面跳转的效果 image.png 当指定了路由参数时,会解析参数然后传给组件,user组件使用onload在加载后发送带有userid参数的GET请求给服务器,服务器返回对应的用户信息(这里为了简化服务器只返回userid),客户端接收后进行动态更新。 image.png

结语

至此,一个简单的组件化的,客户端渲染的SPA就实现了。

本次实践收获良多,也加深了我对前端工程化概念的理解。

感谢您的阅读。