创建项目
npm init vue@latest
// npm create vite@latest
Vue文件结构
<!-- 开关:经过语法糖的封装,容许在script中书写组合式API -->
<!-- setup在beforeCreate钩子之前自动执行 -->
<script setup>
<!-- 不再要求唯一根元素 -->
<template>
setup样例
<script setup>
const message = 'this is message'
</script>
<template>
<div>{{message}}</div>
</template>
语法糖,会自动编译成3.0的代码结构
https://play.vuejs.org/
在线编译组合式api代码
响应式数据
reactive():接受对象类型数据的参数传入并返回一个响应式的对象
ref():接受简单类型或者对象类型数据的参数传入并返回一个响应式的对象
ref
函数的内部实现依赖于reactive
函数
<script setup>
import { ref, reactive } from 'vue';
const state = reactive({
count: 0
})
const setCount = () => {
state.count++
}
const count2 = ref(0)
const setCount2 = () => {
count2.value++
}
</script>
<template>
<button @click="setCount">{{ state.count }}</button>
<button @click="setCount2">{{ count2 }}</button>
</template>
computed计算属性函数
<script setup>
import { ref, computed } from 'vue';
const list = ref([1, 2, 3, 4, 5, 6, 7,8])
const computedList = computed(() => {
return list.value.filter(item => item > 2)
})
setTimeout(() => {
list.value.push(9, 10)
}, 3000)
</script>
<template>
<div>原始数据:{{ list }}</div>
<div>计算后的数据:{{ computedList }}</div>
</template>
- 计算属性中不应该有副作用
- 避免直接修改计算属性的值
watch函数
监听一个或者多个数据的变化,数据变化时执行回调函数
有2个参数:
immediate
在监听器创建时,立即触发回调
deep
深度监听:通过watch监听的ref对象默认是浅层监听,直接修改嵌套的对象属性不会触发回调执行
可以同时监听多个响应式数据的变化,不管哪个数据变化都需要执行回调
<script setup>
import { ref, watch } from 'vue';
const count = ref(0)
const name = ref('Tom')
/监听ref对象,不需要加.value
watch(count, (newValue, oldValue) => {
console.log('count变更 新值:', newValue, '但值:', oldValue)
}, {
immediate: true
})
// 监听多个对象
// watch([count, name], ([newCount, newName], [oldCount, oldName]) => {
// console.log('count或者name变化了', [newCount, newName], [oldCount, oldName])
// })
const setCount = () => {
count.value++
}
</script>
<template>
<button @click="setCount">{{ count }}</button>
</template>
deep监听
<script setup>
import { ref, watch } from 'vue';
const state = ref({
count: 0
})
// 通过watch监听的ref对象默认是浅层监听,需要开启deep
watch(state, (newValue, oldValue) => {
console.log('state变更 新值:', newValue, '但值:', oldValue)
}, {
deep: true
})
// 精确监听某个属性
const info = ref({
name: 'Tom',
age: 18
})
// deep有性能损耗,尽量不开启deep
watch(
() => info.value.age,
() => console.log('age发生了变化')
)
const setCount = () => {
state.value.count++
}
const setAge = () => {
info.value.age = 30
}
</script>
<template>
<button @click="setCount">{{ state.count }}</button>
<button @click="setAge">age {{ info.age }}</button>
</template>
生命周期函数
组合式API - setup
onBeforeMount
onMounted
onBeforeUpdate
onUpdated
onBeforeUnmount
onUmmounted
<script setup>
import { onMounted } from 'vue';
onMounted(() => {
console.log('组件挂载完毕onMounted执行了')
})
</script>
父子通信–父传子
- 父组件中给子组件中绑定属性
- 子组件内部通过
defineProps
接收参数
<script setup>
import { ref } from 'vue';
import SonCom from './son-com.vue'
const count = ref(100)
setTimeout(() => {
count.value = 200
}, 3000)
</script>
<template>
<div>显示</div>
<SonCom message="father message" :count="count"></SonCom>
</template>
<script setup>
import { defineProps } from 'vue';
const props = defineProps({
message: String,
count: Number
})
console.log('props', props)
</script>
<template>
<div>子组件 {{ message }} - {{ count }}</div>
</template>
父子通信–子传父
- 父组件中给子组件标签通过
@
绑定事件 - 子组件内部通过
defineEmits
方法触发事件
<script setup>
import SonCom from './son-com.vue'
const getMessage = (msg) => {
console.log(msg)
}
</script>
<template>
<div>显示</div>
<SonCom @get-message="getMessage"></SonCom>
</template>
<script setup>
import { defineEmits } from 'vue';
const emit = defineEmits(["get-message"])
const sendMsg = () => {
emit('get-message', 'This is son message')
}
</script>
<template>
<button @click="sendMsg">发送消息</button>
</template>
模板引用
通过ref
标识获取真实的dom对象或者组件实例对象
默认情况下,子组件在setup
语法糖中组件内部的属性和方法是不开放给父组件访问的,使用defineExpose
指定可允许访问的属性和方法
<script setup>
import { onMounted, ref } from 'vue';
import testCom from './test-com.vue'
const h1Ref = ref(null)
const comRef = ref(null)
// 组件挂载完毕之后才能获取
onMounted(() => {
console.log(h1Ref.value)
console.log(comRef)
})
</script>
<template>
<h1 ref="h1Ref">绑定ref测试</h1>
<testCom ref="comRef"></testCom>
</template>
//===============================
<script setup>
import { ref } from 'vue';
const name = ref('test name')
const setName = () => {
name.value = 'test new name'
}
// 显示暴露组件内部的属性和方法
defineExpose({
name, setName
})
</script>
<template>
<div>子组件div</div>
</template>
跨层通信provide/inject
顶层组件向任意底层组件传递数据或方法,实现跨层组件通信;可以传递响应式数据、方法
- 顶层组件provide提供数据
provide('key', data)
- 底层组件inject获取数据
const message = inject('key')
<script setup>
import { provide, ref } from 'vue';
import SonCom from './son-com.vue'
provide('data-key', 'This is parent message')
// 响应式数据
const count = ref(0)
provide('count-key', count)
setTimeout(() => {
count.value = 300
}, 3000)
// 传递方法
const setCount = () => {
count.value++
}
provide('setCount-key', setCount)
</script>
<template>
<div>顶层组件</div>
<SonCom></SonCom>
</template>
//=======================
<script setup>
import { inject } from 'vue';
const parentData = inject('data-key')
const countData = inject('count-key')
const setCount = inject('setCount-key')
</script>
<template>
<div>底层组件 {{ parentData }} - {{ countData }}</div>
<button @click="setCount">更新数据</button>
</template>
Element Plus列表、修改页面
http://git.itcast.cn/heimaqianduan/vue3-basic-project.git
<script setup>
import { ref } from 'vue';
import axios from 'axios';
import Edit from './components/Edit.vue'
import { ElMessageBox } from 'element-plus';
// TODO: 列表渲染
const list = ref([])
const getList = async () => {
const res = await axios.get('/list')
list.value = res.data
}
// TODO: 删除功能
const onDelete = async (id) => {
try {
await ElMessageBox.confirm(
'确认删除这条数据吗?',
'删除提醒',
{
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning',
}
)
await axios.delete(`/del/${id}`)
getList()
} catch(err) {}
}
// TODO: 编辑功能
const editRef = ref(null)
const onEdit = (row) => {
editRef.value.openDialog(row)
}
getList()
</script>
<template>
<div class="app">
<el-table :data="list">
<el-table-column label="ID" prop="id"></el-table-column>
<el-table-column label="姓名" prop="name" width="150"></el-table-column>
<el-table-column label="籍贯" prop="place"></el-table-column>
<el-table-column label="操作" width="150">
<template #default="{row}">
<el-button type="primary" link @click="onEdit(row)">编辑</el-button>
<el-button type="danger" link @click="onDelete(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<Edit ref="editRef" @on-update="getList"/>
</template>
<style scoped>
.app {
width: 980px;
margin: 100px auto 0;
}
</style>
/components/Edit.vue
<script setup>
// TODO: 编辑
import { ref, defineEmits } from 'vue'
import axios from 'axios'
// 弹框开关
const dialogVisible = ref(false)
const form = ref({
id: '',
name: '',
place: ''
})
// 打开编辑页面
const openDialog = (row) => {
form.value.id = row.id
form.value.name = row.name
form.value.place = row.place
dialogVisible.value = true
}
const emit = defineEmits(['on-update'])
const onUpdate = async () => {
await axios.patch(`/edit/${form.value.id}`, {
name: form.value.name,
place: form.value.place
})
dialogVisible.value = false
// 父页面重新查询数据
emit('on-update')
}
// 暴露方法给父页面调用
defineExpose({
openDialog
})
</script>
<template>
<el-dialog v-model="dialogVisible" title="编辑" width="400px">
<el-form label-width="50px" :model="form">
<el-form-item label="姓名">
<el-input placeholder="请输入姓名" v-model="form.name" />
</el-form-item>
<el-form-item label="籍贯">
<el-input placeholder="请输入籍贯" v-model="form.place" />
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="onUpdate">确认</el-button>
</span>
</template>
</el-dialog>
</template>
<style scoped>
.el-input {
width: 290px;
}
</style>
Pinia状态管理库
npm init vue@latest //创建一个空项目
vue-pinia
npm install pinia
修改main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
const pinia = createPinia()
const app = createApp(App)
app.use(pinia)
app.mount('#app')
添加文件 stores/counter.js
import { computed, ref } from 'vue'
import axios from 'axios'
import { defineStore } from 'pinia'
const API_URL = 'http://geek.itheima.net/v1_0/channels'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const increment = () => {
count.value++
}
// Pinia中的getters直接使用computed函数进行模拟
const doubleCount = computed(() => count.value * 2)
// 定义异步action
const list = ref([])
const getList = async () => {
const res = await axios.get(API_URL)
list.value = res.data.data.channels
}
return { count, doubleCount, increment, list, getList }
})
使用Pinia
<script setup>
import { useCounterStore } from '@/stores/counter'
import { storeToRefs } from 'pinia';
import { onMounted } from 'vue';
const counterStore = useCounterStore()
// 解构会导致响应式丢失
// const {count, doubleCount} = counterStore
// storeToRefs可以辅助保持数据的响应式解构
// 保持数据响应式
const {count, doubleCount} = storeToRefs(counterStore)
// 方法需要直接从原来的counterStore中解构赋值
const {increment} = counterStore
onMounted(() => {
counterStore.getList()
})
</script>
<template>
<button @click="increment">{{ count }}</button>
<div>{{ doubleCount }}</div>
<ul>
<li v-for="item in counterStore.list" :key="item.id">{{ item.name }}</li>
</ul>
</template>
全局属性
在Vue3中使用 <script setup>
语法时,不能直接使用 this
来访问组件的实例属性。但是,如果你想在 <script setup>
组件中使用 app.config.globalProperties
设置的全局属性,可以通过属性注入或直接从应用实例访问它们。
以下是一个使用 <script setup>
语法的示例,展示如何在Vue3应用中使用 app.config.globalProperties
:
首先,在Vue3应用的入口文件(如 main.js 或 main.ts)中设置全局属性:
import { createApp } from 'vue';
const app = createApp({})
// 定义全局属性
app.config.globalProperties.$myGlobalProperty = 'Hello World';
// 定义全局方法
app.config.globalProperties.myGlobalMethod = function() {
console.log('This is a global method');
};
// 挂载应用
app.mount('#app');
然后,创建一个使用 <script setup>
语法的组件,并在其中访问全局属性:
<template>
<div>
<p>{{ myGlobalProperty }}</p>
<!-- 如果全局属性是一个方法,可以这样调用: -->
<button @click="myGlobalMethod">Call Global Method</button>
</div>
</template>
<script setup>
import { getCurrentInstance } from 'vue';
// 使用 getCurrentInstance 获取组件实例
const instance = getCurrentInstance();
const myGlobalProperty = instance.appContext.config.globalProperties.$myGlobalProperty;
const myGlobalMethod = instance.appContext.config.globalProperties.myGlobalMethod;
</script>
配置src路径别名
创建测试项目
npm init vue@latest
vue3-rabbit
选中Router/Pinia/ESLint
jsconfig.json配置别名路径,使用@自动路径提示
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules", "dist"]
}
在vite.config.js文件中,alias配置会把@路径转换成真实的路径
Element Plus安装
#按需引入
npm install element-plus --save
npm install -D unplugin-vue-components unplugin-auto-import
修改vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
export default defineConfig({
plugins: [
vue(),
AutoImport({
resolvers: [ElementPlusResolver()],
}),
Components({
resolvers: [ElementPlusResolver()],
}),
],
})
主题定制
npm i sass -D
对Element Plus样式进行覆盖
添加文件 styles/element/index.scss
/* 只需要重写你需要的即可 */
@forward 'element-plus/theme-chalk/src/common/var.scss' with (
$colors: (
'primary': (
// 主色
'base': #27ba9b,
),
'success': (
// 成功色
'base': #1dc779,
),
'warning': (
// 警告色
'base': #ffb302,
),
'danger': (
// 危险色
'base': #e26237,
),
'error': (
// 错误色
'base': #cf4444,
),
)
)
配置Element Plus采用sass样式配色系统
修改文件vite.config.js
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue(),
AutoImport({
resolvers: [ElementPlusResolver()],
}),
Components({
resolvers: [
// 配置ElementPlus采用sass样式配色系统
ElementPlusResolver({ importStyle: 'sass'})
],
})
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
css: {
preprocessorOptions: {
scss: {
// 自动导入定制化样式文件进行样式覆盖
additionalData: `
@use "@/styles/element/index.scss" as *;
`,
}
}
}
})
axios基础配置
安装
npm i axios
添加工具类 utils/http.js
import axios from "axios";
// 创建axios实例
const http = axios.create({
baseURL: 'http://pcapi-xiaotuxian-front-devtest.itheima.net',
timeout: 5000
})
// axios请求拦截器
http.interceptors.request.use(config => {
return config
}, e => Promise.reject(e))
// axios响应式拦截器
http.interceptors.response.use(res => res.data, e => {
return Promise.reject(e)
})
export default http
添加测试方法 api/testAPI.js
import http from "@/utils/http";
export function getCategory() {
return http({
url: 'home/category/head'
})
}
使用方法
import { getCategory } from './apis/testAPI'
getCategory().then((res) => console.log(res))
路由设计原则
找内容切换的区域,如果是页面整体切换,则为一级路由;
如果是在一级路由页的内部切换,则为二级路由
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'layout',
component: () => import('../views/Layout/index.vue'),
children: [
{
path: '',
name: 'home',
component: () => import('../views/Home/index.vue')
}, {
path: 'category',
name: 'category',
component: () => import('../views/Category/index.vue')
}
]
}, {
path: '/login',
name: 'login',
component: () => import('../views/Login/index.vue')
}
]
})
export default router
scss文件中变量的自动导入
在项目中一般把颜色值以scss变量的方式放到var.scss文件中,在使用时需要先导入var.scss文件
修改vite.config.js
css: {
preprocessorOptions: {
scss: {
// 自动导入定制化样式文件进行样式覆盖
additionalData: `
@use "@/styles/element/index.scss" as *;
@use "@/styles/var.scss" as *;
`,
}
}
}
测试
<template>
<div class="test">test</div>
</template>
<style scoped lang="scss">
.test {
color: $priceColor;
}
</style>
VS code安装插件Error Lens,语法错误提示
吸顶导航
准备吸顶导航组件,获取滚动距离,以滚动距离做判断条件控制组件盒子展示隐藏
https://vueuse.org/
常用的组合式api集合
#安装Vue3组合式api集合
npm i @vueuse/core
<script setup>
import { useScroll } from '@vueuse/core'
// 滚动时实时获取滚动条的位置
const { y } = useScroll(window)
</script>
// css:在满足条件时,样式类自动生效
:class="{show: y > 78}"
element-plus banner轮播图组件
<el-carousel height="500px">
<el-carousel-item v-for="item in bannerList" :key="item.id">
<img :src="item.imgUrl" alt="">
</el-carousel-item>
</el-carousel>
图片懒加载
使用自定义组件实现
使用useIntersectionObserver
函数,监听绑定的dom对象是否进入可视窗口区域
// main.js
import { useIntersectionObserver } from '@vueuse/core'
// 定义全局指令
app.directive('img-lazy', {
mounted(el, binding) {
// el:指令绑定的那个元素
// binding:binding.value 指令等号后面绑定的表达式
const { stop } = useIntersectionObserver(
el,
([{ isIntersecting }]) => {
if (isIntersecting) {
// 进入可视窗口区域
el.src = binding.value
// 停止监听
stop()
}
},
)
}
})
// 页面使用
<img v-img-lazy="item.picture" alt="" />
代码优化,抽取成插件
// directives/index.js
import { useIntersectionObserver } from '@vueuse/core'
// 定义懒加载插件
export const lazyPlugin = {
install(app) {
app.directive('img-lazy', {
mounted(el, binding) {
// el:指令绑定的那个元素
// binding:binding.value 指令等号后面绑定的表达式
const { stop } = useIntersectionObserver(
el,
([{ isIntersecting }]) => {
if (isIntersecting) {
// 进入可视窗口区域
el.src = binding.value
// 停止监听
stop()
}
},
)
}
})
}
}
// main.js
import { lazyPlugin } from '@/directives'
app.use(lazyPlugin)
动态路由
<li v-for="item in categoryStore.categoryList" :key="item.id">
<RouterLink active-class="active" :to="`/category/${item.id}`">
{{ item.name }}
</RouterLink>
</li>
获取参数
import {useRoute} from 'vue-router'
const route = useRoute()
console.log(route.params.id)
// console.log(route.query.id)
// route.params 用于访问当前路由的路由参数(route parameters),即路由路径中的动态参数部分
// route.query 用于访问当前路由的查询参数,通常用于传递额外的信息(获取到的路由参数是字符串)
激活路由链接中的样式
active-class配置选中路由的样式
<RouterLink active-class="active" :to="`/category/${item.id}`">{{ item.name }}</RouterLink>
.active {
color: $xtxColor;
border-bottom: 1px solid $xtxColor;
}
路由缓存问题
使用带有参数的路由时(/category/${item.id}
),相同的组件实例将被重复使用,
导致组件的生命周期钩子函数不会被调用
{
path: 'category/:id',
name: 'category',
component: () => import('../views/Category/index.vue')
}
解决方式
- 让组件实例不复用,强制销毁重建
// 添加key,破坏复用机制,强制销毁重建
<RouterView :key="$route.fullPath"/>
- 监听路由变化,变化之后执行数据更新操作
// useCategory.js
import {useRoute, onBeforeRouteUpdate} from 'vue-router'
onBeforeRouteUpdate((to) => {
// console.log('路由变化了', to)
getCategory(to.params.id)
})
以下是useCategory.js完整代码
import { ref, onMounted } from "vue"
import { onBeforeRouteUpdate, useRoute } from 'vue-router';
import { getCategoryAPI } from '@/apis/category';
export function useCategory() {
const categoryData = ref({})
const getCategory = async (id) => {
const res = await getCategoryAPI(id)
categoryData.value = res.result
}
// 解决组件复用生命周期钩子不调用的问题
onBeforeRouteUpdate((to) => {
// console.log('to', to)
getCategory(to.params.id)
})
// 首次调用会用到
const route = useRoute()
onMounted(() => {
getCategory(route.params.id)
})
return {
categoryData
}
}
面包屑导航
<el-breadcrumb separator=">">
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item :to="{ path: `/category/${categoryData.parentId}` }">{{ categoryData.parentName }}
</el-breadcrumb-item>
<el-breadcrumb-item>{{ categoryData.name }}</el-breadcrumb-item>
</el-breadcrumb>
Infinite Scroll无限滚动
在要实现滚动加载的列表上添加指令v-infinite-scroll
,可实现滚动到底部时自动执行加载方法
<div class="body" v-infinite-scroll="load" :infinite-scroll-disabled="disabled">
<!-- 商品列表-->
<GoodsItem :good="good" v-for="good in goodsList" :key="good.id"/>
</div>
// 下拉加载
const disabled = ref(false)
const load = async () => {
reqData.value.page++
const res = await getSubCategoryAPI(reqData.value)
goodsList.value = [...goodsList.value, ...res.result.items]
if (res.result.items.length < reqData.value.pageSize) {
disabled.value = true
}
}
tab排序方式切换
v-model绑定的是tab-pane对应的name属性值
<el-tabs v-model="reqData.sortField" @tab-change="tabChange">
<el-tab-pane label="最新商品" name="publishTime"></el-tab-pane>
<el-tab-pane label="最高人气" name="orderNum"></el-tab-pane>
<el-tab-pane label="评论最多" name="evaluateNum"></el-tab-pane>
</el-tabs>
// 切换排序方式
const tabChange = () => {
console.log('change', reqData.value.sortField)
reqData.value.page = 1
disabled.value = false
getGoodsList(reqData.value)
}
定制路由滚动行为
在切换路由时,自动滚动到页面的顶部
// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'layout',
component: () => import('../views/Layout/index.vue')
}, {
path: '/login',
name: 'login',
component: () => import('../views/Login/index.vue')
}
],
scrollBehavior() {
return {top: 0}
}
})
export default router
激活某个样式
格式 :class="{ className: condition == true }"
<li v-for="(img, i) in imageList" :key="i" @mouseenter="enterHandler(i)" :class="{ active: activeIndex === i }">
<img :src="img" alt="" />
</li>
图片放大镜功能
<script setup>
import { ref, watch } from 'vue';
import { useMouseInElement } from '@vueuse/core'
// 图片列表
// const imageList = [
// "https://yanxuan-item.nosdn.127.net/d917c92e663c5ed0bb577c7ded73e4ec.png",
// "https://yanxuan-item.nosdn.127.net/e801b9572f0b0c02a52952b01adab967.jpg",
// "https://yanxuan-item.nosdn.127.net/b52c447ad472d51adbdde1a83f550ac2.jpg",
// "https://yanxuan-item.nosdn.127.net/f93243224dc37674dfca5874fe089c60.jpg",
// "https://yanxuan-item.nosdn.127.net/f881cfe7de9a576aaeea6ee0d1d24823.jpg"
// ]
defineProps({
imageList: {
type: Array,
default: () => []
}
})
const activeIndex = ref(0)
const enterHandler = (i) => {
activeIndex.value = i
}
// 鼠标位置
const target = ref(null)
const top = ref(0)
const left = ref(0)
const positionX = ref(0)
const positionY = ref(0)
const { elementX, elementY, isOutside } = useMouseInElement(target)
watch([elementX, elementY, isOutside], () => {
if (isOutside.value) return
// 有效范围内控制滑块距离
if (elementX.value > 100 && elementX.value < 300) {
left.value = elementX.value - 100
}
if (elementY.value > 100 && elementY.value < 300) {
top.value = elementY.value - 100
}
// 边界
if (elementX.value > 300) {
left.value = 200
}
if (elementX.value < 100) {
left.value = 0
}
if (elementY.value > 300) {
top.value = 200
}
if (elementY.value < 100) {
top.value = 0
}
positionX.value = -left.value * 2
positionY.value = -top.value * 2
})
</script>
<template>
<div class="goods-image">
<!-- 左侧大图-->
<div class="middle" ref="target">
<img :src="imageList[activeIndex]" alt="" />
<!-- 蒙层小滑块 -->
<div class="layer" v-show="!isOutside" :style="{ left: `${left}px`, top: `${top}px` }"></div>
</div>
<!-- 小图列表 -->
<ul class="small">
<li v-for="(img, i) in imageList" :key="i" @mouseenter="enterHandler(i)" :class="{active: activeIndex === i}">
<img :src="img" alt="" />
</li>
</ul>
<!-- 放大镜大图 -->
<div class="large" :style="[
{
backgroundImage: `url(${imageList[activeIndex]})`,
backgroundPositionX: `${positionX}px`,
backgroundPositionY: `${positionY}px`,
},
]" v-show="!isOutside"></div>
</div>
</template>
<style scoped lang="scss">
.goods-image {
width: 480px;
height: 400px;
position: relative;
display: flex;
.middle {
width: 400px;
height: 400px;
background: #f5f5f5;
}
.large {
position: absolute;
top: 0;
left: 412px;
width: 400px;
height: 400px;
z-index: 500;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
background-repeat: no-repeat;
// 背景图:盒子的大小 = 2:1 将来控制背景图的移动来实现放大的效果查看 background-position
background-size: 800px 800px;
background-color: #f8f8f8;
}
.layer {
width: 200px;
height: 200px;
background: rgba(0, 0, 0, 0.2);
// 绝对定位 然后跟随咱们鼠标控制left和top属性就可以让滑块移动起来
left: 0;
top: 0;
position: absolute;
}
.small {
width: 80px;
li {
width: 68px;
height: 68px;
margin-left: 12px;
margin-bottom: 15px;
cursor: pointer;
&:hover,
&.active {
border: 2px solid $xtxColor;
}
}
}
}
</style>
使用第三方组件的关注点
关注props
和emit
props
接收参数,emit
返回数据
配置全局组件插件
编写插件时引入组件
// 使用插件形式,把组件全局注册(components/index.js)
import ImageView from './ImageView/index.vue'
import Sku from './XtxSku/index.vue'
export const componentPlugin = {
install(app) {
// app.component('组件名字', 组件配置对象)
app.component('XtxImageView', ImageView)
app.component('XtxSku', Sku)
}
}
注册全局插件
// main.js
import { componentPlugin } from './components'
const app = createApp(App)
app.use(componentPlugin)
Form表单
表单校验
<script setup>
import {ref} from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import 'element-plus/theme-chalk/el-message.css'
// 1.表单对象
const form = ref({
account: '',
password: '',
agree: true
})
// 2.规则对象
const rules = {
account: [
{required: true, message: '用户名不能为空', trigger: 'blur'}
],
password: [
{required: true, message: '密码不能为空', trigger: 'blur'},
{min: 6, max: 14, message: '密码长度为6-14个字符', trigger: 'blur'}
],
// 自定义规则
agree: [
{
validator: (rule, value, callback) => {
if (!value) {
callback(new Error('请勾选协议'))
} else {
callback()
}
}
}
]
}
const router = useRouter()
// 3.表单校验
const formRef = ref(null)
const doLogin = () => {
formRef.value.validate((valid) => {
// 所有表单项校验通过才为true
if (valid) {
const {account, password} = form.value
await loginAPI({
account,
password
})
ElMessage({ type: 'success', message: '登录成功' })
router.replace({ path: '/' })
}
})
}
</script>
<template>
<el-form label-position="right" label-width="60px"
:model="form" :rules="rules" ref="formRef"
status-icon>
<el-form-item label="账户" prop="account">
<el-input v-model="form.account"/>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input v-model="form.password"/>
</el-form-item>
<el-form-item label-width="22px" prop="agree">
<el-checkbox size="large" v-model="form.agree">
我已同意隐私条款和服务条款
</el-checkbox>
</el-form-item>
<el-button size="large" class="subBtn" @click="doLogin">点击登录</el-button>
</el-form>
</template>
用户数据存入Pinia
// userStore.js
import { defineStore } from "pinia";
import { ref } from "vue";
import {loginAPI} from '@/apis/user'
export const useUserStore = defineStore('user', () => {
const userInfo = ref({})
const getUserInfo = async ({account, password}) => {
const res = await loginAPI({account, password})
userInfo.value = res.result
}
return {
userInfo,
getUserInfo
}
})
//登录时直接调用getUserInfo方法
import {useUserStore} from '@/stores/user'
const userStore = useUserStore()
const doLogin = async () => {
formRef.value.validate(async (valid) => {
// 所有表单项校验通过才为true
if (valid) {
const {account, password} = form.value
await userStore.getUserInfo({account, password})
ElMessage({ type: 'success', message: '登录成功' })
router.replace({ path: '/' })
}
})
}
Pinia用户数据持久化
// 安装插件
npm i pinia-plugin-persistedstate
// 注册插件main.js
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const app = createApp(App)
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
app.use(pinia)
在定义defineStore
方法时,传入第3个参数:{ persist: true }
// userStore.js完整代码
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { loginAPI } from '@/apis/user';
import { useCartStore } from './cartStore';
export const useUserStore = defineStore('user', () => {
const cartStore = useCartStore()
const userInfo = ref({})
// 登录
const getUserInfo = async ({account, password}) => {
const res = await loginAPI({account, password})
userInfo.value = res.result
// 合并购物车
await cartStore.mergeCart()
// 查询最新购物车
cartStore.updateNewList()
}
const clearUserInfo = () => {
userInfo.value = {}
// 退出时删除购物车
cartStore.clearCart()
}
return { userInfo, getUserInfo, clearUserInfo }
}, {
persist: true
})
Form确认框
退出登录
<script setup>
import { useRouter } from 'vue-router';
import {useUserStore} from '@/stores/user'
const userStore = useUserStore()
const router = useRouter()
const confirm = () => {
userStore.clearUserInfo()
router.push('/login')
}
</script>
<template>
<el-popconfirm title="确认退出吗?" @confirm="confirm" confirm-button-text="确认" cancel-button-text="取消">
<template #reference>
<a href="javascript:;">退出登录</a>
</template>
</el-popconfirm>
</template>
常用的页面点击跳转方式
# 1. RouterLink
<li class="home" v-for="item in categoryStore.categoryList" :key="item.id">
<RouterLink active-class="active" :to="`/category/${item.id}`">{{ item.name }}</RouterLink>
</li>
# 2. $router
<el-button size="large" type="primary" @click="$router.push('/cartlist')">去购物车结算</el-button>
# 3. router
import { useRouter } from 'vue-router';
const router = useRouter()
router.push('/login')
# push跳转带参数
router.push({
path: '/pay',
query: {
id: orderId
}
})
# 4. 超链接
<a href="javascript:;" @click="$router.push('/login')">请先登录</a>
单选全选框选中
把单选框值与pinia中的数据绑定
v-model
双向绑定指令不合适命令式的操作,使用独立的2个指令:model-value
和@change
// 默认change事件只有一个selected参数,使用匿名箭头函数是为了扩展传参
<el-checkbox :model-value="item.selected" @change="(selected) => changeHandler(item.skuId, selected)"/>
// 单选回调
const changeHandler = (skuId, selected) => {
// selected的值为true/false,即当前选中的状态
console.log(skuId, selected)
cartStore.singleCheck(skuId, selected)
}
全选功能
<el-checkbox :model-value="cartStore.isAll" @change="allCheck"/>
// 是否全选
const isAll = computed(() => cartList.value.every((item) => item.selected))
const allCheck = (selected) => {
cartList.value.forEach(item => item.selected = selected)
}
cartStore.js
完整代码
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { useUserStore } from './userStore'
import { insertCartAPI, findNewCartListAPI, delCartAPI, mergeCartAPI } from '@/apis/cart'
export const useCartStore = defineStore('cart', () => {
const userStore = useUserStore()
const isLogin = computed(() => userStore.userInfo.token)
const cartList = ref([])
const addCart = async (goods) => {
const {skuId, count} = goods
if (isLogin.value) {
await insertCartAPI({skuId, count})
await updateNewList()
} else {
// 未登录,本地购物车
const item = cartList.value.find(item => item.skuId === goods.skuId)
if (item) {
item.count = item.count + goods.count
} else {
cartList.value.push(goods)
}
}
}
const delCart = async (skuId) => {
if (isLogin.value) {
await delCartAPI([skuId])
await updateNewList()
} else {
const index = cartList.value.findIndex(item => item.skuId === skuId)
cartList.value.splice(index, 1)
// const newList = cartList.value.filter(item => item.skuId !== skuId)
// cartList.value = newList
}
}
// 合并本地购物车,在登录时使用
const mergeCart = async () => {
if (cartList.value.length > 0) {
await mergeCartAPI(cartList.value.map(item => {
return {
skuId: item.skuId,
count: item.count,
selected: item.selected
}
}))
}
}
// 清除购物车
const clearCart = () => {
cartList.value = []
}
// 获取最新购物车列表
const updateNewList = async () => {
const res = await findNewCartListAPI()
cartList.value = res.result
}
// 单选功能
const singleCheck = (skuId, selected) => {
const item = cartList.value.find(item => item.skuId === skuId)
item.selected = selected
}
// 全选
const allCheck = (selected) => {
cartList.value.forEach(item => item.selected = selected)
}
// 是否全选
const isAll = computed(() => cartList.value.every(item => item.selected))
// 总数量
const allCount = computed(() => cartList.value.reduce((a, c) => a + c.count, 0))
// 总价
const allPrice = computed(() => cartList.value.reduce((a, c) => a + c.count * c.price, 0))
// 已选择数量
const selectedCount = computed(() => cartList.value.filter(item => item.selected).reduce((a, c) => a + c.count, 0))
// 已选择商品总份
const selectedPrice = computed(() => cartList.value.filter(item => item.selected).reduce((a, c) => a + c.count * c.price, 0))
return { cartList, allCount, allPrice, selectedCount, selectedPrice, isAll,
addCart, delCart, clearCart, mergeCart, singleCheck, allCheck, updateNewList }
}, {
persist: true
})
弹窗组件
<el-dialog v-model="showDialog" title="切换收货地址" width="30%" center>
<div class="addressWrapper">
<div class="text item" :class="{active : activeAddress.id === item.id }" @click="switchAddress(item)" v-for="item in checkInfo.userAddresses" :key="item.id">
<ul>
<li><span>收<i />货<i />人:</span>{{ item.receiver }} </li>
<li><span>联系方式:</span>{{ item.contact }}</li>
<li><span>收货地址:</span>{{ item.fullLocation + item.address }}</li>
</ul>
</div>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="showDialog = false">取消</el-button>
<el-button type="primary" @click="confirmAddress">确定</el-button>
</span>
</template>
</el-dialog>
倒计时组件
// useCountDown.js
// 倒计时
import { ref, computed, onUnmounted } from "vue";
import dayjs from "dayjs";
export const useCountDown = () => {
let timer = null
const time = ref(0)
const formatTime = computed(() => dayjs.unix(time.value).format('mm分ss秒'))
// 传入倒计时的秒数
const start = (currentTime) => {
time.value = currentTime
timer = setInterval(() => {
if (time.value > 0) {
time.value --
} else {
timer && clearTimeout(timer)
}
}, 1000)
}
// 组件销毁时清除定时器
onUnmounted(() => {
timer && clearInterval(timer)
})
return {
formatTime,
start
}
}
自定义列表空数据显示样式
<div class="holder-container" v-if="orderList.length === 0">
<el-empty description="暂无订单数据" />
</div>
<div v-else>
<!-- 订单列表 -->
<div class="order-item" v-for="order in orderList" :key="order.id">
</div>
</div>
tab页切换
<script setup>
import {ref, onMounted} from 'vue'
import {getUserOrder} from '@/apis/order'
// tab列表
const tabTypes = [
{ name: "all", label: "全部订单" },
{ name: "unpay", label: "待付款" },
{ name: "deliver", label: "待发货" },
{ name: "receive", label: "待收货" },
{ name: "comment", label: "待评价" },
{ name: "complete", label: "已完成" },
{ name: "cancel", label: "已取消" }
]
// 订单列表
const orderList = ref([])
const params = ref({
orderState: 0,
page: 1,
pageSize: 2
})
const getOrderList = async () => {
const res = await getUserOrder(params.value)
orderList.value = res.result.items
}
const tabChange = (index) => {
params.value.orderState = index
getOrderList()
}
onMounted(() => getOrderList())
</script>
<template>
<div class="order-container">
<el-tabs @tab-change="tabChange">
<!-- tab切换 -->
<el-tab-pane v-for="item in tabTypes" :key="item.name" :label="item.label" />
</div>
<div>内容</div>
</el-tabs>
</div>
</template>
列表分页
// 订单列表
const orderList = ref([])
const params = ref({
orderState: 0,
page: 1,
pageSize: 2
})
const total = ref(0)
const getOrderList = async (params) => {
const res = await getUserOrder(params)
orderList.value = res.result.items
total.value = res.result.counts
}
onMounted(() => getOrderList(params.value))
const tabChange = (type) => {
params.value.page = 1
params.value.orderState = type
getOrderList(params.value)
}
const pageChange = (page) => {
params.value.page = page
getOrderList(params.value)
}
<!-- 分页 -->
<div class="pagination-container">
<el-pagination :total="total" :page-size="params.pageSize" @current-change="pageChange" background layout="prev, pager, next" />
</div>
<style scoped lang="scss">
.order-container {
padding: 10px 20px;
.pagination-container {
display: flex;
justify-content: center;
}
.main-container {
min-height: 500px;
.holder-container {
min-height: 500px;
display: flex;
justify-content: center;
align-items: center;
}
}
}
</style>
参考文档
https://gitee.com/galen.zhang/vue3-rabbit
项目源码
https://gitee.com/galen.zhang/sku-demo
sku demo
https://www.bilibili.com/video/BV1Ac411K7EQ/?spm_id_from=333.999.0.0&vd_source=0311265fb5615f1fc596bb4c1dcdcd20
黑马程序员前端Vue3小兔鲜电商项目实战,vue3全家桶从入门到实战电商项目一套通关
https://zsjie.blog.csdn.net/article/details/131323373
黑马程序员前端 Vue3 小兔鲜电商项目
https://www.yuque.com/fechaichai/trash-1cydvph9
Vue3小兔鲜新版文档
https://apifox.com/apidoc/shared-fa9274ac-362e-4905-806b-6135df6aa90e
黑马前端api文档
https://play.vuejs.org/
在线编译组合式api代码
https://vueuse.org/
vue官方提供的vue3组合式函数
测试登录账号
xiaotuxian001
123456
支付宝沙箱账号
scobys4865@sandbox.com
登录密码111111
支付密码111111
支付回调地址
http://127.0.0.1:5173/paycallback?payResult=true&orderId=1809449837100273665