一、问题描述

博客首页的天气组件加载速度很慢,用户打开页面后需要等待较长时间才能看到天气信息。

问题分析

原实现流程

页面加载 → 等待定位 → 获取位置 → 请求天气 → 显示天气

问题原因

  1. 定位耗时:浏览器 Geolocation API 获取用户位置需要时间
  2. 用户授权:用户可能需要确认定位权限
  3. 网络请求:定位成功后还要发起天气 API 请求
  4. 失败处理:用户拒绝定位时,天气组件无内容显示

二、解决方案

核心思路

将"先定位后显示"改为"先显示默认天气,再异步更新":

改进前:页面加载 → 定位 → 天气API → 显示(总耗时约3-5秒)
改进后:页面加载 → 显示默认天气 → 异步定位 → 更新天气(首屏几乎无延迟)

优化后流程

┌─────────────────────────────────────────────────────────────┐
│                      优化后流程                              │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  页面加载                                                    │
│      │                                                      │
│      ▼                                                      │
│  ┌─────────────────────┐                                    │
│  │ 立即请求南京天气      │  ← 快速显示默认天气                │
│  │ 显示:🌤️ 南京 XX°C   │                                    │
│  └─────────────────────┘                                    │
│      │                                                      │
│      ▼                                                      │
│  ┌─────────────────────┐                                    │
│  │ 异步获取用户位置      │  ← 浏览器 Geolocation API          │
│  └─────────────────────┘                                    │
│      │                                                      │
│      ├── 成功 → 更新为用户所在城市天气                        │
│      │                                                      │
│      └── 拒绝/失败 → 保持南京天气                             │
│                                                             │
└─────────────────────────────────────────────────────────────┘

三、代码实现

3.1 优化前代码

document.addEventListener('DOMContentLoaded', function() {
  const weatherWidget = document.getElementById('weather-widget');
  if (!weatherWidget) return;
  
  // 直接请求基于 IP 的天气(需要定位)
  fetch('https://wttr.in/?format=j1')
    .then(response => response.json())
    .then(data => {
      // 渲染天气信息
      const current = data.current_condition[0];
      const area = data.nearest_area[0];
      // ...
    })
    .catch(() => {
      weatherWidget.innerHTML = '<span>🌤️ 天气加载中...</span>';
    });
});

问题:用户看到"天气加载中…“的时间过长。

四、关键技术点

4.1 wttr.in API 使用

wttr.in 支持多种查询方式:

# 按城市名查询
https://wttr.in/Nanjing?format=j1

# 按坐标查询
https://wttr.in/32.06,118.79?format=j1

# 按 IP 自动定位
https://wttr.in/?format=j1

4.2 Geolocation API

navigator.geolocation.getCurrentPosition(
  successCallback,  // 成功回调
  errorCallback,    // 失败回调
  options          // 可选配置
);

// 成功回调参数
function successCallback(position) {
  position.coords.latitude   // 纬度
  position.coords.longitude  // 经度
  position.coords.accuracy   // 精度(米)
}

// 失败回调参数
function errorCallback(error) {
  error.code
  // 1: PERMISSION_DENIED - 用户拒绝
  // 2: POSITION_UNAVAILABLE - 位置不可用
  // 3: TIMEOUT - 超时
}

五、效果对比

5.1 加载时间对比

指标优化前优化后
首屏显示时间3-5 秒< 1 秒
用户感知延迟明显等待几乎无感知
定位失败处理显示"加载中”显示默认天气

5.2 用户体验对比

优化前:
┌────────────────────────────────────────┐
│  页面加载                               │
│  ┌──────────────────────────────────┐  │
│  │  🌤️ 天气加载中...                │  │  ← 用户等待 3-5 秒
│  └──────────────────────────────────┘  │
│           ↓ (3-5秒后)                   │
│  ┌──────────────────────────────────┐  │
│  │  ☀️ 北京 25°C                    │  │
│  └──────────────────────────────────┘  │
└────────────────────────────────────────┘

优化后:
┌────────────────────────────────────────┐
│  页面加载                               │
│  ┌──────────────────────────────────┐  │
│  │  ☀️ 南京 22°C                    │  │  ← 立即显示
│  └──────────────────────────────────┘  │
│           ↓ (后台异步)                  │
│  ┌──────────────────────────────────┐  │
│  │  ☀️ 北京 25°C                    │  │  ← 自动更新(如果定位成功)
│  └──────────────────────────────────┘  │
└────────────────────────────────────────┘

六、第三次优化:完全重写

6.1 重写原因

经过多次迭代优化后,代码仍然存在以下问题:

  1. 逻辑混乱:多次修改导致代码结构不清晰
  2. 状态管理混乱:没有统一的状态管理机制
  3. 错误处理不完善:各种边界情况考虑不周
  4. 代码耦合度高:各功能模块之间耦合严重

6.2 新架构设计

采用模块化、状态驱动的设计:

┌─────────────────────────────────────────────────────────────┐
│                    新架构设计                                │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐         │
│  │   CONFIG    │  │    State    │  │   Cache     │         │
│  │   配置常量   │  │   状态管理   │  │   缓存管理   │         │
│  └─────────────┘  └─────────────┘  └─────────────┘         │
│                                                             │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐         │
│  │    Utils    │  │     API     │  │     UI      │         │
│  │   工具函数   │  │   API请求   │  │   UI渲染    │         │
│  └─────────────┘  └─────────────┘  └─────────────┘         │
│                                                             │
│              ┌─────────────────────┐                        │
│              │    WeatherApp       │                        │
│              │    核心逻辑控制器    │                        │
│              └─────────────────────┘                        │
│                                                             │
└─────────────────────────────────────────────────────────────┘

6.3 核心改进

6.3.1 配置集中管理

const CONFIG = {
  CACHE_KEY: 'weather_cache_v2',
  CACHE_TTL: 10 * 60 * 1000,      // 10分钟
  DEFAULT_CITY: 'Nanjing',
  DEFAULT_CITY_NAME: '南京',
  API_TIMEOUT: 5000,
  GEO_TIMEOUT: 8000,
  GEO_MAX_AGE: 5 * 60 * 1000       // 5分钟
};

6.3.2 安全的数据访问

/**
 * 安全地获取嵌套对象属性
 */
function safeGet(obj, path, defaultValue = null) {
  try {
    const keys = path.split('.');
    let result = obj;
    for (const key of keys) {
      if (result == null || typeof result !== 'object') return defaultValue;
      result = result[key];
    }
    return result !== undefined ? result : defaultValue;
  } catch (e) {
    return defaultValue;
  }
}

6.3.3 清晰的数据提取

/**
 * 从API响应中提取城市名称
 */
function extractCityName(apiData) {
  // 尝试从 nearest_area 获取
  const areaName = safeGet(apiData, 'nearest_area.0.areaName.0.value');
  if (areaName) return areaName;
  
  // 尝试从其他字段获取
  const region = safeGet(apiData, 'nearest_area.0.region.0.value');
  if (region) return region;
  
  return null;
}

/**
 * 从API响应中提取天气数据
 */
function extractWeatherData(apiData) {
  return {
    temp: safeGet(apiData, 'current_condition.0.temp_C', '--'),
    desc: safeGet(apiData, 'current_condition.0.weatherDesc.0.value', '未知'),
    city: extractCityName(apiData)
  };
}

6.3.4 模块化的缓存管理

const Cache = {
  get() {
    try {
      const cached = localStorage.getItem(CONFIG.CACHE_KEY);
      if (!cached) return null;
      
      const data = JSON.parse(cached);
      const isExpired = Date.now() - (data.timestamp || 0) > CONFIG.CACHE_TTL;
      
      if (isExpired) {
        this.clear();
        return null;
      }
      
      return data;
    } catch (e) {
      return null;
    }
  },
  
  set(weatherData, cityName) {
    try {
      const data = {
        weather: weatherData,
        city: cityName,
        timestamp: Date.now()
      };
      localStorage.setItem(CONFIG.CACHE_KEY, JSON.stringify(data));
    } catch (e) {
      // 忽略存储错误
    }
  },
  
  clear() {
    try {
      localStorage.removeItem(CONFIG.CACHE_KEY);
    } catch (e) {}
  }
};

6.3.5 Promise 化的 Geolocation

const API = {
  /**
   * 获取用户地理位置
   */
  getCurrentPosition() {
    return new Promise((resolve, reject) => {
      if (!navigator.geolocation) {
        reject(new Error('Geolocation not supported'));
        return;
      }
      
      navigator.geolocation.getCurrentPosition(
        (position) => {
          resolve({
            lat: position.coords.latitude.toFixed(2),
            lon: position.coords.longitude.toFixed(2)
          });
        },
        (error) => {
          reject(error);
        },
        {
          enableHighAccuracy: false,
          timeout: CONFIG.GEO_TIMEOUT,
          maximumAge: CONFIG.GEO_MAX_AGE
        }
      );
    });
  }
};

6.3.6 清晰的初始化流程

const WeatherApp = {
  /**
   * 初始化
   */
  init() {
    if (state.isInitialized) return;
    
    state.widget = document.getElementById('weather-widget');
    if (!state.widget) return;
    
    state.isInitialized = true;
    
    // 步骤1:尝试从缓存加载
    this.loadFromCache();
    
    // 步骤2:加载默认城市天气(南京)
    this.loadDefaultWeather();
    
    // 步骤3:异步尝试定位用户位置
    this.tryGeolocation();
  },
  
  /**
   * 从缓存加载
   */
  loadFromCache() {
    const cached = Cache.get();
    if (cached && cached.weather) {
      UI.render(cached.weather, cached.city);
      return true;
    }
    return false;
  },
  
  /**
   * 加载默认城市天气
   */
  async loadDefaultWeather() {
    try {
      // 检查缓存是否已包含默认城市
      const cached = Cache.get();
      if (cached && cached.city === CONFIG.DEFAULT_CITY_NAME) {
        return; // 缓存已是最新,无需重复加载
      }
      
      const weather = await API.getWeather(CONFIG.DEFAULT_CITY);
      UI.render(weather, weather.city || CONFIG.DEFAULT_CITY_NAME);
      Cache.set(weather, weather.city || CONFIG.DEFAULT_CITY_NAME);
    } catch (error) {
      console.warn('Failed to load default weather:', error);
      // 如果缓存也没有,显示错误状态
      if (!Cache.get()) {
        UI.renderError(CONFIG.DEFAULT_CITY_NAME);
      }
    }
  },
  
  /**
   * 尝试获取用户地理位置并加载天气
   */
  async tryGeolocation() {
    if (state.isGeoLocating) return;
    state.isGeoLocating = true;
    
    try {
      const position = await API.getCurrentPosition();
      const locationStr = `${position.lat},${position.lon}`;
      
      // 获取定位城市的天气
      const weather = await API.getWeather(locationStr);
      
      // 使用API返回的城市名称,或回退到坐标
      const cityName = weather.city || `${position.lat},${position.lon}`;
      
      // 更新UI和缓存
      UI.render(weather, cityName);
      Cache.set(weather, cityName);
      
      state.currentLocation = cityName;
      
    } catch (error) {
      // 定位失败静默处理,保持默认城市天气
      console.log('Geolocation failed or denied, using default city');
    } finally {
      state.isGeoLocating = false;
    }
  }
};

6.4 完整代码

/**
 * 天气组件 - 完全重写版
 * 
 * 设计原则:
 * 1. 状态驱动:所有UI更新都基于状态变化
 * 2. 错误隔离:每个操作都有独立的错误处理
 * 3. 缓存优先:有缓存时立即显示,后台静默更新
 * 4. 定位增强:定位成功后显示实际城市名称
 */
(function() {
  'use strict';
  
  // ============ 配置常量 ============
  const CONFIG = {
    CACHE_KEY: 'weather_cache_v2',
    CACHE_TTL: 10 * 60 * 1000, // 10分钟
    DEFAULT_CITY: 'Nanjing',
    DEFAULT_CITY_NAME: '南京',
    API_TIMEOUT: 5000,
    GEO_TIMEOUT: 8000,
    GEO_MAX_AGE: 5 * 60 * 1000 // 5分钟
  };
  
  // ============ 天气图标映射 ============
  const WEATHER_ICONS = {
    'sunny': '☀️', 'clear': '🌙', 'partly cloudy': '⛅',
    'cloudy': '☁️', 'overcast': '☁️', 'mist': '🌫️',
    'fog': '🌫️', 'rain': '🌧️', 'light rain': '🌦️',
    'heavy rain': '⛈️', 'snow': '❄️', 'thunder': '⛈️',
    'patchy': '🌤️', 'drizzle': '🌦️', 'sleet': '🌨️',
    'blizzard': '❄️', 'ice': '🧊', 'hail': '🌨️',
    'thundery': '⛈️', 'outbreaks': '🌦️', 'possible': '🌤️',
    'moderate': '🌧️', 'heavy': '⛈️', 'light': '🌦️'
  };
  
  // ============ 状态管理 ============
  const state = {
    widget: null,
    isInitialized: false,
    currentLocation: null,
    isGeoLocating: false
  };
  
  // ============ 工具函数 ============
  
  /**
   * 获取天气图标
   */
  function getWeatherIcon(description) {
    if (!description) return '🌤️';
    const lowerDesc = description.toLowerCase();
    
    for (const [key, icon] of Object.entries(WEATHER_ICONS)) {
      if (lowerDesc.includes(key)) return icon;
    }
    return '🌤️';
  }
  
  /**
   * 安全地获取嵌套对象属性
   */
  function safeGet(obj, path, defaultValue = null) {
    try {
      const keys = path.split('.');
      let result = obj;
      for (const key of keys) {
        if (result == null || typeof result !== 'object') return defaultValue;
        result = result[key];
      }
      return result !== undefined ? result : defaultValue;
    } catch (e) {
      return defaultValue;
    }
  }
  
  /**
   * 从API响应中提取城市名称
   */
  function extractCityName(apiData) {
    // 尝试从 nearest_area 获取
    const areaName = safeGet(apiData, 'nearest_area.0.areaName.0.value');
    if (areaName) return areaName;
    
    // 尝试从其他字段获取
    const region = safeGet(apiData, 'nearest_area.0.region.0.value');
    if (region) return region;
    
    return null;
  }
  
  /**
   * 从API响应中提取天气数据
   */
  function extractWeatherData(apiData) {
    return {
      temp: safeGet(apiData, 'current_condition.0.temp_C', '--'),
      desc: safeGet(apiData, 'current_condition.0.weatherDesc.0.value', '未知'),
      city: extractCityName(apiData)
    };
  }
  
  // ============ 缓存管理 ============
  
  const Cache = {
    get() {
      try {
        const cached = localStorage.getItem(CONFIG.CACHE_KEY);
        if (!cached) return null;
        
        const data = JSON.parse(cached);
        const isExpired = Date.now() - (data.timestamp || 0) > CONFIG.CACHE_TTL;
        
        if (isExpired) {
          this.clear();
          return null;
        }
        
        return data;
      } catch (e) {
        return null;
      }
    },
    
    set(weatherData, cityName) {
      try {
        const data = {
          weather: weatherData,
          city: cityName,
          timestamp: Date.now()
        };
        localStorage.setItem(CONFIG.CACHE_KEY, JSON.stringify(data));
      } catch (e) {
        // 忽略存储错误
      }
    },
    
    clear() {
      try {
        localStorage.removeItem(CONFIG.CACHE_KEY);
      } catch (e) {}
    }
  };
  
  // ============ UI 渲染 ============
  
  const UI = {
    /**
     * 渲染天气组件
     */
    render(weatherData, cityName) {
      if (!state.widget) return;
      
      const icon = getWeatherIcon(weatherData.desc);
      const displayCity = cityName || weatherData.city || CONFIG.DEFAULT_CITY_NAME;
      
      state.widget.innerHTML = `
        <span class="weather-icon">${icon}</span>
        <div class="weather-info">
          <span class="weather-temp">${weatherData.temp}°C</span>
          <span class="weather-desc">${weatherData.desc}</span>
        </div>
        <span class="weather-location">📍 ${displayCity}</span>
      `;
    },
    
    /**
     * 渲染加载状态
     */
    renderLoading(cityName) {
      if (!state.widget) return;
      state.widget.innerHTML = `<span>🌤️ ${cityName || '加载中...'}</span>`;
    },
    
    /**
     * 渲染错误状态
     */
    renderError(cityName) {
      if (!state.widget) return;
      state.widget.innerHTML = `<span>🌤️ ${cityName || CONFIG.DEFAULT_CITY_NAME}</span>`;
    }
  };
  
  // ============ API 请求 ============
  
  const API = {
    /**
     * 带超时的 fetch 请求
     */
    async fetchWithTimeout(url, timeout = CONFIG.API_TIMEOUT) {
      const controller = new AbortController();
      const timeoutId = setTimeout(() => controller.abort(), timeout);
      
      try {
        const response = await fetch(url, { signal: controller.signal });
        clearTimeout(timeoutId);
        
        if (!response.ok) {
          throw new Error(`HTTP ${response.status}`);
        }
        
        return await response.json();
      } catch (error) {
        clearTimeout(timeoutId);
        throw error;
      }
    },
    
    /**
     * 获取指定位置的天气
     */
    async getWeather(location) {
      const url = `https://wttr.in/${encodeURIComponent(location)}?format=j1`;
      const data = await this.fetchWithTimeout(url);
      return extractWeatherData(data);
    },
    
    /**
     * 获取用户地理位置
     */
    getCurrentPosition() {
      return new Promise((resolve, reject) => {
        if (!navigator.geolocation) {
          reject(new Error('Geolocation not supported'));
          return;
        }
        
        navigator.geolocation.getCurrentPosition(
          (position) => {
            resolve({
              lat: position.coords.latitude.toFixed(2),
              lon: position.coords.longitude.toFixed(2)
            });
          },
          (error) => {
            reject(error);
          },
          {
            enableHighAccuracy: false,
            timeout: CONFIG.GEO_TIMEOUT,
            maximumAge: CONFIG.GEO_MAX_AGE
          }
        );
      });
    }
  };
  
  // ============ 核心逻辑 ============
  
  const WeatherApp = {
    /**
     * 初始化
     */
    init() {
      if (state.isInitialized) return;
      
      state.widget = document.getElementById('weather-widget');
      if (!state.widget) return;
      
      state.isInitialized = true;
      
      // 步骤1:尝试从缓存加载
      this.loadFromCache();
      
      // 步骤2:加载默认城市天气(南京)
      this.loadDefaultWeather();
      
      // 步骤3:异步尝试定位用户位置
      this.tryGeolocation();
    },
    
    /**
     * 从缓存加载
     */
    loadFromCache() {
      const cached = Cache.get();
      if (cached && cached.weather) {
        UI.render(cached.weather, cached.city);
        return true;
      }
      return false;
    },
    
    /**
     * 加载默认城市天气
     */
    async loadDefaultWeather() {
      try {
        // 检查缓存是否已包含默认城市
        const cached = Cache.get();
        if (cached && cached.city === CONFIG.DEFAULT_CITY_NAME) {
          return; // 缓存已是最新,无需重复加载
        }
        
        const weather = await API.getWeather(CONFIG.DEFAULT_CITY);
        UI.render(weather, weather.city || CONFIG.DEFAULT_CITY_NAME);
        Cache.set(weather, weather.city || CONFIG.DEFAULT_CITY_NAME);
      } catch (error) {
        console.warn('Failed to load default weather:', error);
        // 如果缓存也没有,显示错误状态
        if (!Cache.get()) {
          UI.renderError(CONFIG.DEFAULT_CITY_NAME);
        }
      }
    },
    
    /**
     * 尝试获取用户地理位置并加载天气
     */
    async tryGeolocation() {
      if (state.isGeoLocating) return;
      state.isGeoLocating = true;
      
      try {
        const position = await API.getCurrentPosition();
        const locationStr = `${position.lat},${position.lon}`;
        
        // 获取定位城市的天气
        const weather = await API.getWeather(locationStr);
        
        // 使用API返回的城市名称,或回退到坐标
        const cityName = weather.city || `${position.lat},${position.lon}`;
        
        // 更新UI和缓存
        UI.render(weather, cityName);
        Cache.set(weather, cityName);
        
        state.currentLocation = cityName;
        
      } catch (error) {
        // 定位失败静默处理,保持默认城市天气
        console.log('Geolocation failed or denied, using default city');
      } finally {
        state.isGeoLocating = false;
      }
    }
  };
  
  // ============ 启动 ============
  
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', () => WeatherApp.init());
  } else {
    WeatherApp.init();
  }
  
})();

6.5 架构对比

特性旧版本重写版
代码组织混杂在一起模块化分离
状态管理集中式状态对象
配置管理硬编码集中配置对象
错误处理分散且不一致统一且完善
数据访问直接访问,易出错safeGet 安全访问
缓存结构简单存储结构化存储
Geolocation回调式Promise 化
可维护性

6.6 关键改进点

  1. 模块化设计:CONFIG、State、Cache、Utils、API、UI、WeatherApp 各司其职
  2. 安全的数据访问safeGet 函数避免深层属性访问错误
  3. 清晰的错误边界:每个模块独立处理错误,不影响其他模块
  4. Promise 化:Geolocation API 包装为 Promise,支持 async/await
  5. 状态驱动:所有 UI 更新都基于状态变化,避免直接操作 DOM

七、总结

优化演进

第一次优化:解决基本性能问题
    ↓
第二次优化:完善缓存和错误处理
    ↓
第三次优化:完全重写,架构升级

核心经验

  1. 渐进优化:先解决主要问题,再逐步完善
  2. 架构重构:当补丁太多时,考虑重写
  3. 模块化:分离关注点,提高可维护性
  4. 防御式编程:考虑各种边界情况和错误场景

参考资源