原文地址: http://www.linzichen.cn/article/1577881001718185984
在一些需要做 seo
优化的应用里,比如门户网站、博客论坛网站、商城商品页网站等,我们的数据常常采用 服务端渲染
的方式来展现,目的是为了让爬虫更好的抓取到,从而在搜索引擎搜索时,可以搜到到我们自己的应用网站。
模板引擎
服务端渲染是在服务端通过模板引擎和其特定语法完成数据
和页面DOM
的拼接,然后统一响应给浏览器。市面上存在许许多多五花八门的模板引擎。就以 Java 为例,在 JavaWeb 阶段就存在 JSP
,到了 Springboot 又推荐 Thymeleaf
,还有其他的如 FreeMarker
、velocity
等。每种引擎的语法、特性及优缺点这里就不再赘述,SpringBoot官方推荐使用 Thymeleaf
,且本站也是基于此引擎搭建的,所以本文就以 Thymeleaf
模板引擎为案例来演示。
thymeleaf 案例
先实现页面跳转效果:
公共组件抽取
每个页面都会引入一些公共的 css 或 js,还有一些公共的页面组件,比如上面案例中头部导航栏 和 底部栏,我们可以抽取出来,放在公共组件中。然后直接在页面中引入即可。
在 resources/templates/common
目录中,创建 fragment.html
文件,可以用来存放公共组件。
fragment.html :
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<!-- 公共head -->
<head th:fragment="head(title, links, scripts)">
<meta charset="UTF-8">
<title th:replace="${title}"></title>
<th:block th:replace="${links}" />
<script th:replace="${scripts}"></script>
<style>
ul {overflow: hidden}
li {list-style: none; margin-right: 20px;float: left}
</style>
</head>
<body>
<!-- 公共header -->
<header th:fragment="header">
<ul>
<li><a href="/view" data-pjax>首页</a></li>
<li><a href="/view/a" data-pjax>a页面</a></li>
<li><a href="/view/b" data-pjax>b页面</a></li>
</ul>
</header>
<!-- 公共footer -->
<footer th:fragment="footer">
<p> 这是 footer 部分 </p>
</footer>
</body>
</html>
页面创建
在 resources/templates/view
目录下,创建3个页面:index.html(首页)、a.html 和 b.html。
index.html:
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head th:replace="common/fragment :: head(~{::title}, ~{}, ~{})">
<title>index页面</title>
</head>
<body>
<header th:replace="common/fragment :: header"></header>
<div class="content">
index页面内容
</div>
<footer th:replace="common/fragment :: footer"></footer>
</body>
</html>
其他页面同理,只需改下 title
标签和 <div class="content">
中的内容即可。
路由控制
我们把负责控制页面路由的 controller 单独抽取出来,为了方便管理,给加一个统一的前缀 /view
,这样可以在后期加监控或拦截器时,只需要针对此前缀的路由即可。
@Controller
@RequestMapping("/view")
public class PageController {
@GetMapping(value = {"", "/{name}"})
public ModelAndView page(@PathVariable(value = "name", required = false) String name) {
if (StringUtils.isEmpty(name)) {
name = "index" ;
}
return new ModelAndView("/view/" + name) ;
}
}
因为此处的
@RequestMapping
中统一加了前缀/view
,所以在fragment.html
中a
标签的链接会加上这个前缀,否则路由不到指定的页面。
假设项目运行在 9999
端口,此时启动项目,访问 localhost:9999/view
则会自动跳转到 index.html 。且点击a标签时,与上面案例效果一致。
案例不足的地方
上面案例虽实现了基本页面跳转,且也提取了公共的组件。但是在页面切换时,浏览器是重新加载的,这样会存在几个弊端:
1、公共静态资源重新请求,网页整体响应相对较慢。
2、公共数据服务端重复获取,增加后台服务器压力。
3、扩展性不足,无法定制化需求(比如加一个音乐播放器的功能,一直刷新体验不好)。
针对以上问题,我们希望在页面跳转时,让浏览器不再刷新,且每次请求时,服务器只响应我们想要的数据,即已经加载过的静态资源和公共数据,我们就不让其重新加载了。
解决方案?
前后分离,前端采用 router 跳转
我们知道现在前端框架 vue
或者 react
等都有 router
路由的概念。虽然路由的两种模式 hash
和 history
均可以实现我们想要的效果,但它们属于 客户端渲染
,即通过 js 实现数据与DOM的拼接,不利于我们一开始提到的 seo
,有悖初衷。
后端 forward 转发
我们也可以在controller 层通过 servlet
的 forward
转发来控制页面跳转,但是此种方式不会改变浏览器地址,作为对外网站来说不友好,所以也不推荐此种方式。
pjax
pjax是一个jQuery插件,它通过ajax和pushState技术提供了极速的(无刷新ajax加载)浏览体验,并且保持了真实的地址、网页标题,浏览器的后退(前进)按钮也可以正常使用。
pjax的工作原理是通过ajax从服务器端获取HTML,在页面中用获取到的HTML替换指定容器元素中的内容。然后使用pushState技术更新浏览器地址栏中的当前地址。
以下两点原因决定了pjax会有更快的浏览体验:
1、不存在页面资源(js/css)的重复加载和应用;
2、如果服务器端配置了pjax,它可以只渲染页面局部内容,从而避免服务器渲染完整布局的额外开销。
具体细节使用这里不再赘述,详情可以参考其 github: JQuery-Pjax文档
pjax流程分析
1、在页面中,有些链接我们希望返回的是局部页面,有些则是做其他的处理,所以我们需要把 返回局部页面
的 a标签
上,给加一个标识(例如 data-pjax
),标注此链接是交给 pjax
来管理。
<a href="xxx" data-pjax> pjax链接 </a>
<a href="xxx"> 普通链接 </a>
2、我们希望在每个页面中,存在一个容器 .content
,来存放本页面的主要内容。此容器也是 pjax 请求过来时,我们响应给它的局部html内容。
2、我们希望在每个页面中,提供一个容器 #pjax-container
,用于展示 pjax 请求回来的其他局部页面。在没有 pjax 请求前,此容器中展示自己页面中的 .content
内容。
关键代码实现
pjax 管理 a标签
<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/jquery.pjax/2.0.1/jquery.pjax.min.js"></script>
<li><a href="/view" data-pjax>首页</a></li>
<li><a href="/view/a" data-pjax>a页面</a></li>
<li><a href="/view/b" data-pjax>b页面</a></li>
<div id="pjax-container" >
<div th:fragment="content">
这是index页面的内容
</div>
</div>
<script type="text/javascript">
// 管理具有 data-pjax 属性的 a标签,把a请求回来的内容,填充到本页面的#pjax-container中
$(document).pjax('a[data-pjax]', '#pjax-container');
</script>
controller部分页面
@GetMapping("/a")
public ModelAndView a () {
// 返回a页面中 th:fragment 为 content 的内容
String fragment = "/view/a::content" ;
return new ModelAndView(fragment) ;
}
效果
我们发现,我们在点击 a 标签后,页面没有刷新,且只返回了a 页面的局部内容,可以达到我们预期的效果。
遗留问题
在 controller 层中,对于a页面,只返回了 th:fragment="content"
的内容。如果此链接是通过 pjax 访问的,那么正好是我们想要的效果,只返回局部页面。但如果用户一开始就访问的是 a 页面,会发生什么情况?
答案是仍然只返回 th:fragment 中的内容。
因为我们没有对请求做判断,我们需要判断请求是否是来自 pjax 请求,如果是,我们只返回部分页面。如果不是,我们需要返回整个 a 页面,包含 导航栏和底部栏。
拦截器判断请求是否是 pjax 请求
针对上面遗留的问题,我们只需要判断请求是浏览器第一次加载的,还是通过点击站内链接发起的 pjax 。我们发现,pjax 在发送请求时,会多携带两个请求头信息:
所以我们只需要在拦截器中,判断请求头是否包含此请求头就好了。如果包含,就只返回部分页面,否则就返回整个页面:
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
Boolean pjax = Boolean.parseBoolean(request.getHeader("X-PJAX")) ;
if(pjax) {
modelAndView.setViewName(modelAndView.getViewName() + "::content");
}
}
加上拦截器判断之后,此时我们的跳转就一切正常了。
如图:
项目代码
目录结构
fragment.html
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<!-- 公共head -->
<head th:fragment="head(title, links, scripts)">
<meta charset="UTF-8">
<title th:replace="${title}"></title>
<th:block th:replace="${links}" />
<script th:replace="${scripts}"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/jquery.pjax/2.0.1/jquery.pjax.min.js"></script>
<style>
ul {overflow: hidden}
li {list-style: none; margin-right: 20px;float: left}
</style>
<script type="text/javascript">
// 管理具有 data-pjax 属性的 a标签,把a请求回来的内容,填充到本页面的#pjax-container中
$(document).pjax('a[data-pjax]', '#pjax-container');
</script>
</head>
<body>
<!-- 公共header -->
<header th:fragment="header">
<ul>
<li><a href="/view" data-pjax>首页</a></li>
<li><a href="/view/a" data-pjax>a页面</a></li>
<li><a href="/view/b" data-pjax>b页面</a></li>
</ul>
</header>
<!-- 公共footer -->
<footer th:fragment="footer">
<p> 这是 footer 部分 </p>
</footer>
</body>
</html>
index.html(a/b.html一样)
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head th:replace="common/fragment :: head(~{::title}, ~{}, ~{})">
<title>index页面</title>
</head>
<body>
<header th:replace="common/fragment :: header"></header>
<div id="pjax-container">
<div th:fragment="content">
index页面内容
</div>
</div>
<footer th:replace="common/fragment :: footer"></footer>
</body>
</html>
PageController
@Controller
@RequestMapping("/view")
public class PageController {
@GetMapping(value = {"", "/{name}"})
public ModelAndView page(@PathVariable(value = "name", required = false) String name) {
if (StringUtils.isEmpty(name)) {
name = "index" ;
}
return new ModelAndView("/view/" + name) ;
}
}
InterceptorConfig
@Configuration
public class InterceptorConfig extends WebMvcConfigurationSupport {
@Autowired
private PjaxInterceptor pjaxInterceptor ;
private static final String[] CLASSPATH_RESOURCE_LOCATIONS = {
"classpath:/META-INF/resources/", "classpath:/resources/",
"classpath:/static/", "classpath:/public/" };
@Override
protected void addInterceptors(InterceptorRegistry registry) {
String[] interceptPathList = new String[] {
"/view/**"
};
registry.addInterceptor(pjaxInterceptor).addPathPatterns(interceptPathList) ;
}
@Override
protected void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/**")
.addResourceLocations(CLASSPATH_RESOURCE_LOCATIONS) ;
}
}
PjaxInterceptor
@Component
public class PjaxInterceptor implements HandlerInterceptor {
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
Boolean pjax = Boolean.parseBoolean(request.getHeader("X-PJAX")) ;
if(pjax) {
modelAndView.setViewName(modelAndView.getViewName() + "::content");
}
}
}