Skip to content

S11-09 Vue-项目:mr-trip

[TOC]

项目搭建

技术栈:

​ Node: 16.19.0

​ Vue: 3.2.37

​ Vite: 3.0.1

​ Vant:3.x

​ vue-router: 4.1.6

创建项目

基于 vite 工具,使用 create-vue 创建项目

sh
npm init vue@latest

问题: vite@3.2.4 要求 node 的版本是:"engines": "node": "^14.18.0 || >=16.0.0",否则报错

项目配置

项目 icon、标题、jsconfig.json

项目目录结构

image-20221123122016167

CSS 样式重置、初始化

  • 1、normalize.css : 让不同的浏览器在渲染网页元素的时候形式更统一

    sh
    # 安装
    npm i normalize.css
    # 使用:@/main.js
    import 'normalize.css'
    import '@/assets/css/index.css'
  • 2、reset.css: 自定义重置 CSS

  • 3、common.css :自定义公共 CSS 样式

  • 4、index.css : css 文件夹的入口文件,然后在 main.js 中引入

    js
    // 引入:@/main.js
    import 'normalize.css'
    import '@/assets/css/index.css'

安装 less

sh
npm i less -D

设置用户片段

在 VSCode 中设置用户片段

  • 1、模板
html
<template>
  <div class="${1:home}">${1:home}</div>
</template>

<script ${2:setup}></script>

<style ${3:lang="less" } scoped></style>
  • 2、将模板在 https://snippet-generator.app 网站上转化成 json 格式,设置 trigger:vuesetup,和简介
  • 3、在 VSCode - 文件 - 首选项 - 配置用户代码片段 - [输入 vue.json] - 复制转化后的模板到 vue.json 中
  • 4、使用时输入:vuesetup 即可

路由配置

  • 1、安装路由

    sh
    npm i vue-router
  • 2、创建 router 实例,并导出

    js
    // @/router/index.js
    import { createRouter, createWebHashHistory } from 'vue-router'
    const router = createRouter({
      routes: [
        {
          path: '/',
          redirect: '/home'
        },
        {
          path: '/home',
          component: () => import('@/views/home/home.vue')
        },
        {
          path: '/favor',
          component: () => import('@/views/favor/favor.vue')
        },
        {
          path: '/order',
          component: () => import('@/views/order/order.vue')
        },
        {
          path: '/message',
          component: () => import('@/views/message/message.vue')
        }
      ],
      history: createWebHashHistory('/trip')
    })
    export default router
  • 3、在 @/main.js 引入

    js
    // @/main.js
    import router from './router'
    ...
    createApp(App).use(router).mount('#app')
  • 4、在@/app.vue 中 占位

    html
    <template>
      <div class="app">+ <router-view></router-view></div>
    </template>

pinia 配置

  • 1、安装 pinia

    sh
    npm i pinia
  • 2、创建 pinia 实例,并导出

    js
    // @/stores/index.js
    import { createPinia } from 'pinia'
    
    const pinia = createPinia()
    
    export default pinia
  • 3、在 @/main.js 引入

    js
    // @/main.js
    import pinia from './stores'
    ...
    createApp(App).use(router).use(pinia).mount('#app')
  • 4、创建 store 实例,并导出

    js
    // @/stores/modules/city.js
    import { defineStore } from 'pinia'
    const useCityStore = defineStore('city', {
      state: () => ({
        allCities: {}
      }),
      getters: {},
      actions: {}
    })
    export default useCityStore

出错: 在 store 的 state 中写法出错了

image-20221123225535408

引入 vant

  • 1、安装

    sh
    npm i vant
  • 2、引入(自动按需引入)

    插件: unplugin-vue-components

    • 2.1、安装插件

      sh
      npm i unplugin-vue-components -D
    • 2.2、配置插件

      js
      // vite.config.js
      import vue from '@vitejs/plugin-vue';
      + import Components from 'unplugin-vue-components/vite';
      + import { VantResolver } from 'unplugin-vue-components/resolvers';
      
      export default {
        plugins: [
          vue(),
      +    Components({
      +      resolvers: [VantResolver()],
      +    }),
        ],
      };
    • 2.3、使用组件

      html
      <template>
        <van-button type="primary" />
      </template>
  • 2、引入(自动按需引入)最新引入方法(2024-5-28)

    依赖包:

    • unplugin-vue-components
    • @vant/auto-import-resolver
    • unplugin-auto-import

    安装插件:

    sh
    # 通过 npm 安装
    npm i @vant/auto-import-resolver unplugin-vue-components unplugin-auto-import -D
    
    # 通过 pnpm 安装
    pnpm add @vant/auto-import-resolver unplugin-vue-components unplugin-auto-import -D

    **配置插件:**在 vite.config.js 文件中配置插件

    json
    import vue from '@vitejs/plugin-vue';
    
    + import AutoImport from 'unplugin-auto-import/vite';
    + import Components from 'unplugin-vue-components/vite';
    + import { VantResolver, VantImports } from '@vant/auto-import-resolver';
    
    export default {
      plugins: [
        vue(),
    +    AutoImport({
    +      imports: [VantImports()],
    +      resolvers: [VantResolver()],
    +    }),
    +    Components({
    +      resolvers: [VantResolver()],
    +    }),
      ],
    };
  • 3、安装 vscode 中的 vant 代码提示插件:Vant Snippets

封装 axios

  • 1、安装 axios

    sh
    npm i axios
  • 2、封装(基础版)

    js
    // @/utils/request/index.js
    import axios from 'axios'
    
    class MrRequest {
      // 构造器
      constructor(baseURL, timeout = 10000) {
        this.instance = axios.create({
          baseURL,
          timeout
        })
      }
    
      // request 方法
      request(config) {
        return new Promise((resolve, reject) => {
          this.instance
            .request(config)
            .then((res) => {
              resolve(res.data)
            })
            .catch((err) => {
              reject(err)
            })
        })
      }
    
      // get 方法
      get(config) {
        return this.request({ ...config, method: 'get' })
      }
    
      // post 方法
      post(config) {
        return this.request({ ...config, method: 'post' })
      }
    }
    
    export default new MrRequest('http://123.207.32.32:9001')

全局组件

tabbar

image-20221123141839190

  • 1、创建 tab-bar 组件

    html
    // @/components/tab-bar/tab-bar.vue
    <div class="tab-bar">
      <template v-for="item in tabBarData" :key="item.path">
        <div class="item">
          <img :src="getImageUrl(item.image)" alt="" />
          <span class="text">{{ item.text }}</span>
        </div>
      </template>
    </div>
  • 2、加载到 App.vue 中

    html
    // @/App.vue
    <template>
      <div class="app">
        <!-- tabbar -->
        + <tab-bar />
      </div>
    </template>
    <script setup>
      +  import TabBar from '@/components/tab-bar/tab-bar.vue'
    </script>
  • 3、注意: 在 vue 的 template 模板中不能动态引入 img 的地址 ,如这样写:<img :src="data.imgUrl">

    • webpack 环境中可以通过 require 引入,如:<img :src="require(data.imgUrl)"

    • vite 环境中没有require 函数,需要定义一个函数

      js
      /**
       * 动态获取图片(或其他模块)
       * @param imgUrl string 图片的相对路径(相对于当前文件getImageUrl.js)
       */
      export const getImageUrl = (imgUrl) => {
        return new URL(`../assets/img/${imgUrl}`, import.meta.url).href
      }

      使用 getImageUrl

      html
      <template> + <img :src="getImageUrl(item.image)" alt="" /> </template>
      <script setup>
        +  import { getImageUrl } from '@/utils/getImageUrl'
      </script>
  • 4、点击 item,切换路由

    html
    <template v-for="(item, index) in tabBarData" :key="item.path">
      +
      <div class="item" :class="{ active: activeIndex === index }" @click="itemClick(index, item)">
        + <img v-if="activeIndex !== index" :src="getImageUrl(item.image)" alt="" /> +
        <img v-else :src="getImageUrl(item.imageActive)" alt="" />
        <span class="text">{{ item.text }}</span>
      </div>
    </template>
    js
    import { useRouter } from 'vue-router'
    
    // 响应式变量
    const activeIndex = ref(0)
    
    // 全局变量
    const router = useRouter()
    
    // 方法
    function itemClick(index, item) {
      activeIndex.value = index
      router.push(item.path)
    }

出错:

如图所示,否则的话就不解析这个图片,只是一段 url 地址

image-20221124231343967

BUG: tabbar 默认索引 bug:直接修改 url 中 path 时,tabbar 不能同步切换

js
const route = useRoute()
/* BUG:解决通过浏览器地址栏手动输入路由无法改变图标,activeIndex无法响应式更改 */
const activeIndex = computed(() => {
  const index = tabBarData.findIndex((item) => item.path === route.path)
  if (index === -1) return 0
  return index
})

loading

image-20230201113306364

1、基础布局

html
<div class="loading">
  <div class="bg">
    <img src="@/assets/img/home/full-screen-loading.gif" alt="" />
  </div>
</div>
css
.loading {
  position: fixed;
  left: 0;
  right: 0;
  top: 0;
  bottom: 0;
  z-index: 999;
  display: flex;
  justify-content: center;
  align-items: center;
  background-color: rgba(0, 0, 0, 0.5);
  .bg {
    display: flex;
    justify-content: center;
    align-items: center;
    width: 104px;
    height: 104px;
    background: url('@/assets/img/home/loading-bg.png') center / 100%;

    img {
      width: 70px;
      height: 70px;
      margin-bottom: 8px;
    }
  }
}

2、loading 显示状态控制

由于很多页面在发起网络请求时,都会用到 loading 组件,所以要将它放在公共组件中,

同样的原因,它的控制变量也要放在 mainStore 中,供所有页面访问和修改

js
// src\stores\modules\main.js
const useMainStore = defineStore('main', {
  state: () => ({
+    isLoading: false
  })
})
html
<!-- src\components\loading\loading.vue -->
+
<div class="loading" v-if="isLoading" @click="hideLoading">
  <script setup>
            const mainStore = useMainStore()
    +        const { isLoading } = storeToRefs(mainStore)
  </script>
</div>

点击蒙版,loading 消失

html
<!-- src\components\loading\loading.vue -->
<div class="loading" v-if="isLoading" @click="hideLoading">
  <script setup>
    /* 点击蒙版,隐藏loading */
    function hideLoading() {
      mainStore.isLoading = false
    }
  </script>
</div>

在 axios 的拦截器中修改isLoading 的状态

js
 // src\service\request\index.js
import useMainStore from '@/stores/modules/main'
const mainStore = useMainStore()

/* 构造器 */
  constructor(baseURL, timeout = 10000) {
    // 在拦截器中修改`isLoading` 的状态
    this.instance.interceptors.request.use(config => {
+      mainStore.isLoading = true
      return config
    },err => {
      return err
    })
    this.instance.interceptors.response.use(response => {
+      mainStore.isLoading = false
      return response
    },err => {
+      mainStore.isLoading = false
      return err
    })
  }

首页

效果:

image-20221123145018902

image-20230128174657548

image-20230128174644772

image-20230128180006488

image-20230128180219984

image-20230128180228570

image-20230129113206255

image-20230129115053422

城市、位置

image-20230128180329879

1、布局

image-20230128180749365

2、点击城市,跳转到 city 页

html
<div class="city" @click="getCity">合肥</div>
js
import { useRouter } from 'vue-router'
const router = useRouter()
function getCity() {
  router.push('/city')
}

3、获取位置信息

image-20230129115105058

image-20230129115341388

日期范围

image-20230129113242048

1、基础布局

html
<!-- src\views\home\cpns\search-box.vue -->
<!-- 日期范围 -->
<div class="section date-range">
  <div class="start">
    <div class="date">
      <span class="tip">入住</span>
      <span class="time">1月30日</span>
    </div>
    <div class="stay">共一晚</div>
  </div>
  <div class="end">
    <div class="date">
      <span class="tip">离开</span>
      <span class="time">1月31日</span>
    </div>
  </div>
</div>

2、点击选择日期范围

  • 插件:npm i dayjs
  • 调用 vant 中 canlendar 组件
  • 自定义日期文案
  • 自定义颜色(主题色)#ff9854
  • 全屏显示 canlendar 组件 --van-canlendar-popup-height: 100%, [ round: false ]
  • 快捷选择 [ :show-confirm="false" ]
  • 选择好日期后,点击确认按钮,触发 confirm 事件
html
<div class="section date-range" @click="calendarShow = true">
  <van-calendar
    v-model:show="calendarShow"
    type="range"
    color="var(--primary-color)"
    :round="false"
    :show-confirm="false"
    @confirm="onConfirm"
  />
</div>
js
const calendarShow = ref(false)

/* 选择日期 */
let startDate = ref(new Date())
let endDate = ref(new Date().setDate(startDate.value.getDate() + 2))

const startDateStr = computed(() => dateFormat(startDate.value, 'MM月DD日'))
const endDateStr = computed(() => dateFormat(endDate.value, 'MM月DD日'))

function onConfirm(date) {
  startDate.value = date[0]
  endDate.value = date[1]
  calendarShow.value = false
}

3、计算停留天数

注意:此处 stayDays 赋值时忘了加.value

热门建议

image-20230129113311831

  • 1、数据请求和展示

    技巧: 请求数据时最好在一级组件里面请求(如 home,city 等),方面后续查找

    image-20221206152714174

  • 2、数据请求分层结构

    home-search-box.vue

    image-20221206161537915

    @/stores/modules/home.js

    image-20221206161557299

    @/service/modules/home.js

    image-20221206161405697

搜索按钮

image-20221206212603822

  • 1、样式布局

    image-20221206212443725

  • 2、页面跳转

    image-20221206212551244

推荐类别

image-20221207130119439

  • 1、GET 请求/home/categories数据,并保存到 homeStore 中:homeStore.fetchCategories()

    image-20221207125913286

  • 2、从 homeStore 中获取 categories 数据

    image-20221207130318944

  • 3、分类菜单组件 home-categories.vue

    image-20221207125947914

    技巧: 下面的代码可以隐藏滚动条(有兼容问题,移动端可以无视兼容问题)

    image-20221207130011354

热门精选

1、基础布局

html
<!-- src\views\home\home.vue -->
<!-- 热门精选 -->
<house-list></house-list>

<script setup>
  import HouseList from '@/views/home/cpns/house-list.vue'
</script>
html
<!-- src\views\home\cpns\house-list.vue -->
<template>
  <div class="house-list">
    <h2 class="title">热门精选</h2>
    <div class="list">list</div>
  </div>
</template>

2、网络请求

GET 请求/home/houselist?page=1 数据,并保存到 homeStore 中

js
// src\service\modules\home.js
/* 请求houselist */
export function getHouseList() {
  return mrRequest.get({
    url: 'home/houselist',
    params: {
      page: 1 // 此处先写死,后续再改
    }
  })
}
js
// src\stores\modules\home.js
  state: () => ({
    houseList: []
  }),
  /* 请求 houselist */
  async fetchHouseList() {
    const res = await getHouseList()
    this.houseList = res.data // 此处先这样写,后续优化
  }
js
// src\views\home\home.vue
// 网络请求
homeStore.fetchHouseList()

image-20221207134354581

3、分页请求,page 参数值动态获取

image-20221207141814225

4、不同类型组件的展示

image-20221209214722317

house-item-v9.vuehouse-item-v3.vue

注意:

  • van-rate 同时设置 readonly 和 allow-half 后,可以显示小数 score
  • 此处的 score 必须是 number 类型
  • 获取 defineProps 中的数据时,需要通过 props 对象访问
  • 如果不需要修改 score 可以通过 :model-value 绑定数据,而不需要 v-model

image-20221207154718313

封装 useScroll@

1、优化: 监听滚动到底部(封装useScroll),自动加载更多数据

插件:npm i underscore

滚动到底部公式:scrollHeight <= scrollTop + clientHeight

注意: 监听 window 窗口的滚动,因为 window 是所有页面公用的,当我们离开页面时,需要移除监听

@/hooks/useScroll.js

image-20241111215526009

@/views/home.vue

image-20241111215600431

2、优化: useScroll 函数中使用节流函数包裹,降低 scroll 事件触发频率(见上图 throttle)

知识点: 虚拟列表

  • 前端处理巨量数据的方法:虚拟列表、开启多个线程

BUG: 加载更多数据时的 BUG

在获取 3 个及以上属性时,为了防止没有该属性时出现 undefined.xxx 的情况,可以使用可选链操作符 ?.

image-20241111215618213

image-20230131163940255

  • 1、封装search-bar:页面搭建和 CSS 样式
  • 2、获取search-box组件中的startDateendDate
  • 3、将nowDatenewDate 共享到 mainStore 中,方便在其他组件中使用
  • 4、修改时间格式化函数 formatMonthDay,可以自定义时间格式

1、基础布局

html
<div class="search-bar">
  <div class="wrap">
    <div class="time">
      <div class="item start">
        <span class="text">住</span>
        <span class="date">05.08</span>
      </div>
      <div class="item end">
        <span class="text">离</span>
        <span class="date">05.10</span>
        <i class="icon-search-arrow"></i>
      </div>
    </div>
    <div class="content">
      <div class="keyword">关键字/位置/民宿名</div>
    </div>
    <div class="search">
      <i class="icon-search"></i>
    </div>
  </div>
</div>

2、使用 watch 监听滚动到指定位置,显示 search-bar

html
<!-- 搜索栏 -->
<search-bar v-if="isShowSearchBar"></search-bar>
js
/* 监听滚动到指定位置,显示搜索栏 */
const isShowSearchBar = ref(0)
watch(scrollTop, (newValue) => {
  isShowSearchBar.value = newValue > 360
})

3、优化:使用计算属性 computed 代替 watch 监听 scrollTop 的变化

html
<!-- 搜索栏 -->
<search-bar v-if="isShowSearchBar"></search-bar>
js
/* 监听滚动到指定位置,显示搜索栏 */
const isShowSearchBar = computed(() => scrollTop.value > 100)

优点: computed 有缓存功能,不会频繁监听 scrollTop 的变化,可以优化速度

计算属性使用场景: 定义的可响应式数据依赖另外一个可响应式数据,可以使用计算属性(computed)

4、将日期共享到 mainStore

注意:mainStore 中存放的东西:

  • startDateendDate
  • isLoading 状态
  • userInfo 用户信息
  • token
js
// src\stores\modules\main.js
import { defineStore } from 'pinia'
const useMainStore = defineStore('main', {
  state: () => ({
    startDate: new Date(),
    endDate: new Date(new Date().setDate(new Date().getDate() + 1))
  })
})
export default useMainStore
js
// src\views\home\cpns\search-box.vue
const mainStore = useMainStore()
const { startDate, endDate } = storeToRefs(mainStore)
/* 选择日期 */
const startDateStr = computed(() => dateFormat(startDate.value, 'MM月DD日'))
const endDateStr = computed(() => dateFormat(endDate.value, 'MM月DD日'))
function onConfirm(date) {
  mainStore.startDate = date[0]
  mainStore.endDate = date[1]
  calendarShow.value = false
}
/* 计算一共住了几晚 */
const stay = computed(() => dateDiff(startDate.value, endDate.value))
js
// src\views\home\cpns\search-bar.vue
const mainStore = useMainStore()
const { startDate, endDate } = storeToRefs(mainStore)
/* 格式化时间 */
const startDateStr = dateFormat(startDate.value, 'MM.DD')
const endDateStr = dateFormat(endDate.value, 'MM.DD')

city 页

获取接口数据

接口地址:http://codercba.com:1888/api/city/all

接口数据:

image-20221127153046091

将接口数据放入 pinia

在 vue 组件中发送网络请求的缺点

image-20230129164435210

在 service 中 getCityAll()

js
// @/service/modules/city.js
import mrRequest from '@/service/request'

export function getCityAll() {
  return mrRequest.get({
    url: '/city/all'
  })
}

在 pinia 中fetchCityAll()

js
// @/stores/modules/city.js
import { defineStore } from 'pinia'
import { getCityAll } from '@/service/modules/city'

const useCityStore = defineStore('city', {
  state: () => ({
    allCities: {}
  }),
  actions: {
    async fetchCityAll() {
      const res = await getCityAll()
      this.allCities = res.data
    }
  }
})

export default useCityStore

在 city.vue 中发起网络请求

js
/* 获取city接口数据 */
const cityStore = useCityStore()
cityStore.fetchCityAll()
const { allCities } = storeToRefs(cityStore)

出错: axios.create 的 options 参数 baseURL 的写法出错,写成了 baseUrl

image-20221123223322764

隐藏底部 tabbar

**技巧:**影藏底部 tabbar(或者说是全屏显示当前组件)的 2 种方法:

  • 1、在 router 路由中设置 meta 传参,使用组件是通过 v-if 判断是否显示

    传参:@/router/index.js

    image-20221123152801378

    接收参数并判断是否显示组件:@/app.js

    image-20221123152906330

  • 2、通过 CSS 样式设置,并封装到 common.css

    css
    /* common.css */
    .full-page {
      position: relative;
      z-index: 9;
      height: 100vh;
      background-color: #fff;
      overflow: auto;
    }

搜索区

效果:

image-20230129124047406

1、布局

image-20230129124241370

image-20230129124247719

全局定制样式

image-20230129124254281

2、取消搜索

js
// 取消搜索
  <van-search @cancel="cancelClick" />

  import { useRouter } from 'vue-router';
  const router = useRouter()
  const cancelClick = () => {
    router.back()
  }

3、执行搜索

城市切换

image-20221123160545971

1、布局

html
<!-- 标签区 -->
<van-tabs v-model:active="tabActive" color="var(--primary-color)" line-height="2px">
  <van-tab title="国内·港澳台">内容 1</van-tab>
  <van-tab title="海外">内容 2</van-tab>
</van-tabs>
js
const tabActive = ref()

2、获取城市数据 API

image-20230129164757673

1、 技巧: top 区固定显示的 2 种方式:

  • 1、通过 fixed 布局

    css
    .top {
      position: fixed;
      top: 0;
      left: 0;
      right: 0;
    }
    .content {
      margin-top: 98px;
    }

    **缺点:**滚动条是整个页面的滚动条

    image-20221127153936065

  • 2、局部滚动

    css
    .content {
      height: calc(100vh - 98px); /* 整个页面高度 - 固定top的高度,固定top为relative标准流 */
      overflow-y: auto; /* 高度超出部分auto滚动 */
    }

2、测试: vant3 中貌似没有 element-plus 中的 .native 修饰符,可以使用 dom 的原生事件?

image-20221127160037363

3、难点: 默认情况下,v-model:active 绑定的是一个索引,而 allCities 是一个对象,在遍历的时候只能通过 key 来遍历,不能通过索引遍历,而转化为索引的公式:allCities[Object.keys(allCities)[tabActive.value]] ,通过它转化后,可能就失去了响应式。

**解决:**需要通过在 van-tab 上设置 name 属性,改变 tabActive 的值为 key,这样就是可以直接遍历:allCities[tabActive.value]

image-20221127160626054

4、注意:

image-20221127163057629

结果:

image-20221127163133986

城市分组列表展示

image-20230130094213454

1、基础布局

html
<!-- city.vue -->
<div class="content">
  <city-group :group-data="currentGroup"></city-group>
</div>

<script setup>
  import CityGroup from './cpns/city-group.vue'
</script>
html
<!-- city-group.vue -->
<template>
  <div class="group">
    <template v-for="group in groupData.cities" :key="group.group">
      <van-index-bar>
        <van-index-anchor :index="group.group" />
        <template v-for="city in group.cities" :key="city.cityId">
          <van-cell :title="city.cityName" />
        </template>
      </van-index-bar>
    </template>
  </div>
</template>

<script setup>
  // 属性
  defineProps({
    groupData: {
      type: Object,
      default: () => ({})
    }
  })
</script>

2、优化切换国内/海外时的加载速度

通过 v-show 显示 city-group 组件,因为 v-show 是通过控制 display: none / block 来隐藏 / 显示组件的,切换时不用重新加载数据

html
<template v-for="(value, key, index) in allCities">
  <city-group v-show="key === tabActive" :group-data="value"></city-group>
</template>

3、BUG:上拉时会遮盖 tab 标题区域

image-20230130104856746

解决:

为 tab 标题区添加 z-index,提高层级

css
.top {
  position: relative;
  z-index: 9;
}

4、索引动态映射

少了 I

image-20230130112149458

属性 index-list 类型:{ string[] | number[] } 控制索引展示列表

html
<van-index-bar :index-list="indexList"></van-index-bar>
js
/* 索引动态映射 */
const indexList = computed(() => props.groupData.cities.map((item) => item.group))

热门数据

image-20230130110353238

1、基础布局

html
<!-- 热门城市 -->
<van-index-anchor index="热门" />
<div class="hot-city">
  <template v-for="city in groupData.hotCities" :key="city.cityId">
    <div class="city">{{ city.cityName }}</div>
  </template>
</div>

2、添加索引#

js
/* 索引动态映射 */
const indexList = computed(() => {
  const list = props.groupData.cities.map((item) => item.group) + list.unshift('#')
  return list
})

点击城市

监听 点击城市,选中当前城市,并返回上一页

html
<!-- views\city\cpns\city-group.vue -->
<!-- 热门城市 -->
<van-index-anchor index="#">热门</van-index-anchor>
<div class="hot">
  <template v-for="(city, i) in groupData.hotCities">
    +
    <div class="item" @click="onCityClick(city)">{{ city.cityName }}</div>
  </template>
</div>
<!-- 分组城市 -->
<template v-for="(group, index) in groupData.cities" :key="index">
  <van-index-anchor :index="group.group" />
  <template v-for="(city, i) in group.cities" :key="i">
    + <van-cell :title="city.cityName" @click="onCityClick(city)" />
  </template>
</template>

<script setup>
  /* 监听城市点击 */
  const router = useRouter() // 注意:此处不能写在 onCityClick 函数内部
  const onCityClick = (city) => {
    // 保存选中城市
    const cityStore = useCityStore()
    cityStore.currentCity = city

    // 返回上一页
    router.back()
  }
</script>
js
// store/modules/city.js
  state: () => ({
    allCities: {},
+    currentCity: {cityName: '合肥'}
  }),
html
<!-- views/home/cpns/search-box.vue -->
<!-- 位置、城市 -->
<div class="location">
  +
  <div class="city" @click="getCity">{{ currentCity.cityName }}</div>

  <script setup>
    +	const cityStore = useCityStore()
    +   const { currentCity } = storeToRefs(cityStore)
  </script>
</div>

search 页

detail 页

点击跳转到详情页

1、基础布局

页面搭建

html
<!-- src\views\detail\detail.vue -->
<div class="detail">detail:{{ $route.params.id }}</div>

配置路由

js
// src\router\index.js
const router = createRouter({
  history: createWebHashHistory('/trip'),
  routes: [
    {
      path: '/detail/:id',
      component: () => import('@/views/detail/detail.vue'),
      meta: { hideTabBar: true }
    }
  ]
})

2、点击 item,跳转到详情页,并通过动态路由传参

html
<!-- src\views\home\cpns\house-list.vue -->
<house-item-v9 v-if="item.discoveryContentType === 9" :item-data="item.data" @click="jumpDetail(item.data)" />
<house-item-v3 v-else-if="item.discoveryContentType === 3" :item-data="item.data" @click="jumpDetail(item.data)" />

<script setup>
  import { useRouter } from 'vue-router'
  const router = useRouter()

  /* 点击item,跳转到详情页,并通过动态路由传参 */
  function jumpDetail(itemData) {
    ;+router.push('/detail/' + itemData.houseId)
  }
</script>

数据分析

总体

image-20230201131856332

mainPart

image-20230201132030941

轮播图

image-20230201132310121

导航栏

image-20230201164324907

html
<!-- src\views\detail\detail.vue -->
<van-nav-bar title="房屋详情" left-text="旅途" left-arrow @click-left="onClickLeft" />
js
/* 返回上页 */
function onClickLeft() {
  router.back()
}

全局修改 vant 主题色

css
--van-primary-color: var(--primary-color);

轮播图

image-20230202121852622

1、基础布局

image-20230201165027917

html
<div class="swipe">
  <van-swipe class="my-swipe" :autoplay="3000" indicator-color="white">
    <!-- 轮播图片 -->
    <template v-for="(item, index) in housePics" :key="index">
      <van-swipe-item class="item">
        <img :src="item.url" alt="" />
      </van-swipe-item>
    </template>
  </van-swipe>
</div>

请求数据

server

js
// src\service\modules\detail.js
export function getDetailInfos(houseId) {
  return mrRequest.get({
    url: '/detail/infos',
    params: {
      houseId
    }
  })
}

store

js
// src\stores\modules\detail.js
const detailStore = defineStore('detail', {
  state: () => ({
    houseId: 0,
    detailInfos: {}, // 总数据
    housePics: [] // 轮播图图片(数组未分类)
  }),
  actions: {
    /* 获取 详情页数据*/
    async fetchDetailInfos() {
      const res = await getDetailInfos(this.houseId)
      // console.log(res.data);
      this.detailInfos = res.data
      this.housePics = res.data.mainPart?.topModule?.housePicture?.housePics
    }
  }
})

detail.vue 组件

js
// src\views\detail\detail.vue
// 属性
const route = useRoute()
const detailStore = useDetailStore()

/* 初始化store中的houseId */
detailStore.houseId = route.params.houseId

// 网络请求
detailStore.fetchDetailInfos()

2、自定义指示器:分组显示

image-20230201165036987

store 中将得到的 housePics 转化为分组显示的 housePicsGroup

js
const detailStore = defineStore('detail', {
  state: () => ({
    houseId: 0,
    detailInfos: {}, // 总数据
    housePics: [], // 轮播图图片(数组未分类)
+    housePicsGroup: {} // 轮播图图片(对象分类)
  }),
  actions: {
    /* 获取 详情页数据*/
    async fetchDetailInfos() {
      const res = await getDetailInfos(this.houseId)
      // console.log(res.data);
      this.detailInfos = res.data
      this.housePics = res.data.mainPart?.topModule?.housePicture?.housePics

+      for(const item of this.housePics) {
+        let valueArr = this.housePicsGroup[item.enumPictureCategory]
+        if(!valueArr) {
+        valueArr = []
+          this.housePicsGroup[item.enumPictureCategory] = valueArr
+        }
+        valueArr.push(item)
+      }
      // console.log(this.housePicsGroup);
    }
  }
})

swipe.vue 组件中使用#indicator插槽

  • 根据 enumPictureCategory 属性分组图片
  • 格式化轮播图 title 文字,去除【】:
  • 高亮显示当前图片所在的分组指示器
  • 显示高亮时所在分组中的如 卧室 2 / 7 这种效果
html
<template>
  <div class="swipe">
    <van-swipe class="my-swipe" :autoplay="3000" indicator-color="white">
      <!-- 指示器 -->
      <template #indicator="{ active, total }">
        <div class="indicator">
          <template v-for="(value, key, index) in housePicsGroup" :key="key">
            +
            <span class="room" :class="{ active: housePics[active]?.enumPictureCategory == key }">
              + <span class="text">{{ formatRoom(value[0].title) }}</span> +
              <span class="index" v-if="housePics[active]?.enumPictureCategory == key"
                >{{ getCategoryIndex(housePics[active]) }}/{{ value.length }}</span
              >
            </span>
          </template>
        </div>
      </template>
    </van-swipe>
  </div>
</template>

<script setup>
  // 属性
  const detailStore = useDetailStore()

  const { housePics, housePicsGroup } = storeToRefs(detailStore)

  /* 格式化轮播图房间分类文字 */
  function formatRoom(room) {
    const reg = /【(.*?)】:/i
    return reg.exec(room)[1]
  }

  /* 获取图片所在分类的当前索引 */
  function getCategoryIndex(item) {
  +  const valueArr = housePicsGroup.value[item.enumPictureCategory]
  +  return valueArr.findIndex(pic => pic === item) + 1
  }
</script>

基本信息

image-20230202121837232

1、获取数据

js
// src\stores\modules\detail.js
const detailStore = defineStore('detail', {
  state: () => ({
+    topModule: {}, // 基本信息(detail-info)
  }),
  actions: {
    /* 获取 详情页数据*/
    async fetchDetailInfos() {
      const res = await getDetailInfos(this.houseId)
 +     this.topModule = res.data.mainPart?.topModule
    }
  }
})

2、实现detail-info 组件

html
<!-- src\views\detail\cpns\detail-info.vue -->
<div class="detail-info">
  <h2 class="title">{{ topModule.houseName }}</h2>
  <div class="tags">
    +
    <template v-for="(item, index) in topModule.houseTags" :key="item.tagCode">
      +
      <span
        v-if="item.tagText"
        class="tag"
        :style="{ color: item.tagText.color, backgroundColor: item.tagText.background?.color }"
      >
        {{ item.tagText.text }}
      </span>
    </template>
  </div>
  <div class="comment section">
    <div class="left">
      + <span class="overall">{{ topModule.commentBrief?.overall }}</span> +
      <span class="score-title">{{ topModule.commentBrief?.scoreTitle }}</span> +
      <span class="comment-brief">{{ topModule.commentBrief?.commentBrief }}</span>
    </div>
    <div class="right">
      + <span class="text">{{ topModule.commentBrief?.totalCount }} 条评论</span>
      <van-icon name="arrow" />
    </div>
  </div>
  <div class="location section">
    +
    <div class="left">{{ topModule.nearByPosition?.address }}</div>
    <div class="right">
      <span class="text">地图·周边</span>
      <van-icon name="arrow" />
    </div>
  </div>
</div>

房屋设施

image-20230202121911526

1、获取数据

js
// src\stores\modules\detail.js
const detailStore = defineStore('detail', {
  state: () => ({
    houseFacilityFiltereds: [] // 房屋设施
  }),
  actions: {
    /* 获取 详情页数据*/
    async fetchDetailInfos() {
      const res = await getDetailInfos(this.houseId)
      const houseFacility = res.data.mainPart?.dynamicModule?.facilityModule?.houseFacility

      // 根据facilitySort筛选需要展示的数组数据
+      this.houseFacilityFiltereds = houseFacility.houseFacilitys.filter((item, index) => houseFacility.facilitySort.includes(index))
    }
  }
})

2、抽取公共组件detail-section

html
<!-- src\components\detail-section\detail-section.vue -->
<template>
  <div class="detail-section">
    +
    <h2 class="title">{{ title }}</h2>
    <div class="content">+ <slot>默认内容</slot></div>
    <div class="footer">
      + <span class="more">{{ more }}</span>
      <van-icon name="arrow" />
    </div>
  </div>
</template>

<script setup>
  defineProps({
  +  title: {
      type: String,
      default: '默认标题'
    },
  +  more: {
      type: String,
      default: '查看更多'
    }
  })
</script>

3、实现detail-facility 组件

html
<div class="detail-facility">
  <detail-section title="房屋设施" more="查看全部设施">
    <div class="content">
      +
      <template v-for="group in houseFacilityFiltereds" :key="group.groupId">
        <div class="group">
          <div class="left">
            <img class="group-icon" :src="group.icon" alt="" />
            <div class="group-name">{{ group.groupName }}</div>
          </div>
          <div class="right">
            +
            <template v-for="(tag, index) in group.facilitys" :key="index">
              +
              <div v-if="index < 4" class="tag">
                <img class="tag-icon" src="@/assets/img/detail/icon_check.png" alt="" />
                <div class="tag-text">{{ tag.name }}</div>
              </div>
            </template>
          </div>
        </div>
      </template>
    </div>
  </detail-section>
</div>

房东介绍

image-20230202121748481

1、请求数据

js
// src\stores\modules\detail.js
const detailStore = defineStore('detail', {
  state: () => ({
+    landlord: {}, // 房东介绍
  }),
  actions: {
    /* 获取 详情页数据*/
    async fetchDetailInfos() {
      const res = await getDetailInfos(this.houseId)
+      this.landlord = res.data.mainPart?.dynamicModule?.landlordModule
  }
})

2、实现 detail-landlord 组件

html
<!-- src\views\detail\cpns\detail-landlord.vue -->
<div class="detail-landlord">
  <detail-section title="房东介绍" more="查看房东主页">
    <div class="content">
      <div class="intro-head">
        <img :src="landlord.topScroll" alt="" />
      </div>
      <div class="info">
        <img class="left" :src="landlord.hotelLogo" alt="" />
        <div class="center">
          <div class="name">{{ landlord.hotelName }}</div>
          <div class="tags">
            <template v-for="(tag, index) in landlord.hotelTags" :key="index">
              <span v-if="tag.tagText" class="tag">{{ tag.tagText.text }}</span>
              <span v-if="index < landlord.hotelTags.length - 1" class="split">|</span>
            </template>
          </div>
        </div>
        <div class="right">
          <div class="btn">联系房东</div>
        </div>
      </div>
      <div class="summary">
        <template v-for="(item, index) in landlord.hotelSummary" :key="index">
          <div class="item">
            <div class="item-title">{{ item.title }}</div>
            <div class="item-introduction">{{ item.introduction }}</div>
            <div class="item-tip" v-html="formatTip(item.tip, item.highLight)"></div>
          </div>
        </template>
      </div>
    </div>
  </detail-section>
</div>

<script setup>
  // 引入
  import DetailSection from '@/components/detail-section/detail-section.vue'

  import useDetailStore from '@/stores/modules/detail'
  import { storeToRefs } from 'pinia'

  // 属性
  const detailStore = useDetailStore()

  const { landlord } = storeToRefs(detailStore)

  // 方法
  /* 添加高亮格式 */
  function formatTip(tip, highLight) {
      // 注意:当写成class="high-light" 时无法解析样式
      // 写成 style="color: var(--primary-color)" 也无法解析样式
      // 写成 :style="{color: var(--primary-color)}" 也无法解析样式
      // 只有写死成以下方式才能解析
  +  return tip.replace(highLight, `<span style="color: #ff9854"> ${highLight} </span>`)
  }
</script>

房客点评

image-20230202121800464

预定须知

image-20230202121934671

位置周边@

image-20230202143028774

百度地图开发文档地址:https://lbsyun.baidu.com/index.php?title=jspopularGL/guide/helloworld

1、注册百度地图开放平台 账号并完成开发者认证

2、创建应用并获取 appkey

image-20230202145212765

image-20230202145436127

tab-control 点击滚动@

1、自己封装 tab-control 组件

html
<div class="tab-control">
  <template v-for="(item, index) in titles" :key="index">
    <div class="tab-control-item" @click="onItemClick(index)">
      <div class="text" :class="{ active: currIndex === index }">{{ item }}</div>
    </div>
  </template>
</div>

<script setup>
  // 引入
  import { ref } from 'vue'

  // props
  defineProps({
    titles: {
      type: Array,
      default: () => []
    }
  })

  // 属性
  const currIndex = ref(0)

  // 抛出事件
  const emit = defineEmits(['itemClick'])

  // 方法
  /* 点击item,选择当前项 */
  function onItemClick(index) {
    currIndex.value = index
    emit('itemClick', index)
  }
</script>

2、在组件内部向外发射事件

组件内部定义事件 itemClick ,并向外发射(同时传递参数 index

js
    // 抛出事件
+    const emit = defineEmits(['itemClick'])

    // 方法
    /* 点击item,选择当前项 */
    function onItemClick(index) {
      currIndex.value = index
+      emit('itemClick', index)
    }

组件外部使用定义的事件

html
+ <tab-control :titles="titles" @item-click="itemClick"></tab-control>

+ function itemClick(index) { + console.log(index); }

3、控制 tab 的显示、隐藏

组件外部定义变量showTabControl控制 tab 的显示和隐藏,showTabControl根据滚动的位置计算而来

image-20241112210726363

4、点击 tab,滚动到指定位置

1、实现<tab-control>组件暴露的@tabItemClick事件实现函数tabClick()

image-20241112214612748

2、在tabClick()实现滚动到对应组件的位置

image-20241112214818589

3、注意: 绑定组件时可以通过:ref="fnRef"给 ref 绑定一个函数的方式批量绑定组件

image-20241112215115894

image-20241112215225973

4、注意: 由于受到滚动影响,组件会不断刷新,可以使用v-memo="[mainPart]"实现只有 mainPart 数据变化时,组件才会刷新

image-20241112215433622

5、实现点击标签项,滚动到指定组件的位置,第一个组件特殊处理

image-20241112215708399

6、关联<tab-control>中的标签项titles和组件列表sectionEls

  • 为每个组件添加 name 属性

    image-20241112220142383

  • 根据组件的 name 属性,动态生成 titles

    image-20241112221025097

    image-20241112221037547

页面滚动匹配 tab-control 索引@

分析:

image-20230203165016108


实现过程:

image-20241113102548358


匹配算法: 一个一直变化的值(scrollTop)去一个数组(values)中寻找自己的位置的算法

image-20241113104315450


匹配算法其他应用: 歌词匹配

BUG:点击 tab 时会出现跳动

分析: 这是因为点击周边时,页面会从设施一直滚动到周边的位置,滚动的时候会不断触发匹配索引。

解决: 在点击标签时,禁用匹配索引

1、设置isClick控制变量,当点击时设置isClick = true

image-20241113105653307

2、在页面滚动的监听函数中,判断是否处于点击状态,如果是则直接返回

image-20241113105840753

3、 当滚动到指定位置后,设置isClick = false

  • 设置currentDistance,初始时设置为滚动的目标距离

    image-20241113110412156

  • 在滚动监听函数中判断当滚动到目标距离后,重新设置 isClick 为 false

    image-20241113110513429

其他问题

切换页面的 keep-alive@

为了让 home 页面在离开后再回来的时候,不再重新发送网络请求数据,需要对它进行缓存,此时就用到 <keep-alive> 组件

html
<!-- App.vue -->
<router-view v-slot="{ Component }">
  <keep-alive include="Home">
    <component :is="Component"></component>
  </keep-alive>
</router-view>
js
// home.vue
export default {
  name: 'Home'
}

注意: keep-alive 的属性 include 需要保存 home 组件的 name 值

首页切换其他页后 nextPage 加 1

image-20230211125829899

原因:

这是由于其他页面的页面高度没有溢出,触发了 usScroll() 的滚动到底部 ,首页的滚动监听是监听的 window.addEventListener ,在离开页面后,依然处于监听 window 的状态

解决:

  • 方法一:每次离开 Home 页后,移除 window 的监听事件

    js
    onUnmounted(() => {
      el.removeEventListener('scroll', onScrollHandler)
    })
    onDeactivated(() => {
      el.removeEventListener('scroll', onScrollHandler)
    })
  • 方法二:不要监听 window 了,直接监听 home 的根元素 .home

    html
    <template>
      <!-- 2. 绑定 ref -->
      <div class="home" ref="homeRef">
    </template>
    
    <script setup>
    // 3. useScroll 监听滚动时,监听 homeRef的滚动
    const homeRef = ref()
    const { isReachBottom, scrollTop } = useScroll(homeRef)
    
    /* 4. 离开home页再回来时,保存滚动位置 */
    onActivated(() => {
      homeRef.value?.scrollTo({
        top: scrollTop.value
      })
    })
    
    </script>
    
    <style lang="less" scoped>
    /* 1. 设置 .home 元素一个固定的高度,让它可以触发滚动事件 */
    .home {
      height: 100vh;
      box-sizing: border-box;
      overflow-y: auto;
      padding-bottom: 50px;
    }
    </style>

禁止视口缩放

html
<meta
  name="viewport"
  content="width=device-width, initial-scale=1.0, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0"
/>

pxtovw 单位转换

插件: postcss-px-to-viewport

安装:

sh
npm i postcss-px-to-viewport -D

配置:

创建 postcss.config.js 文件,配置如下

js
module.exports = {
  plugins: {
    'postcss-px-to-viewport': {
      viewportWidth: 375
    }
  }
}

postcss-px-to-viewport 完整配置:

js
    {
      unitToConvert: 'px',
+	  viewportWidth: 320,  // 设计稿的视口宽度
      unitPrecision: 5,
+      propList: ['*'], // 能转化为vw的属性列表
      viewportUnit: 'vw',
      fontViewportUnit: 'vw',
      selectorBlackList: ['favor'], // 需要忽略的CSS选择器,不会转为视口单位,使用原有的px等单位
      minPixelValue: 1,
      mediaQuery: false,
      replace: true,
      exclude: undefined, // 忽略某些文件夹下的文件或特定文件,例如 'node_modules' 下的文件
      include: undefined, // 如果设置了include,那将只有匹配到的文件才会被转换,例如只转换 'src/mobile' 下的文件 (include: /\/src\/mobile\//)
      landscape: false,
      landscapeUnit: 'vw',
      landscapeWidth: 568
    }

注意: 当前(2023-02-11) postcss-px-to-viewport 插件已经过期:

js
postcss-px-to-viewport: postcss.plugin was deprecated. Migration guide:
https://evilmartians.com/chronicles/postcss-8-plugin-migration

项目打包

1、打包

执行命令,生成 dist 文件夹,就是打包后的内容

sh
npm run bulid

2、预览

sh
npm run preview

项目部署

错误日志

1、部署线上服务器时,会出现 CORS 跨域错误

出错描述:

http://180.76.178.61/trip/#/home

部署在百度服务器上时,出现在以下报错

但是有时候多刷新(强制刷新)几次又能显示正常

image-20221126111733210

此时 nginx 的配置如下

sh
        location / {
          # 配置 nginx的跨域问题 CORS
          # add_header Access-Control-Allow-Origin *;
          # if ($request_method = 'OPTIONS') {
          #     return 204;
          # }

          root /root/mr-vue3-ts-cms-v1;
          index index.html;
        }

        location /trip {
          # 配置 nginx的跨域问题 CORS
          # add_header Access-Control-Allow-Origin *;
          # if ($request_method = 'OPTIONS') {
          #     return 204;
          # }

          alias /root/mr-trip;
          index index.html;
        }

项目的 vite.config.js 配置如下

js
  server: {
    cors: true,
    proxy: {
      '/api': {
        target: 'http://codercba.com:1888',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, '')
      }
    }
  }
  • 2022-11-26 解决思路:修改 nginx 如下
sh
        location / {
          # 配置 nginx的跨域问题 CORS
          add_header Access-Control-Allow-Origin *;
          if ($request_method = 'OPTIONS') {
              return 204;
          }

          root /root/mr-vue3-ts-cms-v1;
          index index.html;
        }

        location /trip {
          # 配置 nginx的跨域问题 CORS
          add_header Access-Control-Allow-Origin *;
          if ($request_method = 'OPTIONS') {
              return 204;
          }

          alias /root/mr-trip;
          index index.html;
        }

结果:

依然无效,报错如下 network(和之前的错误一样)

image-20221126113325472

2、城市页索引字体很粗

image-20230130102101108

原因&解决:

<van-index-bar> 写在了 <template> 里面了,应该写在外面

3、Vue Router 切换时报错:api.now is not a function

报错:

image-20230130120114772

原因:

安装了 beta 版本的 vue devtools

image-20230130120200270

解决:

安装正式版的 devtools

4、响应式总结

如图

image-20230130154410998

5、行高行距的分配问题

问题:font-size: 12px 时,即使设置了 line-height: 12px 文字也会向上偏移

image-20230130165534749

分析:

这是由于 normalize.css 在初始化时设置了 line-height: 1.15

12 * 1.15 = 13.8 ,而在渲染时 13.8px 会被当做 13px 计算,这样就多了 1px,无法平均分配,在显示时就是偏上一些

image-20230130170244953

解决: 设置line-height: 1 ,这样就是 12px 的行高了,此时就不会多出 1px 来

6、浏览器无法获取到定位的问题

问题:

在 windows 系统的 chrome 浏览器中,无法通过 navigator.geolocation.getCurrentPosition 获取到经纬度定位

经测试,在 chrome, firefox 浏览器中,获取失败;在 edge 浏览器中,获取成功;在手机端没有问题,可以获取到

js
/* 获取地理位置 */
function getPosition() {
  navigator.geolocation.getCurrentPosition(
    (pos) => {
      console.log('获取地理位置成功:', pos)
    },
    (err) => {
      console.log('获取地理位置失败:', err)
    },
    {
      timeout: 3000
    }
  )
}

image-20230130172102389

原因: geolocation 是由浏览器自行实现的,所以不同的浏览器的实现方式时不同的,谷歌需要连接自己的数据库(要翻墙),edge 可以直接在 win 系统中获取定位

7、计算属性先定义后计算的写法是错误的

如图,此时的 isShowSearchBar 的值为 undefined

image-20230131171508914

正确的写法:

image-20230131171550347

8、监听 element 而非 window 的 scroll 事件时,需要在最后添加 true

如图:原因未知

image-20230203122403549

测试: 尝试在一个全新干净的 vue 项目中再测试一遍看看

原因:

在进行 detail.vue 页面全屏时,用的时在 route 中添加 meta.hideTabBar 的方法

而不是添加的.full-page 样式,所以 detail 页面没有设置固定的高度heightoverflow: auto,如法触发 onScroll 事件

image-20230203123902734

9、在获取 getCpnRef 时,每次滚动时都会重新执行一遍 getCpnRef

image-20230203132121345

每次滚动的时候都会执行 getCpnRef

image-20230203132152891

解决:

方法一:使用 v-memo ,它的作用是只有当detailInfos 数据发生变化的时候,才会重新渲染当前元素包裹的内容

image-20230203132532291

方法二:

10、要慎用 v-memo

image-20230203145910079

11、报错

js
Cannot read properties of null (reading '$el')

原因: 绑定 ref 函数 getCpnRef 时,不但在加载(onMounted)的时候会执行一次 getCpnRef ,在卸载(onUnmounted)组件的时候也会执行一次 getCpnRef(el),而卸载时的 el 值为 undefined,所以会报错

image-20230203153423501

解决:

image-20230203153957999

12、组件外部通过 ref 调用组件内部的方法(setup 标签)

js
    // 组件内部
+    defineExpose({
+      setCurrIndex
    })

    /* 设置 currIndex */
+    function setCurrIndex(index) {
      currIndex.value = index
    }
html
// 组件外部调用 + <tab-control ref="tabControlRef"></tab-control>

<script setup>
  ;+tabControlRef.value?.setCurrIndex(index)
</script>