1.本节目标
1、搜索页面Thymeleaf渲染实战
* 1)商品搜索页模板渲染
* 2)搜索页条件搜索实现
2、商品详情页静态化处理
- 1)商品详情页静态化
- 2)Vue+Thymeleaf静态页属性切换
3、静态页实时更新
* 1)Canal实时监听数据库变更
* 2)实时更新静态页
2. Thymeleaf模板引擎
Thymeleaf
是一种模板语言,它包含数据模型(Data)、模板(Template)、模板引擎(Template Engine)和结果文档(Result Documents
)。
-
数据模型
数据是信息的表现形式和载体,可以是符号、文字、数字、语音、图像、视频等。数据和信息是不可分离的,数据是信息的表达,信息是数据的内涵。数据本身没有意义,数据只有对实体行为产生影响时才成为信息。 -
模板
模板,是一个蓝图,即一个与类型无关的类。编译器在使用模板时,会根据模板实参对模板进行实例化,得到一个与类型相关的类。 -
模板引擎
模板引擎(这里特指用于Web开发的模板引擎)是为了使用户界面与业务数据(内容)分离而产生的,它可以生成特定格式的文档,用于网站的模板引擎就会生成一个标准的HTML文档。 -
结果文档
一种特定格式的文档,比如用于网站的模板引擎就会生成一个标准的HTML文档。
Thymeleaf
是一款用于渲染XML/XHTML/HTML5
内容的模板引擎。类似JSP
,Velocity
,FreeMaker
等, 它也可以轻易的与Spring MVC
等Web框架进行集成作为Web应用的模板引擎。与其它模板引擎相比, Thymeleaf
最大的特点是能够直接在浏览器中打开并正确显示模板页面,而不需要启动整个Web应用。
2.1 Springboot整合thymeleaf
1)在zmall-web-search
工程中引入如下依赖:
<dependencies>
<!--web起步依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!--thymeleaf配置-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!--Nacos-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
</dependencies>
2)编辑配置文件bootstrap.yml
:
server:
port: 8085
spring:
application:
name: zmall-web-search
cloud:
nacos:
config:
file-extension: yaml
server-addr: 192.168.100.130:8848
discovery:
#Nacos的注册地址
server-addr: 192.168.100.130:8848
thymeleaf:
cache: false
suffix: .html
encoding: UTF-8
prefix: classpath:/templates/
3)启动类创建WebSearchApplication
:
@SpringBootApplication
@EnableFeignClients(basePackages = {"com.zmall.api.goods.feign"})
public class WebSearchApplication {
public static void main(String[] args) {
SpringApplication.run(WebSearchApplication.class,args);
}
}
2.2 搜索页适配thymeleaf
将frant
文件下的所有样式、图片、js拷贝到工程的resources/static
目录下,search.html
拷贝到resources/templates
目录下,如下所示:
resources
├─static
│ ├─css
│ ├─js
│ └─images
├─templates
│ └─search.html
└─bootstrap.yml
将search.html
中所有相对路径换成绝对路径,也就是将"./"换成"/",把search.html
中头部的<html>
换成<html xmlns:th="http://www.thymeleaf.org">
创建控制器SearchController
,代码如下:
@Controller
@RequestMapping(value = "/web/search")
public class SearchController {
/****
* 搜索页面跳转
* @return
*/
@GetMapping
public String search(@RequestParam(required = false)Map<String,Object> searchMap, Model model){
//搜索
RespResult<Map<String, Object>> resultMap = skuSearchFeign.search(searchMap);
//组装用户访问的url
model.addAttribute("url", UrlUtils.map2url("/web/search",searchMap,"page"));
model.addAttribute("urlsort", UrlUtils.map2url("/web/search",searchMap,"sm","sfield","page"));
model.addAttribute("result",resultMap.getData());
model.addAttribute("searchMap",searchMap);
return "search";
}
}
访问http://localhost:8085/web/search
,即可看到 search页面。
3.搜索页面Thymeleaf渲染
3.1 搜索数据接口
搜索数据渲染我们需要调用搜索服务的 skuSearchFeign。
3.2 列表加载
3.2.1 列表语法
th:each
对象遍历,功能类似jstl中的<c:forEach>
标签。
<tr th:each="user,userStat:${users}">
<td>
下标:<span th:text="${userStat.index}"></span>,
</td>
<td th:text="${user.id}"></td>
<td th:text="${user.name}"></td>
<td th:text="${user.address}"></td>
</tr>
th:text
:输出指定数据,例如th:text="${user.address}"
表示输出user对象中的address属性。
th:src
:加载指定图片
3.2.2 列表加载实现
修改search.html
商品部分,输出商品列表信息,代码如下:
3.3 关键词搜索回显
3.3.1 语法说明
th:value
给指定表单赋值。
#maps.containsKey(searchMap,'keywords')
:判断map对象searchMap
中是否包含keywords
的key。
三元表达式:th:value="${#maps.containsKey(searchMap,'keywords')}? ${searchMap.keywords}:''"
3.3.2 搜索实现
用户输入关键词,后台会用searchMap
接收,接收后,前台需要显示,我们可以把searchMap
再存入到model
中,在页面搜索框中回显搜索条件。
1)搜索条件存储:
修改SearchController
的search
方法,将搜索条件存储到model中:
model.addAttribute("searchMap",searchMap);
2)页面搜索框配置
修改search.html
的搜索框:
3)展示优化
可以看到商品列表中标题html标签没有渲染,我们只需要把之前商品名字展示标签换成th:utext
即可,这样就能识别html标签了。
<a target="_blank" href="item.html" th:utext="${item.name}"></a>
3.4 搜索条件回显
3.4.1 语法说明
th:if
:条件成立,则显示
th:unless
:条件不成立则显示
th:each
:循环(前面用过)
th:text
:文本显示
3.4.2 条件回显
1)分类条件回显
分类条件在result.categoryList
中,可以直接在页面回显,如果没有该对象,则不回显。
2)品牌回显
品牌条件在result.brandList
中,可以直接在页面回显,如果没有该对象,则不回显。
3)规格回显
属性回显需要注意,如果用户没有输入该属性,才回显,如果输入了该属性,则不回显,属性是以attr_
开始传入后台。
语法同上。
4)价格回显
语法同上。
3.5 搜索条件记录
这些条件其实都已经存在在searchMap中了,只需要取出显示即可,但如果是属性回显,就需要把attr_去掉,回显如下:
3.6 动态搜索实现
3.6.1 语法说明
${#strings.replace(str,x,y)
字符串替换成指定的y
th:href
:a标签的超链接,th:href="@{${#strings.replace(url,'price='+searchMap.price,'')}}"
3.6.2 动态搜索分析
进行搜索的时候,我们可以发现一个规律,选择搜索条件的时候,其实就是将搜索条件作为参数追加到搜索地址后面,移除某个搜索条件的时候,其实就是把搜索参数从搜索路径上移除就可以了。
我们可以在后台定义一个基础的搜索地址/web/search
,每次执行搜索的时候,搜索参数会存入到searchMap中,我们可以将searchMap中的参数拼接到基础搜索地址后面作为参数,如果下次增加搜索条件,直接在它后面追加搜索条件即可,如果是减少搜索条件,在它后面移除指定条件即可。
3.6.3 动态搜索
1)当前URL生成
用户每次请求,我们需要根据当前提交的搜索条件生成当前的URL地址,这里提供了UrlUtils
。
该工具类中有三个方法
- ①将Map参数转成URL的参数
- ②提供baseUrl和Map,组装一个完整的Url
- ③去掉Url中指定参数,代码如下:
public class UrlUtils {
/**
* 去掉URL中指定的参数
*/
public static String replateUrlParameter(String url,String... names){
for (String name : names) {
url = url.replaceAll("(&"+name+"=([0-9\\w]+))|("+name+"=([0-9\\w]+)&)|("+name+"=([0-9\\w]+))", "");
}
return url;
}
/***
* 当前请求地址组装
*/
public static String map2url(String baseUrl,Map<String,Object> searchMap){
//参数获取
String parm = map2parm(searchMap);
if(!StringUtils.isEmpty(parm)){
baseUrl+="?"+parm;
}
return baseUrl;
}
/**
* 将map转换成url参数
* @param map
* @return
*/
public static String map2parm(Map<String, Object> map) {
if (map == null) {
return "";
}
StringBuffer sb = new StringBuffer();
for (Map.Entry<String, Object> entry : map.entrySet()) {
sb.append(entry.getKey() + "=" + entry.getValue());
sb.append("&");
}
String parameters = sb.toString();
if (parameters.endsWith("&")) {
parameters = StringUtils.substringBeforeLast(parameters ,"&");
}
return parameters;
}
}
这里同时添加commons-lang3
依赖:
<dependencies>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
</dependencies>
修改SearchController#search
添加当前地址生成调用:
model.addAttribute("url", UrlUtils.map2url("/web/search",searchMap,"page"));
2)页面动态调用
每次动态调用其实就是在当前url上添加指定条件,修改search.html
的搜索条件,给每个搜索条件添加url,代码如下:
3)条件移除
条件移除是指每次搜索的条件中如果想移除掉某个条件,我们可以把指定参数从当前url中替换成空即可。修改search.html
当前已选中的条件,代码如下:
3.7 排序实现
无论是哪种排序方式,我们可以直接传递2个参数sfield
和sm
到后台即可:
综合:不带这2个参数的url
新品:sfield=updateTime,sm=DESC
价格+:sfield=price,sm=ASC
价格-:sfield=price,sm=DESC
我们需要一个不带sfield和sm这两个参数的url,可以在后台生成一个,修改com.gupaoedu.vip.mall.search.controller.SearchController#search
,添加一个没有排序参数的url:
页面排序修改:
3.8 分页
每次分页携带的分页参数是page,而page每次必须和上次不同,所以我们需要将url中上一次搜索的page参数去掉,而且每次需要计算分页。
public class PageInfo implements Serializable {
// 页数(第几页)
private long currentPage;
// 查询数据库里面对应的数据有多少条
private long total;
// 每页查5条
private int size;
// 下页
private int next;
// 最后一页
private int last;
private int lpage;
private int rpage;
//从哪条开始查
private long start;
//全局偏移量
public int offSize = 2;
public PageInfo() {
super();
}
/****
*
* @param currentPage
* @param total
* @param pageSize
*/
public void setCurrentPage(long currentPage, long total, long pageSize) {
//如果整除表示正好分N页,如果不能整除在N页的基础上+1页
int totalPages = (int) (total % pageSize == 0 ? total / pageSize : (total / pageSize) + 1);
//总页数
this.last = totalPages;
//判断当前页是否越界,如果越界,我们就查最后一页
if (currentPage > totalPages) {
this.currentPage = totalPages;
} else {
this.currentPage = currentPage;
}
//计算start
this.start = (this.currentPage - 1) * pageSize;
}
//上一页
public long getUpper() {
return currentPage > 1 ? currentPage - 1 : currentPage;
}
//总共有多少页,即末页
public void setLast(int last) {
this.last = (int) (total % size == 0 ? total / size : (total / size) + 1);
}
/****
* 带有偏移量设置的分页
* @param total
* @param currentPage
* @param pageSize
* @param offSize
*/
public PageInfo(long total, int currentPage, int pageSize, int offSize) {
this.offSize = offSize;
initPage(total, currentPage, pageSize);
}
/****
*
* @param total 总记录数
* @param currentPage 当前页
* @param pageSize 每页显示多少条
*/
public PageInfo(long total, int currentPage, int pageSize) {
initPage(total, currentPage, pageSize);
}
/****
* 初始化分页
* @param total
* @param currentPage
* @param pageSize
*/
public void initPage(long total, int currentPage, int pageSize) {
//总记录数
this.total = total;
//每页显示多少条
this.size = pageSize;
//计算当前页和数据库查询起始值以及总页数
setCurrentPage(currentPage, total, pageSize);
//分页计算
//需要向上一页执行多少次
int leftCount = this.offSize;
int rightCount = this.offSize;
//起点页
this.lpage = currentPage;
//结束页
this.rpage = currentPage;
//2点判断
//正常情况下的起点
this.lpage = currentPage - leftCount;
//正常情况下的终点
this.rpage = currentPage + rightCount;
//页差=总页数和结束页的差
//判断是否大于最大页数
int topDiv = this.last - rpage;
/***
* 起点页
* 1、页差<0 起点页=起点页+页差值
* 2、页差>=0 起点和终点判断
*/
this.lpage = topDiv < 0 ? this.lpage + topDiv : this.lpage;
/***
* 结束页
* 1、起点页<=0 结束页=|起点页|+1
* 2、起点页>0 结束页
*/
this.rpage = this.lpage <= 0 ? this.rpage + (this.lpage * -1) + 1 : this.rpage;
/***
* 当起点页<=0 让起点页为第一页
* 否则不管
*/
this.lpage = this.lpage <= 0 ? 1 : this.lpage;
/***
* 如果结束页>总页数 结束页=总页数
* 否则不管
*/
this.rpage = this.rpage > last ? this.last : this.rpage;
}
public long getNext() {
return currentPage < last ? currentPage + 1 : last;
}
public void setNext(int next) {
this.next = next;
}
public long getCurrentPage() {
return currentPage;
}
public long getTotal() {
return total;
}
public void setTotal(long total) {
this.total = total;
}
public long getSize() {
return size;
}
public void setSize(int size) {
this.size = size;
}
public long getLast() {
return last;
}
public long getLpage() {
return lpage;
}
public void setLpage(int lpage) {
this.lpage = lpage;
}
public long getRpage() {
return rpage;
}
public void setRpage(int rpage) {
this.rpage = rpage;
}
public long getStart() {
return start;
}
public void setStart(long start) {
this.start = start;
}
public void setCurrentPage(long currentPage) {
this.currentPage = currentPage;
}
}
1)分页封装
在zmall-search-service
的SkuSearchServiceImpl#search
实现分页,代码如下:
int currentpage = queryBuilder.build().getPageable().getPageNumber()+1;
PageInfo pageInfo = new PageInfo(page.getTotalElements(),currentpage, 5);
resultMap.put("pageInfo",pageInfo);
2)分页url处理
修改SearchController#search
,代码如下:
// 去掉 page
model.addAttribute("url", UrlUtils.map2url("/web/search",searchMap,"page"));
3)页面分页处理
4. 商品详情页静态化处理
在做网站的时候,为了提升网站数据加载效率同时降低数据库负载,一般会将变更频率较低的数据做特殊处理,比如做成静态页、添加缓存,通常网站门户会这么做。商品详情页访问频率非常高,而且数据变更频率非常低,所以完全可以做成静态页。
静态页生成流程如下图:
- 1.商家添加商品到后台。
- 2.后台程序生成 item.html文件。
- 3.之后通过MQ或者 Canal 异步把 item.html更新到 nginx 服务指定的目录。
4.1 页面数据分析
页面的图片是Spu中的图片集合,标题是当前选中的Sku标题,属性数据来源于Spu中的attribute_list
属性,它记录了当前Spu所有Sku的属性集合,如:`
{
"颜色": [
"金色",
"银色",
"黑色"
],
"内存容量": [
"16G",
"64G",
"128G"
]
}
我们要想生成静态页,需要同时查询Spu和对应Sku集合以及三级分类。
在此之前,把 frant
目录下的 item.html
放入到 zmall-page-web
项目中。
4.2 详情页数据加载
这里就是提供 sku,spu 数据的feign接口。
4.3 静态页生成
在zmall-page-web
创建PageController
,代码如下:
1)Controller
@RestController
@RequestMapping(value = "/page")
public class PageController {
@Autowired
private PageService pageService;
/****
* 生成静态页
*/
@GetMapping(value = "/{id}")
public RespResult html(@PathVariable(value = "id")String id) throws Exception {
pageService.html(id);
return RespResult.ok();
}
}
2)Service
public interface PageService {
//生成静态页
void html(String skuid) throws FileNotFoundException, UnsupportedEncodingException;
}
在zmall-page-web
中创建PageServiceImpl
,代码如下:
@Service
public class PageServiceImpl implements PageService {
@Autowired
private CategoryFeign categoryFeign;
@Autowired
private SpuFeign spuFeign;
@Value("${pagepath}")
private String pagepath;
@Autowired
private TemplateEngine templateEngine;
/****
* 生成静态页
* @param skuid
* @throws Exception
*/
@Override
public void html(String skuid) throws Exception {
//1、创建容器对象(上下文对象)
Context context = new Context();
//2、设置模板数据
context.setVariables(loadData(skuid));
//3、指定文件生成后存储路径
File file = new File(pagepath,skuid+".html");
PrintWriter writer = new PrintWriter(file,"UTF-8");
//4、执行合成生成
templateEngine.process("item",context,writer);
}
/****
* 数据加载
*/
public Map<String,Object> loadData(String skuid){
//查询数据
RespResult<Sku> skuResult = skuFeign.one(skuid);
Long spuid = skuResult.getData().getSpuid();
RespResult<Product> productResult = spuFeign.one(spuid);
Product product = productResult.getData();
if(product!=null){
//Map
Map<String,Object> resultMap = new HashMap<String,Object>();
//Spu
Spu spu = product.getSpu();
resultMap.put("spu",spu);
//图片处理
resultMap.put("images",spu.getImages().split(","));
//属性列表
resultMap.put("attrs",JSON.parseObject(spu.getAttributeList(),Map.class));
//三级分类
RespResult<Category> one = categoryFeign.one(spu.getCategoryOneId());
RespResult<Category> two = categoryFeign.one(spu.getCategoryTwoId());
RespResult<Category> three = categoryFeign.one(spu.getCategoryThreeId());
resultMap.put("one",one.getData());
resultMap.put("two",two.getData());
resultMap.put("three",three.getData());
//Sku集合
List<Map<String,Object>> skuList = new ArrayList<Map<String,Object>>();
for (Sku sku : product.getSkus()) {
Map<String,Object> skuMap = new HashMap<String,Object>();
skuMap.put("id",sku.getId());
skuMap.put("name",sku.getName());
skuMap.put("price",sku.getPrice());
skuMap.put("attr",sku.getSkuAttribute());
//将skuMap添加到skuList中
skuList.add(skuMap);
}
resultMap.put("skuList",skuList);
return resultMap;
}
return null;
}
}
4.4 数据绑定
同样用的是 一些不会变化的数据按照 Thymeleaf语法设置即可,这里不再重复。
4.5 Vue+Thymeleaf静态页属性切换
4.5.1 数据动态切换分析
我们可以把京东商城打开,商品详情如上图,每次点击不同属性时,页面根本没有跳动,其实是静态页已经把静态数据加载好了,每次选择不同产品时,直接从页面指定对象中找对应的数据即可,那么每次是怎么匹配的呢?
我们目前已经加载了Sku集合,Sku集合中有一个属性sku_attribute,它记录了每个Sku的属性集合,用户在页面选择不同Sku组合的时候,其实最终组合起来一定是某个Sku的sku_attribute的值,而且该值不可能重复,所以我们可以利用这个特性来实现对应Sku的查找。
4.5.2 Vue+Thymeleaf数据绑定
1)默认数据
我们将所有Sku集合中第一个商品作为默认Sku,可以点定义一个集合skulist
接收所有skulist,再定义一个sku
存储当前选中的Sku,定义一个cattr
存储当前选中的sku的属性。
代码如下:
<script th:inline="javascript">
new Vue({
el: '#app',
data() {
return {
//Sku集合
skulist: [[${skulist}]],
//当前Sku
sku:{},
//当前选中的属性
cattr:{}
}
},
created:function () {
//默认选中第1个sku
this.sku=JSON.parse(JSON.stringify(this.skulist[0]))
//选中的属性
this.cattr =JSON.parse(this.skulist[0].attr)
}
})
</script>
2)选中Sku属性匹配
选中某一个Sku后,我们需要根据用户选择的属性从skulist中所有Sku的attr
进行匹配,如果匹配上了,则表示用户选择是该商品,如果匹配失败,表示不是该商品,继续匹配。
我们需要先编写一个方法,实现2个Map对象匹配:
代码如下:
//匹配2个map是否相同
sameMap(map1,map2){
//循环第1个map
for(var key in map1){
//匹配当前相同key的值是否相同
if(map1[key]!=map2[key]){
return false;
}
}
return true;
}
3)选中Sku匹配
用户每次选择不同属性,我们把属性存入到当前选中sku的属性cattr
中,然后从skulist中进行匹配:
//sku匹配
choosesku(key,value){
//将key和value填充到cattr中
this.$set(this.cattr,key,value)
//循环匹配
for(var i=0;i<this.skulist.length;i++){
//匹配,则返回true
if(this.sameMap(JSON.parse(this.skulist[i].attr),this.cattr)){
this.sku=JSON.parse(JSON.stringify(this.skulist[i]))
return;
}
}
//没有找到,给默认值
this.sku.id=0;
this.sku.name="该商品已下架";
this.sku.price=0;
},
choosesku
方法调用:
<a href="javascript:;" th:@click="|choosesku('${attr.key}','${opt}')|"
th:v-bind:class="|{selected:ischoose('${attr.key}','${opt}')}|">
<i th:text="${opt}"></i>
<span title="点击取消选择"> </span>
</a>
4)样式切换
样式为class="selected"
,我们可以写一个方法,将当前的属性名和属性值传入到方法中,在cattr
中判断是否存在,如果存在,则表示要选中它,否则不选中。
//样式匹配
ischoose(key,value){
if(this.cattr!=undefined && this.cattr[key]==value){
return true;
}
return false;
},
页面选中样式:
th:v-bind:class="|{selected:ischoose('${attr.key}','${opt}')}|"
4.6 静态页实时同步
静态页同步,我们可以在监听sku表变化,一旦发生变更,可以直接通过feign调用实现静态页生成和删除。
评论区