项目结束已经半月有余,但未及时的总结,现在进行对项目进行总结。
代码源码github地址:Vue2项目尚品汇源码⭐
前端Vue项目核心
开发一个前端页面分为以下:
- 编写静态页面、拆分为静态界面组件
- 发请求(
API) vuex(actions,mutations,state,getter操作);- 组件获取仓库数据,数据动态展示
创建Vue2项目
项目初始化
- 安装脚手架,即全局安装
@vue/cli使用命令:npm install -g @vue/cli - 切换到需要创建项目的目录:
vue create app<项目名>选择Vue2版本 - 启动项目
npm run serve
脚手架目录说明
node_modules:放置项目依赖的地方
public:一般放置一些共用的静态资源,打包上线的时候,public文件夹里面资源原封不动打包到dist文件夹里面
src:程序员源代码文件夹
assets文件夹:经常放置一些静态资源(图片),assets文件夹里面资源webpack会进行打包为一个模块(js文件夹里面)
components文件夹:一般放置非路由组件(或者项目共用的组件)
App.vue :唯一的根组件
main.js 入口文件:程序最先执行的文件
babel.config.js:babel配置文件
package.json:看到项目描述、项目依赖、项目运行指令
README.md:项目说明文件
项目配置的说明
项目中使用的插件
axios(发送请求插件)|element-ui(引入插件库<支付界面弹出框>) | less(对less进行预编译)| mockjs(模拟数据)| nprogress(进度条)| qrcode(生成二维码) | swiper(轮播图) | vee-validate(表单验证)| vue-lazyload(图片懒加载)
"dependencies": {
"axios": "^1.3.6",
"core-js": "^3.8.3",
"element-ui": "^2.15.13",
"less": "^4.1.3",
"less-loader": "^7.0.0",
"mockjs": "^1.1.0",
"nprogress": "^0.2.0",
"qrcode": "^1.5.3",
"swiper": "^5.4.5",
"vee-validate": "^2.2.15",
"vue": "^2.6.14",
"vue-lazyload": "^1.3.3",
"vue-router": "^3.6.5",
"vuex": "^3.6.2"
}
关闭eslint语法检查 (影响正常开发调试)
在创建的项目根目录 vue.config.js 文件中进行配置 lintOnSave: false 这个配置项
组件路由
安装Vue-Router插件
终端输入 npm install vue-router\@3 (vue-router3 版本支持 vue2)
注册路由
在 main.js 文件中进行注册
//注册路由功能
import router from './router';
beforeCreate() {
//关闭vue中生产提示
Vue.config.productionTip = false
},
//下面代码作用:给项目添加路由功能,给全部VC实例身上拥有属性,$router
router,
render: h => h(App),
}).$mount('#app');
在 src 文件夹中创建 router 文件夹,在后者中创建 index.js 文件来存储路由配置信息。对路由信息进行模块化引入,将路由信息存在 routes.js 文件中。
配置路由
/route/index.js
import Vue from 'vue'
// 引入路由
import VueRouter from 'vue-router'
// 引入路由信息
import routes from './routes'
// 引入store模块
import store from '@/store';
Vue.use(VueRouter)
const router = new VueRouter({
// 添加routes文件,储存路由组件信息,方便管理
routes,
scrollBehavior(to, from, savedPosition) {
// 始终滚动到顶部
return { y: 0 }
},
})
在 /route/index.js 中重写 router 的 push方法 和 replace 方法
在项目进行的过程中,发现当使用编程式导航时进行路由跳转多次执行相同的 push 问题,控制台会出现错误。
例如:使用 this.$router.push({name:‘Search’,params:{keyword:“…”||undefined}}) 时,如果多次执行相同的 push ,控制台会出现警告。
第一次执行时返回一个成功的promise。但是对此执行后,控制台会出现报错。
原因:push 方法会返回一个promise对象,我们需要传递指定成功和失败的回调,上述的push中没有传递两个回调函数,所以出现了错误。
解决方法:
第一种:在每次使用push方法的时候,指定好回调。以上面的代码为例 this.$router.push({name:‘Search’,params:{keyword:“…”||undefined}},(){},(){})
指定两个回调进行解决。
第二种:对Router原型链上的 push、replace 方法进行重写。代码如下所示:
// 重写push || replace方法
// 第一个参数:告诉原来的push方法,跳转到哪里(传递哪些参数)
// 第二个参数:成功的回调
// 第三个参数:失败的回调
let originpush = VueRouter.prototype.push;
let originreplace = VueRouter.prototype.replace;
VueRouter.prototype.push = function(location,resolve,reject){
if(resolve && reject){
// 如果两个会处理函数都有的话,那么就使用原来的push方法
originpush.call(this,location,resolve,reject)
}else{
// 如果不能确定是否有回调处理函数 或者 回调处理函数的数目不能确定那么空的回调来替代
originpush.call(this,location,()=>{},()=>{})
}
}
VueRouter.prototype.replace = function(location,resolve,reject){
if(resolve && reject){
//与上面同理
originreplace.call(this,location,resolve,reject)
}else{
originreplace.call(this,location,()=>{},()=>{})
}
}
/route/routers.js
先引入插件,再对插件进行路由的配置
// 引入路由插件
import Login from '../pages/Login'
export default [
{
{
// 登录Login组件
name:'login',
path:'/login',
component:Login
},
// 重定向,定向到home页面首页
{
path:'*',
redirect:'/home'
}
]
小问题总结
- 路由组件存放在 pages || views 文件夹
- 非路由组件存放在 components 文件夹
- 非路由组件通过标签的形式使用 ; 路由组件通过跳转的方式使用
- 在 main.js 文件中注册完成后 , 所有的路由和路由组件身上都有 route 和 router 属性
- $router:一般进行编程式路由导航进行路由跳转
- $route:一般获取路由信息(name , path , params , query , meta)
vuex 的使用配置
vuex概念
在Vue中实现集中式状态(数据)管理的一个Vue插件,对vue应用中多个组件的共享状态进行集中式的管理(读/写),也是一种组件间通信的方式,且适用于任意组件间通信。
搭建vuex环境
创建store文件
在 src 文件下创建 store 文件 , 在 store 文件中添加 index.js 来配置文件
import Vue from 'vue'
import Vuex from 'vuex'
// 引入home仓库
import home from './home'
// 使用插件
Vue.use(Vuex)
// 对外暴露store类的一个实例
// 大仓库
export default new Vuex.Store({
modules:{
//注册小仓库
home,
}
})
采取模块化编程,将各组件的数据仓库单独设置, 以 home 组件为例 , 在 store 文件下创建 store/home/index.js 文件 , 来配置对 home 的数据管理。
在总仓库文件 /store/index.js 中进行统一的引入管理
//创建vuex的四件套 state , mutations , actions , getter
const state = {<存放数据的地方>}
const mutations = {<将数据存到state中>}
const actions = {<对派发的活动进行响应,即获取数据!!不能在此处操作state>}
const getter = {<简化数据>}
// 向外暴露
export defalut {
state,
mutation,
actions,
getter
}
在 main.js 中引入 store 配置项
// 引入仓库
import store from './store'
export defalut new
// 注册仓库
store
}).$mount('#app')
vuex的使用
首先在组件中通过 dispatch 派发 actions
this.$store.dispatch('getData')
紧接着在 actions 中响应
const actions = {
//设置获取数据方法 getData 使用async/await
//async 实现一个异步执行函数
async getData(context){
//await 等待异步方法执行完成,直接返回执行结果
const result = await reqGetData()
if(result.code === 200){
context.commit('GETDATA',result.data)
return 'ok'
}else{
return Promise.reject(new Error('请求失败'))
}
}
}
进入 mutations 中操作 state 进行值的存取
//设置state
const state = {
//初始化data
data = {}
}
//设置mutations
const mutations = {
GETDATA(state,data){
//将数据存在state的data中
state.data = data
}
}
组件中如果需要使用 vuex 中的数据
可以使用 getter 方法简化数据使用 mapGetters 也可以直接在组件中使用 mapState 方法
// 引用vuex 中的mapGetter,mapState方法分别对应两种方法
import {mapGetter,mapState} from 'vuex'
computed:{
//mapGetters方法
...mapGetters(['data'];
//mapState 方法
...mapState({
data:function(state){
return state.home.data
}
//上面data简写形式
data:state => state.home.data
})
}
axios的二次封装
创建 /src/api/requests.js 文件, 对 axios 进行二次封装。 实际上是创建一个 axios 实例。设置请求拦截器和响应拦截器。
利用 axios 的 create 方法创建一个 axios 实例 , 配置基础的 URL 和请求超时的时间
在请求拦截器中的 config 配置对象 , 有一个非常重要的属性 header 请求头
在请求拦截器中,每次请求时调用进度条函数 nprogress.start() ,使每次请求有一个进度条动画效果。其次判断store仓库中是否有游客身份 nanoid_token 或登录后返回的 token 。如果有,则把 token 使用 config 配置项中的 headers 加入其中 , 发送给服务器 , 以证明用户当前身份 ,返回相应的数据。
在响应拦截器中,若返回成功的回调,则将数据返回,进度条动画结束 nprogress.done() 。若返回失败的回调,弹出错误信息,并终止 promise 链
// 对axios进行二次封装
// 先引用axios
import axios from "axios"
// 引入进度条
import nprogress from 'nprogress'
// start 开始 done 结束
// 引入进度条样式
import "nprogress/nprogress.css"
// 引入store仓库
import store from '@/store'
// 利用axios方法create创建一个axios实例
const requests = axios.create({
// 配置对象
// 基础路径,发送请求的时候,路径当中会出现api
baseURL:'/api',
// 请求超时时间
timeout:5000,
});
// 请求拦截器
requests.interceptors.request.use((config)=>{
// 判断数据是否由本属的nanoid
if(store.state.detail.nanoid_token){
// 有的话就将nanoid_token放进config.headers.userTempId
config.headers.userTempId = store.state.detail.nanoid_token
}
// 把token加入请求头信息
if(store.state.login.token){
config.headers.token = store.state.login.token
}
// config配置对象 对象里面有一个属性很重要,header请求头
nprogress.start();
return config;
});
// 响应请求头
requests.interceptors.response.use(
(response)=>{
nprogress.done();
return response.data
},(error)=>{
return Promise.reject(new Error('why error'))
})
// 对外暴露
export default requests
请求接口统一封装
创建 /src/api/index.js 文件, 来封装所有的请求。
将每个请求封装为一个函数, 并暴露出去, 组件只需要调用相应的函数就可以完成数据的请求。
// 引入
import requests from "./request"
//三级联动接口
export const reqCategoryList = ()=>{
return requests({
url:'/product/getBaseCategoryList',
method:'GET'
})
}
mock 模拟数据
当后端接口未设置好之前, 前端项目要进行测试, 就可以通过 mock 来模拟数据模拟接口 。
模拟数据接口
对需要的数据进行模拟, 创建 /src/mock/xxx.json 文件, 数据格式为 json 数据格式
//banner.json数据模拟
[ { "id":"1", "imgUrl":"/images/banner1.jpg" }, { "id":"2", "imgUrl":"/images/banner2.jpg" }, { "id":"3", "imgUrl":"/images/banner3.jpg" }, { "id":"4", "imgUrl":"/images/banner4.jpg" }]
对mock 使用的axios进行二次封装
创建 /src/api/mockRequest.js 文件, 对接口进行配置, 对 axios 再次进行二次封装 。
设置基础的 URL 和 timeout
// 对axios进行二次封装
// 先引用axios
import axios from "axios"
// 引入进度条
import nprogress from 'nprogress'
// start 开始 done 结束
// 引入进度条样式
import "nprogress/nprogress.css"
// 利用axios方法create创建一个axios实例
const requests = axios.create({
// 配置对象
// 基础路径,发送请求的时候,路径当中会出现api
baseURL:'/mock',
// 请求超时时间
timeout:5000,
});
// 请求拦截器
requests.interceptors.request.use((config)=>{
// config配置对象 对象里面有一个属性很重要,header请求头
nprogress.start();
return config;
});
// 响应请求头
requests.interceptors.response.use(
(response)=>{
nprogress.done();
return response.data
},(error)=>{
return Promise.reject(new Error('why error'))
})
// 对外暴露
export default requests
mock模拟服务器
创建 /src/mock/mockServe.js 来模拟服务器接口。
// 先引入mockjs模块
import Mock from 'mockjs'
import banner from './banner.json'
import floor from './floor.json'
// mock数据:第一个参数请求地址 第二个参数:请求数据
Mock.mock("/mock/banner",{code:200,data:banner}) //mock模拟轮播图
Mock.mock("/mock/floor",{code:200,data:floor}) //
Vue中通过代理解决跨域问题
为什么会发生跨域?
由于URL是由 协议、域名、端口号 三部分组成,那么只要请求接口的任一部分存在差异就会导致跨域问题的出现。
如何解决跨域? (未完......)
proxy 代理:proxy 实现的原理是基于“ 同源策略是浏览器需要遵循的标准,而如果是服务器向服务器请求就无需遵循同源策略。 ”这一准则。
怎么实现呢?
通过 本地 向 proxy 代理服务器发送请求,proxy 接收本地请求,转换为目标地址相同 IP 和端口向目标地址发送请求。
在根目录下的 vue.config.js 中进行配置, proxy 为通过代理解决跨域问题。
在封装 axios 的时候已经设置了 baseURL 为 /api ,所以所有的请求都会携带 /api,这里我们就将 /api 进行了转换。如果你的项目没有封装 axios ,或者没有配置 baseURL ,建议进行配置。要保证 baseURL 和这里的代理映射相同,此处都为 /api 。
proxy 中的 target 表示的是代理到的目标地址
proxy 中的 pathRewrite 根据在 requests 中是否配置 baseURL 决定它的存在 ,如果配置了就不要, 反之。(暂时不确定)
proxy 中的 changeOrigin 它表示是否更新代理后请求的 headers 中 host 地址;
const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
transpileDependencies: true,
lintOnSave: false,
devServer:{
proxy: {
'/api': {
//代理的对象
target: 'http://gmall-h5-api.atguigu.cn',
// pathRewrite: { '^/api': '' },
},
},
}
})
商品三级联动模块开发
三级联动是home路由中一个重要的模块,当我们点击商品列表时,需要把我们点击的商品信息作为参数,携带给服务器,服务器根据相应的参数返回对应的数据。
三级联动的难点
1.如何得知点击的标签为 a 标签
在路由跳转方面,使用事件委托的方法。 不论我们点击的是哪个标签,只要是 div 中的标签, 点击都会触发事件委托的 goSearch 方法。 现在需求是确认点击的是 a 标签, 那么该如何确定呢?
2.如何得知点击的一、二、三级标签 id
在点击的同时需要向服务器返回点击的一、二、三级标签的 id。 以便返回数据。
解决方法
我们引入一个方法 ,自定义属性 ,为 a 标签设置自定义属性 , 作为 a 标签的判断标识。 存在该属性则为 a 标签。 否则就不是。 紧接着使用 event.target.dataset 方法。 对自定义标签进行解构, 获取到自定义属性的值。 在动态数据展示的时候, 为每个标签都添加。 那么当用户点击任意标签都可以获取该标签的 name 和 id。
首先,设置自定义属性, 格式为 data-xxxxxx。 需要注意的是: 使用上述的 event.target.dataset 方法, 要确保每个属性格式统一, 都为data-xxxxxx 。 dataset 方法可以获取全部的以 data 开头的自定义属性。
其次, 我们要获取点击的标签,向上述的 goSearch 方法中传入 event 对象 。
goSearch(event){
console.log(event.target)
//得到:<a href="javascript:;" data-categoryname="手机" data-category3id="61">手机</a>
//对数据进行解构
const {name, cate1id,cate2id,cate3id} = event.target.dataset
//判断是否为a标签;如果由name属性,就说明点击的是a标签
if(name){
//进行参数整合等一系列操作,准备向服务器传递参数
......some code......
}
}
封装轮播图组件(swiper)
我们使用轮播图组件, 可以简化代码, 提高代码的复用。
swiper 插件的安装
轮播图的插件众多,在此处使用的为 swiper 插件。
安装轮播图插件版本为 swiper 6.8.4
//安装轮播图插件
npm install @6/7/8
轮播图使用
全局引入
//swiper使用步骤:
//第一步:引入依赖包、样式
import Swiper from "swiper/swiper-bundle";
import 'swiper/swiper-bundle.min.css'
创建swiper 结构
此处引用为 swiper 官网代码。
<div class="swiper">
<div class="swiper-wrapper">
<div class="swiper-slide">Slide 1</div>
<div class="swiper-slide">Slide 2</div>
<div class="swiper-slide">Slide 3</div>
</div>
<!-- 如果需要分页器 -->
<div class="swiper-pagination"></div>
<!-- 如果需要导航按钮 -->
<div class="swiper-button-prev"></div>
<div class="swiper-button-next"></div>
<!-- 如果需要滚动条 -->
<div class="swiper-scrollbar"></div>
</div>
导航等组件可以放在Swiper容器之外
创建swiper 实例
this.$nextTick(() => {
// 创建swiper实例
var mySwiper = new Swiper(".swiper-container", {
// direction: "vertical", // 垂直切换选项
loop: true, // 循环模式选项
autoplay: {
autoplay: true,
delay: 2000,
},
// 如果需要分页器
pagination: {
el: ".swiper-pagination",
// 点击小球切换图片
clickable: true,
},
// 如果需要前进后退按钮
navigation: {
nextEl: ".swiper-button-next",
prevEl: ".swiper-button-prev",
},
// 如果需要滚动条
scrollbar: {
el: ".swiper-scrollbar",
},
});
});
轮播图的注意点
对于轮播图使用一个重要的知识,在初始化 swiper实例 之前,页面中必须要有轮播图的结构,否则是无法实现轮播效果的。
对于 Vue 一个组件而言,mounted(组件挂载完毕),结构就生成了。 所以第一反应是应该直接在 mounted 里创建 swiper实例。
但由于轮播图的数据是动态向服务器中获取的,请求是异步的,然后使用 v-for 遍历数据生成 Dom 节点,那么就会存在可能因为网络原因,或者数据返回的时间延迟等原因,导致先创建了 swiper实例,而 Swiper 需要获取到轮播图的节点 DOM 的时候,页面 Dom 并未完全渲染。以至于轮播图的效果失败。那我们如何去解决这个问题呢?
尝试一:
在 mounted 之前的 created 里面请求获取数据。是否可以实现呢?
答: 不可以哦! 在理论上好像是可行的,但是在实际操作中,它们的间隔时间极短,不能解决上述的问题。
尝试二:
我们可以使用 watch 监听 bannerList 轮播图列表属性,因为 bannerList 初始值为空,当它有数据时,我们就可以创建 swiper 对象,这样可以解决问题吗?
答:也不可以哦! 这样还是无法实现轮播图,原因是: 轮播图的 html 结构中有 v-for 的循环,我们是通过 v-for 遍历 bannerList 中的图片数据,然后展示。我们的 watch 只能保证在 bannerList 变化时创建 swiper 对象,但是并不能保证此时 v-for 已经执行完了。假如 watch 先监听到 bannerList 数据变化,执行回调函数创建了 swiper 对象,之后 v-for 才执行,这样也是无法渲染轮播图图片(因为 swiper 对象生效的前提是 html 即 Dom 结构已经完全渲染好了)
解决方法:
最终的解决方法就是 watch + this.$nextTick
nextTick 的官方解释:
在下次DOM更新, 循环结束之后,执行延迟回调。在 修改数据之后 立即使用这个方法,获取更新后的DOM。
this.$nextTick的使用场景:当改变数据后,要基于更新后的新DOM进行某些操作时,要在nextTick所指定的回调函数中执行。
对上述引入本例解决的理解就是:当节点的渲染、v-for执行完毕完成数据的展示。 接下来再创建 swiper 实例,就可以保证 swiper创建实例的前提。 (相关代码展示在上面)
项目中使用Swiper出现的问题总结:
问题一:点击左右切换轮播图按钮功能失效?
在mounted中挂载生成dom元素后才new swiper,由于是封装组件,ListContainer(父组件)组件通过props数据传递数据给轮播图组件,但先触发的是轮播图组件的mounted,此时会渲染dom元素,却还没有拿到数据。
解决办法:所以利用 watch监听 到父组件传递来数据才进行初始化。
问题二:虽然有数据但左右切换功能还是失效?
由于vue响应式更新是异步的,此时监视到数据变化,dep.notify就会通知watcher去更新视图图,watch就被塞到异步队列中,数据同步更新,但轮播图组件dom元素还未重新遍历更新。
解决办法: 利用 this.nextTick 等数据渲染成DOM元素加载完成后才触发(只会触发一次)。
问题三:轮播图移入停止移出播放功能出问题?
由于多处调用组件,复用的是同一个swiper实例,都通过类选择器找对用dom元素。
解决办法: 利用 下标、绑定ref 来进行区分不同的实例。
问题四:Floor组件中调用的轮播图自动轮播功能失效?
由于先对Floor组件进行遍历,然后又在每个Floor组件中调调轮播图组件,数据初始化为空数组,此时Floor组件根本还未遍历渲染。而等Floor组件请求到数据后把数据通过props传给轮播图组件,此时轮播图组件监视到的数据是无变化(他会监听这个数据抑制剂就存在),所以new swiper不会触发。
解决办法: 使用 immediate: true 先初始化一次就可监视到数据变化。
利用路由信息变化实现动态搜索
每次进行新的搜索时,路由传递的 query 和 params 参数中的部分内容肯定会改变。我们可以通过监听路由信息的变化来动态发起搜索请求。
如下面代码所示,$route 是组件的属性,所以 watch 是可以监听的(watch可以监听组件 data 中所有的属性)
注意: 组件中 data 的属性包括:自己定义的、系统自带的(如 $route)、父组件向子组件传递的等等。
watch:{
// 监听路由
$route(newValue,oldValue){
// 重置参数,确保数据之间不会影响
this.searchParams.category1id = ''
this.searchParams.category2id = ''
this.searchParams.category3id = ''
this.searchParams.keyword = ''
//重新合并需要向后台发送的数据参数
Object.assign(this.searchParams,this.$route.query,this.$route.params)
// 再次发送请求,解决mounted只能发送一次请求的问题
this.getData()
}
},
面包屑相关操作
面包屑的相关操作就是,当我们选择一些的商品属性或搜索框进行进一步搜索时,会重新发送请求展示对应的数据,而全部结果那里会显示我们所选的属性,路由信息也会发送改变。
实际上就是对 query 参数和 params 参数做出一些更新或删除操作,做后参数合并 一并发送给服务器。
重点一:子组件 SearchSelector 把用户每次点击的品牌ID和名字,以及点击对应的品牌属性,属性值,属性id。 通过自定义事件,把数据传给父组件Search。子传父最好可以使用自定义事件的方式。
//点击品牌,向父组件传递点击的参数信息
<ul class="logo-list">
<li v-for="(trademark) in trademarkList" :key="trademark.tmId" @click="trademarkHardle(trademark)">{{ trademark.tmName }}</li>
</ul>
// 点击售卖属性 ,向父组件传递售卖属性
<ul class="type-list">
<li v-for="(attrvalue,index) in attr.attrValueList" :key="index" @click="attrHardle(attr,attrvalue)">
<a>{{ attrvalue }}</a>
</li>
</ul>
//绑定上面事件
methods:{
// 点击事件,触发自定义事件向父组件发送数据、
//传递品牌信息
trademarkHardle(trademark){
this.$emit('trademarkInfo',trademark)
},
//传递售卖属性
attrHardle(attr,attrvalue){
console.log(attr,attrvalue);
this.$emit('attrInfo',attr,attrvalue)
}
}
父组件进行接受子组件传递的信息
// 自定义事件:子向父传递参数
trademarkInfo(trademark){
console.log('我是父组件:',trademark);
// 整理参数
this.searchParams.trademark = `${trademark.tmId}:${trademark.tmName}`
// 再次发送请求
this.getData()
},
// 自定义事件返回售卖属性数据
attrInfo(attr,attrvalue){
// 接收来自子组件的数据
// 注意向服务器传输的数据格式为数组,先进行参数的处理
let props = `${attr.attrId}:${attrvalue}:${attr.attrName}`
// 对数据进行判断防止数组中的数据重复
if(this.searchParams.props.indexOf(props) == -1){
// 将数据整合在searchParams的发往服务器的参数中
this.searchParams.props.push(props)
}
},
重点二:本次项目的面包屑操作主要就是两个删除逻辑。
分为:
当删除分类属性 (query):删除面包屑同时修改路由信息。
当删除搜索关键字 (params) :删除面包屑、修改路由信息、同时删除输入框内的关键字。
当 query 删除的时候:
此部分在面包屑中是通过 categoryName 展示的,所以删除时应将该属性值制空或undefined。 可以通过路由再次跳转修改路由信息和 url 链接。
removeCategory(){
// 将分类标签名置空 实现面包屑的清空
this.searchParams.categoryName = undefined;
// 将发送的参数清空
this.searchParams.category1id = undefined;
this.searchParams.category2id = undefined;
this.searchParams.category3id = undefined;
//面包屑清除之后,实现页面的全部展现,路由跳转向自己跳转:编程式路由导航
if(this.$route.params){
// 如果存在params参数,则需将参数保留下来
this.$router.push({name:'search',params:this.$route.params})
}
},
当 params 删除时:
相较于 query 删除的唯一不同点是会多一步操作:删除输入框内的关键字(因为params参数是从输入框内获取的)
输入框是在 Header 组件中的 header 和 search 组件是兄弟组件,要实现该操作就要通过兄弟组件之间进行通信完成。
实现兄弟组件间通讯,使用全局事件总线实现 header 和 search 组件间通讯。
全实现局事件总线
在 /mian.js 中进行注册全局事件总线 $bus 。
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')
search 组件使用 $bus 通信,第一个参数可以理解为(通信的暗号),还可以有第二个参数(用于传递数据),我们这里只是用于通知 header 组件进行相应操作,所以没有设置第二个参数。 $emit 触发 clear 这个事件
// 移除关键词面包屑
removeKeyword(){
// 将searchParams参数中的keyword设置为underfined
this.searchParams.keyword = undefined;
// 初始化排序
this.searchParams.order="1:desc"
// 全局事件总线将keyword的数据清除
this.$bus.$emit('clear')
// 跳转路由 保留query参数
if(this.$route.query){
this.$router.push({name:'search',query:this.$route.query})
}
},
header 组件接受 $bus 通信。 注意:mounted 组件挂载时就监听 clear 事件。
当 search 触发 clear 这个事件的时候, 就执行 clear 事件回调。将keyword 置空。
mounted(){
// 全局事件总线,将keyword置空
this.$bus.$on("clear",()=>{
this.keyword = ''
})
}
分页器的实现与封装
如何封装一个分页器组件呢?
分页器封装业务逻辑分析:
封装分页器组件的时候:需要知道哪些条件?
- 分页器组件需要知道要展示多少条数据 ----
total(100条数据) - 每一个需要展示几条数据------
pageSize(每一页3条数据) - 通过1和2,计算出总共有多少页----
totalSize(总共页数) - 需要知道当前在第几页-------
pageNo(当前在第几页) - 需要知道中间展示的连续页码数,因为对称好看,一般取奇数 ------
continues(展示连续几页页码数)
重点: 考虑如何正确显示中间的页码?
举例进行考虑。 有100条数据。 每页显示3条。那么就需要34页来展示。 中间连续的页数为5。 当 pageNo 为5时,展示的页码数为 :3 4 5 6 7 定义 两个变量 start end 分别表示 开始页码和结束页码 此种情况 start = 3 end = 7 判断情况 当总页码小于展示页码的时候 start = 1 ; end = totalPage; 正常情况展示 start = 当前页码数 - 取整(连续页码数/2) end = 当前页码数 + 取整(连续页码数/2)
正常情况
当前页为 4时 展示数据为 2 3 4 5 6
当前页为 3时 展示数据为 1 2 3 4 5
当前页为 2时 展示数据为 0 1 2 3 4 (很明显已经错误 根本不存在0页)
当前页为 1时 展示数据为 -1 0 1 2 3
当 start < 1 时 此时 start = 1 end = continues
当为最后的时候
即 end > totalPage 时 start = totalPage - continues ; end = totalPage
上述代码展示如下:
// 计算连续页码的起始数字和结束数字
continuesNumStartEnd(){
const {total,pageSize,pageNo,continues} = this;
let start = 0,end = 0
// 不正常情况
if(total < continues){
start = 1
end = this.totalPage
}else{
// 正常情况
// 计算起始页码
start = pageNo - parseInt(continues/2)
// 计算结束页码
end = pageNo + parseInt(continues/2)
// 不正常情况,【start为负数,为零】
if(start < 1){
start = 1
end = continues
}
// 不正常情况,【end溢出,即end的值大于最大的页数】
if(end > this.totalPage){
end = this.totalPage
start = end - continues + 1
}
}
// console.log(start+','+end);
return {start,end}
}
特殊情况
当1...2345 省略号存在的情况 是比较容易解决的
可以进行判断 当 start > 2 的时候展示 ...
尾部的省略号
当 end < totalPage - 1 时候展示
还有左边的 1 和右边的 totalPage
左边的1 在start > 1 的时候 显示 右边的 totalPPage 在 end < totalPage 的时候展示
注意点: v-for 遍历数组 索引从0开始 v-for 遍历数字 索引从1开始
//分页器前半部分
<button v-if="continuesNumStartEnd.start > 1" @click="$emit('getPageno',1)" :class="{active:pageNo ==1}">1</button>
<button v-if="continuesNumStartEnd.start > 2" >···</button>
//分页器后半部分
<button v-if="continuesNumStartEnd.end < totalPage - 1">···</button>
<button v-if="continuesNumStartEnd.end < totalPage" :class="{active:pageNo == totalPage}" @click="$emit('getPageno',totalPage)">{{totalPage}}</button>
商品排序
当点击价格或者综合的时候,根据上升或者下降的方式进行排序。前端做的效果是比较简单的,我们只需根据不同的状态发送对应的参数给服务器,然后服务器给我们返回排序后的产品进行展示即可。
根据向服务器返回数据的格式为 order: "默认值:排序值"
desc 为综合排序 asc 为价格排序
1 和 0 为默认值的取值
通过 split 方法分别获取 默认值 和 排序值 。
当点击的时候为默认值,说明是第一次点击。那么就改变排序的属性 desc 改为 asc ;反之亦然。
重新设置 newOrder 按格式存储 默认值 和 排序值。 整合参数,重新发送请求。获取后端计算好的排序。 进行展示。
<!-- 综合排序 -->
<li :class="{active:isOne}" @click="isOrder('1')">
<a>综合<span v-show="isOne" :class="{'iconfont':isOne,'icon-download':isDown,'icon-upload':isUp}"></span></a>
</li>
<!-- 价格排序 -->
<li :class="{active:isTwo}" @click="isOrder('2')">
<a>价格<span v-show="isTwo" :class="{'iconfont':isTwo,'icon-download':isDown,'icon-upload':isUp}"></span></a>
</li>
// 计算属性进行计算 判断格式高亮与否 同时设置上下箭头展现
// 通过判断来是否存在属性来 返回展示与否
isOne(){
return this.searchParams.order.indexOf(1) != -1;
},
isTwo(){
return this.searchParams.order.indexOf(2) != -1 ;
},
isUp(){
return this.searchParams.order.indexOf('asc') != -1;
},
isDown(){
return this.searchParams.order.indexOf('desc') != -1;
},
对降序升序,价格和综合排序进行判断。
// 判断排序函数 综合排序和价格排序
isOrder(flag){
console.log(flag);
// 取出默认的flag值
let orderFlag = this.searchParams.order.split(':')[0]
// 取出默认的排序值
let orderState = this.searchParams.order.split(':')[1]
console.log(orderState);
//创建一个新的order属性参数,存储新的变换值
let newOrder = ''
// 如果点击的为默认的排序
if(flag === orderFlag){
// 将现在的flag值和变换后的排序值存储致新的属性参数 如果原来的值为desc那么就将现在的值改为asc 保证点击后和原来的属性相反
newOrder = `${flag}:${orderState == 'desc' ? 'asc':'desc'}`
console.log(newOrder);
}else{
// 如果变换类型(综合或价格)点击时将值初始为desc 再次点击就会变换
newOrder = `${flag}:${orderState = 'desc'}`
console.log(newOrder);
}
this.searchParams.order = newOrder
this.getData()
放大镜效果
实现商品页的 放大镜 效果。鼠放在图片上显示放大后的商品图片。
这里不能直接操作 DOM 获取节点,而是使用 ref 标记。除过获取 DOM 节点,还可以通过改变设置 绝对定位 的遮罩层,到设置相对定位的父盒子的 left 和 top,实现遮罩层的移动效果。这里需要考虑一下边界情况,而放大后的盒子可以根据比例也可以直接获取到图片在大盒子的移动距离值。
比例公式为:
遮罩层移动距离 / 遮罩层移动的最大距离 === 大图片移动的距离/大图片移动的最大距离
( 400 - mask.offsetWidth(200)) === (大图片宽度(800)-大盒子宽度(400))
// 处理函数传入事件
handle(event){
// 通过 ref 标记获取
let mask = this.$refs.mask
let big = this.$refs.big
// 计算出左右的距离
let left = event.offsetX - mask.offsetWidth/2
let top = event.offsetY - mask.offsetHeight/2
// 判断 left 的不合理之处 进行处理
if(left <= 0 ){
left = 0
}else if(left >= mask.offsetWidth){
left = mask.offsetWidth
}
// top处理如上
if(top <= 0){
top = 0
}else if(top >= mask.offsetHeight){
top = mask.offsetHeight
}
// 设置mask 和 big 的位置
mask.style.left = left +'px'
mask.style.top = top +'px'
big.style.left = -2*left + 'px'
big.style.top = -2*top + 'px'
}
}
改变放大镜展示的图片
在放大镜下面实现一个小轮播图,同时点击切换图片。展示图片。
在轮播图组件中设置一个 currendIndex ,用来记录所点击图片的下标,并用 currendIndex 实现点击图片高亮设置。 当符合图片的下标满足 currentIndex === index 时,该图片就会被标记为选中。使用事件总线,将用户所点击的图片索引值给放大镜组件。
放大镜组件将轮播图组件传入的 index 与服务器返回的值 imageList[this.index] 进行关联。 在放大镜组件中动态展示 currentIndex === index 的图片。
代码实现如下:
methods:{ changeCurrentIndex(index){ //修改响应式数据 this.currentIndex = index this.emit('getIndex',index) } }
// 小轮播图组件代码
// 点击实现传递 index 对高亮样式进行动态选择
<div class="swiper-slide" v-for="(slide,index) in skuImageList" :key="slide.id" >
<img :src="slide.imgUrl" :class="{active:currentIndex == index}" @click="changeCurrentIndex(index)">
</div>
// 全局事件传递 index
methods:{
changeCurrentIndex(index){
//修改响应式数据
this.currentIndex = index
this.$bus.$emit('getIndex',index)
}
}
// 放大镜组件部分代码
computed: {
imgObj() {
return this.skuImageList[this.index] || {}
}
},
methods:{
//全局事件总线的处理函数
resolveIndex(index){
this.index = index
},
}
购物车组件开发
封装 api 对购物车的数据进行请求,即封装一个请求函数 reqgetCartList 调用请求函数请求数据。
export const reqShopCartList = ()=>{
return requests({
// 请求数据的 URL
url:'/cart/cartList',
// 请求方式 get
method:'get'
})
}
如果想要获取详细信息,还需要一个用户的 nanoid_token ,用来验证用户身份。但是该请求接口中的设置不能传递参数,所以只能把 nanoid_token 加在请求头 header 中。 在前面二次封装 axios 中, 在请求拦截器的 config 中有 header 参数,将 nanoid_token 添加进去。
// 请求拦截器
requests.interceptors.request.use((config)=>{
// 判断数据是否由本属的nanoid
if(store.state.detail.nanoid_token){
// 有的话就将nanoid_token放进config.headers.userTempId
config.headers.userTempId = store.state.detail.nanoid_token
}
// 把token加入请求头信息
if(store.state.login.token){
config.headers.token = store.state.login.token
}
// config配置对象 对象里面有一个属性很重要,header请求头
nprogress.start();
return config;
});
创建 /src/utils/nanoid_token.js 文件,对外暴露为函数(记得导入 nanoid => npm install nanoid )。
生成临时游客的 nanoid(随机字符串),每台设备上的用户的 nanoid 不能发生变化,还要进行持久存储。 持久存储使用 localStorage 对 nanoid 进行存储。
// 引入nanoid
import { nanoid } from 'nanoid'
// 设置对外暴露的函数getNanoid()
export const getNanoid = ()=>{
// 现从localstorage中读取.看是否存在NANIODTOKEN的值
let nanoid_token = localStorage.getItem('NANOIDTOKEN');
// 如果值不存在
if(!nanoid_token){
// 获取该值
nanoid_token = nanoid()
// 将该值存入localstorage
localStorage.setItem('NANOIDTOKEN',nanoid_token)
}
// 返回生成的nanoid_token
return nanoid_token
}
在 /src/store/detail/index.js 文件的 state 中定义 nanoid ,调用暴露的 getNanoid() ,获取存储的 nanoid 或者生成新的 nanoid 并存储。
import {getNanoid} from '@/utils/nanoid_token'
// vuex 商品详情的数据存储
const state = {
detaillist:{},
// 游客临时身份
nanoid_token: getNanoid()
}
购物车商品数量修改
购物车商品数量修改。首先考虑商品数量的增加与减少, 其次是商品的选中部分操作,最后就是商品的删除。
商品的增加与减少
点击加号数目 +1 点击减号数目 -1 。 点击事件第一个需要传递的就是增加还是减少类型 type 。 其次就是变化值 disNum , 还有输入框的值 change 直接输入商品数目,需要对值的合法性进行判断。
当值为非法字符或者负数时进行判断: isNaN(disNum) || disNum < 1 如果满足,则 disNum = 0。
当值为非整数的时候进行取整: disNum = parseInt(disNum) - cart.skuNum。
//点击处理数量
// 传递参数,type传递的是加减类型
// disNum传递的是目前的值 在函数里面对disNum的值进行选择判断 1 负1 0 或者为输入值减原来的值
// cart传递原来的小计数值
async handle(type, disNum, cart) {
// switch case 进行判断
switch (type) {
// 如果为add 那么disNum则为 加1
case "add":
disNum = 1;
break;
// 如果为minus 那么disNum则为 负1
case 'minus':
// 先对原先的值进行判断,小于1就不进行减一操作,那么disNum的值为0
disNum = cart.skuNum > 1 ? -1 : 0;
break;
// 如果为change
case 'change':
// 判断非法和小于0
if (isNaN(disNum) || disNum < 1) {
disNum = 0;
} else {
// 非整数可以进行取整操作
disNum = parseInt(disNum) - cart.skuNum
}
break;
}
//派发actions
try {
await this.$store.dispatch('getUpdateShopCar', { skuId: cart.skuId, skuNum: disNum })
this.getData()
} catch (error) {
alert(error.message)
}
},
当选中所有商品时,全选自动选中
对购物车所有商品进行勾选判断,当全部为选中状态时,全选按钮自动选中。
对上述需求使用 every : every遍历某个数组,判断数组中的元素是否满足表达式,全部为满足返回 true ,不满足返回 false 。
// 处理全选事件
isAllCheck() {
//先判断是否为空
if(this.cartList.cartInfoList){
// every 进行判断是否满足
return this.cartList.cartInfoList.every(item => {
// 指定返回 选中状态为 1
return item.isChecked == 1
})
}
},
一键全选或全不选
点击全选按钮,选中时为全选状态即所有的 isChecked == 1 ; 再次点击全选的时候,全不选即所有的 isChecked == 0。
代码实现,如下所示:
// 修改全选中状态
async changeAllChecked(event){
try {
// 通过此处传入 event进行判断商品的选中状态
let Checked = event.target.checked ? "1" : "0";
// 派发请求 选中或者不选中 向服务端传递参数
await this.$store.dispatch('getChangeAllChecked',Checked)
// 刷新页面更新数据的选中状态
this.getData()
} catch (error) {
alert(error.message)
}
},
在上述代码中,派发了 getChangeAllChecked() 事件。在处理派发事件的时候使用到了 Promise.all() 。因为在修改全部商品选中状态的时候, 后面处理依然使用的是单个商品的状态修改, 只不过是遍历派发,使每个商品的状态都修改了。 服务端返回的处理结果为一个 promise 。 使用 Promise.all() 当每个单个的选框都为选中状态时。 Promise.all() 会返回成功, 否则为失败。
【Promise.all():参数需要的是一个数组【数组里面需要promise】 Promise.all()执行一次,返回的是一个Promise对象,Promise对象状态:成功、失败取决于什么? 成功、还是失败取决于数组里面的promise状态:四个都成功、返回成功Promise、只要有一个失败、返回Promise失败状态!!!】
// 修改商品全部选中状态
getChangeAllChecked({getters,dispatch},Checked){
let promiseAll = []
if(getters.cartList.cartInfoList){
getters.cartList.cartInfoList.forEach(item =>{
let primise = dispatch('getChangeChecked',{skuId:item.skuId,isChecked:Checked});
promiseAll.push(primise)
})
}
return Promise.all(promiseAll)
}
删除单个商品
点击各自商品后的删除, 触发点击事件 shopCartDelete() 传递商品的 skuId ,派发请求, 对购物车的数据进行删除。 然后重新发送请求, 获得删除后的购物车数据。
// 删除购物车数据
async shopCartDelete(skuId) {
// 派发请求
try {
await this.$store.dispatch('getDeleteCart', skuId)
this.getData()
} catch (error) {
alert(error.message)
}
},
删除选中的商品
和选中处理同理, 删除选中,进行遍历删除。
getAllChecked({getters,dispatch}){
let primiseAll = []
getters.cartList.cartInfoList.forEach(item =>{
let primise = item.isChecked == 1 ? dispatch('getDeleteCart',item.skuId) : ''
console.log(1);
primiseAll.push(primise)
})
// 返回 删除成功或失败的状态。
return Promise.all(primiseAll)
},
节流和防抖
节流:当我们修改单个数量的时候,例如减少商品数量,可能由于我们点击频率过快,当上一次服务器数据还未返回时,又进行了大量相同操作,导致最后页面数量出现为负数,这个时候我们需要进行节流操作,在一定时间间隔内,用户的点击操作只能生效一次。 防抖:当用户直接输入数字改变数量的时候,每输入一个数字时就会发送一次请求,比如用户想输入1000,就得发四次请求,但其实前三次是不必要的,于是我们可以使用防抖,在一定时间间隔内,用户没有了新的输入,再统一重新发送请求。减少了我们请求次数。 下面是项目中我们直接使用了lodash插件,调用的节流和防抖函数。但其实我们自己是必须学会手写的。
//按需引入lodash节流函数
import throttle from "lodash/throttle";
//按需引入lodash防抖函数
import debounce from "lodash/debounce";```
//修改商品数据-减的操作
minusSkuNum: throttle(async function (cart) {
if (cart.skuNum > 1) {
//整理参数:至少加入购物车的数量最低1个
let params = { skuId: cart.skuId, skuNum: -1 };
//修改商品的数据
try {
//修改商品的个数、成功以后再次获取购物车的数据
await this.$store.dispatch('AddShopCar', params);
this.getShopCartdata();
} catch (error) { }
}
}, 2000),
changeSkuNum: debounce(async function (cart, e) {
//整理参数
let params = { skuId: cart.skuId };
//计算出SkuNum携带的数据
let userResultValue = e.target.value * 1;
//用户输入完毕,最终结果【非法条件】
if (isNaN(userResultValue) || userResultValue < 1) {
params.skuNum = 0;
} else {
//正常情况
params.skuNum = parseInt(userResultValue) - cart.skuNum;
}
//发请求:修改商品的个数
try {
//修改商品的个数、成功以后再次获取购物车的数据
await this.$store.dispatch('AddShopCar', params);
this.getShopCartdata();
} catch (error) { }
}, 500),
简单手写一个节流防抖函数
//防抖
function debounced(fn, time) {
let timer = null;
return function () {
clearTimeout(timer);
timer = setTimeout(() => {
fn();
}, time);
};
}
//节流
function throttle(fn, time) {
let flag = false;
return function () {
if (flag) {
return;
}
flag = true;
setTimeout(() => {
fn();
flag = false;
}, time);
};
}
其余注意点
敬请期待......
登录注册流程
注册登录也是很重要的一部分。 注册的表单验证,获取验证码等操作。
完成注册
这里的重点是发送请求获取验证码以及完成表单的校验功能,当我们输入的信息不符合规则的时候,会进行对应的提示。这里校验功能我们使用的 VeeValidate 插件,具体使用方法可以去官网查看,这里我们直接附上代码。
配置 VeeValidate 插件
// 配置表单验证插件
import Vue from 'vue';
import VeeValidate from 'vee-validate';
// 中文提示信息
import zh_CN, { attributes } from 'vee-validate/dist/locale/zh_CN'
Vue.use(VeeValidate);
// 表单验证
VeeValidate.Validator.localize("zh_CN",{
messages:{
...zh_CN.messages,
is:(field) => `${field}必须与密码相同`,
},
attributes:{
phone:'手机号',
code:'验证码',
password:'密码',
password1:'确认密码',
agree:'协议'
},
});
// 自定义校验规则
VeeValidate.Validator.extend('tongyi', {
validate: value => {return value},
getMessage: (field) => field + ' 必须同意'
});
在 app/main.js 中引入
// 引入表单验证插件
import "@/plugins/validate"
完成登录功能
当完成注册,输入手机号,和密码,点击获取验证码,动态获取验证码,整理后向服务器发送请求,服务器向我们返回 token ,有了 token 才能验证用户身份,发送请求服务器给我们返回用户信息。
token 如何持久化保存呢?
如果直接存储在 vuex 中,页面刷新后,vuex 中的信息就会消失,那么我们就需要再次登录,显然这是不合理的,所以我们可以存储在本地浏览器中,使用 localStorge 这样就不会产生页面刷新后的问题。第一次发送请求,服务器验证身份,返回 token 并保存到本地。
// 派发登录请求
async getLogin(context,data){
let result = await reqLogin(data)
console.log(result);
if(result.code == 200){
// console.log(result);
context.commit('GETLOGIN',result.data.token)
// 存储 token
setToken(result.data.token)
return 'ok'
}else{
return Promise.reject(new Error(result.message))
}
},
创建 /src/utils/token.js 文件,对存储,获取,删除操作进行封装。 代码如下:
// 存储token
export const setToken = (token)=>{
localStorage.setItem('TOKEN',token)
}
// 读取 token
export const getToken = ()=>{
return localStorage.getItem('TOKEN')
}
// 删除token
export const removeToken = ()=>{
localStorage.removeItem('TOKEN')
}
刷新页面,每次的 /store/login/index.js 文件的 state 会读取浏览器的本地存储 token ,所以刷新的时候,不会失去 token 而失去登录状态。
import { setToken,getToken, removeToken } from '@/utils/token'
const state = {
// 服务器返回信息token
token:getToken(),
userinfo:{},
// 交易页信息
tradeList:{}
}
在 login 组件的 vuex 的读取的 token ,每次请求使用 store.state.login.token ,在请求拦截器中,加入请求头信息。
// 请求拦截器
requests.interceptors.request.use((config)=>{
// 把token加入请求头信息
if(store.state.login.token){
config.headers.token = store.state.login.token
}
// config配置对象 对象里面有一个属性很重要,header请求头
nprogress.start();
return config;
});
退出登录
退出登录其实就很简单,登录有关的操作是基于 token ,思考退出登录,只需发送退出登录请求通知服务器清除 token 以及清除本机浏览器中存储的 token 即可。
// 退出登录
async getUserLoginOut(context){
let result =await reqUserLoginOut()
if(result.code == 200){
// 事件处理 清除 token
context.commit('CLEARDATA')
return 'ok'
}else{
return Promise.reject(new Error('huaila'))
}
},
// 清除数据
CLEARDATA(state){
state.token = '',
state.userinfo = {},
removeToken()
},