目 录CONTENT

文章目录

商品搜索与详情页构建

zhouzz
2024-09-29 / 0 评论 / 0 点赞 / 7 阅读 / 51293 字
温馨提示:
本文最后更新于 2024-10-09,若内容或图片失效,请留言反馈。部分素材来自网络,若不小心影响到您的利益,请联系我们删除。

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内容的模板引擎。类似JSPVelocityFreeMaker等, 它也可以轻易的与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商品部分,输出商品列表信息,代码如下:
20240930013559.png

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)搜索条件存储:

修改SearchControllersearch方法,将搜索条件存储到model中:

model.addAttribute("searchMap",searchMap);

2)页面搜索框配置

修改search.html的搜索框:

20240930014256.png

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中,可以直接在页面回显,如果没有该对象,则不回显。

20240930014641.png

2)品牌回显

品牌条件在result.brandList中,可以直接在页面回显,如果没有该对象,则不回显。
20240930014817.png

3)规格回显

属性回显需要注意,如果用户没有输入该属性,才回显,如果输入了该属性,则不回显,属性是以attr_开始传入后台。

语法同上。

4)价格回显

语法同上。

3.5 搜索条件记录

20240930015451.png

这些条件其实都已经存在在searchMap中了,只需要取出显示即可,但如果是属性回显,就需要把attr_去掉,回显如下:

20240930015626.png

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 动态搜索分析

20240930020232.png

进行搜索的时候,我们可以发现一个规律,选择搜索条件的时候,其实就是将搜索条件作为参数追加到搜索地址后面,移除某个搜索条件的时候,其实就是把搜索参数从搜索路径上移除就可以了。

我们可以在后台定义一个基础的搜索地址/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,代码如下:

20240930020938.png

3)条件移除

20240930020939.png

条件移除是指每次搜索的条件中如果想移除掉某个条件,我们可以把指定参数从当前url中替换成空即可。修改search.html当前已选中的条件,代码如下:

20240930020940.png

3.7 排序实现

20240930020941.png

无论是哪种排序方式,我们可以直接传递2个参数sfieldsm到后台即可:

综合:不带这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:

20240930022028.png

页面排序修改:

20240930022029.png

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-serviceSkuSearchServiceImpl#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)页面分页处理

20240930022030.png

4. 商品详情页静态化处理

在做网站的时候,为了提升网站数据加载效率同时降低数据库负载,一般会将变更频率较低的数据做特殊处理,比如做成静态页、添加缓存,通常网站门户会这么做。商品详情页访问频率非常高,而且数据变更频率非常低,所以完全可以做成静态页。

静态页生成流程如下图:

  • 1.商家添加商品到后台。
  • 2.后台程序生成 item.html文件。
  • 3.之后通过MQ或者 Canal 异步把 item.html更新到 nginx 服务指定的目录。

4.1 页面数据分析

20240930022031.png

页面的图片是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 数据动态切换分析

20240930022032.png

我们可以把京东商城打开,商品详情如上图,每次点击不同属性时,页面根本没有跳动,其实是静态页已经把静态数据加载好了,每次选择不同产品时,直接从页面指定对象中找对应的数据即可,那么每次是怎么匹配的呢?

我们目前已经加载了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="点击取消选择">&nbsp;</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调用实现静态页生成和删除。

5.小结

0

评论区