Skip to content

阶段二:核心服务与状态管理

1. 教学目标

  • 理解服务分层:掌握将不同职责的逻辑(如定位、API请求、状态管理、工具函数)拆分到独立 utils 文件中的重要性。
  • 掌握异步流程:能够熟练运用 Promiseasync/await 封装 uni-app 的原生异步 API。
  • 学习简易状态管理:理解如何使用 Vue 3 的 reactive API 结合 uni.setStorageSync 构建一个轻量、持久化的全局状态管理器。
  • 建立真实数据流:将阶段一的静态首页与真实的服务层连接,建立起清晰的数据流动闭环:页面加载 → 定位 → 获取数据 → 更新状态 → 渲染UI
  • 数据驱动开发:实现首页所有数据均由真实 API 动态驱动,替换掉所有硬编码的静态数据。

2. 真实数据流图 (Mermaid)

此图精确展示了本项目中从应用启动到首页渲染完成的完整数据流。

3. 服务层构建详解

📄 ➡️ 🔗 ➡️ 🗄️ ➡️ 💻

在阶段一,我们的页面是“死”的。现在,我们要通过建立服务层,为它注入“灵魂”——真实的数据。这个服务层由一系列各司其职的JS文件构成。

🔗 步骤一:封装定位服务 (utils/location.js)

设计流程

目标:我们需要一个可靠的方式来获取应用的起始城市信息。这个过程比单一的API调用要复杂,它包含多个步骤:首先通过IP地址大致定位城市,然后用获取到的城市名去查询更精确的 locationId(天气API需要这个ID)。

设计决策:将这个复杂、涉及多个异步调用的流程封装到一个名为 getCurrentCity 的函数中。这样,页面层只需调用这一个函数,就能得到所有需要的位置信息,大大简化了页面的逻辑。

编码流程

  1. 创建 /utils/location.js 文件。
  2. 在文件中,定义 getCurrentCity 函数,并标记为 async
  3. 在函数内部,使用 new Promise 包装第一个 uni.request,用于通过高德IP定位API获取城市名和经纬度。提供 fail 回调,以便在失败时返回一个默认城市(如北京)。
  4. 使用 await 等待上一步的结果。
  5. 拿到城市名后,再次 await 调用 fetchCityId 函数(同样是Promise封装的 uni.request),去和风天气API查询该城市名的 locationId
  6. 将所有获取到的信息(城市名、经纬度、locationId)组合成一个对象并 return
点击展开/折叠 utils/location.js 源代码
/**
 * 定位工具函数
 * 1、获取天气情况的api中,需要用到loaction(城市id),需要先获取城市id,
 * 2、获取城市的id,可以通过城市名称来获取(调用和风天气api)
 * 3、城市名称,可以根据ip地址获取,通过ip地址获取城市名称和经纬度信息。
 * * 步骤一、根据ip地址获取城市名称和经纬度信息(用高德地图api)
 * 步骤二、根据城市名称获取城市id(用和风天气api)
 * * 错误处理:失败的话,都默认返回北京id、城市名、经纬度信息
 */

// 和风天气API Key
import UrlConfig from '../datas/url.json';
const QWEATHER_KEY = UrlConfig.getCityIdUrl.key;

const AMAP_URL = UrlConfig.getCurrentCityUrl.AMAP_URL;
const AMAP_KEY = UrlConfig.getCurrentCityUrl.AMAP_KEY;


// const BAIDU_URL = UrlConfig.getCurrentCityUrl.BAIDU_URL;
// const BAIDU_KEY = UrlConfig.getCurrentCityUrl.BAIDU_KEY;

/**
 * 获取当前位置信息
 * @returns {Promise} - 城市信息对象,包含city、longitude、latitude、locationId
 */
export const getCurrentCity = async () => {
  // 第一步:获取IP对应的城市
  const ipData = await new Promise((resolve) => {
    uni.request({
      // // url: '[http://ip-api.com/json/?lang=zh-CN](http://ip-api.com/json/?lang=zh-CN)' 
      url: AMAP_URL, //高德 /百度
      data: {
        key: AMAP_KEY //高德 
        // ak: "BAIDU_KEY"  , coor:"bd09ll" //百度
      },
      method: 'GET',
      success: (res) => {
        const [lng, lat] = res.data.rectangle.split(';')[0].split(',');
        resolve({ 
          city: res.data.city, 
          longitude: parseFloat(lng).toFixed(2), //经度
          latitude: parseFloat(lat).toFixed(2)   //纬度
        });
        console.log("success", res.data);
      },
      fail: (err) => {
        resolve({ 
          city: '北京',
          longitude: 116.41,//经度
          latitude: 39.92   //纬度
        });
        console.log("fail");
      }
    });
  });

  const cityName = ipData.city || '北京';
  
  // 第二步:获取城市对应的locationId
  const cityIdData = await fetchCityId(cityName);
  const locationId = cityIdData?.location?.[0]?.id || '101010100';
  
  // 创建城市信息对象
  const cityInfo = {
    city: cityName,
    longitude: ipData.longitude,
    latitude: ipData.latitude,
    locationId: locationId
  };
  
  // 返回城市信息对象
  return cityInfo;
};

/**
 * 根据城市名获取和风天气locationId
 * @param {string} cityName - 城市名称
 * @returns {Promise} - 包含locationId的响应数据
 */
export const fetchCityId = (cityName) => {
  return new Promise((resolve) => {
    uni.request({
      url: UrlConfig.getCityIdUrl.url,
      method: 'GET',
      data: {
        location: cityName,
        key: QWEATHER_KEY
      },
      success: (res) => {
        resolve(res.data);
      },
      fail: () => {
        // 失败时返回默认数据
        resolve({ location: [{ id: '101010100' }] });
      }
    });
  });
};

/**
 * 根据城市名更新城市信息
 * @param {string} cityName - 城市名称
 * @returns {Promise} - 城市信息对象,包含city、longitude、latitude、locationId
 */
export const updateCityInfoByCity = async (cityName) => {
  // 获取城市对应的locationId
  const cityIdData = await fetchCityId(cityName);
  const locationId = cityIdData?.location?.[0]?.id || '101010100';
  
  // 获取经纬度(如果cityIdData中包含)
  const longitude = cityIdData?.location?.[0]?.lon || 116.41;
  const latitude = cityIdData?.location?.[0]?.lat || 39.92;
  
  // 创建城市信息对象
  const cityInfo = {
    city: cityName,
    longitude: longitude,
    latitude: latitude,
    locationId: locationId
  };
  
  // 返回城市信息对象
  return cityInfo;
};

🗄️ 步骤二:创建简易状态管理器 (utils/store.js)

设计流程

目标:我们需要一个地方来存放全局共享的数据,例如当前城市信息。这个数据需要在多个页面(首页、城市页)之间同步,并且在小程序关闭后能够被记住。

设计决策:不引入大型状态管理库(如Pinia),而是利用 Vue 3 自带的 reactive API 创建一个轻量级的 store 对象。通过导出 setget 方法来规范化对状态的读写。在每次 set 操作时,都同步使用 uni.setStorageSync 将数据写入本地缓存,实现持久化。在应用首次加载时,通过 initStore 函数从缓存中恢复数据。

编码流程

  1. 创建 /utils/store.js 文件。
  2. vue 导入 reactive
  3. 创建一个名为 storereactive 对象,并定义初始状态,如 cityInfo
  4. 编写 setCityInfo(cityInfo) 函数。函数内部首先更新 store.cityInfo,然后立即调用 uni.setStorageSync('CityInfo', cityInfo)
  5. 编写 getCityInfo() 函数,直接返回 store.cityInfo
  6. 编写 initStore() 函数,在文件首次被导入时执行。它尝试用 uni.getStorageSync('CityInfo') 读取数据,如果存在,则用它初始化 store.cityInfo
点击展开/折叠 utils/store.js 源代码
import { reactive } from 'vue';

// 创建全局状态
const store = reactive({
  // 当前城市信息
  cityInfo: {
    city: '北京',
    longitude: 116.41,
    latitude: 39.92,
    locationId: '101010100'
  },
  // 我的城市列表
  myCities: ['北京', '上海', '广州'],
  // 热门城市列表
  hotCities: ['北京', '上海', '广州', '深圳', '杭州', '成都', '武汉', '西安']
});

/**
 * 设置当前城市信息
 * @param {Object} cityInfo - 城市信息对象,包含city、longitude、latitude、locationId
 */
export const setCityInfo = (cityInfo) => {
  store.cityInfo = cityInfo;
  // 保存到本地存储,使用单个键"CityInfo"
  uni.setStorageSync('CityInfo', cityInfo);
};

/**
 * 获取当前城市信息
 * @returns {Object} - 当前城市信息对象
 */
export const getCityInfo = () => {
  return store.cityInfo;
};

/**
 * 添加城市到我的城市列表
 * @param {string} city - 城市名称
 */
export const addMyCity = (city) => {
  if (!store.myCities.includes(city)) {
    store.myCities.push(city);
    // 保存到本地存储
    uni.setStorageSync('myCities', store.myCities);
  }
};

/**
 * 从我的城市列表中删除城市
 * @param {string|number} city - 城市名称或索引
 */
export const removeMyCity = (city) => {
  let index;
  if (typeof city === 'string') {
    index = store.myCities.indexOf(city);
  } else {
    index = city;
  }
  
  if (index !== -1) {
    store.myCities.splice(index, 1);
    // 保存到本地存储
    uni.setStorageSync('myCities', store.myCities);
  }
};

/**
 * 获取我的城市列表
 * @returns {Array} - 我的城市列表
 */
export const getMyCities = () => {
  return store.myCities;
};

/**
 * 获取热门城市列表
 * @returns {Array} - 热门城市列表
 */
export const getHotCities = () => {
  return store.hotCities;
};

// 初始化时从本地存储加载数据
const initStore = () => {
  // 加载城市信息
  const cityInfo = uni.getStorageSync('CityInfo');
  if (cityInfo) {
    store.cityInfo = cityInfo;
  }
  
  // 加载我的城市列表
  const cities = uni.getStorageSync('myCities');
  if (cities && Array.isArray(cities)) {
    store.myCities = cities;
  }
};

initStore();

export default store;

🔗 步骤三:封装统一API服务 (utils/api.js)

设计流程

目标:将所有与第三方天气API的直接通信都集中到一个地方。这样做可以避免在多个页面中重复编写 uni.request 代码,并方便未来统一处理如更换API供应商、修改API参数、添加通用错误处理等需求。

设计决策:为每个API端点(如获取实时天气、获取7日预报)创建一个独立的、async 的导出函数。在函数内部,从 store(通过本地缓存)读取需要的 locationId,然后执行 uni.request。最重要的是,在 success 回调中,对API返回的原始数据进行“清洗”和“格式化”,将其转换为UI组件真正需要的、干净整洁的 ObjectArray 格式再返回。

编码流程

  1. 创建 /utils/api.js 文件。
  2. 为每个API(如 getCurrentWeather)创建一个 async 函数。
  3. 在函数内部,通过 uni.getStorageSync('CityInfo') 获取 locationId
  4. 使用 new Promise 包装 uni.request,并在其中配置好URL、Key和 locationId 等参数。
  5. success 回调中,从 res.data 中提取需要的数据(如 res.data.now),然后构建一个新的、结构清晰的对象返回。例如,将 res.data.now.temp 映射为 data.temp
  6. 为所有API函数重复此过程。
点击展开/折叠 utils/api.js 源代码
// 引入API地址配置
// 引入API地址配置
import UrlConfig from '../datas/url.json';

/**
 * 天气文本映射表
 * 将天气文本映射到组件使用的icon名称
 */
const WEATHER_TEXT_MAP = {
  '晴': 'star',
  '多云': 'cloud-download',
  '阴': 'cloud-upload',
  '小雨': 'download',
  '中雨': 'download',
  '大雨': 'download',
  '暴雨': 'download',
  '阵雨': 'download',
  '雷阵雨': 'download',
  '小雪': 'cloud-download',
  '中雪': 'cloud-download',
  '大雪': 'cloud-download',
  '暴雪': 'cloud-download',
  '雨夹雪': 'cloud-download',
  '雾': 'info',
  '霾': 'info',
  '沙尘暴': 'info'
};

/**
 * 获取实时天气数据
 * @param {string} city - 城市名称
 * @returns {object} - 实时天气数据
 */
export const getCurrentWeather = async (city) => {
  // 从CityInfo对象中获取locationId
  const cityInfo = uni.getStorageSync('CityInfo') || {};
  const locationId = cityInfo.locationId || UrlConfig.CurrentWeatherUrl.data.location;
  // 使用异步请求获取实时天气数据
  const result = await new Promise((resolve) => {
    uni.request({
      url: UrlConfig.CurrentWeatherUrl.url,
      method: 'GET',
      data: {
        key: UrlConfig.CurrentWeatherUrl.data.key,
        location: locationId
      },
      dataType: 'json',
      success: (res) => resolve(res.data),
      fail: () => resolve({ code: '500' })
    });
  });
  if (result.code === '200') {
    const nowData = result.now;
    return {
      code: '200',
      data: {
        city: city,
        temp: nowData.temp,
        weather: nowData.text,
        humidity: `${nowData.humidity}%`,
        wind: `${nowData.windDir} ${nowData.windScale}级`,
        pressure: `${nowData.pressure} hPa`,
        visibility: `${nowData.vis} km`,
        icon: WEATHER_TEXT_MAP[nowData.text] || 'star'
      }
    };
  } else {
    // 请求失败或数据错误时返回默认数据
    return {
      code: '500',
      data: {
        city: city,
        temp: '25',
        weather: '晴',
        humidity: '45%',
        wind: '东北风 3级',
        visibility: '10 km',
        icon: 'star'
      }
    };
  }
};

/**
 * 获取空气质量数据
 * @param {string} city - 城市经纬度,格式为"经度longitude,纬度latitude"
 * @returns {object} - 空气质量数据
 */
export const getAirQuality = async (city) => {
  // 直接导入getCurrentCity
  const { getCurrentCity } = await import('./location.js');
  
  // 从CityInfo对象中获取经纬度
  const cityInfo = uni.getStorageSync('CityInfo') || {};
  const longitude = cityInfo.longitude || 116.41;
  const latitude = cityInfo.latitude || 39.92;
  
  // 构建空气质量URL,使用获取到的经纬度
  const baseUrl = UrlConfig.CurrentAirQualityUrl.url;
  const url = `${baseUrl}/${latitude}/${longitude}`;
  // 使用异步请求获取空气质量数据
  const result = await new Promise((resolve) => {
    uni.request({
      url: url,
      method: 'GET',
      data: UrlConfig.CurrentAirQualityUrl.data,
      dataType: 'json',
      success: (res) => resolve(res.data),
      fail: () => resolve({ code: '500' })
    });
  });

  if (result.indexes && result.pollutants) {
    const aqiData = result.indexes[0];
    const pollutants = result.pollutants;
    
    // 从pollutants中提取各项污染物数据
    const pollutantMap = {};
    pollutants.forEach(pollutant => {
      pollutantMap[pollutant.code] = pollutant.concentration.value;
    });
    
    // 根据AQI值计算颜色
    const getAirQualityColor = (aqi) => {
      if (aqi <= 50) return '#00E400'; // 优
      if (aqi <= 100) return '#FFFF00'; // 良
      if (aqi <= 150) return '#FF7E00'; // 轻度污染
      if (aqi <= 200) return '#FF0000'; // 中度污染
      if (aqi <= 300) return '#99004C'; // 重度污染
      return '#7E0023'; // 严重污染
    };
    return {
      code: '200',
      data: {
        aqi: aqiData.aqi,
        level: aqiData.category,
        pm10: pollutantMap['pm10'] || 0,
        no2: pollutantMap['no2'] || 0,
        o3: pollutantMap['o3'] || 0,
        color: getAirQualityColor(aqiData.aqi)
      }
    };
  } else {
    // 请求失败或数据错误时返回默认数据
    return {
      code: '500',
      data: {
        aqi: 75,
        level: '良',
        pm25: 35,
        pm10: 60,
        no2: 25,
        o3: 80,
        color: '#FFFF00'
      }
    };
  }
};

/**
 * 获取24小时天气预报
 * @param {string} city - 城市名称
 * @returns {object} - 24小时天气预报数据
 */
export const get24hForecast = async (city) => {
  // 从CityInfo对象中获取locationId
  const cityInfo = uni.getStorageSync('CityInfo') || {};
  const locationId = cityInfo.locationId || UrlConfig.Weather24hUrl.data.location;
  
  // 使用异步请求获取24小时天气预报数据
  const result = await new Promise((resolve) => {
    uni.request({
      url: UrlConfig.Weather24hUrl.url,
      method: 'GET',
      data: {
        key: UrlConfig.Weather24hUrl.data.key,
        location: locationId
      },
      dataType: 'json',
      success: (res) => resolve(res.data),
      fail: () => resolve({ code: '500' })
    });
  });
  
  if (result.code === '200') {
    const hourlyData = result.hourly;
    const forecast = hourlyData.map(item => {
      // 从fxTime中提取小时,格式为HH:00
      const hour = new Date(item.fxTime).getHours().toString().padStart(2, '0');
      return {
        time: `${hour}:00`,
        temp: item.temp,
        weather: item.text,
        wind: `${item.windDir} ${item.windScale}级`,
        icon: WEATHER_TEXT_MAP[item.text] || 'star'
      };
    });
    
    return {
      code: '200',
      data: forecast
    };
  } else {
    // 请求失败或数据错误时返回默认数据
    return {
      code: '500',
      data: []
    };
  }
};

/**
 * 获取7天天气预报
 * @param {string} city - 城市名称
 * @returns {object} - 7天天气预报数据
 */
export const get7dForecast = async (city) => {
  // 1、从CityInfo对象中获取locationId
  
  // 2、使用异步请求获取7天天气预报数据
  
  // 3、处理返回数据:对日期数据进行格式化、提取需要的字段(需要返回date、day、tempMax、tempMin、weather、wind、icon)
  
  // 4、如果请求成功,返回格式化后的数据;如果失败,返回默认数据(code: '500', data: [])
};


/**
 * 获取生活指数数据
 * @param {string} city - 城市名称
 * @returns {object} - 生活指数数据
 */
export const getLifeIndex = async (city) => {
  // 1、从CityInfo对象中获取locationId
  
  // 2、使用异步请求获取生活指数数据
  
  // 3、 处理返回数据:提取需要的字段(需要返回{type、name、value、icon、color、desc})(type类型,参考和风天气api中天气指数-天气指数预报的接口文档)

  // 4、如果请求成功,返回格式化后的数据;如果失败,返回默认数据(code: '500', data: [])
};

🗄️ 步骤四:编写辅助工具 (utils/weather.js 暂不写)

设计流程

目标:项目中的一些业务逻辑并不直接依赖于外部 API 或全局状态,而是对数据进行纯粹的计算或转换,例如根据AQI数值判断空气质量等级。将这类纯计算逻辑独立出来,可以提高代码的可测试性、可维护性和复用性。

设计决策:创建一个专门存放这类辅助函数的 weather.js 文件。这些函数应该接收输入、返回输出,不产生副作用,也即“纯函数”。

编码流程

  1. 创建 /src/utils/weather.js 文件。
  2. 定义 getAirQualityLevel(aqi) 函数,它接收一个AQI数值作为参数。
  3. 在函数内部,根据不同的AQI范围,返回包含 level, color, desc 的对象。
  4. getAirQualityLevel 函数 export 导出,以便其他模块可以导入并使用。
点击展开/折叠 utils/weather.js 源代码
/**
 * 天气工具函数
 */

/**
 * 根据AQI值获取空气质量等级
 * @param {number} aqi - AQI值
 * @returns {object} - 空气质量等级信息
 */
export const getAirQualityLevel = (aqi) => {
  if (aqi <= 50) {
    return {
      level: '优',
      color: '#00E400',
      desc: '空气质量令人满意,基本无空气污染'
    };
  } else if (aqi <= 100) {
    return {
      level: '良',
      color: '#FFFF00',
      desc: '空气质量可接受,但某些污染物可能对极少数异常敏感人群健康有较弱影响'
    };
  } else if (aqi <= 150) {
    return {
      level: '轻度污染',
      color: '#FF7E00',
      desc: '易感人群症状有轻度加剧,健康人群出现刺激症状'
    };
  } else if (aqi <= 200) {
    return {
      level: '中度污染',
      color: '#FF0000',
      desc: '进一步加剧易感人群症状,可能对健康人群心脏、呼吸系统有影响'
    };
  } else if (aqi <= 300) {
    return {
      level: '重度污染',
      color: '#99004C',
      desc: '心脏病和肺病患者症状显著加剧,运动耐受力降低,健康人群普遍出现症状'
    };
  } else {
    return {
      level: '严重污染',
      color: '#7E0023',
      desc: '健康人群运动耐受力降低,有明显强烈症状,提前出现某些疾病'
    };
  }
};

🔗 步骤五:连接服务与视图 (index.vue)

设计流程

目标:让 index.vue 页面(父组件)成为一个“总指挥”,负责调用前面创建的各种服务,获取所有数据,然后分发给各个子组件进行展示。

设计决策:我们将在 onMounted 生命周期钩子(页面首次加载时触发)中执行一个名为 initData 的总初始化函数。initData 首先调用 location.js 中的 getCurrentCity 获取位置,然后调用 store.js 中的 setCityInfo 将位置信息存入全局状态。随后,调用 updateWeather 函数,此函数内将并行调用 api.js 中的多个接口,并将获取到的数据分别更新到 index.vue 中定义的各个 ref 变量上。Vue的响应式系统会自动将这些更新后的数据传递给子组件,从而刷新UI。

编码流程

  1. 打开 pages/index/index.vue 文件。
  2. <script setup> 顶部,从 utils 目录 import 所有需要的服务函数。
  3. 使用 ref() 为每个子组件所需的数据创建响应式变量,如 currentWeather = ref({})
  4. 创建 async 函数 updateWeather,在其中 await 调用 api.js 中的各个数据获取函数,并将返回的数据赋值给对应的 ref 变量。
  5. 创建 async 函数 initData,在其中 await 调用 getCurrentCitysetCityInfo,然后 await 调用 updateWeather
  6. onMounted 钩子中调用 initData()
点击展开/折叠 index.vue 脚本区源代码
<script setup>
	import {ref, onMounted, computed} from 'vue';
	// 1. 导入所有组件和服务
	import CurrentEnvironment from '../../components/CurrentEnvironment.vue';
	import HourlyForecast from '../../components/HourlyForecast.vue';
	import DailyForecast from '../../components/DailyForecast.vue';
	import LifeIndex from '../../components/LifeIndex.vue';
	
	import {getCurrentWeather, get24hForecast, get7dForecast, getAirQuality, getLifeIndex} from '../../utils/api';
	import {getCityInfo, setCityInfo} from '../../utils/store';
	import {getCurrentCity} from '../../utils/location';
	// ...

	const currentCity = computed(() => getCityInfo().city);

	// 2. 定义 ref 变量
	const currentWeather = ref({});
	const airQuality = ref({});
	const hourlyForecast = ref([]);
	// dailyForecast  、 lifeIndices
	
	// 3. 核心数据更新函数
	const updateWeather = async () => {
		const city = getCityInfo().city;
		// 获取当前天气情况 currentWeather
		const weatherRes = await getCurrentWeather(city);
		if (weatherRes.code === '200') {
			currentWeather.value = weatherRes.data;
		}
		// 获取当前空气质量 airQuality
		const airQualityRes = await getAirQuality(city);
		if (airQualityRes.code === '200') {
			airQuality.value = airQualityRes.data;
		}
		
		// ...获取其他天气数据... hourlyForecast 、 dailyForecast  、 lifeIndices
	};

	// 4. 初始化函数
	const initData = async () => {
		const currentLocation = await getCurrentCity();
		setCityInfo(currentLocation);
		await updateWeather();
	};

	// 5. 在页面加载时触发所有逻辑
	onMounted(() => {
		initData();
	});
</script>