9 Vue.js 复杂 UI 实战:从双层循环、CSS 穿透到 Pinia 状态管理与性能优化 奇淫巧计之vue3大型项目keep-alive缓存提升加载性能技巧!


双层for-template循环- 测试热门城市- css样式调整 - 动态数据获取-封装store- pinia-嵌套组件 -动态组件引入- 人们推荐列表拼音首字目bug修改

就问麻不麻

上面是开发中实录...

0. 引言:告别“麻”烦,迎接 Vue.js 的优雅之旅

在 Vue.js 的开发中,我们经常面临构建复杂用户界面的挑战。从多层数据循环渲染,到动态组件的按需加载,再到跨组件的状态管理,每一步都可能让人感到“麻不麻”。特别是当你需要处理用户体验、性能优化以及代码维护性之间的平衡时,这些问题会变得尤为突出。

本文将带领你深入探索 Vue.js 在处理这些复杂场景时的最佳实践,包括:

  • 双层 for-template 循环: 如何高效、清晰地渲染复杂列表数据。
  • 测试热门城市功能: 设计与实现一个实用且用户友好的城市选择器。
  • CSS 样式调整: 深入理解 scoped CSS 的边界与穿透技巧,打造像素完美的 UI。
  • 动态数据获取: 策略与实践,确保数据流的顺畅与高效。
  • 封装 Store - Pinia: 掌握下一代 Vue 状态管理库 Pinia,实现简洁、可维护的全局状态。
  • 嵌套组件: 构建模块化、可复用的组件体系。
  • 动态组件引入: 优化应用性能,实现组件的按需加载。
  • 热门推荐列表拼音首字母 Bug 修复: 解决实际开发中常见的数据处理问题,提升用户体验。

我们将通过一个模拟“城市选择器”的实际案例,贯穿所有知识点,让你不仅理解原理,更能掌握实战技巧。准备好了吗?让我们一起告别“麻”烦,开启 Vue.js 的优雅之旅!


1. 深入浅出:双层 for-template 循环的高效渲染

在构建复杂的列表界面时,例如城市列表按字母分类显示,我们常常需要用到双层甚至多层循环。Vue 的 v-for 指令提供了强大的能力来处理这类场景,但如何写得清晰、高效,并避免潜在的性能问题,是我们需要关注的。

1.1 理解 v-for 的基本用法与 key 的重要性

v-for 用于迭代数组或对象,并为每个元素渲染一个模板块。

程式碼片段

<template>
  <div>
    <p v-for="item in items" :key="item.id">{{ item.name }}</p>
  </div>
</template>

<script>
export default {
  data() {
    return {
      items: [
        { id: 1, name: 'Apple' },
        { id: 2, name: 'Banana' },
        { id: 3, name: 'Orange' }
      ]
    };
  }
};
</script>

key 的重要性:

v-for 中使用 :key 属性是至关重要的key 帮助 Vue 识别每个节点的唯一性,从而高效地重用和重新排序现有元素,而不是从头开始渲染。这对于性能优化、动画以及避免状态混乱(例如在列表项中包含表单输入)都非常关键。

  • 唯一性: key 必须是唯一的,通常是数据项的 ID。
  • 稳定性: key 应该是稳定的,不应在数据项顺序变化时改变。
  • 避免使用索引作为 key 除非你的列表项是静态的且不会发生变化,否则避免使用 v-for 提供的索引作为 key,这会导致性能问题和不可预测的行为。

1.2 城市列表场景下的双层 v-for 实现

假设我们有一个城市数据结构,按首字母分组:

JavaScript

// 示例城市数据结构
const cityData = [
  {
    initial: 'A',
    cities: [{ id: 'A001', name: '安阳' }, { id: 'A002', name: '鞍山' }]
  },
  {
    initial: 'B',
    cities: [{ id: 'B001', name: '北京' }, { id: 'B002', name: '保定' }]
  },
  // ... 更多城市数据
];

在模板中实现双层循环:

程式碼片段

<template>
  <div class="city-list">
    <div v-for="group in cityGroups" :key="group.initial" class="city-group">
      <h3 class="initial-header">{{ group.initial }}</h3>
      <ul class="cities-ul">
        <li v-for="city in group.cities" :key="city.id" class="city-item">
          {{ city.name }}
        </li>
      </ul>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      cityGroups: [
        {
          initial: 'A',
          cities: [{ id: 'A001', name: '安阳' }, { id: 'A002', name: '鞍山' }]
        },
        {
          initial: 'B',
          cities: [{ id: 'B001', name: '北京' }, { id: 'B002', name: '保定' }]
        },
        {
          initial: 'C',
          cities: [{ id: 'C001', name: '成都' }, { id: 'C002', name: '重庆' }]
        },
      ]
    };
  }
};
</script>

<style scoped>
.city-list {
  max-height: 400px;
  overflow-y: auto;
  border: 1px solid #ddd;
  border-radius: 8px;
  padding: 15px;
}
.city-group {
  margin-bottom: 20px;
}
.initial-header {
  font-size: 1.2em;
  color: #333;
  margin-top: 0;
  margin-bottom: 10px;
  padding: 5px 0;
  border-bottom: 1px solid #eee;
  position: sticky; /* For sticky headers in scrollable list */
  top: 0;
  background-color: #fff;
  z-index: 10;
}
.cities-ul {
  list-style: none;
  padding: 0;
  margin: 0;
  display: flex; /* For horizontal layout */
  flex-wrap: wrap; /* Allow wrapping */
  gap: 10px; /* Space between city items */
}
.city-item {
  padding: 8px 12px;
  background-color: #f0f0f0;
  border-radius: 4px;
  cursor: pointer;
  transition: background-color 0.2s ease;
  white-space: nowrap; /* Prevent city names from breaking */
}
.city-item:hover {
  background-color: #e0e0e0;
}
</style>

关键点:

  • 外层 v-for="group in cityGroups" 循环每个字母分组,key 使用 group.initial
  • 内层 v-for="city in group.cities" 循环每个分组内的城市,key 使用 city.id
  • 样式中利用 position: sticky 实现了字母标题的吸顶效果,提升用户体验。

1.3 性能考量与优化策略

虽然双层循环在逻辑上简单,但如果数据量巨大,仍需考虑性能:

  • 虚拟列表/长列表优化: 当城市数量达到数千甚至上万时,一次性渲染所有 DOM 会导致性能问题。此时应考虑使用虚拟列表(Virtual Scroll)技术,只渲染可视区域内的列表项。Vue 生态中有像 vue-virtual-scrollervue-recycle-scroller 这样的库可以帮助实现。
  • 数据预处理: 如果原始数据格式不适合直接渲染,可以在组件 createdcomputed 属性中进行预处理,将其转换为更适合 v-for 的结构。

2. 丰富功能:热门城市与近期访问

除了按字母排序的城市列表,一个实用的城市选择器通常还需要展示“热门城市”和“近期访问城市”等快捷入口。

2.1 设计热门城市模块

热门城市通常是固定的或通过后端接口获取的精选城市列表。它们可以直接展示在城市列表的顶部。

程式碼片段

<template>
  <div class="hot-cities">
    <h3 class="section-header">热门城市</h3>
    <ul class="hot-cities-ul">
      <li
        v-for="city in hotCities"
        :key="city.id"
        class="hot-city-item"
        @click="selectCity(city)"
      >
        {{ city.name }}
      </li>
    </ul>
  </div>
</template>

<script>
export default {
  name: 'HotCities',
  props: {
    hotCities: {
      type: Array,
      default: () => []
    }
  },
  emits: ['select-city'],
  methods: {
    selectCity(city) {
      this.$emit('select-city', city);
    }
  }
};
</script>

<style scoped>
.hot-cities {
  margin-bottom: 30px;
  padding: 15px;
  background-color: #f9f9f9;
  border-radius: 8px;
}
.section-header {
  font-size: 1.1em;
  color: #555;
  margin-bottom: 15px;
  border-bottom: 1px dashed #eee;
  padding-bottom: 8px;
}
.hot-cities-ul {
  list-style: none;
  padding: 0;
  margin: 0;
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
}
.hot-city-item {
  padding: 10px 15px;
  background-color: #e0f2f7; /* Light blue background */
  border-radius: 20px;
  cursor: pointer;
  font-size: 0.95em;
  color: #007bff;
  transition: background-color 0.2s ease, transform 0.2s ease;
  box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05);
}
.hot-city-item:hover {
  background-color: #cceeff; /* Darker blue on hover */
  transform: translateY(-2px);
}
</style>

2.2 设计近期访问城市模块

近期访问城市需要存储在本地(例如 localStorage),并在每次用户选择城市时进行更新。

程式碼片段

<template>
  <div class="recent-cities">
    <h3 class="section-header">近期访问</h3>
    <p v-if="recentCities.length === 0" class="no-data-hint">暂无近期访问城市</p>
    <ul v-else class="recent-cities-ul">
      <li
        v-for="city in recentCities"
        :key="city.id"
        class="recent-city-item"
        @click="selectCity(city)"
      >
        {{ city.name }}
      </li>
    </ul>
  </div>
</template>

<script>
export default {
  name: 'RecentCities',
  props: {
    recentCities: {
      type: Array,
      default: () => []
    }
  },
  emits: ['select-city'],
  methods: {
    selectCity(city) {
      this.$emit('select-city', city);
    }
  }
};
</script>

<style scoped>
.recent-cities {
  margin-bottom: 30px;
  padding: 15px;
  background-color: #fcfcfc;
  border-radius: 8px;
}
.no-data-hint {
  color: #999;
  font-style: italic;
  text-align: center;
  padding: 10px 0;
}
.recent-cities-ul {
  list-style: none;
  padding: 0;
  margin: 0;
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
}
.recent-city-item {
  padding: 8px 12px;
  background-color: #f7f7f7;
  border: 1px solid #eee;
  border-radius: 4px;
  cursor: pointer;
  font-size: 0.9em;
  transition: background-color 0.2s ease;
}
.recent-city-item:hover {
  background-color: #e9e9e9;
}
</style>

3. 像素完美:CSS 样式调整与 scoped 的艺术

3.1 scoped CSS 的作用域限制与原理回顾

正如引言中所述,scoped CSS 通过为组件的 DOM 元素添加唯一 data-v-xxxxxx 属性来隔离样式,避免全局污染。

原理简述: 当你写:

CSS

.my-class {
  color: red;
}

编译后可能变为:

CSS

.my-class[data-v-f3f3eg9g] {
  color: red;
}

这意味着这个样式只对带有 data-v-f3f3eg9g 属性的 .my-class 元素生效。子组件的 DOM 不会拥有父组件的 data-v-xxxxxx 属性,因此父组件的 scoped 样式无法直接作用于子组件内部。

3.2 样式穿透:::v-deep:deep() 的实战

为了解决 scoped 样式无法穿透子组件的问题,Vue 提供了样式穿透选择器。

  • ::v-deep (推荐):伪元素选择器,例如 .parent-wrapper ::v-deep .child-inner-selector
  • :deep() (Vue 3+ 推荐):伪类函数,例如 .parent-wrapper :deep(.child-inner-selector)

Vant UI 库样式穿透实战: 假设我们使用 Vant 的 van-tabbarvan-tabbar-item 组件,并且想修改激活状态下的文字颜色。

程式碼片段

<template>
  <div class="my-custom-tabbar-wrapper">
    <van-tabbar v-model="active">
      <van-tabbar-item icon="home-o">首页</van-tabbar-item>
      <van-tabbar-item icon="search">发现</van-tabbar-item>
      <van-tabbar-item icon="friends-o">我的</van-tabbar-item>
    </van-tabbar>
  </div>
</template>

<script>
import { ref } from 'vue';
// 假设你已正确引入 Vant 组件
// import { Tabbar, TabbarItem } from 'vant';

export default {
  name: 'MyCustomTabbar',
  setup() {
    const active = ref(0);
    return { active };
  }
};
</script>

<style scoped>
.my-custom-tabbar-wrapper {
  /* 父组件自身样式 */
  background-color: #f8f8f8;
  padding-top: 10px;
}

/* 使用 ::v-deep 穿透修改 Vant 组件内部样式 */
.my-custom-tabbar-wrapper ::v-deep .van-tabbar-item--active {
  background-color: #e6f7ff; /* 激活项背景色 */
}

.my-custom-tabbar-wrapper ::v-deep .van-tabbar-item--active .van-tabbar-item__text,
.my-custom-tabbar-wrapper ::v-deep .van-tabbar-item--active .van-icon {
  color: #007bff !important; /* 激活项文字和图标颜色 */
  font-weight: bold;
}

/* 或者使用 :deep() */
/*
.my-custom-tabbar-wrapper :deep(.van-tabbar-item--active .van-tabbar-item__text),
.my-custom-tabbar-wrapper :deep(.van-tabbar-item--active .van-icon) {
  color: #28a745 !important; // 另一种颜色
}
*/
</style>

重要提示:

  • 查看 DOM 结构: 在使用样式穿透前,务必通过浏览器开发者工具检查第三方组件(如 Vant)渲染出的实际 DOM 结构和类名。这是编写正确选择器的基础。
  • 精确性: 尽可能将穿透选择器限定在父组件的特定区域内,例如 .my-custom-tabbar-wrapper ::v-deep ...,而不是全局的 ::v-deep ...,以避免不必要的污染。
  • !important 尽量避免使用 !important,因为它会破坏 CSS 的层叠规则,使样式难以管理。如果必须使用,请确保其使用的必要性,并考虑是否有通过提高选择器特异性来覆盖样式的替代方案。

3.3 响应式设计与媒体查询

为了适应不同屏幕尺寸,可以使用 CSS 媒体查询:

CSS

<style scoped>
/* ... 其他样式 ... */

/* 针对小屏幕设备 */
@media (max-width: 768px) {
  .city-list {
    padding: 10px;
  }
  .city-item {
    font-size: 0.85em;
    padding: 6px 10px;
  }
  .hot-city-item, .recent-city-item {
    font-size: 0.8em;
    padding: 8px 12px;
  }
}

/* 针对大屏幕设备 */
@media (min-width: 1200px) {
  .city-list {
    max-height: 600px;
  }
  .initial-header {
    font-size: 1.3em;
  }
}
</style>

4. 数据为王:动态数据获取与处理

一个真实的城市选择器的数据通常来自后端 API。

4.1 数据获取策略

  • onMounted / created 在组件挂载后(或创建后)立即发起数据请求。
  • 异步组件 / 路由懒加载: 如果数据量大或组件复杂,可以结合 Vue 的异步组件或路由懒加载,将组件的渲染推迟到需要时,减少初始加载时间。

程式碼片段

<template>
  <div class="city-selector">
    <current-location @select-city="handleCitySelect"></current-location>
    <hot-cities :hotCities="hotCities" @select-city="handleCitySelect"></hot-cities>
    <recent-cities :recentCities="recentCities" @select-city="handleCitySelect"></recent-cities>
    <city-list :cityGroups="cityGroups" @select-city="handleCitySelect"></city-list>
    <div v-if="loading" class="loading-overlay">加载中...</div>
    <div v-if="error" class="error-message">加载失败:{{ error }}</div>
  </div>
</template>

<script>
import { ref, onMounted } from 'vue';
import HotCities from './HotCities.vue';
import RecentCities from './RecentCities.vue';
import CityList from './CityList.vue'; // Assume this is the component with double v-for
// import CurrentLocation from './CurrentLocation.vue'; // Assume a component for current location

// 模拟 API 请求
const fetchCityData = () => {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve({
        hotCities: [
          { id: 'S001', name: '上海' },
          { id: 'B001', name: '北京' },
          { id: 'G001', name: '广州' },
          { id: 'S002', name: '深圳' },
          { id: 'H001', name: '杭州' },
        ],
        allCities: [
          { name: '安阳', pinyin: 'anyang' },
          { name: '鞍山', pinyin: 'anshan' },
          { name: '北京', pinyin: 'beijing' },
          { name: '保定', pinyin: 'baoding' },
          { name: '成都', pinyin: 'chengdu' },
          { name: '重庆', pinyin: 'chongqing' },
          { name: '大连', pinyin: 'dalian' },
          { name: '东莞', pinyin: 'dongguan' },
          { name: '鄂尔多斯', pinyin: 'eerduosi' },
          { name: '佛山', pinyin: 'foshan' },
          { name: '福州', pinyin: 'fuzhou' },
          { name: '广州', pinyin: 'guangzhou' },
          { name: '贵阳', pinyin: 'guiyang' },
          { name: '海口', pinyin: 'haikou' },
          { name: '哈尔滨', pinyin: 'haerbin' },
          { name: '呼和浩特', pinyin: 'huhehaote' },
          { name: '济南', pinyin: 'jinan' },
          { name: '昆明', pinyin: 'kunming' },
          { name: '兰州', pinyin: 'lanzhou' },
          { name: '拉萨', pinyin: 'lasa' },
          { name: '洛阳', pinyin: 'luoyang' },
          { name: '南京', pinyin: 'nanjing' },
          { name: '南宁', pinyin: 'nanning' },
          { name: '宁波', pinyin: 'ningbo' },
          { name: '青岛', pinyin: 'qingdao' },
          { name: '泉州', pinyin: 'quanzhou' },
          { name: '上海', pinyin: 'shanghai' },
          { name: '沈阳', pinyin: 'shenyang' },
          { name: '石家庄', pinyin: 'shijiazhuang' },
          { name: '深圳', pinyin: 'shenzhen' },
          { name: '苏州', pinyin: 'suzhou' },
          { name: '天津', pinyin: 'tianjin' },
          { name: '太原', pinyin: 'taiyuan' },
          { name: '武汉', pinyin: 'wuhan' },
          { name: '无锡', pinyin: 'wuxi' },
          { name: '西安', pinyin: 'xian' },
          { name: '厦门', pinyin: 'xiamen' },
          { name: '西宁', pinyin: 'xining' },
          { name: '银川', pinyin: 'yinchuan' },
          { name: '长春', pinyin: 'changchun' },
          { name: '长沙', pinyin: 'changsha' },
          { name: '郑州', pinyin: 'zhengzhou' },
          { name: '重庆', pinyin: 'chongqing' },
          { name: '珠海', pinyin: 'zhuhai' },
        ]
      });
    }, 1000);
  });
};

// 辅助函数:获取城市拼音首字母
import { pinyin } from 'pinyin-pro'; // 假设已安装 pinyin-pro

const getPinyinInitial = (cityName) => {
  if (!cityName) return '#';
  const p = pinyin(cityName, { toneType: 'none', type: 'array' });
  if (p && p.length > 0 && p[0].length > 0) {
    return p[0][0].toUpperCase();
  }
  return '#'; // 无法获取拼音的默认值
};

export default {
  name: 'CitySelectorPage',
  components: {
    HotCities,
    RecentCities,
    CityList,
    // CurrentLocation
  },
  setup() {
    const loading = ref(true);
    const error = ref(null);
    const hotCities = ref([]);
    const allCities = ref([]);
    const cityGroups = ref([]);
    const recentCities = ref([]);

    // 从 localStorage 获取近期访问城市
    const getRecentCitiesFromLocalStorage = () => {
      try {
        const stored = localStorage.getItem('recent_cities');
        return stored ? JSON.parse(stored) : [];
      } catch (e) {
        console.error("Failed to parse recent cities from localStorage", e);
        return [];
      }
    };

    // 保存近期访问城市到 localStorage
    const saveRecentCitiesToLocalStorage = (city) => {
      let currentRecents = getRecentCitiesFromLocalStorage();
      // 移除已存在的城市,确保最新访问的在最前面
      currentRecents = currentRecents.filter(item => item.id !== city.id);
      currentRecents.unshift(city); // 添加到最前面
      // 限制数量,例如只保留最新 5 个
      if (currentRecents.length > 5) {
        currentRecents = currentRecents.slice(0, 5);
      }
      localStorage.setItem('recent_cities', JSON.stringify(currentRecents));
      recentCities.value = currentRecents; // 更新响应式数据
    };

    const processCityData = (cities) => {
      const groups = {};
      cities.forEach(city => {
        // 为每个城市生成一个简单的ID,或者使用后端提供的ID
        city.id = city.id || `${city.name}-${getPinyinInitial(city.name)}`; // 确保id存在
        const initial = getPinyinInitial(city.name);
        if (!groups[initial]) {
          groups[initial] = { initial: initial, cities: [] };
        }
        groups[initial].cities.push(city);
      });

      // 将对象转换为数组并按字母排序
      const sortedGroups = Object.values(groups).sort((a, b) => a.initial.localeCompare(b.initial));
      return sortedGroups;
    };

    onMounted(async () => {
      loading.value = true;
      error.value = null;
      try {
        const data = await fetchCityData();
        hotCities.value = data.hotCities;
        allCities.value = data.allCities.map((city, index) => ({
          ...city,
          id: city.id || `${city.name}-${index}`, // Ensure each city has a unique ID
          initial: getPinyinInitial(city.name) // Add initial for sorting
        }));
        cityGroups.value = processCityData(allCities.value);
        recentCities.value = getRecentCitiesFromLocalStorage(); // 加载近期访问城市
      } catch (e) {
        error.value = e.message;
      } finally {
        loading.value = false;
      }
    });

    const handleCitySelect = (city) => {
      console.log('Selected city:', city.name);
      saveRecentCitiesToLocalStorage(city); // 保存到近期访问
      // 可以在这里触发路由跳转或者其他业务逻辑
      alert(`您选择了:${city.name}`);
    };

    return {
      loading,
      error,
      hotCities,
      cityGroups,
      recentCities,
      handleCitySelect
    };
  }
};
</script>

<style scoped>
.city-selector {
  padding: 20px;
  background-color: #fff;
  border-radius: 10px;
  box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
}
.loading-overlay, .error-message {
  text-align: center;
  padding: 20px;
  font-size: 1.1em;
  color: #666;
}
.error-message {
  color: red;
}
</style>

4.2 数据处理与格式化

原始数据可能不是我们需要的最终格式。例如,城市列表需要按拼音首字母分组。

JavaScript

// 在 CitySelectorPage.vue 中的 processCityData 函数
const processCityData = (cities) => {
  const groups = {};
  cities.forEach(city => {
    // 为每个城市生成一个简单的ID,或者使用后端提供的ID
    // 假设 city 对象已经有 id 或者可以通过 name 生成一个稳定的 id
    // 为了健壮性,我们在此处为没有id的城市生成一个基于name和index的id
    const cityId = city.id || `${city.name}_${Math.random().toString(36).substr(2, 9)}`; // 生成一个足够唯一的ID
    const initial = getPinyinInitial(city.name); // 使用 pinyin-pro 获取首字母
    if (!groups[initial]) {
      groups[initial] = { initial: initial, cities: [] };
    }
    groups[initial].cities.push({ ...city, id: cityId }); // 添加 id 到城市对象
  });

  // 将对象转换为数组并按字母排序
  const sortedGroups = Object.values(groups).sort((a, b) => a.initial.localeCompare(b.initial));
  return sortedGroups;
};

我们引入了 pinyin-pro 库来处理中文字符的拼音转换,这比手动映射更可靠。

安装 pinyin-pro

Bash

npm install pinyin-pro
# 或者
yarn add pinyin-pro

5. 状态管理利器:Pinia 的封装与应用

在大型应用中,我们需要一个集中式的状态管理方案来共享数据和逻辑。Pinia 作为 Vue 官方推荐的下一代状态管理库,提供了简洁、类型安全、模块化的体验。

5.1 为什么选择 Pinia?

  • 简单直观: API 设计极其简单,学习曲线平缓。
  • 类型安全: 完美支持 TypeScript,提供优秀的类型推断。
  • 模块化: 每个 Store 都是独立的模块,方便组织和维护。
  • DevTools 支持: 提供了出色的 Vue DevTools 集成,方便调试。
  • 性能优化: 没有 Mutations,直接修改 State,但通过 DevTools 仍可追踪所有状态变化。

5.2 Pinia Store 的基本结构与封装

首先,安装 Pinia:

Bash

npm install pinia
# 或者
yarn add pinia

main.js 中引入并使用 Pinia:

JavaScript

// src/main.js
import { createApp } from 'vue';
import App from './App.vue';
import { createPinia } from 'pinia';
import Vant from 'vant'; // 引入 Vant UI 库
import 'vant/lib/index.css'; // 引入 Vant 样式

const app = createApp(App);
const pinia = createPinia();

app.use(pinia);
app.use(Vant); // 注册 Vant
app.mount('#app');

创建 cityStore.js

JavaScript

// src/stores/cityStore.js
import { defineStore } from 'pinia';
import { pinyin } from 'pinyin-pro'; // 引入拼音库

// 模拟 API 请求
const fetchAllCitiesAPI = () => {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve([
        { name: '安阳', pinyin: 'anyang', id: 'AY' },
        { name: '北京', pinyin: 'beijing', id: 'BJ' },
        { name: '保定', pinyin: 'baoding', id: 'BD' },
        { name: '上海', pinyin: 'shanghai', id: 'SH' },
        { name: '深圳', pinyin: 'shenzhen', id: 'SZ' },
        { name: '广州', pinyin: 'guangzhou', id: 'GZ' },
        { name: '成都', pinyin: 'chengdu', id: 'CD' },
        { name: '重庆', pinyin: 'chongqing', id: 'CQ' },
        { name: '杭州', pinyin: 'hangzhou', id: 'HZ' },
        { name: '西安', pinyin: 'xian', id: 'XA' },
        { name: '武汉', pinyin: 'wuhan', id: 'WH' },
        // ... 更多城市数据,确保有 ID
      ]);
    }, 800);
  });
};

const fetchHotCitiesAPI = () => {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve([
        { name: '上海', pinyin: 'shanghai', id: 'SH' },
        { name: '北京', pinyin: 'beijing', id: 'BJ' },
        { name: '广州', pinyin: 'guangzhou', id: 'GZ' },
        { name: '深圳', pinyin: 'shenzhen', id: 'SZ' },
      ]);
    }, 500);
  });
};

// 辅助函数:获取城市拼音首字母
const getPinyinInitial = (cityName) => {
  if (!cityName) return '#';
  const p = pinyin(cityName, { toneType: 'none', type: 'array' });
  if (p && p.length > 0 && p[0].length > 0) {
    const initial = p[0][0].toUpperCase();
    // 修正特殊拼音首字母的 bug:例如 '重庆' 的拼音是 'chongqing',首字母是 'C'
    // 但在某些语境下,可能希望是 'Z'。这里我们坚持按 pinyin-pro 的结果。
    // 如果需要特殊处理,可以在这里添加映射逻辑。
    return initial;
  }
  return '#'; // 无法获取拼音的默认值
};

export const useCityStore = defineStore('city', {
  state: () => ({
    allCities: [],
    hotCities: [],
    recentCities: [],
    loading: false,
    error: null,
    selectedCity: null, // 新增:当前选中的城市
  }),

  getters: {
    // 城市按首字母分组
    cityGroups: (state) => {
      const groups = {};
      state.allCities.forEach(city => {
        // 确保每个城市都有一个稳定的 ID,如果没有,使用现有的或生成一个
        const cityId = city.id || `${city.name}-${city.pinyin || ''}`;
        const initial = getPinyinInitial(city.name);
        if (!groups[initial]) {
          groups[initial] = { initial: initial, cities: [] };
        }
        // 确保传递给组件的城市对象有 ID
        groups[initial].cities.push({ ...city, id: cityId });
      });

      // 将对象转换为数组并按字母排序
      const sortedGroups = Object.values(groups).sort((a, b) => a.initial.localeCompare(b.initial));
      return sortedGroups;
    },

    // 格式化后的热门城市 (如果需要额外处理)
    formattedHotCities: (state) => state.hotCities,

    // 格式化后的近期访问城市 (从 localStorage 加载,并确保数据一致性)
    formattedRecentCities: (state) => {
      // 从 localStorage 获取数据
      const stored = localStorage.getItem('recent_cities');
      try {
        let recent = stored ? JSON.parse(stored) : [];
        // 确保 recentCities 中的城市 ID 匹配 allCities 中的 ID,避免数据不一致
        // 这是一个简单的去重和匹配,可以根据实际需求优化
        recent = recent.filter(rc => state.allCities.some(ac => ac.id === rc.id));
        return recent;
      } catch (e) {
        console.error("Failed to parse recent cities from localStorage:", e);
        return [];
      }
    }
  },

  actions: {
    async fetchCities() {
      this.loading = true;
      this.error = null;
      try {
        const [all, hot] = await Promise.all([
          fetchAllCitiesAPI(),
          fetchHotCitiesAPI()
        ]);
        this.allCities = all;
        this.hotCities = hot;
        // 在数据加载完成后,尝试从 localStorage 初始化 recentCities
        this.recentCities = this.formattedRecentCities;
      } catch (e) {
        this.error = '加载城市数据失败:' + e.message;
        console.error(e);
      } finally {
        this.loading = false;
      }
    },

    // 更新近期访问城市
    updateRecentCities(city) {
      // 确保城市对象有 ID,Pinia 状态才能正确追踪
      if (!city || !city.id) {
        console.warn("Attempted to update recent cities with invalid city object:", city);
        return;
      }
      let currentRecents = [...this.recentCities]; // 复制一份,避免直接修改 state
      // 移除已存在的城市,确保最新访问的在最前面
      currentRecents = currentRecents.filter(item => item.id !== city.id);
      currentRecents.unshift(city); // 添加到最前面
      // 限制数量,例如只保留最新 5 个
      if (currentRecents.length > 5) {
        currentRecents = currentRecents.slice(0, 5);
      }
      this.recentCities = currentRecents; // 更新 Pinia state
      localStorage.setItem('recent_cities', JSON.stringify(currentRecents)); // 保存到 localStorage
    },

    // 设置当前选中的城市
    setSelectedCity(city) {
      if (!city || !city.id) {
        console.warn("Attempted to set selected city with invalid city object:", city);
        return;
      }
      this.selectedCity = city;
      this.updateRecentCities(city); // 同时更新近期访问
    }
  }
});

5.3 在组件中使用 Pinia Store

程式碼片段

<template>
  <div class="city-selector-pinia">
    <h2 class="page-title">城市选择 (Pinia 管理)</h2>
    <div v-if="cityStore.loading" class="loading-overlay">加载中...</div>
    <div v-else-if="cityStore.error" class="error-message">
      {{ cityStore.error }}
    </div>
    <div v-else>
      <hot-cities
        :hotCities="cityStore.formattedHotCities"
        @select-city="cityStore.setSelectedCity"
      ></hot-cities>

      <recent-cities
        :recentCities="cityStore.formattedRecentCities"
        @select-city="cityStore.setSelectedCity"
      ></recent-cities>

      <city-list
        :cityGroups="cityStore.cityGroups"
        @select-city="cityStore.setSelectedCity"
      ></city-list>

      <div v-if="cityStore.selectedCity" class="selected-city-display">
        当前选中:<span class="selected-city-name">{{ cityStore.selectedCity.name }}</span>
      </div>
    </div>
  </div>
</template>

<script>
import { onMounted } from 'vue';
import { useCityStore } from '@/stores/cityStore'; // 导入 Pinia Store
import HotCities from './HotCities.vue';
import RecentCities from './RecentCities.vue';
import CityList from './CityList.vue';

export default {
  name: 'CitySelectorPageWithPinia',
  components: {
    HotCities,
    RecentCities,
    CityList,
  },
  setup() {
    const cityStore = useCityStore(); // 使用 Store

    onMounted(() => {
      cityStore.fetchCities(); // 在组件挂载时调用 action 获取数据
    });

    return {
      cityStore,
    };
  }
};
</script>

<style scoped>
.city-selector-pinia {
  padding: 20px;
  background-color: #fff;
  border-radius: 10px;
  box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
}
.page-title {
  text-align: center;
  color: #007bff;
  margin-bottom: 30px;
}
.loading-overlay, .error-message {
  text-align: center;
  padding: 20px;
  font-size: 1.1em;
  color: #666;
}
.error-message {
  color: red;
}
.selected-city-display {
  margin-top: 30px;
  padding: 15px;
  background-color: #e6f7ff;
  border: 1px solid #cceeff;
  border-radius: 8px;
  text-align: center;
  font-size: 1.1em;
  color: #333;
}
.selected-city-name {
  font-weight: bold;
  color: #007bff;
}
</style>

Pinia 的优势体现在:

  • 集中管理: 所有城市相关数据和逻辑都集中在 cityStore 中,方便维护。
  • 数据响应式: cityStore 中的 state 是响应式的,可以直接在组件中使用。
  • 可测试性: Store 是独立的 JavaScript 模块,易于进行单元测试。
  • 逻辑分离: 组件只关注 UI 渲染和事件触发,复杂的业务逻辑(如数据获取、处理、localStorage 读写)都封装在 Store 的 actionsgetters 中。

6. 模块化构建:嵌套组件与职责分离

组件化是 Vue.js 的核心理念,通过将 UI 拆分为独立、可复用的组件,可以大大提高代码的可维护性和开发效率。

6.1 设计嵌套组件结构

在城市选择器中,我们可以将不同的功能模块拆分为独立的组件:

  • CitySelectorPage (父组件): 负责整合所有子组件,管理整体布局和数据流(通过 Pinia)。
  • HotCities.vue 展示热门城市列表。
  • RecentCities.vue 展示近期访问城市列表。
  • CityList.vue 包含双层 v-for 的城市分组列表。
  • CityGroup.vue (可选,进一步拆分): 单个字母分组的组件。
  • CityItem.vue (可选,进一步拆分): 单个城市项的组件。

6.2 嵌套组件间的通信

  • Props (父传子): 父组件通过 props 向子组件传递数据。
  • Emits (子传父): 子组件通过 emit 事件通知父组件发生了什么。

在前面的代码示例中,CitySelectorPage (或 CitySelectorPageWithPinia) 向 HotCitiesRecentCitiesCityList 传递了 hotCitiesrecentCitiescityGroups 等数据,并通过 @select-city 监听子组件的城市选择事件。

CityList.vue 的实现(嵌套组件的体现):

程式碼片段

<template>
  <div class="city-list-container">
    <h3 class="section-header">全部城市</h3>
    <div v-if="cityGroups.length === 0" class="no-data-hint">暂无城市数据</div>
    <div v-else class="city-groups-wrapper">
      <div v-for="group in cityGroups" :key="group.initial" class="city-group-item">
        <h4 class="initial-header">{{ group.initial }}</h4>
        <ul class="cities-grid">
          <li
            v-for="city in group.cities"
            :key="city.id"
            class="city-grid-item"
            @click="handleSelectCity(city)"
          >
            {{ city.name }}
          </li>
        </ul>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  name: 'CityList',
  props: {
    cityGroups: {
      type: Array,
      default: () => []
    }
  },
  emits: ['select-city'],
  methods: {
    handleSelectCity(city) {
      this.$emit('select-city', city);
    }
  }
};
</script>

<style scoped>
.city-list-container {
  margin-top: 30px;
  padding: 15px;
  background-color: #fcfcfc;
  border-radius: 8px;
  max-height: 500px; /* Limit height for scroll */
  overflow-y: auto;
}
.section-header {
  font-size: 1.1em;
  color: #555;
  margin-bottom: 15px;
  border-bottom: 1px dashed #eee;
  padding-bottom: 8px;
}
.no-data-hint {
  color: #999;
  font-style: italic;
  text-align: center;
  padding: 10px 0;
}
.city-groups-wrapper {
  /* overflow for sticky header */
}
.city-group-item {
  margin-bottom: 20px;
}
.initial-header {
  font-size: 1.15em;
  color: #333;
  margin-top: 0;
  margin-bottom: 10px;
  padding: 5px 0;
  border-bottom: 1px solid #eee;
  background-color: #fff; /* Ensure background for sticky effect */
  z-index: 10;
  position: sticky; /* Make headers sticky */
  top: -15px; /* Adjust based on parent padding/margin */
}
.cities-grid {
  list-style: none;
  padding: 0;
  margin: 0;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); /* Responsive grid */
  gap: 10px;
}
.city-grid-item {
  padding: 8px 12px;
  background-color: #f0f0f0;
  border-radius: 4px;
  cursor: pointer;
  text-align: center;
  transition: background-color 0.2s ease;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.city-grid-item:hover {
  background-color: #e0e0e0;
}
</style>

7. 性能提升:动态组件与异步加载

动态组件(&lt;component :is="..."&gt;)和异步组件(defineAsyncComponent)是 Vue.js 优化应用性能的强大工具,特别是在处理大型应用或按需加载特定功能模块时。

7.1 理解动态组件 &lt;component :is="..."&gt;

&lt;component :is="componentName" 允许你根据数据动态渲染不同的组件。这在构建 Tab 切换、Wizard 步骤或者根据用户权限加载不同模块时非常有用。

用例:城市搜索结果页可以根据搜索状态动态显示“无结果”组件或“结果列表”组件。

程式碼片段

<template>
  <div class="city-search-page">
    <input
      type="text"
      v-model="searchQuery"
      placeholder="输入城市名或拼音"
      class="search-input"
    />
    <button @click="performSearch" class="search-button">搜索</button>

    <div class="search-results">
      <component :is="currentResultComponent" :searchResult="searchResult" />
    </div>
  </div>
</template>

<script>
import { ref, computed } from 'vue';
import SearchResultsList from './SearchResultsList.vue';
import NoResultsFound from './NoResultsFound.vue';
import SearchInitialPrompt from './SearchInitialPrompt.vue';

export default {
  name: 'CitySearchPage',
  components: {
    SearchResultsList,
    NoResultsFound,
    SearchInitialPrompt,
  },
  setup() {
    const searchQuery = ref('');
    const searchResult = ref([]); // 模拟搜索结果
    const hasSearched = ref(false);

    const performSearch = () => {
      hasSearched.value = true;
      // 模拟 API 搜索
      if (searchQuery.value.includes('北京')) {
        searchResult.value = [{ id: 'BJ', name: '北京' }];
      } else if (searchQuery.value.includes('上海')) {
        searchResult.value = [{ id: 'SH', name: '上海' }];
      } else {
        searchResult.value = [];
      }
    };

    const currentResultComponent = computed(() => {
      if (!hasSearched.value) {
        return 'SearchInitialPrompt'; // 初始提示组件
      }
      if (searchResult.value.length === 0) {
        return 'NoResultsFound'; // 无结果组件
      }
      return 'SearchResultsList'; // 结果列表组件
    });

    return {
      searchQuery,
      performSearch,
      searchResult,
      currentResultComponent,
    };
  },
};
</script>

<style scoped>
.city-search-page {
  padding: 20px;
  background-color: #fcfcfc;
  border-radius: 8px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
  text-align: center;
}
.search-input {
  padding: 10px 15px;
  border: 1px solid #ccc;
  border-radius: 25px;
  width: 70%;
  max-width: 300px;
  margin-right: 10px;
  outline: none;
  font-size: 1em;
}
.search-button {
  padding: 10px 20px;
  background-color: #007bff;
  color: white;
  border: none;
  border-radius: 25px;
  cursor: pointer;
  font-size: 1em;
  transition: background-color 0.2s ease;
}
.search-button:hover {
  background-color: #0056b3;
}
.search-results {
  margin-top: 20px;
  padding: 15px;
  border: 1px dashed #eee;
  border-radius: 8px;
  min-height: 100px;
  display: flex;
  align-items: center;
  justify-content: center;
  color: #666;
  font-style: italic;
}
</style>

<template>
  <div class="search-results-list">
    <h4>搜索结果:</h4>
    <ul v-if="searchResult.length > 0">
      <li v-for="city in searchResult" :key="city.id">{{ city.name }}</li>
    </ul>
    <p v-else>没有找到匹配的城市。</p>
  </div>
</template>
<script>
export default {
  props: ['searchResult'],
};
</script>
<style scoped>
.search-results-list ul { list-style: none; padding: 0; margin: 0; }
.search-results-list li { padding: 5px 0; border-bottom: 1px dashed #eee; }
</style>

<template>
  <div class="no-results">
    <p>抱歉,没有找到相关城市。</p>
    <p>请尝试其他关键词。</p>
  </div>
</template>
<style scoped>
.no-results { color: #dc3545; font-weight: bold; }
</style>

<template>
  <div class="initial-prompt">
    <p>请输入城市名或拼音进行搜索...</p>
  </div>
</template>
<style scoped>
.initial-prompt { color: #888; }
</style>

7.2 异步组件 defineAsyncComponent 实现按需加载

当组件非常大或者在应用启动时不需要立即加载时,可以使用 defineAsyncComponent 来实现组件的懒加载(延迟加载)。

JavaScript

// main.js 或者路由配置中
import { createApp, defineAsyncComponent } from 'vue';
import App from './App.vue';
import { createPinia } from 'pinia';
import Vant from 'vant';
import 'vant/lib/index.css';

const app = createApp(App);
const pinia = createPinia();

app.use(pinia);
app.use(Vant);

// 异步加载 CitySelectorPageWithPinia 组件
const AsyncCitySelector = defineAsyncComponent(() =>
  import('./components/CitySelectorPageWithPinia.vue')
);

// 在 App.vue 中使用
// <template><AsyncCitySelector /></template>

app.mount('#app');

好处:

  • 优化首屏加载时间: 只有当用户访问到包含 AsyncCitySelector 的路由或组件时,才会加载其对应的 JavaScript 文件。
  • 减少打包体积: 将组件代码拆分成更小的块,浏览器可以并行下载,提升加载速度。

8. 细节决定成败:热门推荐列表拼音首字母 Bug 修复

在城市选择器中,如果城市数据是动态获取的,并依赖拼音首字母进行分组,很容易出现因拼音转换不准确导致的 Bug。

8.1 常见 Bug 描述

例如:

  • “重庆”的拼音是 chongqing,首字母应该是 C,但在某些简陋的拼音转换库中可能错误地识别为 Z(zh-)。
  • 多音字处理不当。
  • 生僻字无法正确转换拼音。

8.2 解决方案:引入专业的拼音库 pinyin-pro

pinyin-pro 是一个功能强大且准确的中文转拼音库,能够很好地处理多音字、生僻字等复杂情况。

安装:

Bash

npm install pinyin-pro
# 或者
yarn add pinyin-pro

使用示例:

JavaScript

// 在 CityStore.js 或其他需要处理拼音的地方
import { pinyin } from 'pinyin-pro';

const getPinyinInitial = (cityName) => {
  if (!cityName) return '#';
  // toneType: 'none' 表示不带声调
  // type: 'array' 表示返回拼音数组,例如 'zhong' -> ['zhong']
  const p = pinyin(cityName, { toneType: 'none', type: 'array' });
  if (p && p.length > 0 && p[0].length > 0) {
    return p[0][0].toUpperCase(); // 获取第一个字的第一个拼音的首字母并转大写
  }
  return '#'; // 无法获取拼音的默认值
};

// 示例
console.log(getPinyinInitial('重庆')); // 'C' (正确)
console.log(getPinyinInitial('厦门')); // 'X'
console.log(getPinyinInitial('亳州')); // 'B'
console.log(getPinyinInitial('长春')); // 'C'

cityStore.js 中的集成:

cityStore.jsgetPinyinInitial 辅助函数中,我们已经集成了 pinyin-pro。这确保了城市分组的准确性和稳定性。

JavaScript

// src/stores/cityStore.js
// ... (之前的代码)

// 辅助函数:获取城市拼音首字母
import { pinyin } from 'pinyin-pro'; // 引入拼音库

const getPinyinInitial = (cityName) => {
  if (!cityName) return '#';
  const p = pinyin(cityName, { toneType: 'none', type: 'array' });
  if (p && p.length > 0 && p[0].length > 0) {
    return p[0][0].toUpperCase();
  }
  return '#';
};

export const useCityStore = defineStore('city', {
  // ... state, getters, actions
  getters: {
    cityGroups: (state) => {
      const groups = {};
      state.allCities.forEach(city => {
        const cityId = city.id || `${city.name}-${city.pinyin || ''}`;
        const initial = getPinyinInitial(city.name); // 使用此函数获取首字母
        if (!groups[initial]) {
          groups[initial] = { initial: initial, cities: [] };
        }
        groups[initial].cities.push({ ...city, id: cityId });
      });
      const sortedGroups = Object.values(groups).sort((a, b) => a.initial.localeCompare(b.initial));
      return sortedGroups;
    },
    // ... 其他 getters
  },
  // ... actions
});

通过集成专业的拼音库,我们从根本上解决了拼音首字母的准确性问题,避免了在用户界面上出现错误的城市分组。


总结:驾驭复杂,提升体验

至此,我们已经全面剖析了在 Vue.js 中构建一个功能完善、性能优良、易于维护的城市选择器所需掌握的各项核心技能。

  • 我们通过双层 for-template 循环高效地渲染了按首字母分组的城市列表,并强调了 key 的重要性。
  • 我们设计并实现了热门城市近期访问城市模块,提升了用户体验的便捷性。
  • 深入探讨了 scoped CSS 的原理与局限,并掌握了 ::v-deep:deep() 样式穿透的实战技巧,确保了界面的像素完美。
  • 讨论了动态数据获取的策略,并展示了如何将数据处理逻辑集成到组件生命周期中。
  • 重点介绍了 Pinia 作为 Vue 官方推荐的下一代状态管理库,演示了如何封装 Store、管理全局状态,并与组件进行高效交互,显著提升了代码的简洁性和可维护性。
  • 强调了嵌套组件在模块化开发中的作用,并通过 Props 和 Emits 实现了组件间的清晰通信。
  • 学习了动态组件 &lt;component :is="..."&gt;异步组件 defineAsyncComponent 的应用,为性能优化提供了有力武器。
  • 最终,我们解决了热门推荐列表拼音首字母 Bug,通过引入专业的 pinyin-pro 库,确保了数据展示的准确性。

从双层循环的表面复杂度,到 scoped 样式的深层机制,再到 Pinia 的优雅状态管理,以及性能优化的诸多细节,每一个环节都至关重要。掌握这些知识,你将能够更自信、更从容地应对各种复杂的 Vue.js 开发挑战。

希望这篇文章能帮助你彻底摆脱“麻”烦,在 Vue.js 的开发旅程中游刃有余,构建出更多高性能、高质量的用户界面!

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值