说明:在开发过程中,最新功能请查阅编号最大的分支,项目完成后将统一合并到master分支;
创建工程
全局安装@vue/cli(老版本的官方脚手架为vue-cli)
// 安装最新版本的@vue/cli 实际为5.x
npm install -g @vue/cli
使用@vue/cli创建vue工程
//默认的包管理工具是pnpm(有的磁盘格式不支持pnpm 此时可以手动将包管理工具设置为npm)
vue create myapp
//创建工程时将包管理工具设置为npm
vue create myapp -m npm
//如果磁盘格式不支持 但一定要使用pnpm
//可以由支持的队友创建空白工程 然后再克隆下来 依然能正确使用pnpm
初识化本地仓库并关联远程仓库
cd myapp
//初始化本地仓库
git init
//关联远程仓库
git remote add origin xxx.git
进行首次提交
git add .
//首次提交会形成一个默认的master分支
git commit -m "first commit"
//将本地分支推送远程
git push -u origin master
搭建路由
路由规划
- 首页 /
- 登录页 /login
- 详情页 /goods/:gid
- 购物车页 /cart
- 关于页 /about
- 404页 /notfound
- demos页 /demos
- TodoList /demos/todolist
git checkout feat1-router
import { createRouter, createWebHistory } from "vue-router";
// import Home from '@/views/Home/index.vue'
const Home = () => import("@/views/Home.vue");
const Login = () => import("@/views/Login.vue");
const Cart = () => import("@/views/Cart.vue");
const GoodsDetail = () => import("@/views/GoodsDetail.vue");
const Demos = () => import("@/views/demos/index.vue");
const TodoList = () => import("@/views/demos/TodoList.vue");
const NotFound = () => import("@/views/NotFound.vue");
const About = () => import(/* webpackChunkName: "about" */ "@/views/About.vue");
const routes = [
// 访问/即访问Home组件
// name用于编程式导航(即用代码跳转路由无需用户操作)例如用户未登录时访问【个人中心】一脚踹到登录页
{
path: "/",
name: "home",
component: Home,
},
/* 访问/home 重定向到'/' */
{ path: "/home", redirect: "/" },
{
path: "/about",
name: "about",
component: About,
},
{
path: "/login",
name: "login",
component: Login,
},
{
path: "/cart",
name: "cart",
component: Cart,
},
/*
动态路由
商品详情页:/goods/123 :gid指代任意id
(\\d+) 正则描述gid的构成 即1~多个数字构成
/goods/abc无法命中该路由 因为abc不符合gid的正则描述
Vue2组件获取路由动态参数:
this.$route.params.gid
Vue3组件获取路由动态参数:
import { useRoute } from 'vue-router'
const route = useRoute()
route.params.gid
*/
{
path: "/goods/:gid(\\d+)",
name: "goodsDetail",
component: GoodsDetail,
},
{ path: "/demos", redirect: "/demos/todolist" },
/*
嵌套路由
*/
{
path: "/demos",
name: "demos",
component: Demos,
/* 定义/demos下的二级路由 */
children: [
// /demos/todolist
{
path: "todolist",
component: TodoList,
},
],
},
/*
上述路由如果一个都无法命中 则最终命中404
(.*)* 任意字符出现任意多次 这样的片段出现任意多段
*/
{
path: "/:path(.*)*",
name: "notfound",
component: NotFound,
},
];
const router = createRouter({
// process为全局变量 代表当前进程实例 env是process下的环境变量
// BASE_URL为当前部署的主机名 localhost:8080
history: createWebHistory(process.env.BASE_URL),
routes,
});
export default router;
git add .
git commit -m "完成路由搭建"
git push -u origin feat1-router
首页静态布局
子曰:天将降大任于斯人也,必先打开某别人家的页面,调其控制台,删其script,删其body内的style,另存其页面,所以open with live-server,增益其所不能!
组件拆分
此处省略一万字,请自行脑补; 原则是最大化的复用组件,每个组件自己的DOM结构放到自己的template节点中,自己的样式放到自己的style节点中,数据逻辑互动放到自己的script节点中
首页数据渲染
- 分析京东的异步数据(通常的页面异步数据来源:Ajax + Jsonp),发现其异步数据来源于jsonp
- 控制台分析数据,手动保存相关数据的json文件
- 组件内导入json数据为JS数据
// 完整导入整个JSON数据为对象/数组
import feedsData from '@/mock/feedsData.json'
// 导入JSON中的一个key的值
import {data} from '@/mock/feedsData.json'
const dataList = data
- 执行列表渲染
<!-- 列表渲染内部的item含有多分支时 外层使用幽灵标签放置v-for -->
<!-- 原理:DOM层级越简单 则diff起来性能越高 -->
<!-- {bigImg,bigLink,name,desc,item,smallLink,productId,groupId}对item的解构 -->
<!-- :attrName等价于v-bind:attrName 所有v-xxx="value"的值均为JS表达式 -->
<!-- 列表渲染中key的作用:对item元素进行迅速定量 key相同则视为相同的DOM结构(重新渲染时直接复用缓存) key不相同直接重新绘制 -->
<template v-for="({bigImg,bigLink,name,desc,item,smallLink,productId,groupId}, index) in dataList" :key="productId">
<!-- 前两个item为大频道 -->
<div v-if="index < 2" :class="`channels_item channels_item_${index+1}`">
<a
class="channels_item_link"
:href="bigLink"
target="_blank"
clstag="h|keycount|chan|fixed#01_null"
aria-label="京东图书"
>
<div class="lazyimg lazyimg_loaded channels_item_img">
<img
:src="bigImg"
class="lazyimg_img"
/>
</div>
</a>
</div>
<!-- 后续item为小频道 -->
<div v-else :class="`channels_item channels_item_${index+1}`">
<a
class="channels_item_title"
:href="getSmallLink(smallLink,productId,groupId,item)"
target="_blank"
clstag="h|keycount|chan|bi#01_a"
><span class="channels_item_title_main">{{name}}</span
><span class="channels_item_title_aside"
>{{desc}}</span
></a
>
<div class="channels_item_imgs">
<a class="channels_item_link"
:href="getSmallLink(smallLink,productId,groupId,item)"
target="_blank"
clstag="h|keycount|chan|bi#01_b01"
tabindex="-1"
>
<div class="lazyimg lazyimg_loaded channels_item_img">
<img
:src="`https://img12.360buyimg.com/img/s100x100_${item[0].img}`"
class="lazyimg_img"
/>
</div>
</a>
<a
class="channels_item_link"
:href="getSmallLink(smallLink,productId,groupId,item)"
target="_blank"
clstag="h|keycount|chan|bi#01_b02"
tabindex="-1">
<div class="lazyimg lazyimg_loaded channels_item_img">
<img
:src="`https://img12.360buyimg.com/img/s100x100_${item[1].img}`"
class="lazyimg_img"
/>
</div>
</a>
</div>
</div>
</template>
部署mock数据(json-server)
安装依赖
npm i -g json-server
配置并导出数据
// mockData.js
const hotcate = require('./cookbook-hotcate.json')
const list = require('./cookbook-list.json')
const category = require('./cookbook-category.json')
module.exports = () => {
return {
hotcate,
list,
category
}
}
定义路由
//mockRouter.json
{
"/api/hotcate":"/hotcate",
"/api/list":"/list",
"/api/category":"/category"
}
启动json-server
json-server ./src/mock/mockData.js -r ./src/mock/mockRouter.json -H 0.0.0.0 -p 9000 --watch
在package.json中配置一个快捷启动脚本
"script":{
"mock":"json-server ./src/mock/mockData.js -r ./src/mock/mockRouter.json -H 0.0.0.0 -p 9000 --watch"
}
快速启动mock服务器
npm run mock
浏览器中测试
localhost:9000/api/list
部署mock数据(fast-mock)
注册fastmock账号 fastmock
创建项目
添加GET接口
添加POST接口
添加简单的业务逻辑模拟
以登录时用户名必须为admin且密码必须为123456为例
调试接口
Mock的更多用法请参见 mockjs官网
请求网络数据
详见 axios的一般使用
使用Vuex
vuex结构一览
import { createStore } from "vuex";
import createPersistedState from "vuex-persistedstate";
import { getChannels } from "@/api/channelApi";
import { getFeedsByPage } from "@/api/feedApi";
export default createStore({
/* 全局共享的状态 */
state: {
},
/* 基于一手数据(state)返回二手数据 */
getters: {},
/* 同步修改state时 */
// store.commit(mutation,payload)
mutations: {
// payload即要修改的数据
setChannels(state, payload) {
},
},
/* 当需要异步修改数据时派发一个action */
// store.dispatch('getChannels')
actions: {
//commit用于获取数据成功后提交一个mutation进行数据的同步修改
async getChannels({ commit, state }) {
},
},
/* 模块化管理全局状态 */
modules: {},
/*插件项*/
plugins: [],
});
在Vue3中使用Vuex
获取全局store对象
import { useStore } from 'vuex'
export default {
setup () {
const store = useStore()
}
}
发起异步修改数据的请求
store.dispatch('asyncIncrement',payload)
发起同步修改数据的请求
store.commit('increment',payload)
Vuex的模块化
const user = {
// 将来通过模块名访问模块下的state/getters/muations/actions...
namespaced: true,
state: () => ({
username: null,
// password: null,
}),
getters: {
isLoggedIn(state) {
return state.username !== null;
},
},
mutations: {
setUsername(state, payload) {
state.username = payload;
},
},
actions: {
logout({ commit, state, getters }) {
console.log("logout");
commit("setUsername", null);
},
},
};
export default user;
中央仓库定义
import user from "./modules/user";
export default createStore({
// ...
/* 模块化管理全局状态 */
modules: {
user,
},
// ...
});
映射子模块中的数据和功能
export default {
computed: {
/* 将state中的数据映射为组件内的计算属性 */
...mapState({
// 将user模块中的username映射为当前组件的计算属性username
username:state => state.user.username,
}),
...mapGetters({
// 将user模块下的getter项目isLoggedIn映射为计算属性usernameuserHasLoggedIn
userHasLoggedIn:"user/isLoggedIn",
})
},
methods:{
// 将user模块下的logout的派发 映射为当前组件内的logout方法
...mapActions({
logout:"user/logout"
}),
sayHello(){
console.log("hello");
}
},
}
数据持久化
安装插件
npm i vuex-persistedstate
给store配置持久化插件
import { createStore } from "vuex";
import createPersistedState from "vuex-persistedstate";
const store = createStore({
// ...
plugins: [createPersistedState()],
});
vuex-persistedstate将vuex数据缓存在localStorage中,不再走内存,因此通过devtools无法再追踪数据的更改;
首页交互
边栏与顶栏的联动
注意到顶部通栏TitleBar与侧边栏Elevator之间有如下联动关系:顶栏固定时边栏也随之固定,顶栏隐藏时,边栏也随之隐藏;而顶栏出现与否取决于纵向滚动是否超过某个特定阈值;
修改Home.vue如下:
mounted() {
window.addEventListener("scroll",this.checkTitleBar);
},
beforeUnmount() {
window.removeEventListener("scroll", this.checkTitleBar);
},
data() {
return {
fixTitleBar: false,
};
},
methods: {
checkTitleBar(){
// 监听st
if (document.documentElement.scrollTop > 700) {
// 通栏出现+边栏固定
this.fixTitleBar = true;
} else {
// 通栏出现+边栏固定
this.fixTitleBar = false;
}
},
},
同时,将fixTitleBar作为props注入到JHeader与Elevator这两个子组件,具体操控着顶栏与边栏是否显式:
<template>
<div class="mod_container">
...
<!-- JHeader -->
<JHeaderVue :fixTitleBar="fixTitleBar"></JHeaderVue>
...
</div>
<div id="app">
<div id="J_app">
...
<ElevatorVue :fix="fixTitleBar"></ElevatorVue>
</div>
</div>
...
</template>
在子组件中声明接收这一props,并应用于模板中
Elevator.vue
<script setup>
const { fix } = defineProps({
fix: Boolean,
});
</script>
<div class="elevator elevator_recommend" :class="{ elevator_fix: fix }">
JHeader.vue
<script setup>
const { fixTitleBar } = defineProps({
fixTitleBar: Boolean,
});
</script>
<div id="search" :class="{ 'search-fix': fixTitleBar }" >
最后要在index.html中声明一下动画容器
<html lang="" class="csstransitions cssanimations">
分页加载
修改fastmock数据接口
//奇数页数据接口定义
/goods/list/:page(\d*[13579])
//偶数页数据接口定义
/goods/list/:page(\d*[24680])
监听特定DOM元素进入窗口
此处我们用到一个JS原生APIIntersectionObserver
原理详见 面试官系列
src/utils/interactionUtil.js
export function setIntersectionObserver(
targetSelector,
onEnter,
threshold = 1.0
) {
const options = {
// 表示重叠面积占被观察者的比例,从 0 - 1 取值,
// 1 表示完全被包含
threshold,
// root: document.querySelector("#app"), // 必须是目标元素的父级元素
// root: null, // 默认父元素为当前窗口
};
const callback = function (entries, observer) {
// console.log("callback called,entries=", entries);
// console.log("callback called,observer=", observer);
entries.forEach((entry) => {
// console.log(
// "time",entry.time, // 触发的时间
// "rootBounds",entry.rootBounds, // 根元素的位置矩形,这种情况下为视窗位置
// "boundingClientRect",entry.boundingClientRect, // 被观察者的位置举行
// "intersectionRect",entry.intersectionRect, // 重叠区域的位置矩形
// "intersectionRatio",entry.intersectionRatio, // 重叠区域占被观察者面积的比例(被观察者不是矩形时也按照矩形计算)
// "target",entry.target, // 被观察者
// );
if (entry.intersectionRatio >= threshold) {
// console.log("开始加载下一页...");
onEnter && onEnter();
}
});
};
const observer = new IntersectionObserver(callback, options);
const target = document.querySelector(targetSelector);
observer.observe(target);
}
Vuex新增加载下一页功能
state: {
channels: [],
feeds: [],
// 每页商品条数
feedsPageSize: 15,
},
getters: {
/* 获得下一页商品的页码 */
getNextPage(state) {
return state.feeds.length / state.feedsPageSize + 1;
},
},
actions: {
...
/* 如果明令加载下一页 又或者商品数据为空时 联网进行加载 否则使用缓存数据 */
async loadNextPage({ commit, state, getters }, next) {
if (next || !state.feeds.length) {
const page = getters.getNextPage;
console.log("联网加载商品数据", page);
const { data } = await getFeedsByPage(page);
commit("addFeeds", data);
} else {
console.log("本地缓存中加载商品数据");
}
},
},
商品组件对外暴露加载下一页方法
src/components/Home/Feeds.vue
const loadNextPage = (next = true) => store.dispatch("loadNextPage", next);
defineExpose({
loadNextPage,
});
/* 组件挂载时联网获取商品数据 */
onMounted(() => {
loadNextPage(false);
});
首页触底时触发加载下一页
Feeds子组件绑定一个ref
<FeedsVue ref="feedsRef"></FeedsVue>
当首页滚动见底,露出【三个点】时,触发加载下一页逻辑 src/views/Home.vue
mounted() {
...
setInteractionObserver(".more2_loading", this.$refs.feedsRef.loadNextPage);
},
楼层导航
定义楼层插槽
这里我们用到具名插槽
Elevator.vue的模板
<template>
<div class="elevator elevator_recommend" :class="{ elevator_fix: fix }">
<!-- 矢量图标定义区 -->
<svg
class="svgcont"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
style="display: none"
>
<defs>
<!-- 铅笔矢量图 -->
<symbol id="icon_timline" viewBox="0 0 16 16">
<path d="M12.986 5.582a.505.505 ..." ></path>
</symbol>
<!-- 反馈矢量图 -->
<symbol id="icon_feedback" viewBox="0 0 16 16">
<path d="M1.4,15l0-1l14,0v1H1.4z M2.5,..."></path>
</symbol>
<!-- 让父组件自己定义矢量图 -->
<slot name="svg"></slot>
</defs>
</svg>
<!-- 楼层插槽 -->
<ul class="elevator_list">
<slot></slot>
</ul>
<!-- 返回顶部按钮 -->
<a
class="elevator_totop"
href="javascript: void(0);"
clstag="h|keycount|core|elvt_07"
tabindex="-1"
aria-hidden="true"
@click="toTop"
><span class="elevator_totop_icon"></span
><span class="elevator_totop_txt">顶部</span></a
>
</div>
</template>
父组件插入内容
父组件插入两块内容,矢量图定义和楼层定义 父组件Home.vue中使用Elevator子组件
<!-- Elevator -->
<ElevatorVue :fix="fixTitleBar" @toTop="toTop">
<!-- 插入矢量图定义具体内容 -->
<template #svg>
<!-- 钢笔矢量图 -->
<symbol id="icon_pen" viewBox="0 0 16 16">
<path
d="M12.986 5.582a.505.505 0 0 0..."
></path>
</symbol>
<!-- 购物车矢量图 -->
<symbol id="icon_cart" viewBox="0 0 16 16">
<path
d="M1.4,15l0-1l14,0v1H1.4z M2.5,..."
></path>
</symbol>
</template>
<!-- 插入楼层具体内容 -->
<template
v-for="({ name, imgSrc, link, icon, floorRef }, index) in floors"
:key="index"
>
<li
v-if="name && !link"
class="elevator_item"
@click="navToFloor(floorRef)"
>
<a
class="elevator_lk"
href="javascript:void(0);"
clstag="h|keycount|core|elvt_01"
tabindex="-1"
aria-hidden="true"
>
<div>
<span class="elevator_lk_bg"></span>
<span class="elevator_lk_txt">{{ name }}</span>
</div>
</a>
</li>
<!-- 图片楼层 -->
<li v-else-if="imgSrcs" class="elevator_item">
<a
id="imgFloor"
class="elevator_lk elevator_promotional"
href="javascript:void(0);"
clstag="h|keycount|core|elvt_08"
tabindex="-1"
aria-hidden="true"
@mouseover="imgFloorHovered = true"
@mouseout="imgFloorHovered = false"
@click="navigateToFloor(floor)"
>
<img :src="imgFloorHovered ? imgSrcs[1] : imgSrcs[0]" alt=""/>
</a>
</li>
<li v-else class="elevator_item">
<a
class="elevator_lk elevator_lk2"
:href="link"
target="_blank"
clstag="h|keycount|core|elvt_06"
><span class="elevator_lk_bg"></span>
<svg>
<use :xlink:href="icon"></use>
</svg>
<span class="elevator_lk_txt">{{ name }}</span>
</a>
</li>
</template>
</ElevatorVue>
楼层的数据定义
此处楼层定义我们使用数据配置
/* 初始化楼层 */
this.floors = [
{
name: "京东秒杀",
floorRef: this.$refs.secRef,
},
{
imgSrc: ["/d0287bcff19a17e3.jpg", "/b60d63729c0710c1.jpg"],
floorRef: this.$refs.activityRef,
},
{
name: "特色优选",
floorRef: this.$refs.specialRef,
},
{
name: "频道广场",
floorRef: this.$refs.channelRef,
},
{
name: "为你推荐",
floorRef: this.$refs.feedsRef,
},
{
name: "客服",
link: "https://jdcs.jd.com/chat/index.action?venderId=1&entry=jd_web_jimi_jdhome",
icon: "#icon_feedback",
},
{
name: "反馈",
link: "https://wj-dongjian.jd.com/q/61961a0a457e4000a3085f78",
icon: "#icon_timline",
},
];
楼层导航方法
Elevator.vue
/* 返回顶部 */
const toTop = (e) => yScrollTo(0, 500);
/* 滚动到指定楼层 */
const toFloor = (floorRef) => {
// console.log("floorRef.$el.offsetTop", floorRef.$el.offsetTop);
yScrollTo(
document.documentElement.scrollTop +
floorRef.$el.getBoundingClientRect().top -
50,
300
);
};
/* 将滚动到指定楼层的方法暴露给父组件 */
defineExpose({
toFloor,
});
滚动工具函数实现
src/utils/scrollUtil.js
let timer = null;
export function yScrollTo(y, timespan = 1000) {
if (timer) clearInterval(timer);
let current = document.documentElement.scrollTop;
let step = (y - current) / (timespan / 40);
timer = setInterval(() => {
if (Math.abs(y - current) >= Math.abs(step)) {
current += step;
window.scrollTo(0, current);
} else {
clearInterval(timer);
timer = null;
window.scrollTo(0, y);
}
}, 40);
}
PlanB:父组件直接给楼层组件注入数据
Home.vue中部署Elevator
<!-- Elevator -->
<ElevatorVue
:fix="fixTitleBar"
:floors="floors"
:scrollSpeed="{ toFloor: 200, toTop: 500 }"
></ElevatorVue>
Elevator根据父组件输入的数据自行渲染模板
<template>
<div class="elevator elevator_recommend" :class="{ elevator_fix: fix }">
<svg
class="svgcont"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
style="display: none"
>
<defs>
<symbol id="icon_timline" viewBox="0 0 16 16">
<path
d="M12.986 5.582a.505.505 0 0 0..."
></path>
</symbol>
<symbol id="icon_feedback" viewBox="0 0 16 16">
<path
d="M1.4,15l0-1l14,0v1H1.4z M2.5,..."
></path>
</symbol>
</defs>
</svg>
<ul class="elevator_list">
<template
v-for="({ name, imgSrc, link, icon, floorRef }, index) in floors"
:key="index"
>
<li
v-if="name && !link"
class="elevator_item"
@click="navToFloor(floorRef)"
>
<a
class="elevator_lk"
href="javascript:void(0);"
clstag="h|keycount|core|elvt_01"
tabindex="-1"
aria-hidden="true"
>
<div>
<span class="elevator_lk_bg"></span>
<span class="elevator_lk_txt">{{ name }}</span>
</div>
</a>
</li>
<li
v-else-if="imgSrc"
class="elevator_item"
@click="navToFloor(floorRef)"
>
<a
class="elevator_lk elevator_promotional"
href="javascript:void(0);"
clstag="h|keycount|core|elvt_08"
tabindex="-1"
aria-hidden="true"
@mouseover="imgHovered = true"
@mouseout="imgHovered = false"
>
<img v-if="imgHovered" :src="imgSrc[1]" alt="" />
<img v-else :src="imgSrc[0]" alt="" />
</a>
</li>
<li v-else class="elevator_item">
<a
class="elevator_lk elevator_lk2"
:href="link"
target="_blank"
clstag="h|keycount|core|elvt_06"
><span class="elevator_lk_bg"></span>
<svg>
<use :xlink:href="icon"></use>
</svg>
<span class="elevator_lk_txt">{{ name }}</span>
</a>
</li>
</template>
</ul>
<a
class="elevator_totop"
href="javascript: void(0);"
clstag="h|keycount|core|elvt_07"
tabindex="-1"
aria-hidden="true"
@click="toTop"
>
<span class="elevator_totop_icon"></span>
<span class="elevator_totop_txt">顶部</span>
</a>
</div>
</template>
<script setup>
import { reactive, toRefs } from "vue";
import { yScrollTo } from "@/utils/scrollUtil";
const { fix, floors, scrollSpeed } = defineProps({
fix: Boolean,
floors: Array,
scrollSpeed: Object,
});
const { imgHovered } = toRefs(
reactive({
imgHovered: false,
})
);
const navToFloor = (ref) => {
// console.log("navToFloor", ref);
// window.scrollTo(0,document.documentElement.scrollTop + ref.$el.getBoundingClientRect().top - 50)
yScrollTo(
document.documentElement.scrollTop +
ref.$el.getBoundingClientRect().top -
50,
scrollSpeed.toFloor || 1000
);
};
const toTop = () => {
yScrollTo(0, scrollSpeed.toTop || 500);
};
</script>
<style lang="scss" scoped>
.elevator_promotional:hover img {
z-index: 2;
}
</style>
PlanB实现了更好的封装性和复用性
将封装好的组件发布到NPM
请移步 Vue发布自定义组件库
倒计时
倒计时逻辑有广泛的使用场景,我们用自定义hook撸它
准备计算时差的工具轮子
datetimeUtil.js
/* getLengthedNumberStr(9,3) => 009 */
function getLengthedNumberStr(num, len) {
let ret = "";
for (let i = 0; i < len - (num + "").length; i++) {
ret += "0";
}
ret += num;
return ret;
}
export function getTimeDiffer(d1, d2) {
const differMillis = d2 - d1;
const hours = parseInt(differMillis / (3600 * 1000));
const minutes = parseInt((differMillis % (3600 * 1000)) / (60 * 1000));
const seconds = parseInt((differMillis % (60 * 1000)) / 1000);
return {
hours: getLengthedNumberStr(hours, 2),
minutes: getLengthedNumberStr(minutes, 2),
seconds: getLengthedNumberStr(seconds, 2),
};
}
撸一个自定义hook
名曰useCountDown:
- 构建hoursLeft,minutesLeft,secondsLeft三个响应式数据
- 最终返回这三个响应式数据
- 在调用组件挂载时创建定时器,每秒重新一下上述三个响应式数据
- 在调用组件卸载时移除定时器,避免内存泄露 具体实现如下
import { reactive, toRefs } from "vue";
import { onMounted, onBeforeUnmount, onUnmounted } from "vue";
import { getTimeDiffer } from "@/utils/datetimeUtil";
function useCountDown(targetDate) {
/* 构建响应式数据 */
const { hoursLeft, minutesLeft, secondsLeft } = toRefs(
reactive({
hoursLeft: "00",
minutesLeft: "00",
secondsLeft: "00",
})
);
/* 让响应式数据自动变化 */
let timer;
/* 组件一挂载即创建定时器开始倒计时 动态修改hoursLeft,minutesLeft,secondsLeft */
onMounted(() => {
console.log("onMounted");
timer = setInterval(() => {
const { hours, minutes, seconds } = getTimeDiffer(new Date(), targetDate);
/* 更新响应式数据 */
hoursLeft.value = hours;
minutesLeft.value = minutes;
secondsLeft.value = seconds;
}, 1000);
});
/* 组件卸载前 清除倒计时定时器 */
onBeforeUnmount(() => {
console.log("onBeforeUnmount");
clearInterval(timer);
console.log("定时器已清除");
});
/* 将响应式数据丢还调用者 */
return { hoursLeft, minutesLeft, secondsLeft };
}
export default useCountDown;
在组件中使用自定义hook
最后,调用它的组件像个傻瓜一样使用它
<script setup>
import useCountDown from "@/hooks/useCountDown";
const { hoursLeft, minutesLeft, secondsLeft } = useCountDown(
new Date(2022, 5, 10, 18)
);
</script>
得到这三个响应式数据在视图上渲染即可
轮播图
这里我们使用 ElementPlus中的走马灯组件实现
具体参见 element-plus.gitee.io/zh-CN/compo…
登录与鉴权
登录页开发
UI上我们依然使用ElementPlus组件库下的Form组件实现如下页面效果
我们基于以下案例进行修改 element-plus.gitee.io/zh-CN/compo…
- 在提交时访问后台的登录接口
- 成功或失败的结果使用Message组件进行提示
- 登录成功时将用户数据写入Vuex全局状态仓库并跳转首页
代码实现如下:
src/views/Login.vue
const submitForm = (formEl) => {
if (!formEl) return;
formEl.validate((valid) => {
if (valid) {
console.log("submit!", ruleForm.age, ruleForm.pass);
/* 登录 */
login({
username: ruleForm.age,
password: ruleForm.pass,
})
.then(({ data }) => {
ElMessage({
showClose: false,
message: data.msg,
type: "success",
});
if (data.code === 1) {
store.commit("user/setUser", {
username: ruleForm.age,
password: ruleForm.pass,
});
router.push({
name: "home",
// query: {
// a: 123,
// },
});
}
})
.catch((err) => {
console.log("err=", err);
ElMessage({
showClose: true,
message: err.toString(),
type: "error",
});
});
} else {
console.log("error submit!");
return false;
}
});
};
编程式导航
// 直接跳转目标地址
router.push("/");
// 带配置地跳转目标地址
router.push({
// 定义目标地址
path: "/",
//携带查询参数(queryString,searchParams)
//username:xxx,age:18
query: { username: ruleForm.age, age: 18 },
//#team
hash: "#team",
});
router.push({
// 通过路由表中的name定义跳转目标页
name: "home",
// query: { username: ruleForm.age, age: 18 },
// hash: "#team",
});
路由守卫
全局路由守卫
/* 全局守卫 */
// 所有跳转前执行
router.beforeEach((to, from, next) => {
console.log("router.beforeEach", from, to);
// if (to.name === "about" && !store.state.loggedIn) next({ name: "login" });
// 目标路由的元信息中含有requireLogin 且vuex中的loggedIn不成立 则一脚踹到login
if (to.meta.requireLogin && !store.state.loggedIn) next({ name: "login" });
// 否则正常放行
else next();
});
// 全局后置守卫:每次跳转成功后执行(用于统计等作业)
router.afterEach((to, from, failure) => {
// if (!failure) sendToAnalytics(to.fullPath);
console.log("router.afterEach", from, to);
});
路由独享的守卫
function loginCheck(to) {
console.log("loginCheck to=",to);
/* 如果用户已登录 直接放行 */
if(store.state.loggedIn){
console.log("用户已登录");
return true;
}
/* 否则 一脚踹到登录页 */
console.log("用户未登录");
return { name:'login' };
}
{
path: "/about",
name: "about",
component: About,
// 路由元信息 to.meta.requireLogin
meta: { requireLogin: true }
// 在进入about页前行执行loginCheck
beforeEnter: [loginCheck],
}
组件内守卫
const UserDetails = {
template: `...`,
beforeRouteEnter(to, from) {
// 在渲染该组件的对应路由被验证前调用
// 不能获取组件实例 `this` !
// 因为当守卫执行时,组件实例还没被创建!
},
beforeRouteUpdate(to, from) {
// 在当前路由改变,但是该组件被复用时调用
// 举例来说,对于一个带有动态参数的路径 `/users/:id`,在 `/users/1` 和 `/users/2` 之间跳转的时候,
// 由于会渲染同样的 `UserDetails` 组件,因此组件实例会被复用。而这个钩子就会在这个情况下被调用。
// 因为在这种情况发生的时候,组件已经挂载好了,导航守卫可以访问组件实例 `this`
},
beforeRouteLeave(to, from) {
// 在导航离开渲染该组件的对应路由时调用
// 与 `beforeRouteUpdate` 一样,它可以访问组件实例 `this`
},
}
export default {
setup() {},
/* 进入当前页面之前回调 */
// 没有使用next参数 则return true/false代表放行/不放行
// 使用了next之后就只能
// next(newRoute) 一脚踹到新页面
// 或next() 直接放行
beforeRouteEnter(to, from, next) {
console.log("loggedIn", store.state.loggedIn);
// 如果未登录 一脚踹到登录页
if (!store.state.loggedIn) {
next({ name: "login" });
}
// 否则直接放行
else{
next()
}
},
};
全流程梳理
- 登录成功后,将user子模块中的username设置为用户名(附带效果是isLoggedIn也会被连带地修改为true)+ 跳转首页
/* 提交mutation 在全局vuex中写入username */
store.commit("user/setUsername", 'admin');
//跳转首页
router.push({name: "home"});
- 数据仓库中动态记录用户是否已登录
getters: {
// 一旦username被设置具体的值 则视为用户已登录
isLoggedIn(state) {
return state.username !== null;
},
},
- 组件内订阅user模块下的状态
username以及二手状态isLoggedIn
computed: {
/* 将state中的数据映射为组件内的计算属性 */
...mapState({
// 将user模块中的username映射为当前组件的计算属性username
username:state => state.user.username,
}),
...mapGetters({
// 将user模块下的getter项目isLoggedIn映射为计算属性usernameuserHasLoggedIn
userHasLoggedIn:"user/isLoggedIn",
})
},
- 组件根据【是否已登录】进行条件渲染
<!-- 如果未登录 -->
<template v-if="!userHasLoggedIn">
<a href="/login" class="link-login">你好,请登录</a>
<a href="/login" class="link-regist style-red">免费注册</a>
</template>
<!-- 如果已登录 -->
<template v-else>
<a href="javascript:;" class="link-login">欢迎回来 {{username}}</a
>
<!-- 点击登出 -->
<a @click="logout" href="javascript:;" class="link-regist style-red">登出</a>
</template>
- vuex中添加了登出功能
// vue3用法 store.dispatch("user/logout")
// ...mapActions({logout:"user/logout"}),
actions: {
logout({ commit, state, getters }) {
console.log("logout");
//提交mutation将username值为空
commit("setUsername", null);
},
},
- 将登出功能映射为组件内的logout方法
methods:{
// 将user模块下的logout的派发 映射为当前组件内的logout方法
...mapActions({
logout:"user/logout"
}),
},
- 用户点击登出
<!-- 点击登出 -->
<a @click="logout" href="javascript:;" class="link-regist style-red">登出</a>