前端路由以及页面渲染的实现原理

227 阅读9分钟

前言

在现代前端开发中,如何实现流畅的页面切换和高效的内容渲染是关键问题。前端路由用于管理页面跳转,主要有基于 URL 片段的 Hash 路由 和依赖浏览器历史记录的 History 路由。同时,页面渲染方式CSRSSR)在性能优化和用户体验上扮演着重要角色。

本文将以项目架构模式为切入点,逐步探讨这些技术的原理和特点。

SPA和MPA

SPA(单页应用)MPA(多页应用) 是两种常见的前端应用架构模式

SPA(单页应用) :整个应用加载一次 index.html 文件,后续页面切换通过 JavaScript 管理路由,不依赖服务端返回完整的 HTML 页面。内容动态渲染时通过 API 获取数据,常见实现工具有 Vue RouterReact Router

MPA(多页应用):每个页面对应一个独立的 HTML 文件,页面之间的跳转是通过浏览器发起 HTTP 请求实现的。每次用户切换页面,浏览器都会向服务端发送请求,服务端返回包含完整 HTML 的响应(通常还包括页面所需的 CSS 和 JS 文件)。浏览器接收到响应后,重新加载和渲染页面。

SPA 的优缺点

优点

  • 用户体验好:页面切换快,无需重新加载,类似桌面应用。
  • 开发效率高:代码可重用性强,前后端职责分离。
  • 服务器压力小:减少服务器渲染压力,只返回数据。
  • 前端技术主导:使用现代框架提供的状态管理和路由系统,提高开发效率。

缺点

  • 首次加载慢:需要加载较大的 JS 和 CSS 文件。
  • SEO 不友好:单页应用的内容通常通过 JS 动态渲染,爬虫难以抓取,需要 SSR 或预渲染解决。
  • 前端复杂度高:需要解决前端路由、状态管理、缓存等问题。

MPA 的优缺点

优点

  • SEO 友好:每个页面都是独立的 HTML,天然支持爬虫抓取。
  • 首次加载快:每个页面只加载需要的资源,按需加载资源。
  • 简单易用:适合传统项目,开发逻辑简单。

缺点

  • 页面切换慢:每次页面跳转都会重新加载 HTML 和资源。
  • 用户体验较差:可能会有白屏和闪烁。
  • 代码复用性差:每个页面可能有重复的代码和资源。

什么是SEO优化

上面无论是SPA还是MPA都提到了SEO优化,那什么是SEO优化呢?为什么说SPA不利于SEO优化

SEO(Search Engine Optimization)指通过优化网站内容和结构,提高在搜索引擎中的排名。搜索引擎爬虫(如 Googlebot)通过解析网页 HTML 抓取内容,并计算权重。当用户输入某个关键词搜索引擎会把匹配到的网站按照权重依次展示。如果页面的内容是基于客户端渲染或者基于JS动态绑定的,则在网站的源代码中,是没有这些渲染内容的。

对于 SPA 应用,由于内容通常是通过 JavaScript 动态渲染,爬虫抓取到的可能是空白或不完整内容,这对 SEO 不利。因此,解决方案包括:

  • 服务端渲染(SSR):在服务端完成 HTML 内容渲染后返回完整页面。
  • 预渲染:通过工具生成静态 HTML,提供给爬虫。

前端路由机制

hash

hash路由也称哈希路由,是基于 URL 的 #(哈希)部分实现,路由切换时修改 location.hash 值,但是页面并不会刷新也就是不会像传统的URL一样向服务器发送新的页面请求

路由切换逻辑:

  1. 监听 window.onhashchange 事件,捕获哈希值变化。
  2. 通过路由表匹配哈希值对应的组件或内容。
  3. 将匹配结果渲染到指定容器。

首先我们进行最基本的页面编写

<body>
  <nav class="nav-container">
    <a href="#/">首页</a>
    <a href="#/about">关于</a>
    <a href="#/personal">我的</a>
  </nav>

  <div class="view-container">
    首页的内容
  </div>
</body>

hashbase-google.png

然后我们进行切换一下,看看地址栏的变化

hashswitch-google.png

我们可以看到地址栏最后多了个#/about,切换另外两个分别是#/#/personal,这些值也是用户操作后对应页面的hash值。我们可以根据对应变化的hash值来指定页面,首先先指定一个默认页,例如首页为默认页,将首页内容进行渲染。

同时还要监听hash值的变化,拿hash值跟路由表进行匹配,将匹配到的内容放到容器中渲染。

const viewCon = document.querySelector(".view-container");

const routes = [
    {
      path: "/",
      component: "首页的内容",
    },
    {
      path: "/about",
      component: "关于的内容",
    },
    {
      path: "/personal",
      component: "个人中心的内容",
    }
];

const matchRoute = function matchRoute() {
    let hash = location.hash.substr(1),
        component = "404页面";

    let item = routes.find((item) => item.path === hash);
    if (item) component = item.component;
    viewCon.innerHTML = component;
};

location.hash = "#/";
matchRoute();

window.onhashchange = matchRoute;

history

history路由是基于 HTML5 的 History API 实现,通过 pushStatereplaceState 修改浏览器历史记录。

HistoryAPI核心方法:

  • history.pushState 跳转到一个新的地址,并新增一条历史记录
  • history.replaceState 跳转到一个新的地址,但是会替换现有的历史记录
  • history.back 回退到上一条
  • history.forward 快进到下一条
  • history.go 跳转到指定索引这一条,例如history.go(-1)等价于history.back()

基于这些方法进行路由切换的时候,页面是不会刷新的 , 另外它也不会改变URL中的hash值,而是通过修改URL中的pathnamesearch来实现,所以使用history模式时,URL是没有#符号。

首先我们同样是设置一个默认页面,然后点击a标签进行页面的切换,不过a标签点击时会默认跳转,所以点击a标签时我们首先要阻止这个默认行为,利用HistoryAPI进行页面跳转,跳转后拿到location.pathname的值去路由表中进行匹配,,将匹配到的组件/内容放到指定容器中渲染。另外当我们触发前进/后退操作时也需要进行路由匹配。

  const viewCon = document.querySelector(".view-container"),
    navCon = document.querySelector(".nav-container");

  const routes = [
    {
      path: "/",
      component: "首页的内容",
    },
    {
      path: "/about",
      component: "关于的内容",
    },
    {
      path: "/personal",
      component: "个人中心的内容",
    },
  ];

  const matchRoute = function matchRoute() {
    const { component = "404页面" } =
      routes.find((route) => window.location.pathname === route.path) || {};
    viewCon.innerHTML = component;
  };

  navCon.onclick = function (e) {
    e.preventDefault();
    if (e.target.tagName === "A") {
      history.pushState(null, null, e.target.href);
      matchRoute();
    }
  };

  history.pushState(null, null, "/");
  matchRoute();

  window.onpopstate = matchRoute;

注:我们在一开始提到SPA 只包含一个html文件,每一次路由切换的那个地址是不存在的,平时基于HistoryAPI切换的时候,因为页面没有刷新,所以可以正常去匹配渲染,但是当我们在此地址下手动刷新或者直接访问某个路由时,浏览器就会请求该地址资源,但是对应的路径在服务端不存在所以就会报404错误!此时我们需要服务器的支持,当我们访问一个并不存在的页面时,服务器不能返回404,而是把唯一真正的页面内容返回,此时我们才可以在其路由处理的机制下,决定渲染哪些内容。

知道了前端是如何实现路由的下一节我们来看下如何创建两种模式对应的node服务,并且从资源的请求和返回方面查看是否验证了我们上面的描述

创建两种模式对应node服务

首先我们创建个项目,先后分别启用hash路由和history路由

const router = createRouter({
	// history: createWebHashHistory(), // hash 模式
	history: createWebHistory(), // history 模式
	routes
})

然后基于这两种模式分别进行打包,得到打包之后的项目,分别命名为hash-disthistory-dist

hash的node实现

接下来我们创建对应的后端服务,在此我是基于node.js实现的(没有借助node相关框架)

const http = require('http')
const fs = require('fs')
const path = require('path')
const httpPort = 3000
const readName = 'hash-dist'

/**
 * 根据不同的 url ,返回不同的 contentType
 */
function getContentType(url) {
	const extname = path.extname(url)
	switch (extname) {
		case '.html':
			return 'text/html; charset=utf-8'
		case '.js':
			return 'text/javascript'
		case '.css':
			return 'text/css'
		default:
			return 'application/octet-stream'
	}
}

// 创建服务
http
	.createServer((req, res) => {
		const url = req.url

		console.log(`服务端接收到了前端的请求,url为:${url}`)
		// 处理根目录请求
		if (url === '/') {
			// 读取 index.html 文件
			fs.readFile(`./${readName}/index.html`, (err, data) => {
				// 返回 index.html 文件内容
				res.writeHead(200, { 'Content-Type': 'text/html' })
				res.end(data)
			})
		} else {
			// 处理静态资源请求
			fs.readFile(`./${readName}${url}`, (err, data) => {
				if (err) {
					// 返回 404 错误
					res.writeHead(404, { 'Content-Type': 'text/plain' })
					res.end('404 - Not Found')

				} else {
					// 返回静态资源文件内容
					res.writeHead(200, { 'Content-Type': getContentType(url) })
					res.end(data)
				}
			})
		}
	})
	.listen(httpPort, () => {
		console.log(`Server listening on: http://localhost:${httpPort}`, httpPort)
	})

hash-node.png 然后我们查看浏览器项目的运行

hash-google.png

hash-google2.png

虽然我们切换了路由,hash改变但是我们会发现请求并不会包含#及之后内容,由此可见hash值并不会真正影响服务端的返回内容。而且我们也可以再次查看node服务打印输出来验证这一点(这是切换了about后的服务打印)

hash-node2.png

如果细心观察会发现当我们切换页面时,并没有发送新的页面请求,而是请求了一个js文件。

以上就是hash对应node服务的创建了,并且从图示以及服务打印情况来看也是符合我们上面所总结的。

history的node实现

我们将readName改为history-dist然后重新启动服务,发现url中不存在#,network请求与url一致。切换到/about中,network中也是会同hash模式一样多出一个about.xxx.js的资源请求。我们尝试在http://localhost:3000/about页面进行刷新,发现得到了一个404错误,如下所示:

history-google.png

原因我们在上面history部分已经具体阐述过。总结就是因为项目中只有一个index.html文件,并没有对应的about.html文件,所以请求后报错404,如果想要解决这个错误,我们可以修改代码:

...
if (url === '/') {
		...
	} else {
			// 处理静态资源请求
		fs.readFile(`./${readName}${url}`, (err, data) => {
			if (err) {
				// 依然返回 index.html
				fs.readFile(`./${readName}/index.html`, (err, data) => {
					// 返回 index.html 文件内容
					res.writeHead(200, { 'Content-Type': 'text/html' })
					res.end(data)
				})
			} else {
				...
		}
...

页面渲染分类

CSR

CSR又称客户端渲染(Client Side Render),是指页面的 HTML 框架先从服务器加载到浏览器,具体的内容和交互通过 JavaScript 动态生成。

工作流程:

  1. 用户请求页面,服务器返回一个基本的 包含JS、CSS的HTML 文件,文件中只包含一个根节点,如

    这个阶段页面的<div id="app"></div>是空的,没有内容展示,所以是白屏状态

  2. 浏览器解析<script src="main.js"></script>后,向服务器请求main.js文件。待main.js加载完毕并开始执行后,页面的实际内容会被插入到 <div id="app"></div>

  3. 动态生成内容后,用户才能看到完整的页面。

SSR

SSR又称服务端渲染(Server Side Render),是指页面在服务器生成完整的 HTML,并将其返回给浏览器进行展示。

工作流程:

  1. 用户请求页面,服务器根据路由确定要渲染的页面组件生成完整的 HTML。
  2. 浏览器接收到 HTML 后立即显示内容。
  3. 页面加载完成后,前端框架接管页面的交互逻辑(称为“水合”或“Hydration”)。

Prerender

Prerender是一种在构建(编译)阶段生成静态 HTML 文件的技术。相比传统的动态渲染方式,预渲染将页面内容提前生成好,在用户访问时直接返回静态 HTML,无需动态计算,提高加载速度和 SEO 效果。

优点:

  • 加载速度快: 静态文件可以通过 CDN 分发,减少服务器压力。
  • SEO 友好: 搜索引擎爬取到完整 HTML。
  • 简单易用: 无需服务器复杂处理。

缺点:

  • 不适合频繁更新的数据: 每次内容更新需要重新构建静态页面。
  • 动态交互复杂: 动态内容需要依赖前端代码额外加载。

以上就是本篇所有内容,完结,撒花🎉