vue3-jd-h5
在vue的Composition API刚发布的时候,写了一篇基于vue3.0.1 beta,搭建仿京东的电商H5项目!的文章,介绍了vue的一些新特性,如今正式版本已经发布了,今年乘着有时间,开始使用最新的vue全家桶来进行重构!其他基础使用就直接略过了,不懂的可以直接看中文官网的例子或者我之前那篇文章!
项目介绍
vue3-jd-h5
是一个电商H5页面前端项目,从vue2.6.1过度到vue3.0.0进行重构,基于「Vue 3.0.0全家桶」 ➕ 「Vant 3.0.0」 实现!
📖本地线下代码「vue2.6」在分支demo中,使用「mockjs」数据进行开发,效果图请点击🔗这里
⚠️master分支是线上生产环境代码,因为部分后台接口已经挂了😫,可能无法看到实际效果。
📌 本项目还有很多不足之处,如果有想为此做贡献的伙伴,也欢迎给我们提出PR,或者issue ;
🔑 本项目是免费开源的,如果有伙伴想要在次基础上进行二次开发,可以clone或者fork整个仓库,如果能帮助到您,我将感到非常高兴,如果您觉得这个项目不错还请给个start!🙏
开始搭建
首先,在本地选择一个文件,将代码clone到本地:
git clone https://github.com/GitHubGanKai/vue-jd-h5.git
👉切换到分支「vue-next」开始进行体验(目前正在逐步重构中)!👈
在 IDEA 命令行中运行命令:
npm install
下载安装相关依赖;
安装vue全家桶
配置安装vue-router
// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
const indexRouter = {
path: '/',
component: () => import('@/views/index'),
redirect: '/index',
children: []
}
const routes = [
indexRouter,
{
path: '/nopermission',
name: 'nopermission',
meta: {
index: 1
},
component: () => import('@/views/error/NoPermission')
},
{
path: '/*',
name: '404',
meta: {
index: 1
},
component: () => import('@/views/error/404')
},
]
const routerContext = require.context('./modules', true, /\.js$/)
routerContext.keys().forEach(route => {
const routerModule = routerContext(route)
indexRouter.children = [...indexRouter.children, ...(routerModule.default || routerModule)]
})
export default createRouter({
history: createWebHistory(process.env.BASE_URL),
routes
})
使用useRouter
hooks可以获取路由对象:
import { onMounted } from "vue";
import { useRouter, useRoute } from "vue-router";
export default {
name: "home",
setup(props, context) {
const $router = useRouter();
const handleClick = id => {
$router.push(`/classify/product/${id}`);
};
return {
handleClick,
};
}
};
使用useRoute
获取路由参数对象
import { onMounted } from "vue";
import { useRoute } from "vue-router";
export default {
name: "home",
setup(props, context) {
// 可以拿到所有和路由相关的参数
// 和useRouter()就差一个字母r,😅
const $route = useRoute();
onMounted(async () => {
const { data } = await ctx.$http.get(
`http://test.happymmall.com/product/${$route.params.id}`
);
});
return {
$route,
};
}
};
配置安装vuex
// src/store/index.js
import { createStore } from 'vuex'
import cart from './modules/cart'
import search from './modules/search'
export default createStore({
modules: {
cart,
search
},
strict: process.env.NODE_ENV !== 'production'
})
在文件中使用如下:
import { useStore } from "vuex";
import { reactive, getCurrentInstance } from "vue";
setup(props, context) {
const { ctx } = getCurrentInstance();
const $store = useStore();
// ctx.$store === $store ==>true 其实是同一个对象!
const ball = reactive({
show: false,
el: ""
});
const addToCart = (event, tag) => {
$store.commit("cart/addToCart", tag);
ball.show = true;
ball.el = event.target;
};
return {
...toRefs(ball),
addToCart,
};
}
在入口文件main.js
中使用:
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
import 'lib-flexible/flexible'
import Vant from 'vant'
import 'vant/lib/index.css' // 全局引入样式
const app = createApp(App);
app.use(Vant).use(store).use(router).mount('#app');
使用svg-sprite-loader
处理svg文件
首先在vue.config.js
中配置svg-sprite-loader
:
module.exports = {
chainWebpack: config => {
const svgRule = config.module.rule("svg");
svgRule.uses.clear();
svgRule
.use("svg-sprite-loader")
.loader("svg-sprite-loader")
.options({
symbolId: "icon-[name]"
})
.end();
},
}
在src/components/SvgIcon/index.vue
中:
import { computed, toRefs, toRef } from "vue";
export default {
name: "svg-icon",
props: {
iconClass: {
type: String,
required: true
},
className: {
type: String
}
},
setup(initProps) {
// const { iconClass } = initProps;❌
// 因为 props 是响应式的,你不能使用 ES6 解构,因为它会消除 prop 的响应性。
// 如果需要解构 prop,可以通过使用 setup 函数中的 toRefs 来完成此操作:
const { iconClass } = toRefs(initProps);
const iconName = computed(() => {
return `#icon-${iconClass.value}`;
});
// 由于 className 是可选的 prop,则传入的 props 中可能没有 className 。
// 在这种情况下,toRefs 将不会为 className 创建一个 ref ,需要使用 toRef 替代它。
const className = toRef(initProps, "className");
const svgClass = computed(() => {
if (className) {
return "svg-icon " + className.value;
} else {
return "svg-icon";
}
});
return {
iconName,
svgClass
};
}
};
将这个写成一个插件,统一将所有的svg文件注册成组件的形式方便全局使用!
// src/icons/index.js
import SvgIcon from '@/components/SvgIcon'
const requireAll = requireContext => requireContext.keys().map(requireContext)
export default {
install(app) {
app.component('svg-icon', SvgIcon);
const req = require.context('./svgs/', false, /\.svg$/)
requireAll(req)
}
}
统一注册所有组件
在src/components/index.js
文件中:
function capitalizeFirstLetter(str) {
return str.charAt(0).toUpperCase() + str.slice(1)
}
function validateFileName(str) {
return /^\S+\.vue$/.test(str) &&
str.replace(/^\S+\/(\w+)\.vue$/, (rs, $1) => capitalizeFirstLetter($1))
}
const requireComponent = require.context('.', true, /\.vue$/)
export default {
install(app) {
requireComponent.keys().forEach(filePath => {
const componentConfig = requireComponent(filePath)
const fileName = validateFileName(filePath)
const componentName = fileName.toLowerCase() === 'index' ?
capitalizeFirstLetter(componentConfig.default.name) :
fileName
app.component(componentName, componentConfig.default || componentConfig)
})
}
}
function capitalizeFirstLetter(str) {
return str.charAt(0).toUpperCase() + str.slice(1)
}
function validateFileName(str) {
return /^\S+\.vue$/.test(str) &&
str.replace(/^\S+\/(\w+)\.vue$/, (rs, $1) => capitalizeFirstLetter($1))
}
const requireComponent = require.context('.', true, /\.vue$/)
export default {
install(app) {
requireComponent.keys().forEach(filePath => {
const componentConfig = requireComponent(filePath)
const fileName = validateFileName(filePath)
const componentName = fileName.toLowerCase() === 'index' ?
capitalizeFirstLetter(componentConfig.default.name) :
fileName
app.component(componentName, componentConfig.default || componentConfig)
})
}
}
配置全局axios
封装异步请求:
// src/plugins/axios.js
import axios from 'axios'
import router from '../router/index'
import { Toast } from 'vant'
const tip = msg => {
Toast({
message: msg,
duration: 1000,
forbidClick: true
})
}
const errorHandle = (status, other) => {
switch (status) {
case 401:
toLogin()
break
case 403:
tip('登录过期,请重新登录')
localStorage.removeItem('token')
setTimeout(() => {
toLogin()
}, 1000)
break
case 404:
tip('请求的资源不存在')
break
default:
console.log(other)
}
}
const instance = axios.create({
baseURL: process.env.VUE_APP_BASE_URL,
// baseURL: '',
timeout: 1000 * 12
})
instance.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded;charset=UTF-8'
instance.interceptors.request.use(
config => {
const token = localStorage.token
token && (config.headers.token = token)
return config
},
error => Promise.error(error))
// 响应拦截器
instance.interceptors.response.use(
// 请求成功
response => {
return response.status === 200 ? Promise.resolve(response) : Promise.reject(response)
},
// 请求失败
error => {
const {
response
} = error
if (response) {
// 请求已发出,但是不在2xx的范围
errorHandle(response.status, response.data.message)
return Promise.reject(response)
}
})
export default {
install(app) {
// 这可以代替 Vue 2.x Vue.prototype 在单文件中,可以这样使用:
// const { ctx } = getCurrentInstance();
// ctx.$http访问
app.config.globalProperties.$http = instance
}
};
封装全局eventBus
在2.x
中,Vue
实例可用于触发由事件触发 API 通过指令式方式添加的处理函数 (off 和 $once)。这可以创建 event hub,用来创建在整个应用程序中可用的全局事件监听器:
// src/utils/eventBus.js
const eventBus = new Vue()
export default eventBus
3.x
从实例中完全移除了 $on
、$off
和 $once
方法。$emit
仍然包含于现有的 API 中,因为它用于触发由父组件声明式添加的事件处理函数。
可以使用实现了事件触发接口的外部库来替换现有的 event hub,例如 mitt
或 tiny-emitter
。
所以需要在2.x
的基础上进行自我改造(没有使用 mitt
或 tiny-emitter
):
import { getCurrentInstance } from 'vue'
class EventBus {
constructor(app) {
if (!this.handles) {
Object.defineProperty(this, 'handles', {
value: {},
enumerable: false
})
}
this.app = app
// _uid和EventName的映射
this.eventMapUid = {}
}
setEventMapUid(uid, eventName) {
if (!this.eventMapUid[uid]) {
this.eventMapUid[uid] = []
}
this.eventMapUid[uid].push(eventName)
// 把每个_uid订阅的事件名字push到各自uid所属的数组里
}
$on(eventName, callback, vm) {
// vm是在组件内部使用时组件当前的this用于取_uid
if (!this.handles[eventName]) {
this.handles[eventName] = []
}
this.handles[eventName].push(callback)
this.setEventMapUid(vm._uid, eventName)
}
$emit() {
let args = [...arguments]
let eventName = args[0]
let params = args.slice(1)
if (this.handles[eventName]) {
let len = this.handles[eventName].length
for (let i = 0; i < len; i++) {
this.handles[eventName][i](...params)
}
}
}
$offVmEvent(uid) {
let currentEvents = this.eventMapUid[uid] || []
currentEvents.forEach(event => {
this.$off(event)
})
}
$off(eventName) {
delete this.handles[eventName]
}
}
let $EventBus = {}
$EventBus.install = (app) => {
app.config.globalProperties.$eventBus = new EventBus(app)
app.mixin({
beforeUnmount() {
const currentInstance = getCurrentInstance();
// 拦截beforeUnmount钩子,自动销毁自身所有订阅的事件
this.$eventBus.$offVmEvent(currentInstance._uid)
}
})
}
export default $EventBus
在src/views/classify/index.vue
文件中使用如下:
import ListScroll from "@/components/scroll/ListScroll";
import { ref, reactive, onMounted, toRefs, getCurrentInstance } from "vue";
import { useRouter } from "vue-router";
export default {
name: "classify",
components: {
ListScroll
},
setup(props) {
const { ctx } = getCurrentInstance();
const $router = useRouter();
const searchWrap = ref(null);
const state = reactive({
categoryDatas: [],
currentIndex: 0
});
const selectMenu = index => {
state.currentIndex = index;
};
const setSearchWrapHeight = () => {
const { clientHeight } = document.documentElement;
searchWrap.value.style.height = clientHeight - 100 + "px";
};
const selectProduct = sku => {
$router.push({ path: "/classify/recommend", query: { sku } });
};
onMounted(async () => {
setSearchWrapHeight();
// 使用全局注过的$eventBus
ctx.$eventBus.$emit("changeTag", 1);
// 使用全局注册过的$http
const { data } = await ctx.$http.get(
"http://test.happymmall.com/category/categoryData"
);
const { categoryData } = data;
state.categoryDatas = categoryData;
});
return {
searchWrap,
...toRefs(state),
selectProduct,
selectMenu
};
}
};
封装个简单的hooks:useClickOutside
// src/hooks/useClickOutside.js
import { onMounted, onUnmounted, ref } from "vue";
export default useClickOutSide = (domRef) => {
const isOutside = ref(false);
const handler = (event) => {
if (domRef.value) {
if (domRef.value.contains(event.target)) {
isOutside.value = false;
} else {
isOutside.value = true;
}
}
}
onMounted(() => {
document.addEventListener('click', handler);
});
onUnmounted(() => {
document.removeEventListener('click', handler);
});
return isOutside;
}
使用 this
在 setup()
内部,this
不会是该活跃实例的引用,因为 setup()
是在解析其它组件选项之前被调用的,所以 setup()
内部的 this
的行为与其它选项中的 this
完全不同。这在和其它选项式 API
一起使用 setup()
时可能会导致混淆。
可以通过getCurrentInstance
获取当前单个文件组件的实例,同时ctx
上面挂在了一些全局属性:
import { getCurrentInstance, onMounted, reactive, toRefs } from 'vue'
export default {
name: "classify",
setup(props) {
const { ctx } = getCurrentInstance();
const state = reactive({
categoryDatas: [],
currentIndex: 0
});
onMounted(async () => {
const { data } = await ctx.$http.get("http://test.happymmall.com/category/categoryData");
const { categoryData, page } = data;
state.categoryDatas = categoryData;
state.currentIndex = page;
});
return {
...toRefs(state)
};
}
};
📦 封装better-scroll
<template>
<div ref="wrapper" class="scroll-wrapper">
<slot></slot>
</div>
</template>
<script>
import BScroll from "better-scroll";
import { onMounted, nextTick, ref, watchEffect } from "vue";
export default {
props: {
probeType: {
type: Number,
default: 1
},
click: {
type: Boolean,
default: true
},
scrollX: {
type: Boolean,
default: false
},
listenScroll: {
type: Boolean,
default: false
},
scrollData: {
type: Array,
default: null
},
pullup: {
type: Boolean,
default: false
},
pulldown: {
type: Boolean,
default: false
},
beforeScroll: {
type: Boolean,
default: false
},
refreshDelay: {
type: Number,
default: 20
}
},
setup(props, setupContext) {
const wrapper = ref(null);
const initScroll = () => {
if (!wrapper.value) return;
const scroll = new BScroll(wrapper.value, {
probeType: props.probeType,
click: props.click,
scrollX: props.scrollX
});
// 是否派发滚动事件
if (props.listenScroll) {
scroll.on("scroll", pos => {
setupContext.emit("scroll", pos);
});
}
// 是否派发滚动到底部事件,用于上拉加载
if (props.pullup) {
scroll.on("scrollEnd", () => {
// 滚动到底部
if (scroll.y <= scroll.maxScrollY + 50) {
setupContext.emit("scrollToEnd");
}
});
}
// 是否派发顶部下拉事件,用于下拉刷新
if (props.pulldown) {
scroll.on("touchend", pos => {
// 下拉动作
if (pos.y > 50) {
setupContext.emit("pulldown");
}
});
}
// 是否派发列表滚动开始的事件
if (props.beforeScroll) {
scroll.on("beforeScrollStart", () => {
setupContext.emit("beforeScroll");
});
}
};
const disable = () => {
// 代理better-scroll的disable方法
scroll?.disable();
};
const enable = () => {
// 代理better-scroll的enable方法
scroll?.enable();
};
const refresh = () => {
// 代理better-scroll的refresh方法
scroll?.refresh();
};
const scrollTo = () => {
// 代理better-scroll的scrollTo方法
scroll?.scrollTo.apply(scroll, arguments);
};
const scrollToElement = () => {
// 代理better-scroll的scrollToElement方法
scroll?.scrollToElement.apply(scroll, arguments);
};
onMounted(() => {
nextTick(() => {
initScroll();
});
});
return {};
}
};
</script>
<style lang="scss" type="text/scss" scoped>
.scroll-wrapper {
width: 100%;
height: 100%;
overflow: hidden;
overflow-y: scroll;
}
</style>
未完待续。。。
由于时间关系先写到这里了,明天还要上班,这个项目仅仅只是用来练习vue3用的小demo,里面还有部分的🐛,随时欢迎各位小伙伴提出分享意见!github代码点击这里。
❤️ 看完三件事: 如果你觉得这篇内容对你挺有启发,我想邀请你帮我个小忙:
点赞,让更多的人也能看到这篇内容,也方便自己随时找到这篇内容(收藏不点赞,都是耍流氓 -_-); 关注我们,不定期分好文章; 也看看其它文章;
🎉欢迎你把自己的学习体会写在留言区,与我和其他同学一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。