Vue3开发京东商城纪实(持续更新)

1,051 阅读7分钟

项目源码地址 gitee.com/steveouyang…

说明:在开发过程中,最新功能请查阅编号最大的分支,项目完成后将统一合并到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

创建项目

image.png

添加GET接口

image.png

添加POST接口

image.png

添加简单的业务逻辑模拟

以登录时用户名必须为admin且密码必须为123456为例

image.png 调试接口

image.png 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&amp;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组件实现如下页面效果

image.png

我们基于以下案例进行修改 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>&nbsp;&nbsp;
  <a href="/login" class="link-regist style-red">免费注册</a>
</template>

<!-- 如果已登录 -->
<template v-else>
  <a href="javascript:;" class="link-login">欢迎回来 {{username}}</a
  >&nbsp;&nbsp;

  <!-- 点击登出 -->
  <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>