仿京东 项目笔记1

目录

项目代码

项目代码1

1. 项目配置

  1. 项目运行起来时,让浏览器自动打开
    在package.json文件中设置 --open
"scripts": {
    
    
    "serve": "vue-cli-service serve --open",
    "build": "vue-cli-service build",
    "lint": "vue-cli-service lint"
  },
  1. 关闭eslint校验工具
    vue.config.js
const {
    
     defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
    
    
  transpileDependencies: true,
  lintOnSave: false
})

  1. src文件夹简写方法,配置别名
    因为项目大的时候src(源代码文件夹):里面目录会很多,找文件不方便,设置src文件夹的别名的好处,找文件会方便一些
    在文件根目录下,跟vue.config.js同级目录下,新建jsconfig.json文件,[@代表的是src文件夹]
{
    
    
    "compilerOptions": {
    
    
        "baseUrl": "./",
        "paths": {
    
    
            "@/*": [
                "src/*"
            ]
        }
    },
    "exclude": [
        "node_modules",
        "dist"
    ]
}

创建的vue2项目,默认是有jsconfig.js文件,文件配置选项说明,可参考这个blog

  1. 路由分析 Vue-Router
    前端路由:
    K即为URL(网络资源定位符)
    V即为相应的路由组件
    确定项目结构顺序:上中下 -----只有中间部分的V在发生变化,中间部分应该使用的是路由组件
    项目路由分析:2个非路由组件和四个路由组件
    两个非路由组件:Header 、Footer
    路由组件 : Home、Search、Login(没有底部的Footer组件,带有二维码的)、Register(没有底部的Footer组件,带二维码的)

  2. 路由组件和非路由组件区别:

  • 非路由组件放在components中,路由组件放在pages或views中
  • 非路由组件通过标签使用,路由组件通过路由使用
  • 在main.js注册完路由,所有的路由和非路由组件身上都会拥有$router $route属性
  • $router:一般进行编程式导航进行路由跳转
  • $route: 一般获取路由信息(name path params等)

2. 前端Vue核心

开发一个前端模块可以概括为以下几个步骤:
(1)写静态页面、拆分为静态组件;
(2)发请求(API);
(3)vuex(actions、mutations、state三连操作);
(4)组件获取仓库数据,动态展示;

3. 组件的显示与隐藏用v-if和v-show

v-if:频繁操作DOM、耗性能
v-show: 通过样式将元素显示或隐藏,性能更好

场景: footer组件在登录注册页面是不存在的,所以要隐藏,v-if 或者 v-show
那么条件判断是什么?
根据组件身上的 $route.path 判断

<Footer v-show="$route.path == '/login' || $route.path == '/register'" ></Footer>

问题: 当组件数量增多时,判断过于冗余
解决: 利用路由元信息meta,在路由的元信息中定义show属性,用来给v-show赋值,判断是否显示footer组件

//router/idnex.js
{
    
    
    path: '/register',
    component: Register,
    meta: {
    
    showFooter: false} 
  },

判断:

<Footer v-show="$route.meta.showFooter" ></Footer>

在这里插入图片描述

4. 路由传参

详细学习: Vue-Router

4.1 路由跳转有几种方式?

  1. 声明式导航:router-link(务必要有to属性)
  2. 编程式导航:主要利用的是组件实例的$router.push | replace方法,可以书写一些自己的业务

4.2 路由传参,参数有几种写法?

  1. params参数:属于路径中的一部分,在配置路由的时候需要占位
  2. query参数:;不属于路径的一部分,类似于Ajax中的querystring ,不需要占位 /home?k=v&kv=
    情况说明: 当点击搜索按钮之后 将home页面跳转到search页面,输入内容后,需要将内容传递给search页面
    params:
    占位:(注意冒号:)
path: '/search/:keyWord',

params传参:

第一种:字符串

this.$router.push('/search/'+this.keyWord)

query传参: (不需要占位)

第一种:字符串

this.$router.push('/search/'+this.keyWord +'?k='+this.keyWord.toUpperCase())

第二种方法:模板字符串

this.$router.push(`/search${
      
      this.keyWord}?k=${
      
      this.keyWord.toUpperCase()}`)

第三种:对象写法(常用)

this.$router.push({
    
    
            name: 'search',           //使用params 不能使用 path: '/Search'
            params: {
    
    keyWord: this.keyWord}, 
            query: {
    
    k:this.keyWord.toUpperCase()}
        })

以对象方式传参时,如果我们传参中使用了params,只能使用name(而且需要使用命名路由),不能使用path,如果只是使用query传参,可以使用path

{
    
    
    path: '/search/:keyWord',
    name: 'search',           //命名路由
    component: Search, 
    meta: {
    
    showFooter: true}
  },

在这里插入图片描述

4.3 路由传参相关面试题

4.3.1 路由传递参数(对象写法)path是否可以结合params参数一起使用?

不可以,以对象方式传参时,对象写法可以是namepath形式,但需要注意的是,path这种写法不能与params参数一起,如果只是使用query传参,可以使用path

//无效
this.$router.push({
    
    
    path: '/search', 
    params: {
    
    keyWord: this.keyWord}, 
    query: {
    
    k:this.keyWord.toUpperCase()}
})
//有效
this.$router.push({
    
    
    path: '/search', 
    query: {
    
    k:this.keyWord.toUpperCase()}
})

//有效
this.$router.push({
    
    
    name: 'search',           //使用params 不能使用 path: '/Search'
    params: {
    
    keyWord: this.keyWord}, 
    query: {
    
    k:this.keyWord.toUpperCase()}
})
       

效果:params参数获取不到,只有query参数获取到了
在这里插入图片描述

4.3.2 如何指定params参数可传可不传?

配置路由时已占位(params参数),但是路由跳转时不传递参数,路径会存在问题,详情如下:

1. Search路由项的path已经指定要传一个keyword的params参数,如下所示:
path: "/search/:keyword",    

2. 执行下面进行路由跳转的代码:
this.$router.push({
    
    name:"Search",query:{
    
    k:this.keyword}})
当前跳转代码没有传递params参数
此时的url路径为:`http://localhost:8080/?k=asd`
此时的地址信息少了 `/search`
正常的地址栏信息: `http://localhost:8080/search?k=asd`

解决方法:
可以通过改变path,在后面加个问号来指定params参数可传可不传 ,

path: "/search/:keyword?",        //?表示该参数可传可不传

4.3.3 params参数可传可不传,若传递为空串,如何解决?

问题:

this.$router.push({
    
    
    name: 'search',          
    params: {
    
    keyWord: ''}, 
    query: {
    
    k:this.keyWord.toUpperCase()}
})

此时的url路径有问题:`http://localhost:8080/?k=SAD`

解决方法:
使用undefined解决,params参数可以传递也可不传递(空字符串)

this.$router.push({
    
    
    name: 'search',          
    params: {
    
    keyWord: '' || undefined}, 
    query: {
    
    k:this.keyWord.toUpperCase()}
})
此时的url路径为:`http://localhost:8080/search?k=SAD`

4.3.4 路由组件能否传递props数据?

可以,有三种写法。
布尔值写法:只能传递params参数。
对象写法:额外的给路由组件传递一些props
函数写法(常用):params、query参数都可传递
具体用法看 之前总结的 Vue Router 路由 里的路由props配置

4.4 编程式路由跳转到当前路由(参数不变),多次执行会弹出NavigationDuplicated的警告错误

**问题:**多次点击搜索按钮会出现(编程式导航,$route.push()
**注意:**声明式导航不会出现该问题,因为vue底层已解决

let res = this.$router.push({
    
    
	name: 'search', 
	params: {
    
    keyWord: this.keyWord}, 
	query: {
    
    k:this.keyWord.toUpperCase()}
})
console.log(res);

执行上面的代码,会出现下面的结果

在这里插入图片描述

**原因:**最新的"vue-router": "^3.5.3"引入了promise,编程式导航具有其返回值,失败成功的回调
push是一个promise,promise需要传递成功和失败两个参数,我们的push中没有传递。

**解决方法:**给push方法添加两个回调参数

let res = this.$router.push({
    
    
      name: 'search', 
      params: {
    
    keyWord: this.keyWord}, 
      query: {
    
    k:this.keyWord.toUpperCase()}
  },
  ()=>{
    
    },                             //执行成功回调
  (error)=>{
    
    console.log(error);}      //执行失败回调
)
console.log(res);

点击两下搜索按钮,确实捕获到当前错误,但这种方法治标不治本,将来在别的组件中push|replace,编程式导航还是会有类似错误,这个方法只解决了单个编程式导航。
在这里插入图片描述

根治方法:
push是VueRouter.prototype的一个方法,在router/index.js文件中 重写 该方法即可

//先把VueRouter原型对象的push和replace备份一份
let originPush = VueRouter.prototype.push;
let originReplace = VueRouter.prototype.replace;

//重写push | replace
//第一个参数:告诉原来push方法,你往哪里跳转(传递哪些参数)
//第二个参数:成功回调,第三个参数:失败回调
VueRouter.prototype.push = function (location, resolve, reject) {
    
    
  if (resolve && reject) {
    
    
    //call || applay 区别:
    //相同点:都可以调用函数一次,都可以篡改函数的上下文一次
    //不同点:call与apply传递参数:call传递参数用逗号隔开,apply方法执行,传递数据
    originPush.call(this, location, resolve, reject);
  } else {
    
    
    originPush.call(this, location, ()=>{
    
    }, ()=>{
    
    })
  }
}
VueRouter.prototype.replace = function(location, resolve, reject) {
    
    
  if (resolve && reject) {
    
    
    //call || applay 区别:
    //相同点:都可以调用函数一次,都可以篡改函数的上下文一次
    //不同点:call与apply传递参数:call传递参数用逗号隔开,apply方法执行,传递数据
    originReplace.call(this, location, resolve, reject);
  } else {
    
    
    originReplace.call(this, location, ()=>{
    
    }, ()=>{
    
    })
  }
}

5. 接口统一管理

项目小:可以在组件的生命周期函数中发请求
项目大:axios.get('xxx')
文件:index.js

5.1 二次封装Axios

axios中文文档

可以查看之前 Vue全家桶(二):Vue中的axios异步通信

src/api/request.js文件

import axios from "axios";
//对axios二次封装
const requests = axios.create({
    
    
    //基础路径,requests发出的请求在端口号后面会跟改baseURl
    baseURL:'/api',
    timeout: 5000,
})

//配置请求拦截器
requests.interceptors.request.use(config => {
    
    
    //config内主要是对请求头Header配置
    //比如添加token
    return config;
})

//配置相应拦截器
requests.interceptors.response.use((res) => {
    
    
    //成功的回调函数
    return  res.data;
},(error) => {
    
    
    //失败的回调函数
    console.log("响应失败"+error)
    return Promise.reject(new Error('fail'))
})

//对外暴露
export default requests;

5.2 前端通过代理解决跨域问题

扩展学习:
前端跨域解决方案
前端跨域

跨域: 协议、域名、端口号不同请求
http://localhost:8080/#/home 前端本地服务器
http://39.98.123.211 后台服务器地址
解决跨域问题: JSONP、CORS、代理

在根目录下的vue.config.js中配置,proxy为通过代理解决跨域问题。
我们在封装axios的时候已经设置了baseURL为api,所以所有的请求都会携带/api,这里我们就将/api进行了转换。如果你的项目没有封装axios,或者没有配置baseURL,建议进行配置。要保证baseURL和这里的代理映射相同,此处都为’/api’。

vue.config.js

const {
    
     defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
    
    
  transpileDependencies: true,
  lintOnSave: false,
  //代理跨域
  devServer: {
    
    
    proxy: {
    
    
      // 匹配所有以 '/api1'开头的请求路径
      'api': {
    
    
        target: 'http://gmall-h5-api.atguigu.cn'     // 将请求代理到目标服务器上
      }
    }
  }
})

5.2 请求接口统一封装

在src/api/文件中创建index.js文件,用于封装所有请求
将每个请求封装为一个函数,并暴露出去,组件只需要调用相应函数即可,这样当我们的接口比较多时,如果需要修改只需要修改该文件即可。

src/api/index.js

// 在当前这个模块:API进行统一管理
import requests from "./request";
//三级联动接口
export const reqCategoryList = ()=>{
    
    
    //发请求,request模块已经配置了/api,可以去掉/api,
    return requests({
    
    url: '/product/getBaseCategoryList',method: 'get'})
}
//简化
export const reqCategoryList = ()=>requests.get('/product/getBaseCategoryList')


当组件想要使用相关请求时,只需要导入相关函数即可,以上图的reqCateGoryList 为例:

import {
    
    reqCateGoryList} from '@/api'
//发起请求
reqCateGoryList();

5.3 async和await

我们将一个axios请求封装为了函数,我们在下面代码中调用了该函数:
比如在Vuex模块化的src/store/home.js中请求数据

import {
    
     reqCategoryList } from "@/api"
//home模块的Vuex模块
const state = {
    
    
    //state中数据默认初始值别瞎写,根据接口的返回值进行初始化
    cateGoryList: []
}
const mutations = {
    
    
    CATEGORYLIST(state, categoryList){
    
    
        state.cateGoryList = categoryList
    }
}
const actions = {
    
    
    //通过API里得接口函数调用,向服务器请求,获得服务器参数
    cateGoryList(context){
    
    
        let ressult = reqCategoryList()        //返回的是Promise实例对象
        console.log(ressult);
    }
}
const getters = {
    
    }
export default{
    
    
    state,
    getters,
    mutations,
    actions
}

在这里插入图片描述
返回了一个promise,证明这是一个promise请求,但是我们想要的是图片中的data数据。
没有将函数封装前我们都会通过then()回调函数拿到服务器返回的数据,现在我们将其封装了,依然可以使用then获取数据,代码如下

import {
    
     reqCategoryList } from "@/api"
//home模块的Vuex模块
const state = {
    
    
    //state中数据默认初始值别瞎写,根据接口的返回值进行初始化
    cateGoryList: []
}
const mutations = {
    
    
    CATEGORYLIST(state, categoryList){
    
    
        state.cateGoryList = categoryList
    }
}
actions:{
    
    
        categoryList(){
    
    
            let result = reqCateGoryList().then(
                res=>{
    
    
                console.log("res:",res)
                return res
                }
            )
            console.log("result:",result)
            if(result.code == 200){
    
    
	            console.log(result.code);
	            context.commit('CATEGORYLIST',result.data)
	        }
        }
    }

在这里插入图片描述

由于Promis是异步请求,我们发现请求需要花费时间,但是它是异步的,所有后面的console.log(“result”);console.log(result)会先执行,等我们的请求得到响应后,才执行console.log(“res”);console.log(res),这也符合异步的原则,但是我们如果在请求下面啊执行的是将那个请求的结果赋值给某个变量,这样就会导致被赋值的变量先执行,并且赋值为undefine,因为此时Promise还没有完成。

(具体的关于Promise的扩展学习看 ES6: Promise)

在这里插入图片描述

所以引入了asyncawait,async写在函数名前,await卸载api函数前面。await含义是async标识的函数体内的并且在await标识代码后面的代码先等待await标识的异步请求执行完,再执行。这也使得只有reqCateGoryList执行完,result 得到返回值后,才会执行后面的输出操作。

const actions = {
    
    
    //通过API里得接口函数调用,向服务器请求,获得服务器参数
    async cateGoryList(context){
    
    
        //返回的是Promise实例对象
        let result  = await reqCategoryList()
        console.log("result",result) 
        if(result.code == 200){
    
    
            context.commit('CATEGORYLIST',result.data)
        }
    }
}

在这里插入图片描述

5.4 nprogress请求加载进度条

场景:打开一个页面时,往往会伴随一些请求,并且会在页面上方出现进度条。它的原理时,在我们发起请求的时候开启进度条,在请求成功后关闭进度条,所以只需要在request.js中进行配置。
如下图所示,我们页面加载时发起了一个请求,此时页面上方出现蓝色进度条
在这里插入图片描述

nprogress安装:npm i --save nprogress
使用nprogress:src/api/request.js

src/api/request.js

import axios from "axios";                 //对axios二次封装
import nprogress from "nprogress";         //引入进度条
import "nprogress/nprogress.css"           //引入进度条样式
const requests = axios.create({
    
    
    //基础路径,requests发出的请求在端口号后面会跟改baseURl
    baseURL:'/api',
    timeout: 5000,
})
//配置请求拦截器
requests.interceptors.request.use(config => {
    
    
    //config内主要是对请求头Header配置
    //比如添加token
    
    //开启进度条
    nprogress.start()
    return config;
})

//配置响应拦截器
requests.interceptors.response.use((res) => {
    
    
    //成功的回调函数

    //响应成功,关闭进度条
    nprogress.done()
    return  res.data;
},(error) => {
    
    
    //失败的回调函数
    console.log("响应失败"+error)
    return Promise.reject(new Error('fail'))
})
//对外暴露
export default requests;

6. 事件委派

事件委派 : 也叫事件代理,简单理解就是:原事件的委派指将事件统一绑定给元素的共同的祖先元素,这样当后代元素上的事件触发时,会一直冒泡到祖先元素,从而通过祖先元素的响应函数来处理事件。比如:原本是给li绑定点击事件,现在交给它父级ul绑定,利用冒泡原理,点击li的时候会触发ul的事件;

**问题:**在三级列表中,鼠标在“全部商品分类”时,没有对应的选中样式,如图1所示,正常的情况应该是鼠标在离开一级菜单,进入“全部商品分类”时,会有对应的选中样式,存储的data数据应为对应选中的一级分类的索引值,如图2所示。

图1:
在这里插入图片描述
图2:
在这里插入图片描述
解决方法:
给一级分类和全部商品分类包裹一个父元素<div>,在这个<div>绑定一个鼠标移开事件leaveIndex

<!-- 事件委派 -->
<div @mouseleave="leaveIndex">                
	<h2 class="all">全部商品分类</h2>
	<!-- 三级联动菜单 -->
	<div class="sort">
	  <div class="all-sort-list2">
	    <div
	      class="item"
	      v-for="(c1, index) in categoryList"
	      :key="c1.categoryId"
	      :class="{ cur: index == currentIndex }"    <!-- 选中高亮 -->
	    >
      <h3 @mouseenter="changeIndex(index)" >      <!-- 鼠标进入事件 -->
      <!-- …… -->
</div>
<script>
....
data() {
      
      
    return {
      
      
      //存储用户鼠标移上哪一个一级分类
      currentIndex: -1,
    };
  },
methods: {
      
      
    //鼠标进入一级分类修改响应式数据currentIndex
    changeIndex(index) {
      
      
      this.currentIndex = index;
    },
    //鼠标移出一级分类的事件回调
    leaveIndex() {
      
      
      this.currentIndex = -1;
    },
  },
....
</script>

7. 卡顿现象

场景:
在上面讲到的事件委派中我们为三级联动菜单,为每个一级分类添加了鼠标进入事件,在用户使用过程中会出现以下两种情况。
正常情况 (用户慢慢的操作) :鼠标进入, 每一个一级分类h3,都会触发鼠标进入事件changeIndex
非正常情况 (用户操作很快):本身全部的一级分类都应该触发鼠标进入事件,但是经过测试,只有部分h3触发了,这种情况就是由于用户行为过快,导致浏览器反应不过来,如果当前回调函数中有一些大量业务,有可能出现卡顿现象

**解决方案:**防抖和节流
函数防抖和节流

7.1 函数的防抖和节流

防抖(debounce): 前面的所有的触发都被取消,最后一次执行在规定的时间之后才会触发,也就是说如果连续快速的触发只会执行一次
所谓防抖,就是指触发事件后在 n 秒内函数只能执行一次,如果在 n 秒内又触发了事件,则会重新计算函数执行时间。

例子: 输入框搜索 输入完内容之后 一秒后才发送一次请求
解决: ladash插件,封装函数的防抖与节流业务(闭包+延迟器)

节流(throttle): 在规定的间隔时间范围内不会重复触发回调,只有大于这个时间间隔才会触发回调,把频繁触发变为少量触发。
所谓节流,就是指连续触发事件但是在 n 秒中只执行一次函数。节流会稀释函数的执行频率。
例子: 计数器限定一秒内不管用户点击按钮多少次,数值只能加一、轮播图左右按钮切换时,只能在1s内切换一张图。
解决: _throttle()
引入:

import throttle from 'lodash/throttle'

默认暴露 不需要花括号
回调函数不要用箭头函数,可能出现上下文this

防抖和节流的区别:
防抖:用户操作很频繁,但是只是执行一次
节流:用户操作很频繁,但是把频繁的操作变为少量操作[可以给浏览器有充裕的时间解析代码]

7.2 三级联动表单的节流

下面代码就是将changeIndex设置了节流,如果操作很频繁,限制50ms执行一次。这里函数定义采用的键值对形式。throttle的返回值就是一个函数,所以直接键值对赋值就可以,函数的参数在function中传入即可。

import throttle from 'lodash/throttle'
//……
 methods: {
    
    
    //鼠标进入一级分类修改响应式数据currentIndex
    //采用键值对形式创建函数,将changeIndex定义为节流函数,该函数触发很频繁时,设置50ms才会执行一次
    changeIndex: throttle(function(index) {
    
    
      this.currentIndex = index;
    },50),
    //鼠标移出一级分类的事件回调
    leaveIndex() {
    
    
      this.currentIndex = -1;
    },
  },

7.3 三级联动菜单路由跳转

在这里插入图片描述
如上图所示,三级标签列表有很多,每一个标签都是一个页面链接,我们要实现通过点击表现进行路由跳转。
路由跳转有两种方法:导航式路由,编程式路由。

  1. 导航式路由,我们有多少个a标签就会生成多少个router-link标签,这样当我们频繁操作时会出现卡顿现象。
    卡顿原因:
    router-link是一个组件,当服务器的数据返回之后,循环出很多的router-link组件【创建组件实例–>虚拟DOM】,如果有1000+个router-link,在创建组件实例时,是非常耗内存的,因此会出现卡顿现象
  2. 编程式路由,我们是通过触发点击事件实现路由跳转。同理有多少个a标签就会有多少个触发函数。虽然不会出现卡顿,但是也会影响性能。

上面两种方法无论采用哪一种,都会影响性能。我们提出一种:编程式导航+事件委派 的方式实现路由跳转。事件委派即把子节点的触发事件都委托给父节点。这样只需要一个回调函数goSearch就可以解决。

事件委派问题:
(1)如何确定我们点击的一定是a标签呢?如何保证我们只能通过点击a标签才跳转呢?如何区分一级、二级、三级的分类标签?
(2)如何获取子节点标签的商品名称和商品id(我们是通过商品名称和商品id进行页面跳转的)

解决方法:
问题1:为三个等级的a标签添加自定义属性date-categoryName绑定商品标签名称来标识a标签(其余的标签是没有该属性的)。

问题2:为三个等级的a标签再添加 自定义属性 data-category1Id、data-category2Id、data-category3Id来获取三个等级a标签的商品id,用于路由跳转。
我们可以通过在函数中传入event参数,获取当前的点击事件,通过event.target属性获取当前点击节点,再通过dataset属性获取节点的属性信息。

 <div class="all-sort-list2" @click="goSearch" @mouseleave="leaveIndex">
          <div class="item"  v-for="(c1,index) in categoryList" v-show="index!==16" :key="c1.categoryId" :class="{cur:currentIndex===index}">
            <h3 @mouseenter="changeIndex(index)"  >
              <a :data-categoryName="c1.categoryName" :data-category1Id="c1.categoryId" >{
   
   {c1.categoryName}}</a>
            </h3>
            <div class="item-list clearfix" :style="{display:currentIndex===index?'block':'none'}">
              <div class="subitem" v-for="(c2,index) in c1.categoryChild" :key="c2.categoryId">
                <dl class="fore">
                  <dt>
                    <a :data-categoryName="c2.categoryName" :data-category2Id="c2.categoryId">{
   
   {c2.categoryName}}</a>
                  </dt>
                  <dd>
                    <em v-for="(c3,index) in c2.categoryChild"  :key="c3.categoryId">
                      <a :data-categoryName="c2.categoryName" :data-category3Id="c3.categoryId">{
   
   {c3.categoryName}}</a>
                    </em>
</dd></dl></div></div></div></div>

注意:event是系统属性,所以我们只需要在函数定义的时候作为参数传入,在函数使用的时候不需要传入该参数。

//函数使用
<div class="all-sort-list2" @click="goSearch" @mouseleave="leaveIndex">
//函数定义
methods:{
    
    
 //三级联动菜单的路由跳转_编程式导航+事件委派
  goSearch(event) {
    
    
     //获取触发这个事件的节点,需要带有data-categoryName这样的节点
     let element = event.target;
     //节点有一个属性dataset属性,可以获取节点的自定义属性与属性值
     // console.log(element.dataset);
     let {
    
    categoryname, category1id, category2id, category3id} = element.dataset
     
     //如果标签拥有categoryname一定是a标签
     if(categoryname) {
    
    
       let location = {
    
    name: 'search'}
       let query = {
    
    categoryName: categoryname}
       if (category1id){
    
    
         query.category1Id = category1id
       } else if (category2id){
    
    
         query.category2Id = category2id
       } else {
    
    
         query.category3Id = category3id
       } 
       location.query = query
       //路由跳转
       this.$router.push(location)
     }
}

在这里插入图片描述

8. Vue路由切换的请求优化

问题:组件切换过程多次向服务器发送请求
解决:APP的mounted只会执行一次
问题:在切换路由时会重复发送 商品分类列表数据请求
原因:Vue在路由切换的时候会销毁旧路由。我们在三级列表全局组件TypeNav中的mounted进行了请求一次商品分类列表数据。由于Vue在路由切换的时候会销毁旧路由,当我们再次使用三级列表全局组件时还会发一次请求。
TypeNav/index.vue

//当组件挂载完毕:可以向服务器请求数据
  mounted() {
    
    
    this.$store.dispatch("cateGoryList");
    //判断当前的路由是search时,则进行隐藏,Home的话是显示
    if(this.$route.path !== '/home') {
    
    
      this.show = false
    }
    
  },

如下图所示:当我们在包含三级列表全局组件的不同组件之间进行切换时,都会进行一次信息请求。
在这里插入图片描述
由于信息都是一样的,出于性能的考虑我们希望该数据只请求一次,所以我们把这次请求放在App.vue的mounted中。(根组件App.vue的mounted只会执行一次)
注意:虽然main.js也是只执行一次,但是不可以放在main.js中。因为只有组件的身上才会有$store属性。

9. Mock插件

9.1 Mock使用

Mockjs:用来拦截前端ajax请求,返回我们自定义的数据用于测试前端接口。
参考文档:
http://mockjs.com/
https://github.com/nuysoft/Mock

安装:npm i --save mockjs
使用步骤: mockjs使用步骤

  1. 在项目当中src文件夹中创建mock文件夹
  2. 第二步准备JSON数据(mock 文件夹中创建相应的JSON文件) ----格式化一下,别留有空格(跑不起来的)
  3. mock数据需要的图片放置到public文件夹中(public文件夹在打包的时候,会把相应的资源原封不动打包到dist文件夹)
  4. 创建mock/mockServer.js 通过mockjs插件实现模拟数据

在这里插入图片描述
banners、floors分别为轮播图和页面底部的假数据的JSON文件。

mock/mockServer.js

import Mock from 'mockjs'
//webpack默认对外暴露:json、图片
import banners from './banners.json'
import floors from './floors.json'

//mock数据:第一个参数请求地址,第二个参数:请求数据
// 提供广告位轮播数据的接口
Mock.mock("/mock/banners", {
    
    code: 200, data: banners})
// 提供所有楼层数据的接口
Mock.mock("/mock/floors", {
    
    code: 200, data: floors})

//记得要在main.js中引入一下(至少需要执行一次,才能模拟数据)
//import '@/mock/mockServer'

9.2 利用mockjs提供模拟数据

api/mockAjax.js
这个文件跟前面二次封装axios里的api/request.js 文件内容一样,只不过是区分真实接口和虚拟接口

//专门请求mock接口的axios封装
import axios from "axios";

const mockAjax  = axios.create({
    
    
    baseURL:'/mock',     //路径前缀
    timeout: 5000,       //请求超时
})

//配置请求拦截器
mockAjax.interceptors.request.use(config => {
    
    
    //config内主要是对请求头Header配置
    //比如添加token
    //开启进度条
    return config;
})

//配置响应拦截器
mockAjax.interceptors.response.use((res) => {
    
    
    //成功的回调函数
    return  res.data;
},(error) => {
    
    
    //失败的回调函数
    console.log("响应失败"+error)
    return Promise.reject(new Error('fail'))
})

//对外暴露
export default mockAjax;

api/index.js

// 在当前这个模块:API进行统一管理
import requests from "./request";
import mockAjax from './mockAjax'

//三级联动接口
// export const reqCategoryList = ()=>{
    
    
//     //发请求,request模块已经配置了/api,可以去掉/api,
//     return requests({url: '/product/getBaseCategoryList',method: 'get'})
// }
//简化写法
export const reqCategoryList = ()=> requests.get('/product/getBaseCategoryList')

//获取广告轮播列表
export const reqBannersList = ()=> mockAjax.get('/banners')

//获取首页楼层列表
export const reqFloors = ()=> mockAjax.get('/floors')

我们会把公共的数据放在store中,然后使用时再去store中取。
以我们的首页轮播图数据为例。
1、在轮播图组件ListContainer.vue组件加载完毕后发起轮播图数据请求。

 mounted() {
    
    
    this.$store.dispatch("getBannerList")
  },
  1. 请求实际是在store中的actions中完成的
    store/home/index.js
import {
    
     reqCategoryList, reqBannersList } from "@/api"
//home模块的Vuex模块
const state = {
    
    
    //state中数据默认初始值别瞎写,根据接口的返回值进行初始化
    categoryList: [],
    bannerList: []
}
const mutations = {
    
    
    CATEGORYLIST(state, categoryList) {
    
    
        state.categoryList = categoryList
    },
    BANNERLIST(state, bannerList) {
    
    
        state.bannerList = bannerList
    }
}
const actions = {
    
    
    //通过API里得接口函数调用,向服务器请求,获得服务器参数
    async cateGoryList(context){
    
    
        //返回的是Promise实例对象
        let result  = await reqCategoryList()
        // console.log("result",result) 
        if(result.code == 200){
    
    
            context.commit('CATEGORYLIST',result.data)
        }
        
    },
    //获取首页轮播图数据
    async getBannerList(context) {
    
    
        let result = await reqBannersList()
        if(result.code == 200){
    
    
            context.commit('BANNERLIST',result.data)
        }

    }
}
const getters = {
    
    }
export default{
    
    
    state,
    getters,
    mutations,
    actions
}
  1. 轮播图组件ListContainer.vue组件在store中获取轮播图数据。由于在这个数据是通过异步请求获得的,所以我们要通过计算属性computed获取轮播图数据。
    ListContainer.vue
import {
    
    mapState} from 'vuex'
export default {
    
    
  name: "ListContainer",
  mounted() {
    
    
    this.$store.dispatch('getBannerList')
  },
  computed: {
    
    
    ...mapState({
    
    
      bannerList: (state) => state.home.bannerList,})
  }
};

总结:只要是公共数据都会放在store中,之后的实现步骤就是上面的固定步骤。

10. Swiper轮播图

10.1 Swiper的基本使用

Swiper插件
官网中给出了代码实例:
做一个简要总结:

  1. 安装:npm i swiper@5
  2. 在需要使用轮播图的组件内导入swpier和它的css样式
import Swiper from 'swiper'
//引入swiper的样式(如果用到的轮播图的地方多,可以在main.js引入样式)
import 'swiper/css/swiper.css'
  1. 在组件中创建swiper需要的dom标签(html代码,参考官网代码)
    ListContainer/index.vue
<!--banner轮播-->
<div class="swiper-container" id="mySwiper">
  <div class="swiper-wrapper">
    <div class="swiper-slide" v-for="(carouse, index) in bannerList" :key="carouse.id">
      <img :src="carouse.imgUrl" />
    </div>
  </div>
  <!-- 如果需要分页器 -->
  <div class="swiper-pagination"></div>

  <!-- 如果需要导航按钮 -->
  <div class="swiper-button-prev"></div>
  <div class="swiper-button-next"></div>
 </div>
</div>
<script>
import {
      
       mapState } from "vuex";
import Swiper from "swiper";
export default {
      
      
  name: "ListContainer",
  mounted() {
      
      
  	//请求数据
    this.$store.dispatch("getBannerList");
    //创建swiper实例
    let mySwiper = new Swiper(document.getElementsByClassName("swiper-container"),{
      
      
        // 如果需要分页器
        pagination:{
      
      
          el: '.swiper-pagination',
          clickable: true,
        },
        // 如果需要前进后退按钮
        navigation: {
      
      
          nextEl: '.swiper-button-next',
          prevEl: '.swiper-button-prev',
        },
        // 如果需要滚动条
        scrollbar: {
      
      
          el: '.swiper-scrollbar',
        },
      })
  },
  computed: {
      
      
    ...mapState({
      
      
      bannerList: (state) => state.home.bannerList,
    }),
 },
}
</script>
  1. 创建swiper实例
    **问题:**接下来要考虑的是什么时候去加载这个swiper,我们第一时间想到的是在mounted中创建这个实例。如上代码,但是会出现无法加载轮播图片的问题。

原因: 在new Swpier实例之前,页面中结构必须的有(现在把new Swiper实例放在mounte这里发现不行),而我们在mounted中先去dispatch异步请求了轮播图数据,然后又创建的swiper实例。由于请求数据是异步的,所以浏览器不会等待该请求执行完再去创建swiper,而是先创建了swiper实例,但是此时我们的轮播图数据还没有获得,就导致了轮播图展示失败。

解决方法:
方法一: update能解决,但若有别的数据更新,同时触发了响应内容,冗余

update() {
    
    
    //创建swiper实例
    let mySwiper = new Swiper(document.getElementsByClassName("swiper-container"),{
    
    
       	//......
      })
  },

方法二: setTimeout定时器解决,但过时效才能显示分页器效果
等我们的数据请求完毕后再创建swiper实例。只需要加一个1000ms时间延迟再创建swiper实例.。将上面代码改为:

mounted() {
    
    
	//请求数据
    this.$store.dispatch("getBannerList")
    //创建swiper实例
    setTimeout(()=>{
    
    
    	let mySwiper = new Swiper(document.getElementsByClassName("swiper-container"),{
    
    
       	//......})
    },1000)
    
  },

这个方法肯定不是最好的,但是我们开发的第一要义就是实现功能,之后可以再完善。
原理:为什么setTimeout有效,这个涉及到异步和同步,具体可以看 ES6 事件循环

方法三:最完美的方案——watch+nectTick

  • watch 监听bannerList数据的变化——空数组变为数组里有元素,但是watch中的handler只能保证数据已经存在,不能保证html结构是否完整,就是不能保证html中v-for遍历bannerList数据是否执行完。假如watch先监听到bannerList数据变化,执行回调函数创建了swiper对象,之后v-for才执行,这样也是无法渲染轮播图图片(因为swiper对象生效的前提是html即dom结构已经渲染好了)。
  • nectTickthis.$nextTick它会将回调延迟到下次 DOM 更新循环之后执行
    官方介绍:服务器数据已返回,循环结束之后v-for执行结束,html结构已完整)执行延迟回调。
    修改数据之后(服务器数据回来)立即使用这个方法,获取更新后的DOM。
    **个人理解:**无非是等我们页面中的结构都有了再去执行回调函数
    应用:$nextTick可以保证页面结构存在,常与插件一起使用(一般插件都需要DOM存在)
watch: {
    
    
    // 监听bannerList数据的属性值的变化
    bannerList: {
    
    
      handler(newValue, oldValue) {
    
    
        this.$nextTick(() => {
    
    
          //创建swiper实例
          let mySwiper = new Swiper(document.getElementsByClassName("swiper-container"),{
    
    
              loop: true, // 循环模式选项
              pagination: {
    
    
                el: ".swiper-pagination",
                clickable: true,
              },
              // 如果需要前进后退按钮
              navigation: {
    
    
                nextEl: ".swiper-button-next",
                prevEl: ".swiper-button-prev",
              },
              // 如果需要滚动条
              scrollbar: {
    
    
                el: ".swiper-scrollbar",
              },
            }
          );
        });
      },
    },
  },

**注意1:**之前我们在学习watch时,一般都是监听的定义在data中的属性,但是我们这里是监听的computed中的属性,这样也是完全可以的,并且如果你的业务数据也是从store中通过computed动态获取的,也需要watch监听数据变化执行相应回调函数,完全可以模仿上面的写法。

注意2: 在创建swiper对象时,我们会传递一个参数用于获取展示轮播图的DOM元素,官网直接通过class(而且这个class不能修改,是swiper的css文件自带的)获取。但是这样有缺点:当页面中有多个轮播图时,因为它们使用了相同的class修饰的DOM,就会出现所有的swiper使用同样的数据,这肯定不是我们希望看到的。
**解决方法:**在轮播图最外层的DOM中添加ref属性
ref : 为某个元素注册一个唯一标识, vue对象通过$refs属性访问这个元素

<div class="swiper-container" id="mySwiper" ref="mySwiper">

通过ref属性值获取DOM

let mySwiper = new Swiper(this.$refs.mySwiper,{
    
    ...})

ListContainer/index.vue完整代码:

<!--banner轮播-->
<div class="swiper-container" id="mySwiper" ref="mySwiper">
  <div class="swiper-wrapper">
    <div class="swiper-slide" v-for="(carouse, index) in bannerList" :key="carouse.id">
      <img :src="carouse.imgUrl" />
    </div>
  </div>
  <!-- 如果需要分页器 -->
  <div class="swiper-pagination"></div>

  <!-- 如果需要导航按钮 -->
  <div class="swiper-button-prev"></div>
  <div class="swiper-button-next"></div>
 </div>
</div>
<script>
import {
      
       mapState } from "vuex";
import Swiper from "swiper";
export default {
      
      
  name: "ListContainer",
  mounted() {
      
      
  	//请求数据
    this.$store.dispatch("getBannerList");
  },
  computed: {
      
      
    ...mapState({
      
      
      bannerList: (state) => state.home.bannerList,
    }),
  },
  watch: {
      
      
    // 监听bannerList数据的属性值的变化
    bannerList: {
      
      
      handler(newValue, oldValue) {
      
      
        this.$nextTick(() => {
      
      
          //执行这个回调后,数据已存在,html结构已加载
          //创建swiper实例
          let mySwiper = new Swiper(this.$refs.mySwiper,
          {
      
      
          	  loop: true, // 循环模式选项
              pagination: {
      
      
                el: ".swiper-pagination",
                clickable: true,
              },
              // 如果需要前进后退按钮
              navigation: {
      
      
                nextEl: ".swiper-button-next",
                prevEl: ".swiper-button-prev",
              },
              // 如果需要滚动条
              scrollbar: {
      
      
                el: ".swiper-scrollbar",
              },
            }
          );
        });
      },
    },
  },
 
}
</script>

10.2 父传子数据+轮播图Swiper的使用

假设一个场景:在父组件home请求了一个数据,子组件用props接受该数据floor,该数据有轮播图的数据。

父组件:home/index.vue

<template>
<div>
//...
<!--  父组件通过自定义属性list给子组件传递数据-->
  <Floor v-for="floor in floorList"  :key="floor.id" :list="floor"/>
</div>
</template>

子组件:home/floor/index.vue

<template>
  <!--楼层-->
  <div class="floor">
    <div class="swiper-container" id="floor1Swiper" ref="floor1Swiper">
         <div class="swiper-wrapper">
           <div class="swiper-slide" v-for="carouse in floor.carouselList" :key="carouse.id">
             <img :src="carouse.imgUrl" />
           </div>
         </div>
         <!-- 如果需要分页器 -->
         <div class="swiper-pagination"></div>
         <!-- 如果需要导航按钮 -->
         <div class="swiper-button-prev"></div>
         <div class="swiper-button-next"></div>
       </div>
     </div>
  </div>
</template>
<script>
export default {
      
      
  name: "floor",
//子组件通过props属性接受父组件传递的数据
  props:['list'],
  mounted() {
      
      
    //创建swiper实例,这里是在mounted里,因为这里的数据是来源父组件home,通过props传递的,数据、html结构都已经存在
    //所以可以在mounted中创建swiper实例
    let mySwiper = new Swiper(this.$refs.floor1Swiper, {
      
      
      loop: true, // 循环模式选项
      pagination: {
      
      
        el: ".swiper-pagination",
        clickable: true,
      },
      // 如果需要前进后退按钮
      navigation: {
      
      
        nextEl: ".swiper-button-next",
        prevEl: ".swiper-button-prev",
      },
      // 如果需要滚动条
      scrollbar: {
      
      
        el: ".swiper-scrollbar",
      },
    });
  },
}
</script>

这时候子组件里的创建swiper实例可以放在mounted下,而不像是 10.1中的 ListContainer/idnex.vue里的只能放在watch下,这是由于该子组件的轮播图数据是父组件异步请求,并通过props传递给子组件的,其数据、html结构都已经存在,所以可以放在mounted中创建swiper实例。

问:创建swiper实例的代码除了可以放在mounted中,也可以放在watch下吗?
可以,但是需要添加immediate: true属性,在watch没有加immdediate属性的话是无法监听到floor的,因为这个数据从来没有发生过变化(数据是父组件传递的,父组件传递的时候就是一个对象,对象里面该有的数据都是存在的),而immediate: true表示,初始化时让handler调用一下,即初始化就监听一次,不管数据是否有变化,代码如下:

watch: {
    
    
    // 监听bannerList数据的属性值的变化
    floor: {
    
    
      immediate: true,    //初始化就监听一次(不管数据是否有变化)
      handler(newValue, oldValue) {
    
    
        //handler只能监听到数据已经有了,但是v-for动态渲染结构还没办法确定,需要用到nextTick
        this.$nextTick(() => {
    
    
          //执行这个回调后,数据已存在,html结构已加载
          //创建swiper实例
          let mySwiper = new Swiper(
            this.$refs.floor1Swiper,
            {
    
    
              loop: true, // 循环模式选项
              pagination: {
    
    
                el: ".swiper-pagination",
                clickable: true,
              },
              // 如果需要前进后退按钮
              navigation: {
    
    
                nextEl: ".swiper-button-next",
                prevEl: ".swiper-button-prev",
              },
              // 如果需要滚动条
              scrollbar: {
    
    
                el: ".swiper-scrollbar",
              },
            }
          );
        });
      },
    },
  },

10.3 将轮播图Swiper模块提取为公共组件

通过前两节,可以发现,ListContainer组件和Floor组件都是Home父组件的子组件,都用到了Swiper轮播图,且代码结构都相似,那么就可以将其Swiper轮播图单独拆出成一个全局组件Carousel

<template>
  <div class="swiper-container" ref="swiper" id="floor1Swiper">
    <div class="swiper-wrapper">
      <div class="swiper-slide" v-for="(carouse,index) in carouselList" :key="carouse.id">
        <img :src="carouse.imgUrl">
      </div>
    </div>
    <!-- 如果需要分页器 -->
    <div class="swiper-pagination"></div>

    <!-- 如果需要导航按钮 -->
    <div class="swiper-button-prev"></div>
    <div class="swiper-button-next"></div>
  </div>
</template>

<script>
import Swiper from "swiper";
import 'swiper/css/swiper.css'
export default {
      
      
  name: "Carousel",
  props:["carouselList"],
  watch: {
      
      
    carouselList: {
      
      
      //这里监听,无论数据有没有变化,上来立即监听一次
      immediate: true,
      //监听后执行的函数
      handler(){
      
      
        //第一次ListContainer中的轮播图Swiper定义是采用watch+ this.$nextTick()实现
        this.$nextTick(() => {
      
      
          let mySwiper = new Swiper(this.$refs.swiper,{
      
      
            loop: true, // 循环模式选项

            // 如果需要分页器
            pagination: {
      
      
              el: '.swiper-pagination',
              // clickable: true
            },

            // 如果需要前进后退按钮
            navigation: {
      
      
              nextEl: '.swiper-button-next',
              prevEl: '.swiper-button-prev',
            },

            // 如果需要滚动条
            scrollbar: {
      
      
              el: '.swiper-scrollbar',
            },
          })
        })
      }
    }
  }
}
</script>
<style scoped></style>

main.js注册全局组件

//轮播图组件——全局组件
import Carousel from '@/components/Carousel'
//全局注册,第一个参数:组件名字,第二参数:是哪个组件
Vue.component(Carousel.name, Carousel)

Floor组件引用Carousel组件

<!-- 轮播图 -->
<Carousel :carouselList="floor.carouselList" />

我们还记得在首页上方我们的ListContainer组件也使用了轮播图,同样我们替换为我们的公共组件。
ListContainer组件引用Carousel组件

<Carousel :carouselList="bannerList"/>

注意:
(1)将该组件在main.js中引入,并定义为全局组件。其实也可以在使用到该组件的地方引入并声明
(2)引用组件时要在components中声明引入的组件。
(3)我们将轮播图组件已经提取为公共组件Carouse,所以我们只需要在Carouse中引入swiper和相应css样式。

11. vuex中getters的使用

官方getters使用
getters是vuex store中的计算属性。
如果不使用getters属性,我们在组件获取state中的数据表达式为:this.$store.state.子模块.属性
如果有多个组件需要用到此属性,我们要么复制这个表达式,或者抽取到一个共享函数然后在多处导入它——无论哪种方式都不是很理想。
Vuex 允许我们在 store 中定义“getter”(可以认为是 store 的计算属性)。就像计算属性一样,getter 的返回值会根据它的依赖被缓存起来,且只有当它的依赖值发生了改变才会被重新计算。

// 计算属性,用于简化仓库数据,让组件获取仓库的数据更加方便
const getters = {
    
    
    // 当前形参state,是当前仓库中的state
    goodsList(state){
    
    
        return state.searchList.goodsList
    },
    trademarkList(state) {
    
    
        return state.searchList.trademarkList
    },
    attrsList(state) {
    
    
        return state.searchList.attrsList
    },
}

仓库中的getters是全局属性,是不分模块的。即store中所有模块的getter内的函数都可以通过$store.getters.函数名获取
在这里插入图片描述
我们在Search模块中获取商品列表数据就是通过getters实现,需要注意的是当网络出现故障时应该将返回值设置为空,如果不设置返回值就变成了undefined。

// 计算属性,用于简化仓库数据,让组件获取仓库的数据更加方便
const getters = {
    
    
    // 当前形参state,是当前仓库中的state
    goodsList(state){
    
    
	    //网络出现故障时应该将返回值设置为空
        return state.searchList.goodsList || []
    },
}

在Search组件中使用getters获取仓库数据

//只展示了使用getters的代码
<script>
  //引入mapGetters
  import {
    
    mapGetters} from 'vuex'
  export default {
    
    
    name: 'Search',
    computed:{
    
    
      //使用mapGetters,参数是一个数组,数组的元素对应getters中的函数名
      ...mapGetters(['goodsList'])
    }
  }
</script>

12. Object.asign实现对象拷贝

ES6参考

Object.assign() 方法用于将所有可枚举属性的值从一个或多个源对象复制到目标对象。它将返回目标对象。
Object.assign(target, ...sources)    【target:目标对象】,【souce:源对象(可多个)】
举个栗子:
const object1 = {
    
    
  a: 1,
  b: 2,
  c: 3
};

const object2 = Object.assign({
    
    c: 4, d: 5}, object1);

console.log(object2.c, object2.d);
console.log(object1)  // { a: 1, b: 2, c: 3 }
console.log(object2)  // { c: 3, d: 5, a: 1, b: 2 }

注意:
1.如果目标对象中的属性具有相同的键,则属性将被源对象中的属性覆盖。后面的源对象的属性将类似地覆盖前面的源对象的属性
2.Object.assign 方法只会拷贝源对象自身的并且可枚举的属性到目标对象。该方法使用源对象的[[Get]]和目标
对象的[[Set]],所以它会调用相关 getter 和 setter。因此,它分配属性,而不仅仅是复制或定义新的属性。如
果合并源包含getter,这可能使其不适合将新属性合并到原型中。为了将属性定义(包括其可枚举性)复制到
原型,应使用Object.getOwnPropertyDescriptor()和Object.defineProperty()

对象深拷贝

针对深拷贝,需要使用其他办法,因为 Object.assign()拷贝的是属性值。假如源对象的属性值是一个对象的引用,那么它也只指向那个引用。
let obj1 = {
    
     a: 0 , b: {
    
     c: 0}}; 
let obj2 = Object.assign({
    
    }, obj1); 
console.log(JSON.stringify(obj2)); // { a: 0, b: { c: 0}} 

obj1.a = 1; 
console.log(JSON.stringify(obj1)); // { a: 1, b: { c: 0}} 
console.log(JSON.stringify(obj2)); // { a: 0, b: { c: 0}} 

obj2.a = 2; 
console.log(JSON.stringify(obj1)); // { a: 1, b: { c: 0}} 
console.log(JSON.stringify(obj2)); // { a: 2, b: { c: 0}}
 
obj2.b.c = 3; 
console.log(JSON.stringify(obj1)); // { a: 1, b: { c: 3}} 
console.log(JSON.stringify(obj2)); // { a: 2, b: { c: 3}} 
最后一次赋值的时候,b是值是对象的引用,只要修改任意一个,其他的也会受影响

// Deep Clone (深拷贝)
obj1 = {
    
     a: 0 , b: {
    
     c: 0}}; 
let obj3 = JSON.parse(JSON.stringify(obj1)); 
obj1.a = 4; 
obj1.b.c = 4; 
console.log(JSON.stringify(obj3)); // { a: 0, b: { c: 0}}

13. 利用路由信息变化实现动态搜索

最初想法:在每个三级列表和搜索按钮加一个点击触发事件,只要点击了就执行搜索函数。
这是一个很蠢的想法,如果这样就会生成很多回调函数,很耗性能。
最佳方法: 我们每次进行新的搜索时,我们的query和params参数中的部分内容肯定会改变,而且这两个参数是路由的属性。我们可以通过监听路由信息的变化来动态发起搜索请求。

如下图所示,$route是组件的属性,所以watch是可以监听的(watch可以监听组件data中所有的属性)
在这里插入图片描述

search组件的watch部分代码

//在组件挂载完毕之前,整理发送请求要携带的参数
beforeMount() {
    
    
  //ES6语法 对象拷贝
  Object.assign(this.searchParams, this.$route.query, this.$route.params)
},  
// mounted只会执行一次
mounted() {
    
    
  //组件挂载请求数据
  this.getData()
},
methods: {
    
    
  //向服务器发请求获取Search模块数据,根据参数不同返回不同的数据进行展示
  //把请求封装成一个函数,需要调用时再调用
  getData() {
    
    
    this.$store.dispatch('getSearchList',this.searchParams)
  }
},
watch: {
    
    
  //监听路由的信息是否发送变化,如果发送变化,再次发起请求
  $route(newValue, oldValue) {
    
    
    //再次整理发给服务器的数据
    Object.assign(this.searchParams, this.$route.query, this.$route.params)
    //再发请求
    this.getData()
    //如果下一次搜索时只有params参数,拷贝后会发现searchParams会保留上一次的query参数
    //所以每次请求结束后将相应参数制空
    this.searchParams.category1Id = '';
    this.searchParams.category2Id = '';
    this.searchParams.category3Id = '';
  }
}

14. 面包屑相关操作

在这里插入图片描述
本次项目的面包屑操作主要就是两个删除逻辑。
分为:
当分类属性(query)删除时删除面包屑同时修改路由信息。
当搜索关键字(params)删除时删除面包屑、修改路由信息、同时删除输入框内的关键字。

14.1 面包屑添加/删除分类属性(query)

在这里插入图片描述

因为此部分在面包屑中是通过categoryName展示的,所所以删除时应将该属性值制空或undefined。
可以通过路由再次跳转修改路由信息和url链接

 <!--面包屑-->
<ul class="fl sui-tag">
  <li class="with-x" v-if="searchParams.categoryName">
    {
   
   {searchParams.categoryName}}
    <i @click="removeCateGoryName">×</i>
    </li>
</ul>

<script>
methods: {
      
      
    //删除面包屑——分类名
    removeCateGoryName() {
      
      
      // 带给服务器的参数是可选的,如果属性值设置为空字符串,还是会把相应的字段带给服务器,
      //如果属性值设置undefined,不会发送字段发给服务器,减少参数量
      this.searchParams.categoryName = undefined
      this.searchParams.category1Id = undefined
      this.searchParams.category2Id = undefined
      this.searchParams.category3Id = undefined
      //再发一次请求
      this.getData()
      //地址栏也要修改,如果有params参数,要保留params参数,这里仅删除query参数
      if(this.$route.params) {
      
      
         this.$router.push({
      
      name: 'search', params:this.$route.params})
      }
    }
}
</script>

14.2 面包屑添加/删除搜索关键字(params)

在这里插入图片描述

和query删除的唯一不同点是此部分会多一步操作:删除输入框内的关键字(因为params参数是从输入框内获取的)
输入框实在Header组件中的
在这里插入图片描述

header和search组件是兄弟组件,要实现该操作就要通过兄弟组件之间进行通信完成。
在这里插入图片描述
详细的组件通信

这里通过$bus实现header和search组件的通信。
$bus使用
(1)在main.js中注册

new Vue({
    
    
  //全局事件总线$bus配置
  beforeCreate() {
    
    
    //此处的this就是这个new Vue()对象
    //网络有很多bus通信总结,原理相同,换汤不换药
    Vue.prototype.$bus = this
  },
  render: h => h(App),
  //router2、注册路由,此时组件中都会拥有$router $route属性
  router,
  //注册store,此时组件中都会拥有$store
  store
}).$mount('#app')

(2)search组件使用$bus通信,第一个参数可以理解为为通信的暗号,还可以有第二个参数(用于传递数据),我们这里只是用于通知header组件进行相应操作,所以没有设置第二个参数。

 <!--面包屑-->
<ul class="fl sui-tag">
  <!-- 分类名的面包屑 -->
  <li class="with-x" v-if="searchParams.categoryName">
    {
   
   {searchParams.categoryName}}
    <i @click="removeCateGoryName">×</i>
   </li>
   <!-- 关键字的面包屑 -->
   <li class="with-x" v-if="searchParams.keyWord">
     {
   
   {searchParams.keyWord}}
     <i @click="removeKeyWord">×</i>
    </li>
</ul>

<script>
methods: {
      
      
    //删除面包屑——分类名
    removeCateGoryName() {
      
      
      // 带给服务器的参数是可选的,如果属性值设置为空字符串,还是会把相应的字段带给服务器,
      //如果属性值设置undefined,不会发送字段发给服务器,减少参数量
      this.searchParams.categoryName = undefined
      this.searchParams.category1Id = undefined
      this.searchParams.category2Id = undefined
      this.searchParams.category3Id = undefined
      //再发一次请求
      this.getData()
      //地址栏也要修改,如果有params参数,要保留params参数,这里仅删除query参数
      if(this.$route.params) {
      
      
         this.$router.push({
      
      name: 'search', params:this.$route.params})
      }
    },
    //删除面包屑——关键字
    removeKeyWord() {
      
      
       this.searchParams.keyWord = undefined
       //再发一次请求
       this.getData()
       //通知兄弟组件header删除输入框的keyword关键字
       this.$bus.$emit('clearKeyWord')
       this.$router.push({
      
      name:'search',query:this.$route.query})
     }
}
</script>

(3)header组件接受$bus通信
注意:组件挂载时就监听clearKeyWord事件

mounted() {
    
    
  //  组件挂载时就监听clear事件,clear事件在search模块中定义
  //  当删除关键字面包屑时,触发该事件,同时header的输入框绑定的keyword要删除
    this.$bus.$on("clearKeyWord",()=>{
    
    
      this.keyWord = ''
    })
  }

**问题:**在删除面包屑时,会发送两次请求
**原因:**我们前面写到了用watch监听路由变化的代码,在删除面包屑时,除了要删除面包屑,还要修改路由信息,所以watch监听到了路由信息,所以,只需要将removeKeyWordremoveCateGoryName方法里的this.getData()删除即可

Search/index.vue的部分代码

import SearchSelector from './SearchSelector/SearchSelector'
  import {
    
    mapGetters} from 'vuex'
  export default {
    
    
    name: 'Search',
    components: {
    
    
      SearchSelector
    },
    data() {
    
    
      return {
    
    
        //带给服务器的参数
        searchParams: {
    
    
          category1Id: "",  //一级分类
          category2Id: "",
          category3Id: "",
          categoryName: "", //分类名
          keyword: "",     //搜索关键字
          order: "",       //排序
          pageNo: 1,       //默认值为1,分页器,页数
          pageSize: 10,    //每一页展示条数
          props: [],       //平台售卖属性
          trademark: ""    //品牌
        }
      }
    },
    //在组件挂载完毕之前,整理发送请求要携带的参数
    beforeMount() {
    
    
      //复杂的写法
      // this.searchParams.category1Id = this.$route.query.category1Id
      // this.searchParams.category2Id = this.$route.query.category2Id
      // this.searchParams.category3Id = this.$route.query.category3Id
      // this.searchParams.categoryName = this.$route.query.categoryName
      // this.searchParams.keyword = this.$route.params.keyword

      //ES6语法 对象拷贝
      Object.assign(this.searchParams, this.$route.query, this.$route.params)

    },  
    // mounted只会执行一次
    mounted() {
    
    
      //组件挂载请求数据
      this.getData()
    },
    computed: {
    
    
      ...mapGetters(['goodsList'])
    },
    methods: {
    
    
      //向服务器发请求获取Search模块数据,根据参数不同返回不同的数据进行展示
      //把请求封装成一个函数,需要调用时再调用
      getData() {
    
    
        this.$store.dispatch('getSearchList',this.searchParams)
      },
      //删除面包屑——分类名
      removeCateGoryName() {
    
    
        // 带给服务器的参数是可选的,如果属性值设置为空字符串,还是会把相应的字段带给服务器,
        //如果属性值设置undefined,不会发送字段发给服务器,减少参数量
        this.searchParams.categoryName = undefined
        this.searchParams.category1Id = undefined
        this.searchParams.category2Id = undefined
        this.searchParams.category3Id = undefined
        //再发一次请求
        // this.getData()
        //地址栏也要修改,如果有params参数,要保留params参数,这里仅删除query参数
        if(this.$route.params) {
    
    
          this.$router.push({
    
    name: 'search', params:this.$route.params})
        }
      },
      //删除面包屑——关键字
      removeKeyWord() {
    
    
        this.searchParams.keyWord = undefined
        //再发一次请求
        // this.getData()
        this.$bus.$emit('clearKeyWord')
        if(this.$route.query) {
    
    
          this.$router.push({
    
    name:'search',query:this.$route.query})
        }
      }
    },
    watch: {
    
    
      //监听路由的信息是否发送变化,如果发送变化,再次发起请求
      $route(newValue, oldValue) {
    
    
        //console.log(newValue, oldValue);
        //再次整理发给服务器的数据
        Object.assign(this.searchParams, this.$route.query, this.$route.params)
        //再发请求
        this.getData()
        //如果下一次搜索时只有params参数,拷贝后会发现searchParams会保留上一次的query参数
        //所以每次请求结束后将相应参数制空
        this.searchParams.category1Id = undefined;
        this.searchParams.category2Id = undefined;
        this.searchParams.category3Id = undefined;
      }
    }
  }

14.3 SearchSelector子组件传参关联面包屑

在前两小节中描述了通过query、params参数生成面包屑,以及面包屑的删除操作对应地址栏url的修改。
SearchSelector组件有两个属性也会生成面包屑,分别为品牌名、手机属性。如下图所示
在这里插入图片描述
此处生成面包屑时会涉及到子组件向父组件传递信息操作,之后的操作和前面两个小节讲的面包屑操作原理相同。唯一的区别是,这里删除面包屑时不需要修改地址栏url,因为url是由路由地址确定的,并且只有query、params两个参数变化回影响路由地址变化。

使用自定义事件,让子组件给父组件传递数据
Search/SearchSelector/index.vue

<ul class="logo-list">
   <li v-for="trademark in trademarkList" :key="trademark.tmId" @click="trademarkHandler(trademark)">{
   
   {trademark.tmName}}</li>
 </ul>
<!-- ... -->
<ul class="type-list">
  <li v-for="(attrValue,index) in attr.attrValueList" :key="index" @click="attrsHandler(attr.attrId, attr.attrName, attrValue)">
    <a href="#">{
   
   {attrValue}}</a>
  </li>
</ul>

<script>
import {
      
      mapGetters} from 'vuex'
  export default {
      
      
    name: 'SearchSelector',
    computed: {
      
      
      ...mapGetters(['trademarkList', 'attrsList'])
    },
    methods: {
      
      
      trademarkHandler(trademark) {
      
      
        //将子组件中的点击的品牌信息传递给父组件Search,自定义事件
        this.$emit('trademarkInfoEvent', trademark)
      },
      attrsHandler(attrId, attrName, attrValue) {
      
      
        let str = `${ 
        attrId}:${ 
        attrValue}:${ 
        attrName}`
        this.$emit('attrInfoEvent', str)
      }
    }
  }
</script>

Search/index.vue

<ul class="fl sui-tag">
 <!-- 分类的面包屑 -->
  <li class="with-x" v-if="searchParams.categoryName">
    {
   
   {searchParams.categoryName}}
    <i @click="removeCateGoryName">×</i>
  </li>
  <!-- 关键字的面包屑 -->
  <li class="with-x" v-if="searchParams.keyWord">
    {
   
   {searchParams.keyWord}}
    <i @click="removeKeyWord">×</i>
  </li>
  <!-- 品牌的面包屑 -->
  <li class="with-x" v-if="searchParams.trademark">
    {
   
   {searchParams.trademark.split(':')[1]}}
    <i @click="removeTrademark">×</i>
  </li>
<!-- 售卖属性面包屑 -->
  <li class="with-x" v-for="(attr,index) in searchParams.props" :key="index">
     {
   
   {attr.split(':')[1]}}
     <i @click="removeAttr(index)">×</i>
   </li>
</ul>
</div>

<!--selector-->
<SearchSelector @trademarkInfoEvent="getTrademark" />

<script>
methods: {
      
      
	//.....
	//删除面包屑——关键字
    removeKeyWord() {
      
      
      this.searchParams.keyWord = undefined
      //再发一次请求, watch已经监听路由变化发请求了,这里不需要再请求
      // this.getData()
      this.$bus.$emit('clearKeyWord')
      if(this.$route.query) {
      
      
        this.$router.push({
      
      name:'search',query:this.$route.query})
      }
    },
    //删除面包屑——品牌信息
   removeTrademark() {
      
      
      this.searchParams.trademark = undefined
      //由于路由没有变化,需要发送请求
      this.getData()            
    },
    //删除面包屑——属性信息
    removeAttr(index){
      
      
      this.searchParams.props.splice(index,1)
      this.getData()
    },
	//获取子组件传递的品牌信息(自定义事件)
    getTrademark(trademark) {
      
      
      //整理品牌字段参数: "ID:品牌名称"
      this.searchParams.trademark = `${ 
        trademark.tmId}:${ 
        trademark.tmName}`
      //再发请求
      this.getData()
    },
    //获取子组件传递的属性信息(自定义事件)
    getAttr(attr) {
      
      
       //整理品牌字段参数: ["属性ID:属性值:属性名"]
       //数组去重
       if(this.searchParams.props.indexOf(attr) === -1) {
      
      
         this.searchParams.props.push(attr)
         //再发请求
         this.getData()
       }
     }
}
</script>

在这里插入图片描述

15. 商品排序

排序的逻辑比较简单,只是改变一下请求参数中的order字段,后端会根据order值返回不同的数据来实现升降序。
order属性值为字符串,例如‘1:asc’、‘2:desc’。1代表综合,2代表价格,asc代表升序,desc代表降序。

我们的升降序是通过箭头图标来辨别的,如图所示:
在这里插入图片描述

<ul class="sui-nav">
 <li :class="{ active: isActive1 }" @click="changeOrder('1')">
   <a href="#">
     <span>综合</span>
     <em class="fs-down">
       <i class="arrow"></i>
     </em>
   </a>
 </li>
 <!-- ..... -->
 <li :class="{ active: isActive2 }" @click="changeOrder('2')">
   <a href="#">
     <span>价格</span>
     <em :class="getClass()">
       <i class="arrow-top"></i>
       <i class="arrow-bottom"></i>
     </em>
   </a>
 </li>
</ul>

<script>
computed: {
      
      
    ...mapGetters(["goodsList"]),
    isActive1() {
      
      
      return this.searchParams.order.indexOf("1") !== -1;
    },
    isActive2() {
      
      
      return this.searchParams.order.indexOf("2") !== -1;
    },
  },
methods: {
      
      
	//类名设置
    getClass() {
      
      
      return {
      
      
        'fs-down': this.searchParams.order.indexOf('desc')!==-1,
        'fs-up': this.searchParams.order.indexOf('asc')!==-1
        
      }
    },
    //flag区分综合、价格,1:综合,2:价格
    changeOrder(flag) {
      
      
      let newSearchOrder = this.searchParams.order
      //将order拆为两个字段orderFlag(1:2)、order(asc:desc)
      let orderFlag = this.searchParams.order.split(':')[0]
      let order = this.searchParams.order.split(':')[1]

      //由综合到价格,由价格到综合
      if(orderFlag !==  flag) {
      
      
         //点击的不是同一个按钮
        newSearchOrder = `${ 
        flag}:desc`
      } else {
      
      
        //多次点击的是不是同一个按钮
        newSearchOrder = `${ 
        flag}:${ 
        order === 'desc' ? 'asc': 'desc'}`
      }
      //需要给order重新赋值
      this.searchParams.order = newSearchOrder
      //发送请求
      this.getData()

      
    }
}
</script>
<style>
.fs-up {
      
      
  .arrow-bottom {
      
      
      filter: alpha(opacity=50);
      -moz-opacity: 0.5;
      opacity: 0.5;
    }
  }
  .fs-down {
      
      
    .arrow-top {
      
      
      filter: alpha(opacity=50);
      -moz-opacity: 0.5;
      opacity: 0.5;
    }
  }
</style>

16. 手写分页器

实际开发中是不会手写的,一般都会用一些开源库封装好的分页,比如element ui。但是这个知识还是值得学习一下的。
核心属性:
pageNo(当前页码)、pageSize(每一页展示多少条数据)、total(共有多少条数据)、continues(连续展示的页码)
核心逻辑是获取连续页码的起始页码和末尾页码,通过计算属性获得。(计算属性如果想返回多个数值,可以通过对象形式返回)
分页器注册为一个全局组件,核心属性是通过父组件传递给分页器组件,计算连续页码和总页数
src/components/Pagination/index.vue

<template>
  <div class="fr page">
    <div class="sui-pagination clearfix">
      <ul class="p-num">
        <li :class="{prev:true ,disabled: pageNo == 1}" @click="goToPageHandler(pageNo-1)">
          <a href="#">
            <i> &lt; </i>
            <em>上一页</em>
          </a>
        </li>
        <li v-if="startNumAndEnd.start > 1" @click="goToPageHandler(1)">
          <a href="#">1</a>
        </li>
        <li class="dotted" v-if="startNumAndEnd.start > 2">
            <span>...</span>
        </li>

        <li
         :class="{active: pageNo == page}"
          v-for="(page, index) in startNumAndEnd.end" 
          :key="index" 
          v-if="page >= startNumAndEnd.start" 
          @click="goToPageHandler(page)" >
          <a href="#">{
    
    {
    
     page }}</a>
        </li>

        <li class="dotted" v-if="startNumAndEnd.end < totalPage-1"><span>...</span></li>
        <li v-if="startNumAndEnd.end < totalPage" @click="goToPageHandler(totalPage)">
          <a href="#">{
    
    {
    
    totalPage}}</a>
        </li>

        <li :class="{next:true ,disabled: pageNo == totalPage}" @click="goToPageHandler(pageNo+1)">
          <a href="#">
            <em>下一页</em>
            <i>&gt;</i>
          </a>
        </li>
      </ul>
      <div class="p-skip">
        <em><b>{
    
    {
    
     totalPage }}</b>&nbsp;&nbsp;到第
        </em>
        <input type="text" value="1" class="input-txt" v-model="toPage" />
        <em></em>
        <a href="" class="btn" @click="goToPageHandler(toPage)">确定</a>
      </div>
    </div>
  </div>
</template>

<script>
export default {
    
    
  name: "Pagination",
  components: {
    
    },
  props: ["pageNo", "total", "pageSize", "continues"],
  data() {
    
    
    return {
    
    
        toPage: 1
    };
  },
  computed: {
    
    
    //共有多少页
    totalPage() {
    
    
      //Math.ceil向上取整
      return Math.ceil(this.total / this.pageSize);
    },
    //连续页码得其实页码、末尾页码
    startNumAndEnd() {
    
    
      let {
    
      continues, pageNo, totalPage } = this;
      continues = parseInt(continues)
      pageNo = parseInt(pageNo)
      let start = 0,end = 0;
      //规定连续页码数字5(totalPage至少5页)
      //不正常现象
      if (continues > totalPage) {
    
    
        start = 1;
        end = totalPage;
      } else {
    
    
        start = pageNo - Math.floor(continues / 2);
        end = pageNo + Math.floor(continues / 2);
        if (start < 1) {
    
    
          start = 1;
          end = continues;
        }
        if (end > totalPage) {
    
    
          end = totalPage;
          start = totalPage - continues + 1;
        }
        
      }
      return {
    
     start, end };
    }
  },
  methods: {
    
    
    //给父组件传递页码
    goToPageHandler(pageNo) {
    
    
        this.$emit("getPageNoEvent", pageNo)
    }
  }
};
</script>
<style lang="less" scoped>
//....
li {
    
    
	//...
	&.disabled {
    
    
          pointer-events:none;   //该样式会阻止默认事件,但是鼠标样式会变成箭头的样子。
          a {
    
    
            color: #999;
            background-color: #fff;
            cursor: not-allowed;      //在此属性中,光标指示将不会执行所请求的动作。
            &:hover {
    
    
                color: #999 !important;
            } 
          }
        }
}
//...
</style>

当点击页码会将pageNo传递给父组件,然后父组件发起请求,最后渲染。这里还是应用通过自定义事件实现子组件向父组件传递信息。

父组组件Search/index.vue

<template>
<!-- 分页器 -->
<Pagination 
    :pageNo="searchParams.pageNo" 
    :total="total" 
    :pageSize="searchParams.pageSize" 
    :continues="5"  
    @getPageNoEvent="getPageNo"
  />
<!-- ... -->
</template>
<script>
import {
      
       mapGetters, mapState } from "vuex";
export default {
      
      
  name: "Search",
  data() {
      
      
    return {
      
      
      //带给服务器的参数
      searchParams: {
      
      
        category1Id: "", //一级分类
        category2Id: "",
        category3Id: "",
        categoryName: "", //分类名
        keyword: "", //搜索关键字
        order: "1:desc", //排序
        pageNo: 1, //默认值为1,分页器,页数
        pageSize: 10, //每一页展示条数
        props: [], //平台售卖属性
        trademark: "", //品牌
      },
    };
  },
  computed: {
      
      
    //获取数据总条数
    ...mapState({
      
      
      total: state=>state.search.searchList.total
    }),
  },
  methods: {
      
      
    //向服务器发请求获取Search模块数据,根据参数不同返回不同的数据进行展示
    //把请求封装成一个函数,需要调用时再调用
    getData() {
      
      
      this.$store.dispatch("getSearchList", this.searchParams);
    },
    // 自定义回调事件,获取子组件传递的页码
    getPageNo(pageNo) {
      
      
      this.searchParams.pageNo = pageNo
      //发请求
      this.getData()
    }
  },
}
</script>

效果
在这里插入图片描述
其中,鼠标禁用样式cursor: not-allowed;和鼠标禁用事件pointer-events:none;看这个blog

17. 路由滚动行为

使用前端路由,当切换到新路由时,想要页面滚到顶部,或者是保持原先的滚动位置,就像重新加载页面那样。 vue-router 能做到,而且更好,它让你可以自定义路由切换时页面如何滚动。

const router = createRouter({
    
    
  history: createWebHashHistory(),
  routes: [...],
  scrollBehavior (to, from, savedPosition) {
    
    
    // return 期望滚动到哪个的位置
    //滚动到顶部
    return {
    
     y: 0}
  }
})

18. undefined细节

访问undefined的属性值会引起红色警告,可以不处理,但是要明白警告的原因。
以获取商品categoryView信息为例,categoryView是一个对象。
对应的getters代码

const getters =  {
    
    
    categoryView(state){
    
    
        return state.goodInfo.categoryView
    }
}

对应的computed代码

 computed:{
    
    
      ...mapGetters(['categoryView'])
    }

html代码

<div class="conPoin">
    <span v-show="categoryView.category1Name" >{
    
    {
    
    categoryView.category1Name}}</span>
    <span v-show="categoryView.category2Name" >{
    
    {
    
    categoryView.category2Name}}</span>
    <span v-show="categoryView.category3Name" >{
    
    {
    
    categoryView.category3Name}}</span>
</div>

下细节在于getters的返回值。如果getters按上面代码写为return state.goodInfo.categoryView,页面可以正常运行,但是会出现红色警告。
在这里插入图片描述
原因: 假设我们网络故障,导致goodInfo的数据没有请求到,即goodInfo是一个空的对象,当我们去调用getters中的return state.goodInfo.categoryView时,因为goodInfo为空,所以也不存在categoryView,即我们getters得到的categoryView为undefined。所以我们在html使用该变量时就会出现没有该属性的报错。
即:网络正常时不会出错,一旦无网络或者网络问题就会报错。
总结: 所以我们在写getters的时候要养成一个习惯在返回值后面加一个||条件。即当属性值undefined时,会返回||后面的数据,这样就不会报错。
如果返回值为对象加|| {},数组:|| [ ]
此处categoryView为对象,所以将getters代码改为

const getters =  {
    
    
    categoryView(state){
    
    
        return state.goodInfo.categoryView || {
    
    }
    }
}

同样的,我们假设网络故障,没有数据回来的情况下,父组件给子组件传递数据,也有这样的问题
detail父组件

<Zoom :skuImageList="skuInfo.skuImageList" />

<script>
computed: {
      
      
    ...mapGetters(['categoryView','skuInfo']),
  }
</script>

zoom子组件

<template>
  <div class="spec-preview">
    <img :src="skuImageList[0].imgUrl" />
    <div class="event"></div>
    <div class="big">
      <img :src="skuImageList[0].imgUrl" />
    </div>
    <div class="mask"></div>
  </div>
</template>

<script>
  export default {
      
      
    name: "Zoom",
    props: ["skuImageList"],
    mounted(){
      
      
      console.log(this.skuImageList);
    }
  }
</script>

在这里插入图片描述

原因如上所诉,只要在detail父组件的计算属性那里计算skuImageList,设置|| [ ] 就可以解决问题

<Zoom :skuImageList="skuImageList" />

<script>
computed: {
      
      
    ...mapGetters(['categoryView','skuInfo']),
    //给子组件的数据
    skuImageList() {
      
      
      //如果服务器的数据没有回来,skuInfo这个是空对象
      return this.skuInfo.skuImageList || []
    }
  }
</script>

解决上述的问题,又出现一个问题,输出一个空数组,空数组没有imgUrl这个属性,所以报错(假设网络故障的情况)
在这里插入图片描述
数组的第0项至少是个对象,不能是undefined ,因此在zoom子组件里,同样要设置|| {}
zoom子组件

<template>
  <div class="spec-preview">
    <img :src="imgObj.imgUrl" />
    <div class="event"></div>
    <div class="big">
      <img :src="imgObj.imgUrl" />
    </div>
    <div class="mask"></div>
  </div>
</template>

<script>
  export default {
      
      
    name: "Zoom",
    props: ["skuImageList"],
    mounted(){
      
      
      console.log(this.skuImageList);
    },
    computed: {
      
      
      imgObj() {
      
      
        return this.skuImageList[0] || {
      
      }
      }
    }
  }
</script>

问题解决,以上的这些问题不会影响功能实现,但需要搞清楚问题的缘由

19. 商品放大镜

<template>
  <div class="spec-preview">
    <img :src="imgObj.imgUrl" />
    <div class="event" @mousemove="handler"></div>
    <div class="big">
      <img :src="imgObj.imgUrl" ref="big" />
    </div>
    <div class="mask" ref="mask"></div>
  </div>
</template>

<script>
  export default {
      
      
    name: "Zoom",
    props: ["skuImageList"],
    data() {
      
      
      return {
      
      
        currentIndex: 0
      }
    },
    mounted(){
      
      
      // console.log(this.skuImageList);
      this.$bus.$on("getIndex",(index)=>{
      
      
        this.currentIndex = index
      })
    },
    computed: {
      
      
      imgObj() {
      
      
        return this.skuImageList[this.currentIndex] || {
      
      }
      }
    },
    methods: {
      
      
      handler(event) {
      
      
        let mask = this.$refs.mask
        let big = this.$refs.big
        //鼠标此时的可视区域的横坐标和纵坐标
        //主要是设置鼠标在遮挡层的中间显示
        let left = event.offsetX - mask.offsetWidth/2
        let top = event.offsetY - mask.offsetHeight/2

        //约束mask的范围
        left = left < 0 ? 0 : left
        left = left >= mask.offsetWidth ? mask.offsetWidth : left
        top = top < 0 ? 0 : top
        top = top >= mask.offsetHeight ? mask.offsetHeight : top
        
        //修改元素的left|top属性
        mask.style.left = left+'px'
        mask.style.top = top +'px'

        big.style.left = -2 * left + 'px'
        big.style.top = -2 * top + 'px'

      }
    }
  }
</script>

<style lang="less">
  .spec-preview {
      
      
    position: relative;
    width: 400px;
    height: 400px;
    border: 1px solid #ccc;
    margin-bottom: 20px;

    img {
      
      
      width: 100%;
      height: 100%;
    }

    .event {
      
      
      width: 100%;
      height: 100%;
      position: absolute;
      top: 0;
      left: 0;
      z-index: 998;
    }

    .mask {
      
      
      width: 50%;
      height: 50%;
      background-color: rgba(0, 255, 0, 0.3);
      position: absolute;
      left: 0;
      top: 0;
      display: none;
    }

    .big {
      
      
      width: 100%;
      height: 100%;
      position: absolute;
      top: -1px;
      left: 100%;
      border: 1px solid #aaa;
      overflow: hidden;
      z-index: 998;
      display: none;
      background: white;

      img {
      
      
        width: 200%;
        max-width: 200%;
        height: 200%;
        position: absolute;
        left: 0;
        top: 0;
      }
    }

    .event:hover~.mask,
    .event:hover~.big {
      
      
      display: block;
    }
  }
</style>

在这里插入图片描述

20. 加入购物车成功路由

点击加入购物车时,会向后端发送API请求,但是该请求的返回值中data为null,所以我们只需要根据状态码code判断是否跳转到‘加入购物车成功页面’。
detail组件‘加入购物车’请求函数:

//加入购物车事件
    async addShopCart() {
    
    
      //1.发请求(发数据给服务器) 
      try {
    
    
        //等待这个请求结束再做路由跳转
        await this.$store.dispatch("addOrUpdateShopCart", {
    
    
          skuId: this.$route.params.goodId,
          skuNum: this.skuNum
        })
        //一些简单的数据,比如skuNum通过query传过去
        //复杂的数据通过session存储,
        //sessionStorage、localStorage只能存储字符串        
        //sessionStorage.setItem("SKUINFO",JSON.stringify(this.skuInfo))

        //2. 服务器存储成功——进行路由传递参数,
        this.$router.push({
    
    name: 'addcartsuccess', query: {
    
    'skuNum': this.skuNum}})
      } catch (error) {
    
    
        //判断成功还是失败
        alert(error.message)
      }
    }

store/detail/index.js

const actions = {
    
    
	//将产品添加到购物车中
    async addOrUpdateShopCart({
     
     commit}, {
     
     skuId, skuNum}) {
    
    
        let result  = await reqAddOrUpdateShopCart(skuId,skuNum)
        //加入购物车以后,服务器写入数据成功,并未返回数据,只返回code=200,代表此次操作成功
        if(result.code == 200){
    
    
            return 'ok'
        } else {
    
    
            return Promise.reject(new Error('fail'))
        }
    }
}

其实这里当不满足result.code === 200条件时,也可以返回字符串‘faile’,自己在addShopCar中判断一下返回值,如果为‘ok’则跳转,如果为‘faile’(或者不为‘ok’)直接提示错误。当然这里出错时返回一个Promise.reject更加符合程序的逻辑。

当我们想要实现两个毫无关系的组件传递数据时,首先想到的就是路由的query传递参数,但是query适合传递单个数值的简单参数,所以如果想要传递对象之类的复杂信息,就可以通过Web Storage实现。

sessionStoragelocalStorage概念:
sessionStorage:为每一个给定的源维持一个独立的存储区域,该区域在页面会话期间可用(即只要浏览器处于打开状态,包括页面重新加载和恢复)。
localStorage:同样的功能,但是在浏览器关闭,然后重新打开后数据仍然存在。
注意:无论是session还是local存储的值都是字符串形式。如果我们想要存储对象,需要在存储前JSON.stringify()将对象转为字符串,在取数据后通过JSON.parse()将字符串转为对象。

 //加入购物车事件
    async addShopCart() {
    
    
      //1.发请求(发数据给服务器) 
      try {
    
    
        //等待这个请求结束再做路由跳转
        await this.$store.dispatch("addOrUpdateShopCart", {
    
    
          skuId: this.$route.params.goodId,
          skuNum: this.skuNum
        })
        //一些简单的数据,比如skuNum通过query传过去
        //复杂的数据通过session存储,
        //sessionStorage、localStorage只能存储字符串        
        //sessionStorage.setItem("SKUINFO",JSON.stringify(this.skuInfo))

        //2. 服务器存储成功——进行路由传递参数,
        //一些简单的数据skuNum,通过query形式给路由组件传递过去
        //产品信息数据(对象数据),通过会话存储(持久)
        this.$router.push({
    
    name: 'addcartsuccess', query: {
    
    'skuNum': this.skuNum}})
        sessionStorage.setItem('skuInfo', JSON.stringify(this.skuInfo))
     } catch (error) {
    
    
        //判断成功还是失败
        alert(error.message)
      }
    }
  },

AddCartSuccess/index.vue 加入购物车成功组件

computed: {
    
    
   skuInfo() {
    
    
     //获取本地存储数据
     return JSON.parse(sessionStorage.getItem('skuInfo'))
   }
 }

21. 购物车组件开发

21.1 临时游客的uuid

根据api接口文档封装请求函数

export const reqGetCartList = () => {
    
    
return requests({
    
    
	url:'/cart/cartList',
	method:'GET'
})}

但是如果想要获取详细信息,还需要一个用户的uuidToken,用来验证用户身份。但是该请求函数没有参数,所以我们只能把uuidToken加在请求头中。

创建utils工具包文件夹,创建生成uuid的js文件,对外暴露为函数(记得导入uuid => npm install uuid)。
生成临时游客的uuid(随机字符串),每个用户的uuid不能发生变化,还要持久存储

src/utils/uuid_token.js

import {
    
    v4 as uuidv4} from 'uuid'

//生成临时游客的uuid(随机字符串),每个用户的uuid不能发生变化,还要持久存储
export const getUUID = ()=> {
    
    
    //1. 判断本地存储是否有uuid
    let uuid_token = localStorage.getItem('UUIDTOKEN')

    //2.本地存储没有uuid
    if(!uuid_token) {
    
    
        //生成uuid
        uuid_token = uuidv4()
        localStorage.setItem("UUIDTOKEN", uuid_token)
    }
    //当用户有uuid时不会再生成
    return uuid_token
}

用户的uuid_token定义在store中的detail模块

import {
    
     getUUID } from "@/utils/uuid_token"
const state =  {
    
    
    goodInfo:{
    
    },
    //游客身份
    uuid_token: getUUID()
}

api/request.js里设置请求头

import store from '@/store';
requests.interceptors.request.use(config => {
    
    
    //config内主要是对请求头Header配置
	//通过请求头给服务器带临时身份给服务器
    //1、先判断uuid_token是否为空
    if(store.state.detail.uuid_token){
    
    
        //2、userTempId字段和后端统一
        config.headers['userTempId'] = store.state.detail.uuid_token
    }
    //比如添加token

    //开启进度条
    nprogress.start();
    return config;
})

注意this.$store只能在组件中使用,不能再js文件中使用。如果要在js中使用,需要引入import store from '@/store';
在这里插入图片描述
商品详情detail添加购物车时发送请求就会带userTempId属性了
在这里插入图片描述

21.2 购物车商品数量更改

  1. every函数使用
    every遍历某个数组,判断数组中的元素是否满足表达式,全部为满足返回true,否则返回false
    例如判断底部勾选框是否全部勾选代码部分
<div class="select-all">
   <input class="chooseAll" type="checkbox" :checked="isAllCheck" />
   <span>全选</span>
 </div>
 <script>
 computed: {
      
      
//判断底部勾选框是否全部勾选
      isAllCheck() {
      
      
        //every遍历某个数组,判断数组中的元素是否满足表达式,全部为满足返回true,否则返回false
        return this.cartInfoList.every(item => item.isChecked === 1)
      }
}
</script>
  1. 购物车商品数量更改
    在这里插入图片描述

添加到购物车和对已有物品进行数量改动使用的同一个api,可以查看api文档。(skuNum可以是一个增量减量或者一个整体的数量,添加到购物车组件用到整体量,购物车组件的商品个数修改用到增减量)
在这里插入图片描述

使用@click@change触发changeSkuNum事件修改商品数量,都用到了同一个函数,但是携带的参数个数不同
changeSkuNum函数有三个参数,type区分操作,disNum用于表示数量变化(正负),cart商品的信息

 <li class="cart-list-con5">
     <a href="javascript:void(0)" class="mins" @click="changeSkuNum('minus',-1,cartInfo)">-</a>
     <input autocomplete="off" type="text" :value="cartInfo.skuNum" @change="changeSkuNum('change',$event.target.value,cartInfo)" minnum="1" class="itxt">
     <a href="javascript:void(0)" class="plus" @click="changeSkuNum('add',1,cartInfo)">+</a>
 </li>

<script>
methods: {
      
      
	getData() {
      
      
      this.$store.dispatch("getCartList");
    },
	//修改某个产品的个数,加入节流操作
    changeSkuNum: throttle(async function (type, disNum, cart) {
      
      
      // type区分操作,disNum用于表示数量变化(正负),cart商品的信息
      switch (type) {
      
      
        case "add":
          disNum = 1;
          break;
        case "minus":
          //产品的个数大于1,才可传递给服务器-1, 如果产品个数的小于1,设置disNum=0表示不增不减(原封不动)
          disNum = cart.skuNum > 1 ? -1 : 0;
          break;
        case "change":
          //如果用户输入的文本非法
          if (isNaN(disNum) || disNum < 1) {
      
      
            disNum = 0; //disNum=0表示不增不减
          } else {
      
      
            //正常大于1 ,不能出现小数
            //用户输入的值 - 产品原本个数
            disNum = parseInt(disNum) - cart.skuNum;
          }
          break;
      }
      //派发actions
      try {
      
      
        await this.$store.dispatch("addOrUpdateShopCart", {
      
      
          skuId: cart.skuId,
          skuNum: disNum,
        });
        //再一次获取服务器最新数据进行展示
        this.getData();
      } catch (error) {
      
      
        //判断成功还是失败
        alert(error.message);
      }
    }, 100),
}
</script>

21.3 购物车单个商品状态修改和删除

该部分较为简单,不过多赘述,但唯一需要注意的是当store的action种的函数返回值data为null时,应该采用下面的写法**(if-else)**

action部分:

//删除购物车某个产品
async deleteCart({
     
     commit}, skuId) {
    
    
     let result  = await reqDeleteCartById(skuId)
     if(result.code == 200){
    
    
         return 'ok'
     } else {
    
    
         return Promise.reject(new Error('fail'))
     }
 },
 //切换某个商品选中状态
async updateChecked({
     
     commit}, {
     
     skuId, isChecked}) {
    
    
     let result  = await reqUpdateCheckedById(skuId, isChecked)
     if(result.code == 200){
    
    
         return 'ok'
     } else {
    
    
         return Promise.reject(new Error('fail'))
     }
 },

method部分:(重点是try、catch)

methods: {
    
    
    //删除产品
    async deleteCartById(skuId) {
    
    
      try {
    
    
        await this.$store.dispatch("deleteCart", skuId);
        //删除成功再次发请求
        this.getData();
      } catch (error) {
    
    
        alert(error.message);
      }
    },
    //切换商品状态
    async updateChecked(skuId, event) {
    
    
      let isChecked = event.target.checked ? "1" : "0"
      try {
    
    
        await this.$store.dispatch("updateChecked", {
    
    skuId, isChecked});
        //修改成功,刷新数据,发请求
        this.getData()
      } catch (error) {
    
    
        alert(error.message);
      }
    },
}

21.4 购物车删除选中的全部商品和全选商品

  1. 删除选中的全部商品
    由于后台只提供了删除单个商品的接口,所以要删除多个商品时,只能多次调用actions中的函数。
    我们可能最简单的方法是在method的方法中多次执行dispatch删除函数,当然这种做法也可行,但是为了深入了解actions,我们还是要将批量删除封装为actions函数。
    actions扩展
deleteAllCheckedById(context) {
    
    
    console.log(context)
 }

context的内容
在这里插入图片描述
context中是包含dispatch、getters、state的,即我们可以在actions函数中通过dispatch调用其他的actions函数,可以通过getters获取仓库的数据。
这样我们的批量删除就简单了,对应的actions函数代码让如下

//删除选中的所有商品
deleteAllCheckedById({
     
     dispatch,getters}) {
    
    
     getters.cartList.cartInfoList.forEach(item =>  {
    
    
         let result = [];
         //将每一次返回值添加到数组中
         result.push(item.isChecked === 1?dispatch('deleteCartById',item.skuId):'')
     })
	return Promise.all(result)
},

Promise.all可以将多个Promise实例包装成一个新的Promise实例。同时,成功和失败的返回值是不同的,成功的时候返回的是一个结果数组,而失败的时候则返回最先被reject失败状态的值。

购物车组件method批量删除函数

//删除多个选中的商品
async deleteAllCheckedCart() {
    
    
   try {
    
    
     await this.$store.dispatch("deleteAllCheckedCart")
     //删除成功,刷新数据
     this.getData()
   } catch (error) {
    
    
     alert(error)
   }
 },
  1. 全选商品
    修改商品的全部状态和批量删除的原理相同
    actions
//修改购物车全选状态
updateAllChecked({
     
     getters, dispatch}, checked) {
    
    
     let result = []
     getters.cartList.cartInfoList.forEach(item=>{
    
    
         result.push(dispatch('updateChecked',{
    
    skuId:item.skuId, isChecked:checked}))
     })
     //result里需要全部都为成功,只要有一个失败就返回失败
     return Promise.all(result)
 }

购物车组件method切换全选状态

//修改全选状态
async updateAllChecked(event) {
    
    
   let checked = event.target.checked ? "1" : "0"
   try {
    
    
     await this.$store.dispatch("updateAllChecked", checked);
     //修改成功,刷新数据,发请求
     this.getData()
   } catch (error) {
    
    
     alert(error.message);
   }
 }

22 CSS样式中使用@符号

在CSS中也可以使用@符号(src别名),但需要在前面加上~

background-image: url(~@/assets/images/icons.png);

关于注册和登录页面、打包上线的笔记总结在 仿京东 项目笔记2(注册登录)

猜你喜欢

转载自blog.csdn.net/qq_33290233/article/details/131342453