最近正在学习vue3小兔鲜
下面是学习笔记
建议大家先去看我第一篇小兔鲜的文章,强烈建议,非常建议,十分建议,从头开始看更完整。

布局模块

路由设计

**目标:**能够理解小兔鲜项目中的路由设计

内容:

一级路由有登录 Login 和布局容器 Layout

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-v3gF56aX-1668072603824)(media/image-20211229174027074.png)]

路径组件(功能)嵌套级别
/首页布局容器 Layout1级
/login登录1级
/category/:id​分类2级
/product/:id商品详情2级
/cart购物车2级
/checkout填写订单2级
/pay支付2级
/pay/result支付结果2级

配置 eslint 规则

/* eslint-env node */
require('@rushstack/eslint-patch/modern-module-resolution');

module.exports = {
  root: true,
+  // 自定义规则
+  rules: {
+    // vue组件必须用组合词: 关闭
+    'vue/multi-word-component-names': 'off',
+  },
  extends: [
    'plugin:vue/vue3-essential',
    'eslint:recommended',
    '@vue/eslint-config-typescript/recommended',
    '@vue/eslint-config-prettier',
  ],
  env: {
    'vue/setup-compiler-macros': true,
  },
};

配置路由

**目标:**能够配置小兔鲜儿项目中的路由

核心代码:

  • 创建组件views/Layout/index.vue
<script setup lang="ts">
// 注意 setup 和 lang="ts" 都是需要的,不能省略
</script>

<template>
  <div>layout布局容器组件</div>
</template>

<style lang="less" scoped></style>

  • 创建组件views/Login/index.vue
<script setup lang="ts">
// 注意 setup 和 lang="ts" 都是需要的,不能省略
</script>

<template>
  <div>login登录页组件</div>
</template>

<style lang="less" scoped></style>

  • 创建文件router/index.ts
import { createRouter, createWebHashHistory } from "vue-router";

const router = createRouter({
  history: createWebHashHistory(),
  routes: [
    {
      path: "/",
      component: () => import("@/views/Layout/index.vue"),
    },
    {
      path: "/login",
      component: () => import("@/views/Login/index.vue"),
    },
  ],
});

export default router;

  • main.ts中导入
import { createApp } from 'vue'
import App from './App.vue'
import 'normalize.css'
import '@/assets/styles/common.less'
+ import router from './router'

const app = createApp(App)

+ app.use(router)
app.mount('#app')

  • 修改App.vue,预留路由出口
<template>
  <ul>
    <li>
      <RouterLink to="/">布局容器</RouterLink>
    </li>
    <li>
      <RouterLink to="/login">登录页</RouterLink>
    </li>
  </ul>
  <RouterView />
</template>
  • 浏览器查看效果

注意事项:

  • script 标签的 setup 和 lang=“ts” 再 TS 项目中都是需要的,不能省略
  • vue3 项目 index.vue 文件后缀名需要补全

Layout布局-顶部通栏

**目标:**能够完成Layout组件的顶部通栏布局

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-sFBuqXxT-1668072603840)(media/image-20211229175807388.png)]

核心步骤:

看不到字体图标?

  • index.html 引入字体图标文件
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <link rel="stylesheet" href="//at.alicdn.com/t/font_2143783_iq6z4ey5vu.css" />
<title>小兔鲜儿</title>

静态结构

  • 新建头部导航组件

Layout/components/app-topnav.vue

<script setup lang="ts"></script>

<template>
  <nav class="app-topnav">
    <div class="container">
      <ul>
        <li>
          <a href="javascript:;"><i class="iconfont icon-user"></i>黑马先锋</a>
        </li>
        <li><a href="javascript:;">退出登录</a></li>
        <li><a href="javascript:;">请先登录</a></li>
        <li><a href="javascript:;">免费注册</a></li>
        <li><a href="javascript:;">我的订单</a></li>
        <li><a href="javascript:;">会员中心</a></li>
        <li><a href="javascript:;">帮助中心</a></li>
        <li><a href="javascript:;">关于我们</a></li>
        <li>
          <a href="javascript:;"><i class="iconfont icon-phone"></i>手机版</a>
        </li>
      </ul>
    </div>
  </nav>
</template>

<style scoped lang="less">
.app-topnav {
  background: #333;
  ul {
    display: flex;
    height: 53px;
    justify-content: flex-end;
    align-items: center;
    li {
      a {
        padding: 0 15px;
        color: #cdcdcd;
        line-height: 1;
        display: inline-block;
        i {
          font-size: 14px;
          margin-right: 2px;
        }
        &:hover {
          color: @xtxColor;
        }
      }
      ~ li {
        a {
          border-left: 2px solid #666;
        }
      }
    }
  }
}
</style>

3)在 src/views/Layout.vue 中导入使用

<script setup lang="ts">
import AppTopnav from "./components/app-topnav.vue";
</script>

<template>
  <AppTopnav />
</template>

<style lang="less" scoped></style>


Layout布局-头部布局

**目标:**能够完成Layout组件的头部布局

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-yQkMqWoj-1668072603841)(media/image-20211229180517464.png)]

核心代码

  • Layout/components/ 下新建 app-header.vue 组件,基础布局如下:
<script setup lang="ts">
// 
</script>

<template>
  <header class="app-header">
    <div class="container">
      <h1 class="logo">
        <RouterLink to="/">小兔鲜</RouterLink>
      </h1>
      <ul class="app-header-nav">
        <li class="home">
          <RouterLink to="/">首页</RouterLink>
        </li>
        <li>
          <a href="#">美食</a>
        </li>
        <li>
          <a href="#">餐厨</a>
        </li>
        <li>
          <a href="#">艺术</a>
        </li>
        <li>
          <a href="#">电器</a>
        </li>
        <li>
          <a href="#">居家</a>
        </li>
        <li>
          <a href="#">洗护</a>
        </li>
        <li>
          <a href="#">孕婴</a>
        </li>
        <li>
          <a href="#">服装</a>
        </li>
        <li>
          <a href="#">杂货</a>
        </li>
      </ul>
      <div class="search">
        <i class="iconfont icon-search"></i>
        <input type="text" placeholder="搜一搜" />
      </div>
      <!-- 购物车 -->
      <div class="cart">
        <a class="curr" href="#">
          <i class="iconfont icon-cart"></i>
          <em>2</em>
        </a>
      </div>
    </div>
  </header>
</template>

<style scoped lang="less">
.app-header {
  background: #fff;
  .container {
    display: flex;
    align-items: center;
  }
  .logo {
    width: 200px;
    a {
      display: block;
      height: 132px;
      width: 100%;
      text-indent: -9999px;
      background: url("@/assets/images/logo.png") no-repeat center 18px /
        contain;
    }
  }
  .app-header-nav {
    width: 820px;
    display: flex;
    padding-left: 40px;
    position: relative;
    z-index: 998;
    li {
      margin-right: 40px;
      width: 38px;
      text-align: center;
      a {
        font-size: 16px;
        line-height: 32px;
        height: 32px;
        display: inline-block;
        &:hover {
          color: @xtxColor;
          border-bottom: 1px solid @xtxColor;
        }
      }
    }
  }
  .search {
    width: 170px;
    height: 32px;
    position: relative;
    border-bottom: 1px solid #e7e7e7;
    line-height: 32px;
    .icon-search {
      font-size: 18px;
      margin-left: 5px;
    }
    input {
      width: 140px;
      padding-left: 5px;
      color: #666;
    }
  }
  .cart {
    width: 50px;
    .curr {
      height: 32px;
      line-height: 32px;
      text-align: center;
      position: relative;
      display: block;
      .icon-cart {
        font-size: 22px;
      }
      em {
        font-style: normal;
        position: absolute;
        right: 0;
        top: 0;
        padding: 1px 6px;
        line-height: 1;
        background: @helpColor;
        color: #fff;
        font-size: 12px;
        border-radius: 10px;
        font-family: Arial;
      }
    }
  }
}
</style>


  • src/views/Layout.vue 中导入使用。
<script setup lang="ts">
import AppTopnav from './components/app-topnav.vue'
+ import AppHeader from './components/app-header.vue'
</script>

<template>
  <AppTopnav />
+ <AppHeader />
</template>

<style lang="scss" scoped></style>


看不到图片?

拷贝素材到项目中

  • 🚨在assets/images/中提供图片,在素材中已经提供

nav 组件拆分

因为在后面的吸顶交互里,我们需要复用导航部分,所以这里我们先直接把他拆分出来,拆分成一个单独的组件

  • Layout/compoennts/ 下,新建app-header-nav.vue组件
<script setup lang="ts">
// 
</script>

<template>
  <ul class="app-header-nav">
    <li class="home">
      <RouterLink to="/">首页</RouterLink>
    </li>
    <li>
      <a href="#">美食</a>
    </li>
    <li>
      <a href="#">餐厨</a>
    </li>
    <li>
      <a href="#">艺术</a>
    </li>
    <li>
      <a href="#">电器</a>
    </li>
    <li>
      <a href="#">居家</a>
    </li>
    <li>
      <a href="#">洗护</a>
    </li>
    <li>
      <a href="#">孕婴</a>
    </li>
    <li>
      <a href="#">服装</a>
    </li>
    <li>
      <a href="#">杂货</a>
    </li>
  </ul>
</template>

<style lang="less" scoped>
.app-header-nav {
  width: 820px;
  display: flex;
  padding-left: 40px;
  position: relative;
  z-index: 998;
  li {
    margin-right: 40px;
    width: 38px;
    text-align: center;
    a {
      font-size: 16px;
      line-height: 32px;
      height: 32px;
      display: inline-block;
    }
  }
}
</style>


  • Layout/components/app-header.vue
<script setup lang="ts">
import { RouterLink } from "vue-router";
+ import AppHeaderNav from "./app-header-nav.vue";
</script>


<template>
  <header class="app-header">
    <div class="container">
      <h1 class="logo"><RouterLink to="/">小兔鲜</RouterLink></h1>
+      <AppHeaderNav />
      // ...
    </div>
  </header>
</template>

  • 查看效果

Layout布局-底部

**目标:**能够完成Layout布局的底部布局效果

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Culk46GH-1668072603842)(media/image-20211229182504728.png)]

静态结构

  • Layout/components下,新建/app-footer.vue 组件,基础布局如下:
<script setup lang="ts"></script>

<template>
  <footer class="app_footer">
    <!-- 联系我们 -->
    <div class="contact">
      <div class="container">
        <dl>
          <dt>客户服务</dt>
          <dd><i class="iconfont icon-kefu"></i> 在线客服</dd>
          <dd><i class="iconfont icon-question"></i> 问题反馈</dd>
        </dl>
        <dl>
          <dt>关注我们</dt>
          <dd><i class="iconfont icon-weixin"></i> 公众号</dd>
          <dd><i class="iconfont icon-weibo"></i> 微博</dd>
        </dl>
        <dl>
          <dt>下载APP</dt>
          <dd class="qrcode"><img src="@/assets/images/qrcode.jpg" /></dd>
          <dd class="download">
            <span>扫描二维码</span>
            <span>立马下载APP</span>
            <a href="javascript:;">下载页面</a>
          </dd>
        </dl>
        <dl>
          <dt>服务热线</dt>
          <dd class="hotline">
            400-0000-000 <small>周一至周日 8:00-18:00</small>
          </dd>
        </dl>
      </div>
    </div>
    <!-- 其它 -->
    <div class="extra">
      <div class="container">
        <div class="slogan">
          <a href="javascript:;">
            <i class="iconfont icon-footer01"></i>
            <span>价格亲民</span>
          </a>
          <a href="javascript:;">
            <i class="iconfont icon-footer02"></i>
            <span>物流快捷</span>
          </a>
          <a href="javascript:;">
            <i class="iconfont icon-footer03"></i>
            <span>品质新鲜</span>
          </a>
        </div>
        <!-- 版权信息 -->
        <div class="copyright">
          <p>
            <a href="javascript:;">关于我们</a>
            <a href="javascript:;">帮助中心</a>
            <a href="javascript:;">售后服务</a>
            <a href="javascript:;">配送与验收</a>
            <a href="javascript:;">商务合作</a>
            <a href="javascript:;">搜索推荐</a>
            <a href="javascript:;">友情链接</a>
          </p>
          <p>CopyRight © 小兔鲜儿</p>
        </div>
      </div>
    </div>
  </footer>
</template>

<style scoped lang="less">
.app_footer {
  overflow: hidden;
  background-color: #f5f5f5;
  padding-top: 20px;
  .contact {
    background: #fff;
    .container {
      padding: 60px 0 40px 25px;
      display: flex;
    }
    dl {
      height: 190px;
      text-align: center;
      padding: 0 72px;
      border-right: 1px solid #f2f2f2;
      color: #999;
      &:first-child {
        padding-left: 0;
      }
      &:last-child {
        border-right: none;
        padding-right: 0;
      }
    }
    dt {
      line-height: 1;
      font-size: 18px;
    }
    dd {
      margin: 36px 12px 0 0;
      float: left;
      width: 92px;
      height: 92px;
      padding-top: 10px;
      border: 1px solid #ededed;
      .iconfont {
        font-size: 36px;
        display: block;
        color: #666;
      }
      &:hover {
        .iconfont {
          color: @xtxColor;
        }
      }
      &:last-child {
        margin-right: 0;
      }
    }
    .qrcode {
      width: 92px;
      height: 92px;
      padding: 7px;
      border: 1px solid #ededed;
    }
    .download {
      padding-top: 5px;
      font-size: 14px;
      width: auto;
      height: auto;
      border: none;
      span {
        display: block;
      }
      a {
        display: block;
        line-height: 1;
        padding: 10px 25px;
        margin-top: 5px;
        color: #fff;
        border-radius: 2px;
        background-color: @xtxColor;
      }
    }
    .hotline {
      padding-top: 20px;
      font-size: 22px;
      color: #666;
      width: auto;
      height: auto;
      border: none;
      small {
        display: block;
        font-size: 15px;
        color: #999;
      }
    }
  }
  .extra {
    background-color: #333;
  }
  .slogan {
    height: 178px;
    line-height: 58px;
    padding: 60px 100px;
    border-bottom: 1px solid #434343;
    display: flex;
    justify-content: space-between;
    a {
      height: 58px;
      line-height: 58px;
      color: #fff;
      font-size: 28px;
      i {
        font-size: 50px;
        vertical-align: middle;
        margin-right: 10px;
        font-weight: 100;
      }
      span {
        vertical-align: middle;
        text-shadow: 0 0 1px #333;
      }
    }
  }
  .copyright {
    height: 170px;
    padding-top: 40px;
    text-align: center;
    color: #999;
    font-size: 15px;
    p {
      line-height: 1;
      margin-bottom: 20px;
    }
    a {
      color: #999;
      line-height: 1;
      padding: 0 10px;
      border-right: 1px solid #999;
      &:last-child {
        border-right: none;
      }
    }
  }
}
</style>


  • src/views/Layout.vue 中导入使用。
<script setup lang="ts">
import AppTopnav from './components/app-topnav.vue'
import AppHeader from './components/app-header.vue'
import AppFooter from './components/app-footer.vue'
</script>

<template>
  <AppTopnav />
  <AppHeader />
  <main class="app-body">
    <!-- 路由出口 -->
  </main>
  <AppFooter />
</template>

<style lang="less" scoped>
.app-body {
  min-height: 600px;
}
</style>


  • 查看效果

使用 pinia 管理数据

目标: 通过 pinia 管理项目中的数据。

核心步骤:

  • main.ts中注册 pinia
import { createApp } from 'vue'
import App from './App.vue'

import 'normalize.css'
import '@/assets/styles/common.less'

import router from './router'
+ import { createPinia } from 'pinia'
+ const pinia = createPinia()

const app = createApp(App)
app.use(router)
+ app.use(pinia)
app.mount('#app')

  • 创建文件store/modules/home.ts,用于管理home模块的数据
import { defineStore } from 'pinia';
import { ref } from 'vue';

export const useHomeStore = defineStore('home', () => {
  // 准备响应式数据
  const money = ref(14000);
  // 🎯记得 return 返回
  return { money };
});


  • 创建store/index.ts统一管理所有的模块
export * from './modules/home';

  • Layout/index.vue中测试
import useHomeStore from '@/store'
const home = useHomeStore()
console.log(home.money)

  • 控制台查看效果

使用 Pinia 获取头部分类导航

**目标:**能够发送请求完成分类导航的渲染

核心代码:

  • store/modules/home.ts中提供 state 和 actions
const useHomeStore = defineStore('home', {
  state: () => ({
    categoryList: []
  }),
  actions: {
    async getAllCategory() {
      const res = await request.get('/home/category/head')
      console.log(res)
    }
  }
})

  • Layout/index.vue中发送请求
<script setup lang="ts">
import useStore from '@/store'
const { home } = useStore()
home.getAllCategory()
</script>

TS 类型声明文件规划🚨

定义类型声明

  • src\types\modules\home.d.ts中定义数据类型
// 分类数据单项类型
export interface Goods {
  desc: string;
  id: string;
  name: string;
  picture: string;
  price: string;
  title: string;
  alt: string;
};

export interface Children {
  id: string;
  name: string;
  picture: string;
  goods: Goods[];
};

export interface Category {
  id: string;
  name: string;
  picture: string;
  children: Children[];
  goods: Goods[];
};

// 分类数据列表类型
export type CategoryList = Category[];


类型出口统一

  • 新建 src\types\index.d.ts
// 统一导出所有类型文件
export * from "./api/home";


应用

  • 修改store/modules/home.ts,给 axios 请求增加泛型
import { defineStore } from "pinia";
import request from "@/utils/request";
import type { CategoryList } from "@/types";

const useHomeStore = defineStore("home", {
  state: () => ({
    categoryList: [] as CategoryList,
  }),
  actions: {
    async getAllCategory() {
      const res = await request.get("/home/category/head");
      this.categoryList = res.data.result;
    },
  },
});

export default useHomeStore;


  • 渲染分类导航 在Layout/components/app-header-nav.vue
<script setup lang="ts">
import useStore from "@/store";
const { home } = useStore();
</script>

<template>
  <ul class="app-header-nav">
    <li class="home">
      <RouterLink to="/">首页</RouterLink>
    </li>
    <li v-for="item in home.categoryList" :key="item.id">
      <a href="#">{{ item.name }}</a>
    </li>
  </ul>
</template>

Axios 二次封装🚨

目标:改写 Axios 返回值的 TS 类型

Axios 二次封装,让 AxiosTS 类型组合使用时更方便。

Axios 内置类型声明解读

// 1. Axios 实例类型
export class Axios {
  // ...省略
  request<T = any, R = AxiosResponse<T>>(config): Promise<R>;
  get<T = any, R = AxiosResponse<T>>(url: string, config): Promise<R>;
}

// 2. AxiosResponse 返回值类型
export interface AxiosResponse<T = any, D = any>  {
  data: T;
  // ...省略
}


[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Kj996dNM-1668072603843)(media/image-20220218165657044.png)]

TS 类型升级支持

import { defineStore } from "pinia";
import request from "@/utils/request";
import type { CategoryList } from "@/types";

+ interface ApiRes<T = unknown> {
+   msg: string;
+   result: T;
+ }

const useHomeStore = defineStore({
  id: "home",
  state: () => ({
    categoryList: [] as CategoryList,
  }),
  actions: {
    async getAllCategory() {
-      // 能用, res.data 的返回值类型为 any
-      const res = await request.get("/home/category/head");
+     // 🎉恭喜已经有 TS 类型提醒了,res.data 能提示 result 和正确的类型
+      const res = await request.get<ApiRes<CategoryList>>("/home/category/head");
      this.categoryList = res.data.result;
    },
  },
});

export default useHomeStore;

TS 类型进阶封装(先使用)

  • 📦 课堂中先直接使用,提高开发效率。
  • ⏰ 课后大家自行解读,提升自己 TS 类型处理能力。

参考代码

src\utils\request.ts

- import axios from "axios";
+ import axios, { type Method } from "axios";

const instance = axios.create({
  baseURL: "http://pcapi-xiaotuxian-front-devtest.itheima.net/",
  timeout: 5000,
});

// 添加请求拦截器
instance.interceptors.request.use(
  function (config) {
    // 在发送请求之前做些什么
    return config;
  },
  function (error) {
    // 对请求错误做些什么
    return Promise.reject(error);
  }
);

// 添加响应拦截器
instance.interceptors.response.use(
  function (response) {
    return response;
  },
  function (error) {
    // 对响应错误做点什么
    return Promise.reject(error);
  }
);

+ // 后端返回的接口数据格式
+ interface ApiRes<T = unknown> {
+    msg: string;
+    result: T;
+ }

+/**
+ * axios 二次封装,整合 TS 类型
+ * @param url  请求地址
+ * @param method  请求类型
+ * @param submitData  对象类型,提交数据
+ */
+export const http = <T>(method: Method, url: string, submitData?: object) => {
+  return instance.request<ApiRes<T>>({
+    url,
+    method,
+    // 🔔 自动设置合适的 params/data 键名称,如果 method 为 get 用 params 传请求参数,否则用 data
+    [method.toUpperCase() === "GET" ? "params" : "data"]: submitData,
+  });
+};

export default instance;


使用

import { defineStore } from "pinia";
-import request from "@/utils/request";
+import { http } from "@/utils/request";

const useHomeStore = defineStore({
  id: "home",
  state: () => ({
    categoryList: [] as CategoryList,
  }),
  actions: {
    async getAllCategory() {
-      const res = await request.get<ApiRes<CategoryList>>("/home/category/head");
+      // 使用起来简洁很多
+      const res = await http<CategoryList>("GET", "/home/category/head");
      this.categoryList = res.data.result;
    },
  },
});

export default useHomeStore;


分类导航吸顶功能

电商网站的首页内容会比较多,页面比较长,为了能让用户在滚动浏览内容的过程中都能够快速的切换到其它分类。需要分类导航一直可见,所以需要一个吸顶导航的效果。

核心步骤:

目标: 完成头部组件吸顶效果的实现

交互要求

  1. 滚动距离大于等于 78 的时候,组件会在顶部固定定位
  2. 滚动距离小于 78 的时候,组件消失隐藏

实现思路

  1. 准备一个吸顶组件,准备一个类名,控制显示隐藏
  2. 监听页面滚动,判断滚动距离,距离大于 78 添加类名

静态结构

核心代码:

  • Layout/components/下,新建 app-header-sticky.vue 组件
<script setup lang="ts">
import { RouterLink } from "vue-router";
import AppHeaderNav from "./app-header-nav.vue";
</script>

<template>
  <div class="app-header-sticky" :class="{ show: true }">
    <div class="container">
      <RouterLink class="logo" to="/" />
      <AppHeaderNav />
      <div class="right">
        <RouterLink to="/">品牌</RouterLink>
        <RouterLink to="/">专题</RouterLink>
      </div>
    </div>
  </div>
</template>

<style scoped lang="less">
.app-header-sticky {
  width: 100%;
  height: 80px;
  position: fixed;
  left: 0;
  top: 0;
  z-index: 999;
  background-color: #fff;
  border-bottom: 1px solid #e4e4e4;
  // 此处为关键样式!!!
  // 默认情况下完全把自己移动到上面
  transform: translateY(-100%);
  // 完全透明
  opacity: 0;
  // 显示出来的类名
  &.show {
    transition: all 0.3s linear;
    transform: none;
    opacity: 1;
  }
  .container {
    display: flex;
    align-items: center;
  }
  .logo {
    width: 200px;
    height: 80px;
    background: url("@/assets/images/logo.png") no-repeat right 2px;
    background-size: 160px auto;
  }
  .right {
    width: 220px;
    display: flex;
    text-align: center;
    padding-left: 40px;
    border-left: 2px solid @xtxColor;
    a {
      width: 38px;
      margin-right: 40px;
      font-size: 16px;
      line-height: 1;
      &:hover {
        color: @xtxColor;
      }
    }
  }
}
</style>


  • Layout首页引入吸顶导航组件
<script setup lang="ts">
import AppTopnav from "./components/app-topnav.vue";
import AppHeader from "./components/app-header.vue";
import AppFooter from "./components/app-footer.vue";
+import AppHeaderSticky from "./components/app-header-sticky.vue";
import useStore from "@/store";
const { home } = useStore();
home.getAllCategory();
</script>

<template>
  <AppTopnav />
  <AppHeader />
+  <AppHeaderSticky />
+  <main class="app-body">
+    <!-- 路由出口 -->
+  </main>
  <AppFooter />
</template>

<style lang="less" scoped>
+.app-body {
+  min-height: 600px;
+}
</style>


吸顶实现

  • 在滚动到 78px 完成显示效果(添加类名)

    通过滚动事件的触发,判断当前是否已经滚动了 78px,如果大于则添加类名,否则移除类名

    1. document.documentElement.scrollTop 获取滚动距离
    2. :class 动态控制类名显示

组件src/views/Layout/components/app-header-sticky.vue

<script setup lang="ts">
import AppHeaderNav from "./app-header-nav.vue";
+import { onMounted, onUnmounted, ref } from "vue";
+// 控制是否显示吸顶组件
+const isShow = ref(false);
+// 考虑优化,组件挂载时绑定事件,组件卸载时移除事件
+const handlerScroll = () => {
+  const y = document.documentElement.scrollTop;
+  if (y >= 78) {
+    isShow.value = true;
+  } else {
+    isShow.value = false;
+  }
+};
+onMounted(() => {
+  window.addEventListener("scroll", handlerScroll);
+});
+onUnmounted(() => {
+  window.removeEventListener("scroll", handlerScroll);
+});
</script>

<template>
+  <div class="app-header-sticky" :class="{ show: isShow }">
    // ...
  </div>
</template>


分类导航吸顶功能-重构

目标: 使用 vueuse/core 重构吸顶功能

vueuse/core : 组合式 API 常用复用逻辑的集合

https://vueuse.org/core/useWindowScroll/

核心步骤

1)安装 @vueuse/core 包,它封装了常见的一些交互逻辑

yarn add @vueuse/core

2)在吸顶导航中使用

src/components/app-header-sticky.vue

<script setup lang="ts">
import AppHeaderNav from './app-header-nav.vue'
import { useWindowScroll } from '@vueuse/core'
// 控制是否显示吸顶组件
const { y } = useWindowScroll()
</script>

<template>
  <div class="app-header-sticky" :class="{ show: y >= 78 }">
     // ...
  </div>
</template>

常见疑问:

  • vue2 项目中能使用 @vueuse/core 吗?
    • 可以使用,需配合 @vue/composition-apiVue2 老项目支持 组合式API
    • @vueuse/core 只能以 组合式API 形式使用。

首页

配置二级路由

**目标:**配置首页的路由,首页 Home 组件属于二级路由

核心步骤:

  • 创建组件 src/views/Home/index.vue
<script setup lang="ts"></script>

<template>
  <div>Home组件</div>
</template>

<style lang="less" scoped></style>


  • 配置路由
{
  path: '/',
  component: () => import('@/views/Layout/index.vue'),
  children: [
    {
      path: '/',
      component: () => import('@/views/Home/index.vue'),
    },
  ],
},

  • 配置路由出口
<main class="app-body">
  <!-- 路由出口 -->
  <RouterView />
</main>

整体组件拆分

任务目标: 从整体角度按照模块功能进行组件拆分

1)拆分左侧分类组件

Home/components/home-category.vue

<script setup lang="ts"></script>

<template>
  <div class="home-category">分类组件</div>
</template>

<style lang="less" scoped></style>


2)拆分banner组件

Home/components/home-banner.vue

<script setup lang="ts"></script>

<template>
  <div class="home-banner">banner</div>
</template>

<style scoped lang="less">
.home-banner {
  width: 1240px;
  height: 500px;
  position: absolute;
  left: 0;
  top: 0;
  z-index: 98;
}
</style>


3)home组件中引入使用

<script setup lang="ts">
import HomeBanner from "./components/home-banner.vue";
import HomeCategory from "./components/home-category.vue";
</script>

<template>
  <div class="page-home">
    <div class="home-entry">
      <div class="container">
        <!-- 左侧分类 -->
        <HomeCategory />
        <!-- banner轮播图 -->
        <HomeBanner />
      </div>
    </div>
  </div>
</template>

<style lang="less" scoped></style>


左侧分类实现

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-5gjbmqFn-1668072603845)(media/left.png)]

1. 导航menu数据渲染

Home/components/home-category.vue

静态结构
<script setup lang="ts">
import { RouterLink } from 'vue-router';
</script>

<template>
  <div class="home-category">
    <ul class="menu">
      <li v-for="i in 9" :key="i">
        <RouterLink to="/">{{ '居家' }}</RouterLink>
        <RouterLink to="/">{{ '茶咖酒具' }}</RouterLink>
        <RouterLink to="/">{{ '水具杯壶' }}</RouterLink>
        <!-- 弹层layer位置 -->
      </li>
    </ul>
  </div>
</template>

<style scoped lang="less">
.home-category {
  width: 250px;
  height: 500px;
  background: rgba(0, 0, 0, 0.8);
  position: relative;
  z-index: 99;
  .menu {
    li {
      padding-left: 40px;
      height: 55px;
      line-height: 55px;
      &:hover {
        background: @xtxColor;
      }
      a {
        margin-right: 4px;
        color: #fff;
        &:first-child {
          font-size: 16px;
        }
      }
      .layer {
        width: 990px;
        height: 500px;
        background: rgba(255, 255, 255, 1);
        position: absolute;
        left: 250px;
        top: 0;
        display: none;
        padding: 0 15px;
        h4 {
          font-size: 20px;
          font-weight: normal;
          line-height: 80px;
          small {
            font-size: 16px;
            color: #666;
          }
        }
        ul {
          display: flex;
          flex-wrap: wrap;
          li {
            width: 310px;
            height: 120px;
            margin-right: 15px;
            margin-bottom: 15px;
            border: 1px solid #eee;
            border-radius: 4px;
            background: #fff;
            &:nth-child(3n) {
              margin-right: 0;
            }
            a {
              display: flex;
              width: 100%;
              height: 100%;
              align-items: center;
              padding: 10px;
              &:hover {
                background: #e3f9f4;
              }
              img {
                width: 95px;
                height: 95px;
              }
              .info {
                padding-left: 10px;
                line-height: 24px;
                overflow: hidden;
                .name {
                  font-size: 16px;
                  color: #666;
                }
                .desc {
                  color: #999;
                }
                .price {
                  font-size: 22px;
                  color: @priceColor;
                  i {
                    font-size: 16px;
                  }
                }
              }
            }
          }
        }
      }
      &:hover {
        .layer {
          display: block;
        }
      }
    }
  }
}
</style>


数据渲染一级分类
<script setup lang="ts">
import useStore from "@/store";
import { RouterLink } from "vue-router";
// 获取 Pinia 中的 home 模块,分类数据为 home.categoryList 
const { home } = useStore();
</script>

<template>
  <div class="home-category">
    <ul class="menu">
      <li v-for="item in home.categoryList" :key="item.id">
        <RouterLink to="/">{{ item.name }}</RouterLink>
        <RouterLink to="/">{{ "茶咖酒具" }}</RouterLink>
        <RouterLink to="/">{{ "水具杯壶" }}</RouterLink>
        <!-- 弹层layer位置 -->
      </li>
    </ul>
  </div>
</template>


数据渲染二级分类
<script setup lang="ts">
import useStore from "@/store";
import { computed } from "vue";
import { RouterLink } from "vue-router";
// 获取 Pinia 中的 home 模块,分类数据为 home.categoryList 
const { home } = useStore();
// 计算:处理左侧分类所需的数据格式
const leftCategoryList = computed(() => {
  // 基于 Pinia 中的分类数据处理
  return home.categoryList.map((item) => ({
    id: item.id,
    name: item.name,
    // 只需要截取前两个二级分类
    children: item.children.slice(0, 2),
  }));
});
</script>

<template>
  <div class="home-category">
    <ul class="menu">
      <li v-for="item in leftCategoryList" :key="item.id">
        <RouterLink to="/">{{ item.name }}</RouterLink>
        <RouterLink v-for="item2 in item.children" :key="item2.id" to="/">
          {{ item2.name }}
        </RouterLink>
        <!-- 弹层layer位置 -->
      </li>
    </ul>
  </div>
</template>

2. 鼠标移入layer展示

实现步骤

  • 布局交互
    • 每个Li标签对应一个自己的layer弹层,默认全部隐藏
    • Li 标签hover状态的时候让自己下面的layer弹层展示出来
  • 数据获取
    • 把goods字段也导入进来

代码落地

1)准备布局

<!-- 弹层layer位置 -->
<div class="layer">
  <h4>分类推荐 <small>根据您的购买或浏览记录推荐</small></h4>
  <ul>
    <li v-for="i in 9" :key="i">
      <RouterLink to="/">
        <img src="https://yanxuan-item.nosdn.127.net/5a115da8f2f6489d8c71925de69fe7b8.png" alt="">
        <div class="info">
          <p class="name ellipsis-2">【定金购】严选零食大礼包(12件)</p>
          <p class="desc ellipsis">超值组合装,满足馋嘴欲</p>
          <p class="price"><i>¥</i>100.00</p>
        </div>
      </RouterLink>
    </li>
  </ul>
</div>

2)导入新增goods字段

const list = computed(() => {
  return home.categoryList.map((item) => {
    return {
      id: item.id,
      name: item.name,
      children: item.children.slice(0, 2),
      // 添加 goods 字段
      goods: item.goods, 
    }
  })
})

3)渲染模板视图

<!-- 弹层layer位置 -->
<div class="layer">
  <h4>分类推荐 <small>根据您的购买或浏览记录推荐</small></h4>
  <ul>
    <li v-for="goods in item.goods" :key="goods.id">
      <RouterLink to="/">
        <img :src="goods.picture" alt="" />
        <div class="info">
          <p class="name ellipsis-2">
            {{ goods.name }}
          </p>
          <p class="desc ellipsis">{{ goods.desc }}</p>
          <p class="price"><i>¥</i>{{ goods.price }}</p>
        </div>
      </RouterLink>
    </li>
  </ul>
</div>

XtxUI 组件库

基本使用

任务目标: 把组件库从素材文件夹,复制到项目中使用。

核心步骤:

  1. 复制素材中的 components文件夹下所有 XtxUI 组件,放到 src/components 中。
  2. yarn lintnpm run lint 格式化文件。
  3. 使用组件库提供的组件。

新建测试页面:src\views\Test\index.vue

<script setup lang="ts">
// 直接导入 vue 组件源文件,导入多个组件需要写多个 import
import Button from "@/components/XtxUI/Button/index.vue";
import Skeleton from "@/components/XtxUI/Skeleton/Skeleton.vue";
</script>

<template>
  <!-- 问题解答1:在模板中可以自动导入 .vue 后缀名的源文件 -->
  <Button type="primary" size="large">按钮</Button>
  <!-- 直接导入的组件支持 TS 类型检查,鼠标悬停测试 -->
  <Skeleton :height="30" :width="100" bg="pink" />
</template>


局部注册组件

任务目标: 组件库中封装了统一出口,修改导入组件库组件的方式。

<script setup lang="ts">
-// 直接导入组件源文件,导入多个组件需要写多个 import
-import Button from "@/components/XtxUI/Button/index.vue";
-import Skeleton from "@/components/XtxUI/Skeleton/Skeleton.vue";
+// 同一个 import 可以同时导入多个组件
+import { XtxSkeleton, XtxButton } from "@/components/XtxUI";
</script>

<template>
-  <Button type="primary" size="large">按钮</Button>
-  <Skeleton :height="30" :width="100" bg="pink" />
+  <!-- 鼠标悬停检查 TS 类型 -->
+  <XtxButton type="primary" size="large">按钮</XtxButton>
+  <XtxSkeleton :height="30" :width="100" bg="pink" />
</template>

全局注册组件

**任务目标:**以插件的形式注册全局组件

核心步骤

  1. 新建文件components/index.ts
import type { App, Plugin } from 'vue'
import Skeleton from './Skeleton/Skeleton.vue'

const XtxUI: Plugin = {
  install(app: App) {
    app.component(`XtxSkeleton`, Skeleton);
  },
};

export default XtxUI;


  1. main.ts中全局注册
import XtxUI from "./components/XtxUI";

const app = createApp(App)
app.use(XtxUI)

  1. 在页面中使用。
<script setup lang="ts">
-import { XtxSkeleton, XtxButton } from "@/components/XtxUI/index";
</script>

<template>
+  <!-- 🐛问题:鼠标悬停为 any,失去 TS 类型检查 -->
  <XtxButton type="primary" size="large">按钮</XtxButton>
  <XtxSkeleton :height="30" :width="100" bg="pink" />
</template>

  • 🐛问题:全局组件注册成功,但是调用时没有 TS 类型提示。
  • 解决方案:为组件库创建对应的类型声明文件。

全局组件 TS 类型声明文件🚨

任务目标: 为全局组件书写对应的 TS 类型声明文件。

新建类型声明文件: src\components\XtxUI\global.d.ts ,准备基本结构

// 全局组件类型声明文件 for Volar
declare module 'vue' {
  // 全局组件需要定义 interface GlobalComponents
  export interface GlobalComponents { 
     全局组件名: 组件类型;
  }
}

export { }

添加全局组件类型声明:

+// 导入 .vue 源文件
+import Button from "./Button/index.vue";
+import Skeleton from "./Skeleton/Skeleton.vue";

// 全局组件类型声明文件 for Volar
declare module "vue" {
  // 全局组件需要定义 interface GlobalComponents
  export interface GlobalComponents {
+    // typeof 获取 TS 类型
+    XtxButton: typeof Button;
+    XtxSkeleton: typeof Skeleton;
  }
}

export {};

骨架组件

基本使用

任务目标: 在分类模块中使用我们定义好的骨架组件增强用户体验

核心步骤:

  1. 测试 XtxSkeleton 的使用
<template>
  <div>
    <XtxSkeleton :width="400" :height="300" bg="pink" />
    <XtxSkeleton :width="400" :height="300" bg="#000" />
  </div>
</template>

分类优化

**任务目标:**能够使用骨架组件优化首页的分类展示

核心步骤

  1. Home/components/home-category.vue中优化左侧分类的展示
<div class="home-category">
  <!-- 分类数据展示 v-if -->
  <ul class="menu" v-if="leftCategoryList.length > 0">
     // 左侧分类
  </ul>
  <!-- 分类占位效果 v-else 互斥 -->
  <ul class="menu" v-else>
    <li v-for="i in 9" :key="i">
      <XtxSkeleton
        :width="40"
        :height="20"
        style="margin-right: 5px"
        bg="rgba(255,255,255,0.2)"
      />
      <XtxSkeleton
        :width="50"
        :height="20"
        bg="rgba(255,255,255,0.2)"
        style="margin-right: 5px"
      />
      <XtxSkeleton :width="50" :height="20" bg="rgba(255,255,255,0.2)" />
    </li>
  </ul>
</div>

  1. Layout/components/app-header-nav.vue中优化头部导航的展示
<ul class="app-header-nav">
  <li class="home"><RouterLink to="/">首页</RouterLink></li>
  <template v-if="home.categoryList.length > 0">
    <li v-for="item in home.categoryList" :key="item.id">
      <a href="#">{{ item.name }}</a>
    </li>
  </template>
  <template v-else>
    <li v-for="i in 9" :key="i">
      <XtxSkeleton
        :width="60"
        :height="32"
        style="margin-right: 5px"
        bg="rgba(0,0,0,0.2)"
      />
    </li>
  </template>
</ul>

注意:

  • v-if 和 v-for 指令在 Vue2Vue3 的情况不同,
  • Vue3v-if 的优先级比 v-for 更高
  • 推荐在同一元素上使用 v-ifv-for,建议配合template 标签进行处理。参考资料

轮播图功能

获取数据

任务目标: 基于 pinia 获取轮播图数据

核心代码:

  1. store/modules/home.ts文件中封装接口,获取轮播图数据
const useHomeStore = defineStore('home', {
  actions: {
    // ...
    // 获取轮播图数据
    async getBannerList() {
      const res = await http('GET', '/home/banner');
      console.log('/home/banner', res);
    },
  }
})

export default useHomeStore


  1. 在组件中发送请求Home/components/home-banner.vue
<script setup lang="ts">
import useStore from '@/store'

const { home } = useStore()
home.getBannerList()
</script>

  1. 根据请求返回的数据,在src\types\api\home.d.ts 文件中定义对应的 TS 类型声明。
// 轮播图类型
export interface Banner {
  id: string;
  imgUrl: string;
  hrefUrl: string;
  type: string;
}

export type BannerList = Banner[];


  1. store/modules/home.ts文件中,完善 TS 类型声明。
-import type { CategoryList } from '@/types'
+import type { CategoryList, BannerList } from '@/types'
const useHomeStore = defineStore('home', {
  state: () => ({
    // ...
    // 轮播图数据
+    bannerList: [] as BannerList
  }),
  actions: {
    // ...
    // 获取轮播图数据
    async getBannerList() {
-      const res = await http("GET", "/home/banner");
+      const res = await http<BannerList>("GET", "/home/banner");
+      this.bannerList = res.data.result;
    },
  }
})

export default useHomeStore


渲染轮播图(快速实现)

**任务目标:**基于封装好的轮播图组件快速实现 Banner 模块

  1. 在Banner组件中使用Home/components/home-banner.vue
<script setup lang="ts">
import useStore from "@/store";
const { home } = useStore();
home.getBannerList();
</script>

<template>
  <div class="home-banner">
+    <XtxSlider :sliders="home.bannerList" auto-play />
  </div>
</template>

首页主体-面板组件封装

新鲜好物、人气推荐俩个模块的布局结构上非常类似,我们可以抽离出一个通用的面板组件来进行复用

任务目标: 封装一个通用的面板组件。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-NUzI4WPP-1668072603846)(media/panel.png)]

思路分析

  1. 图中标出的四个部分都是可能会发生变化的,需要我们定义为可配置
  2. 主标题和副标题由于是纯文本,我们定义成 props 即可
  3. 右侧内容和主体内容由于可能会传入较为复杂的自定义模板,我们定义成 slot 利用插槽渲染

核心代码

  • 组件编写

Home/components/home-panel.vue

<script setup lang="ts">
defineProps<{
  title: string;
  subTitle?: string;
}>();
</script>

<template>
  <div class="home-panel">
    <div class="container">
      <div class="head">
        <h3>
          {{ title }}<small>{{ subTitle }}</small>
        </h3>
        <!-- 右侧内容区域 -->
        <slot name="right"></slot>
      </div>
      <!-- 主体内容区域 -->
      <slot></slot>
    </div>
  </div>
</template>

<style scoped lang="less">
.home-panel {
  background-color: #fff;
  .head {
    padding: 40px 0;
    display: flex;
    align-items: flex-end;
    h3 {
      flex: 1;
      font-size: 32px;
      font-weight: normal;
      margin-left: 6px;
      height: 35px;
      line-height: 35px;
      small {
        font-size: 16px;
        color: #999;
        margin-left: 20px;
      }
    }
  }
}
</style>


  • 使用home-pannel组件Home/index.vue
<script setup lang="ts">
import HomeBanner from './components/home-banner.vue';
import HomeCategory from './components/home-category.vue';
import HomePanel from './components/home-panel.vue';
</script>

<template>
  <div class="page-home">
    <!-- 首屏 -->
    <div class="home-entry">
      <div class="container">
        <!-- 左侧分类 -->
        <HomeCategory />
        <!-- banner轮播图 -->
        <HomeBanner />
      </div>
    </div>
    <!-- 楼层面板组件和 home-entry 同级 -->
    <HomePanel title="大标题" sub-title="副标题">
      <template #right>
        <XtxMore />
      </template>
      <h2>我是主体内容-默认插槽</h2>
    </HomePanel>
  </div>
</template>

<style lang="less" scoped></style>


首页主体-新鲜好物

目标:封装一个新鲜好物的组件,用于处理新鲜好物模块。

  1. 创建组件,准备静态结构 Home/components/home-new.vue
<script setup lang="ts">
import HomePanel from './home-panel.vue'
</script>

<template>
  <div class="home-new">
    <HomePanel title="新鲜好物" sub-title="新鲜出炉 品质靠谱">
      <template #right><XtxMore path="/" /></template>
      <!-- 面板内容 -->
      <ul class="goods-list">
        <li v-for="item in 4" :key="item">
          <RouterLink to="/">
            <img
              src="https://yanxuan-item.nosdn.127.net/e7337596de7161c57c27e8d8040231aa.jpg"
              alt=""
            />
            <p class="name ellipsis">情侣款时尚户外轻型徒步鞋环保大底</p>
            <p class="price">&yen;364.00</p>
          </RouterLink>
        </li>
      </ul>
    </HomePanel>
  </div>
</template>

<style scoped lang="less">
.goods-list {
  display: flex;
  justify-content: space-between;
  height: 406px;
  li {
    width: 306px;
    height: 406px;
    background: #f0f9f4;
    .hoverShadow();
    img {
      width: 306px;
      height: 306px;
    }
    p {
      font-size: 22px;
      padding: 12px 30px 0 30px;
      text-align: center;
    }
    .price {
      color: @priceColor;
    }
  }
}
</style>


  1. 修改 src\views\Home\index.vue ,引入并使用组件。
<script setup lang="ts">
import HomeNew from './components/home-new.vue'
</script>

<template>
  <div class="page-home">
    <!-- 左侧分类和轮播图 -->
    <div class="home-entry">
      ...
    </div>
    <!-- 面板组件 和 home-entry 同级 -->
    <HomeNew />
  </div>
</template>

  1. store/modules/home.ts文件中封装请求
// ...
const useHomeStore = defineStore('home', {
  // ...
  actions: {
    // ...
    async getNewGoodsList() {
      const res = await http('GET', '/home/new');
      console.log('/home/new', res);
    }
  }
})


  1. Home\components\home-new.vue 组件中,调用 actions 获取数据
<script setup lang="ts">
import HomePanel from './home-panel.vue'
import useStore from '@/store'
const { home } = useStore()
home.getNewGoodsList()
</script>

  1. 完善类型声明文件(商品类型可复用)
// 🔔电商网站商品的类型基本一致,可以复用
export interface Goods {
  id: string;
  name: string;
  desc: string;
  price: string;
  picture: string;
  orderNum: number;
}

// 商品列表类型(可以复用)
export type GoodsList = Goods[];

  1. 完善 store/modules/home.ts 文件中的类型
import { defineStore } from 'pinia'
import { http } from '@/utils/request'
-import type { CategoryList, BannerList } from '@/types'
+import type { CategoryList, BannerList, GoodsList } from '@/types'
const useHomeStore = defineStore('home', {
  state: () => ({
     // ...
+    newGoodsList: [] as GoodsList
  }),
  actions: {
    // ...
    async getNewGoodsList() {
-      const res = await http("GET", "/home/new");
+      const res = await http<GoodsList>("GET", "/home/new");
+      this.newGoodsList = res.data.result
    }
  }
})

export default useHomeStore


  1. Home\components\home-new.vue 组件中,完成列表渲染
<template>
  <div class="home-new">
    <HomePanel title="新鲜好物" sub-title="新鲜出炉 品质靠谱">
      <template #right><XtxMore path="/" /></template>
      <!-- 面板内容 -->
      <ul class="goods-list">
        <li v-for="item in home.newGoodsList" :key="item.id">
          <RouterLink to="/">
            <img :src="item.picture" alt="" />
            <p class="name ellipsis">{{ item.name }}</p>
            <p class="price">&yen;{{ item.price }}</p>
          </RouterLink>
        </li>
      </ul>
    </HomePanel>
  </div>
</template>

首页主体-人气推荐(课后作业)

温馨提示:人气推荐的逻辑和新鲜好物的逻辑基本一致

(1)发送请求,获取数据 src/store/modules/home.ts

const useHomeStore = defineStore('home', {
  state: () => ({
+    hotGoodsList: [] as GoodsList
  }),
  actions: {
+    async getHotGoodsList() {
+      const res = await http<GoodsList>("GET", "/home/hot");
+      this.hotGoodsList = res.data.result
+    }
  }
})


(2)创建组件Home/components/home-hot.vue

<script setup lang="ts">
import useStore from '@/store';
import HomePanel from './home-panel.vue';
import { useObserver } from '@/hooks';

const { home } = useStore();
const { target } = useObserver(home.getHotGoodsList);
</script>

<template>
  <div class="home-hot" ref="target">
    <HomePanel title="人气推荐" sub-title="人气爆款 不容错过">
      <ul class="goods-list">
        <li v-for="item in home.hotGoodsList" :key="item.id">
          <RouterLink to="/">
            <img :src="item.picture" alt="" />
            <p class="name">{{ item.title }}</p>
            <p class="desc">{{ item.alt }}</p>
          </RouterLink>
        </li>
      </ul>
    </HomePanel>
  </div>
</template>

<style scoped lang="less">
.goods-list {
  display: flex;
  justify-content: space-between;
  height: 426px;
  li {
    width: 306px;
    height: 406px;
    .hoverShadow();
    img {
      width: 306px;
      height: 306px;
    }
    p {
      font-size: 22px;
      padding-top: 12px;
      text-align: center;
    }
    .desc {
      color: #999;
      font-size: 18px;
    }
  }
}
</style>


(3)首页中渲染src/views/home/index.vue

<script setup lang="ts">
import HomeBanner from './components/home-banner.vue'
import HomeCategory from './components/home-category.vue'
import HomeNew from './components/home-new.vue'
import HomeHot from './components/home-hot.vue'
</script>

<template>
  <div class="page-home">
    <div class="home-entry">
      <div class="container">
        <!-- 左侧分类 -->
        <HomeCategory />
        <!-- banner轮播图 -->
        <HomeBanner />
      </div>
      <!-- 新鲜好物 -->
      <HomeNew />
      <!-- 人气推荐 -->
      <HomeHot />
    </div>
  </div>
</template>

<style lang="less" scoped></style>


按需请求(请求懒加载)🚨🚨

电商项目核心优化技术手段:组件数据懒加载 (首屏渲染优化)

说明:电商类网站的首页内容会有好几屏,如果直接加载并渲染所有屏的数据,会比较浪费性能。

优化:应该 当模块进入到 可视区 ,再发请求获取数据

检测目标元素的可见性

任务目标: 了解如何检测目标元素的可见性

技术方案:

我们可以使用 @vueuse/core 中的 useIntersectionObserver 来实现监听组件进入可视区域行为,

需要配合 vue3 的组合 API 的方式才能实现

https://vueuse.org/core/useIntersectionObserver/

先分析下这个useIntersectionObserver 函数:

<script setup lang="ts">
import { ref } from 'vue';
import { useIntersectionObserver } from '@vueuse/core';

// 🔔核心单词解释:
//   useIntersectionObserver   检查元素是否进入可视区函数
//   target                    目标元素,🎯需配合模板 ref 使用
//   isIntersecting            是否进入可视区(布尔值)
//   stop                      用于停止检测的函数

// 准备目标元素(DOM节点或组件,需配合模板 ref 使用)
const target = ref(null);

const { stop } = useIntersectionObserver(target, ([{ isIntersecting }]) => {
  console.log('检测元素可见性', isIntersecting);
  // 需求:如果目标元素进入可视区,就发送请求,并停止检测
  if (isIntersecting) {
    // 当目标元素进入可视区域时,才发送请求
    console.log('进入可视区,需要发送请求');
    // 请求已发送,主动停止检查
    stop();
  }
});
</script>

<template>
  <div style="height: 2000px"></div>
  <!-- 🎯目标元素需添加模板 ref  -->
  <div ref="target">
    <h1>🎯我是目标元素🎯</h1>
  </div>
  <div style="height: 2000px"></div>
</template>


我们以新鲜好物模块为例演示一下这个函数的使用方式

1)通过 ref 属性获得组件实例并测试

2)使用useIntersectionObserver监听函数

<script setup lang="ts">
import HomePanel from "./home-panel.vue";
import { ref } from "vue";
import { useIntersectionObserver } from "@vueuse/core";
import useStore from "@/store";
const { home } = useStore();
// 通过 ref 获得组件实例
const target = ref(null);
const { stop } = useIntersectionObserver(
  // target 被检测的目标元素
  target,
  // isIntersecting 是否进入可视区域
  ([{ isIntersecting }]) => {
    // 在此处可根据isIntersecting来判断,然后做业务
    console.log('是否进入可视区域', isIntersecting);
    if (isIntersecting) {
      home.getHotGoodsList();
      stop();
    }
  }
);
</script>

<template>
  <div class="home-hot">
    <!-- 🚨 添加 ref="target" 和 模板关联 -->
    <HomePanel ref="target" title="人气推荐" sub-title="人气爆款 不容错过">
      ...
    </HomePanel>
  </div>
</template>

3)测试效果

打开浏览器,人气推荐模块还未进入到可视区,打印值为false,

然后我们滑动页面,当人气模块组件进入可视区中时,再次发生打印,此时为true,

到此我们就可以判断组件进入和离开可视区了

**特别注意:**每次被监听的dom进入离开可视区时都会触发一次,而不是只触发一次, 可以stop关闭监听

具体业务实现

任务目标: 利用我们捋清楚的发送请求的位置实现业务数据拉取完成实际业务功能

实现步骤

  1. 发送的ajax请求在isIntersecting 为true时触发
  2. 一旦触发一次之后停止监听,防止接口重复调用

代码落地

<script setup lang="ts">
 // ...省略
</script>



<!-- 🚨 添加 ref="target" 和 模板关联 -->
<HomePanel ref="target" title="人气推荐" sub-title="人气爆款 不容错过">...</HomePanel>

组件数据懒加载逻辑复用

本节目标: 抽离组件数据懒加载可复用的逻辑

现存问题

首页中,很多地方都应该使用组件数据懒加载这个功能,不管是哪个模块使用,下面代码都会重复书写

事实上,唯一可能会随着业务使用发生变化的是 ajax接口的调用

其余的部分我们进行重复使用,抽离为可复用逻辑

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-tNzOgAbJ-1668072603847)(media/cma1.png)]

抽离通用逻辑

1)抽离逻辑

src/hooks/index.ts

import { useIntersectionObserver } from "@vueuse/core";
import { ref } from "vue";

/**
 * 请求按需加载
 * @param apiFn 发送请求函数
 * @returns  🚨 target 用于模板绑定
 */
export const useObserver = (apiFn: () => void) => {
  // 准备个 ref 用于绑定模板中的某个目标元素(DOM节点或组件)
  const target = ref(null);
  const { stop } = useIntersectionObserver(target, ([{ isIntersecting }]) => {
    console.log("是否进入可视区域", isIntersecting);
    if (isIntersecting) {
      // 当目标元素进入可视区域时,才发送请求
      apiFn();
      // 请求已发送,主动停止检查
      stop();
    }
  });
  // 🚨返回 ref 用于模板绑定,建议返回对象格式支持解构获取
  return { target };
};


2)业务改写

<script setup lang="ts">
import { RouterLink } from "vue-router";
import useStore from "@/store";
import HomePanel from "./home-panel.vue";
+ import { useObserver } from "@/hooks";
const { home } = useStore();
+ const { target } = useObserver(home.getHotGoodsList);
</script>

骨架组件 - 优化默认显示结构

<ul class="goods-list" v-else>
  <li v-for="item in 4" :key="item">
    <XtxSkeleton :width="306" :height="406" bg="rgba(255,255,255,0.2)" />
  </li>
</ul>

Pinia 持久化存储 - 首页数据缓存

目标: 通过 Pinia 插件快速实现持久化存储。

插件文档:点击查看

用法

安装

yarn add pinia-plugin-persistedstate
# 或
npm i pinia-plugin-persistedstate

使用插件

+ import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

const pinia = createPinia();
+ pinia.use(piniaPluginPersistedstate);
app.use(pinia);

模块开启持久化

const useHomeStore = defineStore("home",{
+  persist: true
   state:()=>({})
  // ...省略
});

常见疑问

  • Vue2 能不能用 Pinia 和 持久化存储插件。
    • 可以使用,需配合 @vue/composition-api 先让 Vue2 老项目支持 组合式API
    • Pinia 能在 组合式API 中使用。
  • 模块做了持久化后,以后数据会不会变,怎么办?
    • 先读取本地的数据,如果新的请求获取到新数据,会自动把新数据覆盖掉旧的数据。
    • 无需额外处理,插件会自己更新到最新数据。

进阶用法

需求:不想所有数据都持久化处理,能不能按需持久化所需数据,怎么办?

  • 可以用配置式写法,按需缓存某些模块的数据。
import { defineStore } from 'pinia'

export const useStore = defineStore('main', {
  state: () => ({
      someState: 'hello pinia',
      nested: {
        data: 'nested pinia',
      },
  }),
  // 所有数据持久化
  // persist: true,
  // 持久化存储插件其他配置
  persist: {
    // 修改存储中使用的键名称,默认为当前 Store的 id
    key: 'storekey',
    // 修改为 sessionStorage,默认为 localStorage
    storage: window.sessionStorage,
    // 🎉按需持久化,默认不写会存储全部
    paths: ['nested.data'],
  },
})

Logo

前往低代码交流专区

更多推荐