11.elasticsearch2

0.學習目標

  • 獨立編寫數據導入功能
  • 獨立實現基本搜索
  • 獨立實現頁面分頁
  • 獨立實現結果排序

1.索引庫數據導入

昨天我們學習了Elasticsearch的基本應用。今天就學以致用,搭建搜索微服務,實現搜索功能。

1.1.創建搜索服務

創建module:
在這裏插入圖片描述

在這裏插入圖片描述

Pom文件:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>leyou</artifactId>
        <groupId>com.leyou.parent</groupId>
        <version>1.0.0-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.leyou.search</groupId>
    <artifactId>leyou-search</artifactId>
    <version>1.0.0-SNAPSHOT</version>

    <dependencies>
        <!-- web -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!-- elasticsearch -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-elasticsearch</artifactId>
        </dependency>
        <!-- eureka -->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
        </dependency>
        <!-- feign -->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-openfeign</artifactId>
        </dependency>
    </dependencies>
</project>

application.yml:

server:
  port: 8083
spring:
  application:
    name: search-service
  data:
    elasticsearch:
      cluster-name: elasticsearch
      cluster-nodes: 192.168.56.101:9300
eureka:
  client:
    service-url:
      defaultZone: http://127.0.0.1:10086/eureka
  instance:
    lease-renewal-interval-in-seconds: 5 # 每隔5秒發送一次心跳
    lease-expiration-duration-in-seconds: 10 # 10秒不發送就過期
    prefer-ip-address: true
    ip-address: 127.0.0.1
    instance-id: ${spring.application.name}:${server.port}

啓動類:

@SpringBootApplication
@EnableDiscoveryClient
@EnableFeignClients
public class LySearchService {

    public static void main(String[] args) {
        SpringApplication.run(LySearchService.class, args);
    }
}

1.2.索引庫數據格式分析

接下來,我們需要商品數據導入索引庫,便於用戶搜索。

那麼問題來了,我們有SPU和SKU,到底如何保存到索引庫?

1.2.1.以結果爲導向

大家來看下搜索結果頁:
在這裏插入圖片描述

可以看到,每一個搜索結果都有至少1個商品,當我們選擇大圖下方的小圖,商品會跟着變化。

因此,搜索的結果是SPU,即多個SKU的集合

既然搜索的結果是SPU,那麼我們索引庫中存儲的應該也是SPU,但是卻需要包含SKU的信息。

1.2.2.需要什麼數據

再來看看頁面中有什麼數據:
在這裏插入圖片描述

直觀能看到的:圖片、價格、標題、副標題

暗藏的數據:spu的id,sku的id

另外,頁面還有過濾條件:
在這裏插入圖片描述

這些過濾條件也都需要存儲到索引庫中,包括:

商品分類、品牌、可用來搜索的規格參數等

綜上所述,我們需要的數據格式有:

spuId、SkuId、商品分類id、品牌id、圖片、價格、商品的創建時間、sku信息集、可搜索的規格參數

1.2.3.最終的數據結構

我們創建一個類,封裝要保存到索引庫的數據,並設置映射屬性:

@Document(indexName = "goods", type = "docs", shards = 1, replicas = 0)
public class Goods {
    @Id
    private Long id; // spuId
    @Field(type = FieldType.Text, analyzer = "ik_max_word")
    private String all; // 所有需要被搜索的信息,包含標題,分類,甚至品牌
    @Field(type = FieldType.Keyword, index = false)
    private String subTitle;// 賣點
    private Long brandId;// 品牌id
    private Long cid1;// 1級分類id
    private Long cid2;// 2級分類id
    private Long cid3;// 3級分類id
    private Date createTime;// 創建時間
    private List<Long> price;// 價格
    @Field(type = FieldType.Keyword, index = false)
    private String skus;// sku信息的json結構
    private Map<String, Object> specs;// 可搜索的規格參數,key是參數名,值是參數值
}

一些特殊字段解釋:

  • all:用來進行全文檢索的字段,裏面包含標題、商品分類信息

  • price:價格數組,是所有sku的價格集合。方便根據價格進行篩選過濾

  • skus:用於頁面展示的sku信息,不索引,不搜索。包含skuId、image、price、title字段

  • specs:所有規格參數的集合。key是參數名,值是參數值。

    例如:我們在specs中存儲 內存:4G,6G,顏色爲紅色,轉爲json就是:

    {
        "specs":{
            "內存":[4G,6G],
            "顏色":"紅色"
        }
    }
    

    當存儲到索引庫時,elasticsearch會處理爲兩個字段:

    • specs.內存:[4G,6G]
    • specs.顏色:紅色

    另外, 對於字符串類型,還會額外存儲一個字段,這個字段不會分詞,用作聚合。

    • specs.顏色.keyword:紅色

1.3.商品微服務提供接口

索引庫中的數據來自於數據庫,我們不能直接去查詢商品的數據庫,因爲真實開發中,每個微服務都是相互獨立的,包括數據庫也是一樣。所以我們只能調用商品微服務提供的接口服務。

先思考我們需要的數據:

  • SPU信息

  • SKU信息

  • SPU的詳情

  • 商品分類名稱(拼接all字段)

再思考我們需要哪些服務:

  • 第一:分批查詢spu的服務,已經寫過。
  • 第二:根據spuId查詢sku的服務,已經寫過
  • 第三:根據spuId查詢SpuDetail的服務,已經寫過
  • 第四:根據商品分類id,查詢商品分類名稱,沒寫過
  • 第五:根據商品品牌id,查詢商品的品牌,沒寫過

因此我們需要額外提供一個查詢商品分類名稱的接口。

1.3.1.商品分類名稱查詢

controller:

/**
 * 根據商品分類id查詢名稱
 * @param ids 要查詢的分類id集合
 * @return 多個名稱的集合
 */
@GetMapping("names")
public ResponseEntity<List<String>> queryNameByIds(@RequestParam("ids") List<Long> ids){
    List<String > list = this.categoryService.queryNameByIds(ids);
    if (list == null || list.size() < 1) {
        return new ResponseEntity<>(HttpStatus.NOT_FOUND);
    }
    return ResponseEntity.ok(list);
}

測試:
在這裏插入圖片描述

1.3.2.編寫FeignClient

1.3.2.1.問題展現

操作leyou-search工程

現在,我們要在搜索微服務調用商品微服務的接口。

第一步要引入商品微服務依賴:leyou-item-interface

<!--商品微服務-->
<dependency>
    <groupId>com.leyou.service</groupId>
    <artifactId>ly-item-interface</artifactId>
    <version>${leyou.latest.version}</version>
</dependency>

第二步,編寫FeignClient

@FeignClient(value = "item-service")
@RequestMapping("/goods")
public interface GoodsClient {

    /**
     * 分頁查詢商品
     * @param page
     * @param rows
     * @param saleable
     * @param key
     * @return
     */
    @GetMapping("/spu/page")
    ResponseEntity<PageResult<SpuBo>> querySpuByPage(
            @RequestParam(value = "page", defaultValue = "1") Integer page,
            @RequestParam(value = "rows", defaultValue = "5") Integer rows,
            @RequestParam(value = "saleable", defaultValue = "true") Boolean saleable,
            @RequestParam(value = "key", required = false) String key);

    /**
     * 根據spu商品id查詢詳情
     * @param id
     * @return
     */
    @GetMapping("/spu/detail/{id}")
    ResponseEntity<SpuDetail> querySpuDetailById(@PathVariable("id") Long id);

    /**
     * 根據spu的id查詢sku
     * @param id
     * @return
     */
    @GetMapping("sku/list")
    ResponseEntity<List<Sku>> querySkuBySpuId(@RequestParam("id") Long id);
}

以上的這些代碼直接從商品微服務中拷貝而來,完全一致。差別就是沒有方法的具體實現。大家覺得這樣有沒有問題?

而FeignClient代碼遵循SpringMVC的風格,因此與商品微服務的Controller完全一致。這樣就存在一定的問題:

  • 代碼冗餘。儘管不用寫實現,只是寫接口,但服務調用方要寫與服務controller一致的代碼,有幾個消費者就要寫幾次。
  • 增加開發成本。調用方還得清楚知道接口的路徑,才能編寫正確的FeignClient。

1.3.2.2.解決方案

因此,一種比較友好的實踐是這樣的:

  • 我們的服務提供方不僅提供實體類,還要提供api接口聲明
  • 調用方不用字自己編寫接口方法聲明,直接繼承提供方給的Api接口即可,

第一步:服務的提供方在leyou-item-interface中提供API接口,並編寫接口聲明:

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-d2xgJ7RP-1572272745494)(assets/1526613268722.png)]

商品分類服務接口:

@RequestMapping("category")
public interface CategoryApi {

    @GetMapping("names")
    ResponseEntity<List<String>> queryNameByIds(@RequestParam("ids") List<Long> ids);
}

商品服務接口,返回值不再使用ResponseEntity:

@RequestMapping("/goods")
public interface GoodsApi {

    /**
     * 分頁查詢商品
     * @param page
     * @param rows
     * @param saleable
     * @param key
     * @return
     */
    @GetMapping("/spu/page")
    PageResult<SpuBo> querySpuByPage(
            @RequestParam(value = "page", defaultValue = "1") Integer page,
            @RequestParam(value = "rows", defaultValue = "5") Integer rows,
            @RequestParam(value = "saleable", defaultValue = "true") Boolean saleable,
            @RequestParam(value = "key", required = false) String key);

    /**
     * 根據spu商品id查詢詳情
     * @param id
     * @return
     */
    @GetMapping("/spu/detail/{id}")
    SpuDetail querySpuDetailById(@PathVariable("id") Long id);

    /**
     * 根據spu的id查詢sku
     * @param id
     * @return
     */
    @GetMapping("sku/list")
    List<Sku> querySkuBySpuId(@RequestParam("id") Long id);
}

需要引入springMVC及leyou-common的依賴:

<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-webmvc</artifactId>
    <version>5.0.6.RELEASE</version>
</dependency>
<dependency>
    <groupId>com.leyou.common</groupId>
    <artifactId>leyou-common</artifactId>
    <version>1.0.0-SNAPSHOT</version>
</dependency>

第二步:在調用方leyou-search中編寫FeignClient,但不要寫方法聲明瞭,直接繼承leyou-item-interface提供的api接口:

商品的FeignClient:

@FeignClient(value = "item-service")
public interface GoodsClient extends GoodsApi {
}

商品分類的FeignClient:

@FeignClient(value = "item-service")
public interface CategoryClient extends CategoryApi {
}

是不是簡單多了?

項目結構:
在這裏插入圖片描述
在這裏插入圖片描述

1.3.2.3.測試

在leyou-search中引入springtest依賴:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

創建測試類:

在接口上按快捷鍵:Ctrl + Shift + T
在這裏插入圖片描述

在這裏插入圖片描述

測試代碼:

@RunWith(SpringRunner.class)
@SpringBootTest(classes = LeyouSearchApplication.class)
public class CategoryClientTest {

    @Autowired
    private CategoryClient categoryClient;

    @Test
    public void testQueryCategories() {
        List<String> names = this.categoryClient.queryNameByIds(Arrays.asList(1L, 2L, 3L));
        names.forEach(System.out::println);
    }
}

結果:
在這裏插入圖片描述

1.4.導入數據

導入數據只做一次,以後的更新刪除等操作通過消息隊列來操作索引庫

1.4.1.創建GoodsRepository

java代碼:

public interface GoodsRepository extends ElasticsearchRepository<Goods, Long> {
}

1.4.2.創建索引

我們新建一個測試類,在裏面進行數據的操作:

@RunWith(SpringRunner.class)
@SpringBootTest(classes = LeyouSearchApplication.class)
public class ElasticsearchTest {

    @Autowired
    private GoodsRepository goodsRepository;

    @Autowired
    private ElasticsearchTemplate elasticsearchTemplate;

    @Test
    public void createIndex(){
        // 創建索引
        this.elasticsearchTemplate.createIndex(Goods.class);
        // 配置映射
        this.elasticsearchTemplate.putMapping(Goods.class);
    }
}

通過kibana查看:
在這裏插入圖片描述

1.4.3.導入數據

導入數據其實就是查詢數據,然後把查詢到的Spu轉變爲Goods來保存,因此我們先編寫一個SearchService,然後在裏面定義一個方法, 把Spu轉爲Goods

@Service
public class SearchService {

    @Autowired
    private CategoryClient categoryClient;

    @Autowired
    private GoodsClient goodsClient;

    @Autowired
    private SpecificationClient specificationClient;

    private ObjectMapper mapper = new ObjectMapper();

    public Goods buildGoods(Spu spu) throws IOException {
        Goods goods = new Goods();

        // 查詢商品分類名稱
        List<String> names = this.categoryClient.queryNameByIds(Arrays.asList(spu.getCid1(), spu.getCid2(), spu.getCid3()));
        // 查詢sku
        List<Sku> skus = this.goodsClient.querySkuBySpuId(spu.getId());
        // 查詢詳情
        SpuDetail spuDetail = this.goodsClient.querySpuDetailById(spu.getId());
        // 查詢規格參數
        List<SpecParam> params = this.specificationClient.querySpecParam(null, spu.getCid3(), true, null);

        // 處理sku,僅封裝id、價格、標題、圖片,並獲得價格集合
        List<Long> prices = new ArrayList<>();
        List<Map<String, Object>> skuList = new ArrayList<>();
        skus.forEach(sku -> {
            prices.add(sku.getPrice());
            Map<String, Object> skuMap = new HashMap<>();
            skuMap.put("id", sku.getId());
            skuMap.put("title", sku.getTitle());
            skuMap.put("price", sku.getPrice());
            skuMap.put("image", StringUtils.isBlank(sku.getImages()) ? "" : StringUtils.split(sku.getImages(), ",")[0]);
            skuList.add(skuMap);
        });

        // 處理規格參數
        Map<String, Object> genericSpecs = mapper.readValue(spuDetail.getGenericSpec(), new TypeReference<Map<String, Object>>() {
        });
        Map<String, Object> specialSpecs = mapper.readValue(spuDetail.getSpecialSpec(), new TypeReference<Map<String, Object>>() {
        });
        // 獲取可搜索的規格參數
        Map<String, Object> searchSpec = new HashMap<>();

        // 過濾規格模板,把所有可搜索的信息保存到Map中
        Map<String, Object> specMap = new HashMap<>();
        params.forEach(p -> {
            if (p.getSearching()) {
                if (p.getGeneric()) {
                    String value = genericSpecs.get(p.getId().toString()).toString();
                    if(p.getNumeric()){
                        value = chooseSegment(value, p);
                    }
                    specMap.put(p.getName(), StringUtils.isBlank(value) ? "其它" : value);
                } else {
                    specMap.put(p.getName(), specialSpecs.get(p.getId().toString()));
                }
            }
        });

        goods.setId(spu.getId());
        goods.setSubTitle(spu.getSubTitle());
        goods.setBrandId(spu.getBrandId());
        goods.setCid1(spu.getCid1());
        goods.setCid2(spu.getCid2());
        goods.setCid3(spu.getCid3());
        goods.setCreateTime(spu.getCreateTime());
        goods.setAll(spu.getTitle() + " " + StringUtils.join(names, " "));
        goods.setPrice(prices);
        goods.setSkus(mapper.writeValueAsString(skuList));
        goods.setSpecs(specMap);
        return goods;
    }                                                   

}

因爲過濾參數中有一類比較特殊,就是數值區間:
在這裏插入圖片描述

所以我們在存入時要進行處理:

private String chooseSegment(String value, SpecParam p) {
    double val = NumberUtils.toDouble(value);
    String result = "其它";
    // 保存數值段
    for (String segment : p.getSegments().split(",")) {
        String[] segs = segment.split("-");
        // 獲取數值範圍
        double begin = NumberUtils.toDouble(segs[0]);
        double end = Double.MAX_VALUE;
        if(segs.length == 2){
            end = NumberUtils.toDouble(segs[1]);
        }
        // 判斷是否在範圍內
        if(val >= begin && val < end){
            if(segs.length == 1){
                result = segs[0] + p.getUnit() + "以上";
            }else if(begin == 0){
                result = segs[1] + p.getUnit() + "以下";
            }else{
                result = segment + p.getUnit();
            }
            break;
        }
    }
    return result;
}

然後編寫一個測試類,循環查詢Spu,然後調用IndexService中的方法,把SPU變爲Goods,然後寫入索引庫:

@Test
public void loadData(){
    // 創建索引
    this.elasticsearchTemplate.createIndex(Goods.class);
    // 配置映射
    this.elasticsearchTemplate.putMapping(Goods.class);
    int page = 1;
    int rows = 100;
    int size = 0;
    do {
        // 查詢分頁數據
        PageResult<SpuBo> result = this.goodsClient.querySpuByPage(page, rows, true, null);
        List<SpuBo> spus = result.getItems();
        size = spus.size();
        // 創建Goods集合
        List<Goods> goodsList = new ArrayList<>();
        // 遍歷spu
        for (SpuBo spu : spus) {
            try {
                Goods goods = this.searchService.buildGoods(spu);
                goodsList.add(goods);
            } catch (Exception e) {
                break;
            }
        }

        this.goodsRepository.saveAll(goodsList);
        page++;
    } while (size == 100);
}

通過kibana查詢, 可以看到數據成功導入:
在這裏插入圖片描述

2.實現基本搜索

2.1.頁面分析

2.1.1.頁面跳轉

在首頁的頂部,有一個輸入框:
在這裏插入圖片描述

當我們輸入任何文本,點擊搜索,就會跳轉到搜索頁search.html了:

並且將搜索關鍵字以請求參數攜帶過來:
在這裏插入圖片描述

我們打開search.html,在最下面會有提前定義好的Vue實例:

<script type="text/javascript">
    var vm = new Vue({
        el: "#searchApp",
        data: {
        },
        components:{
            // 加載頁面頂部組件
            lyTop: () => import("./js/pages/top.js")
        }
    });
</script>

這個Vue實例中,通過import導入的方式,加載了另外一個js:top.js並作爲一個局部組件。top其實是頁面頂部導航組件,我們暫時不管

2.1.2.發起異步請求

要想在頁面加載後,就展示出搜索結果。我們應該在頁面加載時,獲取地址欄請求參數,併發起異步請求,查詢後臺數據,然後在頁面渲染。

我們在data中定義一個對象,記錄請求的參數:

data: {
    search:{
        key:"", // 搜索頁面的關鍵字
    }
}

我們通過鉤子函數created,在頁面加載時獲取請求參數,並記錄下來。

created(){
    // 判斷是否有請求參數
    if(!location.search){
        return;
    }
    // 將請求參數轉爲對象
    const search = ly.parse(location.search.substring(1));
    // 記錄在data的search對象中
    this.search = search;
    
    // 發起請求,根據條件搜索
    this.loadData();
}

然後發起請求,搜索數據。

methods: {
    loadData(){
        // ly.http.post("/search/page", ly.stringify(this.search)).then(resp=>{
        ly.http.post("/search/page", this.search).then(resp=>{
            console.log(resp);
        });
    }
}
  • 我們這裏使用ly是common.js中定義的工具對象。
  • 這裏使用的是post請求,這樣可以攜帶更多參數,並且以json格式發送

在leyou-gateway中,添加允許信任域名:
在這裏插入圖片描述

並添加網關映射:
在這裏插入圖片描述

刷新頁面試試:
在這裏插入圖片描述

因爲後臺沒有提供接口,所以無法訪問。沒關係,接下來我們實現後臺接口

2.2.後臺提供搜索接口

2.2.1.controller

首先分析幾個問題:

  • 請求方式:Post

  • 請求路徑:/search/page,不過前面的/search應該是網關的映射路徑,因此真實映射路徑page,代表分頁查詢

  • 請求參數:json格式,目前只有一個屬性:key-搜索關鍵字,但是搜索結果頁一定是帶有分頁查詢的,所以將來肯定會有page屬性,因此我們可以用一個對象來接收請求的json數據:

    public class SearchRequest {
        private String key;// 搜索條件
    
        private Integer page;// 當前頁
    
        private static final Integer DEFAULT_SIZE = 20;// 每頁大小,不從頁面接收,而是固定大小
        private static final Integer DEFAULT_PAGE = 1;// 默認頁
    
        public String getKey() {
            return key;
        }
    
        public void setKey(String key) {
            this.key = key;
        }
    
        public Integer getPage() {
            if(page == null){
                return DEFAULT_PAGE;
            }
            // 獲取頁碼時做一些校驗,不能小於1
            return Math.max(DEFAULT_PAGE, page);
        }
    
        public void setPage(Integer page) {
            this.page = page;
        }
    
        public Integer getSize() {
            return DEFAULT_SIZE;
        }
    }
    
  • 返回結果:作爲分頁結果,一般都兩個屬性:當前頁數據、總條數信息,我們可以使用之前定義的PageResult類

代碼:

@RestController
@RequestMapping
public class SearchController {

    @Autowired
    private SearchService searchService;

    /**
     * 搜索商品
     *
     * @param request
     * @return
     */
    @PostMapping("page")
    public ResponseEntity<PageResult<Goods>> search(@RequestBody SearchRequest request) {
        PageResult<Goods> result = this.searchService.search(request);
        if (result == null) {
            return new ResponseEntity<>(HttpStatus.NOT_FOUND);
        }
        return ResponseEntity.ok(result);
    }
}

2.2.2.service

@Service
public class SearchService {

    @Autowired
    private GoodsRepository goodsRepository;

    public PageResult<Goods> search(SearchRequest request) {
        String key = request.getKey();
        // 判斷是否有搜索條件,如果沒有,直接返回null。不允許搜索全部商品
        if (StringUtils.isBlank(key)) {
            return null;
        }

        // 構建查詢條件
        NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder();
        
        // 1、對key進行全文檢索查詢
        queryBuilder.withQuery(QueryBuilders.matchQuery("all", key).operator(Operator.AND));
        
        // 2、通過sourceFilter設置返回的結果字段,我們只需要id、skus、subTitle
        queryBuilder.withSourceFilter(new FetchSourceFilter(
                new String[]{"id","skus","subTitle"}, null));
        
        // 3、分頁
        // 準備分頁參數
        int page = request.getPage();
        int size = request.getSize();
        queryBuilder.withPageable(PageRequest.of(page - 1, size));

        // 4、查詢,獲取結果
        Page<Goods> pageInfo = this.goodsRepository.search(queryBuilder.build());

        // 封裝結果並返回
        return new PageResult<>(goodsPage.getTotalElements(), goodsPage.getTotalPages(), goodsPage.getContent());
    }
}

注意點:我們要設置SourceFilter,來選擇要返回的結果,否則返回一堆沒用的數據,影響查詢效率。

2.2.3.測試

刷新頁面測試:
在這裏插入圖片描述

在這裏插入圖片描述

數據是查到了,但是因爲我們只查詢部分字段,所以結果json 數據中有很多null,這很不優雅。

解決辦法很簡單,在leyou-search的application.yml中添加一行配置,json處理時忽略空值:

spring:
  jackson:
    default-property-inclusion: non_null # 配置json處理時忽略空值

結果:
在這裏插入圖片描述

2.3.頁面渲染

頁面已經拿到了結果,接下來就要渲染樣式了。

2.3.1.保存搜索結果

首先,在data中定義屬性,保存搜索的結果:
在這裏插入圖片描述
[

loadData的異步查詢中,將結果賦值給goodsList
在這裏插入圖片描述

2.3.2.循環展示商品

在search.html的中部,有一個div,用來展示所有搜索到的商品:
在這裏插入圖片描述

可以看到,div中有一個無序列表ul,內部的每一個li就是一個商品spu了。

我們刪除多餘的,只保留一個li,然後利用vue的循環來展示搜索到的結果:
在這裏插入圖片描述

2.3.3.多sku展示

2.3.3.1.分析

接下來展示具體的商品信息,來看圖:
在這裏插入圖片描述

這裏我們可以發現,一個商品位置,是多個sku的信息集合。當用戶鼠標選擇某個sku,對應的圖片、價格、標題會隨之改變!

我們先來實現sku的選擇,才能去展示不同sku的數據。
在這裏插入圖片描述

可以看到,在列表中默認第一個是被選中的,那我們就需要做兩件事情:

  • 在搜索到數據時,先默認把第一個sku作爲被選中的,記錄下來

  • 記錄當前被選中的是哪一個sku,記錄在哪裏比較合適呢?顯然是遍歷到的goods對象自己內部,因爲每一個goods都會有自己的sku信息。

2.3.3.2.初始化sku

查詢出的結果集skus是一個json類型的字符串,不是js對象
在這裏插入圖片描述

我們在查詢成功的回調函數中,對goods進行遍歷,把skus轉化成對象,並添加一個selected屬性保存被選中的sku:
在這裏插入圖片描述

在這裏插入圖片描述

2.3.3.3.多sku圖片列表

接下來,我們看看多個sku的圖片列表位置:
在這裏插入圖片描述

看到又是一個無序列表,這裏我們也一樣刪掉多餘的,保留一個li,需要注意選中的項有一個樣式類:selected

我們的代碼:

<!--多sku圖片列表-->
<ul class="skus">
    <li :class="{selected: sku.id == goods.selected.id}" v-for="sku in goods.skus" :key="sku.id"
        @mouseEnter="goods.selected=sku">
        <img :src="sku.image">
    </li>
</ul>

注意:

  • class樣式通過 goods.selected的id是否與當前sku的id一致來判斷
  • 綁定了鼠標事件,鼠標進入後把當前sku賦值到goods.selected

2.3.4.展示sku其它屬性

現在,我們已經可以通過goods.selected獲取用戶選中的sku,那麼我們就可以在頁面展示了:
在這裏插入圖片描述

刷新頁面:
在這裏插入圖片描述

看起來很完美是吧!

但其實有一些瑕疵

2.3.5.幾個問題

2.3.5.1.價格顯示的是分

首先價格顯示就不正確,我們數據庫中存放的是以分爲單位,所以這裏要格式化。

好在我們之前common.js中定義了工具類,可以幫我們轉換。

改造:
在這裏插入圖片描述

結果報錯:
在這裏插入圖片描述

爲啥?

因爲在Vue範圍內使用任何變量,都會默認去Vue實例中尋找,我們使用ly,但是Vue實例中沒有這個變量。所以解決辦法就是把ly記錄到Vue實例:
在這裏插入圖片描述

然後刷新頁面:
在這裏插入圖片描述

2.3.5.2.標題過長

標題內容太長了,已經無法完全顯示,怎麼辦?

截取一下:

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-4j4wu857-1572272745512)(/assets/1526656959487.png)]

最好在加個懸停展示所有內容的效果

2.3.5.3.sku點擊不切換

還有一個錯誤比較隱蔽,不容易被發現。我們點擊sku 的圖片列表,發現沒有任何變化。

這不科學啊,爲什麼?

通過控制檯觀察,發現數據其實是變化了,但是Vue卻沒有重新渲染視圖。

這是因爲Vue的自動渲染是基於對象的屬性變化的。比如頁面使用GoodsList進行渲染,如果GoodsList變化,或者其內部的任何子對象變化,都會Vue感知,從而從新渲染頁面。

然而,這一切有一個前提,那就是當你第一次渲染時,對象中有哪些屬性,Vue就只監視這些屬性,後來添加的屬性發生改變,是不會被監視到的。

而我們的goods對象中,本身是沒有selected屬性的,是我們後來才添加進去的:
在這裏插入圖片描述

這段代碼稍微改造一下,即可:
在這裏插入圖片描述

也就是說,我們先把selected屬性初始化完畢,然後才把整個對象賦值給goodsList,這樣,goodsList已初始化時就有selected屬性,以後就會被正常監控了。
在這裏插入圖片描述

3.頁面分頁效果

剛纔的查詢中,我們默認了查詢的頁碼和每頁大小,因此所有的分頁功能都無法使用,接下來我們一起看看分頁功能條該如何製作。

這裏要分兩步,

  • 第一步:如何生成分頁條
  • 第二步:點擊分頁按鈕,我們做什麼

3.1.如何生成分頁條

先看下頁面關於分頁部分的代碼:

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-2iSD3ka9-1572272745515)(H:assets/1526692249371.png)]

可以看到所有的分頁欄內容都是寫死的。

3.1.1.需要的數據

分頁數據應該是根據總頁數當前頁總條數等信息來計算得出。

  • 當前頁:肯定是由頁面來決定的,點擊按鈕會切換到對應的頁
  • 總頁數:需要後臺傳遞給我們
  • 總條數:需要後臺傳遞給我們

我們首先在data中記錄下這幾個值:page-當前頁,total-總條數,totalPage-總頁數

data: {
    ly,
    search:{
        key: "",
        page: 1
    },
    goodsList:[], // 接收搜索得到的結果
    total: 0, // 總條數
    totalPage: 0 // 總頁數
}

因爲page是搜索條件之一,所以記錄在search對象中。

要注意:我們在created鉤子函數中,會讀取url路徑的參數,然後賦值給search。如果是第一次請求頁面,page是不存在的。因此爲了避免page被覆蓋,我們應該這麼做:
在這裏插入圖片描述

不過,這個時候我們自己的search對象中的值就可有可無了

3.1.2.後臺提供數據

後臺返回的結果中,要包含total和totalPage,我們改造下剛纔的接口:

在我們返回的PageResult對象中,其實是有totalPage字段的:

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-6oi4qxbB-1572272745516)(assets/1526695144476.png)]

我們在返回時,把這個值填上:

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-Gdy4UkjO-1572272745517)(/assets/1526695592422.png)]

頁面測試一下:
在這裏插入圖片描述

OK

3.1.3.頁面計算分頁條

首先,把後臺提供的數據保存在data中:

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-KstZwZap-1572272745518)(H:/%E4%B9%90%E4%BC%98/7%E6%9C%881%E5%8F%B7%E6%9B%B4%E6%96%B0/day12-%E5%9F%BA%E6%9C%AC%E6%90%9C%E7%B4%A2%E5%8A%9F%E8%83%BD/assets/1526695967230.png)]

然後看下我們要實現的效果:

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-bySVXxmS-1572272745519)(H:/%E4%B9%90%E4%BC%98/7%E6%9C%881%E5%8F%B7%E6%9B%B4%E6%96%B0/day12-%E5%9F%BA%E6%9C%AC%E6%90%9C%E7%B4%A2%E5%8A%9F%E8%83%BD/assets/1526695821870.png)]

這裏最複雜的是中間的1~5的分頁按鈕,它需要動態變化。

思路分析:

  • 最多有5個按鈕,因此我們可以用v-for循環從1到5即可
  • 但是分頁條不一定是從1開始:
    • 如果當前頁值小於等於3的時候,分頁條位置從1開始到5結束
    • 如果總頁數小於等於5的時候,分頁條位置從1開始到5結束
    • 如果當前頁碼大於3,應該從page-3開始
    • 但是如果當前頁碼大於totalPage-3,應該從totalPage-5開始

所以,我們的頁面這樣來做:
在這裏插入圖片描述

a標籤中的分頁數字通過index函數來計算,需要把i傳遞過去:

index(i){
    if(this.search.page <= 3 || this.totalPage <= 5){
        // 如果當前頁小於等於3或者總頁數小於等於5
        return i;
    } else if(this.search.page > 3) {
        // 如果當前頁大於3
        return this.search.page - 3 + i;
    } else {
        return this.totalPage - 5 + i;
    }
}

需要注意的是,如果總頁數不足5頁,我們就不應該遍歷15,而是1總頁數,稍作改進:
在這裏插入圖片描述

分頁條的其它部分就比較簡單了:

<div class="sui-pagination pagination-large">
    <ul style="width: 550px">
        <li :class="{prev:true,disabled:search.page === 1}">
            <a href="#">«上一頁</a>
        </li>
        <li :class="{active: index(i) === search.page}" v-for="i in Math.min(5,totalPage)" :key="i">
            <a href="#">{{index(i)}}</a>
        </li>
        <li class="dotted" v-show="totalPage > 5"><span>...</span></li>
        <li :class="{next:true,disabled:search.page === totalPage}">
            <a href="#">下一頁»</a>
        </li>
    </ul>
    <div>
        <span>共{{totalPage}}頁&nbsp;</span>
        <span>
            到第
            <input type="text" class="page-num" :value="search.page">
            頁 <button class="page-confirm" οnclick="alert(1)">確定</button>
        </span>
    </div>
</div>

3.2.點擊分頁做什麼

點擊分頁按鈕後,自然是要修改page的值

所以,我們在上一頁下一頁按鈕添加點擊事件,對page進行修改,在數字按鈕上綁定點擊事件,點擊直接修改page:
在這裏插入圖片描述

    prevPage(){
        if(this.search.page > 1){
            this.search.page--
        }
    },
    nextPage(){
        if(this.search.page < this.totalPage){
            this.search.page++
        }
    }

page發生變化,我們應該去後臺重新查詢數據。

不過,如果我們直接發起ajax請求,那麼瀏覽器的地址欄中是不會有變化的,沒有記錄下分頁信息。如果用戶刷新頁面,那麼就會回到第一頁。

這樣不太友好,我們應該把搜索條件記錄在地址欄的查詢參數中

因此,我們監聽search的變化,然後把search的過濾字段拼接在url路徑後:

watch:{
    search:{
        deep:true,
            handler(val){
            // 把search對象變成請求參數,拼接在url路徑
            window.location.href = "http://www.leyou.com/search.html?" + ly.stringify(val);
        }
    }
},

刷新頁面測試,然後就出現重大bug:頁面無限刷新!爲什麼?

因爲Vue實例初始化的鉤子函數中,我們讀取請求參數,賦值給search的時候,也觸發了watch監視!也就是說,每次頁面創建完成,都會觸發watch,然後就會去修改window.location路徑,然後頁面被刷新,再次觸發created鉤子,又觸發watch,週而復始,無限循環。

所以,我們需要在watch中進行監控,如果發現是第一次初始化,則不繼續向下執行。

那麼問題是,如何判斷是不是第一次?

第一次初始化時,search中的key值肯定是空的,所以,我們這麼做:

watch:{
    search:{
        deep:true,
            handler(val,old){
            if(!old || !old.key){
                // 如果舊的search值爲空,或者search中的key爲空,證明是第一次
                return;
            }
            // 把search對象變成請求參數,拼接在url路徑
            window.location.href = "http://www.leyou.com/search.html?" + ly.stringify(val);
        }
    }
}

再次刷新,OK了!

3.3.頁面頂部分頁條

在頁面商品列表的頂部,也有一個分頁條:
在這裏插入圖片描述

我們把這一部分,也加上點擊事件:
在這裏插入圖片描述

4.排序(作業)

4.1.頁面搜索排序條件

在搜索商品列表的頂部,有這麼一部分內容:
在這裏插入圖片描述

這是用來做排序的,默認按照綜合排序。點擊新品,應該按照商品創建時間排序,點擊價格應該按照價格排序。因爲我們沒有統計銷量和評價,這裏咱們以新品價格爲例,進行講解,做法是想通的。

排序需要知道兩個內容:

  • 排序的字段
  • 排序的方式

因此,我們首先在search中記錄這兩個信息,因爲created鉤子函數會對search進行覆蓋,因此我們在鉤子函數中對這兩個信息進行初始化即可:
在這裏插入圖片描述

然後,在頁面上給按鈕綁定點擊事件,修改sortBydescending的值:

<!--排序字段-->
<ul class="sui-nav">
    <li :class="{active:!search.sortBy}" @click="search.sortBy=''">
        <a href="#">綜合</a>
    </li>
    <li>
        <a href="#">銷量</a>
    </li>
    <li @click="search.sortBy='createTime'" :class="{active: search.sortBy==='createTime'}">
        <a href="#">新品</a>
    </li>
    <li>
        <a href="#">評價</a>
    </li>
    <li @click="search.sortBy='price'; search.descending = !search.descending"
        :class="{active: search.sortBy==='price'}">
        <a href="#">
            價格
            <v-icon v-show="search.descending">arrow_drop_down</v-icon>
            <v-icon v-show="!search.descending">arrow_drop_up</v-icon>
        </a>
    </li>
</ul>

可以看到,頁面請求參數中已經有了排序字段了:
在這裏插入圖片描述

4.2.後臺添加排序邏輯

接下來,後臺需要接收請求參數中的排序信息,然後在搜索中加入排序的邏輯。

現在,我們的請求參數對象SearchRequest中,只有page、key兩個字段。需要進行擴展:
在這裏插入圖片描述

然後在搜索業務邏輯中,添加排序條件:
在這裏插入圖片描述

注意,因爲我們存儲在索引庫中的的價格是一個數組,因此在按照價格排序時,會進行智能處理:

  • 如果是價格降序,則會把數組中的最大值拿來排序
  • 如果是價格升序,則會把數組中的最小值拿來排序
    在這裏插入圖片描述
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章