一、问题描述
博客首页的天气组件加载速度很慢,用户打开页面后需要等待较长时间才能看到天气信息。
问题分析
原实现流程:
页面加载 → 等待定位 → 获取位置 → 请求天气 → 显示天气
问题原因:
- 定位耗时:浏览器 Geolocation API 获取用户位置需要时间
- 用户授权:用户可能需要确认定位权限
- 网络请求:定位成功后还要发起天气 API 请求
- 失败处理:用户拒绝定位时,天气组件无内容显示
二、解决方案
核心思路
将"先定位后显示"改为"先显示默认天气,再异步更新":
改进前:页面加载 → 定位 → 天气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 重写原因
经过多次迭代优化后,代码仍然存在以下问题:
- 逻辑混乱:多次修改导致代码结构不清晰
- 状态管理混乱:没有统一的状态管理机制
- 错误处理不完善:各种边界情况考虑不周
- 代码耦合度高:各功能模块之间耦合严重
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 关键改进点
- 模块化设计:CONFIG、State、Cache、Utils、API、UI、WeatherApp 各司其职
- 安全的数据访问:
safeGet函数避免深层属性访问错误 - 清晰的错误边界:每个模块独立处理错误,不影响其他模块
- Promise 化:Geolocation API 包装为 Promise,支持 async/await
- 状态驱动:所有 UI 更新都基于状态变化,避免直接操作 DOM
七、总结
优化演进
第一次优化:解决基本性能问题
↓
第二次优化:完善缓存和错误处理
↓
第三次优化:完全重写,架构升级
核心经验
- 渐进优化:先解决主要问题,再逐步完善
- 架构重构:当补丁太多时,考虑重写
- 模块化:分离关注点,提高可维护性
- 防御式编程:考虑各种边界情况和错误场景